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
|
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
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user