From 40fb49fb212d6fdde74bf995996b1d0e0eec128c Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Wed, 2 Sep 2026 00:04:27 +0200 Subject: [PATCH] Filmsichtung laesst sich wieder zuruecknehmen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit War ein Film einmal als gesehen markiert, gab es keinen Weg zurueck: im Zustand "gesehen" stand statt eines Knopfes ein

— ein Etikett ohne Klickziel. Der Statuswechsel konnte das nicht heilen, weil "gesehen" aus den Sichtungen abgeleitet wird und setStatus() die absichtlich nicht anfasst. Und eine Gegenfunktion fuer Filme gab es nicht: unmarkEpisodes() und unmarkSeason() waren da, ein unmarkMovie() nie geschrieben. unmarkMovie() ist auf run: entry.currentRun und episodeId: null eingegrenzt. Wer einen Film dreimal gesehen hat, hat drei Sichtungen — ohne die Eingrenzung loeschte das Zuruecknehmen alle drei und verfaelschte rueckwirkend die Statistik. Die gruene Plakette bleibt stehen, ist jetzt aber klickbar, so wie sich Episodenhaekchen auch abwaehlen lassen. Ueber die Schnittstelle geht es mit DELETE {"action":"movie"} als Gegenstueck zum gleichnamigen POST, ohne dass man die watchIds kennen muss. Drei Tests: Zuruecknehmen samt erneutem Abhaken, die Eingrenzung auf den laufenden Durchlauf, und dass Serien abgewiesen werden. Klickweg und Endpunkt gegen einen Produktionsbau geprueft. Der im Issue vermutete Nebenpunkt trifft nicht zu: report() blendet das Zuruecknehmen nur bei ids.length > 0 ein, ein wirkungsloses Angebot gab es also nie. Refs #5 --- docs/API.md | 13 +++++ src/app/actions/library.ts | 8 +++ .../bibliothek/[entryId]/sichtungen/route.ts | 15 +++++- src/app/api/v1/openapi.json/route.ts | 10 ++++ src/components/library/NextEpisode.tsx | 28 ++++++++-- src/lib/library.ts | 21 ++++++++ tests/library.test.ts | 54 +++++++++++++++++++ 7 files changed, 144 insertions(+), 5 deletions(-) diff --git a/docs/API.md b/docs/API.md index 2e3ff6e..3408add 100644 --- a/docs/API.md +++ b/docs/API.md @@ -116,6 +116,19 @@ curl -X DELETE -H "Authorization: Bearer $KEY" -H "content-type: application/jso Alternativ nimmt `DELETE` auch `{"episodeIds":["…"]}` — dann verschwindet die Markierung dieser Episoden im aktuellen Durchlauf. +Für Filme gibt es `{"action":"movie"}` als Gegenstück zum gleichnamigen POST; +dafür braucht man die `watchIds` nicht zu kennen: + +```bash +curl -X DELETE -H "Authorization: Bearer $KEY" -H "content-type: application/json" \ + -d '{"action":"movie"}' \ + https://flimmer.example.com/api/v1/bibliothek/$ENTRY/sichtungen +``` + +Zurückgenommen wird dabei **nur der laufende Durchlauf**. Wer einen Film dreimal +gesehen hat, verliert also nicht die Sichtungen der früheren Durchläufe — die +zählen weiter in die Statistik. + ### Suche ```http diff --git a/src/app/actions/library.ts b/src/app/actions/library.ts index fc824cf..f72a36a 100644 --- a/src/app/actions/library.ts +++ b/src/app/actions/library.ts @@ -17,6 +17,7 @@ import { startRewatch, undoWatches, unmarkEpisodes, + unmarkMovie, unmarkSeason, updateEntryDetails, watchNextEpisode, @@ -175,6 +176,13 @@ export async function markMovieAction(entryId: string) { return run(async (userId) => toResult(await markMovieWatched(userId, cuid.parse(entryId))), PATHS); } +export async function unmarkMovieAction(entryId: string) { + return run(async (userId) => { + await unmarkMovie(userId, cuid.parse(entryId)); + return { message: "Markierung entfernt" }; + }, PATHS); +} + export async function undoAction(entryId: string, watchIds: string[]) { return run(async (userId) => { await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds)); diff --git a/src/app/api/v1/bibliothek/[entryId]/sichtungen/route.ts b/src/app/api/v1/bibliothek/[entryId]/sichtungen/route.ts index b44d86b..d72c270 100644 --- a/src/app/api/v1/bibliothek/[entryId]/sichtungen/route.ts +++ b/src/app/api/v1/bibliothek/[entryId]/sichtungen/route.ts @@ -8,6 +8,7 @@ import { markUpTo, undoWatches, unmarkEpisodes, + unmarkMovie, watchNextEpisode, } from "@/lib/library"; @@ -75,9 +76,17 @@ export const POST = withApi(async (request, user, context) => { const deleteSchema = z.union([ z.object({ watchIds: z.array(z.string().min(1)).min(1).max(500) }), z.object({ episodeIds: z.array(z.string().min(1)).min(1).max(500) }), + z.object({ action: z.literal("movie") }), ]); -/** DELETE — Sichtungen zurücknehmen, über watchIds oder episodeIds. */ +/** + * DELETE — Sichtungen zurücknehmen. + * + * Drei Wege: gezielt über `watchIds` (die Antwort eines POST), über + * `episodeIds`, oder mit `{"action":"movie"}` als Gegenstück zum + * gleichnamigen POST — dafür braucht man die `watchIds` nicht zu kennen. + * Zurückgenommen wird immer nur der laufende Durchlauf. + */ export const DELETE = withApi(async (request, user, context) => { const { entryId } = await context.params; if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400); @@ -86,8 +95,10 @@ export const DELETE = withApi(async (request, user, context) => { if ("watchIds" in body) { await undoWatches(user.id, entryId, body.watchIds); - } else { + } else if ("episodeIds" in body) { await unmarkEpisodes(user.id, entryId, body.episodeIds); + } else { + await unmarkMovie(user.id, entryId); } return { ok: true }; }); diff --git a/src/app/api/v1/openapi.json/route.ts b/src/app/api/v1/openapi.json/route.ts index 39758e2..bd3aeb5 100644 --- a/src/app/api/v1/openapi.json/route.ts +++ b/src/app/api/v1/openapi.json/route.ts @@ -210,6 +210,11 @@ export function GET() { }, delete: { summary: "Sichtungen zurücknehmen", + description: + "Gezielt über watchIds, über episodeIds, oder mit action=movie als Gegenstück " + + "zum gleichnamigen POST — dafür braucht man die watchIds nicht zu kennen. " + + "Zurückgenommen wird immer nur der laufende Durchlauf; frühere Durchläufe " + + "bleiben für die Statistik erhalten.", requestBody: { required: true, content: { @@ -226,6 +231,11 @@ export function GET() { properties: { episodeIds: { type: "array", items: { type: "string" } } }, required: ["episodeIds"], }, + { + type: "object", + properties: { action: { const: "movie" } }, + required: ["action"], + }, ], }, }, diff --git a/src/components/library/NextEpisode.tsx b/src/components/library/NextEpisode.tsx index 803e8ce..5ecf37a 100644 --- a/src/components/library/NextEpisode.tsx +++ b/src/components/library/NextEpisode.tsx @@ -2,7 +2,12 @@ import { useTransition } from "react"; import { useRouter } from "next/navigation"; -import { markMovieAction, undoAction, watchNextAction } from "@/app/actions/library"; +import { + markMovieAction, + undoAction, + unmarkMovieAction, + watchNextAction, +} from "@/app/actions/library"; import { useToasts } from "@/components/Toasts"; import { episodeCode, formatDate, isUpcoming } from "@/lib/labels"; @@ -93,14 +98,31 @@ export function WatchMovieButton({ router.refresh(); } + // Im Zustand „gesehen" bleibt die grüne Plakette stehen, ist aber klickbar — + // so wie sich Episodenhäkchen auch wieder abwählen lassen. Vorher stand hier + // ein

