From 781a6a8196390487e1557193136fbf315c61e7c8 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 11 Mar 2025 14:37:26 +0100 Subject: [PATCH 01/15] Add history table --- api/README.md | 8 +++++--- api/src/db/schema/history.ts | 25 +++++++++++++++++++++++++ api/src/db/schema/index.ts | 2 ++ api/src/db/schema/profiles.ts | 6 ++++++ api/src/models/watchlist.ts | 10 ++++++++++ 5 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 api/src/db/schema/history.ts create mode 100644 api/src/db/schema/profiles.ts create mode 100644 api/src/models/watchlist.ts diff --git a/api/README.md b/api/README.md index 6faa62eb..734c44d9 100644 --- a/api/README.md +++ b/api/README.md @@ -116,13 +116,14 @@ erDiagram history { int id PK guid entry_id FK - guid user_id FK - uint time "in seconds, null of finished" - uint progress "NN, from 0 to 100" + guid profile_id FK + guid video_id FK + jsonb progress "{ percent, time }" datetime played_date } entries ||--|{ history : part_of users ||--|{ history : has + videos o|--o{ history : has roles { guid show_id PK, FK @@ -143,6 +144,7 @@ erDiagram jsonb external_id } staff ||--|{ roles : has + shows ||--|{ roles : has studios { guid id PK diff --git a/api/src/db/schema/history.ts b/api/src/db/schema/history.ts new file mode 100644 index 00000000..63fcddde --- /dev/null +++ b/api/src/db/schema/history.ts @@ -0,0 +1,25 @@ +import { index, integer, jsonb, timestamp } from "drizzle-orm/pg-core"; +import type { Progress } from "~/models/watchlist"; +import { entries } from "./entries"; +import { profiles } from "./profiles"; +import { schema } from "./utils"; +import { videos } from "./videos"; + +export const history = schema.table( + "history", + { + pk: integer().primaryKey().generatedAlwaysAsIdentity(), + profilePk: integer() + .notNull() + .references(() => profiles.pk, { onDelete: "cascade" }), + entryPk: integer() + .notNull() + .references(() => entries.pk, { onDelete: "cascade" }), + videoPk: integer() + .notNull() + .references(() => videos.pk, { onDelete: "set null" }), + progress: jsonb().$type(), + playedDate: timestamp({ mode: "string" }).notNull().defaultNow(), + }, + (t) => [index("history_play_date").on(t.playedDate.desc())], +); diff --git a/api/src/db/schema/index.ts b/api/src/db/schema/index.ts index 67f4e990..f1e91a59 100644 --- a/api/src/db/schema/index.ts +++ b/api/src/db/schema/index.ts @@ -4,4 +4,6 @@ export * from "./shows"; export * from "./studios"; export * from "./staff"; export * from "./videos"; +export * from "./profiles"; +export * from "./history"; export * from "./mqueue"; diff --git a/api/src/db/schema/profiles.ts b/api/src/db/schema/profiles.ts new file mode 100644 index 00000000..2296010a --- /dev/null +++ b/api/src/db/schema/profiles.ts @@ -0,0 +1,6 @@ +import { integer } from "drizzle-orm/pg-core"; +import { schema } from "./utils"; + +export const profiles = schema.table("profiles", { + pk: integer().primaryKey().generatedAlwaysAsIdentity(), +}); diff --git a/api/src/models/watchlist.ts b/api/src/models/watchlist.ts new file mode 100644 index 00000000..5cc8529a --- /dev/null +++ b/api/src/models/watchlist.ts @@ -0,0 +1,10 @@ +import { t } from "elysia"; + +export const Progress = t.Object({ + percent: t.Integer({ minimum: 0, maximum: 100 }), + time: t.Number({ + minimum: 0, + description: "When this episode was stopped (in seconds since the start", + }), +}); +export type Progress = typeof Progress.static; From 6ecaec2dee313b969421c93f5d31e778a0d5a929 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 11 Mar 2025 15:20:33 +0100 Subject: [PATCH 02/15] Add progress status in every entry --- api/src/controllers/entries.ts | 37 +++++++++++++++++++-------- api/src/db/schema/history.ts | 13 +++++++--- api/src/models/entry/episode.ts | 4 ++- api/src/models/entry/extra.ts | 10 +++++++- api/src/models/entry/movie-entry.ts | 2 ++ api/src/models/entry/special.ts | 2 ++ api/src/models/entry/unknown-entry.ts | 4 +++ api/src/models/watchlist.ts | 27 ++++++++++++++++--- 8 files changed, 79 insertions(+), 20 deletions(-) diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index b5546ca4..dc31f28a 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -1,10 +1,11 @@ -import { type SQL, and, eq, isNotNull, ne, sql } from "drizzle-orm"; +import { type SQL, and, desc, eq, isNotNull, ne, sql } from "drizzle-orm"; import { Elysia, t } from "elysia"; import { db } from "~/db"; import { entries, entryTranslations, entryVideoJoin, + history, shows, videos, } from "~/db/schema"; @@ -39,7 +40,7 @@ import { processLanguages, sortToSql, } from "~/models/utils"; -import { desc } from "~/models/utils/descriptions"; +import { desc as description } from "~/models/utils/descriptions"; import type { EmbeddedVideo } from "~/models/video"; const entryFilters: FilterDef = { @@ -149,6 +150,18 @@ async function getEntries({ .leftJoin(videos, eq(videos.pk, entryVideoJoin.videoPk)) .as("videos"); + const progressQ = db + .selectDistinctOn([history.entryPk], { + percent: history.percent, + time: history.time, + entryPk: history.entryPk, + videoId: videos.id, + }) + .from(history) + .leftJoin(videos, eq(history.videoPk, videos.pk)) + .orderBy(history.entryPk, desc(history.playedDate)) + .as("progress"); + const { kind, externalId, @@ -163,6 +176,9 @@ async function getEntries({ ...entryCol, ...transCol, videos: videosQ.videos, + progress: { + ...getColumns(progressQ), + }, // specials don't have an `episodeNumber` but a `number` field. number: episodeNumber, @@ -181,6 +197,7 @@ async function getEntries({ .from(entries) .innerJoin(transQ, eq(entries.pk, transQ.pk)) .leftJoinLateral(videosQ, sql`true`) + .leftJoin(progressQ, eq(entries.pk, progressQ.entryPk)) .where( and( filter, @@ -265,14 +282,14 @@ export const entriesH = new Elysia({ tags: ["series"] }) query: t.Object({ sort: entrySort, filter: t.Optional(Filter({ def: entryFilters })), - query: t.Optional(t.String({ description: desc.query })), + query: t.Optional(t.String({ description: description.query })), limit: t.Integer({ minimum: 1, maximum: 250, default: 50, description: "Max page size.", }), - after: t.Optional(t.String({ description: desc.after })), + after: t.Optional(t.String({ description: description.after })), }), headers: t.Object( { @@ -342,14 +359,14 @@ export const entriesH = new Elysia({ tags: ["series"] }) query: t.Object({ sort: extraSort, filter: t.Optional(Filter({ def: extraFilters })), - query: t.Optional(t.String({ description: desc.query })), + query: t.Optional(t.String({ description: description.query })), limit: t.Integer({ minimum: 1, maximum: 250, default: 50, description: "Max page size.", }), - after: t.Optional(t.String({ description: desc.after })), + after: t.Optional(t.String({ description: description.after })), }), response: { 200: Page(Extra), @@ -383,14 +400,14 @@ export const entriesH = new Elysia({ tags: ["series"] }) query: t.Object({ sort: extraSort, filter: t.Optional(Filter({ def: unknownFilters })), - query: t.Optional(t.String({ description: desc.query })), + query: t.Optional(t.String({ description: description.query })), limit: t.Integer({ minimum: 1, maximum: 250, default: 50, description: "Max page size.", }), - after: t.Optional(t.String({ description: desc.after })), + after: t.Optional(t.String({ description: description.after })), }), response: { 200: Page(UnknownEntry), @@ -423,14 +440,14 @@ export const entriesH = new Elysia({ tags: ["series"] }) detail: { description: "Get new movies/episodes added recently." }, query: t.Object({ filter: t.Optional(Filter({ def: entryFilters })), - query: t.Optional(t.String({ description: desc.query })), + query: t.Optional(t.String({ description: description.query })), limit: t.Integer({ minimum: 1, maximum: 250, default: 50, description: "Max page size.", }), - after: t.Optional(t.String({ description: desc.after })), + after: t.Optional(t.String({ description: description.after })), }), response: { 200: Page(Entry), diff --git a/api/src/db/schema/history.ts b/api/src/db/schema/history.ts index 63fcddde..4f7211e2 100644 --- a/api/src/db/schema/history.ts +++ b/api/src/db/schema/history.ts @@ -1,5 +1,5 @@ -import { index, integer, jsonb, timestamp } from "drizzle-orm/pg-core"; -import type { Progress } from "~/models/watchlist"; +import { sql } from "drizzle-orm"; +import { check, index, integer, jsonb, timestamp } from "drizzle-orm/pg-core"; import { entries } from "./entries"; import { profiles } from "./profiles"; import { schema } from "./utils"; @@ -18,8 +18,13 @@ export const history = schema.table( videoPk: integer() .notNull() .references(() => videos.pk, { onDelete: "set null" }), - progress: jsonb().$type(), + percent: integer().notNull().default(0), + time: integer(), playedDate: timestamp({ mode: "string" }).notNull().defaultNow(), }, - (t) => [index("history_play_date").on(t.playedDate.desc())], + (t) => [ + index("history_play_date").on(t.playedDate.desc()), + + check("percent_valid", sql`${t.percent} between 0 and 100`), + ], ); diff --git a/api/src/models/entry/episode.ts b/api/src/models/entry/episode.ts index 05478465..6012f126 100644 --- a/api/src/models/entry/episode.ts +++ b/api/src/models/entry/episode.ts @@ -10,6 +10,7 @@ import { } from "../utils"; import { EmbeddedVideo } from "../video"; import { BaseEntry, EntryTranslation } from "./base-entry"; +import { Progress } from "../watchlist"; export const BaseEpisode = t.Intersect([ t.Object({ @@ -27,7 +28,8 @@ export const Episode = t.Intersect([ EntryTranslation(), BaseEpisode, t.Object({ - videos: t.Optional(t.Array(EmbeddedVideo)), + videos: t.Array(EmbeddedVideo), + progress: Progress, }), DbMetadata, ]); diff --git a/api/src/models/entry/extra.ts b/api/src/models/entry/extra.ts index 1d033753..d6ce8758 100644 --- a/api/src/models/entry/extra.ts +++ b/api/src/models/entry/extra.ts @@ -4,6 +4,7 @@ import { madeInAbyss, registerExamples } from "../examples"; import { DbMetadata, SeedImage } from "../utils"; import { Resource } from "../utils/resource"; import { BaseEntry } from "./base-entry"; +import { Progress } from "../watchlist"; export const ExtraType = t.UnionEnum([ "other", @@ -31,7 +32,14 @@ export const BaseExtra = t.Intersect( }, ); -export const Extra = t.Intersect([Resource(), BaseExtra, DbMetadata]); +export const Extra = t.Intersect([ + Resource(), + BaseExtra, + t.Object({ + progress: t.Omit(Progress, ["videoId"]), + }), + DbMetadata, +]); export type Extra = Prettify; export const SeedExtra = t.Intersect([ diff --git a/api/src/models/entry/movie-entry.ts b/api/src/models/entry/movie-entry.ts index ceab00c4..a8215db2 100644 --- a/api/src/models/entry/movie-entry.ts +++ b/api/src/models/entry/movie-entry.ts @@ -11,6 +11,7 @@ import { } from "../utils"; import { EmbeddedVideo } from "../video"; import { BaseEntry, EntryTranslation } from "./base-entry"; +import { Progress } from "../watchlist"; export const BaseMovieEntry = t.Intersect( [ @@ -46,6 +47,7 @@ export const MovieEntry = t.Intersect([ BaseMovieEntry, t.Object({ videos: t.Optional(t.Array(EmbeddedVideo)), + progress: Progress, }), DbMetadata, ]); diff --git a/api/src/models/entry/special.ts b/api/src/models/entry/special.ts index c062f6d4..70a6191c 100644 --- a/api/src/models/entry/special.ts +++ b/api/src/models/entry/special.ts @@ -10,6 +10,7 @@ import { } from "../utils"; import { EmbeddedVideo } from "../video"; import { BaseEntry, EntryTranslation } from "./base-entry"; +import { Progress } from "../watchlist"; export const BaseSpecial = t.Intersect( [ @@ -38,6 +39,7 @@ export const Special = t.Intersect([ BaseSpecial, t.Object({ videos: t.Optional(t.Array(EmbeddedVideo)), + progress: Progress, }), DbMetadata, ]); diff --git a/api/src/models/entry/unknown-entry.ts b/api/src/models/entry/unknown-entry.ts index efe1d380..22600c80 100644 --- a/api/src/models/entry/unknown-entry.ts +++ b/api/src/models/entry/unknown-entry.ts @@ -2,6 +2,7 @@ import { t } from "elysia"; import { type Prettify, comment } from "~/utils"; import { bubbleImages, registerExamples, youtubeExample } from "../examples"; import { DbMetadata, Resource } from "../utils"; +import { Progress } from "../watchlist"; import { BaseEntry, EntryTranslation } from "./base-entry"; export const BaseUnknownEntry = t.Intersect( @@ -27,6 +28,9 @@ export const UnknownEntry = t.Intersect([ Resource(), UnknownEntryTranslation, BaseUnknownEntry, + t.Object({ + progress: t.Omit(Progress, ["videoId"]), + }), DbMetadata, ]); export type UnknownEntry = Prettify; diff --git a/api/src/models/watchlist.ts b/api/src/models/watchlist.ts index 5cc8529a..a70924cf 100644 --- a/api/src/models/watchlist.ts +++ b/api/src/models/watchlist.ts @@ -1,10 +1,29 @@ import { t } from "elysia"; +import { comment } from "~/utils"; export const Progress = t.Object({ percent: t.Integer({ minimum: 0, maximum: 100 }), - time: t.Number({ - minimum: 0, - description: "When this episode was stopped (in seconds since the start", - }), + time: t.Nullable( + t.Integer({ + minimum: 0, + description: comment` + When this episode was stopped (in seconds since the start). + This value is null if the entry was never watched or is finished. + `, + }), + ), + videoId: t.Nullable( + t.String({ + format: "uuid", + description: comment` + Id of the video the user watched. + This can be used to resume playback in the correct video file + without asking the user what video to play. + + This will be null if the user did not watch the entry or + if the video was deleted since. + `, + }), + ), }); export type Progress = typeof Progress.static; From be2e5e5ccf297c03bc999224e54b48903900b6d1 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 11 Mar 2025 15:26:10 +0100 Subject: [PATCH 03/15] Fix drizzle patch for lateral join --- api/patches/drizzle-orm@0.39.0.patch | 61 +++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/api/patches/drizzle-orm@0.39.0.patch b/api/patches/drizzle-orm@0.39.0.patch index 236d1c90..2d9da231 100644 --- a/api/patches/drizzle-orm@0.39.0.patch +++ b/api/patches/drizzle-orm@0.39.0.patch @@ -1,3 +1,6 @@ +diff --git a/node_modules/drizzle-orm/.bun-tag-36446a2521398ee8 b/.bun-tag-36446a2521398ee8 +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/node_modules/drizzle-orm/.bun-tag-9fae835e61d5cc75 b/.bun-tag-9fae835e61d5cc75 new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 @@ -53,12 +56,68 @@ index b968ebb3f563f37c8c36221dd17cc6f3603270ec..3fda6d0a97997f6bd07ec6a0c83397c0 * ``` */ - fullJoin: PgSelectJoinFn; ++ fullJoin: PgSelectJoinFn; + private createSetOperator; + /** + * Adds `union` set operator to the query. +diff --git a/pg-core/query-builders/select.d.ts b/pg-core/query-builders/select.d.ts +index d44256289ffe7bd19d3f3af98cbd9ba0fc7efc57..f106eb28a919e0182f833632ace36ea7f87f9a88 100644 +--- a/pg-core/query-builders/select.d.ts ++++ b/pg-core/query-builders/select.d.ts +@@ -98,7 +98,16 @@ export declare abstract class PgSelectQueryBuilderBase; ++ leftJoin: PgSelectJoinFn; ++ /** ++ * For each row of the table, include ++ * values from a matching row of the joined ++ * subquery, if there is a matching row. If not, ++ * all of the columns of the joined subquery ++ * will be set to null. The lateral keyword allows ++ * access to columns after the FROM statement. ++ */ ++ leftJoinLateral: PgSelectJoinFn; + /** + * Executes a `right join` operation by adding another table to the current query. + * +@@ -126,7 +135,7 @@ export declare abstract class PgSelectQueryBuilderBase; ++ rightJoin: PgSelectJoinFn; + /** + * Executes an `inner join` operation, creating a new table by combining rows from two tables that have matching values. + * +@@ -154,7 +163,14 @@ export declare abstract class PgSelectQueryBuilderBase; ++ innerJoin: PgSelectJoinFn; ++ /** ++ * For each row of the table, the joined subquery ++ * needs to have a matching row, or it will ++ * be excluded from results. The lateral keyword allows ++ * access to columns after the FROM statement. ++ */ ++ innerJoinLateral: PgSelectJoinFn; + /** + * Executes a `full join` operation by combining rows from two tables into a new table. + * +@@ -182,7 +198,7 @@ export declare abstract class PgSelectQueryBuilderBase; + fullJoin: PgSelectJoinFn; private createSetOperator; /** * Adds `union` set operator to the query. diff --git a/pg-core/query-builders/select.js b/pg-core/query-builders/select.js -index e54406fcaf68ccfdaf32c8945d4d432212c4cf3f..0441be1e483a7ec02430978b5fac5bf6d863ffc7 100644 +index e54406fcaf68ccfdaf32c8945d4d432212c4cf3f..5c514132f30366ee600b9530c284932d54f481f3 100644 --- a/pg-core/query-builders/select.js +++ b/pg-core/query-builders/select.js @@ -98,7 +98,7 @@ class PgSelectQueryBuilderBase extends TypedQueryBuilder { From 31a749b5ed70b4ad15f38adae2c03364d16185fc Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 11 Mar 2025 15:30:18 +0100 Subject: [PATCH 04/15] Add progress in `/series/:id?width=firstEntry` --- api/src/controllers/entries.ts | 7 ++++--- api/src/controllers/shows/logic.ts | 27 ++++++++++++++++++++++++--- api/src/db/schema/history.ts | 2 +- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index dc31f28a..22f0d25e 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -114,6 +114,7 @@ async function getEntries({ sort, filter, languages, + userId, }: { after: string | undefined; limit: number; @@ -121,6 +122,7 @@ async function getEntries({ sort: Sort; filter: SQL | undefined; languages: string[]; + userId: number; }): Promise<(Entry | Extra | UnknownEntry)[]> { const transQ = db .selectDistinctOn([entryTranslations.pk]) @@ -158,6 +160,7 @@ async function getEntries({ videoId: videos.id, }) .from(history) + .where(eq(history.profilePk, userId)) .leftJoin(videos, eq(history.videoPk, videos.pk)) .orderBy(history.entryPk, desc(history.playedDate)) .as("progress"); @@ -176,9 +179,7 @@ async function getEntries({ ...entryCol, ...transCol, videos: videosQ.videos, - progress: { - ...getColumns(progressQ), - }, + progress: getColumns(progressQ), // specials don't have an `episodeNumber` but a `number` field. number: episodeNumber, diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index 4d743df2..13c1d1cb 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -1,9 +1,10 @@ -import { type SQL, and, eq, exists, ne, sql } from "drizzle-orm"; +import { type SQL, and, desc, eq, exists, ne, sql } from "drizzle-orm"; import { db } from "~/db"; import { entries, entryTranslations, entryVideoJoin, + history, showStudioJoin, showTranslations, shows, @@ -144,7 +145,10 @@ const showRelations = { .leftJoin(videos, eq(videos.pk, entryVideoJoin.videoPk)) .as("videos"); }, - firstEntry: ({ languages }: { languages: string[] }) => { + firstEntry: ({ + languages, + userId, + }: { languages: string[]; userId: number }) => { const transQ = db .selectDistinctOn([entryTranslations.pk]) .from(entryTranslations) @@ -173,6 +177,19 @@ const showRelations = { .leftJoin(videos, eq(videos.pk, entryVideoJoin.videoPk)) .as("videos"); + const progressQ = db + .selectDistinctOn([history.entryPk], { + percent: history.percent, + time: history.time, + entryPk: history.entryPk, + videoId: videos.id, + }) + .from(history) + .where(eq(history.profilePk, userId)) + .leftJoin(videos, eq(history.videoPk, videos.pk)) + .orderBy(history.entryPk, desc(history.playedDate)) + .as("progress"); + return db .select({ firstEntry: jsonbBuildObject({ @@ -180,10 +197,12 @@ const showRelations = { ...transCol, number: entries.episodeNumber, videos: videosQ.videos, + progress: getColumns(progressQ), }).as("firstEntry"), }) .from(entries) .innerJoin(transQ, eq(entries.pk, transQ.pk)) + .leftJoin(progressQ, eq(entries.pk, progressQ.entryPk)) .leftJoinLateral(videosQ, sql`true`) .where(and(eq(entries.showPk, shows.pk), ne(entries.kind, "extra"))) .orderBy(entries.order) @@ -202,6 +221,7 @@ export async function getShows({ fallbackLanguage = true, preferOriginal = false, relations = [], + userId, }: { after?: string; limit: number; @@ -212,6 +232,7 @@ export async function getShows({ fallbackLanguage?: boolean; preferOriginal?: boolean; relations?: (keyof typeof showRelations)[]; + userId: number; }) { const transQ = db .selectDistinctOn([showTranslations.pk]) @@ -245,7 +266,7 @@ export async function getShows({ logo: sql`coalesce(nullif(${shows.original}->'logo', 'null'::jsonb), ${transQ.logo})`, }), - ...buildRelations(relations, showRelations, { languages }), + ...buildRelations(relations, showRelations, { languages, userId }), }) .from(shows) [fallbackLanguage ? "innerJoin" : ("leftJoin" as "innerJoin")]( diff --git a/api/src/db/schema/history.ts b/api/src/db/schema/history.ts index 4f7211e2..823e098a 100644 --- a/api/src/db/schema/history.ts +++ b/api/src/db/schema/history.ts @@ -1,5 +1,5 @@ import { sql } from "drizzle-orm"; -import { check, index, integer, jsonb, timestamp } from "drizzle-orm/pg-core"; +import { check, index, integer, timestamp } from "drizzle-orm/pg-core"; import { entries } from "./entries"; import { profiles } from "./profiles"; import { schema } from "./utils"; From e489d0c445634194f51b3c2ce5cdb47351c59595 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 11 Mar 2025 16:34:00 +0100 Subject: [PATCH 05/15] Add watchlist table --- api/README.md | 14 +++++----- api/src/db/schema/history.ts | 4 ++- api/src/db/schema/watchlist.ts | 50 ++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 8 deletions(-) create mode 100644 api/src/db/schema/watchlist.ts diff --git a/api/README.md b/api/README.md index 734c44d9..28f781d2 100644 --- a/api/README.md +++ b/api/README.md @@ -63,14 +63,14 @@ erDiagram } entries ||--|{ entry_translations : has - video { + videos { guid id PK string path "NN" uint rendering "dedup for duplicates part1/2" uint part uint version "max version is preferred rendering" } - video }|--|{ entries : for + videos }|--|{ entries : for seasons { guid id PK @@ -102,16 +102,16 @@ erDiagram guid id PK } - watched_shows { + watchlist { guid show_id PK, FK guid user_id PK, FK - status status "completed|watching|dropped|planned" + status status "completed|watching|rewatching|dropped|planned" uint seen_entry_count "NN" guid next_entry FK } - shows ||--|{ watched_shows : has - users ||--|{ watched_shows : has - watched_shows ||--|o entries : next_entry + shows ||--|{ watchlist : has + users ||--|{ watchlist : has + watchlist ||--|o entries : next_entry history { int id PK diff --git a/api/src/db/schema/history.ts b/api/src/db/schema/history.ts index 823e098a..487d8776 100644 --- a/api/src/db/schema/history.ts +++ b/api/src/db/schema/history.ts @@ -20,7 +20,9 @@ export const history = schema.table( .references(() => videos.pk, { onDelete: "set null" }), percent: integer().notNull().default(0), time: integer(), - playedDate: timestamp({ mode: "string" }).notNull().defaultNow(), + playedDate: timestamp({ withTimezone: true, mode: "string" }) + .notNull() + .defaultNow(), }, (t) => [ index("history_play_date").on(t.playedDate.desc()), diff --git a/api/src/db/schema/watchlist.ts b/api/src/db/schema/watchlist.ts new file mode 100644 index 00000000..0699134f --- /dev/null +++ b/api/src/db/schema/watchlist.ts @@ -0,0 +1,50 @@ +import { sql } from "drizzle-orm"; +import { + check, + integer, + primaryKey, + text, + timestamp, +} from "drizzle-orm/pg-core"; +import { entries } from "./entries"; +import { profiles } from "./profiles"; +import { shows } from "./shows"; +import { schema } from "./utils"; + +export const watchlistStatus = schema.enum("watchlist_status", [ + "completed", + "watching", + "rewatching", + "dropped", + "planned", +]); + +export const watchlist = schema.table( + "watchlist", + { + profilePk: integer() + .notNull() + .references(() => profiles.pk, { onDelete: "cascade" }), + showPk: integer() + .notNull() + .references(() => shows.pk, { onDelete: "cascade" }), + + status: watchlistStatus().notNull(), + seenCount: integer().notNull().default(0), + nextEntry: integer().references(() => entries.pk, { onDelete: "set null" }), + + score: integer(), + notes: text(), + + createdAt: timestamp({ withTimezone: true, mode: "string" }) + .notNull() + .defaultNow(), + updatedAt: timestamp({ withTimezone: true, mode: "string" }) + .notNull() + .$onUpdate(() => sql`now()`), + }, + (t) => [ + primaryKey({ columns: [t.profilePk, t.showPk] }), + check("score_percent", sql`${t.score} between 0 and 100`), + ], +); From 32cc6e79103e20f5cca0e0979efd24476154dd7c Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 11 Mar 2025 17:16:56 +0100 Subject: [PATCH 06/15] Add watch status type in movies/series --- api/src/controllers/shows/logic.ts | 10 ++++++++++ api/src/db/schema/watchlist.ts | 12 ++++-------- api/src/models/movie.ts | 2 ++ api/src/models/serie.ts | 2 ++ api/src/models/watchlist.ts | 19 +++++++++++++++++++ 5 files changed, 37 insertions(+), 8 deletions(-) diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index 13c1d1cb..376b7b53 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -12,6 +12,7 @@ import { studios, videos, } from "~/db/schema"; +import { watchlist } from "~/db/schema/watchlist"; import { coalesce, getColumns, @@ -248,6 +249,12 @@ export async function getShows({ ) .as("t"); + const watchStatusQ = db + .select() + .from(watchlist) + .where(eq(watchlist.profilePk, userId)) + .as("watchstatus"); + return await db .select({ ...getColumns(shows), @@ -266,9 +273,12 @@ export async function getShows({ logo: sql`coalesce(nullif(${shows.original}->'logo', 'null'::jsonb), ${transQ.logo})`, }), + watchStatus: getColumns(watchStatusQ), + ...buildRelations(relations, showRelations, { languages, userId }), }) .from(shows) + .leftJoin(watchStatusQ, eq(shows.pk, watchStatusQ.showPk)) [fallbackLanguage ? "innerJoin" : ("leftJoin" as "innerJoin")]( transQ, eq(shows.pk, transQ.pk), diff --git a/api/src/db/schema/watchlist.ts b/api/src/db/schema/watchlist.ts index 0699134f..a00d7967 100644 --- a/api/src/db/schema/watchlist.ts +++ b/api/src/db/schema/watchlist.ts @@ -1,11 +1,5 @@ import { sql } from "drizzle-orm"; -import { - check, - integer, - primaryKey, - text, - timestamp, -} from "drizzle-orm/pg-core"; +import { check, integer, primaryKey, timestamp } from "drizzle-orm/pg-core"; import { entries } from "./entries"; import { profiles } from "./profiles"; import { shows } from "./shows"; @@ -34,7 +28,9 @@ export const watchlist = schema.table( nextEntry: integer().references(() => entries.pk, { onDelete: "set null" }), score: integer(), - notes: text(), + + startedAt: timestamp({ withTimezone: true, mode: "string" }), + completedAt: timestamp({ withTimezone: true, mode: "string" }), createdAt: timestamp({ withTimezone: true, mode: "string" }) .notNull() diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index 326f203a..4110f000 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -16,6 +16,7 @@ import { } from "./utils"; import { Original } from "./utils/original"; import { EmbeddedVideo } from "./video"; +import { WatchStatus } from "./watchlist"; export const MovieStatus = t.UnionEnum(["unknown", "finished", "planned"]); export type MovieStatus = typeof MovieStatus.static; @@ -55,6 +56,7 @@ export const Movie = t.Intersect([ t.Object({ original: Original, isAvailable: t.Boolean(), + watchStatus: t.Omit(WatchStatus, ["seenCount"]), }), ]); export type Movie = Prettify; diff --git a/api/src/models/serie.ts b/api/src/models/serie.ts index 1b664bbc..a24fcec9 100644 --- a/api/src/models/serie.ts +++ b/api/src/models/serie.ts @@ -17,6 +17,7 @@ import { TranslationRecord, } from "./utils"; import { Original } from "./utils/original"; +import { WatchStatus } from "./watchlist"; export const SerieStatus = t.UnionEnum([ "unknown", @@ -70,6 +71,7 @@ export const Serie = t.Intersect([ availableCount: t.Integer({ description: "The number of episodes that can be played right away", }), + watchStatus: WatchStatus, }), ]); export type Serie = Prettify; diff --git a/api/src/models/watchlist.ts b/api/src/models/watchlist.ts index a70924cf..daf4ca49 100644 --- a/api/src/models/watchlist.ts +++ b/api/src/models/watchlist.ts @@ -27,3 +27,22 @@ export const Progress = t.Object({ ), }); export type Progress = typeof Progress.static; + +export const WatchlistStatus = t.UnionEnum([ + "completed", + "watching", + "rewatching", + "dropped", + "planned", +]); + +export const WatchStatus = t.Object({ + status: WatchlistStatus, + score: t.Nullable(t.Integer({ minimum: 0, maximum: 100 })), + startedAt: t.Nullable(t.String({ format: "date-time" })), + completedAt: t.Nullable(t.String({ format: "date-time" })), + seenCount: t.Integer({ + description: "The number of episodes you watched in this serie.", + minimum: 0, + }), +}); From be35a4f0d9c2e36ab5c4e5fd5a60b58ef8e5daff Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 12 Mar 2025 09:36:48 +0100 Subject: [PATCH 07/15] Add percent in movie's watchlist --- api/src/controllers/shows/logic.ts | 5 ++++- api/src/models/entry/episode.ts | 2 +- api/src/models/entry/extra.ts | 2 +- api/src/models/entry/movie-entry.ts | 2 +- api/src/models/entry/special.ts | 2 +- api/src/models/serie.ts | 2 +- api/src/models/watchlist.ts | 6 ++++++ 7 files changed, 15 insertions(+), 6 deletions(-) diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index 376b7b53..b3ae221b 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -250,7 +250,10 @@ export async function getShows({ .as("t"); const watchStatusQ = db - .select() + .select({ + ...getColumns(watchlist), + percent: watchlist.seenCount, + }) .from(watchlist) .where(eq(watchlist.profilePk, userId)) .as("watchstatus"); diff --git a/api/src/models/entry/episode.ts b/api/src/models/entry/episode.ts index 6012f126..aea7264a 100644 --- a/api/src/models/entry/episode.ts +++ b/api/src/models/entry/episode.ts @@ -9,8 +9,8 @@ import { TranslationRecord, } from "../utils"; import { EmbeddedVideo } from "../video"; -import { BaseEntry, EntryTranslation } from "./base-entry"; import { Progress } from "../watchlist"; +import { BaseEntry, EntryTranslation } from "./base-entry"; export const BaseEpisode = t.Intersect([ t.Object({ diff --git a/api/src/models/entry/extra.ts b/api/src/models/entry/extra.ts index d6ce8758..5fe5312a 100644 --- a/api/src/models/entry/extra.ts +++ b/api/src/models/entry/extra.ts @@ -3,8 +3,8 @@ import { type Prettify, comment } from "~/utils"; import { madeInAbyss, registerExamples } from "../examples"; import { DbMetadata, SeedImage } from "../utils"; import { Resource } from "../utils/resource"; -import { BaseEntry } from "./base-entry"; import { Progress } from "../watchlist"; +import { BaseEntry } from "./base-entry"; export const ExtraType = t.UnionEnum([ "other", diff --git a/api/src/models/entry/movie-entry.ts b/api/src/models/entry/movie-entry.ts index a8215db2..ab5e863c 100644 --- a/api/src/models/entry/movie-entry.ts +++ b/api/src/models/entry/movie-entry.ts @@ -10,8 +10,8 @@ import { TranslationRecord, } from "../utils"; import { EmbeddedVideo } from "../video"; -import { BaseEntry, EntryTranslation } from "./base-entry"; import { Progress } from "../watchlist"; +import { BaseEntry, EntryTranslation } from "./base-entry"; export const BaseMovieEntry = t.Intersect( [ diff --git a/api/src/models/entry/special.ts b/api/src/models/entry/special.ts index 70a6191c..d34f5c76 100644 --- a/api/src/models/entry/special.ts +++ b/api/src/models/entry/special.ts @@ -9,8 +9,8 @@ import { TranslationRecord, } from "../utils"; import { EmbeddedVideo } from "../video"; -import { BaseEntry, EntryTranslation } from "./base-entry"; import { Progress } from "../watchlist"; +import { BaseEntry, EntryTranslation } from "./base-entry"; export const BaseSpecial = t.Intersect( [ diff --git a/api/src/models/serie.ts b/api/src/models/serie.ts index a24fcec9..22e894ce 100644 --- a/api/src/models/serie.ts +++ b/api/src/models/serie.ts @@ -71,7 +71,7 @@ export const Serie = t.Intersect([ availableCount: t.Integer({ description: "The number of episodes that can be played right away", }), - watchStatus: WatchStatus, + watchStatus: t.Omit(WatchStatus, ["percent"]), }), ]); export type Serie = Prettify; diff --git a/api/src/models/watchlist.ts b/api/src/models/watchlist.ts index daf4ca49..0d5037b9 100644 --- a/api/src/models/watchlist.ts +++ b/api/src/models/watchlist.ts @@ -41,8 +41,14 @@ export const WatchStatus = t.Object({ score: t.Nullable(t.Integer({ minimum: 0, maximum: 100 })), startedAt: t.Nullable(t.String({ format: "date-time" })), completedAt: t.Nullable(t.String({ format: "date-time" })), + // only for series seenCount: t.Integer({ description: "The number of episodes you watched in this serie.", minimum: 0, }), + // only for movies + percent: t.Integer({ + minimum: 0, + maximum: 100, + }), }); From 1943eca52ba343798f6d471ce094b89311024473 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 12 Mar 2025 09:49:58 +0100 Subject: [PATCH 08/15] Use the same subquery (videos/progress) for entries & firstEntry --- api/src/controllers/entries.ts | 70 +++++++++++++++--------------- api/src/controllers/shows/logic.ts | 36 ++------------- 2 files changed, 40 insertions(+), 66 deletions(-) diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index 22f0d25e..7d2e5eb7 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -106,6 +106,37 @@ const newsSort: Sort = { }, ], }; +const { guess, createdAt, updatedAt, ...videosCol } = getColumns(videos); +export const entryVideosQ = db + .select({ + videos: coalesce( + jsonbAgg( + jsonbBuildObject({ + slug: entryVideoJoin.slug, + ...videosCol, + }), + ), + sql`'[]'::jsonb`, + ).as("videos"), + }) + .from(entryVideoJoin) + .where(eq(entryVideoJoin.entryPk, entries.pk)) + .leftJoin(videos, eq(videos.pk, entryVideoJoin.videoPk)) + .as("videos"); + +export const getEntryProgressQ = (userId: number) => + db + .selectDistinctOn([history.entryPk], { + percent: history.percent, + time: history.time, + entryPk: history.entryPk, + videoId: videos.id, + }) + .from(history) + .where(eq(history.profilePk, userId)) + .leftJoin(videos, eq(history.videoPk, videos.pk)) + .orderBy(history.entryPk, desc(history.playedDate)) + .as("progress"); async function getEntries({ after, @@ -134,36 +165,7 @@ async function getEntries({ .as("t"); const { pk, name, ...transCol } = getColumns(transQ); - const { guess, createdAt, updatedAt, ...videosCol } = getColumns(videos); - const videosQ = db - .select({ - videos: coalesce( - jsonbAgg( - jsonbBuildObject({ - slug: entryVideoJoin.slug, - ...videosCol, - }), - ), - sql`'[]'::jsonb`, - ).as("videos"), - }) - .from(entryVideoJoin) - .where(eq(entryVideoJoin.entryPk, entries.pk)) - .leftJoin(videos, eq(videos.pk, entryVideoJoin.videoPk)) - .as("videos"); - - const progressQ = db - .selectDistinctOn([history.entryPk], { - percent: history.percent, - time: history.time, - entryPk: history.entryPk, - videoId: videos.id, - }) - .from(history) - .where(eq(history.profilePk, userId)) - .leftJoin(videos, eq(history.videoPk, videos.pk)) - .orderBy(history.entryPk, desc(history.playedDate)) - .as("progress"); + const entryProgressQ = getEntryProgressQ(userId); const { kind, @@ -178,8 +180,8 @@ async function getEntries({ .select({ ...entryCol, ...transCol, - videos: videosQ.videos, - progress: getColumns(progressQ), + videos: entryVideosQ.videos, + progress: getColumns(entryProgressQ), // specials don't have an `episodeNumber` but a `number` field. number: episodeNumber, @@ -197,8 +199,8 @@ async function getEntries({ }) .from(entries) .innerJoin(transQ, eq(entries.pk, transQ.pk)) - .leftJoinLateral(videosQ, sql`true`) - .leftJoin(progressQ, eq(entries.pk, progressQ.entryPk)) + .leftJoinLateral(entryVideosQ, sql`true`) + .leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk)) .where( and( filter, diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index b3ae221b..c9932a09 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -35,6 +35,7 @@ import { sortToSql, } from "~/models/utils"; import type { EmbeddedVideo } from "~/models/video"; +import { entryVideosQ, getEntryProgressQ } from "../entries"; export const showFilters: FilterDef = { genres: { @@ -160,36 +161,7 @@ const showRelations = { .as("t"); const { pk, ...transCol } = getColumns(transQ); - const { guess, createdAt, updatedAt, ...videosCol } = getColumns(videos); - const videosQ = db - .select({ - videos: coalesce( - jsonbAgg( - jsonbBuildObject({ - slug: entryVideoJoin.slug, - ...videosCol, - }), - ), - sql`'[]'::jsonb`, - ).as("videos"), - }) - .from(entryVideoJoin) - .where(eq(entryVideoJoin.entryPk, entries.pk)) - .leftJoin(videos, eq(videos.pk, entryVideoJoin.videoPk)) - .as("videos"); - - const progressQ = db - .selectDistinctOn([history.entryPk], { - percent: history.percent, - time: history.time, - entryPk: history.entryPk, - videoId: videos.id, - }) - .from(history) - .where(eq(history.profilePk, userId)) - .leftJoin(videos, eq(history.videoPk, videos.pk)) - .orderBy(history.entryPk, desc(history.playedDate)) - .as("progress"); + const progressQ = getEntryProgressQ(userId); return db .select({ @@ -197,14 +169,14 @@ const showRelations = { ...getColumns(entries), ...transCol, number: entries.episodeNumber, - videos: videosQ.videos, + videos: entryVideosQ.videos, progress: getColumns(progressQ), }).as("firstEntry"), }) .from(entries) .innerJoin(transQ, eq(entries.pk, transQ.pk)) .leftJoin(progressQ, eq(entries.pk, progressQ.entryPk)) - .leftJoinLateral(videosQ, sql`true`) + .leftJoinLateral(entryVideosQ, sql`true`) .where(and(eq(entries.showPk, shows.pk), ne(entries.kind, "extra"))) .orderBy(entries.order) .limit(1) From 54131b6762a122cbf849fd150e062c654e4513e8 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 14 Mar 2025 19:43:04 +0100 Subject: [PATCH 09/15] wip: nextEntry --- api/src/controllers/shows/logic.ts | 39 ++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index c9932a09..f4f86bf9 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -1,4 +1,5 @@ import { type SQL, and, desc, eq, exists, ne, sql } from "drizzle-orm"; +import type { PgSelect } from "drizzle-orm/pg-core"; import { db } from "~/db"; import { entries, @@ -182,6 +183,44 @@ const showRelations = { .limit(1) .as("firstEntry"); }, + nextEntry: ({ + languages, + userId, + watchStatusQ, + }: { + languages: string[]; + userId: number; + watchStatusQ: PgSelect; + }) => { + const transQ = db + .selectDistinctOn([entryTranslations.pk]) + .from(entryTranslations) + .orderBy( + entryTranslations.pk, + sql`array_position(${sqlarr(languages)}, ${entryTranslations.language})`, + ) + .as("t"); + const { pk, ...transCol } = getColumns(transQ); + + const progressQ = getEntryProgressQ(userId); + + return db + .select({ + nextEntry: jsonbBuildObject({ + ...getColumns(entries), + ...transCol, + number: entries.episodeNumber, + videos: entryVideosQ.videos, + progress: getColumns(progressQ), + }).as("nextEntry"), + }) + .from(entries) + .innerJoin(transQ, eq(entries.pk, transQ.pk)) + .leftJoin(progressQ, eq(entries.pk, progressQ.entryPk)) + .leftJoinLateral(entryVideosQ, sql`true`) + .where(eq(watchStatusQ.nextEntryPk, entries.pk)) + .as("nextEntry"); + }, }; export async function getShows({ From 22754442adb17421b0081462d661027966940d67 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 6 Apr 2025 18:24:49 +0200 Subject: [PATCH 10/15] Create migration for watchlist/history/profiles --- api/drizzle/0017_watchlist.sql | 40 + api/drizzle/meta/0017_snapshot.json | 1839 +++++++++++++++++++++++++++ api/drizzle/meta/_journal.json | 7 + api/src/db/schema/profiles.ts | 5 +- 4 files changed, 1890 insertions(+), 1 deletion(-) create mode 100644 api/drizzle/0017_watchlist.sql create mode 100644 api/drizzle/meta/0017_snapshot.json diff --git a/api/drizzle/0017_watchlist.sql b/api/drizzle/0017_watchlist.sql new file mode 100644 index 00000000..4f2453b9 --- /dev/null +++ b/api/drizzle/0017_watchlist.sql @@ -0,0 +1,40 @@ +CREATE TYPE "kyoo"."watchlist_status" AS ENUM('completed', 'watching', 'rewatching', 'dropped', 'planned');--> statement-breakpoint +CREATE TABLE "kyoo"."history" ( + "pk" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "kyoo"."history_pk_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "profile_pk" integer NOT NULL, + "entry_pk" integer NOT NULL, + "video_pk" integer NOT NULL, + "percent" integer DEFAULT 0 NOT NULL, + "time" integer, + "played_date" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "percent_valid" CHECK ("kyoo"."history"."percent" between 0 and 100) +); +--> statement-breakpoint +CREATE TABLE "kyoo"."profiles" ( + "pk" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "kyoo"."profiles_pk_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "id" uuid NOT NULL, + CONSTRAINT "profiles_id_unique" UNIQUE("id") +); +--> statement-breakpoint +CREATE TABLE "kyoo"."watchlist" ( + "profile_pk" integer NOT NULL, + "show_pk" integer NOT NULL, + "status" "kyoo"."watchlist_status" NOT NULL, + "seen_count" integer DEFAULT 0 NOT NULL, + "next_entry" integer, + "score" integer, + "started_at" timestamp with time zone, + "completed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + CONSTRAINT "watchlist_profile_pk_show_pk_pk" PRIMARY KEY("profile_pk","show_pk"), + CONSTRAINT "score_percent" CHECK ("kyoo"."watchlist"."score" between 0 and 100) +); +--> statement-breakpoint +ALTER TABLE "kyoo"."history" ADD CONSTRAINT "history_profile_pk_profiles_pk_fk" FOREIGN KEY ("profile_pk") REFERENCES "kyoo"."profiles"("pk") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "kyoo"."history" ADD CONSTRAINT "history_entry_pk_entries_pk_fk" FOREIGN KEY ("entry_pk") REFERENCES "kyoo"."entries"("pk") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "kyoo"."history" ADD CONSTRAINT "history_video_pk_videos_pk_fk" FOREIGN KEY ("video_pk") REFERENCES "kyoo"."videos"("pk") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "kyoo"."watchlist" ADD CONSTRAINT "watchlist_profile_pk_profiles_pk_fk" FOREIGN KEY ("profile_pk") REFERENCES "kyoo"."profiles"("pk") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "kyoo"."watchlist" ADD CONSTRAINT "watchlist_show_pk_shows_pk_fk" FOREIGN KEY ("show_pk") REFERENCES "kyoo"."shows"("pk") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "kyoo"."watchlist" ADD CONSTRAINT "watchlist_next_entry_entries_pk_fk" FOREIGN KEY ("next_entry") REFERENCES "kyoo"."entries"("pk") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "history_play_date" ON "kyoo"."history" USING btree ("played_date" DESC NULLS LAST); \ No newline at end of file diff --git a/api/drizzle/meta/0017_snapshot.json b/api/drizzle/meta/0017_snapshot.json new file mode 100644 index 00000000..52c538c4 --- /dev/null +++ b/api/drizzle/meta/0017_snapshot.json @@ -0,0 +1,1839 @@ +{ + "id": "d013e966-c7af-4047-8b44-e3740aac6beb", + "prevId": "c3bd85b9-5370-4689-9a3e-78e5b5488a4a", + "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 + }, + "entry_pk": { + "name": "entry_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "video_pk": { + "name": "video_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "percent": { + "name": "percent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "time": { + "name": "time", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "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_entry_pk_entries_pk_fk": { + "name": "history_entry_pk_entries_pk_fk", + "tableFrom": "history", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["entry_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.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 + }, + "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[]", + "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.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.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.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"] + } + }, + "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.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.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 + }, + "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.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 + }, + "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": ["unknown", "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", "other"] + }, + "kyoo.watchlist_status": { + "name": "watchlist_status", + "schema": "kyoo", + "values": ["completed", "watching", "rewatching", "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 d9f1e294..57fe40d4 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1742205790510, "tag": "0016_mqueue", "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1743944773824, + "tag": "0017_watchlist", + "breakpoints": true } ] } diff --git a/api/src/db/schema/profiles.ts b/api/src/db/schema/profiles.ts index 2296010a..2a6814ef 100644 --- a/api/src/db/schema/profiles.ts +++ b/api/src/db/schema/profiles.ts @@ -1,6 +1,9 @@ -import { integer } from "drizzle-orm/pg-core"; +import { integer, uuid } from "drizzle-orm/pg-core"; import { schema } from "./utils"; +// user info is stored in keibi (the auth service). +// this table is only there for relations. export const profiles = schema.table("profiles", { pk: integer().primaryKey().generatedAlwaysAsIdentity(), + id: uuid().notNull().unique(), }); From 11e1c59698655e158523ba9b8be07bea6b6e9c1a Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 6 Apr 2025 18:25:28 +0200 Subject: [PATCH 11/15] Handle watchstatus on movies/series --- .env.example | 6 ++--- api/src/auth.ts | 39 +++++++++++++++--------------- api/src/controllers/shows/logic.ts | 18 ++++++++------ api/src/models/movie.ts | 2 +- api/src/models/serie.ts | 2 +- 5 files changed, 35 insertions(+), 32 deletions(-) diff --git a/.env.example b/.env.example index 7ca66f24..a4c83d29 100644 --- a/.env.example +++ b/.env.example @@ -96,7 +96,7 @@ RABBITMQ_DEFAULT_PASS=aohohunuhouhuhhoahothonseuhaoensuthoaentsuhha # v5 stuff, does absolutely nothing on master (aka: you can delete this) -EXTRA_CLAIMS='{"permissions": [], "verified": false}' -FIRST_USER_CLAIMS='{"permissions": ["user.read", "users.write", "users.delete"], "verified": true}' -GUEST_CLAIMS='{"permissions": []}' +EXTRA_CLAIMS='{"permissions": ["core.read"], "verified": false}' +FIRST_USER_CLAIMS='{"permissions": ["user.read", "users.write", "users.delete", "core.read"], "verified": true}' +GUEST_CLAIMS='{"permissions": ["core.read"]}' PROTECTED_CLAIMS="permissions,verified" diff --git a/api/src/auth.ts b/api/src/auth.ts index ff6bd3e5..a2991372 100644 --- a/api/src/auth.ts +++ b/api/src/auth.ts @@ -26,35 +26,36 @@ export const auth = new Elysia({ name: "auth" }) authorization: t.TemplateLiteral("Bearer ${string}"), }), }) + .resolve(async ({ headers: { authorization }, error }) => { + const bearer = authorization?.slice(7); + if (!bearer) { + return error(500, { + status: 500, + message: "No jwt, auth server configuration error.", + }); + } + + // @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 jwt = validator.Decode(payload); + + return { jwt }; + }) .macro({ permissions(perms: string[]) { return { - resolve: async ({ headers: { authorization }, error }) => { - const bearer = authorization?.slice(7); - if (!bearer) { - return error(500, { - status: 500, - message: "No jwt, auth server configuration error.", - }); - } - - // @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 jwt = validator.Decode(payload); - + beforeHandle: ({ jwt, error }) => { for (const perm of perms) { - if (!jwt.permissions.includes(perm)) { + if (!jwt!.permissions.includes(perm)) { return error(403, { status: 403, message: `Missing permission: '${perm}'.`, - details: { current: jwt.permissions, required: perms }, + details: { current: jwt!.permissions, required: perms }, }); } } - - return { jwt }; }, }; }, diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index f4f86bf9..15b46ab2 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -6,6 +6,7 @@ import { entryTranslations, entryVideoJoin, history, + profiles, showStudioJoin, showTranslations, shows, @@ -36,7 +37,7 @@ import { sortToSql, } from "~/models/utils"; import type { EmbeddedVideo } from "~/models/video"; -import { entryVideosQ, getEntryProgressQ } from "../entries"; +import { entryVideosQ, getEntryProgressQ, mapProgress } from "../entries"; export const showFilters: FilterDef = { genres: { @@ -151,7 +152,7 @@ const showRelations = { firstEntry: ({ languages, userId, - }: { languages: string[]; userId: number }) => { + }: { languages: string[]; userId: string }) => { const transQ = db .selectDistinctOn([entryTranslations.pk]) .from(entryTranslations) @@ -171,7 +172,7 @@ const showRelations = { ...transCol, number: entries.episodeNumber, videos: entryVideosQ.videos, - progress: getColumns(progressQ), + progress: mapProgress(progressQ), }).as("firstEntry"), }) .from(entries) @@ -189,7 +190,7 @@ const showRelations = { watchStatusQ, }: { languages: string[]; - userId: number; + userId: string; watchStatusQ: PgSelect; }) => { const transQ = db @@ -211,7 +212,7 @@ const showRelations = { ...transCol, number: entries.episodeNumber, videos: entryVideosQ.videos, - progress: getColumns(progressQ), + progress: mapProgress(progressQ), }).as("nextEntry"), }) .from(entries) @@ -244,7 +245,7 @@ export async function getShows({ fallbackLanguage?: boolean; preferOriginal?: boolean; relations?: (keyof typeof showRelations)[]; - userId: number; + userId: string; }) { const transQ = db .selectDistinctOn([showTranslations.pk]) @@ -263,10 +264,11 @@ export async function getShows({ const watchStatusQ = db .select({ ...getColumns(watchlist), - percent: watchlist.seenCount, + percent: sql`${watchlist.seenCount}`.as("percent"), }) .from(watchlist) - .where(eq(watchlist.profilePk, userId)) + .leftJoin(profiles, eq(watchlist.profilePk, profiles.pk)) + .where(eq(profiles.id, userId)) .as("watchstatus"); return await db diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index 4110f000..9de88479 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -56,7 +56,7 @@ export const Movie = t.Intersect([ t.Object({ original: Original, isAvailable: t.Boolean(), - watchStatus: t.Omit(WatchStatus, ["seenCount"]), + watchStatus: t.Nullable(t.Omit(WatchStatus, ["seenCount"])), }), ]); export type Movie = Prettify; diff --git a/api/src/models/serie.ts b/api/src/models/serie.ts index 22e894ce..78ed77e8 100644 --- a/api/src/models/serie.ts +++ b/api/src/models/serie.ts @@ -71,7 +71,7 @@ export const Serie = t.Intersect([ availableCount: t.Integer({ description: "The number of episodes that can be played right away", }), - watchStatus: t.Omit(WatchStatus, ["percent"]), + watchStatus: t.Nullable(t.Omit(WatchStatus, ["percent"])), }), ]); export type Serie = Prettify; From c3abd7c61b0c3e0e22342ad2c945b748b04e2cb7 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 6 Apr 2025 18:25:41 +0200 Subject: [PATCH 12/15] Handle watch status on entries --- api/src/controllers/entries.ts | 36 +++++++++++++++++++++++++++++----- api/src/db/utils.ts | 19 +++++++++++++----- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index 7d2e5eb7..1fb84b8a 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -1,11 +1,13 @@ import { type SQL, and, desc, eq, isNotNull, ne, sql } from "drizzle-orm"; import { Elysia, t } from "elysia"; +import { auth } from "~/auth"; import { db } from "~/db"; import { entries, entryTranslations, entryVideoJoin, history, + profiles, shows, videos, } from "~/db/schema"; @@ -124,7 +126,7 @@ export const entryVideosQ = db .leftJoin(videos, eq(videos.pk, entryVideoJoin.videoPk)) .as("videos"); -export const getEntryProgressQ = (userId: number) => +export const getEntryProgressQ = (userId: string) => db .selectDistinctOn([history.entryPk], { percent: history.percent, @@ -133,11 +135,23 @@ export const getEntryProgressQ = (userId: number) => videoId: videos.id, }) .from(history) - .where(eq(history.profilePk, userId)) .leftJoin(videos, eq(history.videoPk, videos.pk)) + .leftJoin(profiles, eq(history.profilePk, profiles.pk)) + .where(eq(profiles.id, userId)) .orderBy(history.entryPk, desc(history.playedDate)) .as("progress"); +export const mapProgress = ( + progressQ: ReturnType, +) => { + const { time, percent, videoId } = getColumns(progressQ); + return { + time: coalesce(time, sql`0`), + percent: coalesce(percent, sql`0`), + videoId, + }; +}; + async function getEntries({ after, limit, @@ -153,7 +167,7 @@ async function getEntries({ sort: Sort; filter: SQL | undefined; languages: string[]; - userId: number; + userId: string; }): Promise<(Entry | Extra | UnknownEntry)[]> { const transQ = db .selectDistinctOn([entryTranslations.pk]) @@ -181,7 +195,7 @@ async function getEntries({ ...entryCol, ...transCol, videos: entryVideosQ.videos, - progress: getColumns(entryProgressQ), + progress: mapProgress(entryProgressQ), // specials don't have an `episodeNumber` but a `number` field. number: episodeNumber, @@ -230,6 +244,7 @@ export const entriesH = new Elysia({ tags: ["series"] }) ...models, entry: t.Union([models.episode, models.movie_entry, models.special]), })) + .use(auth) .get( "/series/:id/entries", async ({ @@ -237,6 +252,7 @@ export const entriesH = new Elysia({ tags: ["series"] }) query: { limit, after, query, sort, filter }, headers: { "accept-language": languages }, request: { url }, + jwt: { sub }, error, }) => { const [serie] = await db @@ -270,6 +286,7 @@ export const entriesH = new Elysia({ tags: ["series"] }) filter, ), languages: langs, + userId: sub, })) as Entry[]; return createPage(items, { url, sort, limit }); @@ -316,6 +333,7 @@ export const entriesH = new Elysia({ tags: ["series"] }) params: { id }, query: { limit, after, query, sort, filter }, request: { url }, + jwt: { sub }, error, }) => { const [serie] = await db @@ -347,6 +365,7 @@ export const entriesH = new Elysia({ tags: ["series"] }) filter, ), languages: ["extra"], + userId: sub, })) as Extra[]; return createPage(items, { url, sort, limit }); @@ -386,6 +405,7 @@ export const entriesH = new Elysia({ tags: ["series"] }) async ({ query: { limit, after, query, sort, filter }, request: { url }, + jwt: { sub }, }) => { const items = (await getEntries({ limit, @@ -394,6 +414,7 @@ export const entriesH = new Elysia({ tags: ["series"] }) sort: sort, filter: and(eq(entries.kind, "unknown"), filter), languages: ["extra"], + userId: sub, })) as UnknownEntry[]; return createPage(items, { url, sort, limit }); @@ -421,7 +442,11 @@ export const entriesH = new Elysia({ tags: ["series"] }) ) .get( "/news", - async ({ query: { limit, after, query, filter }, request: { url } }) => { + async ({ + query: { limit, after, query, filter }, + request: { url }, + jwt: { sub }, + }) => { const sort = newsSort; const items = (await getEntries({ limit, @@ -435,6 +460,7 @@ export const entriesH = new Elysia({ tags: ["series"] }) filter, ), languages: ["extra"], + userId: sub, })) as Entry[]; return createPage(items, { url, sort, limit }); diff --git a/api/src/db/utils.ts b/api/src/db/utils.ts index baa3658c..2f1e6689 100644 --- a/api/src/db/utils.ts +++ b/api/src/db/utils.ts @@ -1,6 +1,6 @@ import { + Column, type ColumnsSelection, - InferColumnsDataTypes, type SQL, type SQLWrapper, type Subquery, @@ -13,7 +13,7 @@ import { } from "drizzle-orm"; import type { CasingCache } from "drizzle-orm/casing"; import type { AnyMySqlSelect } from "drizzle-orm/mysql-core"; -import type { AnyPgSelect } from "drizzle-orm/pg-core"; +import type { AnyPgSelect, SelectedFieldsFlat } from "drizzle-orm/pg-core"; import type { AnySQLiteSelect } from "drizzle-orm/sqlite-core"; import type { WithSubquery } from "drizzle-orm/subquery"; import { db } from "./index"; @@ -95,7 +95,7 @@ export function values(items: Record[]) { }; } -export const coalesce = (val: SQL, def: SQLWrapper) => { +export const coalesce = (val: SQL | Column, def: SQL) => { return sql`coalesce(${val}, ${def})`; }; @@ -109,10 +109,19 @@ export const jsonbAgg = (val: SQL) => { return sql`jsonb_agg(${val})`; }; -export const jsonbBuildObject = (select: Record) => { +type JsonFields = { + [k: string]: + | SelectedFieldsFlat[string] + | Table + | SelectedFieldsFlat + | JsonFields; +}; +export const jsonbBuildObject = (select: JsonFields) => { const query = sql.join( Object.entries(select).flatMap(([k, v]) => { - return [sql.raw(`'${k}'`), v]; + if (v.getSQL) return [sql.raw(`'${k}'`), v]; + // nested object (getSql is present in all SqlWrappers) + return [sql.raw(`'${k}'`), jsonbBuildObject(v as JsonFields)]; }), sql.raw(", "), ); From 74ee45244bc8e512bea2cdabba767c7664760d67 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 6 Apr 2025 19:32:48 +0200 Subject: [PATCH 13/15] Support `nextEntry` in /series/{id} --- api/src/controllers/shows/logic.ts | 23 +++++++++++++++++++---- api/src/controllers/shows/series.ts | 17 +++++++++++++---- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index 15b46ab2..90eb9555 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -1,4 +1,13 @@ -import { type SQL, and, desc, eq, exists, ne, sql } from "drizzle-orm"; +import { + type SQL, + type Subquery, + and, + desc, + eq, + exists, + ne, + sql, +} from "drizzle-orm"; import type { PgSelect } from "drizzle-orm/pg-core"; import { db } from "~/db"; import { @@ -191,7 +200,7 @@ const showRelations = { }: { languages: string[]; userId: string; - watchStatusQ: PgSelect; + watchStatusQ: Subquery; }) => { const transQ = db .selectDistinctOn([entryTranslations.pk]) @@ -219,7 +228,9 @@ const showRelations = { .innerJoin(transQ, eq(entries.pk, transQ.pk)) .leftJoin(progressQ, eq(entries.pk, progressQ.entryPk)) .leftJoinLateral(entryVideosQ, sql`true`) - .where(eq(watchStatusQ.nextEntryPk, entries.pk)) + .where( + eq((watchStatusQ as unknown as typeof watchlist).nextEntry, entries.pk), + ) .as("nextEntry"); }, }; @@ -291,7 +302,11 @@ export async function getShows({ watchStatus: getColumns(watchStatusQ), - ...buildRelations(relations, showRelations, { languages, userId }), + ...buildRelations(relations, showRelations, { + languages, + userId, + watchStatusQ, + }), }) .from(shows) .leftJoin(watchStatusQ, eq(shows.pk, watchStatusQ.showPk)) diff --git a/api/src/controllers/shows/series.ts b/api/src/controllers/shows/series.ts index cd7d3440..6e914647 100644 --- a/api/src/controllers/shows/series.ts +++ b/api/src/controllers/shows/series.ts @@ -1,5 +1,6 @@ import { and, eq, sql } from "drizzle-orm"; import { Elysia, t } from "elysia"; +import { auth } from "~/auth"; import { prefix } from "~/base"; import { db } from "~/db"; import { shows } from "~/db/schema"; @@ -22,12 +23,14 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] }) serie: Serie, "serie-translation": SerieTranslation, }) + .use(auth) .get( "/:id", async ({ params: { id }, headers: { "accept-language": languages }, query: { preferOriginal, with: relations }, + jwt: { sub }, error, set, }) => { @@ -42,6 +45,7 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] }) fallbackLanguage: langs.includes("*"), preferOriginal, relations, + userId: sub, }); if (!ret) { return error(404, { @@ -72,10 +76,13 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] }) preferOriginal: t.Optional( t.Boolean({ description: desc.preferOriginal }), ), - with: t.Array(t.UnionEnum(["translations", "studios", "firstEntry"]), { - default: [], - description: "Include related resources in the response.", - }), + with: t.Array( + t.UnionEnum(["translations", "studios", "firstEntry", "nextEntry"]), + { + default: [], + description: "Include related resources in the response.", + }, + ), }), headers: t.Object( { @@ -131,6 +138,7 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] }) query: { limit, after, query, sort, filter, preferOriginal }, headers: { "accept-language": languages }, request: { url }, + jwt: { sub }, }) => { const langs = processLanguages(languages); const items = await getShows({ @@ -141,6 +149,7 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] }) filter: and(eq(shows.kind, "serie"), filter), languages: langs, preferOriginal, + userId: sub, }); return createPage(items, { url, sort, limit }); }, From 5a6e29e57aa4a8888e185f363c58f9484dfebe83 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 6 Apr 2025 19:33:11 +0200 Subject: [PATCH 14/15] Fix guess's sub/sid format --- api/src/auth.ts | 20 ++++++++++++++------ api/src/db/utils.ts | 2 +- auth/jwt.go | 4 ++-- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/api/src/auth.ts b/api/src/auth.ts index a2991372..02698625 100644 --- a/api/src/auth.ts +++ b/api/src/auth.ts @@ -35,13 +35,21 @@ export const auth = new Elysia({ name: "auth" }) }); } - // @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 jwt = validator.Decode(payload); + 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 jwt = validator.Decode(payload); - return { jwt }; + return { jwt }; + } catch (err) { + return error(403, { + status: 403, + message: "Invalid jwt. Verification vailed", + details: err, + }); + } }) .macro({ permissions(perms: string[]) { diff --git a/api/src/db/utils.ts b/api/src/db/utils.ts index 2f1e6689..c5ac3e2f 100644 --- a/api/src/db/utils.ts +++ b/api/src/db/utils.ts @@ -1,5 +1,5 @@ import { - Column, + type Column, type ColumnsSelection, type SQL, type SQLWrapper, diff --git a/auth/jwt.go b/auth/jwt.go index 41d43d29..c854da19 100644 --- a/auth/jwt.go +++ b/auth/jwt.go @@ -58,8 +58,8 @@ func (h *Handler) createGuestJwt() *string { claims := maps.Clone(h.config.GuestClaims) claims["username"] = "guest" - claims["sub"] = "guest" - claims["sid"] = "guest" + claims["sub"] = "00000000-0000-0000-0000-000000000000" + claims["sid"] = "00000000-0000-0000-0000-000000000000" claims["iss"] = h.config.PublicUrl claims["iat"] = &jwt.NumericDate{ Time: time.Now().UTC(), From 5994b8bc0240a4474a9a7a733c70ebeed5f90c41 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 6 Apr 2025 22:02:24 +0200 Subject: [PATCH 15/15] Fix aliases issues in jsonBuildObject --- api/src/controllers/entries.ts | 9 ++++++--- api/src/controllers/shows/shows.ts | 4 ++++ api/src/controllers/staff.ts | 31 ++++++++++++++++++++++++++++-- api/src/models/watchlist.ts | 1 + 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index 1fb84b8a..ac0bd5d0 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -143,13 +143,16 @@ export const getEntryProgressQ = (userId: string) => export const mapProgress = ( progressQ: ReturnType, + { aliased }: { aliased: boolean } = { aliased: false }, ) => { const { time, percent, videoId } = getColumns(progressQ); - return { + const ret = { time: coalesce(time, sql`0`), percent: coalesce(percent, sql`0`), - videoId, + videoId: sql`${videoId}`, }; + if (!aliased) return ret; + return Object.fromEntries(Object.entries(ret).map(([k, v]) => [k, v.as(k)])); }; async function getEntries({ @@ -195,7 +198,7 @@ async function getEntries({ ...entryCol, ...transCol, videos: entryVideosQ.videos, - progress: mapProgress(entryProgressQ), + progress: mapProgress(entryProgressQ, { aliased: true }), // specials don't have an `episodeNumber` but a `number` field. number: episodeNumber, diff --git a/api/src/controllers/shows/shows.ts b/api/src/controllers/shows/shows.ts index a61983fb..397c4f43 100644 --- a/api/src/controllers/shows/shows.ts +++ b/api/src/controllers/shows/shows.ts @@ -1,5 +1,6 @@ import { and, isNull, sql } from "drizzle-orm"; import { Elysia, t } from "elysia"; +import { auth } from "~/auth"; import { prefix } from "~/base"; import { db } from "~/db"; import { shows } from "~/db/schema"; @@ -19,6 +20,7 @@ export const showsH = new Elysia({ prefix: "/shows", tags: ["shows"] }) .model({ show: Show, }) + .use(auth) .get( "random", async ({ error, redirect }) => { @@ -63,6 +65,7 @@ export const showsH = new Elysia({ prefix: "/shows", tags: ["shows"] }) }, headers: { "accept-language": languages }, request: { url }, + jwt: { sub }, }) => { const langs = processLanguages(languages); const items = await getShows({ @@ -76,6 +79,7 @@ export const showsH = new Elysia({ prefix: "/shows", tags: ["shows"] }) ), languages: langs, preferOriginal, + userId: sub, }); return createPage(items, { url, sort, limit }); }, diff --git a/api/src/controllers/staff.ts b/api/src/controllers/staff.ts index 4160d39e..b9184b59 100644 --- a/api/src/controllers/staff.ts +++ b/api/src/controllers/staff.ts @@ -1,15 +1,18 @@ import { type SQL, and, eq, sql } from "drizzle-orm"; import Elysia, { t } from "elysia"; +import { auth } from "~/auth"; import { prefix } from "~/base"; import { db } from "~/db"; -import { showTranslations, shows } from "~/db/schema"; +import { profiles, showTranslations, shows } from "~/db/schema"; import { roles, staff } from "~/db/schema/staff"; -import { getColumns, sqlarr } from "~/db/utils"; +import { watchlist } from "~/db/schema/watchlist"; +import { getColumns, jsonbBuildObject, sqlarr } from "~/db/utils"; import { KError } from "~/models/error"; import type { MovieStatus } from "~/models/movie"; import { Role, Staff } from "~/models/staff"; import { RoleWShow, RoleWStaff } from "~/models/staff-roles"; import { + AcceptLanguage, Filter, type FilterDef, type Image, @@ -22,6 +25,7 @@ import { sortToSql, } from "~/models/utils"; import { desc } from "~/models/utils/descriptions"; +import type { WatchStatus } from "~/models/watchlist"; import { showFilters, showSort } from "./shows/logic"; const staffSort = Sort( @@ -113,6 +117,7 @@ export const staffH = new Elysia({ tags: ["staff"] }) staff: Staff, role: Role, }) + .use(auth) .get( "/staff/:id", async ({ params: { id }, error }) => { @@ -186,6 +191,7 @@ export const staffH = new Elysia({ tags: ["staff"] }) query: { limit, after, query, sort, filter, preferOriginal }, headers: { "accept-language": languages }, request: { url }, + jwt: { sub }, error, }) => { const [member] = await db @@ -210,6 +216,20 @@ export const staffH = new Elysia({ tags: ["staff"] }) sql`array_position(${sqlarr(langs)}, ${showTranslations.language})`, ) .as("t"); + + const watchStatusQ = db + .select({ + watchStatus: jsonbBuildObject({ + ...getColumns(watchlist), + percent: watchlist.seenCount, + }).as("watchStatus"), + }) + .from(watchlist) + .leftJoin(profiles, eq(watchlist.profilePk, profiles.pk)) + .where(and(eq(profiles.id, sub), eq(watchlist.showPk, shows.pk))) + .limit(1) + .as("watchstatus"); + const items = await db .select({ ...getColumns(roles), @@ -229,6 +249,7 @@ export const staffH = new Elysia({ tags: ["staff"] }) banner: sql`coalesce(nullif(${shows.original}->'banner', 'null'::jsonb), ${transQ.banner})`, logo: sql`coalesce(nullif(${shows.original}->'logo', 'null'::jsonb), ${transQ.logo})`, }), + watchStatus: sql`${watchStatusQ}`, }, }) .from(roles) @@ -278,6 +299,12 @@ export const staffH = new Elysia({ tags: ["staff"] }) }), ), }), + headers: t.Object( + { + "accept-language": AcceptLanguage(), + }, + { additionalProperties: true }, + ), response: { 200: Page(RoleWShow), 404: { diff --git a/api/src/models/watchlist.ts b/api/src/models/watchlist.ts index 0d5037b9..2b1f270d 100644 --- a/api/src/models/watchlist.ts +++ b/api/src/models/watchlist.ts @@ -52,3 +52,4 @@ export const WatchStatus = t.Object({ maximum: 100, }), }); +export type WatchStatus = typeof WatchStatus.static;