Merge #9: Filmsichtung laesst sich wieder zuruecknehmen
CI / pruefen (push) Canceled after 0s
CI / pruefen (push) Canceled after 0s
Closes #5
This commit was merged in pull request #9.
This commit is contained in:
+13
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 <p>: ein Etikett ohne Klickziel, aus dem es kein Zurück gab.
|
||||
if (watched) {
|
||||
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>
|
||||
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
Gesehen
|
||||
</p>
|
||||
<span className="text-xs text-[color-mix(in_oklab,var(--fl-success)_75%,var(--fl-muted))]">
|
||||
— Markierung entfernen
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user