: ein Etikett ohne Klickziel, aus dem es kein Zurück gab. if (watched) { return ( -

+ ); } diff --git a/src/lib/library.ts b/src/lib/library.ts index 8a6b926..b75c850 100644 --- a/src/lib/library.ts +++ b/src/lib/library.ts @@ -338,6 +338,27 @@ export async function markMovieWatched( return { watchIds: [watch.id], status, label: "Film als gesehen markiert" }; } +/** + * Nimmt die Sichtung eines Films im aktuellen Durchlauf zurück. + * + * Das Gegenstück zu `markMovieWatched()` — und das Pendant zu + * `unmarkEpisodes()` bei Serien. + * + * Die Eingrenzung auf `run` ist wesentlich: wer einen Film dreimal gesehen + * hat, hat drei Sichtungen. Ohne `run` löschte das Zurücknehmen alle drei und + * verfälschte rückwirkend die Statistik. Zurückgenommen wird nur, was im + * laufenden Durchlauf abgehakt wurde. + */ +export async function unmarkMovie(userId: string, entryId: string) { + const entry = await requireEntry(userId, entryId); + if (entry.title.type !== "MOVIE") throw new LibraryError("Das ist keine Filmsichtung."); + + await prisma.watch.deleteMany({ + where: { userId, entryId, episodeId: null, run: entry.currentRun }, + }); + return reconcileStatus(userId, entryId); +} + /** Macht eine Sichtungsaktion rückgängig. */ export async function undoWatches(userId: string, entryId: string, watchIds: string[]) { await requireEntry(userId, entryId); diff --git a/tests/library.test.ts b/tests/library.test.ts index c6f4510..915f490 100644 --- a/tests/library.test.ts +++ b/tests/library.test.ts @@ -21,6 +21,7 @@ import { startRewatch, undoWatches, unmarkEpisodes, + unmarkMovie, updateEntryDetails, watchNextEpisode, } from "../src/lib/library"; @@ -388,3 +389,56 @@ test("Sammlung: Filter und Zähler in der Bibliothek", async () => { await prisma.libraryEntry.deleteMany({ where: { id: { in: [movie.id, series.id] } } }); }); + +test("Filme: die Sichtung laesst sich wieder zuruecknehmen", async () => { + const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST"); + + await markMovieWatched(userId, entry.id); + assert.equal((await getProgress(userId, entry.id)).percent, 100); + + await unmarkMovie(userId, entry.id); + + const zurueck = await getProgress(userId, entry.id); + assert.equal(zurueck.percent, 0, "der Fortschritt steht wieder auf null"); + assert.equal(zurueck.minutesWatched, 0); + assert.equal( + await prisma.watch.count({ where: { userId, entryId: entry.id } }), + 0, + "die Sichtung ist weg", + ); + + // Und danach laesst es sich erneut abhaken — kein Einbahnzustand. + const erneut = await markMovieWatched(userId, entry.id); + assert.equal(erneut.watchIds.length, 1); +}); + +test("Filme: das Zuruecknehmen betrifft nur den laufenden Durchlauf", async () => { + const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST"); + + await markMovieWatched(userId, entry.id); + await startRewatch(userId, entry.id); + await markMovieWatched(userId, entry.id); + assert.equal(await prisma.watch.count({ where: { userId, entryId: entry.id } }), 2); + + await unmarkMovie(userId, entry.id); + + const rest = await prisma.watch.findMany({ + where: { userId, entryId: entry.id }, + select: { run: true }, + }); + assert.deepEqual( + rest.map((watch) => watch.run), + [0], + "die Sichtung des ersten Durchlaufs bleibt fuer die Statistik stehen", + ); + assert.equal( + (await getProgress(userId, entry.id)).percent, + 0, + "der laufende Durchlauf steht wieder auf null", + ); +}); + +test("unmarkMovie weist Serien ab", async () => { + const entry = await addToLibrary(userId, seriesTitleId, "WATCHING"); + await assert.rejects(() => unmarkMovie(userId, entry.id), /keine Filmsichtung/); +}); -- 2.54.0