Fix #5: Film: „Gesehen" lässt sich nicht mehr zurücknehmen #9

Merged
florian.wilde merged 1 commits from fix/5-gesehen-zuruecknehmen into main 2026-09-01 22:06:54 +00:00
7 changed files with 144 additions and 5 deletions
+13
View File
@@ -116,6 +116,19 @@ curl -X DELETE -H "Authorization: Bearer $KEY" -H "content-type: application/jso
Alternativ nimmt `DELETE` auch `{"episodeIds":["…"]}` — dann verschwindet die Alternativ nimmt `DELETE` auch `{"episodeIds":["…"]}` — dann verschwindet die
Markierung dieser Episoden im aktuellen Durchlauf. 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 ### Suche
```http ```http
+8
View File
@@ -17,6 +17,7 @@ import {
startRewatch, startRewatch,
undoWatches, undoWatches,
unmarkEpisodes, unmarkEpisodes,
unmarkMovie,
unmarkSeason, unmarkSeason,
updateEntryDetails, updateEntryDetails,
watchNextEpisode, watchNextEpisode,
@@ -175,6 +176,13 @@ export async function markMovieAction(entryId: string) {
return run(async (userId) => toResult(await markMovieWatched(userId, cuid.parse(entryId))), PATHS); 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[]) { export async function undoAction(entryId: string, watchIds: string[]) {
return run(async (userId) => { return run(async (userId) => {
await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds)); await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds));
@@ -8,6 +8,7 @@ import {
markUpTo, markUpTo,
undoWatches, undoWatches,
unmarkEpisodes, unmarkEpisodes,
unmarkMovie,
watchNextEpisode, watchNextEpisode,
} from "@/lib/library"; } from "@/lib/library";
@@ -75,9 +76,17 @@ export const POST = withApi(async (request, user, context) => {
const deleteSchema = z.union([ const deleteSchema = z.union([
z.object({ watchIds: z.array(z.string().min(1)).min(1).max(500) }), 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({ 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) => { export const DELETE = withApi(async (request, user, context) => {
const { entryId } = await context.params; const { entryId } = await context.params;
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400); 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) { if ("watchIds" in body) {
await undoWatches(user.id, entryId, body.watchIds); await undoWatches(user.id, entryId, body.watchIds);
} else { } else if ("episodeIds" in body) {
await unmarkEpisodes(user.id, entryId, body.episodeIds); await unmarkEpisodes(user.id, entryId, body.episodeIds);
} else {
await unmarkMovie(user.id, entryId);
} }
return { ok: true }; return { ok: true };
}); });
+10
View File
@@ -210,6 +210,11 @@ export function GET() {
}, },
delete: { delete: {
summary: "Sichtungen zurücknehmen", 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: { requestBody: {
required: true, required: true,
content: { content: {
@@ -226,6 +231,11 @@ export function GET() {
properties: { episodeIds: { type: "array", items: { type: "string" } } }, properties: { episodeIds: { type: "array", items: { type: "string" } } },
required: ["episodeIds"], required: ["episodeIds"],
}, },
{
type: "object",
properties: { action: { const: "movie" } },
required: ["action"],
},
], ],
}, },
}, },
+25 -3
View File
@@ -2,7 +2,12 @@
import { useTransition } from "react"; import { useTransition } from "react";
import { useRouter } from "next/navigation"; 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 { useToasts } from "@/components/Toasts";
import { episodeCode, formatDate, isUpcoming } from "@/lib/labels"; import { episodeCode, formatDate, isUpcoming } from "@/lib/labels";
@@ -93,14 +98,31 @@ export function WatchMovieButton({
router.refresh(); 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 <p>: ein Etikett ohne Klickziel, aus dem es kein Zurück gab.
if (watched) { if (watched) {
return ( return (
<p className="inline-flex items-center gap-2 rounded-xl border border-[color-mix(in_oklab,var(--fl-success)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-success)_12%,transparent)] px-3 py-2 text-sm text-[var(--fl-success)]"> <button
type="button"
disabled={pending}
title="Markierung wieder entfernen"
onClick={() =>
startTransition(async () => {
report(await unmarkMovieAction(entryId));
router.refresh();
})
}
className="group inline-flex items-center gap-2 rounded-xl border border-[color-mix(in_oklab,var(--fl-success)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-success)_12%,transparent)] px-3 py-2 text-sm text-[var(--fl-success)] transition-colors hover:border-[color-mix(in_oklab,var(--fl-success)_70%,transparent)] hover:bg-[color-mix(in_oklab,var(--fl-success)_20%,transparent)] disabled:opacity-60"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="3" aria-hidden> <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="3" aria-hidden>
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" /> <path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
Gesehen Gesehen
</p> <span className="text-xs text-[color-mix(in_oklab,var(--fl-success)_75%,var(--fl-muted))]">
— Markierung entfernen
</span>
</button>
); );
} }
+21
View File
@@ -338,6 +338,27 @@ export async function markMovieWatched(
return { watchIds: [watch.id], status, label: "Film als gesehen markiert" }; 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. */ /** Macht eine Sichtungsaktion rückgängig. */
export async function undoWatches(userId: string, entryId: string, watchIds: string[]) { export async function undoWatches(userId: string, entryId: string, watchIds: string[]) {
await requireEntry(userId, entryId); await requireEntry(userId, entryId);
+54
View File
@@ -21,6 +21,7 @@ import {
startRewatch, startRewatch,
undoWatches, undoWatches,
unmarkEpisodes, unmarkEpisodes,
unmarkMovie,
updateEntryDetails, updateEntryDetails,
watchNextEpisode, watchNextEpisode,
} from "../src/lib/library"; } 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] } } }); 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/);
});