Streaming-Verfügbarkeit beantwortet nur die halbe Frage: manche Filme laufen nirgends mehr, die hat man gekauft und liegen zu Hause — in Jellyfin, Plex, Emby, auf dem NAS oder als Scheibe. Bewusst getrennt vom Feld „wo schaue ich das": das eine sagt, wo ein Titel gerade läuft, und das ändert sich ständig; das andere sagt, was einem gehört, und das bleibt. Auf der Detailseite steht die Sammlung deshalb vor der Verfügbarkeit — was man hat, ist die verlässlichere Antwort. Durchgezogen bis in Filter, Export, CSV-Import und REST-API.
355 lines
14 KiB
TypeScript
355 lines
14 KiB
TypeScript
import Link from "next/link";
|
|
import { notFound } from "next/navigation";
|
|
import type { Metadata } from "next";
|
|
import { requireUser } from "@/lib/auth";
|
|
import { getSettings } from "@/lib/settings";
|
|
import { prisma } from "@/lib/db";
|
|
import { ensureTitle } from "@/lib/catalog";
|
|
import { getNextEpisode, getProgress, getWatchedEpisodeIds } from "@/lib/library";
|
|
import { formatMinutes, STATUS_COLOR, STATUS_LABEL, type StatusKey } from "@/lib/labels";
|
|
import { AppShell } from "@/components/AppShell";
|
|
import { Backdrop, Poster } from "@/components/Poster";
|
|
import { ProgressBar } from "@/components/ProgressBar";
|
|
import {
|
|
ProviderList,
|
|
type ProviderBadgeData,
|
|
type ProviderKindKey,
|
|
} from "@/components/ProviderBadges";
|
|
import { SeasonList, type SeasonData } from "@/components/library/SeasonList";
|
|
import {
|
|
AddToLibrary,
|
|
EntryActions,
|
|
RatingPicker,
|
|
StatusPicker,
|
|
} from "@/components/library/EntryControls";
|
|
import { EntryNotes } from "@/components/library/EntryNotes";
|
|
import { NextEpisodeCard, WatchMovieButton } from "@/components/library/NextEpisode";
|
|
|
|
type Params = Promise<{ id: string }>;
|
|
|
|
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
|
|
const { id } = await params;
|
|
const title = await prisma.title.findUnique({ where: { id }, select: { title: true } });
|
|
return { title: title?.title ?? "Titel" };
|
|
}
|
|
|
|
export default async function TitlePage({ params }: { params: Params }) {
|
|
const user = await requireUser();
|
|
const settings = await getSettings();
|
|
const { id } = await params;
|
|
|
|
const title = await prisma.title.findUnique({
|
|
where: { id },
|
|
include: {
|
|
seasons: {
|
|
orderBy: { seasonNumber: "asc" },
|
|
include: { episodes: { orderBy: { episodeNumber: "asc" } } },
|
|
},
|
|
providers: { where: { region: user.region }, include: { provider: true } },
|
|
entries: {
|
|
where: { userId: user.id },
|
|
include: { tags: { include: { tag: true } } },
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!title) notFound();
|
|
|
|
// Im Hintergrund auffrischen, falls der Eintrag alt ist. Schlägt das fehl,
|
|
// bleibt der lokale Stand stehen — die Seite funktioniert trotzdem.
|
|
if (title.tmdbId) {
|
|
await ensureTitle(title.type, title.tmdbId, { region: user.region }).catch(() => undefined);
|
|
}
|
|
|
|
const entry = title.entries[0] ?? null;
|
|
const isSeries = title.type === "SERIES";
|
|
|
|
const [progress, next, watchedIds] = entry
|
|
? await Promise.all([
|
|
getProgress(user.id, entry.id),
|
|
isSeries ? getNextEpisode(user.id, entry.id) : Promise.resolve(null),
|
|
isSeries ? getWatchedEpisodeIds(user.id, entry.id) : Promise.resolve(new Set<string>()),
|
|
])
|
|
: [null, null, new Set<string>()];
|
|
|
|
const providerGroups: Partial<Record<ProviderKindKey, ProviderBadgeData[]>> = {};
|
|
for (const row of title.providers) {
|
|
(providerGroups[row.kind as ProviderKindKey] ??= []).push({
|
|
id: row.provider.id,
|
|
name: row.provider.name,
|
|
logoPath: row.provider.logoPath,
|
|
color: row.provider.color,
|
|
});
|
|
}
|
|
const suggestion = providerGroups.FLATRATE?.[0]?.name ?? providerGroups.FREE?.[0]?.name ?? null;
|
|
|
|
const seasons: SeasonData[] = title.seasons.map((season) => ({
|
|
id: season.id,
|
|
seasonNumber: season.seasonNumber,
|
|
name: season.name,
|
|
overview: season.overview,
|
|
airDate: season.airDate?.toISOString() ?? null,
|
|
episodes: season.episodes.map((episode) => ({
|
|
id: episode.id,
|
|
episodeNumber: episode.episodeNumber,
|
|
name: episode.name,
|
|
overview: episode.overview,
|
|
airDate: episode.airDate?.toISOString() ?? null,
|
|
runtime: episode.runtime,
|
|
})),
|
|
}));
|
|
|
|
const year = title.releaseDate?.getFullYear() ?? null;
|
|
const movieWatched = !isSeries && (progress?.watched ?? 0) > 0;
|
|
|
|
return (
|
|
<AppShell user={user} instanceName={settings.instanceName}>
|
|
<article>
|
|
<div className="fl-card relative overflow-hidden">
|
|
<Backdrop path={title.backdropPath} alt="" />
|
|
|
|
<div className="relative flex flex-col gap-6 p-5 sm:flex-row sm:p-7">
|
|
<div className="w-36 shrink-0 sm:w-48">
|
|
<Poster path={title.posterPath} alt={title.title} size="w500" priority />
|
|
</div>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
|
{isSeries ? "Serie" : "Film"}
|
|
{year ? ` · ${year}` : ""}
|
|
{title.status ? ` · ${title.status}` : ""}
|
|
</p>
|
|
{entry?.owned && (
|
|
<span
|
|
className="rounded-full px-2.5 py-0.5 text-xs font-medium text-[var(--fl-on-accent)]"
|
|
style={{ backgroundImage: "var(--fl-gradient)" }}
|
|
title={
|
|
entry.ownedSource
|
|
? `In deiner Sammlung: ${entry.ownedSource}`
|
|
: "In deiner Sammlung"
|
|
}
|
|
>
|
|
Hab ich{entry.ownedSource ? ` · ${entry.ownedSource}` : ""}
|
|
</span>
|
|
)}
|
|
{entry && (
|
|
<span
|
|
className="rounded-full px-2.5 py-0.5 text-xs font-medium"
|
|
style={{
|
|
color: STATUS_COLOR[entry.status as StatusKey],
|
|
backgroundColor: `color-mix(in oklab, ${STATUS_COLOR[entry.status as StatusKey]} 16%, transparent)`,
|
|
}}
|
|
>
|
|
{STATUS_LABEL[entry.status as StatusKey]}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">
|
|
{title.title}
|
|
</h1>
|
|
{title.originalTitle && title.originalTitle !== title.title && (
|
|
<p className="mt-0.5 text-sm text-[var(--fl-faint)]">{title.originalTitle}</p>
|
|
)}
|
|
{title.tagline && (
|
|
<p className="mt-2 text-sm italic text-[var(--fl-muted)]">„{title.tagline}"</p>
|
|
)}
|
|
|
|
<dl className="mt-4 flex flex-wrap gap-x-6 gap-y-2 text-sm">
|
|
{isSeries && (
|
|
<Fact label="Umfang">
|
|
{title.seasonCount} {title.seasonCount === 1 ? "Staffel" : "Staffeln"} ·{" "}
|
|
{title.episodeCount} Episoden
|
|
</Fact>
|
|
)}
|
|
{title.runtime ? (
|
|
<Fact label={isSeries ? "Pro Episode" : "Laufzeit"}>{title.runtime} min</Fact>
|
|
) : null}
|
|
{title.voteAverage ? <Fact label="TMDB">{title.voteAverage.toFixed(1)} / 10</Fact> : null}
|
|
{entry && progress ? (
|
|
<Fact label="Deine Zeit">{formatMinutes(progress.minutesWatched)}</Fact>
|
|
) : null}
|
|
</dl>
|
|
|
|
{title.genres.length > 0 && (
|
|
<ul className="mt-4 flex flex-wrap gap-1.5">
|
|
{title.genres.map((genre) => (
|
|
<li
|
|
key={genre}
|
|
className="rounded-full border border-[var(--fl-border)] px-2.5 py-0.5 text-xs text-[var(--fl-muted)]"
|
|
>
|
|
{genre}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{title.overview && (
|
|
<p className="mt-4 max-w-2xl text-sm leading-relaxed text-[var(--fl-muted)]">
|
|
{title.overview}
|
|
</p>
|
|
)}
|
|
|
|
<div className="mt-6">
|
|
{entry ? (
|
|
<div className="space-y-4">
|
|
{progress && (
|
|
<div className="max-w-md">
|
|
<ProgressBar
|
|
percent={progress.percent}
|
|
label={
|
|
isSeries
|
|
? `${progress.watched} von ${progress.total} Episoden gesehen — noch ${formatMinutes(progress.minutesLeft)}`
|
|
: movieWatched
|
|
? "Gesehen"
|
|
: `Noch ${formatMinutes(progress.minutesLeft)}`
|
|
}
|
|
showValue
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{isSeries && next && <NextEpisodeCard entryId={entry.id} next={{ ...next, airDate: next.airDate?.toISOString() ?? null }} />}
|
|
{isSeries && !next && progress?.total ? (
|
|
<p className="rounded-xl border border-[color-mix(in_oklab,var(--fl-success)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-success)_10%,transparent)] px-3 py-2 text-sm text-[var(--fl-success)]">
|
|
Durchgeschaut — alle {progress.total} Episoden abgehakt.
|
|
</p>
|
|
) : null}
|
|
{!isSeries && <WatchMovieButton entryId={entry.id} watched={movieWatched} />}
|
|
|
|
<StatusPicker entryId={entry.id} status={entry.status as StatusKey} />
|
|
</div>
|
|
) : (
|
|
<AddToLibrary titleId={title.id} isSeries={isSeries} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{entry && (
|
|
<div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
|
|
<section className="order-2 lg:order-1">
|
|
<h2 className="mb-3 text-lg font-semibold">
|
|
{isSeries ? "Staffeln" : "Wo läuft das?"}
|
|
</h2>
|
|
{isSeries ? (
|
|
<SeasonList seasons={seasons} entryId={entry.id} watchedIds={[...watchedIds]} />
|
|
) : (
|
|
<div className="fl-card p-4">
|
|
<OwnedNotice owned={entry.owned} source={entry.ownedSource} />
|
|
<ProviderList groups={providerGroups} region={user.region} />
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<aside className="order-1 space-y-6 lg:order-2">
|
|
<div className="fl-card p-4">
|
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
|
Deine Bewertung
|
|
</h2>
|
|
<RatingPicker entryId={entry.id} rating={entry.rating} />
|
|
</div>
|
|
|
|
<div className="fl-card p-4">
|
|
<EntryNotes
|
|
entryId={entry.id}
|
|
notes={entry.notes}
|
|
whereToWatch={entry.whereToWatch}
|
|
tags={entry.tags.map((link) => link.tag.name)}
|
|
suggestion={suggestion}
|
|
owned={entry.owned}
|
|
ownedSource={entry.ownedSource}
|
|
/>
|
|
</div>
|
|
|
|
{isSeries && (
|
|
<div className="fl-card p-4">
|
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
|
Wo läuft das?
|
|
</h2>
|
|
<OwnedNotice owned={entry.owned} source={entry.ownedSource} />
|
|
<ProviderList groups={providerGroups} region={user.region} />
|
|
</div>
|
|
)}
|
|
|
|
<div className="fl-card p-4">
|
|
<EntryActions
|
|
entryId={entry.id}
|
|
currentRun={entry.currentRun}
|
|
canRewatch={entry.status === "COMPLETED"}
|
|
/>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
)}
|
|
|
|
{!entry && (
|
|
<>
|
|
<section className="mt-6">
|
|
<h2 className="mb-3 text-lg font-semibold">Wo läuft das?</h2>
|
|
<div className="fl-card p-4">
|
|
<ProviderList groups={providerGroups} region={user.region} />
|
|
<p className="mt-3 text-xs text-[var(--fl-faint)]">
|
|
Verfügbarkeitsdaten von JustWatch über TMDB, Region {user.region}.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
{isSeries && (
|
|
<section className="mt-6">
|
|
<h2 className="mb-3 text-lg font-semibold">Staffeln</h2>
|
|
<SeasonList seasons={seasons} />
|
|
</section>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<p className="mt-8 text-sm">
|
|
<Link href="/bibliothek" className="text-[var(--fl-faint)] hover:text-[var(--fl-muted)]">
|
|
← Zur Bibliothek
|
|
</Link>
|
|
</p>
|
|
</article>
|
|
</AppShell>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Hinweis auf die eigene Sammlung — steht bewusst **vor** der
|
|
* Streaming-Verfügbarkeit: was man hat, ist die verlässlichere Antwort auf
|
|
* „wo kann ich das schauen".
|
|
*/
|
|
function OwnedNotice({ owned, source }: { owned: boolean; source: string | null }) {
|
|
if (!owned) return null;
|
|
|
|
return (
|
|
<p
|
|
className="mb-3 flex items-center gap-2 rounded-xl border px-3 py-2 text-sm"
|
|
style={{
|
|
borderColor: "color-mix(in oklab, var(--fl-success) 45%, transparent)",
|
|
backgroundColor: "color-mix(in oklab, var(--fl-success) 10%, transparent)",
|
|
color: "var(--fl-success)",
|
|
}}
|
|
>
|
|
<svg viewBox="0 0 24 24" className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden>
|
|
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
<span>
|
|
In deiner Sammlung
|
|
{source ? <span className="font-medium"> · {source}</span> : null}
|
|
</span>
|
|
</p>
|
|
);
|
|
}
|
|
|
|
function Fact({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<div>
|
|
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
|
|
<dd className="mt-0.5 font-medium">{children}</dd>
|
|
</div>
|
|
);
|
|
}
|