From 69006478cb1bd1790115576c4ff25c7218a716d9 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 7 Apr 2025 22:53:07 +0200 Subject: [PATCH] Update watchlist when inserting into history --- api/src/controllers/profiles/history.ts | 144 ++++++++++++++++++++++-- api/tests/series/history.test.ts | 38 +++---- 2 files changed, 155 insertions(+), 27 deletions(-) diff --git a/api/src/controllers/profiles/history.ts b/api/src/controllers/profiles/history.ts index 29a6eb79..b12bfc4e 100644 --- a/api/src/controllers/profiles/history.ts +++ b/api/src/controllers/profiles/history.ts @@ -1,9 +1,22 @@ -import { and, eq, isNotNull, ne, not, or, sql } from "drizzle-orm"; +import { + and, + count, + eq, + exists, + gt, + isNotNull, + ne, + not, + or, + sql, +} from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; import Elysia, { t } from "elysia"; import { auth, getUserInfo } from "~/auth"; import { db } from "~/db"; -import { entries, history, profiles, videos } from "~/db/schema"; -import { values } from "~/db/utils"; +import { entries, history, profiles, shows, videos } from "~/db/schema"; +import { watchlist } from "~/db/schema/watchlist"; +import { coalesce, values } from "~/db/utils"; import { Entry } from "~/models/entry"; import { KError } from "~/models/error"; import { SeedHistory } from "~/models/history"; @@ -165,6 +178,10 @@ export const historyH = new Elysia({ tags: ["profiles"] }) async ({ body, jwt: { sub }, error }) => { const profilePk = await getOrCreateProfile(sub); + const vals = values( + body.map((x) => ({ ...x, entryUseId: isUuid(x.entry) })), + ).as("hist"); + const rows = await db .insert(history) .select( @@ -177,11 +194,7 @@ export const historyH = new Elysia({ tags: ["profiles"] }) time: sql`hist.time::integer`, playedDate: sql`hist.playedDate::timestamptz`, }) - .from( - values( - body.map((x) => ({ ...x, entryUseId: isUuid(x.entry) })), - ).as("hist"), - ) + .from(vals) .innerJoin( entries, or( @@ -198,6 +211,121 @@ export const historyH = new Elysia({ tags: ["profiles"] }) .leftJoin(videos, eq(videos.id, sql`hist.videoId::uuid`)), ) .returning({ pk: history.pk }); + + // automatically update watchlist with this new info + + const nextEntry = alias(entries, "next_entry"); + const nextEntryQ = db + .select({ + pk: nextEntry.pk, + }) + .from(nextEntry) + .where( + and( + eq(nextEntry.showPk, entries.showPk), + gt(nextEntry.order, entries.order), + ), + ) + .orderBy(nextEntry.showPk, entries.order) + .as("nextEntryQ"); + + const seenCountQ = db + .select({ c: count() }) + .from(entries) + .where( + and( + eq(entries.showPk, sql`excluded.show_pk`), + exists( + db + .select() + .from(history) + .where( + and( + eq(history.profilePk, profilePk), + eq(history.entryPk, entries.pk), + ), + ), + ), + ), + ) + .as("seenCountQ"); + + await db + .insert(watchlist) + .select( + db + .select({ + profilePk: sql`${profilePk}`, + showPk: entries.showPk, + status: sql` + case + when + hist.progress >= 95 + and ${nextEntryQ.pk} is null + then 'completed'::watchstatus + else 'watching'::watchstatus + end + `, + seenCount: sql` + case + when ${eq(entries.kind, "movie")} then hist.progress::number + when hist.progress >= 95 then 1 + else 0 + end + `, + nextEntry: nextEntryQ.pk, + score: sql`null`, + startedAt: sql`hist.playedDate::timestamptz`, + completedAt: sql` + case + when ${nextEntryQ.pk} is null then hist.playedDate::timestamptz + else null + end + `, + }) + .from(vals) + .leftJoin( + entries, + or( + and( + sql`hist.entryUseId::boolean`, + eq(entries.id, sql`hist.entry::uuid`), + ), + and( + not(sql`hist.entryUseId::boolean`), + eq(entries.slug, sql`hist.entry`), + ), + ), + ) + .leftLateralJoin(nextEntryQ, sql`true`), + ) + .onConflictDoUpdate({ + target: [watchlist.profilePk, watchlist.showPk], + set: { + status: sql` + case + when ${eq(sql`excluded.status`, "completed")} then excluded.status + when ${and( + ne(watchlist.status, "completed"), + ne(watchlist.status, "rewatching"), + )} then excluded.status + else ${watchlist.status} + end + `, + seenCount: sql`${seenCountQ.c}`, + nextEntry: sql` + case + when ${eq(watchlist.status, "completed")} then null + else excluded.nextEntry + end + `, + completedAt: coalesce( + watchlist.completedAt, + sql`excluded.completed_at`, + ), + }, + }); + return error(201, { status: 201, inserted: rows.length }); }, { diff --git a/api/tests/series/history.test.ts b/api/tests/series/history.test.ts index 8bcd07c2..be9581c3 100644 --- a/api/tests/series/history.test.ts +++ b/api/tests/series/history.test.ts @@ -6,6 +6,7 @@ import { getEntries, getHistory, getNews, + getWatchlist, } from "tests/helpers"; import { expectStatus } from "tests/utils"; import { db } from "~/db"; @@ -127,23 +128,22 @@ describe("Set & get history", () => { // extras, unknowns - // it("Update watchlist", async () => { - // const [r, b] = await setMovieStatus(bubble.slug, { - // status: "rewatching", - // // we still need to specify all values - // completedAt: "2024-12-21", - // score: 85, - // }); - // expectStatus(r, b).toBe(200); - // - // const [resp, body] = await getMovie(bubble.slug, {}); - // expectStatus(resp, body).toBe(200); - // expect(body.slug).toBe(bubble.slug); - // expect(body.progress).toMatchObject({ - // status: "rewatching", - // completedAt: "2024-12-21 00:00:00+00", - // score: 85, - // percent: 0, - // }); - // }); + it("Update watchlist", async () => { + const [resp, body] = await getWatchlist("me", {}); + expectStatus(resp, body).toBe(200); + expect(body.items).toBeArrayOfSize(2); + // watching items before completed ones + expect(body.items[0].slug).toBe(madeInAbyss.slug); + expect(body.items[0].watchStatus).toMatchObject({ + status: "watching", + seenCount: 1, + startedAt: "2025-02-01 00:00:00+00", + }); + expect(body.items[1].slug).toBe(bubble.slug); + expect(body.items[1].watchStatus).toMatchObject({ + status: "completed", + percent: 100, + startedAt: "2025-02-02 00:00:00+00", + }); + }); });