From 86f4ce2bd89de05bcdd471d0628dc390b9ef4bc5 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 12 Dec 2025 12:05:28 +0100 Subject: [PATCH 1/6] wip --- api/src/websockets.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 api/src/websockets.ts diff --git a/api/src/websockets.ts b/api/src/websockets.ts new file mode 100644 index 00000000..c581c700 --- /dev/null +++ b/api/src/websockets.ts @@ -0,0 +1,20 @@ +import Elysia, { t } from "elysia"; + +const actionMap: Record = [ + +] + +export const appWs = new Elysia().ws("/ws", { + body: t.Union([ + t.Object({ + action: t.Literal("ping"), + }), + t.Object({ + action: t.Literal("watch"), + entry: t.String(), + }), + ]), + message(ws, { message }) { + actionMap[message.action](message); + }, +}); From fd29c6f682397bf3ffa604e78c7be195d112204e Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 14 Dec 2025 21:43:02 +0100 Subject: [PATCH 2/6] Rework history to prevent duplicates in the last day --- api/src/controllers/profiles/history.ts | 402 ++++++++++++++---------- api/src/db/utils.ts | 30 -- api/src/models/history.ts | 14 +- api/src/utils.ts | 17 + 4 files changed, 264 insertions(+), 199 deletions(-) diff --git a/api/src/controllers/profiles/history.ts b/api/src/controllers/profiles/history.ts index 9736ee23..324a2738 100644 --- a/api/src/controllers/profiles/history.ts +++ b/api/src/controllers/profiles/history.ts @@ -1,11 +1,28 @@ -import { and, count, eq, exists, gt, isNotNull, ne, sql } from "drizzle-orm"; +import { + and, + count, + eq, + exists, + gt, + isNotNull, + lte, + ne, + 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, shows, videos } from "~/db/schema"; +import { db, type Transaction } from "~/db"; +import { + entries, + entryVideoJoin, + history, + profiles, + shows, + videos, +} from "~/db/schema"; import { watchlist } from "~/db/schema/watchlist"; -import { coalesce, values } from "~/db/utils"; +import { coalesce } from "~/db/utils"; import { Entry } from "~/models/entry"; import { KError } from "~/models/error"; import { SeedHistory } from "~/models/history"; @@ -13,12 +30,12 @@ import { AcceptLanguage, createPage, Filter, - isUuid, Page, processLanguages, } from "~/models/utils"; import { desc } from "~/models/utils/descriptions"; import type { WatchlistStatus } from "~/models/watchlist"; +import { traverse } from "~/utils"; import { entryFilters, entryProgressQ, @@ -27,6 +44,220 @@ import { } from "../entries"; import { getOrCreateProfile } from "./profile"; +export async function updateHistory( + dbTx: Transaction, + userPk: number, + progress: SeedHistory[], +) { + return dbTx.transaction(async (tx) => { + const existing = ( + await tx + .select({ videoId: videos.id }) + .from(history) + .for("update") + .leftJoin(videos, eq(videos.pk, history.videoPk)) + .where( + and( + eq(history.profilePk, userPk), + lte(history.playedDate, sql`interval '1 day'`), + ), + ) + ).map((x) => x.videoId); + + const toUpdate = traverse( + progress.filter((x) => existing.includes(x.videoId)), + ); + const newEntries = traverse( + progress.filter((x) => !existing.includes(x.videoId)), + ); + + const updated = await tx + .update(history) + .set({ + time: sql`hist.ts`, + percent: sql`hist.percent`, + playedDate: coalesce(sql`hist.played_date`, sql`now()`) + }) + .from(sql`unnest( + ${toUpdate.videoId}::uuid[], + ${toUpdate.time}::integer[], + ${toUpdate.percent}::integer[], + ${toUpdate.playedDate}::timestamp[] + ) as hist(video_id, ts, percent, played_date)`) + .innerJoin(videos, eq(videos.id, sql`hist.video_id`)) + .where(and(eq(history.profilePk, userPk), eq(history.videoPk, videos.pk))) + .returning({ + entryPk: history.entryPk, + percent: history.percent, + playedDate: history.playedDate, + }); + + const ret = await tx + .insert(history) + .select( + db + .select({ + profilePk: sql`${userPk}`.as("profilePk"), + entryPk: entries.pk, + videoPk: videos.pk, + percent: sql`hist.percent`.as("percent"), + time: sql`hist.ts`.as("time"), + playedDate: coalesce(sql`hist.played_date`, sql`now()`).as( + "playedDate", + ), + }) + .from(sql`unnest( + ${newEntries.videoId}::uuid[], + ${newEntries.time}::integer[], + ${newEntries.percent}::integer[], + ${newEntries.playedDate}::timestamptz[] + ) as hist(video_id, ts, percent, played_date)`) + .innerJoin(videos, eq(videos.id, sql`hist.videoId`)) + .leftJoin(entryVideoJoin, eq(entryVideoJoin.videoPk, videos.pk)) + .leftJoin(entries, eq(entries.pk, entryVideoJoin.entryPk)), + ) + .returning({ + entryPk: history.entryPk, + percent: history.percent, + playedDate: history.playedDate, + }); + + // only return new and entries whose status has changed. + // we don't need to update the watchlist every 10s when watching a video. + return [...ret, ...updated.filter((x) => x.percent >= 95)]; + }); +} + +export async function updateWatchlist( + tx: Transaction, + userPk: number, + histArr: Awaited>, +) { + const nextEntry = alias(entries, "next_entry"); + const nextEntryQ = tx + .select({ + pk: nextEntry.pk, + }) + .from(nextEntry) + .where( + and( + eq(nextEntry.showPk, entries.showPk), + ne(nextEntry.kind, "extra"), + gt(nextEntry.order, entries.order), + ), + ) + .orderBy(nextEntry.order) + .limit(1) + .as("nextEntryQ"); + + const seenCountQ = tx + .select({ c: count() }) + .from(entries) + .where( + and( + eq(entries.showPk, sql`excluded.show_pk`), + exists( + db + .select() + .from(history) + .where( + and( + eq(history.profilePk, userPk), + eq(history.entryPk, entries.pk), + ), + ), + ), + ), + ); + + const showKindQ = tx + .select({ k: shows.kind }) + .from(shows) + .where(eq(shows.pk, sql`excluded.show_pk`)); + + const hist = traverse(histArr); + await tx + .insert(watchlist) + .select( + db + .selectDistinctOn([entries.showPk], { + profilePk: sql`${userPk}`.as("profilePk"), + showPk: entries.showPk, + status: sql` + case + when + hist.percent >= 95 + and ${nextEntryQ.pk} is null + then 'completed'::watchlist_status + else 'watching'::watchlist_status + end + `.as("status"), + seenCount: sql` + case + when ${entries.kind} = 'movie' then hist.percent + when hist.percent >= 95 then 1 + else 0 + end + `.as("seen_count"), + nextEntry: sql` + case + when hist.percent >= 95 then ${nextEntryQ.pk} + else ${entries.pk} + end + `.as("next_entry"), + score: sql`null`.as("score"), + startedAt: sql`hist.played_date`.as("startedAt"), + lastPlayedAt: sql`hist.played_date`.as("lastPlayedAt"), + completedAt: sql` + case + when ${nextEntryQ.pk} is null then hist.played_date + else null + end + `.as("completedAt"), + // see https://github.com/drizzle-team/drizzle-orm/issues/3608 + updatedAt: sql`now()`.as("updatedAt"), + }) + .from(sql`unnest( + ${hist.entryPk}::integer[], + ${hist.percent}::integer[], + ${hist.playedDate}::timestamptz[] + ) as hist(entry_pk, percent, played_date)`) + .leftJoin(entries, eq(entries.pk, sql`hist.entry_pk`)) + .leftJoinLateral(nextEntryQ, sql`true`), + ) + .onConflictDoUpdate({ + target: [watchlist.profilePk, watchlist.showPk], + set: { + status: sql` + case + when excluded.status = 'completed' then excluded.status + when + ${watchlist.status} != 'completed' + and ${watchlist.status} != 'rewatching' + then excluded.status + else ${watchlist.status} + end + `, + seenCount: sql` + case + when ${showKindQ} = 'movie' then excluded.seen_count + else ${seenCountQ} + end`, + nextEntry: sql` + case + when ${watchlist.status} = 'completed' then null + else excluded.next_entry + end + `, + lastPlayedAt: sql`excluded.last_played_at`, + completedAt: coalesce( + watchlist.completedAt, + sql`excluded.completed_at`, + ), + }, + }); +} + const historyProgressQ: typeof entryProgressQ = db .select({ percent: history.percent, @@ -170,162 +401,11 @@ export const historyH = new Elysia({ tags: ["profiles"] }) async ({ body, jwt: { sub }, status }) => { const profilePk = await getOrCreateProfile(sub); - const hist = values( - body.map((x) => ({ ...x, entryUseId: isUuid(x.entry) })), - { - percent: "integer", - time: "integer", - playedDate: "timestamptz", - videoId: "uuid", - }, - ).as("hist"); - const valEqEntries = sql` - case - when hist.entryUseId::boolean then ${entries.id} = hist.entry::uuid - else ${entries.slug} = hist.entry - end - `; - - const rows = await db - .insert(history) - .select( - db - .select({ - profilePk: sql`${profilePk}`.as("profilePk"), - entryPk: entries.pk, - videoPk: videos.pk, - percent: sql`hist.percent`.as("percent"), - time: sql`hist.time`.as("time"), - playedDate: sql`hist.playedDate`.as("playedDate"), - }) - .from(hist) - .innerJoin(entries, valEqEntries) - .leftJoin(videos, eq(videos.id, sql`hist.videoId`)), - ) - .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), - ne(nextEntry.kind, "extra"), - gt(nextEntry.order, entries.order), - ), - ) - .orderBy(nextEntry.order) - .limit(1) - .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), - ), - ), - ), - ), - ); - - const showKindQ = db - .select({ k: shows.kind }) - .from(shows) - .where(eq(shows.pk, sql`excluded.show_pk`)); - - await db - .insert(watchlist) - .select( - db - .select({ - profilePk: sql`${profilePk}`.as("profilePk"), - showPk: entries.showPk, - status: sql` - case - when - hist.percent >= 95 - and ${nextEntryQ.pk} is null - then 'completed'::watchlist_status - else 'watching'::watchlist_status - end - `.as("status"), - seenCount: sql` - case - when ${entries.kind} = 'movie' then hist.percent - when hist.percent >= 95 then 1 - else 0 - end - `.as("seen_count"), - nextEntry: sql` - case - when hist.percent >= 95 then ${nextEntryQ.pk} - else ${entries.pk} - end - `.as("next_entry"), - score: sql`null`.as("score"), - startedAt: sql`hist.playedDate`.as("startedAt"), - lastPlayedAt: sql`hist.playedDate`.as("lastPlayedAt"), - completedAt: sql` - case - when ${nextEntryQ.pk} is null then hist.playedDate - else null - end - `.as("completedAt"), - // see https://github.com/drizzle-team/drizzle-orm/issues/3608 - updatedAt: sql`now()`.as("updatedAt"), - }) - .from(hist) - .leftJoin(entries, valEqEntries) - .leftJoinLateral(nextEntryQ, sql`true`), - ) - .onConflictDoUpdate({ - target: [watchlist.profilePk, watchlist.showPk], - set: { - status: sql` - case - when excluded.status = 'completed' then excluded.status - when - ${watchlist.status} != 'completed' - and ${watchlist.status} != 'rewatching' - then excluded.status - else ${watchlist.status} - end - `, - seenCount: sql` - case - when ${showKindQ} = 'movie' then excluded.seen_count - else ${seenCountQ} - end`, - nextEntry: sql` - case - when ${watchlist.status} = 'completed' then null - else excluded.next_entry - end - `, - lastPlayedAt: sql`excluded.last_played_at`, - completedAt: coalesce( - watchlist.completedAt, - sql`excluded.completed_at`, - ), - }, - }); - - return status(201, { status: 201, inserted: rows.length }); + return db.transaction(async (tx) => { + const hist = await updateHistory(tx, profilePk, body); + await updateWatchlist(tx, profilePk, hist); + return status(201, { status: 201, inserted: hist.length }); + }); }, { detail: { description: "Bulk add entries/movies to your watch history." }, diff --git a/api/src/db/utils.ts b/api/src/db/utils.ts index 12c2e43c..c6fd3fdd 100644 --- a/api/src/db/utils.ts +++ b/api/src/db/utils.ts @@ -92,36 +92,6 @@ export function sqlarr(array: unknown[]): string { .join(", ")}}`; } -// See https://github.com/drizzle-team/drizzle-orm/issues/4044 -export function values( - items: Record[], - typeInfo: Partial> = {}, -) { - if (items[0] === undefined) - throw new Error("Invalid values, expecting at least one items"); - const [firstProp, ...props] = Object.keys(items[0]) as K[]; - const values = items - .map((x, i) => { - let ret = sql`(${x[firstProp]}`; - if (i === 0 && typeInfo[firstProp]) - ret = sql`${ret}::${sql.raw(typeInfo[firstProp])}`; - for (const val of props) { - ret = sql`${ret}, ${x[val]}`; - if (i === 0 && typeInfo[val]) - ret = sql`${ret}::${sql.raw(typeInfo[val])}`; - } - return sql`${ret})`; - }) - .reduce((acc, x) => sql`${acc}, ${x}`); - const valueNames = [firstProp, ...props].join(", "); - - return { - as: (name: string) => { - return sql`(values ${values}) as ${sql.raw(name)}(${sql.raw(valueNames)})`; - }, - }; -} - /* goal: * unnestValues([{a: 1, b: 2}, {a: 3, b: 4}], tbl) * diff --git a/api/src/models/history.ts b/api/src/models/history.ts index 541153fd..76ec51a9 100644 --- a/api/src/models/history.ts +++ b/api/src/models/history.ts @@ -27,12 +27,10 @@ export const Progress = t.Object({ }); export type Progress = typeof Progress.static; -export const SeedHistory = t.Intersect([ - t.Object({ - entry: t.String({ - description: "Id or slug of the entry/movie you watched", - }), - }), - Progress, -]); +export const SeedHistory = t.Object({ + percent: Progress.properties.percent, + time: Progress.properties.time, + playedDate: t.Optional(Progress.properties.playedDate), + videoId: Progress.properties.videoId.anyOf[0], +}); export type SeedHistory = typeof SeedHistory.static; diff --git a/api/src/utils.ts b/api/src/utils.ts index c74bd4a9..bd52e73c 100644 --- a/api/src/utils.ts +++ b/api/src/utils.ts @@ -38,3 +38,20 @@ export function uniqBy(a: T[], key: (val: T) => string): T[] { return true; }); } + +export function traverse>( + arr: T[], +): { [K in keyof T]: T[K][] } { + const result = {} as { [K in keyof T]: T[K][] }; + + arr.forEach((obj) => { + for (const key in obj) { + if (!result[key]) { + result[key] = []; + } + result[key].push(obj[key]); + } + }); + + return result; +} From a855004fd203414791c1233a9917ecb5baaffc3a Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 14 Dec 2025 23:12:15 +0100 Subject: [PATCH 3/6] Implement watch websocket api --- api/src/auth.ts | 23 +++++---- api/src/base.ts | 4 +- api/src/websockets.ts | 114 ++++++++++++++++++++++++++++++++++++------ 3 files changed, 114 insertions(+), 27 deletions(-) diff --git a/api/src/auth.ts b/api/src/auth.ts index d7f0d874..101a5dc0 100644 --- a/api/src/auth.ts +++ b/api/src/auth.ts @@ -33,6 +33,17 @@ const Jwt = t.Object({ type Jwt = typeof Jwt.static; const validator = TypeCompiler.Compile(Jwt); +export async function verifyJwt(bearer: string) { + // @ts-expect-error ts can't understand that there's two overload idk why + const { payload } = await jwtVerify(bearer, jwtSecret ?? jwks, { + issuer: process.env.JWT_ISSUER, + }); + const raw = validator.Decode(payload); + const jwt = Value.Default(Jwt, raw) as Prettify; + + return { jwt }; +} + export const auth = new Elysia({ name: "auth" }) .guard({ headers: t.Object( @@ -50,18 +61,8 @@ export const auth = new Elysia({ name: "auth" }) message: "No authorization header was found.", }); } - try { - // @ts-expect-error ts can't understand that there's two overload idk why - const { payload } = await jwtVerify(bearer, jwtSecret ?? jwks, { - issuer: process.env.JWT_ISSUER, - }); - const raw = validator.Decode(payload); - const jwt = Value.Default(Jwt, raw) as Prettify< - Jwt & { settings: Settings } - >; - - return { jwt }; + return await verifyJwt(bearer); } catch (err) { return status(403, { status: 403, diff --git a/api/src/base.ts b/api/src/base.ts index ad33b439..920d88ff 100644 --- a/api/src/base.ts +++ b/api/src/base.ts @@ -17,6 +17,7 @@ import { videosReadH, videosWriteH } from "./controllers/videos"; import { db } from "./db"; import type { KError } from "./models/error"; import { otel } from "./otel"; +import { appWs } from "./websockets"; export const base = new Elysia({ name: "base" }) .onError(({ code, error }) => { @@ -91,8 +92,9 @@ export const base = new Elysia({ name: "base" }) export const prefix = "/api"; export const handlers = new Elysia({ prefix }) .use(base) - .use(auth) .use(otel) + .use(appWs) + .use(auth) .guard( { // Those are not applied for now. See https://github.com/elysiajs/elysia/issues/1139 diff --git a/api/src/websockets.ts b/api/src/websockets.ts index c581c700..69bbf62b 100644 --- a/api/src/websockets.ts +++ b/api/src/websockets.ts @@ -1,20 +1,104 @@ -import Elysia, { t } from "elysia"; +import type { TObject, TString } from "@sinclair/typebox"; +import Elysia, { type TSchema, t } from "elysia"; +import { verifyJwt } from "./auth"; +import { updateHistory, updateWatchlist } from "./controllers/profiles/history"; +import { getOrCreateProfile } from "./controllers/profiles/profile"; +import { db } from "./db"; +import { SeedHistory } from "./models/history"; -const actionMap: Record = [ +const actionMap = { + ping: handler({ + message(ws) { + ws.send({ response: "pong" }); + }, + }), + watch: handler({ + body: t.Omit(SeedHistory, ["playedDate"]), + permissions: ["core.read"], + async message(ws, body) { + const profilePk = await getOrCreateProfile(ws.data.jwt.sub); -] + await db.transaction(async (tx) => { + const hist = await updateHistory(tx, profilePk, [body]); + await updateWatchlist(tx, profilePk, hist); + }); + ws.send({ response: "ok" }); + }, + }), +}; -export const appWs = new Elysia().ws("/ws", { - body: t.Union([ - t.Object({ - action: t.Literal("ping"), - }), - t.Object({ - action: t.Literal("watch"), - entry: t.String(), - }), - ]), - message(ws, { message }) { - actionMap[message.action](message); +const baseWs = new Elysia() + .guard({ + headers: t.Object( + { + authorization: t.Optional(t.TemplateLiteral("Bearer ${string}")), + "Sec-WebSocket-Protocol": t.Optional( + t.Array( + t.Union([t.Literal("kyoo"), t.TemplateLiteral("Bearer ${string}")]), + ), + ), + }, + { additionalProperties: true }, + ), + }) + .resolve( + async ({ + headers: { authorization, "Sec-WebSocket-Protocol": wsProtocol }, + status, + }) => { + const auth = + authorization ?? + (wsProtocol?.length === 2 && + wsProtocol[0] === "kyoo" && + wsProtocol[1].startsWith("Bearer ") + ? wsProtocol[1] + : null); + const bearer = auth?.slice(7); + if (!bearer) { + return status(403, { + status: 403, + message: "No authorization header was found.", + }); + } + try { + return await verifyJwt(bearer); + } catch (err) { + return status(403, { + status: 403, + message: "Invalid jwt. Verification vailed", + details: err, + }); + } + }, + ); + +export const appWs = baseWs.ws("/ws", { + body: t.Union( + Object.entries(actionMap).map(([k, v]) => + t.Intersect([t.Object({ action: t.Literal(k) }), v.body ?? t.Object({})]), + ), + ) as unknown as TObject<{ action: TString }>, + async open(ws) { + if (!ws.data.jwt.sub) { + ws.close(3000, "Unauthorized"); + } + }, + async message(ws, { action, ...body }) { + const handler = actionMap[action as keyof typeof actionMap]; + for (const perm of handler.permissions ?? []) { + if (!ws.data.jwt.permissions.includes(perm)) { + return ws.close(3000, `Missing permission: '${perm}'.`); + } + } + await handler.message(ws as any, body as any); }, }); + +type Ws = Parameters[1]["open"]>>[0]; +function handler>(ret: { + body?: Schema; + permissions?: string[]; + message: (ws: Ws, body: Schema["static"]) => void | Promise; +}) { + return ret; +} From 3b6234de46342faf19f347fff9a505049bfad894 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 14 Dec 2025 23:51:40 +0100 Subject: [PATCH 4/6] Fix new history queries --- api/src/controllers/profiles/history.ts | 27 ++++++++++++++----------- api/src/models/history.ts | 2 +- api/src/utils.ts | 4 ++-- api/src/websockets.ts | 4 +++- api/tests/series/history.test.ts | 12 ++++++----- api/tests/series/nextup.test.ts | 7 ++----- 6 files changed, 30 insertions(+), 26 deletions(-) diff --git a/api/src/controllers/profiles/history.ts b/api/src/controllers/profiles/history.ts index 324a2738..355f2fcf 100644 --- a/api/src/controllers/profiles/history.ts +++ b/api/src/controllers/profiles/history.ts @@ -22,7 +22,7 @@ import { videos, } from "~/db/schema"; import { watchlist } from "~/db/schema/watchlist"; -import { coalesce } from "~/db/utils"; +import { coalesce, sqlarr } from "~/db/utils"; import { Entry } from "~/models/entry"; import { KError } from "~/models/error"; import { SeedHistory } from "~/models/history"; @@ -50,16 +50,18 @@ export async function updateHistory( progress: SeedHistory[], ) { return dbTx.transaction(async (tx) => { + // `for("update", { of: history })` will put the `kyoo.history` instead + // of `history` in the sql and that triggers a sql error. const existing = ( await tx .select({ videoId: videos.id }) .from(history) - .for("update") + .for("update", { of: sql`history` as any }) .leftJoin(videos, eq(videos.pk, history.videoPk)) .where( and( eq(history.profilePk, userPk), - lte(history.playedDate, sql`interval '1 day'`), + lte(sql`now() - ${history.playedDate}`, sql`interval '1 day'`), ), ) ).map((x) => x.videoId); @@ -71,18 +73,19 @@ export async function updateHistory( progress.filter((x) => !existing.includes(x.videoId)), ); + // TODO: only call update/insert if toUpdate/newEntries aren't empty const updated = await tx .update(history) .set({ time: sql`hist.ts`, percent: sql`hist.percent`, - playedDate: coalesce(sql`hist.played_date`, sql`now()`) + playedDate: coalesce(sql`hist.played_date`, sql`now()`), }) .from(sql`unnest( - ${toUpdate.videoId}::uuid[], - ${toUpdate.time}::integer[], - ${toUpdate.percent}::integer[], - ${toUpdate.playedDate}::timestamp[] + ${sqlarr(toUpdate.videoId)}::uuid[], + ${sqlarr(toUpdate.time)}::integer[], + ${sqlarr(toUpdate.percent)}::integer[], + ${sqlarr(toUpdate.playedDate)}::timestamp[] ) as hist(video_id, ts, percent, played_date)`) .innerJoin(videos, eq(videos.id, sql`hist.video_id`)) .where(and(eq(history.profilePk, userPk), eq(history.videoPk, videos.pk))) @@ -107,10 +110,10 @@ export async function updateHistory( ), }) .from(sql`unnest( - ${newEntries.videoId}::uuid[], - ${newEntries.time}::integer[], - ${newEntries.percent}::integer[], - ${newEntries.playedDate}::timestamptz[] + ${sqlarr(newEntries.videoId)}::uuid[], + ${sqlarr(newEntries.time)}::integer[], + ${sqlarr(newEntries.percent)}::integer[], + ${sqlarr(newEntries.playedDate)}::timestamptz[] ) as hist(video_id, ts, percent, played_date)`) .innerJoin(videos, eq(videos.id, sql`hist.videoId`)) .leftJoin(entryVideoJoin, eq(entryVideoJoin.videoPk, videos.pk)) diff --git a/api/src/models/history.ts b/api/src/models/history.ts index 76ec51a9..de6189a9 100644 --- a/api/src/models/history.ts +++ b/api/src/models/history.ts @@ -30,7 +30,7 @@ export type Progress = typeof Progress.static; export const SeedHistory = t.Object({ percent: Progress.properties.percent, time: Progress.properties.time, - playedDate: t.Optional(Progress.properties.playedDate), + playedDate: Progress.properties.playedDate, videoId: Progress.properties.videoId.anyOf[0], }); export type SeedHistory = typeof SeedHistory.static; diff --git a/api/src/utils.ts b/api/src/utils.ts index bd52e73c..9402b581 100644 --- a/api/src/utils.ts +++ b/api/src/utils.ts @@ -44,10 +44,10 @@ export function traverse>( ): { [K in keyof T]: T[K][] } { const result = {} as { [K in keyof T]: T[K][] }; - arr.forEach((obj) => { + arr.forEach((obj, i) => { for (const key in obj) { if (!result[key]) { - result[key] = []; + result[key] = new Array(i).fill(null); } result[key].push(obj[key]); } diff --git a/api/src/websockets.ts b/api/src/websockets.ts index 69bbf62b..881efaf5 100644 --- a/api/src/websockets.ts +++ b/api/src/websockets.ts @@ -19,7 +19,9 @@ const actionMap = { const profilePk = await getOrCreateProfile(ws.data.jwt.sub); await db.transaction(async (tx) => { - const hist = await updateHistory(tx, profilePk, [body]); + const hist = await updateHistory(tx, profilePk, [ + { ...body, playedDate: null }, + ]); await updateWatchlist(tx, profilePk, hist); }); ws.send({ response: "ok" }); diff --git a/api/tests/series/history.test.ts b/api/tests/series/history.test.ts index 12b7cc2b..0f2556fe 100644 --- a/api/tests/series/history.test.ts +++ b/api/tests/series/history.test.ts @@ -11,7 +11,12 @@ import { import { expectStatus } from "tests/utils"; import { db } from "~/db"; import { entries, shows, videos } from "~/db/schema"; -import { bubble, madeInAbyss, madeInAbyssVideo } from "~/models/examples"; +import { + bubble, + bubbleVideo, + madeInAbyss, + madeInAbyssVideo, +} from "~/models/examples"; beforeAll(async () => { await db.delete(shows); @@ -35,15 +40,13 @@ describe("Set & get history", () => { const [r, b] = await addToHistory("me", [ { - entry: miaEntrySlug, videoId: madeInAbyssVideo.id, percent: 58, time: 28 * 60 + 12, playedDate: "2025-02-01", }, { - entry: bubble.slug, - videoId: null, + videoId: bubbleVideo.id, percent: 100, time: 2 * 60, playedDate: "2025-02-02", @@ -70,7 +73,6 @@ describe("Set & get history", () => { it("Create duplicated history entry", async () => { const [r, b] = await addToHistory("me", [ { - entry: miaEntrySlug!, videoId: madeInAbyssVideo.id, percent: 100, time: 38 * 60, diff --git a/api/tests/series/nextup.test.ts b/api/tests/series/nextup.test.ts index b10ef224..ea6724db 100644 --- a/api/tests/series/nextup.test.ts +++ b/api/tests/series/nextup.test.ts @@ -13,7 +13,7 @@ import { import { expectStatus } from "tests/utils"; import { db } from "~/db"; import { entries, shows, videos } from "~/db/schema"; -import { bubble, madeInAbyss, madeInAbyssVideo } from "~/models/examples"; +import { bubble, bubbleVideo, madeInAbyss, madeInAbyssVideo } from "~/models/examples"; beforeAll(async () => { await db.delete(shows); @@ -86,15 +86,13 @@ describe("nextup", () => { it("history watching doesn't update", async () => { let [resp, body] = await addToHistory("me", [ { - entry: miaEntrySlug, videoId: madeInAbyssVideo.id, percent: 58, time: 28 * 60 + 12, playedDate: "2025-02-01", }, { - entry: bubble.slug, - videoId: null, + videoId: bubbleVideo.id, percent: 100, time: 2 * 60, playedDate: "2025-02-02", @@ -139,7 +137,6 @@ describe("nextup", () => { it("history completed picks next", async () => { let [resp, body] = await addToHistory("me", [ { - entry: miaEntrySlug, videoId: madeInAbyssVideo.id, percent: 98, time: 28 * 60 + 12, From 333dc46ebf57c84f211791d642017c41b88227bb Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 15 Dec 2025 10:40:54 +0100 Subject: [PATCH 5/6] Remove entry fk in history --- api/drizzle/0025_remove-history-entry.sql | 3 + api/drizzle/meta/0025_snapshot.json | 2017 +++++++++++++++++++++ api/drizzle/meta/_journal.json | 7 + api/src/controllers/entries.ts | 9 +- api/src/controllers/profiles/history.ts | 165 +- api/src/db/schema/history.ts | 6 +- api/src/utils.ts | 5 +- 7 files changed, 2135 insertions(+), 77 deletions(-) create mode 100644 api/drizzle/0025_remove-history-entry.sql create mode 100644 api/drizzle/meta/0025_snapshot.json diff --git a/api/drizzle/0025_remove-history-entry.sql b/api/drizzle/0025_remove-history-entry.sql new file mode 100644 index 00000000..b2cf1383 --- /dev/null +++ b/api/drizzle/0025_remove-history-entry.sql @@ -0,0 +1,3 @@ +ALTER TABLE "kyoo"."history" DROP CONSTRAINT "history_entry_pk_entries_pk_fk"; +--> statement-breakpoint +ALTER TABLE "kyoo"."history" DROP COLUMN "entry_pk"; \ No newline at end of file diff --git a/api/drizzle/meta/0025_snapshot.json b/api/drizzle/meta/0025_snapshot.json new file mode 100644 index 00000000..9b1bf7b8 --- /dev/null +++ b/api/drizzle/meta/0025_snapshot.json @@ -0,0 +1,2017 @@ +{ + "id": "df11ca56-a8a6-4493-9826-bfc83815c91f", + "prevId": "cf102e61-dd73-4664-bbb5-b0b1597ba6e9", + "version": "7", + "dialect": "postgresql", + "tables": { + "kyoo.entries": { + "name": "entries", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "entries_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "entry_type", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "extra_kind": { + "name": "extra_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "air_date": { + "name": "air_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "runtime": { + "name": "runtime", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "available_since": { + "name": "available_since", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "entry_kind": { + "name": "entry_kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "entry_order": { + "name": "entry_order", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entries_show_pk_shows_pk_fk": { + "name": "entries_show_pk_shows_pk_fk", + "tableFrom": "entries", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": [ + "show_pk" + ], + "columnsTo": [ + "pk" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entries_id_unique": { + "name": "entries_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + }, + "entries_slug_unique": { + "name": "entries_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "entries_showPk_seasonNumber_episodeNumber_unique": { + "name": "entries_showPk_seasonNumber_episodeNumber_unique", + "nullsNotDistinct": false, + "columns": [ + "show_pk", + "season_number", + "episode_number" + ] + } + }, + "policies": {}, + "checkConstraints": { + "order_positive": { + "name": "order_positive", + "value": "\"kyoo\".\"entries\".\"order\" >= 0" + } + }, + "isRLSEnabled": false + }, + "kyoo.entry_translations": { + "name": "entry_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "poster": { + "name": "poster", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "entry_name_trgm": { + "name": "entry_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "entry_translations_pk_entries_pk_fk": { + "name": "entry_translations_pk_entries_pk_fk", + "tableFrom": "entry_translations", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": [ + "pk" + ], + "columnsTo": [ + "pk" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entry_translations_pk_language_pk": { + "name": "entry_translations_pk_language_pk", + "columns": [ + "pk", + "language" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.history": { + "name": "history", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "history_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "profile_pk": { + "name": "profile_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "video_pk": { + "name": "video_pk", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "percent": { + "name": "percent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "time": { + "name": "time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "played_date": { + "name": "played_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "history_play_date": { + "name": "history_play_date", + "columns": [ + { + "expression": "played_date", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "history_profile_pk_profiles_pk_fk": { + "name": "history_profile_pk_profiles_pk_fk", + "tableFrom": "history", + "tableTo": "profiles", + "schemaTo": "kyoo", + "columnsFrom": [ + "profile_pk" + ], + "columnsTo": [ + "pk" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "history_video_pk_videos_pk_fk": { + "name": "history_video_pk_videos_pk_fk", + "tableFrom": "history", + "tableTo": "videos", + "schemaTo": "kyoo", + "columnsFrom": [ + "video_pk" + ], + "columnsTo": [ + "pk" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "percent_valid": { + "name": "percent_valid", + "value": "\"kyoo\".\"history\".\"percent\" between 0 and 100" + } + }, + "isRLSEnabled": false + }, + "kyoo.mqueue": { + "name": "mqueue", + "schema": "kyoo", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mqueue_created": { + "name": "mqueue_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.profiles": { + "name": "profiles", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "profiles_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profiles_id_unique": { + "name": "profiles_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.season_translations": { + "name": "season_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "poster": { + "name": "poster", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "banner": { + "name": "banner", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "season_name_trgm": { + "name": "season_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "season_translations_pk_seasons_pk_fk": { + "name": "season_translations_pk_seasons_pk_fk", + "tableFrom": "season_translations", + "tableTo": "seasons", + "schemaTo": "kyoo", + "columnsFrom": [ + "pk" + ], + "columnsTo": [ + "pk" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "season_translations_pk_language_pk": { + "name": "season_translations_pk_language_pk", + "columns": [ + "pk", + "language" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.seasons": { + "name": "seasons", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "seasons_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "start_air": { + "name": "start_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "end_air": { + "name": "end_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "entries_count": { + "name": "entries_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "available_count": { + "name": "available_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "show_fk": { + "name": "show_fk", + "columns": [ + { + "expression": "show_pk", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "season_nbr": { + "name": "season_nbr", + "columns": [ + { + "expression": "season_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "seasons_show_pk_shows_pk_fk": { + "name": "seasons_show_pk_shows_pk_fk", + "tableFrom": "seasons", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": [ + "show_pk" + ], + "columnsTo": [ + "pk" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "seasons_id_unique": { + "name": "seasons_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + }, + "seasons_slug_unique": { + "name": "seasons_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "seasons_showPk_seasonNumber_unique": { + "name": "seasons_showPk_seasonNumber_unique", + "nullsNotDistinct": false, + "columns": [ + "show_pk", + "season_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.show_translations": { + "name": "show_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "poster": { + "name": "poster", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "banner": { + "name": "banner", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "trailer_url": { + "name": "trailer_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "name_trgm": { + "name": "name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "tags": { + "name": "tags", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "show_translations_pk_shows_pk_fk": { + "name": "show_translations_pk_shows_pk_fk", + "tableFrom": "show_translations", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": [ + "pk" + ], + "columnsTo": [ + "pk" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "show_translations_pk_language_pk": { + "name": "show_translations_pk_language_pk", + "columns": [ + "pk", + "language" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.shows": { + "name": "shows", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "shows_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "show_kind", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "genres": { + "name": "genres", + "type": "genres[]", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "runtime": { + "name": "runtime", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "show_status", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "start_air": { + "name": "start_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "end_air": { + "name": "end_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "original": { + "name": "original", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "collection_pk": { + "name": "collection_pk", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "entries_count": { + "name": "entries_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "available_count": { + "name": "available_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kind": { + "name": "kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "rating": { + "name": "rating", + "columns": [ + { + "expression": "rating", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "startAir": { + "name": "startAir", + "columns": [ + { + "expression": "start_air", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shows_collection_pk_shows_pk_fk": { + "name": "shows_collection_pk_shows_pk_fk", + "tableFrom": "shows", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": [ + "collection_pk" + ], + "columnsTo": [ + "pk" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "shows_id_unique": { + "name": "shows_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + }, + "shows_slug_unique": { + "name": "shows_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": { + "rating_valid": { + "name": "rating_valid", + "value": "\"kyoo\".\"shows\".\"rating\" between 0 and 100" + }, + "runtime_valid": { + "name": "runtime_valid", + "value": "\"kyoo\".\"shows\".\"runtime\" >= 0" + } + }, + "isRLSEnabled": false + }, + "kyoo.roles": { + "name": "roles", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "roles_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "staff_pk": { + "name": "staff_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "role_kind", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "character": { + "name": "character", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "role_kind": { + "name": "role_kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "role_order": { + "name": "role_order", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "roles_show_pk_shows_pk_fk": { + "name": "roles_show_pk_shows_pk_fk", + "tableFrom": "roles", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": [ + "show_pk" + ], + "columnsTo": [ + "pk" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "roles_staff_pk_staff_pk_fk": { + "name": "roles_staff_pk_staff_pk_fk", + "tableFrom": "roles", + "tableTo": "staff", + "schemaTo": "kyoo", + "columnsFrom": [ + "staff_pk" + ], + "columnsTo": [ + "pk" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.staff": { + "name": "staff", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "staff_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latin_name": { + "name": "latin_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "staff_id_unique": { + "name": "staff_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + }, + "staff_slug_unique": { + "name": "staff_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.show_studio_join": { + "name": "show_studio_join", + "schema": "kyoo", + "columns": { + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "studio_pk": { + "name": "studio_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "show_studio_join_show_pk_shows_pk_fk": { + "name": "show_studio_join_show_pk_shows_pk_fk", + "tableFrom": "show_studio_join", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": [ + "show_pk" + ], + "columnsTo": [ + "pk" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "show_studio_join_studio_pk_studios_pk_fk": { + "name": "show_studio_join_studio_pk_studios_pk_fk", + "tableFrom": "show_studio_join", + "tableTo": "studios", + "schemaTo": "kyoo", + "columnsFrom": [ + "studio_pk" + ], + "columnsTo": [ + "pk" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "show_studio_join_show_pk_studio_pk_pk": { + "name": "show_studio_join_show_pk_studio_pk_pk", + "columns": [ + "show_pk", + "studio_pk" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.studio_translations": { + "name": "studio_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "studio_name_trgm": { + "name": "studio_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "studio_translations_pk_studios_pk_fk": { + "name": "studio_translations_pk_studios_pk_fk", + "tableFrom": "studio_translations", + "tableTo": "studios", + "schemaTo": "kyoo", + "columnsFrom": [ + "pk" + ], + "columnsTo": [ + "pk" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "studio_translations_pk_language_pk": { + "name": "studio_translations_pk_language_pk", + "columns": [ + "pk", + "language" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.studios": { + "name": "studios", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "studios_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "studios_id_unique": { + "name": "studios_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + }, + "studios_slug_unique": { + "name": "studios_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.entry_video_join": { + "name": "entry_video_join", + "schema": "kyoo", + "columns": { + "entry_pk": { + "name": "entry_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "video_pk": { + "name": "video_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "entry_video_join_entry_pk_entries_pk_fk": { + "name": "entry_video_join_entry_pk_entries_pk_fk", + "tableFrom": "entry_video_join", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": [ + "entry_pk" + ], + "columnsTo": [ + "pk" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entry_video_join_video_pk_videos_pk_fk": { + "name": "entry_video_join_video_pk_videos_pk_fk", + "tableFrom": "entry_video_join", + "tableTo": "videos", + "schemaTo": "kyoo", + "columnsFrom": [ + "video_pk" + ], + "columnsTo": [ + "pk" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entry_video_join_entry_pk_video_pk_pk": { + "name": "entry_video_join_entry_pk_video_pk_pk", + "columns": [ + "entry_pk", + "video_pk" + ] + } + }, + "uniqueConstraints": { + "entry_video_join_slug_unique": { + "name": "entry_video_join_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.videos": { + "name": "videos", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "videos_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rendering": { + "name": "rendering", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "guess": { + "name": "guess", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "videos_id_unique": { + "name": "videos_id_unique", + "nullsNotDistinct": false, + "columns": [ + "id" + ] + }, + "videos_path_unique": { + "name": "videos_path_unique", + "nullsNotDistinct": false, + "columns": [ + "path" + ] + }, + "rendering_unique": { + "name": "rendering_unique", + "nullsNotDistinct": true, + "columns": [ + "rendering", + "part", + "version" + ] + } + }, + "policies": {}, + "checkConstraints": { + "part_pos": { + "name": "part_pos", + "value": "\"kyoo\".\"videos\".\"part\" >= 0" + }, + "version_pos": { + "name": "version_pos", + "value": "\"kyoo\".\"videos\".\"version\" >= 0" + } + }, + "isRLSEnabled": false + }, + "kyoo.watchlist": { + "name": "watchlist", + "schema": "kyoo", + "columns": { + "profile_pk": { + "name": "profile_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "watchlist_status", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "seen_count": { + "name": "seen_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_entry": { + "name": "next_entry", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_played_at": { + "name": "last_played_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "watchlist_profile_pk_profiles_pk_fk": { + "name": "watchlist_profile_pk_profiles_pk_fk", + "tableFrom": "watchlist", + "tableTo": "profiles", + "schemaTo": "kyoo", + "columnsFrom": [ + "profile_pk" + ], + "columnsTo": [ + "pk" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watchlist_show_pk_shows_pk_fk": { + "name": "watchlist_show_pk_shows_pk_fk", + "tableFrom": "watchlist", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": [ + "show_pk" + ], + "columnsTo": [ + "pk" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watchlist_next_entry_entries_pk_fk": { + "name": "watchlist_next_entry_entries_pk_fk", + "tableFrom": "watchlist", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": [ + "next_entry" + ], + "columnsTo": [ + "pk" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "watchlist_profile_pk_show_pk_pk": { + "name": "watchlist_profile_pk_show_pk_pk", + "columns": [ + "profile_pk", + "show_pk" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "score_percent": { + "name": "score_percent", + "value": "\"kyoo\".\"watchlist\".\"score\" between 0 and 100" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "kyoo.entry_type": { + "name": "entry_type", + "schema": "kyoo", + "values": [ + "episode", + "movie", + "special", + "extra" + ] + }, + "kyoo.genres": { + "name": "genres", + "schema": "kyoo", + "values": [ + "action", + "adventure", + "animation", + "comedy", + "crime", + "documentary", + "drama", + "family", + "fantasy", + "history", + "horror", + "music", + "mystery", + "romance", + "science-fiction", + "thriller", + "war", + "western", + "kids", + "reality", + "politics", + "soap", + "talk" + ] + }, + "kyoo.show_kind": { + "name": "show_kind", + "schema": "kyoo", + "values": [ + "serie", + "movie", + "collection" + ] + }, + "kyoo.show_status": { + "name": "show_status", + "schema": "kyoo", + "values": [ + "unknown", + "finished", + "airing", + "planned" + ] + }, + "kyoo.role_kind": { + "name": "role_kind", + "schema": "kyoo", + "values": [ + "actor", + "director", + "writter", + "producer", + "music", + "crew", + "other" + ] + }, + "kyoo.watchlist_status": { + "name": "watchlist_status", + "schema": "kyoo", + "values": [ + "watching", + "rewatching", + "completed", + "dropped", + "planned" + ] + } + }, + "schemas": { + "kyoo": "kyoo" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/api/drizzle/meta/_journal.json b/api/drizzle/meta/_journal.json index f380477f..5e8257f8 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -176,6 +176,13 @@ "when": 1763932730557, "tag": "0024_fix-season-count", "breakpoints": true + }, + { + "idx": 25, + "version": "7", + "when": 1765791459003, + "tag": "0025_remove-history-entry", + "breakpoints": true } ] } diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index 54c9092a..a29568ab 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -45,18 +45,19 @@ import { desc as description } from "~/models/utils/descriptions"; import type { EmbeddedVideo } from "~/models/video"; export const entryProgressQ = db - .selectDistinctOn([history.entryPk], { + .selectDistinctOn([entryVideoJoin.entryPk], { percent: history.percent, time: history.time, - entryPk: history.entryPk, + entryPk: entryVideoJoin.entryPk, playedDate: history.playedDate, videoId: videos.id, }) .from(history) - .leftJoin(videos, eq(history.videoPk, videos.pk)) + .innerJoin(videos, eq(history.videoPk, videos.pk)) + .innerJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk)) .innerJoin(profiles, eq(history.profilePk, profiles.pk)) .where(eq(profiles.id, sql.placeholder("userId"))) - .orderBy(history.entryPk, desc(history.playedDate)) + .orderBy(entryVideoJoin.entryPk, desc(history.playedDate)) .as("progress"); export const entryFilters: FilterDef = { diff --git a/api/src/controllers/profiles/history.ts b/api/src/controllers/profiles/history.ts index 355f2fcf..2e54e5cb 100644 --- a/api/src/controllers/profiles/history.ts +++ b/api/src/controllers/profiles/history.ts @@ -44,7 +44,23 @@ import { } from "../entries"; import { getOrCreateProfile } from "./profile"; -export async function updateHistory( +export async function updateProgress(userPk: number, progress: SeedHistory[]) { + return db.transaction(async (tx) => { + const hist = await updateHistory(tx, userPk, progress); + if (hist.created.length + hist.updated.length !== progress.length) { + tx.rollback(); + } + // only return new and entries whose status has changed. + // we don't need to update the watchlist every 10s when watching a video. + await updateWatchlist(tx, userPk, [ + ...hist.created, + ...hist.updated.filter((x) => x.percent >= 95), + ]); + return { status: 201, inserted: hist.created.length }; + }); +} + +async function updateHistory( dbTx: Transaction, userPk: number, progress: SeedHistory[], @@ -73,69 +89,76 @@ export async function updateHistory( progress.filter((x) => !existing.includes(x.videoId)), ); - // TODO: only call update/insert if toUpdate/newEntries aren't empty - const updated = await tx - .update(history) - .set({ - time: sql`hist.ts`, - percent: sql`hist.percent`, - playedDate: coalesce(sql`hist.played_date`, sql`now()`), - }) - .from(sql`unnest( - ${sqlarr(toUpdate.videoId)}::uuid[], - ${sqlarr(toUpdate.time)}::integer[], - ${sqlarr(toUpdate.percent)}::integer[], - ${sqlarr(toUpdate.playedDate)}::timestamp[] - ) as hist(video_id, ts, percent, played_date)`) - .innerJoin(videos, eq(videos.id, sql`hist.video_id`)) - .where(and(eq(history.profilePk, userPk), eq(history.videoPk, videos.pk))) - .returning({ - entryPk: history.entryPk, - percent: history.percent, - playedDate: history.playedDate, - }); + const updated = + toUpdate === null + ? [] + : await tx + .update(history) + .set({ + time: sql`hist.ts`, + percent: sql`hist.percent`, + playedDate: coalesce(sql`hist.played_date`, sql`now()`), + }) + .from(sql`unnest( + ${sqlarr(toUpdate.videoId)}::uuid[], + ${sqlarr(toUpdate.time)}::integer[], + ${sqlarr(toUpdate.percent)}::integer[], + ${sqlarr(toUpdate.playedDate)}::timestamp[] + ) as hist(video_id, ts, percent, played_date)`) + .innerJoin(videos, eq(videos.id, sql`hist.video_id`)) + .where( + and( + eq(history.profilePk, userPk), + eq(history.videoPk, videos.pk), + ), + ) + .returning({ + videoPk: history.videoPk, + percent: history.percent, + playedDate: history.playedDate, + }); - const ret = await tx - .insert(history) - .select( - db - .select({ - profilePk: sql`${userPk}`.as("profilePk"), - entryPk: entries.pk, - videoPk: videos.pk, - percent: sql`hist.percent`.as("percent"), - time: sql`hist.ts`.as("time"), - playedDate: coalesce(sql`hist.played_date`, sql`now()`).as( - "playedDate", - ), - }) - .from(sql`unnest( - ${sqlarr(newEntries.videoId)}::uuid[], - ${sqlarr(newEntries.time)}::integer[], - ${sqlarr(newEntries.percent)}::integer[], - ${sqlarr(newEntries.playedDate)}::timestamptz[] - ) as hist(video_id, ts, percent, played_date)`) - .innerJoin(videos, eq(videos.id, sql`hist.videoId`)) - .leftJoin(entryVideoJoin, eq(entryVideoJoin.videoPk, videos.pk)) - .leftJoin(entries, eq(entries.pk, entryVideoJoin.entryPk)), - ) - .returning({ - entryPk: history.entryPk, - percent: history.percent, - playedDate: history.playedDate, - }); + const created = + newEntries === null + ? [] + : await tx + .insert(history) + .select( + db + .select({ + profilePk: sql`${userPk}`.as("profilePk"), + videoPk: videos.pk, + percent: sql`hist.percent`.as("percent"), + time: sql`hist.ts`.as("time"), + playedDate: coalesce(sql`hist.played_date`, sql`now()`).as( + "playedDate", + ), + }) + .from(sql`unnest( + ${sqlarr(newEntries.videoId)}::uuid[], + ${sqlarr(newEntries.time)}::integer[], + ${sqlarr(newEntries.percent)}::integer[], + ${sqlarr(newEntries.playedDate)}::timestamptz[] + ) as hist(video_id, ts, percent, played_date)`) + .innerJoin(videos, eq(videos.id, sql`hist.video_id`)), + ) + .returning({ + videoPk: history.videoPk, + percent: history.percent, + playedDate: history.playedDate, + }); - // only return new and entries whose status has changed. - // we don't need to update the watchlist every 10s when watching a video. - return [...ret, ...updated.filter((x) => x.percent >= 95)]; + return { created, updated }; }); } -export async function updateWatchlist( +async function updateWatchlist( tx: Transaction, userPk: number, - histArr: Awaited>, + histArr: { videoPk: number; percent: number; playedDate: string }[], ) { + if (histArr.length === 0) return; + const nextEntry = alias(entries, "next_entry"); const nextEntryQ = tx .select({ @@ -163,10 +186,14 @@ export async function updateWatchlist( db .select() .from(history) + .leftJoin( + entryVideoJoin, + eq(history.videoPk, entryVideoJoin.videoPk), + ) .where( and( eq(history.profilePk, userPk), - eq(history.entryPk, entries.pk), + eq(entryVideoJoin.entryPk, entries.pk), ), ), ), @@ -178,7 +205,7 @@ export async function updateWatchlist( .from(shows) .where(eq(shows.pk, sql`excluded.show_pk`)); - const hist = traverse(histArr); + const hist = traverse(histArr)!; await tx .insert(watchlist) .select( @@ -221,11 +248,15 @@ export async function updateWatchlist( updatedAt: sql`now()`.as("updatedAt"), }) .from(sql`unnest( - ${hist.entryPk}::integer[], - ${hist.percent}::integer[], - ${hist.playedDate}::timestamptz[] - ) as hist(entry_pk, percent, played_date)`) - .leftJoin(entries, eq(entries.pk, sql`hist.entry_pk`)) + ${sqlarr(hist.videoPk)}::integer[], + ${sqlarr(hist.percent)}::integer[], + ${sqlarr(hist.playedDate)}::timestamptz[] + ) as hist(video_pk, percent, played_date)`) + .innerJoin( + entryVideoJoin, + eq(sql`hist.video_pk`, entryVideoJoin.videoPk), + ) + .leftJoin(entries, eq(entries.pk, entryVideoJoin.entryPk)) .leftJoinLateral(nextEntryQ, sql`true`), ) .onConflictDoUpdate({ @@ -261,17 +292,19 @@ export async function updateWatchlist( }); } +// this one is different than the normal progressQ because we want duplicates const historyProgressQ: typeof entryProgressQ = db .select({ percent: history.percent, time: history.time, - entryPk: history.entryPk, + entryPk: entryVideoJoin.entryPk, playedDate: history.playedDate, videoId: videos.id, }) .from(history) - .leftJoin(videos, eq(history.videoPk, videos.pk)) - .leftJoin(profiles, eq(history.profilePk, profiles.pk)) + .innerJoin(videos, eq(history.videoPk, videos.pk)) + .innerJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk)) + .innerJoin(profiles, eq(history.profilePk, profiles.pk)) .where(eq(profiles.id, sql.placeholder("userId"))) .as("progress"); diff --git a/api/src/db/schema/history.ts b/api/src/db/schema/history.ts index a2c6f685..2e01e0a4 100644 --- a/api/src/db/schema/history.ts +++ b/api/src/db/schema/history.ts @@ -1,6 +1,5 @@ import { sql } from "drizzle-orm"; import { check, index, integer } from "drizzle-orm/pg-core"; -import { entries } from "./entries"; import { profiles } from "./profiles"; import { schema, timestamp } from "./utils"; import { videos } from "./videos"; @@ -12,10 +11,7 @@ export const history = schema.table( profilePk: integer() .notNull() .references(() => profiles.pk, { onDelete: "cascade" }), - entryPk: integer() - .notNull() - .references(() => entries.pk, { onDelete: "cascade" }), - videoPk: integer().references(() => videos.pk, { onDelete: "set null" }), + videoPk: integer().notNull().references(() => videos.pk, { onDelete: "cascade" }), percent: integer().notNull().default(0), time: integer().notNull().default(0), playedDate: timestamp({ withTimezone: true, mode: "iso" }) diff --git a/api/src/utils.ts b/api/src/utils.ts index 9402b581..74c11a88 100644 --- a/api/src/utils.ts +++ b/api/src/utils.ts @@ -41,9 +41,10 @@ export function uniqBy(a: T[], key: (val: T) => string): T[] { export function traverse>( arr: T[], -): { [K in keyof T]: T[K][] } { - const result = {} as { [K in keyof T]: T[K][] }; +): { [K in keyof T]: T[K][] } | null { + if (arr.length === 0) return null; + const result = {} as { [K in keyof T]: T[K][] }; arr.forEach((obj, i) => { for (const key in obj) { if (!result[key]) { From ab5da0d5c60290d4867f095394d26a96b651317f Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 16 Dec 2025 12:17:50 +0100 Subject: [PATCH 6/6] Add back entry_pk in history --- api/drizzle/0025_remove-history-entry.sql | 3 - api/drizzle/meta/0025_snapshot.json | 2017 --------------------- api/drizzle/meta/_journal.json | 7 - api/src/controllers/entries.ts | 9 +- api/src/controllers/profiles/history.ts | 105 +- api/src/db/schema/history.ts | 8 +- api/src/models/history.ts | 14 +- api/src/websockets.ts | 14 +- api/tests/series/history.test.ts | 12 +- api/tests/series/nextup.test.ts | 7 +- 10 files changed, 94 insertions(+), 2102 deletions(-) delete mode 100644 api/drizzle/0025_remove-history-entry.sql delete mode 100644 api/drizzle/meta/0025_snapshot.json diff --git a/api/drizzle/0025_remove-history-entry.sql b/api/drizzle/0025_remove-history-entry.sql deleted file mode 100644 index b2cf1383..00000000 --- a/api/drizzle/0025_remove-history-entry.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE "kyoo"."history" DROP CONSTRAINT "history_entry_pk_entries_pk_fk"; ---> statement-breakpoint -ALTER TABLE "kyoo"."history" DROP COLUMN "entry_pk"; \ No newline at end of file diff --git a/api/drizzle/meta/0025_snapshot.json b/api/drizzle/meta/0025_snapshot.json deleted file mode 100644 index 9b1bf7b8..00000000 --- a/api/drizzle/meta/0025_snapshot.json +++ /dev/null @@ -1,2017 +0,0 @@ -{ - "id": "df11ca56-a8a6-4493-9826-bfc83815c91f", - "prevId": "cf102e61-dd73-4664-bbb5-b0b1597ba6e9", - "version": "7", - "dialect": "postgresql", - "tables": { - "kyoo.entries": { - "name": "entries", - "schema": "kyoo", - "columns": { - "pk": { - "name": "pk", - "type": "integer", - "primaryKey": true, - "notNull": true, - "identity": { - "type": "always", - "name": "entries_pk_seq", - "schema": "kyoo", - "increment": "1", - "startWith": "1", - "minValue": "1", - "maxValue": "2147483647", - "cache": "1", - "cycle": false - } - }, - "id": { - "name": "id", - "type": "uuid", - "primaryKey": false, - "notNull": true, - "default": "gen_random_uuid()" - }, - "slug": { - "name": "slug", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "show_pk": { - "name": "show_pk", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "order": { - "name": "order", - "type": "real", - "primaryKey": false, - "notNull": false - }, - "season_number": { - "name": "season_number", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "episode_number": { - "name": "episode_number", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "kind": { - "name": "kind", - "type": "entry_type", - "typeSchema": "kyoo", - "primaryKey": false, - "notNull": true - }, - "extra_kind": { - "name": "extra_kind", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "air_date": { - "name": "air_date", - "type": "date", - "primaryKey": false, - "notNull": false - }, - "runtime": { - "name": "runtime", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "thumbnail": { - "name": "thumbnail", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "external_id": { - "name": "external_id", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "available_since": { - "name": "available_since", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "next_refresh": { - "name": "next_refresh", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "entry_kind": { - "name": "entry_kind", - "columns": [ - { - "expression": "kind", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "hash", - "with": {} - }, - "entry_order": { - "name": "entry_order", - "columns": [ - { - "expression": "order", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "entries_show_pk_shows_pk_fk": { - "name": "entries_show_pk_shows_pk_fk", - "tableFrom": "entries", - "tableTo": "shows", - "schemaTo": "kyoo", - "columnsFrom": [ - "show_pk" - ], - "columnsTo": [ - "pk" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "entries_id_unique": { - "name": "entries_id_unique", - "nullsNotDistinct": false, - "columns": [ - "id" - ] - }, - "entries_slug_unique": { - "name": "entries_slug_unique", - "nullsNotDistinct": false, - "columns": [ - "slug" - ] - }, - "entries_showPk_seasonNumber_episodeNumber_unique": { - "name": "entries_showPk_seasonNumber_episodeNumber_unique", - "nullsNotDistinct": false, - "columns": [ - "show_pk", - "season_number", - "episode_number" - ] - } - }, - "policies": {}, - "checkConstraints": { - "order_positive": { - "name": "order_positive", - "value": "\"kyoo\".\"entries\".\"order\" >= 0" - } - }, - "isRLSEnabled": false - }, - "kyoo.entry_translations": { - "name": "entry_translations", - "schema": "kyoo", - "columns": { - "pk": { - "name": "pk", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "language": { - "name": "language", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "tagline": { - "name": "tagline", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "poster": { - "name": "poster", - "type": "jsonb", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "entry_name_trgm": { - "name": "entry_name_trgm", - "columns": [ - { - "expression": "\"name\" gin_trgm_ops", - "asc": true, - "isExpression": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "gin", - "with": {} - } - }, - "foreignKeys": { - "entry_translations_pk_entries_pk_fk": { - "name": "entry_translations_pk_entries_pk_fk", - "tableFrom": "entry_translations", - "tableTo": "entries", - "schemaTo": "kyoo", - "columnsFrom": [ - "pk" - ], - "columnsTo": [ - "pk" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "entry_translations_pk_language_pk": { - "name": "entry_translations_pk_language_pk", - "columns": [ - "pk", - "language" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "kyoo.history": { - "name": "history", - "schema": "kyoo", - "columns": { - "pk": { - "name": "pk", - "type": "integer", - "primaryKey": true, - "notNull": true, - "identity": { - "type": "always", - "name": "history_pk_seq", - "schema": "kyoo", - "increment": "1", - "startWith": "1", - "minValue": "1", - "maxValue": "2147483647", - "cache": "1", - "cycle": false - } - }, - "profile_pk": { - "name": "profile_pk", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "video_pk": { - "name": "video_pk", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "percent": { - "name": "percent", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "time": { - "name": "time", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "played_date": { - "name": "played_date", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "history_play_date": { - "name": "history_play_date", - "columns": [ - { - "expression": "played_date", - "isExpression": false, - "asc": false, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "history_profile_pk_profiles_pk_fk": { - "name": "history_profile_pk_profiles_pk_fk", - "tableFrom": "history", - "tableTo": "profiles", - "schemaTo": "kyoo", - "columnsFrom": [ - "profile_pk" - ], - "columnsTo": [ - "pk" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "history_video_pk_videos_pk_fk": { - "name": "history_video_pk_videos_pk_fk", - "tableFrom": "history", - "tableTo": "videos", - "schemaTo": "kyoo", - "columnsFrom": [ - "video_pk" - ], - "columnsTo": [ - "pk" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "percent_valid": { - "name": "percent_valid", - "value": "\"kyoo\".\"history\".\"percent\" between 0 and 100" - } - }, - "isRLSEnabled": false - }, - "kyoo.mqueue": { - "name": "mqueue", - "schema": "kyoo", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "kind": { - "name": "kind", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "message": { - "name": "message", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "priority": { - "name": "priority", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "attempt": { - "name": "attempt", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "mqueue_created": { - "name": "mqueue_created", - "columns": [ - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "kyoo.profiles": { - "name": "profiles", - "schema": "kyoo", - "columns": { - "pk": { - "name": "pk", - "type": "integer", - "primaryKey": true, - "notNull": true, - "identity": { - "type": "always", - "name": "profiles_pk_seq", - "schema": "kyoo", - "increment": "1", - "startWith": "1", - "minValue": "1", - "maxValue": "2147483647", - "cache": "1", - "cycle": false - } - }, - "id": { - "name": "id", - "type": "uuid", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "profiles_id_unique": { - "name": "profiles_id_unique", - "nullsNotDistinct": false, - "columns": [ - "id" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "kyoo.season_translations": { - "name": "season_translations", - "schema": "kyoo", - "columns": { - "pk": { - "name": "pk", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "language": { - "name": "language", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "poster": { - "name": "poster", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "thumbnail": { - "name": "thumbnail", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "banner": { - "name": "banner", - "type": "jsonb", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "season_name_trgm": { - "name": "season_name_trgm", - "columns": [ - { - "expression": "\"name\" gin_trgm_ops", - "asc": true, - "isExpression": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "gin", - "with": {} - } - }, - "foreignKeys": { - "season_translations_pk_seasons_pk_fk": { - "name": "season_translations_pk_seasons_pk_fk", - "tableFrom": "season_translations", - "tableTo": "seasons", - "schemaTo": "kyoo", - "columnsFrom": [ - "pk" - ], - "columnsTo": [ - "pk" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "season_translations_pk_language_pk": { - "name": "season_translations_pk_language_pk", - "columns": [ - "pk", - "language" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "kyoo.seasons": { - "name": "seasons", - "schema": "kyoo", - "columns": { - "pk": { - "name": "pk", - "type": "integer", - "primaryKey": true, - "notNull": true, - "identity": { - "type": "always", - "name": "seasons_pk_seq", - "schema": "kyoo", - "increment": "1", - "startWith": "1", - "minValue": "1", - "maxValue": "2147483647", - "cache": "1", - "cycle": false - } - }, - "id": { - "name": "id", - "type": "uuid", - "primaryKey": false, - "notNull": true, - "default": "gen_random_uuid()" - }, - "slug": { - "name": "slug", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "show_pk": { - "name": "show_pk", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "season_number": { - "name": "season_number", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "start_air": { - "name": "start_air", - "type": "date", - "primaryKey": false, - "notNull": false - }, - "end_air": { - "name": "end_air", - "type": "date", - "primaryKey": false, - "notNull": false - }, - "entries_count": { - "name": "entries_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "available_count": { - "name": "available_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "external_id": { - "name": "external_id", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "next_refresh": { - "name": "next_refresh", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "show_fk": { - "name": "show_fk", - "columns": [ - { - "expression": "show_pk", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "hash", - "with": {} - }, - "season_nbr": { - "name": "season_nbr", - "columns": [ - { - "expression": "season_number", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "seasons_show_pk_shows_pk_fk": { - "name": "seasons_show_pk_shows_pk_fk", - "tableFrom": "seasons", - "tableTo": "shows", - "schemaTo": "kyoo", - "columnsFrom": [ - "show_pk" - ], - "columnsTo": [ - "pk" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "seasons_id_unique": { - "name": "seasons_id_unique", - "nullsNotDistinct": false, - "columns": [ - "id" - ] - }, - "seasons_slug_unique": { - "name": "seasons_slug_unique", - "nullsNotDistinct": false, - "columns": [ - "slug" - ] - }, - "seasons_showPk_seasonNumber_unique": { - "name": "seasons_showPk_seasonNumber_unique", - "nullsNotDistinct": false, - "columns": [ - "show_pk", - "season_number" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "kyoo.show_translations": { - "name": "show_translations", - "schema": "kyoo", - "columns": { - "pk": { - "name": "pk", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "language": { - "name": "language", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "tagline": { - "name": "tagline", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "aliases": { - "name": "aliases", - "type": "text[]", - "primaryKey": false, - "notNull": true - }, - "tags": { - "name": "tags", - "type": "text[]", - "primaryKey": false, - "notNull": true - }, - "poster": { - "name": "poster", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "thumbnail": { - "name": "thumbnail", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "banner": { - "name": "banner", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "logo": { - "name": "logo", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "trailer_url": { - "name": "trailer_url", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "name_trgm": { - "name": "name_trgm", - "columns": [ - { - "expression": "\"name\" gin_trgm_ops", - "asc": true, - "isExpression": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "gin", - "with": {} - }, - "tags": { - "name": "tags", - "columns": [ - { - "expression": "tags", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "show_translations_pk_shows_pk_fk": { - "name": "show_translations_pk_shows_pk_fk", - "tableFrom": "show_translations", - "tableTo": "shows", - "schemaTo": "kyoo", - "columnsFrom": [ - "pk" - ], - "columnsTo": [ - "pk" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "show_translations_pk_language_pk": { - "name": "show_translations_pk_language_pk", - "columns": [ - "pk", - "language" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "kyoo.shows": { - "name": "shows", - "schema": "kyoo", - "columns": { - "pk": { - "name": "pk", - "type": "integer", - "primaryKey": true, - "notNull": true, - "identity": { - "type": "always", - "name": "shows_pk_seq", - "schema": "kyoo", - "increment": "1", - "startWith": "1", - "minValue": "1", - "maxValue": "2147483647", - "cache": "1", - "cycle": false - } - }, - "id": { - "name": "id", - "type": "uuid", - "primaryKey": false, - "notNull": true, - "default": "gen_random_uuid()" - }, - "slug": { - "name": "slug", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "show_kind", - "typeSchema": "kyoo", - "primaryKey": false, - "notNull": true - }, - "genres": { - "name": "genres", - "type": "genres[]", - "typeSchema": "kyoo", - "primaryKey": false, - "notNull": true - }, - "rating": { - "name": "rating", - "type": "smallint", - "primaryKey": false, - "notNull": false - }, - "runtime": { - "name": "runtime", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "show_status", - "typeSchema": "kyoo", - "primaryKey": false, - "notNull": true - }, - "start_air": { - "name": "start_air", - "type": "date", - "primaryKey": false, - "notNull": false - }, - "end_air": { - "name": "end_air", - "type": "date", - "primaryKey": false, - "notNull": false - }, - "original": { - "name": "original", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "collection_pk": { - "name": "collection_pk", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "entries_count": { - "name": "entries_count", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "available_count": { - "name": "available_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "external_id": { - "name": "external_id", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "next_refresh": { - "name": "next_refresh", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "kind": { - "name": "kind", - "columns": [ - { - "expression": "kind", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "hash", - "with": {} - }, - "rating": { - "name": "rating", - "columns": [ - { - "expression": "rating", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "startAir": { - "name": "startAir", - "columns": [ - { - "expression": "start_air", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "shows_collection_pk_shows_pk_fk": { - "name": "shows_collection_pk_shows_pk_fk", - "tableFrom": "shows", - "tableTo": "shows", - "schemaTo": "kyoo", - "columnsFrom": [ - "collection_pk" - ], - "columnsTo": [ - "pk" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "shows_id_unique": { - "name": "shows_id_unique", - "nullsNotDistinct": false, - "columns": [ - "id" - ] - }, - "shows_slug_unique": { - "name": "shows_slug_unique", - "nullsNotDistinct": false, - "columns": [ - "slug" - ] - } - }, - "policies": {}, - "checkConstraints": { - "rating_valid": { - "name": "rating_valid", - "value": "\"kyoo\".\"shows\".\"rating\" between 0 and 100" - }, - "runtime_valid": { - "name": "runtime_valid", - "value": "\"kyoo\".\"shows\".\"runtime\" >= 0" - } - }, - "isRLSEnabled": false - }, - "kyoo.roles": { - "name": "roles", - "schema": "kyoo", - "columns": { - "pk": { - "name": "pk", - "type": "integer", - "primaryKey": true, - "notNull": true, - "identity": { - "type": "always", - "name": "roles_pk_seq", - "schema": "kyoo", - "increment": "1", - "startWith": "1", - "minValue": "1", - "maxValue": "2147483647", - "cache": "1", - "cycle": false - } - }, - "show_pk": { - "name": "show_pk", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "staff_pk": { - "name": "staff_pk", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "role_kind", - "typeSchema": "kyoo", - "primaryKey": false, - "notNull": true - }, - "order": { - "name": "order", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "character": { - "name": "character", - "type": "jsonb", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "role_kind": { - "name": "role_kind", - "columns": [ - { - "expression": "kind", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "hash", - "with": {} - }, - "role_order": { - "name": "role_order", - "columns": [ - { - "expression": "order", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "roles_show_pk_shows_pk_fk": { - "name": "roles_show_pk_shows_pk_fk", - "tableFrom": "roles", - "tableTo": "shows", - "schemaTo": "kyoo", - "columnsFrom": [ - "show_pk" - ], - "columnsTo": [ - "pk" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "roles_staff_pk_staff_pk_fk": { - "name": "roles_staff_pk_staff_pk_fk", - "tableFrom": "roles", - "tableTo": "staff", - "schemaTo": "kyoo", - "columnsFrom": [ - "staff_pk" - ], - "columnsTo": [ - "pk" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "kyoo.staff": { - "name": "staff", - "schema": "kyoo", - "columns": { - "pk": { - "name": "pk", - "type": "integer", - "primaryKey": true, - "notNull": true, - "identity": { - "type": "always", - "name": "staff_pk_seq", - "schema": "kyoo", - "increment": "1", - "startWith": "1", - "minValue": "1", - "maxValue": "2147483647", - "cache": "1", - "cycle": false - } - }, - "id": { - "name": "id", - "type": "uuid", - "primaryKey": false, - "notNull": true, - "default": "gen_random_uuid()" - }, - "slug": { - "name": "slug", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "latin_name": { - "name": "latin_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "image": { - "name": "image", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "external_id": { - "name": "external_id", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "staff_id_unique": { - "name": "staff_id_unique", - "nullsNotDistinct": false, - "columns": [ - "id" - ] - }, - "staff_slug_unique": { - "name": "staff_slug_unique", - "nullsNotDistinct": false, - "columns": [ - "slug" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "kyoo.show_studio_join": { - "name": "show_studio_join", - "schema": "kyoo", - "columns": { - "show_pk": { - "name": "show_pk", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "studio_pk": { - "name": "studio_pk", - "type": "integer", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "show_studio_join_show_pk_shows_pk_fk": { - "name": "show_studio_join_show_pk_shows_pk_fk", - "tableFrom": "show_studio_join", - "tableTo": "shows", - "schemaTo": "kyoo", - "columnsFrom": [ - "show_pk" - ], - "columnsTo": [ - "pk" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "show_studio_join_studio_pk_studios_pk_fk": { - "name": "show_studio_join_studio_pk_studios_pk_fk", - "tableFrom": "show_studio_join", - "tableTo": "studios", - "schemaTo": "kyoo", - "columnsFrom": [ - "studio_pk" - ], - "columnsTo": [ - "pk" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "show_studio_join_show_pk_studio_pk_pk": { - "name": "show_studio_join_show_pk_studio_pk_pk", - "columns": [ - "show_pk", - "studio_pk" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "kyoo.studio_translations": { - "name": "studio_translations", - "schema": "kyoo", - "columns": { - "pk": { - "name": "pk", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "language": { - "name": "language", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "logo": { - "name": "logo", - "type": "jsonb", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "studio_name_trgm": { - "name": "studio_name_trgm", - "columns": [ - { - "expression": "\"name\" gin_trgm_ops", - "asc": true, - "isExpression": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "gin", - "with": {} - } - }, - "foreignKeys": { - "studio_translations_pk_studios_pk_fk": { - "name": "studio_translations_pk_studios_pk_fk", - "tableFrom": "studio_translations", - "tableTo": "studios", - "schemaTo": "kyoo", - "columnsFrom": [ - "pk" - ], - "columnsTo": [ - "pk" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "studio_translations_pk_language_pk": { - "name": "studio_translations_pk_language_pk", - "columns": [ - "pk", - "language" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "kyoo.studios": { - "name": "studios", - "schema": "kyoo", - "columns": { - "pk": { - "name": "pk", - "type": "integer", - "primaryKey": true, - "notNull": true, - "identity": { - "type": "always", - "name": "studios_pk_seq", - "schema": "kyoo", - "increment": "1", - "startWith": "1", - "minValue": "1", - "maxValue": "2147483647", - "cache": "1", - "cycle": false - } - }, - "id": { - "name": "id", - "type": "uuid", - "primaryKey": false, - "notNull": true, - "default": "gen_random_uuid()" - }, - "slug": { - "name": "slug", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "external_id": { - "name": "external_id", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'{}'::jsonb" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "studios_id_unique": { - "name": "studios_id_unique", - "nullsNotDistinct": false, - "columns": [ - "id" - ] - }, - "studios_slug_unique": { - "name": "studios_slug_unique", - "nullsNotDistinct": false, - "columns": [ - "slug" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "kyoo.entry_video_join": { - "name": "entry_video_join", - "schema": "kyoo", - "columns": { - "entry_pk": { - "name": "entry_pk", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "video_pk": { - "name": "video_pk", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "slug": { - "name": "slug", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "entry_video_join_entry_pk_entries_pk_fk": { - "name": "entry_video_join_entry_pk_entries_pk_fk", - "tableFrom": "entry_video_join", - "tableTo": "entries", - "schemaTo": "kyoo", - "columnsFrom": [ - "entry_pk" - ], - "columnsTo": [ - "pk" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "entry_video_join_video_pk_videos_pk_fk": { - "name": "entry_video_join_video_pk_videos_pk_fk", - "tableFrom": "entry_video_join", - "tableTo": "videos", - "schemaTo": "kyoo", - "columnsFrom": [ - "video_pk" - ], - "columnsTo": [ - "pk" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "entry_video_join_entry_pk_video_pk_pk": { - "name": "entry_video_join_entry_pk_video_pk_pk", - "columns": [ - "entry_pk", - "video_pk" - ] - } - }, - "uniqueConstraints": { - "entry_video_join_slug_unique": { - "name": "entry_video_join_slug_unique", - "nullsNotDistinct": false, - "columns": [ - "slug" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "kyoo.videos": { - "name": "videos", - "schema": "kyoo", - "columns": { - "pk": { - "name": "pk", - "type": "integer", - "primaryKey": true, - "notNull": true, - "identity": { - "type": "always", - "name": "videos_pk_seq", - "schema": "kyoo", - "increment": "1", - "startWith": "1", - "minValue": "1", - "maxValue": "2147483647", - "cache": "1", - "cycle": false - } - }, - "id": { - "name": "id", - "type": "uuid", - "primaryKey": false, - "notNull": true, - "default": "gen_random_uuid()" - }, - "path": { - "name": "path", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "rendering": { - "name": "rendering", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "part": { - "name": "part", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "version": { - "name": "version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 1 - }, - "guess": { - "name": "guess", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "videos_id_unique": { - "name": "videos_id_unique", - "nullsNotDistinct": false, - "columns": [ - "id" - ] - }, - "videos_path_unique": { - "name": "videos_path_unique", - "nullsNotDistinct": false, - "columns": [ - "path" - ] - }, - "rendering_unique": { - "name": "rendering_unique", - "nullsNotDistinct": true, - "columns": [ - "rendering", - "part", - "version" - ] - } - }, - "policies": {}, - "checkConstraints": { - "part_pos": { - "name": "part_pos", - "value": "\"kyoo\".\"videos\".\"part\" >= 0" - }, - "version_pos": { - "name": "version_pos", - "value": "\"kyoo\".\"videos\".\"version\" >= 0" - } - }, - "isRLSEnabled": false - }, - "kyoo.watchlist": { - "name": "watchlist", - "schema": "kyoo", - "columns": { - "profile_pk": { - "name": "profile_pk", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "show_pk": { - "name": "show_pk", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "watchlist_status", - "typeSchema": "kyoo", - "primaryKey": false, - "notNull": true - }, - "seen_count": { - "name": "seen_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "next_entry": { - "name": "next_entry", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "score": { - "name": "score", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "started_at": { - "name": "started_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "last_played_at": { - "name": "last_played_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "completed_at": { - "name": "completed_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "watchlist_profile_pk_profiles_pk_fk": { - "name": "watchlist_profile_pk_profiles_pk_fk", - "tableFrom": "watchlist", - "tableTo": "profiles", - "schemaTo": "kyoo", - "columnsFrom": [ - "profile_pk" - ], - "columnsTo": [ - "pk" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "watchlist_show_pk_shows_pk_fk": { - "name": "watchlist_show_pk_shows_pk_fk", - "tableFrom": "watchlist", - "tableTo": "shows", - "schemaTo": "kyoo", - "columnsFrom": [ - "show_pk" - ], - "columnsTo": [ - "pk" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "watchlist_next_entry_entries_pk_fk": { - "name": "watchlist_next_entry_entries_pk_fk", - "tableFrom": "watchlist", - "tableTo": "entries", - "schemaTo": "kyoo", - "columnsFrom": [ - "next_entry" - ], - "columnsTo": [ - "pk" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "watchlist_profile_pk_show_pk_pk": { - "name": "watchlist_profile_pk_show_pk_pk", - "columns": [ - "profile_pk", - "show_pk" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "score_percent": { - "name": "score_percent", - "value": "\"kyoo\".\"watchlist\".\"score\" between 0 and 100" - } - }, - "isRLSEnabled": false - } - }, - "enums": { - "kyoo.entry_type": { - "name": "entry_type", - "schema": "kyoo", - "values": [ - "episode", - "movie", - "special", - "extra" - ] - }, - "kyoo.genres": { - "name": "genres", - "schema": "kyoo", - "values": [ - "action", - "adventure", - "animation", - "comedy", - "crime", - "documentary", - "drama", - "family", - "fantasy", - "history", - "horror", - "music", - "mystery", - "romance", - "science-fiction", - "thriller", - "war", - "western", - "kids", - "reality", - "politics", - "soap", - "talk" - ] - }, - "kyoo.show_kind": { - "name": "show_kind", - "schema": "kyoo", - "values": [ - "serie", - "movie", - "collection" - ] - }, - "kyoo.show_status": { - "name": "show_status", - "schema": "kyoo", - "values": [ - "unknown", - "finished", - "airing", - "planned" - ] - }, - "kyoo.role_kind": { - "name": "role_kind", - "schema": "kyoo", - "values": [ - "actor", - "director", - "writter", - "producer", - "music", - "crew", - "other" - ] - }, - "kyoo.watchlist_status": { - "name": "watchlist_status", - "schema": "kyoo", - "values": [ - "watching", - "rewatching", - "completed", - "dropped", - "planned" - ] - } - }, - "schemas": { - "kyoo": "kyoo" - }, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/api/drizzle/meta/_journal.json b/api/drizzle/meta/_journal.json index 5e8257f8..f380477f 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -176,13 +176,6 @@ "when": 1763932730557, "tag": "0024_fix-season-count", "breakpoints": true - }, - { - "idx": 25, - "version": "7", - "when": 1765791459003, - "tag": "0025_remove-history-entry", - "breakpoints": true } ] } diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index a29568ab..54c9092a 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -45,19 +45,18 @@ import { desc as description } from "~/models/utils/descriptions"; import type { EmbeddedVideo } from "~/models/video"; export const entryProgressQ = db - .selectDistinctOn([entryVideoJoin.entryPk], { + .selectDistinctOn([history.entryPk], { percent: history.percent, time: history.time, - entryPk: entryVideoJoin.entryPk, + entryPk: history.entryPk, playedDate: history.playedDate, videoId: videos.id, }) .from(history) - .innerJoin(videos, eq(history.videoPk, videos.pk)) - .innerJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk)) + .leftJoin(videos, eq(history.videoPk, videos.pk)) .innerJoin(profiles, eq(history.profilePk, profiles.pk)) .where(eq(profiles.id, sql.placeholder("userId"))) - .orderBy(entryVideoJoin.entryPk, desc(history.playedDate)) + .orderBy(history.entryPk, desc(history.playedDate)) .as("progress"); export const entryFilters: FilterDef = { diff --git a/api/src/controllers/profiles/history.ts b/api/src/controllers/profiles/history.ts index 2e54e5cb..71af81ad 100644 --- a/api/src/controllers/profiles/history.ts +++ b/api/src/controllers/profiles/history.ts @@ -8,19 +8,13 @@ import { lte, ne, sql, + TransactionRollbackError, } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import Elysia, { t } from "elysia"; import { auth, getUserInfo } from "~/auth"; import { db, type Transaction } from "~/db"; -import { - entries, - entryVideoJoin, - history, - profiles, - shows, - videos, -} from "~/db/schema"; +import { entries, history, profiles, shows, videos } from "~/db/schema"; import { watchlist } from "~/db/schema/watchlist"; import { coalesce, sqlarr } from "~/db/utils"; import { Entry } from "~/models/entry"; @@ -30,6 +24,7 @@ import { AcceptLanguage, createPage, Filter, + isUuid, Page, processLanguages, } from "~/models/utils"; @@ -45,19 +40,27 @@ import { import { getOrCreateProfile } from "./profile"; export async function updateProgress(userPk: number, progress: SeedHistory[]) { - return db.transaction(async (tx) => { - const hist = await updateHistory(tx, userPk, progress); - if (hist.created.length + hist.updated.length !== progress.length) { - tx.rollback(); - } - // only return new and entries whose status has changed. - // we don't need to update the watchlist every 10s when watching a video. - await updateWatchlist(tx, userPk, [ - ...hist.created, - ...hist.updated.filter((x) => x.percent >= 95), - ]); - return { status: 201, inserted: hist.created.length }; - }); + try { + return await db.transaction(async (tx) => { + const hist = await updateHistory(tx, userPk, progress); + if (hist.created.length + hist.updated.length !== progress.length) { + tx.rollback(); + } + // only return new and entries whose status has changed. + // we don't need to update the watchlist every 10s when watching a video. + await updateWatchlist(tx, userPk, [ + ...hist.created, + ...hist.updated.filter((x) => x.percent >= 95), + ]); + return { status: 201, inserted: hist.created.length } as const; + }); + } catch (e) { + if (!(e instanceof TransactionRollbackError)) throw e; + return { + status: 404, + message: "Invalid entry id/slug in progress array", + } as const; + } } async function updateHistory( @@ -86,7 +89,9 @@ async function updateHistory( progress.filter((x) => existing.includes(x.videoId)), ); const newEntries = traverse( - progress.filter((x) => !existing.includes(x.videoId)), + progress + .filter((x) => !existing.includes(x.videoId)) + .map((x) => ({ ...x, entryUseid: isUuid(x.entry) })), ); const updated = @@ -113,6 +118,7 @@ async function updateHistory( ), ) .returning({ + entryPk: history.entryPk, videoPk: history.videoPk, percent: history.percent, playedDate: history.playedDate, @@ -128,6 +134,7 @@ async function updateHistory( .select({ profilePk: sql`${userPk}`.as("profilePk"), videoPk: videos.pk, + entryPk: entries.pk, percent: sql`hist.percent`.as("percent"), time: sql`hist.ts`.as("time"), playedDate: coalesce(sql`hist.played_date`, sql`now()`).as( @@ -135,14 +142,26 @@ async function updateHistory( ), }) .from(sql`unnest( + ${sqlarr(newEntries.entry)}::text[], + ${sqlarr(newEntries.entryUseid)}::boolean[], ${sqlarr(newEntries.videoId)}::uuid[], ${sqlarr(newEntries.time)}::integer[], ${sqlarr(newEntries.percent)}::integer[], ${sqlarr(newEntries.playedDate)}::timestamptz[] - ) as hist(video_id, ts, percent, played_date)`) - .innerJoin(videos, eq(videos.id, sql`hist.video_id`)), + ) as hist(entry, entry_use_id, video_id, ts, percent, played_date)`) + .innerJoin( + entries, + sql` + case + when hist.entry_use_id then ${entries.id} = hist.entry::uuid + else ${entries.slug} = hist.entry + end + `, + ) + .leftJoin(videos, eq(videos.id, sql`hist.video_id`)), ) .returning({ + entryPk: history.entryPk, videoPk: history.videoPk, percent: history.percent, playedDate: history.playedDate, @@ -155,7 +174,11 @@ async function updateHistory( async function updateWatchlist( tx: Transaction, userPk: number, - histArr: { videoPk: number; percent: number; playedDate: string }[], + histArr: { + entryPk: number; + percent: number; + playedDate: string; + }[], ) { if (histArr.length === 0) return; @@ -186,14 +209,10 @@ async function updateWatchlist( db .select() .from(history) - .leftJoin( - entryVideoJoin, - eq(history.videoPk, entryVideoJoin.videoPk), - ) .where( and( eq(history.profilePk, userPk), - eq(entryVideoJoin.entryPk, entries.pk), + eq(history.entryPk, entries.pk), ), ), ), @@ -248,15 +267,11 @@ async function updateWatchlist( updatedAt: sql`now()`.as("updatedAt"), }) .from(sql`unnest( - ${sqlarr(hist.videoPk)}::integer[], + ${sqlarr(hist.entryPk)}::integer[], ${sqlarr(hist.percent)}::integer[], ${sqlarr(hist.playedDate)}::timestamptz[] - ) as hist(video_pk, percent, played_date)`) - .innerJoin( - entryVideoJoin, - eq(sql`hist.video_pk`, entryVideoJoin.videoPk), - ) - .leftJoin(entries, eq(entries.pk, entryVideoJoin.entryPk)) + ) as hist(entry_pk, percent, played_date)`) + .innerJoin(entries, eq(entries.pk, sql`hist.entry_pk`)) .leftJoinLateral(nextEntryQ, sql`true`), ) .onConflictDoUpdate({ @@ -297,13 +312,12 @@ const historyProgressQ: typeof entryProgressQ = db .select({ percent: history.percent, time: history.time, - entryPk: entryVideoJoin.entryPk, + entryPk: history.entryPk, playedDate: history.playedDate, videoId: videos.id, }) .from(history) - .innerJoin(videos, eq(history.videoPk, videos.pk)) - .innerJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk)) + .leftJoin(videos, eq(history.videoPk, videos.pk)) .innerJoin(profiles, eq(history.profilePk, profiles.pk)) .where(eq(profiles.id, sql.placeholder("userId"))) .as("progress"); @@ -437,11 +451,8 @@ export const historyH = new Elysia({ tags: ["profiles"] }) async ({ body, jwt: { sub }, status }) => { const profilePk = await getOrCreateProfile(sub); - return db.transaction(async (tx) => { - const hist = await updateHistory(tx, profilePk, body); - await updateWatchlist(tx, profilePk, hist); - return status(201, { status: 201, inserted: hist.length }); - }); + const ret = await updateProgress(profilePk, body); + return status(ret.status, ret); }, { detail: { description: "Bulk add entries/movies to your watch history." }, @@ -454,6 +465,10 @@ export const historyH = new Elysia({ tags: ["profiles"] }) description: "The number of history entry inserted", }), }), + 404: { + ...KError, + description: "No entry found with the given id or slug.", + }, 422: KError, }, }, diff --git a/api/src/db/schema/history.ts b/api/src/db/schema/history.ts index 2e01e0a4..3df6258d 100644 --- a/api/src/db/schema/history.ts +++ b/api/src/db/schema/history.ts @@ -1,5 +1,6 @@ import { sql } from "drizzle-orm"; import { check, index, integer } from "drizzle-orm/pg-core"; +import { entries } from "./entries"; import { profiles } from "./profiles"; import { schema, timestamp } from "./utils"; import { videos } from "./videos"; @@ -11,7 +12,12 @@ export const history = schema.table( profilePk: integer() .notNull() .references(() => profiles.pk, { onDelete: "cascade" }), - videoPk: integer().notNull().references(() => videos.pk, { onDelete: "cascade" }), + // we need to attach an history to an entry because we want to keep history + // when we delete a video file + entryPk: integer() + .notNull() + .references(() => entries.pk, { onDelete: "cascade" }), + videoPk: integer().references(() => videos.pk, { onDelete: "set null" }), percent: integer().notNull().default(0), time: integer().notNull().default(0), playedDate: timestamp({ withTimezone: true, mode: "iso" }) diff --git a/api/src/models/history.ts b/api/src/models/history.ts index de6189a9..34064ff0 100644 --- a/api/src/models/history.ts +++ b/api/src/models/history.ts @@ -27,10 +27,12 @@ export const Progress = t.Object({ }); export type Progress = typeof Progress.static; -export const SeedHistory = t.Object({ - percent: Progress.properties.percent, - time: Progress.properties.time, - playedDate: Progress.properties.playedDate, - videoId: Progress.properties.videoId.anyOf[0], -}); +export const SeedHistory = t.Intersect([ + Progress, + t.Object({ + entry: t.String({ + description: "Id or slug of the entry/movie you watched", + }), + }), +]); export type SeedHistory = typeof SeedHistory.static; diff --git a/api/src/websockets.ts b/api/src/websockets.ts index 881efaf5..10da619c 100644 --- a/api/src/websockets.ts +++ b/api/src/websockets.ts @@ -1,9 +1,8 @@ import type { TObject, TString } from "@sinclair/typebox"; import Elysia, { type TSchema, t } from "elysia"; import { verifyJwt } from "./auth"; -import { updateHistory, updateWatchlist } from "./controllers/profiles/history"; +import { updateProgress } from "./controllers/profiles/history"; import { getOrCreateProfile } from "./controllers/profiles/profile"; -import { db } from "./db"; import { SeedHistory } from "./models/history"; const actionMap = { @@ -18,13 +17,10 @@ const actionMap = { async message(ws, body) { const profilePk = await getOrCreateProfile(ws.data.jwt.sub); - await db.transaction(async (tx) => { - const hist = await updateHistory(tx, profilePk, [ - { ...body, playedDate: null }, - ]); - await updateWatchlist(tx, profilePk, hist); - }); - ws.send({ response: "ok" }); + const ret = await updateProgress(profilePk, [ + { ...body, playedDate: null }, + ]); + ws.send(ret); }, }), }; diff --git a/api/tests/series/history.test.ts b/api/tests/series/history.test.ts index 0f2556fe..12b7cc2b 100644 --- a/api/tests/series/history.test.ts +++ b/api/tests/series/history.test.ts @@ -11,12 +11,7 @@ import { import { expectStatus } from "tests/utils"; import { db } from "~/db"; import { entries, shows, videos } from "~/db/schema"; -import { - bubble, - bubbleVideo, - madeInAbyss, - madeInAbyssVideo, -} from "~/models/examples"; +import { bubble, madeInAbyss, madeInAbyssVideo } from "~/models/examples"; beforeAll(async () => { await db.delete(shows); @@ -40,13 +35,15 @@ describe("Set & get history", () => { const [r, b] = await addToHistory("me", [ { + entry: miaEntrySlug, videoId: madeInAbyssVideo.id, percent: 58, time: 28 * 60 + 12, playedDate: "2025-02-01", }, { - videoId: bubbleVideo.id, + entry: bubble.slug, + videoId: null, percent: 100, time: 2 * 60, playedDate: "2025-02-02", @@ -73,6 +70,7 @@ describe("Set & get history", () => { it("Create duplicated history entry", async () => { const [r, b] = await addToHistory("me", [ { + entry: miaEntrySlug!, videoId: madeInAbyssVideo.id, percent: 100, time: 38 * 60, diff --git a/api/tests/series/nextup.test.ts b/api/tests/series/nextup.test.ts index ea6724db..b10ef224 100644 --- a/api/tests/series/nextup.test.ts +++ b/api/tests/series/nextup.test.ts @@ -13,7 +13,7 @@ import { import { expectStatus } from "tests/utils"; import { db } from "~/db"; import { entries, shows, videos } from "~/db/schema"; -import { bubble, bubbleVideo, madeInAbyss, madeInAbyssVideo } from "~/models/examples"; +import { bubble, madeInAbyss, madeInAbyssVideo } from "~/models/examples"; beforeAll(async () => { await db.delete(shows); @@ -86,13 +86,15 @@ describe("nextup", () => { it("history watching doesn't update", async () => { let [resp, body] = await addToHistory("me", [ { + entry: miaEntrySlug, videoId: madeInAbyssVideo.id, percent: 58, time: 28 * 60 + 12, playedDate: "2025-02-01", }, { - videoId: bubbleVideo.id, + entry: bubble.slug, + videoId: null, percent: 100, time: 2 * 60, playedDate: "2025-02-02", @@ -137,6 +139,7 @@ describe("nextup", () => { it("history completed picks next", async () => { let [resp, body] = await addToHistory("me", [ { + entry: miaEntrySlug, videoId: madeInAbyssVideo.id, percent: 98, time: 28 * 60 + 12,