From 0aa2c9c086d63753e709d27b2b89eb46f2d63130 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 9 Apr 2025 22:38:33 +0200 Subject: [PATCH 01/23] Cleanup scanner workflow --- pyproject.toml | 45 --------------------------------------------- scanner/README.md | 33 ++++++++++++++++++++------------- 2 files changed, 20 insertions(+), 58 deletions(-) delete mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 4ba4057b..00000000 --- a/pyproject.toml +++ /dev/null @@ -1,45 +0,0 @@ -[tool.robotidy] -diff = false -overwrite = true -verbose = false -separator = "space" -spacecount = 2 -line_length = 120 -lineseparator = "native" -skip_gitignore = true -ignore_git_dir = true -configure = [ - "AddMissingEnd:enabled=True", - "NormalizeSeparators:enabled=True", - "DiscardEmptySections:enabled=True", - "MergeAndOrderSections:enabled=True", - "RemoveEmptySettings:enabled=True", - "ReplaceEmptyValues:enabled=True", - "ReplaceWithVAR:enabled=False", - "NormalizeAssignments:enabled=True", - "GenerateDocumentation:enabled=False", - "OrderSettings:enabled=True", - "OrderSettingsSection:enabled=True", - "NormalizeTags:enabled=True", - "OrderTags:enabled=False", - "RenameVariables:enabled=False", - "IndentNestedKeywords:enabled=False", - "AlignSettingsSection:enabled=True", - "AlignVariablesSection:enabled=True", - "AlignTemplatedTestCases:enabled=False", - "AlignTestCasesSection:enabled=False", - "AlignKeywordsSection:enabled=False", - "NormalizeNewLines:enabled=True", - "NormalizeSectionHeaderName:enabled=True", - "NormalizeSettingName:enabled=True", - "ReplaceRunKeywordIf:enabled=True", - "SplitTooLongLine:enabled=True", - "SmartSortKeywords:enabled=False", - "RenameTestCases:enabled=False", - "RenameKeywords:enabled=False", - "ReplaceReturns:enabled=True", - "ReplaceBreakContinue:enabled=True", - "InlineIf:enabled=True", - "Translate:enabled=False", - "NormalizeComments:enabled=True", -] diff --git a/scanner/README.md b/scanner/README.md index f1ae907f..6247aed6 100644 --- a/scanner/README.md +++ b/scanner/README.md @@ -14,11 +14,12 @@ In order of action: part: number | null, rendering: sha(path except version & part), guess: { - kind: movie | episode | trailer | interview | ..., - name: string, - year: number | null, - season?: number, - episode?: number, + from: "guessit" + kind: movie | episode | extra + title: string, + year?: number[], + season?: number[], + episode?: number[], ... }, } @@ -32,17 +33,22 @@ In order of action: part: number | null, rendering: sha(path except version & part), guess: { - kind: movie | episode | trailer | interview | ..., + from: "anilist", + kind: movie | episode | extra name: string, year: number | null, - season?: number, - episodes?: number[], - absolutes?: number[], + season?: number[], + episode?: number[], + absolute?: number[], externalId: Record, - remap: { - from: "thexem", - oldSeason: number, - oldEpisodes: number[], + history: { + from: "guessit" + kind: movie | episode | extra + title: string, + year?: number, + season?: number[], + episode?: number[], + ... }, ... }, @@ -67,3 +73,4 @@ In order of action: - Matcher retrieves metadata from the movie/serie + ALL episodes/seasons (from an external provider) - Matcher pushes every metadata to the api (if there are 1000 episodes but only 1 video, still push the 1000 episodes) + From c504cbbff545656344f385b27caff13b3163cbfd Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 9 Apr 2025 22:39:24 +0200 Subject: [PATCH 02/23] Type videos's `for` to map to entries --- api/src/models/video.ts | 57 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/api/src/models/video.ts b/api/src/models/video.ts index a61644aa..012a6e70 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -1,7 +1,8 @@ import { t } from "elysia"; import { type Prettify, comment } from "~/utils"; +import { ExtraType } from "./entry/extra"; import { bubbleVideo, registerExamples } from "./examples"; -import { DbMetadata, Resource } from "./utils"; +import { DbMetadata, EpisodeId, ExternalId, Resource } from "./utils"; export const Guess = t.Recursive((Self) => t.Object( @@ -10,8 +11,8 @@ export const Guess = t.Recursive((Self) => year: t.Optional(t.Array(t.Integer(), { default: [] })), season: t.Optional(t.Array(t.Integer(), { default: [] })), episode: t.Optional(t.Array(t.Integer(), { default: [] })), - // TODO: maybe replace "extra" with the `extraKind` value (aka behind-the-scene, trailer, etc) kind: t.Optional(t.UnionEnum(["episode", "movie", "extra"])), + extraKind: t.Optional(ExtraType), from: t.String({ description: "Name of the tool that made the guess", @@ -66,10 +67,56 @@ export const SeedVideo = t.Object({ }), guess: Guess, -}); -export type SeedVideo = typeof SeedVideo.static; -export const Video = t.Intersect([Resource(), SeedVideo, DbMetadata]); + for: t.Array( + t.Union([ + t.Object({ + movie: t.Union([ + t.String({ format: "uuid" }), + t.String({ format: "slug", examples: ["bubble"] }), + ]), + externalId: t.Optional(ExternalId()), + }), + t.Intersect([ + t.Object({ + serie: t.Union([ + t.String({ format: "uuid" }), + t.String({ format: "slug", examples: ["made-in-abyss"] }), + ]), + }), + t.Union([ + t.Object({ + season: t.Integer({ minimum: 1 }), + episode: t.Integer(), + externalId: t.Optional(EpisodeId), + }), + t.Object({ + absolute: t.Integer(), + externalId: t.Optional(t.Union([EpisodeId, ExternalId()])), + }), + t.Object({ + special: t.Integer(), + externalId: t.Optional(EpisodeId), + }), + t.Object({ + slug: t.String({ + format: "slug", + examples: ["made-in-abyss-dawn-of-the-deep-soul"], + }), + externalId: t.Optional(t.Union([EpisodeId, ExternalId()])), + }), + ]), + ]), + ]), + ), +}); +export type SeedVideo = Prettify; + +export const Video = t.Intersect([ + Resource(), + t.Omit(SeedVideo, ["for"]), + DbMetadata, +]); export type Video = Prettify; // type used in entry responses From 621c9cec829d992a52658e867758963d0d71d461 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 9 Apr 2025 22:41:45 +0200 Subject: [PATCH 03/23] Add `GET /videos/` that also list guesses --- api/src/controllers/videos.ts | 54 +++++++++++++++++++++++++++++++++-- api/src/db/utils.ts | 2 +- api/src/models/video.ts | 31 ++++++++++++++++++++ 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index ade6c208..06ddcb73 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -3,10 +3,10 @@ import { alias } from "drizzle-orm/pg-core"; import { Elysia, t } from "elysia"; import { db } from "~/db"; import { entries, entryVideoJoin, shows, videos } from "~/db/schema"; -import { sqlarr } from "~/db/utils"; +import { jsonbBuildObject, jsonbObjectAgg, sqlarr } from "~/db/utils"; import { bubbleVideo } from "~/models/examples"; import { Page } from "~/models/utils"; -import { SeedVideo, Video } from "~/models/video"; +import { Guesses, SeedVideo, Video } from "~/models/video"; import { comment } from "~/utils"; import { computeVideoSlug } from "./seed/insert/entries"; import { updateAvailableCount } from "./seed/insert/shows"; @@ -27,6 +27,56 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) "created-videos": t.Array(CreatedVideo), error: t.Object({}), }) + .get( + "", + async () => { + const years = db.$with("years").as( + db + .select({ + guess: sql`${videos.guess}->>'title'`.as("guess"), + year: sql`coalesce(year, 'unknown')`.as("year"), + id: shows.id, + slug: shows.slug, + }) + .from(videos) + .leftJoin( + sql`jsonb_array_elements_text(${videos.guess}->'year') as year`, + sql`true`, + ) + .innerJoin(entryVideoJoin, eq(entryVideoJoin.videoPk, videos.pk)) + .innerJoin(entries, eq(entries.pk, entryVideoJoin.entryPk)) + .innerJoin(shows, eq(shows.pk, entries.showPk)), + ); + + const guess = db.$with("guess").as( + db + .select({ + guess: years.guess, + years: jsonbObjectAgg( + years.year, + jsonbBuildObject({ id: years.id, slug: years.slug }), + ).as("years"), + }) + .from(years) + .groupBy(years.guess), + ); + + const [{ guesses }] = await db + .with(years, guess) + .select({ guesses: jsonbObjectAgg(guess.guess, guess.years) }) + .from(guess); + + const paths = await db.select({ path: videos.path }).from(videos); + + return { paths: paths.map((x) => x.path), guesses }; + }, + { + detail: { description: "Get all video registered & guessed made" }, + response: { + 200: Guesses, + }, + }, + ) .post( "", async ({ body, error }) => { diff --git a/api/src/db/utils.ts b/api/src/db/utils.ts index c8f6c940..6c2fcccc 100644 --- a/api/src/db/utils.ts +++ b/api/src/db/utils.ts @@ -103,7 +103,7 @@ export const nullif = (val: SQL | Column, eq: SQL) => { return sql`nullif(${val}, ${eq})`; }; -export const jsonbObjectAgg = (key: SQLWrapper, value: SQL) => { +export const jsonbObjectAgg = (key: SQLWrapper, value: SQL | SQLWrapper) => { return sql< Record >`jsonb_object_agg(${sql.join([key, value], sql.raw(","))})`; diff --git a/api/src/models/video.ts b/api/src/models/video.ts index 012a6e70..09ad86f6 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -124,3 +124,34 @@ export const EmbeddedVideo = t.Omit(Video, ["guess", "createdAt", "updatedAt"]); export type EmbeddedVideo = Prettify; registerExamples(Video, bubbleVideo); + +export const Guesses = t.Object({ + paths: t.Array(t.String()), + guesses: t.Record( + t.String(), + t.Record( + t.Union([t.Literal("unknown"), t.String({ pattern: "[1-9][0-9]*" })]), + Resource(), + ), + ), +}); +export type Guesses = typeof Guesses.static; + +registerExamples(Guesses, { + paths: [ + "/videos/Evangelion S01E02.mkv", + "/videos/Evangelion (1995) S01E26.mkv", + ], + guesses: { + Evangelion: { + unknown: { + id: "43b742f5-9ce6-467d-ad29-74460624020a", + slug: "evangelion", + }, + 1995: { + id: "43b742f5-9ce6-467d-ad29-74460624020a", + slug: "evangelion", + }, + }, + }, +}); From 1369da18458035c4d639fb6a5d2667d43b4c9d0c Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 28 Apr 2025 23:21:33 +0200 Subject: [PATCH 04/23] Rework `POST /videos` --- api/src/controllers/videos.ts | 228 ++++++++++++++++++++-------------- api/src/models/video.ts | 22 ++-- 2 files changed, 147 insertions(+), 103 deletions(-) diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index 06ddcb73..4c331d0a 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -1,11 +1,17 @@ -import { and, eq, exists, inArray, not, sql } from "drizzle-orm"; +import { and, eq, exists, inArray, not, or, sql } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import { Elysia, t } from "elysia"; import { db } from "~/db"; import { entries, entryVideoJoin, shows, videos } from "~/db/schema"; -import { jsonbBuildObject, jsonbObjectAgg, sqlarr } from "~/db/utils"; +import { + conflictUpdateAllExcept, + jsonbBuildObject, + jsonbObjectAgg, + sqlarr, + values, +} from "~/db/utils"; import { bubbleVideo } from "~/models/examples"; -import { Page } from "~/models/utils"; +import { Page, isUuid } from "~/models/utils"; import { Guesses, SeedVideo, Video } from "~/models/video"; import { comment } from "~/utils"; import { computeVideoSlug } from "./seed/insert/entries"; @@ -14,11 +20,11 @@ import { updateAvailableCount } from "./seed/insert/shows"; const CreatedVideo = t.Object({ id: t.String({ format: "uuid" }), path: t.String({ examples: [bubbleVideo.path] }), - // entries: t.Array( - // t.Object({ - // slug: t.String({ format: "slug", examples: ["bubble-v2"] }), - // }), - // ), + entries: t.Array( + t.Object({ + slug: t.String({ format: "slug", examples: ["bubble-v2"] }), + }), + ), }); export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) @@ -63,7 +69,9 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) const [{ guesses }] = await db .with(years, guess) - .select({ guesses: jsonbObjectAgg(guess.guess, guess.years) }) + .select({ + guesses: jsonbObjectAgg(guess.guess, guess.years), + }) .from(guess); const paths = await db.select({ path: videos.path }).from(videos); @@ -80,90 +88,128 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) .post( "", async ({ body, error }) => { - const oldRet = await db - .insert(videos) - .values(body) + const vidsI = db.$with("vidsI").as( + db + .insert(videos) + .values(body) + .onConflictDoUpdate({ + target: [videos.path], + set: conflictUpdateAllExcept(videos, ["pk", "id", "createdAt"]), + }) + .returning({ + pk: videos.pk, + id: videos.id, + path: videos.path, + }), + ); + + const entriesQ = db + .select({ + pk: entries.pk, + id: entries.id, + slug: entries.slug, + seasonNumber: entries.seasonNumber, + episodeNumber: entries.episodeNumber, + order: entries.order, + showId: shows.id, + showSlug: shows.slug, + }) + .from(entries) + .innerJoin(shows, eq(entries.showPk, shows.pk)) + .as("entriesQ"); + + const hasRenderingQ = db + .select() + .from(entryVideoJoin) + .where(eq(entryVideoJoin.entryPk, entriesQ.pk)); + + const ret = await db + .with(vidsI) + .insert(entryVideoJoin) + .select( + db + .select({ + entry: entries.pk, + video: vidsI.pk, + slug: computeVideoSlug( + entriesQ.showSlug, + sql`j.needRendering::boolean || exists(${hasRenderingQ})`, + ), + }) + .from( + values( + body.flatMap((x) => + x.for.map((e) => ({ + path: x.path, + needRendering: x.for.length > 1, + entry: { + ...e, + movie: + "movie" in e + ? isUuid(e.movie) + ? { id: e.movie } + : { slug: e.movie } + : undefined, + serie: + "serie" in e + ? isUuid(e.serie) + ? { id: e.serie } + : { slug: e.serie } + : undefined, + }, + })), + ), + ).as("j"), + ) + .innerJoin(vidsI, eq(vidsI.path, sql`j.path`)) + .innerJoin( + entriesQ, + or( + and( + sql`j.entry ? 'slug'`, + eq(entriesQ.slug, sql`j.entry->'slug'`), + ), + and( + sql`j.entry ? 'movie'`, + or( + eq(entriesQ.showId, sql`j.entry #> '{movie, id}'`), + eq(entriesQ.showSlug, sql`j.entry #> '{movie, slug}'`), + ), + ), + and( + sql`j.entry ? 'serie'`, + or( + eq(entriesQ.showId, sql`j.entry #> '{serie, id}'`), + eq(entriesQ.showSlug, sql`j.entry #> '{serie, slug}'`), + ), + or( + and( + sql`j.entry ?& array['season', 'episode']`, + eq(entriesQ.seasonNumber, sql`j.entry->'season'`), + eq(entriesQ.episodeNumber, sql`j.entry->'episode'`), + ), + and( + sql`j.entry ? 'order'`, + eq(entriesQ.order, sql`j.entry->'order'`), + ), + and( + sql`j.entry ? 'special'`, + eq(entriesQ.episodeNumber, sql`j.entry->'special'`), + ), + ), + ), + ), + ), + ) .onConflictDoNothing() .returning({ - pk: videos.pk, - id: videos.id, - path: videos.path, - guess: videos.guess, + slug: entryVideoJoin.slug, + entryPk: entryVideoJoin.entryPk, + id: vidsI.id, + path: vidsI.path, }); - return error(201, oldRet); - - // TODO: this is a huge untested wip - // const vidsI = db.$with("vidsI").as( - // db.insert(videos).values(body).onConflictDoNothing().returning({ - // pk: videos.pk, - // id: videos.id, - // path: videos.path, - // guess: videos.guess, - // }), - // ); - // - // const findEntriesQ = db - // .select({ - // guess: videos.guess, - // entryPk: entries.pk, - // showSlug: shows.slug, - // // TODO: handle extras here - // // guessit can't know if an episode is a special or not. treat specials like a normal episode. - // kind: sql` - // case when ${entries.kind} = 'movie' then 'movie' else 'episode' end - // `.as("kind"), - // season: entries.seasonNumber, - // episode: entries.episodeNumber, - // }) - // .from(entries) - // .leftJoin(entryVideoJoin, eq(entryVideoJoin.entry, entries.pk)) - // .leftJoin(videos, eq(videos.pk, entryVideoJoin.video)) - // .leftJoin(shows, eq(shows.pk, entries.showPk)) - // .as("find_entries"); - // - // const hasRenderingQ = db - // .select() - // .from(entryVideoJoin) - // .where(eq(entryVideoJoin.entry, findEntriesQ.entryPk)); - // - // const ret = await db - // .with(vidsI) - // .insert(entryVideoJoin) - // .select( - // db - // .select({ - // entry: findEntriesQ.entryPk, - // video: vidsI.pk, - // slug: computeVideoSlug( - // findEntriesQ.showSlug, - // sql`exists(${hasRenderingQ})`, - // ), - // }) - // .from(vidsI) - // .leftJoin( - // findEntriesQ, - // and( - // eq( - // sql`${findEntriesQ.guess}->'title'`, - // sql`${vidsI.guess}->'title'`, - // ), - // // TODO: find if @> with a jsonb created on the fly is - // // better than multiples checks - // sql`${vidsI.guess} @> {"kind": }::jsonb`, - // inArray(findEntriesQ.kind, sql`${vidsI.guess}->'type'`), - // inArray(findEntriesQ.episode, sql`${vidsI.guess}->'episode'`), - // inArray(findEntriesQ.season, sql`${vidsI.guess}->'season'`), - // ), - // ), - // ) - // .onConflictDoNothing() - // .returning({ - // slug: entryVideoJoin.slug, - // entryPk: entryVideoJoin.entry, - // id: vidsI.id, - // path: vidsI.path, - // }); - // return error(201, ret as any); + return error(201, ret); + // return error(201, ret.map(x => ({ id: x.id, slug: x.}))); }, { detail: { @@ -176,7 +222,7 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) `, }, body: t.Array(SeedVideo), - response: { 201: t.Array(CreatedVideo) }, + // response: { 201: t.Array(CreatedVideo) }, }, ) .delete( diff --git a/api/src/models/video.ts b/api/src/models/video.ts index 09ad86f6..01a02e30 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -70,12 +70,20 @@ export const SeedVideo = t.Object({ for: t.Array( t.Union([ + t.Object({ + slug: t.String({ + format: "slug", + examples: ["made-in-abyss-dawn-of-the-deep-soul"], + }), + }), + t.Object({ + externalId: t.Optional(t.Union([EpisodeId, ExternalId()])), + }), t.Object({ movie: t.Union([ t.String({ format: "uuid" }), t.String({ format: "slug", examples: ["bubble"] }), ]), - externalId: t.Optional(ExternalId()), }), t.Intersect([ t.Object({ @@ -88,22 +96,12 @@ export const SeedVideo = t.Object({ t.Object({ season: t.Integer({ minimum: 1 }), episode: t.Integer(), - externalId: t.Optional(EpisodeId), }), t.Object({ - absolute: t.Integer(), - externalId: t.Optional(t.Union([EpisodeId, ExternalId()])), + order: t.Number(), }), t.Object({ special: t.Integer(), - externalId: t.Optional(EpisodeId), - }), - t.Object({ - slug: t.String({ - format: "slug", - examples: ["made-in-abyss-dawn-of-the-deep-soul"], - }), - externalId: t.Optional(t.Union([EpisodeId, ExternalId()])), }), ]), ]), From 39dcfb441856655da2d57511b3022d26cc346df5 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 29 Apr 2025 00:49:33 +0200 Subject: [PATCH 05/23] Test `POST /videos` --- api/src/controllers/videos.ts | 14 +++---- api/src/db/utils.ts | 5 ++- api/src/models/video.ts | 63 +++++++++++++++++--------------- api/tests/videos/scanner.test.ts | 50 +++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 38 deletions(-) create mode 100644 api/tests/videos/scanner.test.ts diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index 4c331d0a..81390336 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -7,11 +7,10 @@ import { conflictUpdateAllExcept, jsonbBuildObject, jsonbObjectAgg, - sqlarr, values, } from "~/db/utils"; import { bubbleVideo } from "~/models/examples"; -import { Page, isUuid } from "~/models/utils"; +import { isUuid } from "~/models/utils"; import { Guesses, SeedVideo, Video } from "~/models/video"; import { comment } from "~/utils"; import { computeVideoSlug } from "./seed/insert/entries"; @@ -138,10 +137,11 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) }) .from( values( - body.flatMap((x) => - x.for.map((e) => ({ + body.flatMap((x) => { + if (!x.for) return []; + return x.for.map((e) => ({ path: x.path, - needRendering: x.for.length > 1, + needRendering: x.for!.length > 1, entry: { ...e, movie: @@ -157,8 +157,8 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) : { slug: e.serie } : undefined, }, - })), - ), + })); + }), ).as("j"), ) .innerJoin(vidsI, eq(vidsI.path, sql`j.path`)) diff --git a/api/src/db/utils.ts b/api/src/db/utils.ts index 6c2fcccc..5681eac9 100644 --- a/api/src/db/utils.ts +++ b/api/src/db/utils.ts @@ -103,7 +103,10 @@ export const nullif = (val: SQL | Column, eq: SQL) => { return sql`nullif(${val}, ${eq})`; }; -export const jsonbObjectAgg = (key: SQLWrapper, value: SQL | SQLWrapper) => { +export const jsonbObjectAgg = ( + key: SQLWrapper, + value: SQL | SQLWrapper, +) => { return sql< Record >`jsonb_object_agg(${sql.join([key, value], sql.raw(","))})`; diff --git a/api/src/models/video.ts b/api/src/models/video.ts index 01a02e30..226f064d 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -68,44 +68,47 @@ export const SeedVideo = t.Object({ guess: Guess, - for: t.Array( - t.Union([ - t.Object({ - slug: t.String({ - format: "slug", - examples: ["made-in-abyss-dawn-of-the-deep-soul"], - }), - }), - t.Object({ - externalId: t.Optional(t.Union([EpisodeId, ExternalId()])), - }), - t.Object({ - movie: t.Union([ - t.String({ format: "uuid" }), - t.String({ format: "slug", examples: ["bubble"] }), - ]), - }), - t.Intersect([ + for: t.Optional( + t.Array( + t.Union([ t.Object({ - serie: t.Union([ + slug: t.String({ + format: "slug", + examples: ["made-in-abyss-dawn-of-the-deep-soul"], + }), + }), + t.Object({ + externalId: t.Optional(t.Union([EpisodeId, ExternalId()])), + }), + t.Object({ + movie: t.Union([ t.String({ format: "uuid" }), - t.String({ format: "slug", examples: ["made-in-abyss"] }), + t.String({ format: "slug", examples: ["bubble"] }), ]), }), - t.Union([ + t.Intersect([ t.Object({ - season: t.Integer({ minimum: 1 }), - episode: t.Integer(), - }), - t.Object({ - order: t.Number(), - }), - t.Object({ - special: t.Integer(), + serie: t.Union([ + t.String({ format: "uuid" }), + t.String({ format: "slug", examples: ["made-in-abyss"] }), + ]), }), + t.Union([ + t.Object({ + season: t.Integer({ minimum: 1 }), + episode: t.Integer(), + }), + t.Object({ + order: t.Number(), + }), + t.Object({ + special: t.Integer(), + }), + ]), ]), ]), - ]), + { default: [] }, + ), ), }); export type SeedVideo = Prettify; diff --git a/api/tests/videos/scanner.test.ts b/api/tests/videos/scanner.test.ts new file mode 100644 index 00000000..92badcd0 --- /dev/null +++ b/api/tests/videos/scanner.test.ts @@ -0,0 +1,50 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import { eq } from "drizzle-orm"; +import { createVideo } from "tests/helpers"; +import { expectStatus } from "tests/utils"; +import { db } from "~/db"; +import { entries, shows, videos } from "~/db/schema"; + +beforeAll(async () => { + await db.delete(shows); + await db.delete(entries); + await db.delete(videos); +}); + +describe("Video seeding", () => { + it("Can create a video without entry", async () => { + const [resp, body] = await createVideo({ + guess: { title: "mia", from: "test" }, + part: null, + path: "/video/mia s1e13.mkv", + rendering: "sha", + version: 1, + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/mia s1e13.mkv"); + expect(vid!.guess).toBe({ title: "mia", from: "test" }); + + expect(body[0].slug).toBe("mia"); + // videos created without entries should create an /unknown entry. + expect(vid!.evj).toBeArrayOfSize(1); + expect(vid!.evj[0].slug).toBe("mia"); + expect(vid!.evj[0].entry).toMatchObject({ + kind: "unknown", + name: "mia", + // should we store the video path in the unknown entry? + // in db it would be the `description` field + }); + }); +}); From 1fca8957a28e9134f49c9e8093f9f420be769a75 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 30 Apr 2025 14:51:53 +0200 Subject: [PATCH 06/23] Fix auth default PGHOST --- auth/main.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/auth/main.go b/auth/main.go index 837e5a05..96f106b2 100644 --- a/auth/main.go +++ b/auth/main.go @@ -80,7 +80,7 @@ func OpenDatabase() (*pgxpool.Pool, error) { } // Set default values - if config.ConnConfig.Host == "" { + if config.ConnConfig.Host == "/tmp" { config.ConnConfig.Host = "postgres" } if config.ConnConfig.Database == "" { @@ -96,7 +96,7 @@ func OpenDatabase() (*pgxpool.Pool, error) { // by the pgx library. This doesn't cover the case where the system username happens to be in some other part // of the connection string, but this cannot be checked without full connection string parsing. if currentUserName.Username == config.ConnConfig.User && !strings.Contains(connectionString, currentUserName.Username) { - config.ConnConfig.User = "kyoo" + config.ConnConfig.User = "kyoo" } } if config.ConnConfig.Password == "" { @@ -113,7 +113,7 @@ func OpenDatabase() (*pgxpool.Pool, error) { db, err := pgxpool.NewWithConfig(ctx, config) if err != nil { - fmt.Printf("Could not connect to database, check your env variables!") + fmt.Printf("Could not connect to database, check your env variables!\n") return nil, err } From 6194d806cc241670baf74c60494154ba695786ce Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 30 Apr 2025 14:52:17 +0200 Subject: [PATCH 07/23] Delete unknown entries, rework them as part of unmatched videos --- api/src/controllers/entries.ts | 53 +---------------------- api/src/controllers/videos.ts | 62 ++++++++++++++++++++++++++- api/src/db/schema/entries.ts | 1 - api/src/models/entry/index.ts | 1 - api/src/models/entry/unknown-entry.ts | 38 ---------------- api/src/models/examples/index.ts | 1 - api/src/models/examples/others.ts | 10 ----- api/src/models/video.ts | 4 +- 8 files changed, 65 insertions(+), 105 deletions(-) delete mode 100644 api/src/models/entry/unknown-entry.ts delete mode 100644 api/src/models/examples/others.ts diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index 2e213a5d..269178d2 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -26,7 +26,6 @@ import { ExtraType, MovieEntry, Special, - UnknownEntry, } from "~/models/entry"; import { KError } from "~/models/error"; import { madeInAbyss } from "~/models/examples"; @@ -81,11 +80,6 @@ const extraFilters: FilterDef = { playedDate: { column: entryProgressQ.playedDate, type: "date" }, }; -const unknownFilters: FilterDef = { - runtime: { column: entries.runtime, type: "float" }, - playedDate: { column: entryProgressQ.playedDate, type: "date" }, -}; - export const entrySort = Sort( { order: entries.order, @@ -176,7 +170,7 @@ export async function getEntries({ languages: string[]; userId: string; progressQ?: typeof entryProgressQ; -}): Promise<(Entry | Extra | UnknownEntry)[]> { +}): Promise<(Entry | Extra)[]> { const transQ = db .selectDistinctOn([entryTranslations.pk]) .from(entryTranslations) @@ -244,7 +238,6 @@ export const entriesH = new Elysia({ tags: ["series"] }) movie_entry: MovieEntry, special: Special, extra: Extra, - unknown_entry: UnknownEntry, error: t.Object({}), }) .model((models) => ({ @@ -289,7 +282,6 @@ export const entriesH = new Elysia({ tags: ["series"] }) filter: and( eq(entries.showPk, serie.pk), ne(entries.kind, "extra"), - ne(entries.kind, "unknown"), filter, ), languages: langs, @@ -407,46 +399,6 @@ export const entriesH = new Elysia({ tags: ["series"] }) }, }, ) - .get( - "/unknowns", - async ({ - query: { limit, after, query, sort, filter }, - request: { url }, - jwt: { sub }, - }) => { - const items = (await getEntries({ - limit, - after, - query, - sort: sort, - filter: and(eq(entries.kind, "unknown"), filter), - languages: ["extra"], - userId: sub, - })) as UnknownEntry[]; - - return createPage(items, { url, sort, limit }); - }, - { - detail: { description: "Get unknown/unmatch videos." }, - query: t.Object({ - sort: extraSort, - filter: t.Optional(Filter({ def: unknownFilters })), - 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: description.after })), - }), - response: { - 200: Page(UnknownEntry), - 422: KError, - }, - tags: ["videos"], - }, - ) .get( "/news", async ({ @@ -462,7 +414,6 @@ export const entriesH = new Elysia({ tags: ["series"] }) sort, filter: and( isNotNull(entries.availableSince), - ne(entries.kind, "unknown"), ne(entries.kind, "extra"), filter, ), @@ -489,6 +440,6 @@ export const entriesH = new Elysia({ tags: ["series"] }) 200: Page(Entry), 422: KError, }, - tags: ["videos"], + tags: ["shows"], }, ); diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index 81390336..9cd1bdc4 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -1,4 +1,4 @@ -import { and, eq, exists, inArray, not, or, sql } from "drizzle-orm"; +import { and, eq, exists, inArray, not, notExists, or, sql } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import { Elysia, t } from "elysia"; import { db } from "~/db"; @@ -9,8 +9,17 @@ import { jsonbObjectAgg, values, } from "~/db/utils"; +import { KError } from "~/models/error"; import { bubbleVideo } from "~/models/examples"; -import { isUuid } from "~/models/utils"; +import { + Page, + Sort, + createPage, + isUuid, + keysetPaginate, + sortToSql, +} from "~/models/utils"; +import { desc as description } from "~/models/utils/descriptions"; import { Guesses, SeedVideo, Video } from "~/models/video"; import { comment } from "~/utils"; import { computeVideoSlug } from "./seed/insert/entries"; @@ -84,6 +93,55 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) }, }, ) + .get( + "unknowns", + async ({ query: { sort, query, limit, after }, request: { url } }) => { + const ret = await db + .select() + .from(videos) + .where( + and( + notExists( + db + .select() + .from(entryVideoJoin) + .where(eq(videos.pk, entryVideoJoin.videoPk)), + ), + query + ? or( + sql`${videos.path} %> ${query}::text`, + sql`${videos.guess}->'title' %> ${query}::text`, + ) + : undefined, + keysetPaginate({ after, sort }), + ), + ) + .orderBy(...(query ? [] : sortToSql(sort)), videos.pk) + .limit(limit); + return createPage(ret, { url, sort, limit }); + }, + { + detail: { description: "Get unknown/unmatch videos." }, + query: t.Object({ + sort: Sort( + { createdAt: videos.createdAt, path: videos.path }, + { default: ["-createdAt"], tablePk: videos.pk }, + ), + 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: description.after })), + }), + response: { + 200: Page(Video), + 422: KError, + }, + }, + ) .post( "", async ({ body, error }) => { diff --git a/api/src/db/schema/entries.ts b/api/src/db/schema/entries.ts index 63c6298d..0c3a4a60 100644 --- a/api/src/db/schema/entries.ts +++ b/api/src/db/schema/entries.ts @@ -18,7 +18,6 @@ import { image, language, schema } from "./utils"; import { entryVideoJoin } from "./videos"; export const entryType = schema.enum("entry_type", [ - "unknown", "episode", "movie", "special", diff --git a/api/src/models/entry/index.ts b/api/src/models/entry/index.ts index 199622f6..bfdc54dc 100644 --- a/api/src/models/entry/index.ts +++ b/api/src/models/entry/index.ts @@ -17,4 +17,3 @@ export * from "./episode"; export * from "./movie-entry"; export * from "./special"; export * from "./extra"; -export * from "./unknown-entry"; diff --git a/api/src/models/entry/unknown-entry.ts b/api/src/models/entry/unknown-entry.ts deleted file mode 100644 index 5ae1c811..00000000 --- a/api/src/models/entry/unknown-entry.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { t } from "elysia"; -import { type Prettify, comment } from "~/utils"; -import { bubbleImages, registerExamples, youtubeExample } from "../examples"; -import { Progress } from "../history"; -import { DbMetadata, Resource } from "../utils"; -import { BaseEntry, EntryTranslation } from "./base-entry"; - -export const BaseUnknownEntry = t.Intersect( - [ - t.Object({ - kind: t.Literal("unknown"), - }), - t.Omit(BaseEntry(), ["airDate"]), - ], - { - description: comment` - A video not releated to any series or movie. This can be due to a matching error but it can be a youtube - video or any other video content. - `, - }, -); - -export const UnknownEntryTranslation = t.Omit(EntryTranslation(), [ - "description", -]); - -export const UnknownEntry = t.Intersect([ - Resource(), - UnknownEntryTranslation, - BaseUnknownEntry, - t.Object({ - progress: t.Omit(Progress, ["videoId"]), - }), - DbMetadata, -]); -export type UnknownEntry = Prettify; - -registerExamples(UnknownEntry, { ...youtubeExample, ...bubbleImages }); diff --git a/api/src/models/examples/index.ts b/api/src/models/examples/index.ts index 7bc7a3ba..a5601358 100644 --- a/api/src/models/examples/index.ts +++ b/api/src/models/examples/index.ts @@ -35,4 +35,3 @@ export * from "./made-in-abyss"; export * from "./dune-1984"; export * from "./dune-2021"; export * from "./dune-collection"; -export * from "./others"; diff --git a/api/src/models/examples/others.ts b/api/src/models/examples/others.ts deleted file mode 100644 index 41de7f6e..00000000 --- a/api/src/models/examples/others.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { UnknownEntry } from "~/models/entry"; - -export const youtubeExample: Partial = { - kind: "unknown", - // idk if we'll keep non-ascii characters or if we can find a way to convert them - slug: "lisa-炎-the-first-take", - name: "LiSA - 炎 / THE FIRST TAKE", - runtime: 10, - thumbnail: null, -}; diff --git a/api/src/models/video.ts b/api/src/models/video.ts index 226f064d..c36635fe 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -114,7 +114,9 @@ export const SeedVideo = t.Object({ export type SeedVideo = Prettify; export const Video = t.Intersect([ - Resource(), + t.Object({ + id: t.String({ format: "uuid" }), + }), t.Omit(SeedVideo, ["for"]), DbMetadata, ]); From ce66dba0c876f1e6256bb3810d29ce5e22660975 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 30 Apr 2025 17:50:57 +0200 Subject: [PATCH 08/23] Add unmatched paths in `GET /videos` (for scanner) --- api/src/controllers/videos.ts | 18 +++++++++++++++++- api/src/models/video.ts | 3 +++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index 9cd1bdc4..5b9298d4 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -84,7 +84,23 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) const paths = await db.select({ path: videos.path }).from(videos); - return { paths: paths.map((x) => x.path), guesses }; + const unmatched = await db + .select({ path: videos.path }) + .from(videos) + .where( + notExists( + db + .select() + .from(entryVideoJoin) + .where(eq(entryVideoJoin.videoPk, videos.pk)), + ), + ); + + return { + paths: paths.map((x) => x.path), + guesses, + unmatched: unmatched.map((x) => x.path), + }; }, { detail: { description: "Get all video registered & guessed made" }, diff --git a/api/src/models/video.ts b/api/src/models/video.ts index c36635fe..4877093a 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -137,6 +137,7 @@ export const Guesses = t.Object({ Resource(), ), ), + unmatched: t.Array(t.String()), }); export type Guesses = typeof Guesses.static; @@ -144,6 +145,7 @@ registerExamples(Guesses, { paths: [ "/videos/Evangelion S01E02.mkv", "/videos/Evangelion (1995) S01E26.mkv", + "/videos/SomeUnknownThing.mkv", ], guesses: { Evangelion: { @@ -157,4 +159,5 @@ registerExamples(Guesses, { }, }, }, + unmatched: ["/videos/SomeUnknownThing.mkv"], }); From d2bb37b3a79e548084551f71d7c45653eff22178 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 30 Apr 2025 18:15:01 +0200 Subject: [PATCH 09/23] Add more tests --- api/tests/videos/scanner.test.ts | 87 ++++++++++++++++++++++++++++---- 1 file changed, 77 insertions(+), 10 deletions(-) diff --git a/api/tests/videos/scanner.test.ts b/api/tests/videos/scanner.test.ts index 92badcd0..89525760 100644 --- a/api/tests/videos/scanner.test.ts +++ b/api/tests/videos/scanner.test.ts @@ -1,24 +1,58 @@ import { beforeAll, describe, expect, it } from "bun:test"; import { eq } from "drizzle-orm"; -import { createVideo } from "tests/helpers"; +import { createMovie, createSerie, createVideo } from "tests/helpers"; import { expectStatus } from "tests/utils"; import { db } from "~/db"; import { entries, shows, videos } from "~/db/schema"; +import { bubble, madeInAbyss } from "~/models/examples"; beforeAll(async () => { await db.delete(shows); await db.delete(entries); await db.delete(videos); + let [ret, body] = await createSerie(madeInAbyss); + expectStatus(ret, body).toBe(201); + [ret, body] = await createMovie(bubble); + expectStatus(ret, body).toBe(201); }); describe("Video seeding", () => { it("Can create a video without entry", async () => { const [resp, body] = await createVideo({ - guess: { title: "mia", from: "test" }, + guess: { title: "unknown", from: "test" }, + part: null, + path: "/video/unknown s1e13.mkv", + rendering: "sha", + version: 1, + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/unknown s1e13.mkv"); + expect(vid!.guess).toBe({ title: "unknown", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(0); + expect(vid!.evj).toBeArrayOfSize(0); + }); + + it("With slug", async () => { + const [resp, body] = await createVideo({ + guess: { title: "mia", season: [1], episode: [13], from: "test" }, part: null, path: "/video/mia s1e13.mkv", rendering: "sha", version: 1, + for: [{ slug: `${madeInAbyss.slug}-s1e13` }], }); expectStatus(resp, body).toBe(201); @@ -36,15 +70,48 @@ describe("Video seeding", () => { expect(vid!.path).toBe("/video/mia s1e13.mkv"); expect(vid!.guess).toBe({ title: "mia", from: "test" }); - expect(body[0].slug).toBe("mia"); - // videos created without entries should create an /unknown entry. + expect(body[0].entries).toBeArrayOfSize(1); expect(vid!.evj).toBeArrayOfSize(1); - expect(vid!.evj[0].slug).toBe("mia"); - expect(vid!.evj[0].entry).toMatchObject({ - kind: "unknown", - name: "mia", - // should we store the video path in the unknown entry? - // in db it would be the `description` field + + expect(vid!.evj[0].slug).toBe(`${madeInAbyss.slug}-s1e13`); + expect(vid!.evj[0].entry.slug).toBe(`${madeInAbyss.slug}-s1e13`); + }); + + it("With season/episode", async () => { + const [resp, body] = await createVideo({ + guess: { title: "mia", season: [1], episode: [13], from: "test" }, + part: null, + path: "/video/mia s1e13.mkv", + rendering: "renderingsha", + version: 1, + for: [ + { + serie: madeInAbyss.slug, + season: madeInAbyss.entries[0].seasonNumber!, + episode: madeInAbyss.entries[0].episodeNumber!, + }, + ], }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/mia s1e13.mkv"); + expect(vid!.guess).toBe({ title: "mia", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(1); + expect(vid!.evj).toBeArrayOfSize(1); + + expect(vid!.evj[0].slug).toBe(`${madeInAbyss.slug}-s1e13-renderingsha`); + expect(vid!.evj[0].entry.slug).toBe(`${madeInAbyss.slug}-s1e13`); }); }); From 07a41bb17514a3caf574ee674587922d3937321b Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 1 May 2025 18:49:19 +0200 Subject: [PATCH 10/23] Fix `POST /videos` --- api/src/controllers/videos.ts | 105 +++++++++++++++++-------------- api/src/db/utils.ts | 1 + api/src/models/video.ts | 7 ++- api/tests/videos/scanner.test.ts | 6 +- shell.nix | 2 +- 5 files changed, 68 insertions(+), 53 deletions(-) diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index 5b9298d4..3a8406d7 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -161,20 +161,49 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) .post( "", async ({ body, error }) => { - const vidsI = db.$with("vidsI").as( - db - .insert(videos) - .values(body) - .onConflictDoUpdate({ - target: [videos.path], - set: conflictUpdateAllExcept(videos, ["pk", "id", "createdAt"]), - }) - .returning({ - pk: videos.pk, - id: videos.id, - path: videos.path, - }), - ); + const vids = await db + .insert(videos) + .values(body) + .onConflictDoUpdate({ + target: [videos.path], + set: conflictUpdateAllExcept(videos, ["pk", "id", "createdAt"]), + }) + .returning({ + pk: videos.pk, + id: videos.id, + path: videos.path, + }); + + const vidEntries = body.flatMap((x) => { + if (!x.for) return []; + return x.for.map((e) => ({ + video: vids.find((v) => v.path === x.path)!.pk, + path: x.path, + needRendering: x.for!.length > 1, + entry: { + ...e, + movie: + "movie" in e + ? isUuid(e.movie) + ? { id: e.movie } + : { slug: e.movie } + : undefined, + serie: + "serie" in e + ? isUuid(e.serie) + ? { id: e.serie } + : { slug: e.serie } + : undefined, + }, + })); + }); + + if (!vidEntries.length) { + return error( + 201, + vids.map((x) => ({ id: x.id, path: x.path, entries: [] })), + ); + } const entriesQ = db .select({ @@ -197,45 +226,18 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) .where(eq(entryVideoJoin.entryPk, entriesQ.pk)); const ret = await db - .with(vidsI) .insert(entryVideoJoin) .select( db .select({ entry: entries.pk, - video: vidsI.pk, + video: sql`j.video`, slug: computeVideoSlug( entriesQ.showSlug, sql`j.needRendering::boolean || exists(${hasRenderingQ})`, ), }) - .from( - values( - body.flatMap((x) => { - if (!x.for) return []; - return x.for.map((e) => ({ - path: x.path, - needRendering: x.for!.length > 1, - entry: { - ...e, - movie: - "movie" in e - ? isUuid(e.movie) - ? { id: e.movie } - : { slug: e.movie } - : undefined, - serie: - "serie" in e - ? isUuid(e.serie) - ? { id: e.serie } - : { slug: e.serie } - : undefined, - }, - })); - }), - ).as("j"), - ) - .innerJoin(vidsI, eq(vidsI.path, sql`j.path`)) + .from(values(vidEntries).as("j")) .innerJoin( entriesQ, or( @@ -279,11 +281,20 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) .returning({ slug: entryVideoJoin.slug, entryPk: entryVideoJoin.entryPk, - id: vidsI.id, - path: vidsI.path, + videoPk: entryVideoJoin.videoPk, }); - return error(201, ret); - // return error(201, ret.map(x => ({ id: x.id, slug: x.}))); + const entr = ret.reduce( + (acc, x) => { + acc[x.videoPk] ??= []; + acc[x.videoPk].push({ slug: x.slug }); + return acc; + }, + {} as Record, + ); + return error( + 201, + vids.map((x) => ({ id: x.id, path: x.path, entries: entr[x.pk] })), + ); }, { detail: { diff --git a/api/src/db/utils.ts b/api/src/db/utils.ts index 5681eac9..71950631 100644 --- a/api/src/db/utils.ts +++ b/api/src/db/utils.ts @@ -76,6 +76,7 @@ export function sqlarr(array: unknown[]) { // See https://github.com/drizzle-team/drizzle-orm/issues/4044 // TODO: type values (everything is a `text` for now) export function values(items: Record[]) { + if (items[0] === undefined) throw new Error("Invalid values, expecting at least one items") const [firstProp, ...props] = Object.keys(items[0]); const values = items .map((x) => { diff --git a/api/src/models/video.ts b/api/src/models/video.ts index 4877093a..2cd899e6 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -122,8 +122,11 @@ export const Video = t.Intersect([ ]); export type Video = Prettify; -// type used in entry responses -export const EmbeddedVideo = t.Omit(Video, ["guess", "createdAt", "updatedAt"]); +// type used in entry responses (the slug comes from the entryVideoJoin) +export const EmbeddedVideo = t.Intersect([ + t.Object({ slug: t.String({ format: "slug" }) }), + t.Omit(Video, ["guess", "createdAt", "updatedAt"]), +]); export type EmbeddedVideo = Prettify; registerExamples(Video, bubbleVideo); diff --git a/api/tests/videos/scanner.test.ts b/api/tests/videos/scanner.test.ts index 89525760..02bd7535 100644 --- a/api/tests/videos/scanner.test.ts +++ b/api/tests/videos/scanner.test.ts @@ -39,7 +39,7 @@ describe("Video seeding", () => { expect(vid).not.toBeNil(); expect(vid!.path).toBe("/video/unknown s1e13.mkv"); - expect(vid!.guess).toBe({ title: "unknown", from: "test" }); + expect(vid!.guess).toMatchObject({ title: "unknown", from: "test" }); expect(body[0].entries).toBeArrayOfSize(0); expect(vid!.evj).toBeArrayOfSize(0); @@ -68,7 +68,7 @@ describe("Video seeding", () => { expect(vid).not.toBeNil(); expect(vid!.path).toBe("/video/mia s1e13.mkv"); - expect(vid!.guess).toBe({ title: "mia", from: "test" }); + expect(vid!.guess).toMatchObject({ title: "mia", from: "test" }); expect(body[0].entries).toBeArrayOfSize(1); expect(vid!.evj).toBeArrayOfSize(1); @@ -106,7 +106,7 @@ describe("Video seeding", () => { expect(vid).not.toBeNil(); expect(vid!.path).toBe("/video/mia s1e13.mkv"); - expect(vid!.guess).toBe({ title: "mia", from: "test" }); + expect(vid!.guess).toMatchObject({ title: "mia", from: "test" }); expect(body[0].entries).toBeArrayOfSize(1); expect(vid!.evj).toBeArrayOfSize(1); diff --git a/shell.nix b/shell.nix index b7ce3bfb..18e08f33 100644 --- a/shell.nix +++ b/shell.nix @@ -20,7 +20,7 @@ in pkgs.mkShell { packages = with pkgs; [ - nodejs-18_x + # nodejs-18_x nodePackages.yarn dotnet csharpier From 060c4d74b4f1df3f42afc8eda65b088da391c206 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 1 May 2025 19:13:13 +0200 Subject: [PATCH 11/23] Type value lists --- api/src/controllers/profiles/history.ts | 34 +++++++++++++--------- api/src/controllers/seed/insert/entries.ts | 16 ++++++---- api/src/controllers/videos.ts | 14 ++++++--- api/src/db/utils.ts | 17 +++++++---- 4 files changed, 53 insertions(+), 28 deletions(-) diff --git a/api/src/controllers/profiles/history.ts b/api/src/controllers/profiles/history.ts index ec605ea1..660922c1 100644 --- a/api/src/controllers/profiles/history.ts +++ b/api/src/controllers/profiles/history.ts @@ -167,8 +167,14 @@ export const historyH = new Elysia({ tags: ["profiles"] }) async ({ body, jwt: { sub }, error }) => { const profilePk = await getOrCreateProfile(sub); - const vals = values( + const hist = values( body.map((x) => ({ ...x, entryUseId: isUuid(x.entry) })), + { + percent: "integer", + time: "integer", + playedDate: "timestamptz", + videoId: "uuid", + }, ).as("hist"); const valEqEntries = sql` case @@ -185,13 +191,13 @@ export const historyH = new Elysia({ tags: ["profiles"] }) profilePk: sql`${profilePk}`, entryPk: entries.pk, videoPk: videos.pk, - percent: sql`hist.percent::integer`, - time: sql`hist.time::integer`, - playedDate: sql`hist.playedDate::timestamptz`, + percent: sql`hist.percent`, + time: sql`hist.time`, + playedDate: sql`hist.playedDate`, }) - .from(vals) + .from(hist) .innerJoin(entries, valEqEntries) - .leftJoin(videos, eq(videos.id, sql`hist.videoId::uuid`)), + .leftJoin(videos, eq(videos.id, sql`hist.videoId`)), ) .returning({ pk: history.pk }); @@ -249,7 +255,7 @@ export const historyH = new Elysia({ tags: ["profiles"] }) status: sql` case when - hist.percent::integer >= 95 + hist.percent >= 95 and ${nextEntryQ.pk} is null then 'completed'::watchlist_status else 'watching'::watchlist_status @@ -257,30 +263,30 @@ export const historyH = new Elysia({ tags: ["profiles"] }) `, seenCount: sql` case - when ${entries.kind} = 'movie' then hist.percent::integer - when hist.percent::integer >= 95 then 1 + when ${entries.kind} = 'movie' then hist.percent + when hist.percent >= 95 then 1 else 0 end `, nextEntry: sql` case - when hist.percent::integer >= 95 then ${nextEntryQ.pk} + when hist.percent >= 95 then ${nextEntryQ.pk} else ${entries.pk} end `, score: sql`null`, - startedAt: sql`hist.playedDate::timestamptz`, - lastPlayedAt: sql`hist.playedDate::timestamptz`, + startedAt: sql`hist.playedDate`, + lastPlayedAt: sql`hist.playedDate`, completedAt: sql` case - when ${nextEntryQ.pk} is null then hist.playedDate::timestamptz + when ${nextEntryQ.pk} is null then hist.playedDate else null end `, // see https://github.com/drizzle-team/drizzle-orm/issues/3608 updatedAt: sql`now()`, }) - .from(vals) + .from(hist) .leftJoin(entries, valEqEntries) .leftJoinLateral(nextEntryQ, sql`true`), ) diff --git a/api/src/controllers/seed/insert/entries.ts b/api/src/controllers/seed/insert/entries.ts index 10ce07fa..ee62d4a8 100644 --- a/api/src/controllers/seed/insert/entries.ts +++ b/api/src/controllers/seed/insert/entries.ts @@ -167,15 +167,21 @@ export const insertEntries = async ( .select( db .select({ - entryPk: sql`vids.entryPk::integer`.as("entry"), + entryPk: sql`vids.entryPk`.as("entry"), videoPk: videos.pk, slug: computeVideoSlug( - sql`vids.entrySlug::text`, - sql`vids.needRendering::boolean`, + sql`vids.entrySlug`, + sql`vids.needRendering`, ), }) - .from(values(vids).as("vids")) - .innerJoin(videos, eq(videos.id, sql`vids.videoId::uuid`)), + .from( + values(vids, { + entryPk: "integer", + needRendering: "boolean", + videoId: "uuid", + }).as("vids"), + ) + .innerJoin(videos, eq(videos.id, sql`vids.videoId`)), ) .onConflictDoNothing() .returning({ diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index 3a8406d7..ded21415 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -230,14 +230,20 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) .select( db .select({ - entry: entries.pk, - video: sql`j.video`, + entryPk: entriesQ.pk, + videoPk: sql`j.video`, slug: computeVideoSlug( entriesQ.showSlug, - sql`j.needRendering::boolean || exists(${hasRenderingQ})`, + sql`j.needRendering || exists(${hasRenderingQ})`, ), }) - .from(values(vidEntries).as("j")) + .from( + values(vidEntries, { + video: "integer", + needRendering: "boolean", + entry: "jsonb", + }).as("j"), + ) .innerJoin( entriesQ, or( diff --git a/api/src/db/utils.ts b/api/src/db/utils.ts index 71950631..30bc0b83 100644 --- a/api/src/db/utils.ts +++ b/api/src/db/utils.ts @@ -74,15 +74,22 @@ export function sqlarr(array: unknown[]) { } // See https://github.com/drizzle-team/drizzle-orm/issues/4044 -// TODO: type values (everything is a `text` for now) -export function values(items: Record[]) { - if (items[0] === undefined) throw new Error("Invalid values, expecting at least one items") - const [firstProp, ...props] = Object.keys(items[0]); +export function values( + items: Record[], + typeInfo: Partial> = {}, +) { + if (items[0] === undefined) + throw new Error("Invalid values, expecting at least one items"); + const [firstProp, ...props] = Object.keys(items[0]) as K[]; const values = items - .map((x) => { + .map((x, i) => { let ret = sql`(${x[firstProp]}`; + if (i === 0 && typeInfo[firstProp]) + ret = sql`${ret}::${sql.raw(typeInfo[firstProp])}`; for (const val of props) { ret = sql`${ret}, ${x[val]}`; + if (i === 0 && typeInfo[val]) + ret = sql`${ret}::${sql.raw(typeInfo[val])}`; } return sql`${ret})`; }) From 6a5862ddd2393fbd158a6849f06f198913f6c80d Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 1 May 2025 21:38:51 +0200 Subject: [PATCH 12/23] Fix & test movie, episodes & slug linking --- api/src/controllers/profiles/history.ts | 2 - api/src/controllers/seed/insert/entries.ts | 4 +- api/src/controllers/videos.ts | 49 ++++++--- api/src/models/examples/made-in-abyss.ts | 1 - api/src/models/video.ts | 2 +- api/tests/manual.ts | 35 +++--- api/tests/series/get-entries.test.ts | 4 +- api/tests/series/seed-serie.test.ts | 2 +- api/tests/videos/scanner.test.ts | 120 +++++++++++++++++++-- 9 files changed, 172 insertions(+), 47 deletions(-) diff --git a/api/src/controllers/profiles/history.ts b/api/src/controllers/profiles/history.ts index 660922c1..02b57cf2 100644 --- a/api/src/controllers/profiles/history.ts +++ b/api/src/controllers/profiles/history.ts @@ -79,7 +79,6 @@ export const historyH = new Elysia({ tags: ["profiles"] }) filter: and( isNotNull(entryProgressQ.playedDate), ne(entries.kind, "extra"), - ne(entries.kind, "unknown"), filter, ), languages: langs, @@ -125,7 +124,6 @@ export const historyH = new Elysia({ tags: ["profiles"] }) filter: and( isNotNull(entryProgressQ.playedDate), ne(entries.kind, "extra"), - ne(entries.kind, "unknown"), filter, ), languages: langs, diff --git a/api/src/controllers/seed/insert/entries.ts b/api/src/controllers/seed/insert/entries.ts index ee62d4a8..941e635b 100644 --- a/api/src/controllers/seed/insert/entries.ts +++ b/api/src/controllers/seed/insert/entries.ts @@ -212,10 +212,10 @@ export const insertEntries = async ( })); }; -export function computeVideoSlug(showSlug: SQL | Column, needsRendering: SQL) { +export function computeVideoSlug(entrySlug: SQL | Column, needsRendering: SQL) { return sql` concat( - ${showSlug}, + ${entrySlug}, case when ${videos.part} is not null then ('-p' || ${videos.part}) else '' end, case when ${videos.version} <> 1 then ('-v' || ${videos.version}) else '' end, case when ${needsRendering} then concat('-', ${videos.rendering}) else '' end diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index ded21415..b9d51e03 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -210,11 +210,12 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) pk: entries.pk, id: entries.id, slug: entries.slug, + kind: entries.kind, seasonNumber: entries.seasonNumber, episodeNumber: entries.episodeNumber, order: entries.order, - showId: shows.id, - showSlug: shows.slug, + showId: sql`${shows.id}`.as("showId"), + showSlug: sql`${shows.slug}`.as("showSlug"), }) .from(entries) .innerJoin(shows, eq(entries.showPk, shows.pk)) @@ -231,10 +232,10 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) db .select({ entryPk: entriesQ.pk, - videoPk: sql`j.video`, + videoPk: videos.pk, slug: computeVideoSlug( - entriesQ.showSlug, - sql`j.needRendering || exists(${hasRenderingQ})`, + entriesQ.slug, + sql`j.needRendering or exists(${hasRenderingQ})`, ), }) .from( @@ -244,39 +245,51 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) entry: "jsonb", }).as("j"), ) + .innerJoin(videos, eq(videos.pk, sql`j.video`)) .innerJoin( entriesQ, or( and( sql`j.entry ? 'slug'`, - eq(entriesQ.slug, sql`j.entry->'slug'`), + eq(entriesQ.slug, sql`j.entry->>'slug'`), ), and( sql`j.entry ? 'movie'`, or( - eq(entriesQ.showId, sql`j.entry #> '{movie, id}'`), - eq(entriesQ.showSlug, sql`j.entry #> '{movie, slug}'`), + eq(entriesQ.showId, sql`(j.entry #>> '{movie, id}')::uuid`), + eq(entriesQ.showSlug, sql`j.entry #>> '{movie, slug}'`), ), + eq(entriesQ.kind, "movie"), ), and( sql`j.entry ? 'serie'`, or( - eq(entriesQ.showId, sql`j.entry #> '{serie, id}'`), - eq(entriesQ.showSlug, sql`j.entry #> '{serie, slug}'`), + eq(entriesQ.showId, sql`(j.entry #>> '{serie, id}')::uuid`), + eq(entriesQ.showSlug, sql`j.entry #>> '{serie, slug}'`), ), or( and( sql`j.entry ?& array['season', 'episode']`, - eq(entriesQ.seasonNumber, sql`j.entry->'season'`), - eq(entriesQ.episodeNumber, sql`j.entry->'episode'`), + eq( + entriesQ.seasonNumber, + sql`(j.entry->>'season')::integer`, + ), + eq( + entriesQ.episodeNumber, + sql`(j.entry->>'episode')::integer`, + ), ), and( sql`j.entry ? 'order'`, - eq(entriesQ.order, sql`j.entry->'order'`), + eq(entriesQ.order, sql`(j.entry->>'order')::float`), ), and( sql`j.entry ? 'special'`, - eq(entriesQ.episodeNumber, sql`j.entry->'special'`), + eq( + entriesQ.episodeNumber, + sql`(j.entry->>'special')::integer`, + ), + eq(entriesQ.kind, "special"), ), ), ), @@ -299,7 +312,11 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) ); return error( 201, - vids.map((x) => ({ id: x.id, path: x.path, entries: entr[x.pk] })), + vids.map((x) => ({ + id: x.id, + path: x.path, + entries: entr[x.pk] ?? [], + })), ); }, { @@ -313,7 +330,7 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) `, }, body: t.Array(SeedVideo), - // response: { 201: t.Array(CreatedVideo) }, + response: { 201: t.Array(CreatedVideo) }, }, ) .delete( diff --git a/api/src/models/examples/made-in-abyss.ts b/api/src/models/examples/made-in-abyss.ts index addcd277..58d1dfcf 100644 --- a/api/src/models/examples/made-in-abyss.ts +++ b/api/src/models/examples/made-in-abyss.ts @@ -3,7 +3,6 @@ import type { Video } from "~/models/video"; export const madeInAbyssVideo: Video = { id: "3cd436ee-01ff-4f45-ba98-654282531234", - slug: "made-in-abyss-s1e13", path: "/video/Made in abyss S01E13.mkv", rendering: "459429fa062adeebedcc2bb04b9965de0262bfa453369783132d261be79021bd", part: null, diff --git a/api/src/models/video.ts b/api/src/models/video.ts index 2cd899e6..04b98bd8 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -78,7 +78,7 @@ export const SeedVideo = t.Object({ }), }), t.Object({ - externalId: t.Optional(t.Union([EpisodeId, ExternalId()])), + externalId: t.Union([EpisodeId, ExternalId()]), }), t.Object({ movie: t.Union([ diff --git a/api/tests/manual.ts b/api/tests/manual.ts index 6ad8c521..9c7661d5 100644 --- a/api/tests/manual.ts +++ b/api/tests/manual.ts @@ -1,29 +1,34 @@ import { db, migrate } from "~/db"; import { profiles, shows } from "~/db/schema"; import { madeInAbyss } from "~/models/examples"; -import { createSerie, getSerie, setSerieStatus } from "./helpers"; -import { getJwtHeaders } from "./helpers/jwt"; +import { createSerie, createVideo } from "./helpers"; // test file used to run manually using `bun tests/manual.ts` +// run those before running this script +// export JWT_SECRET="this is a secret"; +// export JWT_ISSUER="https://kyoo.zoriya.dev"; + await migrate(); await db.delete(shows); await db.delete(profiles); -console.log(await getJwtHeaders()); - -const [_, ser] = await createSerie(madeInAbyss); +const [__, ser] = await createSerie(madeInAbyss); console.log(ser); -const [__, ret] = await setSerieStatus(madeInAbyss.slug, { - status: "watching", - startedAt: "2024-12-21", - completedAt: "2024-12-21", - seenCount: 2, - score: 85, +const [_, body] = await createVideo({ + guess: { title: "mia", season: [1], episode: [13], from: "test" }, + part: null, + path: "/video/mia s1e13.mkv", + rendering: "renderingsha", + version: 1, + for: [ + { + serie: madeInAbyss.slug, + season: madeInAbyss.entries[0].seasonNumber!, + episode: madeInAbyss.entries[0].episodeNumber!, + }, + ], }); -console.log(ret); - -const [___, got] = await getSerie(madeInAbyss.slug, {}); -console.log(JSON.stringify(got, undefined, 4)); +console.log(body); process.exit(0); diff --git a/api/tests/series/get-entries.test.ts b/api/tests/series/get-entries.test.ts index 1a31465d..67cd70c7 100644 --- a/api/tests/series/get-entries.test.ts +++ b/api/tests/series/get-entries.test.ts @@ -48,7 +48,7 @@ describe("Get entries", () => { expect(body.items[0].videos).toBeArrayOfSize(1); expect(body.items[0].videos[0]).toMatchObject({ path: madeInAbyssVideo.path, - slug: madeInAbyssVideo.slug, + slug: `${madeInAbyss.slug}-s1e13`, version: madeInAbyssVideo.version, rendering: madeInAbyssVideo.rendering, part: madeInAbyssVideo.part, @@ -63,7 +63,7 @@ describe("Get entries", () => { expect(body.items[0].videos).toBeArrayOfSize(1); expect(body.items[0].videos[0]).toMatchObject({ path: madeInAbyssVideo.path, - slug: madeInAbyssVideo.slug, + slug: `${madeInAbyss.slug}-s1e13`, version: madeInAbyssVideo.version, rendering: madeInAbyssVideo.rendering, part: madeInAbyssVideo.part, diff --git a/api/tests/series/seed-serie.test.ts b/api/tests/series/seed-serie.test.ts index d6a0111f..44c73f9d 100644 --- a/api/tests/series/seed-serie.test.ts +++ b/api/tests/series/seed-serie.test.ts @@ -57,7 +57,7 @@ describe("Serie seeding", () => { ], evj: [ expect.objectContaining({ - slug: madeInAbyssVideo.slug, + slug: `${madeInAbyss.slug}-s1e13`, video: expect.objectContaining({ path: madeInAbyssVideo.path }), }), ], diff --git a/api/tests/videos/scanner.test.ts b/api/tests/videos/scanner.test.ts index 02bd7535..c5302079 100644 --- a/api/tests/videos/scanner.test.ts +++ b/api/tests/videos/scanner.test.ts @@ -77,18 +77,50 @@ describe("Video seeding", () => { expect(vid!.evj[0].entry.slug).toBe(`${madeInAbyss.slug}-s1e13`); }); + it("With movie", async () => { + const [resp, body] = await createVideo({ + guess: { title: "bubble", from: "test" }, + part: null, + path: "/video/bubble.mkv", + rendering: "sha", + version: 1, + for: [{ movie: bubble.slug }], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/bubble.mkv"); + expect(vid!.guess).toMatchObject({ title: "bubble", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(1); + expect(vid!.evj).toBeArrayOfSize(1); + + expect(vid!.evj[0].slug).toBe(bubble.slug); + expect(vid!.evj[0].entry.slug).toBe(bubble.slug); + }); + it("With season/episode", async () => { const [resp, body] = await createVideo({ - guess: { title: "mia", season: [1], episode: [13], from: "test" }, + guess: { title: "mia", season: [2], episode: [1], from: "test" }, part: null, - path: "/video/mia s1e13.mkv", + path: "/video/mia s2e1.mkv", rendering: "renderingsha", version: 1, for: [ { serie: madeInAbyss.slug, - season: madeInAbyss.entries[0].seasonNumber!, - episode: madeInAbyss.entries[0].episodeNumber!, + season: madeInAbyss.entries[3].seasonNumber!, + episode: madeInAbyss.entries[3].episodeNumber!, }, ], }); @@ -105,13 +137,87 @@ describe("Video seeding", () => { }); expect(vid).not.toBeNil(); - expect(vid!.path).toBe("/video/mia s1e13.mkv"); + expect(vid!.path).toBe("/video/mia s2e1.mkv"); expect(vid!.guess).toMatchObject({ title: "mia", from: "test" }); expect(body[0].entries).toBeArrayOfSize(1); expect(vid!.evj).toBeArrayOfSize(1); - expect(vid!.evj[0].slug).toBe(`${madeInAbyss.slug}-s1e13-renderingsha`); - expect(vid!.evj[0].entry.slug).toBe(`${madeInAbyss.slug}-s1e13`); + expect(vid!.evj[0].slug).toBe(`${madeInAbyss.slug}-s2e1`); + expect(vid!.evj[0].entry.slug).toBe(`${madeInAbyss.slug}-s2e1`); + }); + + it("With special", async () => { + const [resp, body] = await createVideo({ + guess: { title: "mia", season: [0], episode: [3], from: "test" }, + part: null, + path: "/video/mia sp3.mkv", + rendering: "notehu", + version: 1, + for: [ + { + serie: madeInAbyss.slug, + special: madeInAbyss.entries[1].number!, + }, + ], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/mia sp3.mkv"); + expect(vid!.guess).toMatchObject({ title: "mia", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(1); + expect(vid!.evj).toBeArrayOfSize(1); + + expect(vid!.evj[0].slug).toBe(`${madeInAbyss.slug}-sp3`); + expect(vid!.evj[0].entry.slug).toBe(`${madeInAbyss.slug}-sp3`); + }); + + it("With order", async () => { + const [resp, body] = await createVideo({ + guess: { title: "mia", season: [0], episode: [3], from: "test" }, + part: null, + path: "/video/mia 13.5.mkv", + rendering: "notehu", + version: 1, + for: [ + { + serie: madeInAbyss.slug, + order: 13.5, + }, + ], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/mia 13.5.mkv"); + expect(vid!.guess).toMatchObject({ title: "mia", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(1); + expect(vid!.evj).toBeArrayOfSize(1); + + expect(vid!.evj[0].slug).toBe("made-in-abyss-dawn-of-the-deep-soul"); + expect(vid!.evj[0].entry.slug).toBe("made-in-abyss-dawn-of-the-deep-soul"); }); }); From 9cb9301a354927410ba138de352d39b5b591beb2 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 2 May 2025 00:10:17 +0200 Subject: [PATCH 13/23] Update packages (drizzle included) --- api/bun.lock | 108 ++++++------ api/package.json | 16 +- api/patches/drizzle-orm@0.39.0.patch | 251 --------------------------- api/patches/drizzle-orm@0.43.1.patch | 72 ++++++++ shell.nix | 1 + 5 files changed, 131 insertions(+), 317 deletions(-) delete mode 100644 api/patches/drizzle-orm@0.39.0.patch create mode 100644 api/patches/drizzle-orm@0.43.1.patch diff --git a/api/bun.lock b/api/bun.lock index becedc29..912a6649 100644 --- a/api/bun.lock +++ b/api/bun.lock @@ -6,23 +6,23 @@ "dependencies": { "@elysiajs/swagger": "zoriya/elysia-swagger#build", "blurhash": "^2.0.5", - "drizzle-kit": "^0.30.4", - "drizzle-orm": "0.39.0", - "elysia": "^1.2.23", + "drizzle-kit": "^0.31.0", + "drizzle-orm": "0.43.1", + "elysia": "^1.3.0-exp.71", "jose": "^6.0.10", "parjs": "^1.3.9", - "pg": "^8.13.3", - "sharp": "^0.34.0", + "pg": "^8.15.6", + "sharp": "^0.34.1", }, "devDependencies": { - "@types/pg": "^8.11.11", - "bun-types": "^1.2.4", + "@types/pg": "^8.11.14", + "bun-types": "^1.2.11", "node-addon-api": "^8.3.1", }, }, }, "patchedDependencies": { - "drizzle-orm@0.39.0": "patches/drizzle-orm@0.39.0.patch", + "drizzle-orm@0.43.1": "patches/drizzle-orm@0.43.1.patch", }, "packages": { "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], @@ -35,51 +35,55 @@ "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-W8bFfPA8DowP8l//sxjJLSLkD8iEjMc7cBVyP+u4cEv9sM7mdUCkgsj+t0n/BWPFtv7WWCN5Yzj0N6FJNUUqBQ=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.3", "", { "os": "android", "cpu": "arm" }, "sha512-PuwVXbnP87Tcff5I9ngV0lmiSu40xw1At6i3GsU77U7cjDDB4s0X2cyFuBiDa1SBk9DnvWwnGvVaGBqoFWPb7A=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.19.12", "", { "os": "android", "cpu": "arm64" }, "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.3", "", { "os": "android", "cpu": "arm64" }, "sha512-XelR6MzjlZuBM4f5z2IQHK6LkK34Cvv6Rj2EntER3lwCBFdg6h2lKbtRjpTTsdEjD/WSe1q8UyPBXP1x3i/wYQ=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.19.12", "", { "os": "android", "cpu": "x64" }, "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.3", "", { "os": "android", "cpu": "x64" }, "sha512-ogtTpYHT/g1GWS/zKM0cc/tIebFjm1F9Aw1boQ2Y0eUQ+J89d0jFY//s9ei9jVIlkYi8AfOjiixcLJSGNSOAdQ=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.19.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eESK5yfPNTqpAmDfFWNsOhmIOaQA59tAcF/EfYvo5/QWQCzXn5iUSOnqt3ra3UdzBv073ykTtmeLJZGt3HhA+w=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.19.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Kd8glo7sIZtwOLcPbW0yLpKmBNWMANZhrC1r6K++uDR2zyzb6AeOYtI6udbtabmQpFaxJ8uduXMAo1gs5ozz8A=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.19.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EJiyS70BYybOBpJth3M0KLOus0n+RRMKTYzhYhFeMwp7e/RaajXvP+BWlmEXNk6uk+KAu46j/kaQzr6au+JcIw=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.19.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Q+wSjaLpGxYf7zC0kL0nDlhsfuFkoN+EXrx2KSB33RhinWzejOd6AvgmP5JbkgXKmjhmpfgKZq24pneodYqE8Q=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.19.12", "", { "os": "linux", "cpu": "arm" }, "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.3", "", { "os": "linux", "cpu": "arm" }, "sha512-dUOVmAUzuHy2ZOKIHIKHCm58HKzFqd+puLaS424h6I85GlSDRZIA5ycBixb3mFgM0Jdh+ZOSB6KptX30DD8YOQ=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.19.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-xCUgnNYhRD5bb1C1nqrDV1PfkwgbswTTBRbAd8aH5PhYzikdf/ddtsYyMXFfGSsb/6t6QaPSzxtbfAZr9uox4A=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.19.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yplPOpczHOO4jTYKmuYuANI3WhvIPSVANGcNUeMlxH4twz/TeXuzEP41tGKNGWJjuMhotpGabeFYGAOU2ummBw=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.3", "", { "os": "linux", "cpu": "none" }, "sha512-P4BLP5/fjyihmXCELRGrLd793q/lBtKMQl8ARGpDxgzgIKJDRJ/u4r1A/HgpBpKpKZelGct2PGI4T+axcedf6g=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.3", "", { "os": "linux", "cpu": "none" }, "sha512-eRAOV2ODpu6P5divMEMa26RRqb2yUoYsuQQOuFUexUoQndm4MdpXXDBbUoKIc0iPa4aCO7gIhtnYomkn2x+bag=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.19.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZC4jV2p7VbzTlnl8nZKLcBkfzIf4Yad1SJM4ZMKYnJqZFD4rTI+pBG65u8ev4jk3/MPwY9DvGn50wi3uhdaghg=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.3", "", { "os": "linux", "cpu": "none" }, "sha512-LDDODcFzNtECTrUUbVCs6j9/bDVqy7DDRsuIXJg6so+mFksgwG7ZVnTruYi5V+z3eE5y+BJZw7VvUadkbfg7QA=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.19.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-s+w/NOY2k0yC2p9SLen+ymflgcpRkvwwa02fqmAwhBRI3SC12uiS10edHHXlVWwfAagYSY5UpmT/zISXPMW3tQ=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.19.12", "", { "os": "linux", "cpu": "x64" }, "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.3", "", { "os": "linux", "cpu": "x64" }, "sha512-nQHDz4pXjSDC6UfOE1Fw9Q8d6GCAd9KdvMZpfVGWSJztYCarRgSDfOVBY5xwhQXseiyxapkiSJi/5/ja8mRFFA=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.19.12", "", { "os": "none", "cpu": "x64" }, "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.3", "", { "os": "none", "cpu": "arm64" }, "sha512-1QaLtOWq0mzK6tzzp0jRN3eccmN3hezey7mhLnzC6oNlJoUJz4nym5ZD7mDnS/LZQgkrhEbEiTn515lPeLpgWA=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.19.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.3", "", { "os": "none", "cpu": "x64" }, "sha512-i5Hm68HXHdgv8wkrt+10Bc50zM0/eonPb/a/OFVfB6Qvpiirco5gBA5bz7S2SHuU+Y4LWn/zehzNX14Sp4r27g=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.19.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-zGAVApJEYTbOC6H/3QBr2mq3upG/LBEXr85/pTtKiv2IXcgKV0RT0QA/hSXZqSvLEpXeIxah7LczB4lkiYhTAQ=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.19.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-fpqctI45NnCIDKBH5AXQBsD0NDPbEFczK98hk/aa6HJxbl+UtLkJV2+Bvy5hLSLk3LHmqt0NTkKNso1A9y1a4w=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.19.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-qspTZOIGoXVS4DpNqUYUs9UxVb04khS1Degaw/MnfMe7goQ3lTfQ13Vw4qY/Nj0979BGvMRpAYbs/BAxEvU8ew=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ICgUR+kPimx0vvRzf+N/7L7tVSQeE3BYY+NhHRHXS1kBuPO7z2+7ea2HbhDyZdTephgvNvKrlDDKUexuCVBVvg=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.1", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.1.0" }, "os": "darwin", "cpu": "arm64" }, "sha512-pn44xgBtgpEbZsu+lWf2KNb6OAf70X68k+yk69Ic2Xz11zHR/w24/U49XT7AeRwJ0Px+mhALhU5LPci1Aymk7A=="], @@ -121,21 +125,17 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.1", "", { "os": "win32", "cpu": "x64" }, "sha512-hw1iIAHpNE8q3uMIRCgGOeDoz9KtFNarFLQclLxr/LK1VBkj8nby18RjFvr6aP7USRYAjTZW6yisnBWMX571Tw=="], - "@petamoriken/float16": ["@petamoriken/float16@3.9.2", "", {}, "sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog=="], - "@scalar/openapi-types": ["@scalar/openapi-types@0.1.9", "", {}, "sha512-HQQudOSQBU7ewzfnBW9LhDmBE2XOJgSfwrh5PlUB7zJup/kaRkBGNgV2wMjNz9Af/uztiU/xNrO179FysmUT+g=="], "@scalar/themes": ["@scalar/themes@0.9.81", "", { "dependencies": { "@scalar/types": "0.1.3" } }, "sha512-asTgdqo8ZYibBBWVYy0503qPx3cvwDlYNuc/cLbrCmTav0MAEL4wNb/gz9iScMVSMwhdkSkL5g9LPdr2mQrHzw=="], "@scalar/types": ["@scalar/types@0.1.3", "", { "dependencies": { "@scalar/openapi-types": "0.1.9", "@unhead/schema": "^1.11.11", "zod": "^3.23.8" } }, "sha512-Fxtgjp5wHhTzXiyODYWIoTsTy3oFC70vme+0I7MNwd8i6D8qplFNnpURueqBuP4MglBM2ZhFv3hPLw4D69anDA=="], - "@sinclair/typebox": ["@sinclair/typebox@0.34.31", "", {}, "sha512-qQ71T9DsITbX3dVCrcBERbs11YuSMg3wZPnT472JhqhWGPdiLgyvihJXU8m+ADJtJvRdjATIiACJD22dEknBrQ=="], + "@sinclair/typebox": ["@sinclair/typebox@0.34.33", "", {}, "sha512-5HAV9exOMcXRUxo+9iYB5n09XxzCXnfy4VTNW4xnDv+FgjzAGY989C28BIdljKqmF+ZltUwujE3aossvcVtq6g=="], "@types/node": ["@types/node@22.13.13", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-ClsL5nMwKaBRwPcCvH8E7+nU4GxHVx1axNvMZTFHMEfNI7oahimt26P5zjVCRrjiIWj6YFXfE1v3dEp94wLcGQ=="], - "@types/pg": ["@types/pg@8.11.11", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^4.0.1" } }, "sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw=="], - - "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], + "@types/pg": ["@types/pg@8.11.14", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^4.0.1" } }, "sha512-qyD11E5R3u0eJmd1lB0WnWKXJGA7s015nyARWljfz5DcX83TKAIlY+QrmvzQTsbIe+hkiFtkyL2gHC6qwF6Fbg=="], "@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA=="], @@ -143,7 +143,7 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "bun-types": ["bun-types@1.2.6", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-FbCKyr5KDiPULUzN/nm5oqQs9nXCHD8dVc64BArxJadCvbNzAI6lUWGh9fSJZWeDIRD38ikceBU8Kj/Uh+53oQ=="], + "bun-types": ["bun-types@1.2.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-dbkp5Lo8HDrXkLrONm6bk+yiiYQSntvFUzQp0v3pzTAsXk6FtgVMjdQ+lzFNVAmQFUkPQZ3WMZqH5tTo+Dp/IA=="], "char-info": ["char-info@0.3.5", "", { "dependencies": { "node-interval-tree": "^1.3.3" } }, "sha512-gRslEBFEcuLMGLNO1EFIrdN1MMUfO+aqa7y8iWzNyAzB3mYKnTIvP+ioW3jpyeEvqA5WapVLIPINGtFjEIH4cQ=="], @@ -161,19 +161,19 @@ "detect-libc": ["detect-libc@2.0.3", "", {}, "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw=="], - "drizzle-kit": ["drizzle-kit@0.30.5", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-l6dMSE100u7sDaTbLczibrQZjA35jLsHNqIV+jmhNVO3O8jzM6kywMOmV9uOz9ZVSCMPQhAZEFjL/qDPVrqpUA=="], + "drizzle-kit": ["drizzle-kit@0.31.0", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.2", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-pcKVT+GbfPA+bUovPIilgVOoq+onNBo/YQBG86sf3/GFHkN6lRJPm1l7dKN0IMAk57RQoIm4GUllRrasLlcaSg=="], - "drizzle-orm": ["drizzle-orm@0.39.0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/react": ">=18", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "react": ">=18", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/react", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "knex", "kysely", "mysql2", "pg", "postgres", "react", "sql.js", "sqlite3"] }, "sha512-kkZwo3Jvht0fdJD/EWGx0vYcEK0xnGrlNVaY07QYluRZA9N21B9VFbY+54bnb/1xvyzcg97tE65xprSAP/fFGQ=="], + "drizzle-orm": ["drizzle-orm@0.43.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-dUcDaZtE/zN4RV/xqGrVSMpnEczxd5cIaoDeor7Zst9wOe/HzC/7eAaulywWGYXdDEc9oBPMjayVEDg0ziTLJA=="], - "elysia": ["elysia@1.2.25", "", { "dependencies": { "@sinclair/typebox": "^0.34.27", "cookie": "^1.0.2", "memoirist": "^0.3.0", "openapi-types": "^12.1.3" }, "peerDependencies": { "typescript": ">= 5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-WsdQpORJvb4uszzeqYT0lg97knw1iBW1NTzJ1Jm57tiHg+DfAotlWXYbjmvQ039ssV0fYELDHinLLoUazZkEHg=="], + "elysia": ["elysia@1.3.0-exp.71", "", { "dependencies": { "@sinclair/typebox": "^0.34.33", "cookie": "^1.0.2", "exact-mirror": "0.1.1", "fast-decode-uri-component": "^1.0.1", "openapi-types": "^12.1.3" }, "peerDependencies": { "file-type": ">= 20.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["file-type", "typescript"] }, "sha512-jL7z5OzJgs8pCzEXRmzzYu972S9hILiab7bVD3VBJHAE/9EMdG5uzxWA++3rxJXPEW7HvK3E31zaJKv6TtKgqA=="], - "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], - - "esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="], + "esbuild": ["esbuild@0.25.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.3", "@esbuild/android-arm": "0.25.3", "@esbuild/android-arm64": "0.25.3", "@esbuild/android-x64": "0.25.3", "@esbuild/darwin-arm64": "0.25.3", "@esbuild/darwin-x64": "0.25.3", "@esbuild/freebsd-arm64": "0.25.3", "@esbuild/freebsd-x64": "0.25.3", "@esbuild/linux-arm": "0.25.3", "@esbuild/linux-arm64": "0.25.3", "@esbuild/linux-ia32": "0.25.3", "@esbuild/linux-loong64": "0.25.3", "@esbuild/linux-mips64el": "0.25.3", "@esbuild/linux-ppc64": "0.25.3", "@esbuild/linux-riscv64": "0.25.3", "@esbuild/linux-s390x": "0.25.3", "@esbuild/linux-x64": "0.25.3", "@esbuild/netbsd-arm64": "0.25.3", "@esbuild/netbsd-x64": "0.25.3", "@esbuild/openbsd-arm64": "0.25.3", "@esbuild/openbsd-x64": "0.25.3", "@esbuild/sunos-x64": "0.25.3", "@esbuild/win32-arm64": "0.25.3", "@esbuild/win32-ia32": "0.25.3", "@esbuild/win32-x64": "0.25.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q=="], "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], - "gel": ["gel@2.0.1", "", { "dependencies": { "@petamoriken/float16": "^3.8.7", "debug": "^4.3.4", "env-paths": "^3.0.0", "semver": "^7.6.2", "shell-quote": "^1.8.1", "which": "^4.0.0" }, "bin": { "gel": "dist/cli.mjs" } }, "sha512-gfem3IGvqKqXwEq7XseBogyaRwGsQGuE7Cw/yQsjLGdgiyqX92G1xENPCE0ltunPGcsJIa6XBOTx/PK169mOqw=="], + "exact-mirror": ["exact-mirror@0.1.1", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-jygrs/z9JT3UBDVPsu4vLy8gqtTLTxVzoxLmDzkVXHizRGixDMdkdLF98ChZxsqHL0F7IcpTf8GUFRqa2qt3uw=="], + + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], "get-tsconfig": ["get-tsconfig@4.10.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A=="], @@ -181,12 +181,8 @@ "is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="], - "isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="], - "jose": ["jose@6.0.10", "", {}, "sha512-skIAxZqcMkOrSwjJvplIPYrlXGpxTPnro2/QWTDCxAdWQrSTV5/KqspMWmi5WAx5+ULswASJiZ0a+1B/Lxt9cw=="], - "memoirist": ["memoirist@0.3.0", "", {}, "sha512-wR+4chMgVPq+T6OOsk40u9Wlpw1Pjx66NMNiYxCQQ4EUJ7jDs3D9kTCeKdBOkvAiqXlHLVJlvYL01PvIJ1MPNg=="], - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "node-addon-api": ["node-addon-api@8.3.1", "", {}, "sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA=="], @@ -201,19 +197,19 @@ "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], - "pg": ["pg@8.14.1", "", { "dependencies": { "pg-connection-string": "^2.7.0", "pg-pool": "^3.8.0", "pg-protocol": "^1.8.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, "optionalDependencies": { "pg-cloudflare": "^1.1.1" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw=="], + "pg": ["pg@8.15.6", "", { "dependencies": { "pg-connection-string": "^2.8.5", "pg-pool": "^3.9.6", "pg-protocol": "^1.9.5", "pg-types": "^2.1.0", "pgpass": "1.x" }, "optionalDependencies": { "pg-cloudflare": "^1.2.5" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-yvao7YI3GdmmrslNVsZgx9PfntfWrnXwtR+K/DjI0I/sTKif4Z623um+sjVZ1hk5670B+ODjvHDAckKdjmPTsg=="], - "pg-cloudflare": ["pg-cloudflare@1.1.1", "", {}, "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q=="], + "pg-cloudflare": ["pg-cloudflare@1.2.5", "", {}, "sha512-OOX22Vt0vOSRrdoUPKJ8Wi2OpE/o/h9T8X1s4qSkCedbNah9ei2W2765be8iMVxQUsvgT7zIAT2eIa9fs5+vtg=="], - "pg-connection-string": ["pg-connection-string@2.7.0", "", {}, "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA=="], + "pg-connection-string": ["pg-connection-string@2.8.5", "", {}, "sha512-Ni8FuZ8yAF+sWZzojvtLE2b03cqjO5jNULcHFfM9ZZ0/JXrgom5pBREbtnAw7oxsxJqHw9Nz/XWORUEL3/IFow=="], "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], "pg-numeric": ["pg-numeric@1.0.2", "", {}, "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw=="], - "pg-pool": ["pg-pool@3.8.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-VBw3jiVm6ZOdLBTIcXLNdSotb6Iy3uOCwDGFAksZCXmi10nyRvnP2v3jl4d+IsLYRyXf6o9hIm/ZtUzlByNUdw=="], + "pg-pool": ["pg-pool@3.9.6", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-rFen0G7adh1YmgvrmE5IPIqbb+IgEzENUm+tzm6MLLDSlPRoZVhzU1WdML9PV2W5GOdRA9qBKURlbt1OsXOsPw=="], - "pg-protocol": ["pg-protocol@1.8.0", "", {}, "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g=="], + "pg-protocol": ["pg-protocol@1.9.5", "", {}, "sha512-DYTWtWpfd5FOro3UnAfwvhD8jh59r2ig8bPtc9H8Ds7MscE/9NYruUQWFAOuraRl29jwcT2kyMFQ3MxeaVjUhg=="], "pg-types": ["pg-types@4.0.2", "", { "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" } }, "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng=="], @@ -237,8 +233,6 @@ "sharp": ["sharp@0.34.1", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.7.1" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.1", "@img/sharp-darwin-x64": "0.34.1", "@img/sharp-libvips-darwin-arm64": "1.1.0", "@img/sharp-libvips-darwin-x64": "1.1.0", "@img/sharp-libvips-linux-arm": "1.1.0", "@img/sharp-libvips-linux-arm64": "1.1.0", "@img/sharp-libvips-linux-ppc64": "1.1.0", "@img/sharp-libvips-linux-s390x": "1.1.0", "@img/sharp-libvips-linux-x64": "1.1.0", "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", "@img/sharp-libvips-linuxmusl-x64": "1.1.0", "@img/sharp-linux-arm": "0.34.1", "@img/sharp-linux-arm64": "0.34.1", "@img/sharp-linux-s390x": "0.34.1", "@img/sharp-linux-x64": "0.34.1", "@img/sharp-linuxmusl-arm64": "0.34.1", "@img/sharp-linuxmusl-x64": "0.34.1", "@img/sharp-wasm32": "0.34.1", "@img/sharp-win32-ia32": "0.34.1", "@img/sharp-win32-x64": "0.34.1" } }, "sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg=="], - "shell-quote": ["shell-quote@1.8.2", "", {}, "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA=="], - "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -251,8 +245,6 @@ "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], - "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "zhead": ["zhead@2.2.4", "", {}, "sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag=="], diff --git a/api/package.json b/api/package.json index 87c27bd2..74d4e81e 100644 --- a/api/package.json +++ b/api/package.json @@ -11,21 +11,21 @@ "dependencies": { "@elysiajs/swagger": "zoriya/elysia-swagger#build", "blurhash": "^2.0.5", - "drizzle-kit": "^0.30.4", - "drizzle-orm": "0.39.0", - "elysia": "^1.2.23", + "drizzle-kit": "^0.31.0", + "drizzle-orm": "0.43.1", + "elysia": "^1.3.0-exp.71", "jose": "^6.0.10", "parjs": "^1.3.9", - "pg": "^8.13.3", - "sharp": "^0.34.0" + "pg": "^8.15.6", + "sharp": "^0.34.1" }, "devDependencies": { - "@types/pg": "^8.11.11", + "@types/pg": "^8.11.14", "node-addon-api": "^8.3.1", - "bun-types": "^1.2.4" + "bun-types": "^1.2.11" }, "module": "src/index.js", "patchedDependencies": { - "drizzle-orm@0.39.0": "patches/drizzle-orm@0.39.0.patch" + "drizzle-orm@0.43.1": "patches/drizzle-orm@0.43.1.patch" } } diff --git a/api/patches/drizzle-orm@0.39.0.patch b/api/patches/drizzle-orm@0.39.0.patch deleted file mode 100644 index 52d2c30f..00000000 --- a/api/patches/drizzle-orm@0.39.0.patch +++ /dev/null @@ -1,251 +0,0 @@ -diff --git a/node_modules/drizzle-orm/.bun-tag-3622ae30f31c0d9a b/.bun-tag-3622ae30f31c0d9a -new file mode 100644 -index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 -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-844efc51a55b820c b/.bun-tag-844efc51a55b820c -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 -diff --git a/node_modules/drizzle-orm/.bun-tag-ce8efc9a806990a3 b/.bun-tag-ce8efc9a806990a3 -new file mode 100644 -index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 -diff --git a/pg-core/dialect.cjs b/pg-core/dialect.cjs -index 52acbfb6038fb1bbba4e34115d75a22bb0f9ab1a..1f10884caf05329ab98b06a68c8e7803e5283d32 100644 ---- a/pg-core/dialect.cjs -+++ b/pg-core/dialect.cjs -@@ -347,7 +347,14 @@ class PgDialect { - buildInsertQuery({ table, values: valuesOrSelect, onConflict, returning, withList, select, overridingSystemValue_ }) { - const valuesSqlList = []; - const columns = table[import_table2.Table.Symbol.Columns]; -- const colEntries = Object.entries(columns).filter(([_, col]) => !col.shouldDisableInsert()); -+ let colEntries = Object.entries(columns); -+ colEntries = select && !is(valuesOrSelect, SQL) -+ ? Object -+ .keys(valuesOrSelect._.selectedFields) -+ .map((key) => [key, columns[key]]) -+ : overridingSystemValue_ -+ ? colEntries -+ : colEntries.filter(([_, col]) => !col.shouldDisableInsert()); - const insertOrder = colEntries.map( - ([, column]) => import_sql2.sql.identifier(this.casing.getColumnCasing(column)) - ); -diff --git a/pg-core/dialect.js b/pg-core/dialect.js -index d7985c81f3d224f7671efe72e79b14153d5ca8ce..91d99ccd2ebda807a7d45c76f7164e571b922159 100644 ---- a/pg-core/dialect.js -+++ b/pg-core/dialect.js -@@ -345,7 +345,14 @@ class PgDialect { - buildInsertQuery({ table, values: valuesOrSelect, onConflict, returning, withList, select, overridingSystemValue_ }) { - const valuesSqlList = []; - const columns = table[Table.Symbol.Columns]; -- const colEntries = Object.entries(columns).filter(([_, col]) => !col.shouldDisableInsert()); -+ let colEntries = Object.entries(columns); -+ colEntries = select && !is(valuesOrSelect, SQL) -+ ? Object -+ .keys(valuesOrSelect._.selectedFields) -+ .map((key) => [key, columns[key]]) -+ : overridingSystemValue_ -+ ? colEntries -+ : colEntries.filter(([_, col]) => !col.shouldDisableInsert()); - const insertOrder = colEntries.map( - ([, column]) => sql.identifier(this.casing.getColumnCasing(column)) - ); -diff --git a/pg-core/query-builders/insert.cjs b/pg-core/query-builders/insert.cjs -index 08bb0d7485ebf997e3f081e2254ea8fd8bc20f65..341d2513d4377acc33ee0606d05580566fd4b88c 100644 ---- a/pg-core/query-builders/insert.cjs -+++ b/pg-core/query-builders/insert.cjs -@@ -75,11 +75,6 @@ class PgInsertBuilder { - } - select(selectQuery) { - const select = typeof selectQuery === "function" ? selectQuery(new import_query_builder.QueryBuilder()) : selectQuery; -- if (!(0, import_entity.is)(select, import_sql.SQL) && !(0, import_utils.haveSameKeys)(this.table[import_table.Columns], select._.selectedFields)) { -- throw new Error( -- "Insert select error: selected fields are not the same or are in a different order compared to the table definition" -- ); -- } - return new PgInsertBase(this.table, select, this.session, this.dialect, this.withList, true); - } - } -diff --git a/pg-core/query-builders/insert.js b/pg-core/query-builders/insert.js -index 0fc8eeb80f4a5512f6c84f3d596832623a33b748..b993f226daf16f423db012dff828d89c522603c3 100644 ---- a/pg-core/query-builders/insert.js -+++ b/pg-core/query-builders/insert.js -@@ -51,11 +51,6 @@ class PgInsertBuilder { - } - select(selectQuery) { - const select = typeof selectQuery === "function" ? selectQuery(new QueryBuilder()) : selectQuery; -- if (!is(select, SQL) && !haveSameKeys(this.table[Columns], select._.selectedFields)) { -- throw new Error( -- "Insert select error: selected fields are not the same or are in a different order compared to the table definition" -- ); -- } - return new PgInsertBase(this.table, select, this.session, this.dialect, this.withList, true); - } - } -diff --git a/pg-core/query-builders/select.d.cts b/pg-core/query-builders/select.d.cts -index b968ebb3f563f37c8c36221dd17cc6f3603270ec..3fda6d0a97997f6bd07ec6a0c83397c0fdd2e97e 100644 ---- a/pg-core/query-builders/select.d.cts -+++ b/pg-core/query-builders/select.d.cts -@@ -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.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..5c514132f30366ee600b9530c284932d54f481f3 100644 ---- a/pg-core/query-builders/select.js -+++ b/pg-core/query-builders/select.js -@@ -98,7 +98,7 @@ class PgSelectQueryBuilderBase extends TypedQueryBuilder { - this.tableName = getTableLikeName(table); - this.joinsNotNullableMap = typeof this.tableName === "string" ? { [this.tableName]: true } : {}; - } -- createJoin(joinType) { -+ createJoin(joinType, lateral = false) { - return (table, on) => { - const baseTableName = this.tableName; - const tableName = getTableLikeName(table); -@@ -127,7 +127,7 @@ class PgSelectQueryBuilderBase extends TypedQueryBuilder { - if (!this.config.joins) { - this.config.joins = []; - } -- this.config.joins.push({ on, table, joinType, alias: tableName }); -+ this.config.joins.push({ on, table, joinType, alias: tableName, lateral }); - if (typeof tableName === "string") { - switch (joinType) { - case "left": { -@@ -185,6 +185,15 @@ class PgSelectQueryBuilderBase extends TypedQueryBuilder { - * ``` - */ - leftJoin = this.createJoin("left"); -+ /** -+ * 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 = this.createJoin("left", true); - /** - * Executes a `right join` operation by adding another table to the current query. - * -@@ -241,6 +250,13 @@ class PgSelectQueryBuilderBase extends TypedQueryBuilder { - * ``` - */ - innerJoin = this.createJoin("inner"); -+ /** -+ * 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 = this.createJoin("inner", true); - /** - * Executes a `full join` operation by combining rows from two tables into a new table. - * diff --git a/api/patches/drizzle-orm@0.43.1.patch b/api/patches/drizzle-orm@0.43.1.patch new file mode 100644 index 00000000..92b380ff --- /dev/null +++ b/api/patches/drizzle-orm@0.43.1.patch @@ -0,0 +1,72 @@ +diff --git a/pg-core/dialect.cjs b/pg-core/dialect.cjs +index a0ef03142f21d319376bc50070ff7fdcd4d18132..45fc94e5a7c3fa4c201e636dd227122164e1bd02 100644 +--- a/pg-core/dialect.cjs ++++ b/pg-core/dialect.cjs +@@ -348,7 +348,14 @@ class PgDialect { + buildInsertQuery({ table, values: valuesOrSelect, onConflict, returning, withList, select, overridingSystemValue_ }) { + const valuesSqlList = []; + const columns = table[import_table2.Table.Symbol.Columns]; +- const colEntries = Object.entries(columns).filter(([_, col]) => !col.shouldDisableInsert()); ++ let colEntries = Object.entries(columns); ++ colEntries = select && !is(valuesOrSelect, SQL) ++ ? Object ++ .keys(valuesOrSelect._.selectedFields) ++ .map((key) => [key, columns[key]]) ++ : overridingSystemValue_ ++ ? colEntries ++ : colEntries.filter(([_, col]) => !col.shouldDisableInsert()); + const insertOrder = colEntries.map( + ([, column]) => import_sql2.sql.identifier(this.casing.getColumnCasing(column)) + ); +diff --git a/pg-core/dialect.js b/pg-core/dialect.js +index 120aaed9c3e4ae0a24653893379b98506c866f6f..48df463c0a6d5864fe2c324c8f86432860e50e00 100644 +--- a/pg-core/dialect.js ++++ b/pg-core/dialect.js +@@ -346,7 +346,14 @@ class PgDialect { + buildInsertQuery({ table, values: valuesOrSelect, onConflict, returning, withList, select, overridingSystemValue_ }) { + const valuesSqlList = []; + const columns = table[Table.Symbol.Columns]; +- const colEntries = Object.entries(columns).filter(([_, col]) => !col.shouldDisableInsert()); ++ let colEntries = Object.entries(columns); ++ colEntries = select && !is(valuesOrSelect, SQL) ++ ? Object ++ .keys(valuesOrSelect._.selectedFields) ++ .map((key) => [key, columns[key]]) ++ : overridingSystemValue_ ++ ? colEntries ++ : colEntries.filter(([_, col]) => !col.shouldDisableInsert()); + const insertOrder = colEntries.map( + ([, column]) => sql.identifier(this.casing.getColumnCasing(column)) + ); +diff --git a/pg-core/query-builders/insert.cjs b/pg-core/query-builders/insert.cjs +index 08bb0d7485ebf997e3f081e2254ea8fd8bc20f65..20c8036374a1f25f7c5880c40e8d3c42c05f3eee 100644 +--- a/pg-core/query-builders/insert.cjs ++++ b/pg-core/query-builders/insert.cjs +@@ -75,11 +75,6 @@ class PgInsertBuilder { + } + select(selectQuery) { + const select = typeof selectQuery === "function" ? selectQuery(new import_query_builder.QueryBuilder()) : selectQuery; +- if (!(0, import_entity.is)(select, import_sql.SQL) && !(0, import_utils.haveSameKeys)(this.table[import_table.Columns], select._.selectedFields)) { +- throw new Error( +- "Insert select error: selected fields are not the same or are in a different order compared to the table definition" +- ); +- } + return new PgInsertBase(this.table, select, this.session, this.dialect, this.withList, true); + } + } +diff --git a/pg-core/query-builders/insert.js b/pg-core/query-builders/insert.js +index 0fc8eeb80f4a5512f6c84f3d596832623a33b748..998e2ab0bfe3f322bf268a01f71ebd06c57d4d07 100644 +--- a/pg-core/query-builders/insert.js ++++ b/pg-core/query-builders/insert.js +@@ -51,11 +51,6 @@ class PgInsertBuilder { + } + select(selectQuery) { + const select = typeof selectQuery === "function" ? selectQuery(new QueryBuilder()) : selectQuery; +- if (!is(select, SQL) && !haveSameKeys(this.table[Columns], select._.selectedFields)) { +- throw new Error( +- "Insert select error: selected fields are not the same or are in a different order compared to the table definition" +- ); +- } + return new PgInsertBase(this.table, select, this.session, this.dialect, this.withList, true); + } + } diff --git a/shell.nix b/shell.nix index 18e08f33..1e3fb313 100644 --- a/shell.nix +++ b/shell.nix @@ -39,6 +39,7 @@ in go-swag bun pkg-config + nodejs node-gyp vips hurl From 72031557473ea573497e174de434a251adcf22bf Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 2 May 2025 00:35:49 +0200 Subject: [PATCH 14/23] Use `.Composite` for models (better swagger) --- api/src/models/collections.ts | 6 +++--- api/src/models/entry/episode.ts | 6 +++--- api/src/models/entry/extra.ts | 6 +++--- api/src/models/entry/movie-entry.ts | 8 ++++---- api/src/models/entry/special.ts | 6 +++--- api/src/models/examples/bubble.ts | 1 - api/src/models/movie.ts | 6 +++--- api/src/models/season.ts | 6 +++--- api/src/models/serie.ts | 6 +++--- api/src/models/show.ts | 6 +++--- api/src/models/staff.ts | 8 ++++---- api/src/models/studio.ts | 6 +++--- api/src/models/video.ts | 4 ++-- api/src/models/watchlist.ts | 2 +- 14 files changed, 38 insertions(+), 39 deletions(-) diff --git a/api/src/models/collections.ts b/api/src/models/collections.ts index 91f6f8df..f3221ae3 100644 --- a/api/src/models/collections.ts +++ b/api/src/models/collections.ts @@ -45,7 +45,7 @@ export const CollectionTranslation = t.Object({ logo: t.Nullable(Image), }); -export const Collection = t.Intersect([ +export const Collection = t.Composite([ Resource(), CollectionTranslation, BaseCollection, @@ -64,7 +64,7 @@ export const FullCollection = t.Intersect([ ]); export type FullCollection = Prettify; -export const SeedCollection = t.Intersect([ +export const SeedCollection = t.Composite([ t.Omit(BaseCollection, ["kind", "startAir", "endAir", "nextRefresh"]), t.Object({ slug: t.String({ format: "slug" }), @@ -72,7 +72,7 @@ export const SeedCollection = t.Intersect([ description: "The language code this collection's items were made in.", }), translations: TranslationRecord( - t.Intersect([ + t.Composite([ t.Omit(CollectionTranslation, [ "poster", "thumbnail", diff --git a/api/src/models/entry/episode.ts b/api/src/models/entry/episode.ts index 1d1460b5..1c4bb99b 100644 --- a/api/src/models/entry/episode.ts +++ b/api/src/models/entry/episode.ts @@ -12,7 +12,7 @@ import { import { EmbeddedVideo } from "../video"; import { BaseEntry, EntryTranslation } from "./base-entry"; -export const BaseEpisode = t.Intersect([ +export const BaseEpisode = t.Composite([ t.Object({ kind: t.Literal("episode"), order: t.Number({ minimum: 1, description: "Absolute playback order." }), @@ -23,7 +23,7 @@ export const BaseEpisode = t.Intersect([ BaseEntry(), ]); -export const Episode = t.Intersect([ +export const Episode = t.Composite([ Resource(), EntryTranslation(), BaseEpisode, @@ -35,7 +35,7 @@ export const Episode = t.Intersect([ ]); export type Episode = Prettify; -export const SeedEpisode = t.Intersect([ +export const SeedEpisode = t.Composite([ t.Omit(BaseEpisode, ["thumbnail", "nextRefresh"]), t.Object({ thumbnail: t.Nullable(SeedImage), diff --git a/api/src/models/entry/extra.ts b/api/src/models/entry/extra.ts index fc248ff2..10a60873 100644 --- a/api/src/models/entry/extra.ts +++ b/api/src/models/entry/extra.ts @@ -16,7 +16,7 @@ export const ExtraType = t.UnionEnum([ ]); export type ExtraType = typeof ExtraType.static; -export const BaseExtra = t.Intersect( +export const BaseExtra = t.Composite( [ t.Object({ kind: ExtraType, @@ -32,7 +32,7 @@ export const BaseExtra = t.Intersect( }, ); -export const Extra = t.Intersect([ +export const Extra = t.Composite([ Resource(), BaseExtra, t.Object({ @@ -42,7 +42,7 @@ export const Extra = t.Intersect([ ]); export type Extra = Prettify; -export const SeedExtra = t.Intersect([ +export const SeedExtra = t.Composite([ t.Omit(BaseExtra, ["thumbnail"]), t.Object({ slug: t.String({ format: "slug" }), diff --git a/api/src/models/entry/movie-entry.ts b/api/src/models/entry/movie-entry.ts index 30bdbac2..1a8df313 100644 --- a/api/src/models/entry/movie-entry.ts +++ b/api/src/models/entry/movie-entry.ts @@ -13,7 +13,7 @@ import { import { EmbeddedVideo } from "../video"; import { BaseEntry, EntryTranslation } from "./base-entry"; -export const BaseMovieEntry = t.Intersect( +export const BaseMovieEntry = t.Composite( [ t.Object({ kind: t.Literal("movie"), @@ -33,7 +33,7 @@ export const BaseMovieEntry = t.Intersect( }, ); -export const MovieEntryTranslation = t.Intersect([ +export const MovieEntryTranslation = t.Composite([ EntryTranslation(), t.Object({ tagline: t.Nullable(t.String()), @@ -41,7 +41,7 @@ export const MovieEntryTranslation = t.Intersect([ }), ]); -export const MovieEntry = t.Intersect([ +export const MovieEntry = t.Composite([ Resource(), MovieEntryTranslation, BaseMovieEntry, @@ -53,7 +53,7 @@ export const MovieEntry = t.Intersect([ ]); export type MovieEntry = Prettify; -export const SeedMovieEntry = t.Intersect([ +export const SeedMovieEntry = t.Composite([ t.Omit(BaseMovieEntry, ["thumbnail", "nextRefresh"]), t.Object({ slug: t.Optional(t.String({ format: "slug" })), diff --git a/api/src/models/entry/special.ts b/api/src/models/entry/special.ts index b687e67d..90b8cdd0 100644 --- a/api/src/models/entry/special.ts +++ b/api/src/models/entry/special.ts @@ -12,7 +12,7 @@ import { import { EmbeddedVideo } from "../video"; import { BaseEntry, EntryTranslation } from "./base-entry"; -export const BaseSpecial = t.Intersect( +export const BaseSpecial = t.Composite( [ t.Object({ kind: t.Literal("special"), @@ -33,7 +33,7 @@ export const BaseSpecial = t.Intersect( }, ); -export const Special = t.Intersect([ +export const Special = t.Composite([ Resource(), EntryTranslation(), BaseSpecial, @@ -45,7 +45,7 @@ export const Special = t.Intersect([ ]); export type Special = Prettify; -export const SeedSpecial = t.Intersect([ +export const SeedSpecial = t.Composite([ t.Omit(BaseSpecial, ["thumbnail", "nextRefresh"]), t.Object({ thumbnail: t.Nullable(SeedImage), diff --git a/api/src/models/examples/bubble.ts b/api/src/models/examples/bubble.ts index 1326f7b4..573c0cf4 100644 --- a/api/src/models/examples/bubble.ts +++ b/api/src/models/examples/bubble.ts @@ -3,7 +3,6 @@ import type { Video } from "~/models/video"; export const bubbleVideo: Video = { id: "3cd436ee-01ff-4f45-ba98-62aabeb22f25", - slug: "bubble", path: "/video/Bubble/Bubble (2022).mkv", rendering: "459429fa062adeebedcc2bb04b9965de0262bfa453369783132d261be79021bd", part: null, diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index 31f28487..d107474a 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -48,7 +48,7 @@ export const MovieTranslation = t.Object({ }); export type MovieTranslation = typeof MovieTranslation.static; -export const Movie = t.Intersect([ +export const Movie = t.Composite([ Resource(), MovieTranslation, BaseMovie, @@ -71,7 +71,7 @@ export const FullMovie = t.Intersect([ ]); export type FullMovie = Prettify; -export const SeedMovie = t.Intersect([ +export const SeedMovie = t.Composite([ t.Omit(BaseMovie, ["kind", "nextRefresh"]), t.Object({ slug: t.String({ format: "slug", examples: ["bubble"] }), @@ -79,7 +79,7 @@ export const SeedMovie = t.Intersect([ description: "The language code this movie was made in.", }), translations: TranslationRecord( - t.Intersect([ + t.Composite([ t.Omit(MovieTranslation, ["poster", "thumbnail", "banner", "logo"]), t.Object({ poster: t.Nullable(SeedImage), diff --git a/api/src/models/season.ts b/api/src/models/season.ts index 225bbce9..79c11976 100644 --- a/api/src/models/season.ts +++ b/api/src/models/season.ts @@ -27,7 +27,7 @@ export const SeasonTranslation = t.Object({ }); export type SeasonTranslation = typeof SeasonTranslation.static; -export const Season = t.Intersect([ +export const Season = t.Composite([ Resource(), SeasonTranslation, BaseSeason, @@ -35,11 +35,11 @@ export const Season = t.Intersect([ ]); export type Season = Prettify; -export const SeedSeason = t.Intersect([ +export const SeedSeason = t.Composite([ t.Omit(BaseSeason, ["nextRefresh"]), t.Object({ translations: TranslationRecord( - t.Intersect([ + t.Composite([ t.Omit(SeasonTranslation, ["poster", "thumbnail", "banner"]), t.Object({ poster: t.Nullable(SeedImage), diff --git a/api/src/models/serie.ts b/api/src/models/serie.ts index 8a9bd942..e16a0ea6 100644 --- a/api/src/models/serie.ts +++ b/api/src/models/serie.ts @@ -58,7 +58,7 @@ export const SerieTranslation = t.Object({ }); export type SerieTranslation = typeof SerieTranslation.static; -export const Serie = t.Intersect([ +export const Serie = t.Composite([ Resource(), SerieTranslation, BaseSerie, @@ -87,7 +87,7 @@ export const FullSerie = t.Intersect([ ]); export type FullSerie = Prettify; -export const SeedSerie = t.Intersect([ +export const SeedSerie = t.Composite([ t.Omit(BaseSerie, ["kind", "nextRefresh"]), t.Object({ slug: t.String({ format: "slug" }), @@ -95,7 +95,7 @@ export const SeedSerie = t.Intersect([ description: "The language code this serie was made in.", }), translations: TranslationRecord( - t.Intersect([ + t.Composite([ t.Omit(SerieTranslation, ["poster", "thumbnail", "banner", "logo"]), t.Object({ poster: t.Nullable(SeedImage), diff --git a/api/src/models/show.ts b/api/src/models/show.ts index 915ce7b4..ae5a0a43 100644 --- a/api/src/models/show.ts +++ b/api/src/models/show.ts @@ -4,7 +4,7 @@ import { Movie } from "./movie"; import { Serie } from "./serie"; export const Show = t.Union([ - t.Intersect([Movie, t.Object({ kind: t.Literal("movie") })]), - t.Intersect([Serie, t.Object({ kind: t.Literal("serie") })]), - t.Intersect([Collection, t.Object({ kind: t.Literal("collection") })]), + t.Composite([t.Object({ kind: t.Literal("movie") }), Movie]), + t.Composite([t.Object({ kind: t.Literal("serie") }), Serie]), + t.Composite([t.Object({ kind: t.Literal("collection") }), Collection]), ]); diff --git a/api/src/models/staff.ts b/api/src/models/staff.ts index 37aad31e..9d6fe403 100644 --- a/api/src/models/staff.ts +++ b/api/src/models/staff.ts @@ -28,19 +28,19 @@ const StaffData = t.Object({ image: t.Nullable(Image), externalId: ExternalId(), }); -export const Staff = t.Intersect([Resource(), StaffData, DbMetadata]); +export const Staff = t.Composite([Resource(), StaffData, DbMetadata]); export type Staff = typeof Staff.static; -export const SeedStaff = t.Intersect([ +export const SeedStaff = t.Composite([ t.Omit(Role, ["character"]), t.Object({ - character: t.Intersect([ + character: t.Composite([ t.Omit(Character, ["image"]), t.Object({ image: t.Nullable(SeedImage), }), ]), - staff: t.Intersect([ + staff: t.Composite([ t.Object({ slug: t.String({ format: "slug" }), image: t.Nullable(SeedImage), diff --git a/api/src/models/studio.ts b/api/src/models/studio.ts index c7b8beff..ce19ec0f 100644 --- a/api/src/models/studio.ts +++ b/api/src/models/studio.ts @@ -14,7 +14,7 @@ export const StudioTranslation = t.Object({ }); export type StudioTranslation = typeof StudioTranslation.static; -export const Studio = t.Intersect([ +export const Studio = t.Composite([ Resource(), StudioTranslation, BaseStudio, @@ -22,12 +22,12 @@ export const Studio = t.Intersect([ ]); export type Studio = Prettify; -export const SeedStudio = t.Intersect([ +export const SeedStudio = t.Composite([ BaseStudio, t.Object({ slug: t.String({ format: "slug" }), translations: TranslationRecord( - t.Intersect([ + t.Composite([ t.Omit(StudioTranslation, ["logo"]), t.Object({ logo: t.Nullable(SeedImage), diff --git a/api/src/models/video.ts b/api/src/models/video.ts index 04b98bd8..874a006c 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -113,7 +113,7 @@ export const SeedVideo = t.Object({ }); export type SeedVideo = Prettify; -export const Video = t.Intersect([ +export const Video = t.Composite([ t.Object({ id: t.String({ format: "uuid" }), }), @@ -123,7 +123,7 @@ export const Video = t.Intersect([ export type Video = Prettify; // type used in entry responses (the slug comes from the entryVideoJoin) -export const EmbeddedVideo = t.Intersect([ +export const EmbeddedVideo = t.Composite([ t.Object({ slug: t.String({ format: "slug" }) }), t.Omit(Video, ["guess", "createdAt", "updatedAt"]), ]); diff --git a/api/src/models/watchlist.ts b/api/src/models/watchlist.ts index 182b2b17..5e072f6a 100644 --- a/api/src/models/watchlist.ts +++ b/api/src/models/watchlist.ts @@ -20,7 +20,7 @@ export const SerieWatchStatus = t.Object({ }); export type SerieWatchStatus = typeof SerieWatchStatus.static; -export const MovieWatchStatus = t.Intersect([ +export const MovieWatchStatus = t.Composite([ t.Omit(SerieWatchStatus, ["startedAt", "seenCount"]), t.Object({ percent: t.Integer({ From 71b3ee61af8d013cc161ed009ffb216ec43411c6 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 2 May 2025 00:47:54 +0200 Subject: [PATCH 15/23] Add support for externalId in `POST /videos` --- api/src/controllers/entries.ts | 2 +- api/src/controllers/profiles/history.ts | 2 +- api/src/controllers/profiles/nextup.ts | 2 +- api/src/controllers/shows/logic.ts | 10 +- api/src/controllers/videos.ts | 12 +- api/src/models/video.ts | 81 +++++++---- api/tests/manual.ts | 1 - api/tests/videos/scanner.test.ts | 181 ++++++++++++++++++++++++ 8 files changed, 252 insertions(+), 39 deletions(-) diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index 269178d2..478b7538 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -213,7 +213,7 @@ export async function getEntries({ }) .from(entries) .innerJoin(transQ, eq(entries.pk, transQ.pk)) - .leftJoinLateral(entryVideosQ, sql`true`) + .crossJoinLateral(entryVideosQ) .leftJoin(progressQ, eq(entries.pk, progressQ.entryPk)) .where( and( diff --git a/api/src/controllers/profiles/history.ts b/api/src/controllers/profiles/history.ts index 02b57cf2..f317e033 100644 --- a/api/src/controllers/profiles/history.ts +++ b/api/src/controllers/profiles/history.ts @@ -286,7 +286,7 @@ export const historyH = new Elysia({ tags: ["profiles"] }) }) .from(hist) .leftJoin(entries, valEqEntries) - .leftJoinLateral(nextEntryQ, sql`true`), + .crossJoinLateral(nextEntryQ), ) .onConflictDoUpdate({ target: [watchlist.profilePk, watchlist.showPk], diff --git a/api/src/controllers/profiles/nextup.ts b/api/src/controllers/profiles/nextup.ts index 6df5d360..4e8b6ac9 100644 --- a/api/src/controllers/profiles/nextup.ts +++ b/api/src/controllers/profiles/nextup.ts @@ -112,7 +112,7 @@ export const nextup = new Elysia({ tags: ["profiles"] }) .from(entries) .innerJoin(watchlist, eq(watchlist.nextEntry, entries.pk)) .innerJoin(transQ, eq(entries.pk, transQ.pk)) - .leftJoinLateral(entryVideosQ, sql`true`) + .crossJoinLateral(entryVideosQ) .leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk)) .where( and( diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index 71c6e567..26c2af66 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -41,7 +41,7 @@ import { entryProgressQ, entryVideosQ, mapProgress } from "../entries"; export const watchStatusQ = db .select({ ...getColumns(watchlist), - percent: sql`${watchlist.seenCount}`.as("percent"), + percent: sql`${watchlist.seenCount}`.as("percent"), }) .from(watchlist) .leftJoin(profiles, eq(watchlist.profilePk, profiles.pk)) @@ -161,9 +161,9 @@ const showRelations = { ).as("videos"), }) .from(entryVideoJoin) + .innerJoin(entries, eq(entries.showPk, shows.pk)) + .innerJoin(videos, eq(videos.pk, entryVideoJoin.videoPk)) .where(eq(entryVideoJoin.entryPk, entries.pk)) - .leftJoin(entries, eq(entries.showPk, shows.pk)) - .leftJoin(videos, eq(videos.pk, entryVideoJoin.videoPk)) .as("videos"); }, firstEntry: ({ languages }: { languages: string[] }) => { @@ -190,7 +190,7 @@ const showRelations = { .from(entries) .innerJoin(transQ, eq(entries.pk, transQ.pk)) .leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk)) - .leftJoinLateral(entryVideosQ, sql`true`) + .crossJoinLateral(entryVideosQ) .where(and(eq(entries.showPk, shows.pk), ne(entries.kind, "extra"))) .orderBy(entries.order) .limit(1) @@ -220,7 +220,7 @@ const showRelations = { .from(entries) .innerJoin(transQ, eq(entries.pk, transQ.pk)) .leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk)) - .leftJoinLateral(entryVideosQ, sql`true`) + .crossJoinLateral(entryVideosQ) .where(eq(watchStatusQ.nextEntry, entries.pk)) .as("nextEntry"); }, diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index b9d51e03..855aedcf 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -53,9 +53,8 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) slug: shows.slug, }) .from(videos) - .leftJoin( + .crossJoin( sql`jsonb_array_elements_text(${videos.guess}->'year') as year`, - sql`true`, ) .innerJoin(entryVideoJoin, eq(entryVideoJoin.videoPk, videos.pk)) .innerJoin(entries, eq(entries.pk, entryVideoJoin.entryPk)) @@ -179,7 +178,6 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) return x.for.map((e) => ({ video: vids.find((v) => v.path === x.path)!.pk, path: x.path, - needRendering: x.for!.length > 1, entry: { ...e, movie: @@ -216,6 +214,7 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) order: entries.order, showId: sql`${shows.id}`.as("showId"), showSlug: sql`${shows.slug}`.as("showSlug"), + externalId: entries.externalId, }) .from(entries) .innerJoin(shows, eq(entries.showPk, shows.pk)) @@ -235,13 +234,12 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) videoPk: videos.pk, slug: computeVideoSlug( entriesQ.slug, - sql`j.needRendering or exists(${hasRenderingQ})`, + sql`exists(${hasRenderingQ})`, ), }) .from( values(vidEntries, { video: "integer", - needRendering: "boolean", entry: "jsonb", }).as("j"), ) @@ -293,6 +291,10 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) ), ), ), + and( + sql`j.entry ? 'externalId'`, + sql`j.entry->'externalId' <@ ${entriesQ.externalId}`, + ), ), ), ) diff --git a/api/src/models/video.ts b/api/src/models/video.ts index 874a006c..15799dd8 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -1,9 +1,21 @@ +import { PatternString } from "@sinclair/typebox"; import { t } from "elysia"; import { type Prettify, comment } from "~/utils"; import { ExtraType } from "./entry/extra"; -import { bubbleVideo, registerExamples } from "./examples"; +import { bubble, bubbleVideo, registerExamples } from "./examples"; import { DbMetadata, EpisodeId, ExternalId, Resource } from "./utils"; +const ExternalIds = t.Record( + t.String(), + t.Omit( + t.Union([ + EpisodeId.patternProperties[PatternString], + ExternalId().patternProperties[PatternString], + ]), + ["link"], + ), +); + export const Guess = t.Recursive((Self) => t.Object( { @@ -13,6 +25,7 @@ export const Guess = t.Recursive((Self) => episode: t.Optional(t.Array(t.Integer(), { default: [] })), kind: t.Optional(t.UnionEnum(["episode", "movie", "extra"])), extraKind: t.Optional(ExtraType), + externalId: t.Optional(ExternalIds), from: t.String({ description: "Name of the tool that made the guess", @@ -78,7 +91,7 @@ export const SeedVideo = t.Object({ }), }), t.Object({ - externalId: t.Union([EpisodeId, ExternalId()]), + externalId: ExternalIds, }), t.Object({ movie: t.Union([ @@ -86,26 +99,28 @@ export const SeedVideo = t.Object({ t.String({ format: "slug", examples: ["bubble"] }), ]), }), - t.Intersect([ - t.Object({ - serie: t.Union([ - t.String({ format: "uuid" }), - t.String({ format: "slug", examples: ["made-in-abyss"] }), - ]), - }), - t.Union([ - t.Object({ - season: t.Integer({ minimum: 1 }), - episode: t.Integer(), - }), - t.Object({ - order: t.Number(), - }), - t.Object({ - special: t.Integer(), - }), + t.Object({ + serie: t.Union([ + t.String({ format: "uuid" }), + t.String({ format: "slug", examples: ["made-in-abyss"] }), ]), - ]), + season: t.Integer({ minimum: 1 }), + episode: t.Integer(), + }), + t.Object({ + serie: t.Union([ + t.String({ format: "uuid" }), + t.String({ format: "slug", examples: ["made-in-abyss"] }), + ]), + order: t.Number(), + }), + t.Object({ + serie: t.Union([ + t.String({ format: "uuid" }), + t.String({ format: "slug", examples: ["made-in-abyss"] }), + ]), + special: t.Integer(), + }), ]), { default: [] }, ), @@ -123,13 +138,29 @@ export const Video = t.Composite([ export type Video = Prettify; // type used in entry responses (the slug comes from the entryVideoJoin) -export const EmbeddedVideo = t.Composite([ - t.Object({ slug: t.String({ format: "slug" }) }), - t.Omit(Video, ["guess", "createdAt", "updatedAt"]), -]); +export const EmbeddedVideo = t.Composite( + [ + t.Object({ slug: t.String({ format: "slug" }) }), + t.Omit(Video, ["guess", "createdAt", "updatedAt"]), + ], + { additionalProperties: true }, +); export type EmbeddedVideo = Prettify; registerExamples(Video, bubbleVideo); +registerExamples(SeedVideo, { + ...bubbleVideo, + for: [ + { movie: "bubble" }, + { + externalId: { + themoviedatabase: { + dataId: bubble.externalId.themoviedatabase.dataId, + }, + }, + }, + ], +}); export const Guesses = t.Object({ paths: t.Array(t.String()), diff --git a/api/tests/manual.ts b/api/tests/manual.ts index 9c7661d5..ca821e3a 100644 --- a/api/tests/manual.ts +++ b/api/tests/manual.ts @@ -8,7 +8,6 @@ import { createSerie, createVideo } from "./helpers"; // export JWT_SECRET="this is a secret"; // export JWT_ISSUER="https://kyoo.zoriya.dev"; - await migrate(); await db.delete(shows); await db.delete(profiles); diff --git a/api/tests/videos/scanner.test.ts b/api/tests/videos/scanner.test.ts index c5302079..82624adc 100644 --- a/api/tests/videos/scanner.test.ts +++ b/api/tests/videos/scanner.test.ts @@ -220,4 +220,185 @@ describe("Video seeding", () => { expect(vid!.evj[0].slug).toBe("made-in-abyss-dawn-of-the-deep-soul"); expect(vid!.evj[0].entry.slug).toBe("made-in-abyss-dawn-of-the-deep-soul"); }); + + it("With external id", async () => { + const [resp, body] = await createVideo({ + guess: { + title: "mia", + season: [0], + episode: [3], + from: "test", + externalId: { + themoviedb: { serieId: "72636", season: 1, episode: 13 }, + }, + }, + part: null, + path: "/video/mia s1e13 [tmdb=72636].mkv", + rendering: "notehu", + version: 1, + for: [ + { + externalId: { + themoviedb: { serieId: "72636", season: 1, episode: 13 }, + }, + }, + ], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/mia s1e13 [tmdb=72636].mkv"); + expect(vid!.guess).toMatchObject({ title: "mia", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(1); + expect(vid!.evj).toBeArrayOfSize(1); + + expect(vid!.evj[0].slug).toBe("made-in-abyss-s1e13-notehu"); + expect(vid!.evj[0].entry.slug).toBe("made-in-abyss-s1e13"); + }); + + it("With movie external id", async () => { + const [resp, body] = await createVideo({ + guess: { + title: "bubble", + from: "test", + externalId: { + themoviedb: { dataId: "912598", season: 1, episode: 13 }, + }, + }, + part: null, + path: "/video/bubble [tmdb=912598].mkv", + rendering: "onetuh", + version: 1, + for: [ + { + externalId: { + themoviedb: { serieId: "912598", season: 1, episode: 13 }, + }, + }, + ], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/bubble [tmdb=912598].mkv"); + expect(vid!.guess).toMatchObject({ title: "bubble", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(1); + expect(vid!.evj).toBeArrayOfSize(1); + + expect(vid!.evj[0].slug).toBe("bubble-onetuh"); + expect(vid!.evj[0].entry.slug).toBe("bubble"); + }); + + it("Two for the same entry", async () => { + const [resp, body] = await createVideo({ + guess: { + title: "bubble", + from: "test", + externalId: { + themoviedb: { dataId: "912598", season: 1, episode: 13 }, + }, + }, + part: null, + path: "/video/bubble [tmdb=912598].mkv", + rendering: "cwhtn", + version: 1, + for: [ + { movie: "bubble" }, + { + externalId: { + themoviedb: { serieId: "912598", season: 1, episode: 13 }, + }, + }, + ], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/bubble [tmdb=912598].mkv"); + expect(vid!.guess).toMatchObject({ title: "bubble", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(1); + expect(vid!.evj).toBeArrayOfSize(1); + + expect(vid!.evj[0].slug).toBe("bubble-cwhtn"); + expect(vid!.evj[0].entry.slug).toBe("bubble"); + }); + + it("Two for the same entry WITHOUT rendering", async () => { + await db.delete(videos); + const [resp, body] = await createVideo({ + guess: { + title: "bubble", + from: "test", + externalId: { + themoviedb: { dataId: "912598", season: 1, episode: 13 }, + }, + }, + part: null, + path: "/video/bubble [tmdb=912598].mkv", + rendering: "cwhtn", + version: 1, + for: [ + { movie: "bubble" }, + { + externalId: { + themoviedb: { serieId: "912598", season: 1, episode: 13 }, + }, + }, + ], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/bubble [tmdb=912598].mkv"); + expect(vid!.guess).toMatchObject({ title: "bubble", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(1); + expect(vid!.evj).toBeArrayOfSize(1); + + expect(vid!.evj[0].slug).toBe("bubble"); + expect(vid!.evj[0].entry.slug).toBe("bubble"); + }); }); From 379765b28fcc00542cefd98a4d4096dc9522d769 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 2 May 2025 02:12:08 +0200 Subject: [PATCH 16/23] Fix typechecking --- api/bun.lock | 10 ++++---- api/package.json | 2 +- api/src/controllers/entries.ts | 14 ++++++----- api/src/controllers/profiles/history.ts | 31 +++++++++++++------------ api/src/controllers/profiles/nextup.ts | 4 +++- api/src/models/entry/index.ts | 3 +-- api/src/models/examples/dune-1984.ts | 1 - api/src/models/examples/dune-2021.ts | 1 - api/src/models/show.ts | 7 +++--- api/src/models/watchlist.ts | 1 + api/tests/movies/get-movie.test.ts | 2 +- 11 files changed, 39 insertions(+), 37 deletions(-) diff --git a/api/bun.lock b/api/bun.lock index 912a6649..a1983672 100644 --- a/api/bun.lock +++ b/api/bun.lock @@ -8,7 +8,7 @@ "blurhash": "^2.0.5", "drizzle-kit": "^0.31.0", "drizzle-orm": "0.43.1", - "elysia": "^1.3.0-exp.71", + "elysia": "^1.2.25", "jose": "^6.0.10", "parjs": "^1.3.9", "pg": "^8.15.6", @@ -165,16 +165,12 @@ "drizzle-orm": ["drizzle-orm@0.43.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-dUcDaZtE/zN4RV/xqGrVSMpnEczxd5cIaoDeor7Zst9wOe/HzC/7eAaulywWGYXdDEc9oBPMjayVEDg0ziTLJA=="], - "elysia": ["elysia@1.3.0-exp.71", "", { "dependencies": { "@sinclair/typebox": "^0.34.33", "cookie": "^1.0.2", "exact-mirror": "0.1.1", "fast-decode-uri-component": "^1.0.1", "openapi-types": "^12.1.3" }, "peerDependencies": { "file-type": ">= 20.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["file-type", "typescript"] }, "sha512-jL7z5OzJgs8pCzEXRmzzYu972S9hILiab7bVD3VBJHAE/9EMdG5uzxWA++3rxJXPEW7HvK3E31zaJKv6TtKgqA=="], + "elysia": ["elysia@1.2.25", "", { "dependencies": { "@sinclair/typebox": "^0.34.27", "cookie": "^1.0.2", "memoirist": "^0.3.0", "openapi-types": "^12.1.3" }, "peerDependencies": { "typescript": ">= 5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-WsdQpORJvb4uszzeqYT0lg97knw1iBW1NTzJ1Jm57tiHg+DfAotlWXYbjmvQ039ssV0fYELDHinLLoUazZkEHg=="], "esbuild": ["esbuild@0.25.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.3", "@esbuild/android-arm": "0.25.3", "@esbuild/android-arm64": "0.25.3", "@esbuild/android-x64": "0.25.3", "@esbuild/darwin-arm64": "0.25.3", "@esbuild/darwin-x64": "0.25.3", "@esbuild/freebsd-arm64": "0.25.3", "@esbuild/freebsd-x64": "0.25.3", "@esbuild/linux-arm": "0.25.3", "@esbuild/linux-arm64": "0.25.3", "@esbuild/linux-ia32": "0.25.3", "@esbuild/linux-loong64": "0.25.3", "@esbuild/linux-mips64el": "0.25.3", "@esbuild/linux-ppc64": "0.25.3", "@esbuild/linux-riscv64": "0.25.3", "@esbuild/linux-s390x": "0.25.3", "@esbuild/linux-x64": "0.25.3", "@esbuild/netbsd-arm64": "0.25.3", "@esbuild/netbsd-x64": "0.25.3", "@esbuild/openbsd-arm64": "0.25.3", "@esbuild/openbsd-x64": "0.25.3", "@esbuild/sunos-x64": "0.25.3", "@esbuild/win32-arm64": "0.25.3", "@esbuild/win32-ia32": "0.25.3", "@esbuild/win32-x64": "0.25.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q=="], "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], - "exact-mirror": ["exact-mirror@0.1.1", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-jygrs/z9JT3UBDVPsu4vLy8gqtTLTxVzoxLmDzkVXHizRGixDMdkdLF98ChZxsqHL0F7IcpTf8GUFRqa2qt3uw=="], - - "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], - "get-tsconfig": ["get-tsconfig@4.10.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A=="], "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], @@ -183,6 +179,8 @@ "jose": ["jose@6.0.10", "", {}, "sha512-skIAxZqcMkOrSwjJvplIPYrlXGpxTPnro2/QWTDCxAdWQrSTV5/KqspMWmi5WAx5+ULswASJiZ0a+1B/Lxt9cw=="], + "memoirist": ["memoirist@0.3.0", "", {}, "sha512-wR+4chMgVPq+T6OOsk40u9Wlpw1Pjx66NMNiYxCQQ4EUJ7jDs3D9kTCeKdBOkvAiqXlHLVJlvYL01PvIJ1MPNg=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "node-addon-api": ["node-addon-api@8.3.1", "", {}, "sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA=="], diff --git a/api/package.json b/api/package.json index 74d4e81e..1206b686 100644 --- a/api/package.json +++ b/api/package.json @@ -13,7 +13,7 @@ "blurhash": "^2.0.5", "drizzle-kit": "^0.31.0", "drizzle-orm": "0.43.1", - "elysia": "^1.3.0-exp.71", + "elysia": "^1.2.25", "jose": "^6.0.10", "parjs": "^1.3.9", "pg": "^8.15.6", diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index 478b7538..dce5edae 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -143,13 +143,15 @@ export const entryVideosQ = db export const mapProgress = ({ aliased }: { aliased: boolean }) => { const { time, percent, playedDate, videoId } = getColumns(entryProgressQ); const ret = { - time: coalesce(time, sql`0`), - percent: coalesce(percent, sql`0`), - playedDate: sql`${playedDate}`, - videoId: sql`${videoId}`, + time: coalesce(time, sql`0`), + percent: coalesce(percent, sql`0`), + playedDate: sql`${playedDate}`, + videoId: sql`${videoId}`, }; if (!aliased) return ret; - return Object.fromEntries(Object.entries(ret).map(([k, v]) => [k, v.as(k)])); + return Object.fromEntries( + Object.entries(ret).map(([k, v]) => [k, v.as(k)]), + ) as unknown as typeof ret; }; export async function getEntries({ @@ -197,7 +199,7 @@ export async function getEntries({ videos: entryVideosQ.videos, progress: mapProgress({ aliased: true }), // specials don't have an `episodeNumber` but a `number` field. - number: episodeNumber, + number: sql`${episodeNumber}`, // merge `extraKind` into `kind` kind: sql`case when ${kind} = 'extra' then ${extraKind} else ${kind}::text end`.as( diff --git a/api/src/controllers/profiles/history.ts b/api/src/controllers/profiles/history.ts index f317e033..fdf239c2 100644 --- a/api/src/controllers/profiles/history.ts +++ b/api/src/controllers/profiles/history.ts @@ -18,6 +18,7 @@ import { processLanguages, } from "~/models/utils"; import { desc } from "~/models/utils/descriptions"; +import type { WatchlistStatus } from "~/models/watchlist"; import { entryFilters, entryProgressQ, @@ -186,12 +187,12 @@ export const historyH = new Elysia({ tags: ["profiles"] }) .select( db .select({ - profilePk: sql`${profilePk}`, + profilePk: sql`${profilePk}`.as("profilePk"), entryPk: entries.pk, videoPk: videos.pk, - percent: sql`hist.percent`, - time: sql`hist.time`, - playedDate: sql`hist.playedDate`, + percent: sql`hist.percent`.as("percent"), + time: sql`hist.time`.as("time"), + playedDate: sql`hist.playedDate`.as("playedDate"), }) .from(hist) .innerJoin(entries, valEqEntries) @@ -248,9 +249,9 @@ export const historyH = new Elysia({ tags: ["profiles"] }) .select( db .select({ - profilePk: sql`${profilePk}`, + profilePk: sql`${profilePk}`.as("profilePk"), showPk: entries.showPk, - status: sql` + status: sql` case when hist.percent >= 95 @@ -258,35 +259,35 @@ export const historyH = new Elysia({ tags: ["profiles"] }) then 'completed'::watchlist_status else 'watching'::watchlist_status end - `, + `.as("status"), seenCount: sql` case when ${entries.kind} = 'movie' then hist.percent when hist.percent >= 95 then 1 else 0 end - `, + `.as("seen_count"), nextEntry: sql` case when hist.percent >= 95 then ${nextEntryQ.pk} else ${entries.pk} end - `, - score: sql`null`, - startedAt: sql`hist.playedDate`, - lastPlayedAt: sql`hist.playedDate`, + `.as("next_entry"), + score: sql`null`.as("score"), + startedAt: sql`hist.playedDate`.as("startedAt"), + lastPlayedAt: sql`hist.playedDate`.as("lastPlayedAt"), completedAt: sql` case when ${nextEntryQ.pk} is null then hist.playedDate else null end - `, + `.as("completedAt"), // see https://github.com/drizzle-team/drizzle-orm/issues/3608 - updatedAt: sql`now()`, + updatedAt: sql`now()`.as("updatedAt"), }) .from(hist) .leftJoin(entries, valEqEntries) - .crossJoinLateral(nextEntryQ), + .leftJoinLateral(nextEntryQ, sql`true`), ) .onConflictDoUpdate({ target: [watchlist.profilePk, watchlist.showPk], diff --git a/api/src/controllers/profiles/nextup.ts b/api/src/controllers/profiles/nextup.ts index 4e8b6ac9..142ae634 100644 --- a/api/src/controllers/profiles/nextup.ts +++ b/api/src/controllers/profiles/nextup.ts @@ -90,6 +90,7 @@ export const nextup = new Elysia({ tags: ["profiles"] }) seasonNumber, episodeNumber, extraKind, + kind, ...entryCol } = getColumns(entries); @@ -100,9 +101,10 @@ export const nextup = new Elysia({ tags: ["profiles"] }) videos: entryVideosQ.videos, progress: mapProgress({ aliased: true }), // specials don't have an `episodeNumber` but a `number` field. - number: episodeNumber, + number: sql`${episodeNumber}`, // assign more restrained types to make typescript happy. + kind: sql`${kind}`, externalId: sql`${externalId}`, order: sql`${order}`, seasonNumber: sql`${seasonNumber}`, diff --git a/api/src/models/entry/index.ts b/api/src/models/entry/index.ts index bfdc54dc..d4a338a3 100644 --- a/api/src/models/entry/index.ts +++ b/api/src/models/entry/index.ts @@ -3,7 +3,6 @@ import { Episode, SeedEpisode } from "./episode"; import type { Extra } from "./extra"; import { MovieEntry, SeedMovieEntry } from "./movie-entry"; import { SeedSpecial, Special } from "./special"; -import type { UnknownEntry } from "./unknown-entry"; export const Entry = t.Union([Episode, MovieEntry, Special]); export type Entry = Episode | MovieEntry | Special; @@ -11,7 +10,7 @@ export type Entry = Episode | MovieEntry | Special; export const SeedEntry = t.Union([SeedEpisode, SeedMovieEntry, SeedSpecial]); export type SeedEntry = SeedEpisode | SeedMovieEntry | SeedSpecial; -export type EntryKind = Entry["kind"] | Extra["kind"] | UnknownEntry["kind"]; +export type EntryKind = Entry["kind"] | Extra["kind"]; export * from "./episode"; export * from "./movie-entry"; diff --git a/api/src/models/examples/dune-1984.ts b/api/src/models/examples/dune-1984.ts index 34084f77..b417ed7e 100644 --- a/api/src/models/examples/dune-1984.ts +++ b/api/src/models/examples/dune-1984.ts @@ -3,7 +3,6 @@ import type { Video } from "../video"; export const dune1984Video: Video = { id: "d1a62b87-9cfd-4f9c-9ad7-21f9b7fa6290", - slug: "dune-1984", path: "/video/Dune_1984/Dune (1984).mkv", rendering: "ea3a0f8f2f2c5b61a07f61e4e8d9f8e01b2b92bcbb6f5ed1151e1f61619c2c0f", part: null, diff --git a/api/src/models/examples/dune-2021.ts b/api/src/models/examples/dune-2021.ts index 08cb3499..1c6b9427 100644 --- a/api/src/models/examples/dune-2021.ts +++ b/api/src/models/examples/dune-2021.ts @@ -3,7 +3,6 @@ import type { Video } from "~/models/video"; export const duneVideo: Video = { id: "c9a0d02e-6b8e-4ac1-b431-45b022ec0708", - slug: "dune", path: "/video/Dune/Dune (2021).mkv", rendering: "f1953a4fb58247efb6c15b76468b6a9d13b4155b02094863b1a4f0c3fbb6db58", part: null, diff --git a/api/src/models/show.ts b/api/src/models/show.ts index ae5a0a43..5bcf6237 100644 --- a/api/src/models/show.ts +++ b/api/src/models/show.ts @@ -4,7 +4,8 @@ import { Movie } from "./movie"; import { Serie } from "./serie"; export const Show = t.Union([ - t.Composite([t.Object({ kind: t.Literal("movie") }), Movie]), - t.Composite([t.Object({ kind: t.Literal("serie") }), Serie]), - t.Composite([t.Object({ kind: t.Literal("collection") }), Collection]), + t.Intersect([t.Object({ kind: t.Literal("movie") }), Movie]), + t.Intersect([t.Object({ kind: t.Literal("serie") }), Serie]), + t.Intersect([t.Object({ kind: t.Literal("collection") }), Collection]), ]); +export type Show = typeof Show.static; diff --git a/api/src/models/watchlist.ts b/api/src/models/watchlist.ts index 5e072f6a..7ce5b557 100644 --- a/api/src/models/watchlist.ts +++ b/api/src/models/watchlist.ts @@ -7,6 +7,7 @@ export const WatchlistStatus = t.UnionEnum([ "dropped", "planned", ]); +export type WatchlistStatus = typeof WatchlistStatus.static; export const SerieWatchStatus = t.Object({ status: WatchlistStatus, diff --git a/api/tests/movies/get-movie.test.ts b/api/tests/movies/get-movie.test.ts index f9afa6fe..5e7b9ee9 100644 --- a/api/tests/movies/get-movie.test.ts +++ b/api/tests/movies/get-movie.test.ts @@ -159,7 +159,7 @@ describe("Get movie", () => { expect(body.videos).toBeArrayOfSize(bubble.videos!.length); expect(body.videos[0]).toMatchObject({ path: bubbleVideo.path, - slug: bubbleVideo.slug, + slug: bubble.slug, version: bubbleVideo.version, rendering: bubbleVideo.rendering, part: bubbleVideo.part, From 466b67afe5ed2c9b200dfe12f616ebc0a1b9091b Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 2 May 2025 12:47:27 +0200 Subject: [PATCH 17/23] Put `POST /videos` in a transaction, handle dups --- api/src/controllers/videos.ts | 316 ++++++++++++++++--------------- api/src/models/video.ts | 7 +- api/tests/videos/scanner.test.ts | 172 +++++++++++++++-- 3 files changed, 326 insertions(+), 169 deletions(-) diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index 855aedcf..676b32b4 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -160,166 +160,174 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) .post( "", async ({ body, error }) => { - const vids = await db - .insert(videos) - .values(body) - .onConflictDoUpdate({ - target: [videos.path], - set: conflictUpdateAllExcept(videos, ["pk", "id", "createdAt"]), - }) - .returning({ - pk: videos.pk, - id: videos.id, - path: videos.path, + return await db.transaction(async (tx) => { + const vids = await tx + .insert(videos) + .values(body) + .onConflictDoUpdate({ + target: [videos.path], + set: conflictUpdateAllExcept(videos, ["pk", "id", "createdAt"]), + }) + .returning({ + pk: videos.pk, + id: videos.id, + path: videos.path, + }); + + const vidEntries = body.flatMap((x) => { + if (!x.for) return []; + return x.for.map((e) => ({ + video: vids.find((v) => v.path === x.path)!.pk, + path: x.path, + entry: { + ...e, + movie: + "movie" in e + ? isUuid(e.movie) + ? { id: e.movie } + : { slug: e.movie } + : undefined, + serie: + "serie" in e + ? isUuid(e.serie) + ? { id: e.serie } + : { slug: e.serie } + : undefined, + }, + })); }); - const vidEntries = body.flatMap((x) => { - if (!x.for) return []; - return x.for.map((e) => ({ - video: vids.find((v) => v.path === x.path)!.pk, - path: x.path, - entry: { - ...e, - movie: - "movie" in e - ? isUuid(e.movie) - ? { id: e.movie } - : { slug: e.movie } - : undefined, - serie: - "serie" in e - ? isUuid(e.serie) - ? { id: e.serie } - : { slug: e.serie } - : undefined, - }, - })); - }); + if (!vidEntries.length) { + return error( + 201, + vids.map((x) => ({ id: x.id, path: x.path, entries: [] })), + ); + } - if (!vidEntries.length) { + const entriesQ = tx + .select({ + pk: entries.pk, + id: entries.id, + slug: entries.slug, + kind: entries.kind, + seasonNumber: entries.seasonNumber, + episodeNumber: entries.episodeNumber, + order: entries.order, + showId: sql`${shows.id}`.as("showId"), + showSlug: sql`${shows.slug}`.as("showSlug"), + externalId: entries.externalId, + }) + .from(entries) + .innerJoin(shows, eq(entries.showPk, shows.pk)) + .as("entriesQ"); + + const hasRenderingQ = tx + .select() + .from(entryVideoJoin) + .where(eq(entryVideoJoin.entryPk, entriesQ.pk)); + + const ret = await tx + .insert(entryVideoJoin) + .select( + tx + .selectDistinctOn([entriesQ.pk, videos.pk], { + entryPk: entriesQ.pk, + videoPk: videos.pk, + slug: computeVideoSlug( + entriesQ.slug, + sql`exists(${hasRenderingQ})`, + ), + }) + .from( + values(vidEntries, { + video: "integer", + entry: "jsonb", + }).as("j"), + ) + .innerJoin(videos, eq(videos.pk, sql`j.video`)) + .innerJoin( + entriesQ, + or( + and( + sql`j.entry ? 'slug'`, + eq(entriesQ.slug, sql`j.entry->>'slug'`), + ), + and( + sql`j.entry ? 'movie'`, + or( + eq( + entriesQ.showId, + sql`(j.entry #>> '{movie, id}')::uuid`, + ), + eq(entriesQ.showSlug, sql`j.entry #>> '{movie, slug}'`), + ), + eq(entriesQ.kind, "movie"), + ), + and( + sql`j.entry ? 'serie'`, + or( + eq( + entriesQ.showId, + sql`(j.entry #>> '{serie, id}')::uuid`, + ), + eq(entriesQ.showSlug, sql`j.entry #>> '{serie, slug}'`), + ), + or( + and( + sql`j.entry ?& array['season', 'episode']`, + eq( + entriesQ.seasonNumber, + sql`(j.entry->>'season')::integer`, + ), + eq( + entriesQ.episodeNumber, + sql`(j.entry->>'episode')::integer`, + ), + ), + and( + sql`j.entry ? 'order'`, + eq(entriesQ.order, sql`(j.entry->>'order')::float`), + ), + and( + sql`j.entry ? 'special'`, + eq( + entriesQ.episodeNumber, + sql`(j.entry->>'special')::integer`, + ), + eq(entriesQ.kind, "special"), + ), + ), + ), + and( + sql`j.entry ? 'externalId'`, + sql`j.entry->'externalId' <@ ${entriesQ.externalId}`, + ), + ), + ), + ) + .onConflictDoNothing() + .returning({ + slug: entryVideoJoin.slug, + entryPk: entryVideoJoin.entryPk, + videoPk: entryVideoJoin.videoPk, + }); + const entr = ret.reduce( + (acc, x) => { + acc[x.videoPk] ??= []; + acc[x.videoPk].push({ slug: x.slug }); + return acc; + }, + {} as Record, + ); return error( 201, - vids.map((x) => ({ id: x.id, path: x.path, entries: [] })), + vids.map((x) => ({ + id: x.id, + path: x.path, + entries: entr[x.pk] ?? [], + })), ); - } - - const entriesQ = db - .select({ - pk: entries.pk, - id: entries.id, - slug: entries.slug, - kind: entries.kind, - seasonNumber: entries.seasonNumber, - episodeNumber: entries.episodeNumber, - order: entries.order, - showId: sql`${shows.id}`.as("showId"), - showSlug: sql`${shows.slug}`.as("showSlug"), - externalId: entries.externalId, - }) - .from(entries) - .innerJoin(shows, eq(entries.showPk, shows.pk)) - .as("entriesQ"); - - const hasRenderingQ = db - .select() - .from(entryVideoJoin) - .where(eq(entryVideoJoin.entryPk, entriesQ.pk)); - - const ret = await db - .insert(entryVideoJoin) - .select( - db - .select({ - entryPk: entriesQ.pk, - videoPk: videos.pk, - slug: computeVideoSlug( - entriesQ.slug, - sql`exists(${hasRenderingQ})`, - ), - }) - .from( - values(vidEntries, { - video: "integer", - entry: "jsonb", - }).as("j"), - ) - .innerJoin(videos, eq(videos.pk, sql`j.video`)) - .innerJoin( - entriesQ, - or( - and( - sql`j.entry ? 'slug'`, - eq(entriesQ.slug, sql`j.entry->>'slug'`), - ), - and( - sql`j.entry ? 'movie'`, - or( - eq(entriesQ.showId, sql`(j.entry #>> '{movie, id}')::uuid`), - eq(entriesQ.showSlug, sql`j.entry #>> '{movie, slug}'`), - ), - eq(entriesQ.kind, "movie"), - ), - and( - sql`j.entry ? 'serie'`, - or( - eq(entriesQ.showId, sql`(j.entry #>> '{serie, id}')::uuid`), - eq(entriesQ.showSlug, sql`j.entry #>> '{serie, slug}'`), - ), - or( - and( - sql`j.entry ?& array['season', 'episode']`, - eq( - entriesQ.seasonNumber, - sql`(j.entry->>'season')::integer`, - ), - eq( - entriesQ.episodeNumber, - sql`(j.entry->>'episode')::integer`, - ), - ), - and( - sql`j.entry ? 'order'`, - eq(entriesQ.order, sql`(j.entry->>'order')::float`), - ), - and( - sql`j.entry ? 'special'`, - eq( - entriesQ.episodeNumber, - sql`(j.entry->>'special')::integer`, - ), - eq(entriesQ.kind, "special"), - ), - ), - ), - and( - sql`j.entry ? 'externalId'`, - sql`j.entry->'externalId' <@ ${entriesQ.externalId}`, - ), - ), - ), - ) - .onConflictDoNothing() - .returning({ - slug: entryVideoJoin.slug, - entryPk: entryVideoJoin.entryPk, - videoPk: entryVideoJoin.videoPk, - }); - const entr = ret.reduce( - (acc, x) => { - acc[x.videoPk] ??= []; - acc[x.videoPk].push({ slug: x.slug }); - return acc; - }, - {} as Record, - ); - return error( - 201, - vids.map((x) => ({ - id: x.id, - path: x.path, - entries: entr[x.pk] ?? [], - })), - ); + }); }, { detail: { diff --git a/api/src/models/video.ts b/api/src/models/video.ts index 15799dd8..15ce5fab 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -1,4 +1,4 @@ -import { PatternString } from "@sinclair/typebox"; +import { PatternStringExact } from "@sinclair/typebox"; import { t } from "elysia"; import { type Prettify, comment } from "~/utils"; import { ExtraType } from "./entry/extra"; @@ -9,12 +9,13 @@ const ExternalIds = t.Record( t.String(), t.Omit( t.Union([ - EpisodeId.patternProperties[PatternString], - ExternalId().patternProperties[PatternString], + EpisodeId.patternProperties[PatternStringExact], + ExternalId().patternProperties[PatternStringExact], ]), ["link"], ), ); +type ExternalIds = typeof ExternalIds.static; export const Guess = t.Recursive((Self) => t.Object( diff --git a/api/tests/videos/scanner.test.ts b/api/tests/videos/scanner.test.ts index 82624adc..989aa833 100644 --- a/api/tests/videos/scanner.test.ts +++ b/api/tests/videos/scanner.test.ts @@ -109,6 +109,38 @@ describe("Video seeding", () => { expect(vid!.evj[0].entry.slug).toBe(bubble.slug); }); + it("Conflicting path", async () => { + const [resp, body] = await createVideo({ + guess: { title: "bubble", from: "test" }, + part: null, + path: "/video/bubble.mkv", + rendering: "sha", + version: 1, + for: [{ movie: bubble.slug }], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/bubble.mkv"); + expect(vid!.guess).toMatchObject({ title: "bubble", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(1); + expect(vid!.evj).toBeArrayOfSize(1); + + expect(vid!.evj[0].slug).toBe(bubble.slug); + expect(vid!.evj[0].entry.slug).toBe(bubble.slug); + }); + it("With season/episode", async () => { const [resp, body] = await createVideo({ guess: { title: "mia", season: [2], episode: [1], from: "test" }, @@ -229,7 +261,7 @@ describe("Video seeding", () => { episode: [3], from: "test", externalId: { - themoviedb: { serieId: "72636", season: 1, episode: 13 }, + themoviedatabase: { serieId: "72636", season: 1, episode: 13 }, }, }, part: null, @@ -239,7 +271,7 @@ describe("Video seeding", () => { for: [ { externalId: { - themoviedb: { serieId: "72636", season: 1, episode: 13 }, + themoviedatabase: { serieId: "72636", season: 1, episode: 13 }, }, }, ], @@ -273,7 +305,7 @@ describe("Video seeding", () => { title: "bubble", from: "test", externalId: { - themoviedb: { dataId: "912598", season: 1, episode: 13 }, + themoviedatabase: { dataId: "912598" }, }, }, part: null, @@ -283,7 +315,7 @@ describe("Video seeding", () => { for: [ { externalId: { - themoviedb: { serieId: "912598", season: 1, episode: 13 }, + themoviedatabase: { dataId: "912598" }, }, }, ], @@ -317,18 +349,18 @@ describe("Video seeding", () => { title: "bubble", from: "test", externalId: { - themoviedb: { dataId: "912598", season: 1, episode: 13 }, + themoviedatabase: { dataId: "912598" }, }, }, part: null, - path: "/video/bubble [tmdb=912598].mkv", - rendering: "cwhtn", + path: "/video/bubble ue [tmdb=912598].mkv", + rendering: "aoeubnht", version: 1, for: [ { movie: "bubble" }, { externalId: { - themoviedb: { serieId: "912598", season: 1, episode: 13 }, + themoviedatabase: { dataId: "912598" }, }, }, ], @@ -346,13 +378,13 @@ describe("Video seeding", () => { }); expect(vid).not.toBeNil(); - expect(vid!.path).toBe("/video/bubble [tmdb=912598].mkv"); + expect(vid!.path).toBe("/video/bubble ue [tmdb=912598].mkv"); expect(vid!.guess).toMatchObject({ title: "bubble", from: "test" }); expect(body[0].entries).toBeArrayOfSize(1); expect(vid!.evj).toBeArrayOfSize(1); - expect(vid!.evj[0].slug).toBe("bubble-cwhtn"); + expect(vid!.evj[0].slug).toBe("bubble-aoeubnht"); expect(vid!.evj[0].entry.slug).toBe("bubble"); }); @@ -363,7 +395,7 @@ describe("Video seeding", () => { title: "bubble", from: "test", externalId: { - themoviedb: { dataId: "912598", season: 1, episode: 13 }, + themoviedatabase: { dataId: "912598" }, }, }, part: null, @@ -374,7 +406,7 @@ describe("Video seeding", () => { { movie: "bubble" }, { externalId: { - themoviedb: { serieId: "912598", season: 1, episode: 13 }, + themoviedatabase: { dataId: "912598" }, }, }, ], @@ -401,4 +433,120 @@ describe("Video seeding", () => { expect(vid!.evj[0].slug).toBe("bubble"); expect(vid!.evj[0].entry.slug).toBe("bubble"); }); + + it("Multi part", async () => { + await db.delete(videos); + const [resp, body] = await createVideo([ + { + guess: { + title: "bubble", + from: "test", + externalId: { + themoviedatabase: { dataId: "912598" }, + }, + }, + part: 1, + path: "/video/bubble p1 [tmdb=912598].mkv", + rendering: "cwhtn", + version: 1, + for: [ + { movie: "bubble" }, + { + externalId: { + themoviedatabase: { dataId: "912598" }, + }, + }, + ], + }, + { + guess: { + title: "bubble", + from: "test", + externalId: { + themoviedatabase: { dataId: "912598" }, + }, + }, + part: 2, + path: "/video/bubble p2 [tmdb=912598].mkv", + rendering: "cwhtn", + version: 1, + for: [ + { movie: "bubble" }, + { + externalId: { + themoviedatabase: { dataId: "912598" }, + }, + }, + ], + }, + ]); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(2); + expect(body[0].id).toBeString(); + expect(body[1].id).toBeString(); + expect(body[0].entries).toBeArrayOfSize(1); + expect(body[1].entries).toBeArrayOfSize(1); + + const entr = (await db.query.entries.findFirst({ + where: eq(entries.slug, bubble.slug), + with: { + evj: { with: { video: true } }, + }, + }))!; + + expect(entr.evj).toBeArrayOfSize(2); + expect(entr.evj[0].video.path).toBe("/video/bubble p1 [tmdb=912598].mkv"); + + expect(entr.evj[0].slug).toBe("bubble-p1"); + expect(entr.evj[1].slug).toBe("bubble-p2"); + }); + + it("Multi entry", async () => { + await db.delete(videos); + const [resp, body] = await createVideo({ + guess: { + title: "mia", + season: [1, 2], + episode: [13, 1], + from: "test", + }, + part: null, + path: "/video/mia s1e13 & s2e1 [tmdb=72636].mkv", + rendering: "notehu", + version: 1, + for: [ + { serie: madeInAbyss.slug, season: 1, episode: 13 }, + { + externalId: { + themoviedatabase: { serieId: "72636", season: 1, episode: 13 }, + }, + }, + { serie: madeInAbyss.slug, season: 2, episode: 1 }, + ], + }); + + expectStatus(resp, body).toBe(201); + expect(body).toBeArrayOfSize(1); + expect(body[0].id).toBeString(); + + const vid = await db.query.videos.findFirst({ + where: eq(videos.id, body[0].id), + with: { + evj: { with: { entry: true } }, + }, + }); + + expect(vid).not.toBeNil(); + expect(vid!.path).toBe("/video/mia s1e13 & s2e1 [tmdb=72636].mkv"); + expect(vid!.guess).toMatchObject({ title: "mia", from: "test" }); + + expect(body[0].entries).toBeArrayOfSize(2); + expect(vid!.evj).toBeArrayOfSize(2); + + expect(vid!.evj[0].slug).toBe("made-in-abyss-s1e13"); + expect(vid!.evj[0].entry.slug).toBe("made-in-abyss-s1e13"); + expect(vid!.evj[1].slug).toBe("made-in-abyss-s2e1"); + expect(vid!.evj[1].entry.slug).toBe("made-in-abyss-s2e1"); + }); }); From 45e769828b69f1ded9215d172985856e15427728 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 2 May 2025 17:29:51 +0200 Subject: [PATCH 18/23] Add unique constraint on [rendering, version, part] --- api/drizzle/0020_video_unique.sql | 5 + api/drizzle/meta/0020_snapshot.json | 1851 ++++++++++++++++++++++++++ api/drizzle/meta/_journal.json | 7 + api/src/controllers/videos.ts | 53 +- api/src/db/schema/videos.ts | 4 + api/src/db/utils.ts | 4 + api/tests/movies/seed-movies.test.ts | 2 +- api/tests/videos/scanner.test.ts | 30 +- 8 files changed, 1935 insertions(+), 21 deletions(-) create mode 100644 api/drizzle/0020_video_unique.sql create mode 100644 api/drizzle/meta/0020_snapshot.json diff --git a/api/drizzle/0020_video_unique.sql b/api/drizzle/0020_video_unique.sql new file mode 100644 index 00000000..2aac2c26 --- /dev/null +++ b/api/drizzle/0020_video_unique.sql @@ -0,0 +1,5 @@ +ALTER TABLE "kyoo"."entries" ALTER COLUMN "kind" SET DATA TYPE text;--> statement-breakpoint +DROP TYPE "kyoo"."entry_type";--> statement-breakpoint +CREATE TYPE "kyoo"."entry_type" AS ENUM('episode', 'movie', 'special', 'extra');--> statement-breakpoint +ALTER TABLE "kyoo"."entries" ALTER COLUMN "kind" SET DATA TYPE "kyoo"."entry_type" USING "kind"::"kyoo"."entry_type";--> statement-breakpoint +ALTER TABLE "kyoo"."videos" ADD CONSTRAINT "rendering_unique" UNIQUE NULLS NOT DISTINCT("rendering","part","version"); \ No newline at end of file diff --git a/api/drizzle/meta/0020_snapshot.json b/api/drizzle/meta/0020_snapshot.json new file mode 100644 index 00000000..8aa20c91 --- /dev/null +++ b/api/drizzle/meta/0020_snapshot.json @@ -0,0 +1,1851 @@ +{ + "id": "0c44c1f6-0b4d-4beb-8f67-b6250f92c5e2", + "prevId": "4a892e3b-fbc3-426f-a86b-b298c6f89d71", + "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": false + }, + "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[]", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "runtime": { + "name": "runtime", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "show_status", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "start_air": { + "name": "start_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "end_air": { + "name": "end_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "original": { + "name": "original", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "collection_pk": { + "name": "collection_pk", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "entries_count": { + "name": "entries_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "available_count": { + "name": "available_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kind": { + "name": "kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "rating": { + "name": "rating", + "columns": [ + { + "expression": "rating", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "startAir": { + "name": "startAir", + "columns": [ + { + "expression": "start_air", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shows_collection_pk_shows_pk_fk": { + "name": "shows_collection_pk_shows_pk_fk", + "tableFrom": "shows", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["collection_pk"], + "columnsTo": ["pk"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "shows_id_unique": { + "name": "shows_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "shows_slug_unique": { + "name": "shows_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": { + "rating_valid": { + "name": "rating_valid", + "value": "\"kyoo\".\"shows\".\"rating\" between 0 and 100" + }, + "runtime_valid": { + "name": "runtime_valid", + "value": "\"kyoo\".\"shows\".\"runtime\" >= 0" + } + }, + "isRLSEnabled": false + }, + "kyoo.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"] + }, + "rendering_unique": { + "name": "rendering_unique", + "nullsNotDistinct": true, + "columns": ["rendering", "part", "version"] + } + }, + "policies": {}, + "checkConstraints": { + "part_pos": { + "name": "part_pos", + "value": "\"kyoo\".\"videos\".\"part\" >= 0" + }, + "version_pos": { + "name": "version_pos", + "value": "\"kyoo\".\"videos\".\"version\" >= 0" + } + }, + "isRLSEnabled": false + }, + "kyoo.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 + }, + "last_played_at": { + "name": "last_played_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "watchlist_profile_pk_profiles_pk_fk": { + "name": "watchlist_profile_pk_profiles_pk_fk", + "tableFrom": "watchlist", + "tableTo": "profiles", + "schemaTo": "kyoo", + "columnsFrom": ["profile_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watchlist_show_pk_shows_pk_fk": { + "name": "watchlist_show_pk_shows_pk_fk", + "tableFrom": "watchlist", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watchlist_next_entry_entries_pk_fk": { + "name": "watchlist_next_entry_entries_pk_fk", + "tableFrom": "watchlist", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["next_entry"], + "columnsTo": ["pk"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "watchlist_profile_pk_show_pk_pk": { + "name": "watchlist_profile_pk_show_pk_pk", + "columns": ["profile_pk", "show_pk"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "score_percent": { + "name": "score_percent", + "value": "\"kyoo\".\"watchlist\".\"score\" between 0 and 100" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "kyoo.entry_type": { + "name": "entry_type", + "schema": "kyoo", + "values": ["episode", "movie", "special", "extra"] + }, + "kyoo.genres": { + "name": "genres", + "schema": "kyoo", + "values": [ + "action", + "adventure", + "animation", + "comedy", + "crime", + "documentary", + "drama", + "family", + "fantasy", + "history", + "horror", + "music", + "mystery", + "romance", + "science-fiction", + "thriller", + "war", + "western", + "kids", + "reality", + "politics", + "soap", + "talk" + ] + }, + "kyoo.show_kind": { + "name": "show_kind", + "schema": "kyoo", + "values": ["serie", "movie", "collection"] + }, + "kyoo.show_status": { + "name": "show_status", + "schema": "kyoo", + "values": ["unknown", "finished", "airing", "planned"] + }, + "kyoo.role_kind": { + "name": "role_kind", + "schema": "kyoo", + "values": ["actor", "director", "writter", "producer", "music", "other"] + }, + "kyoo.watchlist_status": { + "name": "watchlist_status", + "schema": "kyoo", + "values": ["watching", "rewatching", "completed", "dropped", "planned"] + } + }, + "schemas": { + "kyoo": "kyoo" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/api/drizzle/meta/_journal.json b/api/drizzle/meta/_journal.json index a16ce389..34ec2fa1 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -141,6 +141,13 @@ "when": 1744120518941, "tag": "0019_nextup", "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1746198322219, + "tag": "0020_video_unique", + "breakpoints": true } ] } diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index 676b32b4..292c0485 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -5,6 +5,7 @@ import { db } from "~/db"; import { entries, entryVideoJoin, shows, videos } from "~/db/schema"; import { conflictUpdateAllExcept, + isUniqueConstraint, jsonbBuildObject, jsonbObjectAgg, values, @@ -161,18 +162,33 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) "", async ({ body, error }) => { return await db.transaction(async (tx) => { - const vids = await tx - .insert(videos) - .values(body) - .onConflictDoUpdate({ - target: [videos.path], - set: conflictUpdateAllExcept(videos, ["pk", "id", "createdAt"]), - }) - .returning({ - pk: videos.pk, - id: videos.id, - path: videos.path, + let vids: { pk: number; id: string; path: string }[] = []; + try { + vids = await tx + .insert(videos) + .values(body) + .onConflictDoUpdate({ + target: [videos.path], + set: conflictUpdateAllExcept(videos, ["pk", "id", "createdAt"]), + }) + .returning({ + pk: videos.pk, + id: videos.id, + path: videos.path, + }); + } catch (e) { + if (!isUniqueConstraint(e)) + throw e; + return error(409, { + status: 409, + message: comment` + Invalid rendering. A video with the same (rendering, part, version) combo + (but with a different path) already exists in db. + + rendering should be computed by the sha of your path (excluding only the version & part numbers) + `, }); + } const vidEntries = body.flatMap((x) => { if (!x.for) return []; @@ -305,7 +321,11 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) ), ), ) - .onConflictDoNothing() + .onConflictDoUpdate({ + target: [entryVideoJoin.entryPk, entryVideoJoin.videoPk], + // this is basically a `.onConflictDoNothing()` but we want `returning` to give us the existing data + set: { entryPk: sql`excluded.entry_pk` }, + }) .returning({ slug: entryVideoJoin.slug, entryPk: entryVideoJoin.entryPk, @@ -340,7 +360,14 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) `, }, body: t.Array(SeedVideo), - response: { 201: t.Array(CreatedVideo) }, + response: { + 201: t.Array(CreatedVideo), + 409: { + ...KError, + description: + "Invalid rendering specified. (conflicts with an existing video)", + }, + }, }, ) .delete( diff --git a/api/src/db/schema/videos.ts b/api/src/db/schema/videos.ts index a7c60d42..2e4fda6c 100644 --- a/api/src/db/schema/videos.ts +++ b/api/src/db/schema/videos.ts @@ -6,6 +6,7 @@ import { primaryKey, text, timestamp, + unique, uuid, varchar, } from "drizzle-orm/pg-core"; @@ -34,6 +35,9 @@ export const videos = schema.table( (t) => [ check("part_pos", sql`${t.part} >= 0`), check("version_pos", sql`${t.version} >= 0`), + unique("rendering_unique") + .on(t.rendering, t.part, t.version) + .nullsNotDistinct(), ], ); diff --git a/api/src/db/utils.ts b/api/src/db/utils.ts index 30bc0b83..c0caa80a 100644 --- a/api/src/db/utils.ts +++ b/api/src/db/utils.ts @@ -142,3 +142,7 @@ export const jsonbBuildObject = (select: JsonFields) => { ); return sql`jsonb_build_object(${query})`; }; + +export const isUniqueConstraint = (e: unknown): boolean => { + return typeof e === "object" && e != null && "code" in e && e.code === "23505"; +}; diff --git a/api/tests/movies/seed-movies.test.ts b/api/tests/movies/seed-movies.test.ts index 611be355..242a1161 100644 --- a/api/tests/movies/seed-movies.test.ts +++ b/api/tests/movies/seed-movies.test.ts @@ -384,7 +384,7 @@ describe("Movie seeding", () => { path: "/video/bubble3.mkv", part: null, version: 1, - rendering: "oeunhtoeuth", + rendering: "oeunhtoeuthoeu", guess: { title: "bubble", from: "test" }, }, { diff --git a/api/tests/videos/scanner.test.ts b/api/tests/videos/scanner.test.ts index 989aa833..070be851 100644 --- a/api/tests/videos/scanner.test.ts +++ b/api/tests/videos/scanner.test.ts @@ -50,7 +50,7 @@ describe("Video seeding", () => { guess: { title: "mia", season: [1], episode: [13], from: "test" }, part: null, path: "/video/mia s1e13.mkv", - rendering: "sha", + rendering: "sha2", version: 1, for: [{ slug: `${madeInAbyss.slug}-s1e13` }], }); @@ -82,7 +82,7 @@ describe("Video seeding", () => { guess: { title: "bubble", from: "test" }, part: null, path: "/video/bubble.mkv", - rendering: "sha", + rendering: "sha3", version: 1, for: [{ movie: bubble.slug }], }); @@ -114,7 +114,7 @@ describe("Video seeding", () => { guess: { title: "bubble", from: "test" }, part: null, path: "/video/bubble.mkv", - rendering: "sha", + rendering: "sha4", version: 1, for: [{ movie: bubble.slug }], }); @@ -221,7 +221,7 @@ describe("Video seeding", () => { guess: { title: "mia", season: [0], episode: [3], from: "test" }, part: null, path: "/video/mia 13.5.mkv", - rendering: "notehu", + rendering: "notehu2", version: 1, for: [ { @@ -266,7 +266,7 @@ describe("Video seeding", () => { }, part: null, path: "/video/mia s1e13 [tmdb=72636].mkv", - rendering: "notehu", + rendering: "notehu3", version: 1, for: [ { @@ -295,7 +295,7 @@ describe("Video seeding", () => { expect(body[0].entries).toBeArrayOfSize(1); expect(vid!.evj).toBeArrayOfSize(1); - expect(vid!.evj[0].slug).toBe("made-in-abyss-s1e13-notehu"); + expect(vid!.evj[0].slug).toBe("made-in-abyss-s1e13-notehu3"); expect(vid!.evj[0].entry.slug).toBe("made-in-abyss-s1e13"); }); @@ -343,6 +343,22 @@ describe("Video seeding", () => { expect(vid!.evj[0].entry.slug).toBe("bubble"); }); + it("Different path, same sha", async () => { + const [resp, body] = await createVideo({ + guess: { title: "bubble", from: "test" }, + part: null, + path: "/video/bubble invalid-sha.mkv", + rendering: "sha", + version: 1, + for: [{ movie: bubble.slug }], + }); + + // conflict with existing video, message will contain an explanation on how to fix this + expectStatus(resp, body).toBe(409); + expect(body.message).toBeString(); + }); + + it("Two for the same entry", async () => { const [resp, body] = await createVideo({ guess: { @@ -513,7 +529,7 @@ describe("Video seeding", () => { }, part: null, path: "/video/mia s1e13 & s2e1 [tmdb=72636].mkv", - rendering: "notehu", + rendering: "notehu5", version: 1, for: [ { serie: madeInAbyss.slug, season: 1, episode: 13 }, From e26bc931f5b9ccec5cc14f4db1ac81d131bc3279 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 3 May 2025 14:37:05 +0200 Subject: [PATCH 19/23] Fix & test `GET /videos` --- api/src/controllers/videos.ts | 14 ++- api/src/db/utils.ts | 4 +- api/src/models/utils/resource.ts | 1 + api/src/models/video.ts | 7 +- api/tests/helpers/videos-helper.ts | 26 +++++ api/tests/manual.ts | 54 ++++++---- api/tests/videos/getdel.test.ts | 154 +++++++++++++++++++++++++++++ api/tests/videos/scanner.test.ts | 2 - 8 files changed, 232 insertions(+), 30 deletions(-) create mode 100644 api/tests/videos/getdel.test.ts diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index 292c0485..38ad0fdd 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -14,6 +14,7 @@ import { KError } from "~/models/error"; import { bubbleVideo } from "~/models/examples"; import { Page, + type Resource, Sort, createPage, isUuid, @@ -54,8 +55,9 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) slug: shows.slug, }) .from(videos) - .crossJoin( + .leftJoin( sql`jsonb_array_elements_text(${videos.guess}->'year') as year`, + sql`true`, ) .innerJoin(entryVideoJoin, eq(entryVideoJoin.videoPk, videos.pk)) .innerJoin(entries, eq(entries.pk, entryVideoJoin.entryPk)) @@ -78,7 +80,10 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) const [{ guesses }] = await db .with(years, guess) .select({ - guesses: jsonbObjectAgg(guess.guess, guess.years), + guesses: jsonbObjectAgg>( + guess.guess, + guess.years, + ), }) .from(guess); @@ -98,7 +103,7 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) return { paths: paths.map((x) => x.path), - guesses, + guesses: guesses ?? {}, unmatched: unmatched.map((x) => x.path), }; }, @@ -177,8 +182,7 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) path: videos.path, }); } catch (e) { - if (!isUniqueConstraint(e)) - throw e; + if (!isUniqueConstraint(e)) throw e; return error(409, { status: 409, message: comment` diff --git a/api/src/db/utils.ts b/api/src/db/utils.ts index c0caa80a..c57acffa 100644 --- a/api/src/db/utils.ts +++ b/api/src/db/utils.ts @@ -144,5 +144,7 @@ export const jsonbBuildObject = (select: JsonFields) => { }; export const isUniqueConstraint = (e: unknown): boolean => { - return typeof e === "object" && e != null && "code" in e && e.code === "23505"; + return ( + typeof e === "object" && e != null && "code" in e && e.code === "23505" + ); }; diff --git a/api/src/models/utils/resource.ts b/api/src/models/utils/resource.ts index 2fe7465d..878b956b 100644 --- a/api/src/models/utils/resource.ts +++ b/api/src/models/utils/resource.ts @@ -13,6 +13,7 @@ export const Resource = () => id: t.String({ format: "uuid" }), slug: t.String({ format: "slug" }), }); +export type Resource = ReturnType["static"]; const checker = TypeCompiler.Compile(t.String({ format: "uuid" })); export const isUuid = (id: string) => checker.Check(id); diff --git a/api/src/models/video.ts b/api/src/models/video.ts index 15ce5fab..f9cefaf2 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -167,10 +167,7 @@ export const Guesses = t.Object({ paths: t.Array(t.String()), guesses: t.Record( t.String(), - t.Record( - t.Union([t.Literal("unknown"), t.String({ pattern: "[1-9][0-9]*" })]), - Resource(), - ), + t.Record(t.String({ pattern: "^([1-9][0-9]{3})|unknown$" }), Resource()), ), unmatched: t.Array(t.String()), }); @@ -188,7 +185,7 @@ registerExamples(Guesses, { id: "43b742f5-9ce6-467d-ad29-74460624020a", slug: "evangelion", }, - 1995: { + "1995": { id: "43b742f5-9ce6-467d-ad29-74460624020a", slug: "evangelion", }, diff --git a/api/tests/helpers/videos-helper.ts b/api/tests/helpers/videos-helper.ts index 380cd81d..cc750a1a 100644 --- a/api/tests/helpers/videos-helper.ts +++ b/api/tests/helpers/videos-helper.ts @@ -17,3 +17,29 @@ export const createVideo = async (video: SeedVideo | SeedVideo[]) => { const body = await resp.json(); return [resp, body] as const; }; + +export const getVideos = async () => { + const resp = await app.handle( + new Request(buildUrl("videos"), { + method: "GET", + headers: await getJwtHeaders(), + }), + ); + const body = await resp.json(); + return [resp, body] as const; +}; + +export const deleteVideo = async (paths: string[]) => { + const resp = await app.handle( + new Request(buildUrl("videos"), { + method: "DELETE", + body: JSON.stringify(paths), + headers: { + "Content-Type": "application/json", + ...(await getJwtHeaders()), + }, + }), + ); + const body = await resp.json(); + return [resp, body] as const; +}; diff --git a/api/tests/manual.ts b/api/tests/manual.ts index ca821e3a..c038486b 100644 --- a/api/tests/manual.ts +++ b/api/tests/manual.ts @@ -1,7 +1,7 @@ import { db, migrate } from "~/db"; import { profiles, shows } from "~/db/schema"; -import { madeInAbyss } from "~/models/examples"; -import { createSerie, createVideo } from "./helpers"; +import { bubble, madeInAbyss } from "~/models/examples"; +import { createMovie, createSerie, createVideo, getVideos } from "./helpers"; // test file used to run manually using `bun tests/manual.ts` // run those before running this script @@ -12,22 +12,42 @@ await migrate(); await db.delete(shows); await db.delete(profiles); -const [__, ser] = await createSerie(madeInAbyss); -console.log(ser); -const [_, body] = await createVideo({ - guess: { title: "mia", season: [1], episode: [13], from: "test" }, - part: null, - path: "/video/mia s1e13.mkv", - rendering: "renderingsha", - version: 1, - for: [ - { - serie: madeInAbyss.slug, - season: madeInAbyss.entries[0].seasonNumber!, - episode: madeInAbyss.entries[0].episodeNumber!, +const [_, ser] = await createSerie(madeInAbyss); +const [__, mov] = await createMovie(bubble); +const [resp, body] = await createVideo([ + { + guess: { title: "mia", season: [1], episode: [13], from: "test" }, + part: null, + path: "/video/mia s1e13.mkv", + rendering: "sha2", + version: 1, + for: [{ slug: `${madeInAbyss.slug}-s1e13` }], + }, + { + guess: { + title: "mia", + season: [2], + episode: [1], + year: [2017], + from: "test", }, - ], -}); + part: null, + path: "/video/mia 2017 s2e1.mkv", + rendering: "sha8", + version: 1, + for: [{ slug: `${madeInAbyss.slug}-s2e1` }], + }, + { + guess: { title: "bubble", from: "test" }, + part: null, + path: "/video/bubble.mkv", + rendering: "sha5", + version: 1, + for: [{ movie: bubble.slug }], + }, +]); console.log(body); +const [___, ret] = await getVideos(); +console.log(JSON.stringify(ret, undefined, 4)); process.exit(0); diff --git a/api/tests/videos/getdel.test.ts b/api/tests/videos/getdel.test.ts new file mode 100644 index 00000000..e9c7075b --- /dev/null +++ b/api/tests/videos/getdel.test.ts @@ -0,0 +1,154 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import { + createMovie, + createSerie, + createVideo, + getVideos, +} from "tests/helpers"; +import { expectStatus } from "tests/utils"; +import { db } from "~/db"; +import { shows, videos } from "~/db/schema"; +import { bubble, madeInAbyss } from "~/models/examples"; + +beforeAll(async () => { + await db.delete(shows); + await db.delete(videos); + + let [ret, body] = await createSerie(madeInAbyss); + expectStatus(ret, body).toBe(201); + [ret, body] = await createMovie(bubble); + expectStatus(ret, body).toBe(201); + + [ret, body] = await createVideo([ + { + guess: { title: "mia", season: [1], episode: [13], from: "test" }, + part: null, + path: "/video/mia s1e13.mkv", + rendering: "sha2", + version: 1, + for: [{ slug: `${madeInAbyss.slug}-s1e13` }], + }, + { + guess: { + title: "mia", + season: [2], + episode: [1], + year: [2017], + from: "test", + }, + part: null, + path: "/video/mia 2017 s2e1.mkv", + rendering: "sha8", + version: 1, + for: [{ slug: `${madeInAbyss.slug}-s2e1` }], + }, + { + guess: { title: "bubble", from: "test" }, + part: null, + path: "/video/bubble.mkv", + rendering: "sha5", + version: 1, + for: [{ movie: bubble.slug }], + }, + ]); + expectStatus(ret, body).toBe(201); + expect(body).toBeArrayOfSize(3); + expect(body[0].entries).toBeArrayOfSize(1); + expect(body[1].entries).toBeArrayOfSize(1); + expect(body[2].entries).toBeArrayOfSize(1); +}); + +describe("Video get/deletion", () => { + it("Get current state", async () => { + const [resp, body] = await getVideos(); + expectStatus(resp, body).toBe(200); + expect(body.guesses).toMatchObject({ + mia: { + unknown: { + id: expect.any(String), + slug: "made-in-abyss", + }, + "2017": { + id: expect.any(String), + slug: "made-in-abyss", + }, + }, + bubble: { + unknown: { + id: expect.any(String), + slug: "bubble", + }, + }, + }); + }); + + it("With unknown", async () => { + let [resp, body] = await createVideo({ + guess: { title: "mia", season: [1], episode: [13], from: "test" }, + part: null, + path: "/video/mia s1e13 unknown test.mkv", + rendering: "shanthnth", + version: 1, + }); + expectStatus(resp, body).toBe(201); + + [resp, body] = await getVideos(); + expectStatus(resp, body).toBe(200); + expect(body.guesses).toMatchObject({ + mia: { + unknown: { + id: expect.any(String), + slug: "made-in-abyss", + }, + "2017": { + id: expect.any(String), + slug: "made-in-abyss", + }, + }, + bubble: { + unknown: { + id: expect.any(String), + slug: "bubble", + }, + }, + }); + expect(body.unmatched).toBeArrayOfSize(1); + expect(body.unmatched[0]).toBe("/video/mia s1e13 unknown test.mkv"); + }); + + it("Mismatch title guess", async () => { + let [resp, body] = await createVideo({ + guess: { title: "mia", season: [1], episode: [13], from: "test" }, + part: null, + path: "/video/mia s1e13 mismatch.mkv", + rendering: "mismatch", + version: 1, + for: [{ movie: "bubble" }], + }); + expectStatus(resp, body).toBe(201); + + [resp, body] = await getVideos(); + expectStatus(resp, body).toBe(200); + expect(body.guesses).toMatchObject({ + mia: { + unknown: { + id: expect.any(String), + // take the latest slug + slug: "bubble", + }, + "2017": { + id: expect.any(String), + slug: "made-in-abyss", + }, + }, + bubble: { + unknown: { + id: expect.any(String), + slug: "bubble", + }, + }, + }); + }); + + it.todo("Delete video", async () => {}); +}); diff --git a/api/tests/videos/scanner.test.ts b/api/tests/videos/scanner.test.ts index 070be851..011c2094 100644 --- a/api/tests/videos/scanner.test.ts +++ b/api/tests/videos/scanner.test.ts @@ -8,7 +8,6 @@ import { bubble, madeInAbyss } from "~/models/examples"; beforeAll(async () => { await db.delete(shows); - await db.delete(entries); await db.delete(videos); let [ret, body] = await createSerie(madeInAbyss); expectStatus(ret, body).toBe(201); @@ -358,7 +357,6 @@ describe("Video seeding", () => { expect(body.message).toBeString(); }); - it("Two for the same entry", async () => { const [resp, body] = await createVideo({ guess: { From 205dda652abec1d86f0effc95c91bf1781e0425e Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 3 May 2025 16:07:06 +0200 Subject: [PATCH 20/23] Update available count of shows when inserting videos --- api/src/controllers/seed/insert/shows.ts | 9 +++++---- api/src/controllers/videos.ts | 12 ++++++++++++ api/src/models/utils/relations.ts | 2 +- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/api/src/controllers/seed/insert/shows.ts b/api/src/controllers/seed/insert/shows.ts index fcc66ea3..d6b3a84c 100644 --- a/api/src/controllers/seed/insert/shows.ts +++ b/api/src/controllers/seed/insert/shows.ts @@ -1,4 +1,4 @@ -import { and, count, eq, exists, ne, sql } from "drizzle-orm"; +import { type SQLWrapper, and, count, eq, exists, ne, sql } from "drizzle-orm"; import { type Transaction, db } from "~/db"; import { entries, entryVideoJoin, showTranslations, shows } from "~/db/schema"; import { conflictUpdateAllExcept, sqlarr } from "~/db/utils"; @@ -138,9 +138,10 @@ async function insertBaseShow(tx: Transaction, show: Show) { export async function updateAvailableCount( tx: Transaction, - showPks: number[], - updateEntryCount = true, + showPks: number[] | SQLWrapper, + updateEntryCount = false, ) { + const showPkQ = Array.isArray(showPks) ? sqlarr(showPks) : showPks; return await tx .update(shows) .set({ @@ -168,5 +169,5 @@ export async function updateAvailableCount( )}`, }), }) - .where(eq(shows.pk, sql`any(${sqlarr(showPks)})`)); + .where(eq(shows.pk, sql`any(${showPkQ})`)); } diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index 38ad0fdd..ffb6401b 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -8,6 +8,7 @@ import { isUniqueConstraint, jsonbBuildObject, jsonbObjectAgg, + sqlarr, values, } from "~/db/utils"; import { KError } from "~/models/error"; @@ -343,6 +344,17 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) }, {} as Record, ); + + await updateAvailableCount( + tx, + tx + .selectDistinct({ pk: entries.showPk }) + .from(entries) + .where( + eq(entries.pk, sql`any(${sqlarr(ret.map((x) => x.entryPk))})`), + ), + ); + return error( 201, vids.map((x) => ({ diff --git a/api/src/models/utils/relations.ts b/api/src/models/utils/relations.ts index 2b800024..443cdf30 100644 --- a/api/src/models/utils/relations.ts +++ b/api/src/models/utils/relations.ts @@ -4,7 +4,7 @@ import type { SelectResultField } from "drizzle-orm/query-builders/select.types" export const buildRelations = < R extends string, P extends object, - Rel extends Record Subquery>, + Rel extends Record Subquery>, >( enabled: R[], relations: Rel, From 46d98e038d0611f6903921d4c405472cad04f79b Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 3 May 2025 16:36:31 +0200 Subject: [PATCH 21/23] Update `availableSince` of entries on `POST /videos` --- api/src/controllers/seed/insert/entries.ts | 13 ++------- api/src/controllers/seed/insert/shows.ts | 26 +++++++++++++++++- api/src/controllers/videos.ts | 31 +++++++++++----------- 3 files changed, 43 insertions(+), 27 deletions(-) diff --git a/api/src/controllers/seed/insert/entries.ts b/api/src/controllers/seed/insert/entries.ts index 941e635b..8faa6d02 100644 --- a/api/src/controllers/seed/insert/entries.ts +++ b/api/src/controllers/seed/insert/entries.ts @@ -10,7 +10,7 @@ import { conflictUpdateAllExcept, sqlarr, values } from "~/db/utils"; import type { SeedEntry as SEntry, SeedExtra as SExtra } from "~/models/entry"; import { enqueueOptImage } from "../images"; import { guessNextRefresh } from "../refresh"; -import { updateAvailableCount } from "./shows"; +import { updateAvailableCount, updateAvailableSince } from "./shows"; type SeedEntry = SEntry & { video?: undefined; @@ -192,16 +192,7 @@ export const insertEntries = async ( if (!onlyExtras) await updateAvailableCount(tx, [show.pk], show.kind === "serie"); - const entriesPk = [...new Set(vids.map((x) => x.entryPk))]; - await tx - .update(entries) - .set({ availableSince: sql`now()` }) - .where( - and( - eq(entries.pk, sql`any(${sqlarr(entriesPk)})`), - isNull(entries.availableSince), - ), - ); + await updateAvailableSince(tx,[...new Set(vids.map((x) => x.entryPk))]); return ret; }); diff --git a/api/src/controllers/seed/insert/shows.ts b/api/src/controllers/seed/insert/shows.ts index d6b3a84c..6841e8fb 100644 --- a/api/src/controllers/seed/insert/shows.ts +++ b/api/src/controllers/seed/insert/shows.ts @@ -1,4 +1,13 @@ -import { type SQLWrapper, and, count, eq, exists, ne, sql } from "drizzle-orm"; +import { + type SQLWrapper, + and, + count, + eq, + exists, + isNull, + ne, + sql, +} from "drizzle-orm"; import { type Transaction, db } from "~/db"; import { entries, entryVideoJoin, showTranslations, shows } from "~/db/schema"; import { conflictUpdateAllExcept, sqlarr } from "~/db/utils"; @@ -171,3 +180,18 @@ export async function updateAvailableCount( }) .where(eq(shows.pk, sql`any(${showPkQ})`)); } + +export async function updateAvailableSince( + tx: Transaction, + entriesPk: number[], +) { + return await tx + .update(entries) + .set({ availableSince: sql`now()` }) + .where( + and( + eq(entries.pk, sql`any(${sqlarr(entriesPk)})`), + isNull(entries.availableSince), + ), + ); +} diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index ffb6401b..51b0ef6c 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -26,7 +26,10 @@ import { desc as description } from "~/models/utils/descriptions"; import { Guesses, SeedVideo, Video } from "~/models/video"; import { comment } from "~/utils"; import { computeVideoSlug } from "./seed/insert/entries"; -import { updateAvailableCount } from "./seed/insert/shows"; +import { + updateAvailableCount, + updateAvailableSince, +} from "./seed/insert/shows"; const CreatedVideo = t.Object({ id: t.String({ format: "uuid" }), @@ -345,15 +348,15 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) {} as Record, ); + const entriesPk = [...new Set(ret.map((x) => x.entryPk))]; await updateAvailableCount( tx, tx .selectDistinct({ pk: entries.showPk }) .from(entries) - .where( - eq(entries.pk, sql`any(${sqlarr(ret.map((x) => x.entryPk))})`), - ), + .where(eq(entries.pk, sql`any(${sqlarr(entriesPk)})`)), ); + await updateAvailableSince(tx, entriesPk); return error( 201, @@ -405,18 +408,16 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) .where( and( inArray(entryVideoJoin.videoPk, tx.select().from(vids)), - not( - exists( - tx - .select() - .from(evj) - .where( - and( - eq(evj.entryPk, entryVideoJoin.entryPk), - not(inArray(evj.videoPk, db.select().from(vids))), - ), + notExists( + tx + .select() + .from(evj) + .where( + and( + eq(evj.entryPk, entryVideoJoin.entryPk), + not(inArray(evj.videoPk, db.select().from(vids))), ), - ), + ), ), ), ), From 4df171386b016e5b50edd7bd05dfc5e307a0f82b Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 3 May 2025 16:46:14 +0200 Subject: [PATCH 22/23] Test deletions --- api/src/controllers/seed/insert/entries.ts | 2 +- api/tests/videos/getdel.test.ts | 77 +++++++++++++++++++++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/api/src/controllers/seed/insert/entries.ts b/api/src/controllers/seed/insert/entries.ts index 8faa6d02..97c1fd2a 100644 --- a/api/src/controllers/seed/insert/entries.ts +++ b/api/src/controllers/seed/insert/entries.ts @@ -192,7 +192,7 @@ export const insertEntries = async ( if (!onlyExtras) await updateAvailableCount(tx, [show.pk], show.kind === "serie"); - await updateAvailableSince(tx,[...new Set(vids.map((x) => x.entryPk))]); + await updateAvailableSince(tx, [...new Set(vids.map((x) => x.entryPk))]); return ret; }); diff --git a/api/tests/videos/getdel.test.ts b/api/tests/videos/getdel.test.ts index e9c7075b..8fb093f5 100644 --- a/api/tests/videos/getdel.test.ts +++ b/api/tests/videos/getdel.test.ts @@ -1,13 +1,15 @@ import { beforeAll, describe, expect, it } from "bun:test"; +import { eq } from "drizzle-orm"; import { createMovie, createSerie, createVideo, + deleteVideo, getVideos, } from "tests/helpers"; import { expectStatus } from "tests/utils"; import { db } from "~/db"; -import { shows, videos } from "~/db/schema"; +import { entries, shows, videos } from "~/db/schema"; import { bubble, madeInAbyss } from "~/models/examples"; beforeAll(async () => { @@ -56,6 +58,23 @@ beforeAll(async () => { expect(body[0].entries).toBeArrayOfSize(1); expect(body[1].entries).toBeArrayOfSize(1); expect(body[2].entries).toBeArrayOfSize(1); + + const items = await db.query.shows.findMany(); + expect(items.find((x) => x.slug === "bubble")!.availableCount).toBe(1); + expect(items.find((x) => x.slug === "made-in-abyss")!.availableCount).toBe(2); + + const etrs = await db.query.entries.findMany({ + where: eq( + entries.showPk, + items.find((x) => x.slug === "made-in-abyss")!.pk, + ), + }); + expect( + etrs.find((x) => x.slug === "made-in-abyss-s1e13")!.availableSince, + ).not.toBe(null); + expect( + etrs.find((x) => x.slug === "made-in-abyss-s2e1")!.availableSince, + ).not.toBe(null); }); describe("Video get/deletion", () => { @@ -150,5 +169,59 @@ describe("Video get/deletion", () => { }); }); - it.todo("Delete video", async () => {}); + it("Delete video", async () => { + const [resp, body] = await deleteVideo(["/video/mia s1e13 mismatch.mkv"]); + expectStatus(resp, body).toBe(204); + + const bubble = await db.query.shows.findFirst({ + where: eq(shows.slug, "bubble"), + }); + expect(bubble!.availableCount).toBe(1); + }); + + it("Delete all videos of a movie", async () => { + const [resp, body] = await deleteVideo(["/video/bubble.mkv"]); + expectStatus(resp, body).toBe(204); + + const bubble = await db.query.shows.findFirst({ + where: eq(shows.slug, "bubble"), + }); + expect(bubble!.availableCount).toBe(0); + }); + + it("Delete non existing video", async () => { + // it's way too much of a pain to return deleted paths with the current query so this will do + const [resp, body] = await deleteVideo(["/video/toto.mkv"]); + expectStatus(resp, body).toBe(204); + }); + + it("Delete episodes", async () => { + const [resp, body] = await deleteVideo([ + "/video/mia s1e13.mkv", + "/video/mia 2017 s2e1.mkv", + ]); + expectStatus(resp, body).toBe(204); + + const mia = await db.query.shows.findFirst({ + where: eq(shows.slug, "made-in-abyss"), + }); + expect(mia!.availableCount).toBe(0); + + const etrs = await db.query.entries.findMany({ + where: eq(entries.showPk, mia!.pk), + }); + expect( + etrs.find((x) => x.slug === "made-in-abyss-s1e13")!.availableSince, + ).toBe(null); + expect( + etrs.find((x) => x.slug === "made-in-abyss-s2e1")!.availableSince, + ).toBe(null); + }); + + it("Delete unmatched", async () => { + const [resp, body] = await deleteVideo([ + "/video/mia s1e13 unknown test.mkv", + ]); + expectStatus(resp, body).toBe(204); + }); }); From a96813fe309a57b906cd2fdc6db31039f788537a Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 4 May 2025 15:35:07 +0200 Subject: [PATCH 23/23] Return deleted paths in `DELETE /videos` --- api/src/controllers/seed/insert/entries.ts | 4 +- api/src/controllers/videos.ts | 62 +++++++++++----------- api/tests/videos/getdel.test.ts | 21 +++++--- 3 files changed, 48 insertions(+), 39 deletions(-) diff --git a/api/src/controllers/seed/insert/entries.ts b/api/src/controllers/seed/insert/entries.ts index 97c1fd2a..e91c882a 100644 --- a/api/src/controllers/seed/insert/entries.ts +++ b/api/src/controllers/seed/insert/entries.ts @@ -1,4 +1,4 @@ -import { type Column, type SQL, and, eq, isNull, sql } from "drizzle-orm"; +import { type Column, type SQL, eq, sql } from "drizzle-orm"; import { db } from "~/db"; import { entries, @@ -6,7 +6,7 @@ import { entryVideoJoin, videos, } from "~/db/schema"; -import { conflictUpdateAllExcept, sqlarr, values } from "~/db/utils"; +import { conflictUpdateAllExcept, values } from "~/db/utils"; import type { SeedEntry as SEntry, SeedExtra as SExtra } from "~/models/entry"; import { enqueueOptImage } from "../images"; import { guessNextRefresh } from "../refresh"; diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index 51b0ef6c..0fe22fc5 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -392,48 +392,48 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) .delete( "", async ({ body }) => { - await db.transaction(async (tx) => { + return await db.transaction(async (tx) => { const vids = tx.$with("vids").as( tx .delete(videos) - .where(eq(videos.path, sql`any(${body})`)) - .returning({ pk: videos.pk }), + .where(eq(videos.path, sql`any(${sqlarr(body)})`)) + .returning({ pk: videos.pk, path: videos.path }), ); - const evj = alias(entryVideoJoin, "evj"); - const delEntries = tx.$with("del_entries").as( - tx - .with(vids) - .select({ entry: entryVideoJoin.entryPk }) - .from(entryVideoJoin) - .where( - and( - inArray(entryVideoJoin.videoPk, tx.select().from(vids)), - notExists( - tx - .select() - .from(evj) - .where( - and( - eq(evj.entryPk, entryVideoJoin.entryPk), - not(inArray(evj.videoPk, db.select().from(vids))), - ), - ), - ), - ), - ), - ); - const delShows = await tx - .with(delEntries) + + const deletedJoin = await tx + .with(vids) + .select({ entryPk: entryVideoJoin.entryPk, path: vids.path }) + .from(entryVideoJoin) + .rightJoin(vids, eq(vids.pk, entryVideoJoin.videoPk)); + + const delEntries = await tx .update(entries) .set({ availableSince: null }) - .where(inArray(entries.pk, db.select().from(delEntries))) + .where( + and( + eq( + entries.pk, + sql`any(${sqlarr( + deletedJoin.filter((x) => x.entryPk).map((x) => x.entryPk!), + )})`, + ), + notExists( + tx + .select() + .from(entryVideoJoin) + .where(eq(entries.pk, entryVideoJoin.entryPk)), + ), + ), + ) .returning({ show: entries.showPk }); await updateAvailableCount( tx, - delShows.map((x) => x.show), + delEntries.map((x) => x.show), false, ); + + return [...new Set(deletedJoin.map((x) => x.path))]; }); }, { @@ -444,6 +444,6 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) examples: [bubbleVideo.path], }), ), - response: { 204: t.Void() }, + response: { 200: t.Array(t.String()) }, }, ); diff --git a/api/tests/videos/getdel.test.ts b/api/tests/videos/getdel.test.ts index 8fb093f5..9f5b8aea 100644 --- a/api/tests/videos/getdel.test.ts +++ b/api/tests/videos/getdel.test.ts @@ -171,7 +171,9 @@ describe("Video get/deletion", () => { it("Delete video", async () => { const [resp, body] = await deleteVideo(["/video/mia s1e13 mismatch.mkv"]); - expectStatus(resp, body).toBe(204); + expectStatus(resp, body).toBe(200); + expect(body).toBeArrayOfSize(1); + expect(body).toContain("/video/mia s1e13 mismatch.mkv"); const bubble = await db.query.shows.findFirst({ where: eq(shows.slug, "bubble"), @@ -181,7 +183,9 @@ describe("Video get/deletion", () => { it("Delete all videos of a movie", async () => { const [resp, body] = await deleteVideo(["/video/bubble.mkv"]); - expectStatus(resp, body).toBe(204); + expectStatus(resp, body).toBe(200); + expect(body).toBeArrayOfSize(1); + expect(body).toContain("/video/bubble.mkv"); const bubble = await db.query.shows.findFirst({ where: eq(shows.slug, "bubble"), @@ -190,9 +194,9 @@ describe("Video get/deletion", () => { }); it("Delete non existing video", async () => { - // it's way too much of a pain to return deleted paths with the current query so this will do const [resp, body] = await deleteVideo(["/video/toto.mkv"]); - expectStatus(resp, body).toBe(204); + expectStatus(resp, body).toBe(200); + expect(body).toBeArrayOfSize(0); }); it("Delete episodes", async () => { @@ -200,7 +204,10 @@ describe("Video get/deletion", () => { "/video/mia s1e13.mkv", "/video/mia 2017 s2e1.mkv", ]); - expectStatus(resp, body).toBe(204); + expectStatus(resp, body).toBe(200); + expect(body).toBeArrayOfSize(2); + expect(body).toContain("/video/mia s1e13.mkv"); + expect(body).toContain("/video/mia 2017 s2e1.mkv"); const mia = await db.query.shows.findFirst({ where: eq(shows.slug, "made-in-abyss"), @@ -222,6 +229,8 @@ describe("Video get/deletion", () => { const [resp, body] = await deleteVideo([ "/video/mia s1e13 unknown test.mkv", ]); - expectStatus(resp, body).toBe(204); + expectStatus(resp, body).toBe(200); + expect(body).toBeArrayOfSize(1); + expect(body[0]).toBe("/video/mia s1e13 unknown test.mkv"); }); });