feat: eigene Sammlung — „hab ich" mit Quelle
CI / pruefen (push) Canceled after 0s
Release / veroeffentlichen (push) Canceled after 0s

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.
This commit is contained in:
Florian Wilde
2026-09-01 21:15:53 +02:00
parent cd9953c47c
commit 9ee8664233
26 changed files with 467 additions and 52 deletions
+2
View File
@@ -114,6 +114,8 @@ const detailsSchema = z.object({
notes: z.string().max(5000).nullable().optional(),
whereToWatch: z.string().max(120).nullable().optional(),
favorite: z.boolean().optional(),
owned: z.boolean().optional(),
ownedSource: z.string().max(80).nullable().optional(),
});
export async function updateEntryAction(entryId: string, data: unknown) {
@@ -36,6 +36,8 @@ export const GET = withApi(async (_request, user, context) => {
rating: entry.rating,
notes: entry.notes,
whereToWatch: entry.whereToWatch,
owned: entry.owned,
ownedSource: entry.ownedSource,
favorite: entry.favorite,
currentRun: entry.currentRun,
tags: entry.tags.map((link) => link.tag.name),
@@ -58,6 +60,8 @@ const patchSchema = z.object({
notes: z.string().max(5000).nullable().optional(),
whereToWatch: z.string().max(120).nullable().optional(),
favorite: z.boolean().optional(),
owned: z.boolean().optional(),
ownedSource: z.string().max(80).nullable().optional(),
});
/** PATCH /api/v1/bibliothek/{id} — Status und Angaben ändern. */
+1
View File
@@ -22,6 +22,7 @@ export const GET = withApi(async (request, user) => {
genre: url.searchParams.get("genre") ?? undefined,
tag: url.searchParams.get("tag") ?? undefined,
provider: url.searchParams.get("dienst") ?? undefined,
owned: url.searchParams.get("sammlung") === "1" ? true : undefined,
});
return {
+15
View File
@@ -73,6 +73,12 @@ export function GET() {
{ name: "genre", in: "query", schema: { type: "string" } },
{ name: "tag", in: "query", schema: { type: "string" } },
{ name: "dienst", in: "query", schema: { type: "string" } },
{
name: "sammlung",
in: "query",
schema: { type: "string", enum: ["1"] },
description: "Nur Titel, die in der eigenen Sammlung liegen.",
},
{ name: "limit", in: "query", schema: { type: "integer", default: 50, maximum: 200 } },
{ name: "offset", in: "query", schema: { type: "integer", default: 0 } },
],
@@ -123,6 +129,15 @@ export function GET() {
notes: { type: "string", nullable: true },
whereToWatch: { type: "string", nullable: true },
favorite: { type: "boolean" },
owned: {
type: "boolean",
description: "Liegt der Titel in der eigenen Sammlung?",
},
ownedSource: {
type: "string",
nullable: true,
description: "Wo genau — Jellyfin, Plex, Blu-ray …",
},
},
},
},
+1
View File
@@ -29,6 +29,7 @@ export default async function LibraryPage({
genre: params.genre || undefined,
tag: params.tag || undefined,
provider: params.dienst || undefined,
owned: params.sammlung === "1" ? true : undefined,
sort: SORTS.includes(params.sortierung ?? "") ? (params.sortierung as LibrarySort) : undefined,
};
+45
View File
@@ -120,6 +120,19 @@ export default async function TitlePage({ params }: { params: Params }) {
{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"
@@ -225,6 +238,7 @@ export default async function TitlePage({ params }: { params: Params }) {
<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>
)}
@@ -245,6 +259,8 @@ export default async function TitlePage({ params }: { params: Params }) {
whereToWatch={entry.whereToWatch}
tags={entry.tags.map((link) => link.tag.name)}
suggestion={suggestion}
owned={entry.owned}
ownedSource={entry.ownedSource}
/>
</div>
@@ -253,6 +269,7 @@ export default async function TitlePage({ params }: { params: Params }) {
<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>
)}
@@ -299,6 +316,34 @@ export default async function TitlePage({ params }: { params: Params }) {
);
}
/**
* 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>