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/); +});