From 6b0e3e75776cfdbd0aec385e3698c934c8255b6c Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 8 Mar 2025 15:20:28 +0100 Subject: [PATCH 01/17] Add original name & `latinName` in series/movie --- api/src/models/examples/bubble.ts | 1 + api/src/models/movie.ts | 13 +++++++------ api/src/models/serie.ts | 27 +++++++++++++++------------ api/src/models/utils/orignial.ts | 25 +++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 18 deletions(-) create mode 100644 api/src/models/utils/orignial.ts diff --git a/api/src/models/examples/bubble.ts b/api/src/models/examples/bubble.ts index 7ea2399c..9f66f701 100644 --- a/api/src/models/examples/bubble.ts +++ b/api/src/models/examples/bubble.ts @@ -32,6 +32,7 @@ export const bubble: SeedMovie = { }, ja: { name: "バブル:2022", + latinName: "Buburu", tagline: null, description: null, aliases: ["Baburu", "Bubble"], diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index 0e70cce6..ce709318 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -1,5 +1,5 @@ import { t } from "elysia"; -import type { Prettify } from "~/utils"; +import { type Prettify, comment } from "~/utils"; import { SeedCollection } from "./collections"; import { bubble, bubbleImages, registerExamples } from "./examples"; import { SeedStudio, Studio } from "./studio"; @@ -13,6 +13,7 @@ import { SeedImage, TranslationRecord, } from "./utils"; +import { Original } from "./utils/orignial"; import { Video } from "./video"; export const MovieStatus = t.UnionEnum(["unknown", "finished", "planned"]); @@ -28,11 +29,6 @@ const BaseMovie = t.Object({ ), airDate: t.Nullable(t.String({ format: "date" })), - originalLanguage: t.Nullable( - Language({ - description: "The language code this movie was made in.", - }), - ), nextRefresh: t.String({ format: "date-time" }), @@ -60,6 +56,7 @@ export const Movie = t.Intersect([ BaseMovie, DbMetadata, t.Object({ + original: Original, isAvailable: t.Boolean(), }), ]); @@ -79,6 +76,9 @@ export const SeedMovie = t.Intersect([ t.Omit(BaseMovie, ["kind", "nextRefresh"]), t.Object({ slug: t.String({ format: "slug", examples: ["bubble"] }), + originalLanguage: Language({ + description: "The language code this movie was made in.", + }), translations: TranslationRecord( t.Intersect([ t.Omit(MovieTranslation, ["poster", "thumbnail", "banner", "logo"]), @@ -87,6 +87,7 @@ export const SeedMovie = t.Intersect([ thumbnail: t.Nullable(SeedImage), banner: t.Nullable(SeedImage), logo: t.Nullable(SeedImage), + latinName: t.Optional(Original.properties.latinName), }), ]), ), diff --git a/api/src/models/serie.ts b/api/src/models/serie.ts index e94742f8..06b38e72 100644 --- a/api/src/models/serie.ts +++ b/api/src/models/serie.ts @@ -15,6 +15,7 @@ import { SeedImage, TranslationRecord, } from "./utils"; +import { Original } from "./utils/orignial"; export const SerieStatus = t.UnionEnum([ "unknown", @@ -38,19 +39,8 @@ const BaseSerie = t.Object({ startAir: t.Nullable(t.String({ format: "date" })), endAir: t.Nullable(t.String({ format: "date" })), - originalLanguage: t.Nullable( - Language({ - description: "The language code this serie was made in.", - }), - ), nextRefresh: t.String({ format: "date-time" }), - entriesCount: t.Integer({ - description: "The number of episodes in this serie", - }), - availableCount: t.Integer({ - description: "The number of episodes that can be played right away", - }), externalId: ExternalId(), }); @@ -75,6 +65,15 @@ export const Serie = t.Intersect([ SerieTranslation, BaseSerie, DbMetadata, + t.Object({ + original: Original, + entriesCount: t.Integer({ + description: "The number of episodes in this serie", + }), + availableCount: t.Integer({ + description: "The number of episodes that can be played right away", + }), + }), ]); export type Serie = Prettify; @@ -88,9 +87,12 @@ export const FullSerie = t.Intersect([ export type FullMovie = Prettify; export const SeedSerie = t.Intersect([ - t.Omit(BaseSerie, ["kind", "nextRefresh", "entriesCount", "availableCount"]), + t.Omit(BaseSerie, ["kind", "nextRefresh"]), t.Object({ slug: t.String({ format: "slug" }), + originalLanguage: Language({ + description: "The language code this serie was made in.", + }), translations: TranslationRecord( t.Intersect([ t.Omit(SerieTranslation, ["poster", "thumbnail", "banner", "logo"]), @@ -99,6 +101,7 @@ export const SeedSerie = t.Intersect([ thumbnail: t.Nullable(SeedImage), banner: t.Nullable(SeedImage), logo: t.Nullable(SeedImage), + latinName: t.Optional(Original.properties.latinName), }), ]), ), diff --git a/api/src/models/utils/orignial.ts b/api/src/models/utils/orignial.ts new file mode 100644 index 00000000..5e51b11f --- /dev/null +++ b/api/src/models/utils/orignial.ts @@ -0,0 +1,25 @@ +import { t } from "elysia"; +import { comment } from "~/utils"; +import { Language } from "./language"; + +export const Original = t.Object({ + language: Language({ + description: "The language code this was made in.", + examples: ["ja"] + }), + name: t.String({ + description: "The name in the original language", + examples: ["進撃の巨人"], + }), + latinName: t.Nullable( + t.String({ + description: comment` + The original name but using latin scripts. + This is only set if the original language is written with another + alphabet (like japanase, korean, chineses...) + `, + examples: ["Shingeki no Kyojin"], + }), + ), +}); +export type Original = typeof Original.static; From b69cb05088d98d08fda1ddf4470be5a293a558d9 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 8 Mar 2025 15:23:23 +0100 Subject: [PATCH 02/17] Type guess info in db schema --- api/src/db/schema/videos.ts | 3 +- api/src/models/video.ts | 73 +++++++++++++++++++------------------ 2 files changed, 39 insertions(+), 37 deletions(-) diff --git a/api/src/db/schema/videos.ts b/api/src/db/schema/videos.ts index 877f3c9d..a7c60d42 100644 --- a/api/src/db/schema/videos.ts +++ b/api/src/db/schema/videos.ts @@ -9,6 +9,7 @@ import { uuid, varchar, } from "drizzle-orm/pg-core"; +import type { Guess } from "~/models/video"; import { entries } from "./entries"; import { schema } from "./utils"; @@ -21,7 +22,7 @@ export const videos = schema.table( rendering: text().notNull(), part: integer(), version: integer().notNull().default(1), - guess: jsonb().notNull().default({}), + guess: jsonb().$type().notNull(), createdAt: timestamp({ withTimezone: true, mode: "string" }) .notNull() diff --git a/api/src/models/video.ts b/api/src/models/video.ts index 6822cbb2..96da6f0f 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -3,6 +3,42 @@ import { type Prettify, comment } from "~/utils"; import { bubbleVideo, registerExamples } from "./examples"; import { DbMetadata, Resource } from "./utils"; +export const Guess = t.Recursive((Self) => + t.Object( + { + title: t.String(), + 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"])), + + from: t.String({ + description: "Name of the tool that made the guess", + }), + history: t.Optional( + t.Array(t.Omit(Self, ["history"]), { + default: [], + description: comment` + When another tool refines the guess or a user manually edit it, the history of the guesses + are kept in this \`history\` value. + `, + }), + ), + }, + { + additionalProperties: true, + description: comment` + Metadata guessed from the filename. Kyoo can use those informations to bypass + the scanner/metadata fetching and just register videos to movies/entries that already + exists. If Kyoo can't find a matching movie/entry, this information will be sent to + the scanner. + `, + }, + ), +); +export type Guess = typeof Guess.static; + export const SeedVideo = t.Object({ path: t.String(), rendering: t.String({ @@ -29,42 +65,7 @@ export const SeedVideo = t.Object({ "Kyoo will prefer playing back the highest `version` number if there are multiples rendering.", }), - guess: t.Optional( - t.Recursive((Self) => - t.Object( - { - title: t.String(), - 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"])), - - from: t.String({ - description: "Name of the tool that made the guess", - }), - history: t.Optional( - t.Array(t.Omit(Self, ["history"]), { - default: [], - description: comment` - When another tool refines the guess or a user manually edit it, the history of the guesses - are kept in this \`history\` value. - `, - }), - ), - }, - { - additionalProperties: true, - description: comment` - Metadata guessed from the filename. Kyoo can use those informations to bypass - the scanner/metadata fetching and just register videos to movies/entries that already - exists. If Kyoo can't find a matching movie/entry, this information will be sent to - the scanner. - `, - }, - ), - ), - ), + guess: t.Optional(Guess), }); export type SeedVideo = typeof SeedVideo.static; From 67dc251489201e5bed80b916255297f7b94fee97 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 8 Mar 2025 15:40:49 +0100 Subject: [PATCH 03/17] Rework original handling in seeding --- api/src/controllers/seed/insert/collection.ts | 1 + api/src/controllers/seed/movies.ts | 25 ++++++++++----- api/src/controllers/seed/series.ts | 11 +++++++ api/src/db/schema/shows.ts | 31 +++++++------------ api/src/db/schema/utils.ts | 4 +-- api/src/models/movie.ts | 2 +- api/src/models/serie.ts | 2 +- api/src/models/utils/index.ts | 1 + .../models/utils/{orignial.ts => original.ts} | 0 9 files changed, 46 insertions(+), 31 deletions(-) rename api/src/models/utils/{orignial.ts => original.ts} (100%) diff --git a/api/src/controllers/seed/insert/collection.ts b/api/src/controllers/seed/insert/collection.ts index 7ee98e24..bcdcc589 100644 --- a/api/src/controllers/seed/insert/collection.ts +++ b/api/src/controllers/seed/insert/collection.ts @@ -28,6 +28,7 @@ export const insertCollection = async ( endAir: show.kind === "movie" ? show.airDate : show.endAir, nextRefresh: show.nextRefresh, entriesCount: 0, + original: {} as any, ...col, }) .onConflictDoUpdate({ diff --git a/api/src/controllers/seed/movies.ts b/api/src/controllers/seed/movies.ts index 2a83d04d..6cd743cf 100644 --- a/api/src/controllers/seed/movies.ts +++ b/api/src/controllers/seed/movies.ts @@ -1,6 +1,7 @@ import { t } from "elysia"; import type { SeedMovie } from "~/models/movie"; import { getYear } from "~/utils"; +import { processOptImage } from "./images"; import { insertCollection } from "./insert/collection"; import { insertEntries } from "./insert/entries"; import { insertShow, updateAvailableCount } from "./insert/shows"; @@ -45,8 +46,8 @@ export const seedMovie = async ( seed.slug = `random-${getYear(seed.airDate)}`; } - const { translations, videos, collection, studios, ...bMovie } = seed; - const nextRefresh = guessNextRefresh(bMovie.airDate ?? new Date()); + const { translations, videos, collection, studios, ...movie } = seed; + const nextRefresh = guessNextRefresh(movie.airDate ?? new Date()); const col = await insertCollection(collection, { kind: "movie", @@ -54,14 +55,24 @@ export const seedMovie = async ( ...seed, }); + const original = translations[movie.originalLanguage]; const show = await insertShow( { kind: "movie", - startAir: bMovie.airDate, + startAir: movie.airDate, nextRefresh, collectionPk: col?.pk, entriesCount: 1, - ...bMovie, + original: { + language: movie.originalLanguage, + name: original.name, + latinName: original.latinName ?? null, + poster: processOptImage(original.poster), + thumbnail: processOptImage(original.thumbnail), + logo: processOptImage(original.logo), + banner: processOptImage(original.banner), + }, + ...movie, }, translations, ); @@ -70,11 +81,11 @@ export const seedMovie = async ( // even if never shown to the user, a movie still has an entry. const [entry] = await insertEntries(show, [ { - ...bMovie, + ...movie, kind: "movie", order: 1, - thumbnail: (bMovie.originalLanguage - ? translations[bMovie.originalLanguage] + thumbnail: (movie.originalLanguage + ? translations[movie.originalLanguage] : Object.values(translations)[0] )?.thumbnail, translations, diff --git a/api/src/controllers/seed/series.ts b/api/src/controllers/seed/series.ts index 2b145e7b..8f3da08f 100644 --- a/api/src/controllers/seed/series.ts +++ b/api/src/controllers/seed/series.ts @@ -1,6 +1,7 @@ import { t } from "elysia"; import type { SeedSerie } from "~/models/serie"; import { getYear } from "~/utils"; +import { processOptImage } from "./images"; import { insertCollection } from "./insert/collection"; import { insertEntries } from "./insert/entries"; import { insertSeasons } from "./insert/seasons"; @@ -89,12 +90,22 @@ export const seedSerie = async ( ...seed, }); + const original = translations[serie.originalLanguage]; const show = await insertShow( { kind: "serie", nextRefresh, collectionPk: col?.pk, entriesCount: entries.length, + original: { + language: serie.originalLanguage, + name: original.name, + latinName: original.latinName ?? null, + poster: processOptImage(original.poster), + thumbnail: processOptImage(original.thumbnail), + logo: processOptImage(original.logo), + banner: processOptImage(original.banner), + }, ...serie, }, translations, diff --git a/api/src/db/schema/shows.ts b/api/src/db/schema/shows.ts index a28bef1e..d00a5e6c 100644 --- a/api/src/db/schema/shows.ts +++ b/api/src/db/schema/shows.ts @@ -5,6 +5,7 @@ import { date, index, integer, + jsonb, primaryKey, smallint, text, @@ -12,6 +13,7 @@ import { uuid, varchar, } from "drizzle-orm/pg-core"; +import type { Image, Original } from "~/models/utils"; import { entries } from "./entries"; import { seasons } from "./seasons"; import { showStudioJoin } from "./studios"; @@ -54,6 +56,13 @@ export const genres = schema.enum("genres", [ "talk", ]); +type OriginalWithImages = Original & { + poster: Image | null; + thumbnail: Image | null; + banner: Image | null; + logo: Image | null; +}; + export const shows = schema.table( "shows", { @@ -67,7 +76,7 @@ export const shows = schema.table( status: showStatus().notNull(), startAir: date(), endAir: date(), - originalLanguage: language(), + original: jsonb().$type().notNull(), collectionPk: integer().references((): AnyPgColumn => shows.pk, { onDelete: "set null", @@ -120,16 +129,8 @@ export const showTranslations = schema.table( ], ); -export const showsRelations = relations(shows, ({ many, one }) => ({ - selectedTranslation: many(showTranslations, { - relationName: "selected_translation", - }), +export const showsRelations = relations(shows, ({ many }) => ({ translations: many(showTranslations, { relationName: "show_translations" }), - originalTranslation: one(showTranslations, { - relationName: "original_translation", - fields: [shows.pk, shows.originalLanguage], - references: [showTranslations.pk, showTranslations.language], - }), entries: many(entries, { relationName: "show_entries" }), seasons: many(seasons, { relationName: "show_seasons" }), studios: many(showStudioJoin, { relationName: "ssj_show" }), @@ -140,14 +141,4 @@ export const showsTrRelations = relations(showTranslations, ({ one }) => ({ fields: [showTranslations.pk], references: [shows.pk], }), - selectedTranslation: one(shows, { - relationName: "selected_translation", - fields: [showTranslations.pk], - references: [shows.pk], - }), - originalTranslation: one(shows, { - relationName: "original_translation", - fields: [showTranslations.pk, showTranslations.language], - references: [shows.pk, shows.originalLanguage], - }), })); diff --git a/api/src/db/schema/utils.ts b/api/src/db/schema/utils.ts index 02f6e8af..3f6e92e1 100644 --- a/api/src/db/schema/utils.ts +++ b/api/src/db/schema/utils.ts @@ -1,11 +1,11 @@ import { jsonb, pgSchema, varchar } from "drizzle-orm/pg-core"; +import type { Image } from "~/models/utils"; export const schema = pgSchema("kyoo"); export const language = () => varchar({ length: 255 }); -export const image = () => - jsonb().$type<{ id: string; source: string; blurhash: string }>(); +export const image = () => jsonb().$type(); export const externalid = () => jsonb() diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index ce709318..706a87ce 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -13,7 +13,7 @@ import { SeedImage, TranslationRecord, } from "./utils"; -import { Original } from "./utils/orignial"; +import { Original } from "./utils/original"; import { Video } from "./video"; export const MovieStatus = t.UnionEnum(["unknown", "finished", "planned"]); diff --git a/api/src/models/serie.ts b/api/src/models/serie.ts index 06b38e72..d02de2d7 100644 --- a/api/src/models/serie.ts +++ b/api/src/models/serie.ts @@ -15,7 +15,7 @@ import { SeedImage, TranslationRecord, } from "./utils"; -import { Original } from "./utils/orignial"; +import { Original } from "./utils/original"; export const SerieStatus = t.UnionEnum([ "unknown", diff --git a/api/src/models/utils/index.ts b/api/src/models/utils/index.ts index 4326dfc6..1d0c1fa4 100644 --- a/api/src/models/utils/index.ts +++ b/api/src/models/utils/index.ts @@ -8,3 +8,4 @@ export * from "./page"; export * from "./sort"; export * from "./keyset-paginate"; export * from "./db-metadata"; +export * from "./original"; diff --git a/api/src/models/utils/orignial.ts b/api/src/models/utils/original.ts similarity index 100% rename from api/src/models/utils/orignial.ts rename to api/src/models/utils/original.ts From 74f2229dcda30475ecc60f2f30fe6ac77194d563 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 8 Mar 2025 15:47:02 +0100 Subject: [PATCH 04/17] Migrate original handling --- api/drizzle/0013_original.sql | 3 + api/drizzle/meta/0013_snapshot.json | 1277 +++++++++++++++++++++++++++ api/drizzle/meta/_journal.json | 7 + 3 files changed, 1287 insertions(+) create mode 100644 api/drizzle/0013_original.sql create mode 100644 api/drizzle/meta/0013_snapshot.json diff --git a/api/drizzle/0013_original.sql b/api/drizzle/0013_original.sql new file mode 100644 index 00000000..3f20c5ef --- /dev/null +++ b/api/drizzle/0013_original.sql @@ -0,0 +1,3 @@ +ALTER TABLE "kyoo"."videos" ALTER COLUMN "guess" DROP DEFAULT;--> statement-breakpoint +ALTER TABLE "kyoo"."shows" ADD COLUMN "original" jsonb NOT NULL;--> statement-breakpoint +ALTER TABLE "kyoo"."shows" DROP COLUMN "original_language"; \ No newline at end of file diff --git a/api/drizzle/meta/0013_snapshot.json b/api/drizzle/meta/0013_snapshot.json new file mode 100644 index 00000000..68804a22 --- /dev/null +++ b/api/drizzle/meta/0013_snapshot.json @@ -0,0 +1,1277 @@ +{ + "id": "45a7461e-96e1-45d0-a24f-74642db7bed3", + "prevId": "9275889b-9e6a-462c-89c2-4c9138495c2c", + "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 + }, + "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.season_translations": { + "name": "season_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "poster": { + "name": "poster", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "banner": { + "name": "banner", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "season_name_trgm": { + "name": "season_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "season_translations_pk_seasons_pk_fk": { + "name": "season_translations_pk_seasons_pk_fk", + "tableFrom": "season_translations", + "tableTo": "seasons", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "season_translations_pk_language_pk": { + "name": "season_translations_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.seasons": { + "name": "seasons", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "seasons_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "start_air": { + "name": "start_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "end_air": { + "name": "end_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "show_fk": { + "name": "show_fk", + "columns": [ + { + "expression": "show_pk", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "season_nbr": { + "name": "season_nbr", + "columns": [ + { + "expression": "season_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "seasons_show_pk_shows_pk_fk": { + "name": "seasons_show_pk_shows_pk_fk", + "tableFrom": "seasons", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "seasons_id_unique": { + "name": "seasons_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "seasons_slug_unique": { + "name": "seasons_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "seasons_showPk_seasonNumber_unique": { + "name": "seasons_showPk_seasonNumber_unique", + "nullsNotDistinct": false, + "columns": ["show_pk", "season_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.show_translations": { + "name": "show_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "poster": { + "name": "poster", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "banner": { + "name": "banner", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "trailer_url": { + "name": "trailer_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "name_trgm": { + "name": "name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "tags": { + "name": "tags", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "show_translations_pk_shows_pk_fk": { + "name": "show_translations_pk_shows_pk_fk", + "tableFrom": "show_translations", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "show_translations_pk_language_pk": { + "name": "show_translations_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.shows": { + "name": "shows", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "shows_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "show_kind", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "genres": { + "name": "genres", + "type": "genres[]", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "runtime": { + "name": "runtime", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "show_status", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "start_air": { + "name": "start_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "end_air": { + "name": "end_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "original": { + "name": "original", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "collection_pk": { + "name": "collection_pk", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "entries_count": { + "name": "entries_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "available_count": { + "name": "available_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kind": { + "name": "kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "rating": { + "name": "rating", + "columns": [ + { + "expression": "rating", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "startAir": { + "name": "startAir", + "columns": [ + { + "expression": "start_air", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shows_collection_pk_shows_pk_fk": { + "name": "shows_collection_pk_shows_pk_fk", + "tableFrom": "shows", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["collection_pk"], + "columnsTo": ["pk"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "shows_id_unique": { + "name": "shows_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "shows_slug_unique": { + "name": "shows_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": { + "rating_valid": { + "name": "rating_valid", + "value": "\"kyoo\".\"shows\".\"rating\" between 0 and 100" + }, + "runtime_valid": { + "name": "runtime_valid", + "value": "\"kyoo\".\"shows\".\"runtime\" >= 0" + } + }, + "isRLSEnabled": false + }, + "kyoo.show_studio_join": { + "name": "show_studio_join", + "schema": "kyoo", + "columns": { + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "studio_pk": { + "name": "studio_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "show_studio_join_show_pk_shows_pk_fk": { + "name": "show_studio_join_show_pk_shows_pk_fk", + "tableFrom": "show_studio_join", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "show_studio_join_studio_pk_studios_pk_fk": { + "name": "show_studio_join_studio_pk_studios_pk_fk", + "tableFrom": "show_studio_join", + "tableTo": "studios", + "schemaTo": "kyoo", + "columnsFrom": ["studio_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "show_studio_join_show_pk_studio_pk_pk": { + "name": "show_studio_join_show_pk_studio_pk_pk", + "columns": ["show_pk", "studio_pk"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.studio_translations": { + "name": "studio_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "studio_name_trgm": { + "name": "studio_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "studio_translations_pk_studios_pk_fk": { + "name": "studio_translations_pk_studios_pk_fk", + "tableFrom": "studio_translations", + "tableTo": "studios", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "studio_translations_pk_language_pk": { + "name": "studio_translations_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.studios": { + "name": "studios", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "studios_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "studios_id_unique": { + "name": "studios_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "studios_slug_unique": { + "name": "studios_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.entry_video_join": { + "name": "entry_video_join", + "schema": "kyoo", + "columns": { + "entry_pk": { + "name": "entry_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "video_pk": { + "name": "video_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "entry_video_join_entry_pk_entries_pk_fk": { + "name": "entry_video_join_entry_pk_entries_pk_fk", + "tableFrom": "entry_video_join", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["entry_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entry_video_join_video_pk_videos_pk_fk": { + "name": "entry_video_join_video_pk_videos_pk_fk", + "tableFrom": "entry_video_join", + "tableTo": "videos", + "schemaTo": "kyoo", + "columnsFrom": ["video_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entry_video_join_entry_pk_video_pk_pk": { + "name": "entry_video_join_entry_pk_video_pk_pk", + "columns": ["entry_pk", "video_pk"] + } + }, + "uniqueConstraints": { + "entry_video_join_slug_unique": { + "name": "entry_video_join_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.videos": { + "name": "videos", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "videos_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rendering": { + "name": "rendering", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "guess": { + "name": "guess", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "videos_id_unique": { + "name": "videos_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "videos_path_unique": { + "name": "videos_path_unique", + "nullsNotDistinct": false, + "columns": ["path"] + } + }, + "policies": {}, + "checkConstraints": { + "part_pos": { + "name": "part_pos", + "value": "\"kyoo\".\"videos\".\"part\" >= 0" + }, + "version_pos": { + "name": "version_pos", + "value": "\"kyoo\".\"videos\".\"version\" >= 0" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "kyoo.entry_type": { + "name": "entry_type", + "schema": "kyoo", + "values": ["unknown", "episode", "movie", "special", "extra"] + }, + "kyoo.genres": { + "name": "genres", + "schema": "kyoo", + "values": [ + "action", + "adventure", + "animation", + "comedy", + "crime", + "documentary", + "drama", + "family", + "fantasy", + "history", + "horror", + "music", + "mystery", + "romance", + "science-fiction", + "thriller", + "war", + "western", + "kids", + "reality", + "politics", + "soap", + "talk" + ] + }, + "kyoo.show_kind": { + "name": "show_kind", + "schema": "kyoo", + "values": ["serie", "movie", "collection"] + }, + "kyoo.show_status": { + "name": "show_status", + "schema": "kyoo", + "values": ["unknown", "finished", "airing", "planned"] + } + }, + "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 1bac61b4..d4a0b9ff 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1741360992371, "tag": "0012_available_count", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1741444868735, + "tag": "0013_original", + "breakpoints": true } ] } From 3f188e27a118e451b0934034d06f7c0b6f39ed81 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 8 Mar 2025 15:47:18 +0100 Subject: [PATCH 05/17] Only specify `kind` in movie/serie/collection in /shows --- api/src/models/collections.ts | 1 - api/src/models/movie.ts | 3 +-- api/src/models/serie.ts | 1 - api/src/models/show.ts | 6 +++++- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/api/src/models/collections.ts b/api/src/models/collections.ts index 452a7868..62a5c489 100644 --- a/api/src/models/collections.ts +++ b/api/src/models/collections.ts @@ -13,7 +13,6 @@ import { } from "./utils"; const BaseCollection = t.Object({ - kind: t.Literal("collection"), genres: t.Array(Genre), rating: t.Nullable(t.Integer({ minimum: 0, maximum: 100 })), startAir: t.Nullable( diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index 706a87ce..c64bcdef 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -1,5 +1,5 @@ import { t } from "elysia"; -import { type Prettify, comment } from "~/utils"; +import type { Prettify } from "~/utils"; import { SeedCollection } from "./collections"; import { bubble, bubbleImages, registerExamples } from "./examples"; import { SeedStudio, Studio } from "./studio"; @@ -20,7 +20,6 @@ export const MovieStatus = t.UnionEnum(["unknown", "finished", "planned"]); export type MovieStatus = typeof MovieStatus.static; const BaseMovie = t.Object({ - kind: t.Literal("movie"), genres: t.Array(Genre), rating: t.Nullable(t.Integer({ minimum: 0, maximum: 100 })), status: MovieStatus, diff --git a/api/src/models/serie.ts b/api/src/models/serie.ts index d02de2d7..252e090e 100644 --- a/api/src/models/serie.ts +++ b/api/src/models/serie.ts @@ -26,7 +26,6 @@ export const SerieStatus = t.UnionEnum([ export type SerieStatus = typeof SerieStatus.static; const BaseSerie = t.Object({ - kind: t.Literal("serie"), genres: t.Array(Genre), rating: t.Nullable(t.Integer({ minimum: 0, maximum: 100 })), status: SerieStatus, diff --git a/api/src/models/show.ts b/api/src/models/show.ts index ccaaf35a..915ce7b4 100644 --- a/api/src/models/show.ts +++ b/api/src/models/show.ts @@ -3,4 +3,8 @@ import { Collection } from "./collections"; import { Movie } from "./movie"; import { Serie } from "./serie"; -export const Show = t.Union([Movie, Serie, Collection]); +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") })]), +]); From ac7b589d765f99db305bd95691777d6718258699 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 8 Mar 2025 15:50:24 +0100 Subject: [PATCH 06/17] Fix preferOriginal --- api/src/controllers/shows/logic.ts | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index 7d11ee6e..61239027 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -29,7 +29,10 @@ export const showFilters: FilterDef = { airDate: { column: shows.startAir, type: "date" }, startAir: { column: shows.startAir, type: "date" }, endAir: { column: shows.startAir, type: "date" }, - originalLanguage: { column: shows.originalLanguage, type: "string" }, + originalLanguage: { + column: sql`${shows.original}->'language'`, + type: "string", + }, tags: { column: sql.raw(`t.${showTranslations.tags.name}`), type: "string", @@ -59,7 +62,7 @@ export async function getShows({ sort, filter, languages, - preferOriginal, + preferOriginal = false, }: { after: string | undefined; limit: number; @@ -67,7 +70,7 @@ export async function getShows({ sort: StaticDecode; filter: SQL | undefined; languages: string[]; - preferOriginal: boolean | undefined; + preferOriginal?: boolean; }) { const transQ = db .selectDistinctOn([showTranslations.pk]) @@ -77,8 +80,7 @@ export async function getShows({ sql`array_position(${sqlarr(languages)}, ${showTranslations.language})`, ) .as("t"); - const { pk, poster, thumbnail, banner, logo, ...transCol } = - getColumns(transQ); + const { pk, ...transCol } = getColumns(transQ); return await db .select({ @@ -90,21 +92,15 @@ export async function getShows({ kind: sql`${shows.kind}`, isAvailable: sql`${shows.availableCount} != 0`, - poster: sql`coalesce(${showTranslations.poster}, ${poster})`, - thumbnail: sql`coalesce(${showTranslations.thumbnail}, ${thumbnail})`, - banner: sql`coalesce(${showTranslations.banner}, ${banner})`, - logo: sql`coalesce(${showTranslations.logo}, ${logo})`, + ...(preferOriginal && { + poster: sql`coalesce(${shows.original}->'poster', ${showTranslations.poster})`, + thumbnail: sql`coalesce(${shows.original}->'thumbnail', ${showTranslations.thumbnail})`, + banner: sql`coalesce(${shows.original}->'banner', ${showTranslations.banner})`, + logo: sql`coalesce(${shows.original}->'logo', ${showTranslations.logo})`, + }), }) .from(shows) .innerJoin(transQ, eq(shows.pk, transQ.pk)) - .leftJoin( - showTranslations, - and( - sql`${preferOriginal ?? false}`, - eq(shows.pk, showTranslations.pk), - eq(showTranslations.language, shows.originalLanguage), - ), - ) .where( and( filter, From 16fb6382317872b77b487fd68e29625b660bec61 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 8 Mar 2025 16:25:50 +0100 Subject: [PATCH 07/17] Make `guess` field of video mandatory --- api/src/models/utils/language.ts | 1 - api/src/models/video.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/api/src/models/utils/language.ts b/api/src/models/utils/language.ts index b1d0bcf3..2f902d63 100644 --- a/api/src/models/utils/language.ts +++ b/api/src/models/utils/language.ts @@ -23,7 +23,6 @@ export const Language = (props?: NonNullable[0]>) => This is a BCP 47 language code (the IETF Best Current Practices on Tags for Identifying Languages). BCP 47 is also known as RFC 5646. It subsumes ISO 639 and is backward compatible with it. `, - error: "Expected a valid (and NORMALIZED) bcp-47 language code.", examples: ["en-US"], ...props, }), diff --git a/api/src/models/video.ts b/api/src/models/video.ts index 96da6f0f..b01a11bd 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -65,7 +65,7 @@ export const SeedVideo = t.Object({ "Kyoo will prefer playing back the highest `version` number if there are multiples rendering.", }), - guess: t.Optional(Guess), + guess: Guess, }); export type SeedVideo = typeof SeedVideo.static; From cca0da4bf6a6ff6ca4676c1126a20fcd558c7e85 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 8 Mar 2025 16:26:24 +0100 Subject: [PATCH 08/17] Use sql builder instead of orm for /movie --- api/src/controllers/shows/logic.ts | 22 +++++++++++++++++----- api/src/controllers/shows/movies.ts | 13 +++++++++---- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index 61239027..f12cc139 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -62,6 +62,7 @@ export async function getShows({ sort, filter, languages, + fallbackLanguage = true, preferOriginal = false, }: { after: string | undefined; @@ -70,11 +71,17 @@ export async function getShows({ sort: StaticDecode; filter: SQL | undefined; languages: string[]; + fallbackLanguage?: boolean; preferOriginal?: boolean; }) { const transQ = db .selectDistinctOn([showTranslations.pk]) .from(showTranslations) + .where( + !fallbackLanguage + ? eq(showTranslations.language, sql`any(${sqlarr(languages)})`) + : undefined, + ) .orderBy( showTranslations.pk, sql`array_position(${sqlarr(languages)}, ${showTranslations.language})`, @@ -86,6 +93,8 @@ export async function getShows({ .select({ ...getColumns(shows), ...transCol, + lanugage: transQ.language, + // movie columns (status is only a typescript hint) status: sql`${shows.status}`, airDate: shows.startAir, @@ -93,14 +102,17 @@ export async function getShows({ isAvailable: sql`${shows.availableCount} != 0`, ...(preferOriginal && { - poster: sql`coalesce(${shows.original}->'poster', ${showTranslations.poster})`, - thumbnail: sql`coalesce(${shows.original}->'thumbnail', ${showTranslations.thumbnail})`, - banner: sql`coalesce(${shows.original}->'banner', ${showTranslations.banner})`, - logo: sql`coalesce(${shows.original}->'logo', ${showTranslations.logo})`, + poster: sql`coalesce(${shows.original}->'poster', ${transQ.poster})`, + thumbnail: sql`coalesce(${shows.original}->'thumbnail', ${transQ.thumbnail})`, + banner: sql`coalesce(${shows.original}->'banner', ${transQ.banner})`, + logo: sql`coalesce(${shows.original}->'logo', ${transQ.logo})`, }), }) .from(shows) - .innerJoin(transQ, eq(shows.pk, transQ.pk)) + [fallbackLanguage ? "leftJoin" : "innerJoin"]( + transQ, + eq(shows.pk, transQ.pk), + ) .where( and( filter, diff --git a/api/src/controllers/shows/movies.ts b/api/src/controllers/shows/movies.ts index 4d3eca69..b0f4828a 100644 --- a/api/src/controllers/shows/movies.ts +++ b/api/src/controllers/shows/movies.ts @@ -10,10 +10,11 @@ import { Filter, Page, createPage, + isUuid, processLanguages, } from "~/models/utils"; import { desc } from "~/models/utils/descriptions"; -import { getShow, getShows, showFilters, showSort } from "./logic"; +import { getShows, showFilters, showSort } from "./logic"; export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) .model({ @@ -30,11 +31,15 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) set, }) => { const langs = processLanguages(languages); - const ret = await getShow(id, { + const [ret] = await getShows({ + filter: and( + isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id), + eq(shows.kind, "movie"), + ), languages: langs, + fallbackLanguage: langs.includes("*"), preferOriginal, relations, - filters: eq(shows.kind, "movie"), }); if (!ret) { return error(404, { @@ -49,7 +54,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) }); } set.headers["content-language"] = ret.language; - return ret.show; + return ret; }, { detail: { From fd57f506c113d67bdb0279c18789225c7b0f5822 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 8 Mar 2025 16:27:00 +0100 Subject: [PATCH 09/17] Proper error handling for missing `original` --- api/src/controllers/seed/movies.ts | 8 +++++++- api/src/controllers/seed/series.ts | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/api/src/controllers/seed/movies.ts b/api/src/controllers/seed/movies.ts index 6cd743cf..28232697 100644 --- a/api/src/controllers/seed/movies.ts +++ b/api/src/controllers/seed/movies.ts @@ -48,6 +48,13 @@ export const seedMovie = async ( const { translations, videos, collection, studios, ...movie } = seed; const nextRefresh = guessNextRefresh(movie.airDate ?? new Date()); + const original = translations[movie.originalLanguage]; + if (!original) { + return { + status: 422, + message: "No translation available in the original language.", + }; + } const col = await insertCollection(collection, { kind: "movie", @@ -55,7 +62,6 @@ export const seedMovie = async ( ...seed, }); - const original = translations[movie.originalLanguage]; const show = await insertShow( { kind: "movie", diff --git a/api/src/controllers/seed/series.ts b/api/src/controllers/seed/series.ts index 8f3da08f..5ca15eb6 100644 --- a/api/src/controllers/seed/series.ts +++ b/api/src/controllers/seed/series.ts @@ -83,6 +83,13 @@ export const seedSerie = async ( ...serie } = seed; const nextRefresh = guessNextRefresh(serie.startAir ?? new Date()); + const original = translations[serie.originalLanguage]; + if (!original) { + return { + status: 422, + message: "No translation available in the original language.", + }; + } const col = await insertCollection(collection, { kind: "serie", @@ -90,7 +97,6 @@ export const seedSerie = async ( ...seed, }); - const original = translations[serie.originalLanguage]; const show = await insertShow( { kind: "serie", From 3f77a1bda576b102828f583f5352c1c7adce9965 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 8 Mar 2025 22:53:58 +0100 Subject: [PATCH 10/17] Fix tests --- api/src/controllers/shows/logic.ts | 10 +++++----- api/src/models/examples/bubble.ts | 6 ++++++ api/src/models/examples/dune-1984.ts | 6 ++++++ api/src/models/examples/dune-2021.ts | 6 ++++++ api/src/models/utils/sort.ts | 3 ++- api/tests/movies/get-all-movies-with-null.test.ts | 8 +++++--- api/tests/movies/get-all-movies.test.ts | 9 +++++---- api/tests/movies/get-movie.test.ts | 10 +++++----- api/tests/movies/seed-movies.test.ts | 15 ++++++++++++--- api/tests/series/get-series.test.ts | 2 +- 10 files changed, 53 insertions(+), 22 deletions(-) diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index f12cc139..d8548fd8 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -102,14 +102,14 @@ export async function getShows({ isAvailable: sql`${shows.availableCount} != 0`, ...(preferOriginal && { - poster: sql`coalesce(${shows.original}->'poster', ${transQ.poster})`, - thumbnail: sql`coalesce(${shows.original}->'thumbnail', ${transQ.thumbnail})`, - banner: sql`coalesce(${shows.original}->'banner', ${transQ.banner})`, - logo: sql`coalesce(${shows.original}->'logo', ${transQ.logo})`, + poster: sql`coalesce(nullif(${shows.original}->'poster', 'null'::jsonb), ${transQ.poster})`, + thumbnail: sql`coalesce(nullif(${shows.original}->'thumbnail', 'null'::jsonb), ${transQ.thumbnail})`, + banner: sql`coalesce(nullif(${shows.original}->'banner', 'null'::jsonb), ${transQ.banner})`, + logo: sql`coalesce(nullif(${shows.original}->'logo', 'null'::jsonb), ${transQ.logo})`, }), }) .from(shows) - [fallbackLanguage ? "leftJoin" : "innerJoin"]( + [fallbackLanguage ? "innerJoin" : "leftJoin"]( transQ, eq(shows.pk, transQ.pk), ) diff --git a/api/src/models/examples/bubble.ts b/api/src/models/examples/bubble.ts index 9f66f701..1326f7b4 100644 --- a/api/src/models/examples/bubble.ts +++ b/api/src/models/examples/bubble.ts @@ -8,6 +8,12 @@ export const bubbleVideo: Video = { rendering: "459429fa062adeebedcc2bb04b9965de0262bfa453369783132d261be79021bd", part: null, version: 1, + guess: { + kind: "movie", + title: "bubble", + year: [2022], + from: "guessit", + }, createdAt: "2024-11-23T15:01:24.968Z", updatedAt: "2024-11-23T15:01:24.968Z", }; diff --git a/api/src/models/examples/dune-1984.ts b/api/src/models/examples/dune-1984.ts index d12b94e1..34084f77 100644 --- a/api/src/models/examples/dune-1984.ts +++ b/api/src/models/examples/dune-1984.ts @@ -8,6 +8,12 @@ export const dune1984Video: Video = { rendering: "ea3a0f8f2f2c5b61a07f61e4e8d9f8e01b2b92bcbb6f5ed1151e1f61619c2c0f", part: null, version: 1, + guess: { + kind: "movie", + title: "dune", + year: [1984], + from: "guessit", + }, createdAt: "2024-12-02T11:45:12.968Z", updatedAt: "2024-12-02T11:45:12.968Z", }; diff --git a/api/src/models/examples/dune-2021.ts b/api/src/models/examples/dune-2021.ts index d3b7b59f..08cb3499 100644 --- a/api/src/models/examples/dune-2021.ts +++ b/api/src/models/examples/dune-2021.ts @@ -8,6 +8,12 @@ export const duneVideo: Video = { rendering: "f1953a4fb58247efb6c15b76468b6a9d13b4155b02094863b1a4f0c3fbb6db58", part: null, version: 1, + guess: { + kind: "movie", + title: "dune", + year: [2021], + from: "guessit", + }, createdAt: "2024-12-02T10:10:24.968Z", updatedAt: "2024-12-02T10:10:24.968Z", }; diff --git a/api/src/models/utils/sort.ts b/api/src/models/utils/sort.ts index d9a4c177..1a302325 100644 --- a/api/src/models/utils/sort.ts +++ b/api/src/models/utils/sort.ts @@ -78,9 +78,10 @@ export const sortToSql = < T extends string[], Remap extends Partial>, >( - sort: Sort, + sort: Sort | undefined, table: Table["sort"][number]["key"] | "pk">, ) => { + if (!sort) return []; if (sort.random) { return [sql`md5(${sort.random.seed} || ${table.pk})`]; } diff --git a/api/tests/movies/get-all-movies-with-null.test.ts b/api/tests/movies/get-all-movies-with-null.test.ts index 30d5932f..5b633510 100644 --- a/api/tests/movies/get-all-movies-with-null.test.ts +++ b/api/tests/movies/get-all-movies-with-null.test.ts @@ -1,6 +1,5 @@ import { beforeAll, describe, expect, it } from "bun:test"; import { expectStatus } from "tests/utils"; -import { seedMovie } from "~/controllers/seed/movies"; import { db } from "~/db"; import { shows } from "~/db/schema"; import { bubble } from "~/models/examples"; @@ -10,7 +9,10 @@ import { app, createMovie, getMovies } from "../helpers"; beforeAll(async () => { await db.delete(shows); - for (const movie of [bubble, dune1984, dune]) await seedMovie(movie); + for (const movie of [bubble, dune1984, dune]) { + const [ret, _] = await createMovie(movie); + expect(ret.status).toBe(201); + } }); describe("with a null value", () => { @@ -39,7 +41,7 @@ describe("with a null value", () => { rating: null, runtime: null, airDate: null, - originalLanguage: null, + originalLanguage: "en", externalId: {}, studios: [], }); diff --git a/api/tests/movies/get-all-movies.test.ts b/api/tests/movies/get-all-movies.test.ts index fc8776f5..9eb9f2af 100644 --- a/api/tests/movies/get-all-movies.test.ts +++ b/api/tests/movies/get-all-movies.test.ts @@ -1,18 +1,19 @@ import { beforeAll, describe, expect, it } from "bun:test"; import { expectStatus } from "tests/utils"; -import { seedMovie } from "~/controllers/seed/movies"; import { db } from "~/db"; import { shows } from "~/db/schema"; import { bubble } from "~/models/examples"; import { dune1984 } from "~/models/examples/dune-1984"; import { dune } from "~/models/examples/dune-2021"; import type { Movie } from "~/models/movie"; -import { isUuid } from "~/models/utils"; -import { app, getMovies } from "../helpers"; +import { app, createMovie, getMovies } from "../helpers"; beforeAll(async () => { await db.delete(shows); - for (const movie of [bubble, dune1984, dune]) await seedMovie(movie); + for (const movie of [bubble, dune1984, dune]) { + const [ret, _] = await createMovie(movie); + expect(ret.status).toBe(201); + } }); describe("Get all movies", () => { diff --git a/api/tests/movies/get-movie.test.ts b/api/tests/movies/get-movie.test.ts index f3b266ac..687e0dd8 100644 --- a/api/tests/movies/get-movie.test.ts +++ b/api/tests/movies/get-movie.test.ts @@ -1,18 +1,18 @@ import { beforeAll, describe, expect, it } from "bun:test"; import { expectStatus } from "tests/utils"; -import { seedMovie } from "~/controllers/seed/movies"; import { db } from "~/db"; import { shows, videos } from "~/db/schema"; import { bubble, bubbleVideo } from "~/models/examples"; -import { getMovie } from "../helpers"; +import { createMovie, getMovie } from "../helpers"; let bubbleId = ""; beforeAll(async () => { await db.delete(shows); await db.insert(videos).values(bubbleVideo); - const ret = await seedMovie(bubble); - if (!("status" in ret)) bubbleId = ret.id; + const [ret, body] = await createMovie(bubble); + expect(ret.status).toBe(201) + bubbleId = body.id; }); describe("Get movie", () => { @@ -124,7 +124,7 @@ describe("Get movie", () => { expect(body.isAvailable).toBe(true); }); it("With isAvailable=false", async () => { - await seedMovie({ + await createMovie({ ...bubble, slug: "no-video", videos: [], diff --git a/api/tests/movies/seed-movies.test.ts b/api/tests/movies/seed-movies.test.ts index bbc8b4d9..611be355 100644 --- a/api/tests/movies/seed-movies.test.ts +++ b/api/tests/movies/seed-movies.test.ts @@ -168,7 +168,7 @@ describe("Movie seeding", () => { const [resp, body] = await createMovie({ ...bubble, slug: "casing-test", - originalLanguage: "jp-jp", + originalLanguage: "en-us", translations: { "en-us": { name: "foo", @@ -191,7 +191,7 @@ describe("Movie seeding", () => { where: eq(shows.id, body.id), with: { translations: true }, }); - expect(ret!.originalLanguage).toBe("jp-JP"); + expect(ret!.original.language).toBe("en-US"); expect(ret!.translations).toBeArrayOfSize(2); expect(ret!.translations).toEqual( expect.arrayContaining([ @@ -229,7 +229,10 @@ describe("Movie seeding", () => { const [resp, body] = await createMovie({ ...bubble, slug: "bubble-translation-test", - translations: { "en-us": bubble.translations.en }, + translations: { + "en-us": bubble.translations.en, + ja: bubble.translations.ja, + }, }); expectStatus(resp, body).toBe(201); @@ -262,6 +265,7 @@ describe("Movie seeding", () => { "en-us": bubble.translations.en, "en-au": { ...bubble.translations.en, name: "australian thing" }, en: { ...bubble.translations.en, name: "Generic" }, + ja: bubble.translations.ja, }, }); expectStatus(resp, body).toBe(201); @@ -304,6 +308,7 @@ describe("Movie seeding", () => { part: null, version: 1, rendering: "oeunhtoeuth", + guess: { title: "bubble", from: "test" }, }); expectStatus(vresp, video).toBe(201); @@ -329,6 +334,7 @@ describe("Movie seeding", () => { part: null, version: 2, rendering: "oeunhtoeuth", + guess: { title: "bubble", from: "test" }, }); expectStatus(vresp, video).toBe(201); @@ -353,6 +359,7 @@ describe("Movie seeding", () => { part: 1, version: 2, rendering: "oaoeueunhtoeuth", + guess: { title: "bubble", from: "test" }, }); expectStatus(vresp, video).toBe(201); @@ -378,12 +385,14 @@ describe("Movie seeding", () => { part: null, version: 1, rendering: "oeunhtoeuth", + guess: { title: "bubble", from: "test" }, }, { path: "/video/bubble4.mkv", part: null, version: 1, rendering: "aoeuaoeu", + guess: { title: "bubble", from: "test" }, }, ]); expectStatus(vresp, video).toBe(201); diff --git a/api/tests/series/get-series.test.ts b/api/tests/series/get-series.test.ts index 28ab3fb6..2cdfb3c7 100644 --- a/api/tests/series/get-series.test.ts +++ b/api/tests/series/get-series.test.ts @@ -12,7 +12,7 @@ beforeAll(async () => { await createSerie(madeInAbyss); }); -describe("aet series", () => { +describe("Get series", () => { it("Invalid slug", async () => { const [resp, body] = await getSerie("sotneuhn", { langs: "en" }); From c161d680e305efe5047a45bbac77e1c774fc53bc Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 9 Mar 2025 01:16:50 +0100 Subject: [PATCH 11/17] Add `with=translations` in the sql builder logic --- api/src/controllers/shows/logic.ts | 45 +++++++++++++++++++++--- api/src/controllers/shows/movies.ts | 1 + api/src/controllers/shows/series.ts | 12 +++++-- api/src/db/utils.ts | 15 ++++++++ api/src/models/examples/made-in-abyss.ts | 34 ++++++++++++++++++ api/src/models/utils/keyset-paginate.ts | 4 +-- api/tests/manual.ts | 4 ++- 7 files changed, 104 insertions(+), 11 deletions(-) diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index d8548fd8..ae783dcd 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -2,7 +2,12 @@ import type { StaticDecode } from "@sinclair/typebox"; import { type SQL, and, eq, sql } from "drizzle-orm"; import { db } from "~/db"; import { showTranslations, shows, studioTranslations } from "~/db/schema"; -import { getColumns, sqlarr } from "~/db/utils"; +import { + getColumns, + jsonbBuildObject, + jsonbObjectAgg, + sqlarr, +} from "~/db/utils"; import type { MovieStatus } from "~/models/movie"; import { SerieStatus } from "~/models/serie"; import { @@ -55,6 +60,16 @@ export const showSort = Sort( }, ); +const buildRelations = ( + relations: R[], + toSql: (relation: R) => SQL, +) => { + return Object.fromEntries(relations.map((x) => [x, toSql(x)])) as Record< + R, + SQL + >; +}; + export async function getShows({ after, limit, @@ -64,15 +79,17 @@ export async function getShows({ languages, fallbackLanguage = true, preferOriginal = false, + relations = [], }: { - after: string | undefined; + after?: string; limit: number; - query: string | undefined; - sort: StaticDecode; - filter: SQL | undefined; + query?: string; + sort?: StaticDecode; + filter?: SQL; languages: string[]; fallbackLanguage?: boolean; preferOriginal?: boolean; + relations?: ("translations" | "studios" | "videos")[]; }) { const transQ = db .selectDistinctOn([showTranslations.pk]) @@ -89,6 +106,22 @@ export async function getShows({ .as("t"); const { pk, ...transCol } = getColumns(transQ); + const relationsSql = buildRelations(relations, (x) => { + switch (x) { + case "studios": + case "videos": + case "translations": { + // we wrap that in a sql`` instead of using the builder because of this issue + // https://github.com/drizzle-team/drizzle-orm/pull/1674 + const { pk, language, ...trans } = getColumns(showTranslations); + return sql`${db + .select({ json: jsonbObjectAgg(language, jsonbBuildObject(trans)) }) + .from(showTranslations) + .where(eq(showTranslations.pk, shows.pk))}`; + } + } + }); + return await db .select({ ...getColumns(shows), @@ -107,6 +140,8 @@ export async function getShows({ banner: sql`coalesce(nullif(${shows.original}->'banner', 'null'::jsonb), ${transQ.banner})`, logo: sql`coalesce(nullif(${shows.original}->'logo', 'null'::jsonb), ${transQ.logo})`, }), + + ...relationsSql, }) .from(shows) [fallbackLanguage ? "innerJoin" : "leftJoin"]( diff --git a/api/src/controllers/shows/movies.ts b/api/src/controllers/shows/movies.ts index b0f4828a..e1bb197d 100644 --- a/api/src/controllers/shows/movies.ts +++ b/api/src/controllers/shows/movies.ts @@ -32,6 +32,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) }) => { const langs = processLanguages(languages); const [ret] = await getShows({ + limit: 1, filter: and( isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id), eq(shows.kind, "movie"), diff --git a/api/src/controllers/shows/series.ts b/api/src/controllers/shows/series.ts index f84a4d5a..8af3a9d3 100644 --- a/api/src/controllers/shows/series.ts +++ b/api/src/controllers/shows/series.ts @@ -10,6 +10,7 @@ import { Filter, Page, createPage, + isUuid, processLanguages, } from "~/models/utils"; import { desc } from "~/models/utils/descriptions"; @@ -30,11 +31,16 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] }) set, }) => { const langs = processLanguages(languages); - const ret = await getShow(id, { + const [ret] = await getShows({ + limit: 1, + filter: and( + isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id), + eq(shows.kind, "serie"), + ), languages: langs, + fallbackLanguage: langs.includes("*"), preferOriginal, relations, - filters: eq(shows.kind, "serie"), }); if (!ret) { return error(404, { @@ -49,7 +55,7 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] }) }); } set.headers["content-language"] = ret.language; - return ret.show; + return ret; }, { detail: { diff --git a/api/src/db/utils.ts b/api/src/db/utils.ts index f2a4947a..f2fe23df 100644 --- a/api/src/db/utils.ts +++ b/api/src/db/utils.ts @@ -1,6 +1,7 @@ import { type ColumnsSelection, type SQL, + type SQLWrapper, type Subquery, Table, View, @@ -92,3 +93,17 @@ export function values(items: Record[]) { }, }; } + +export const jsonbObjectAgg = (key: SQLWrapper, value: SQLWrapper) => { + return sql`jsonb_object_agg(${sql.join([key, value], sql.raw(","))})`; +}; + +export const jsonbBuildObject = (select: Record) => { + const query = sql.join( + Object.entries(select).flatMap(([k, v]) => { + return [sql.raw(`'${k}'`), v]; + }), + sql.raw(", "), + ); + return sql`jsonb_build_object(${query})`; +}; diff --git a/api/src/models/examples/made-in-abyss.ts b/api/src/models/examples/made-in-abyss.ts index 01991059..36e16dd8 100644 --- a/api/src/models/examples/made-in-abyss.ts +++ b/api/src/models/examples/made-in-abyss.ts @@ -60,6 +60,40 @@ export const madeInAbyss = { banner: null, trailerUrl: "https://www.youtube.com/watch?v=ePOyy6Wlk4s", }, + ja: { + name: "メイドインアビス", + tagline: "さぁ 大穴(アビス)へ――", + aliases: ["烈日の黄金郷"], + description: + "隅々まで探索されつくした世界に、唯一残された秘境の大穴『アビス』。どこまで続くとも知れない深く巨大なその縦穴には、奇妙奇怪な生物たちが生息し、今の人類では作りえない貴重な遺物が眠っている。「アビス」の不可思議に満ちた姿は人々を魅了し、冒険へと駆り立てた。そうして幾度も大穴に挑戦する冒険者たちは、次第に『探窟家』と呼ばれるようになっていった。 アビスの縁に築かれた街『オース』に暮らす孤児のリコは、いつか母のような偉大な探窟家になり、アビスの謎を解き明かすことを夢見ていた。そんなある日、リコはアビスを探窟中に、少年の姿をしたロボットを拾い…?", + tags: [ + "android", + "amnesia", + "post-apocalyptic future", + "exploration", + "friendship", + "mecha", + "survival", + "curse", + "tragedy", + "orphan", + "based on manga", + "robot", + "dark fantasy", + "seinen", + "anime", + "drastic change of life", + "fantasy", + "adventure", + ], + poster: + "https://image.tmdb.org/t/p/original/4Bh9qzB1Kau4RDaVQXVFdoJ0HcE.jpg", + thumbnail: + "https://image.tmdb.org/t/p/original/Df9XrvZFIeQfLKfu8evRmzvRsd.jpg", + logo: "https://image.tmdb.org/t/p/original/7hY3Q4GhkiYPBfn4UoVg0AO4Zgk.png", + banner: null, + trailerUrl: "https://www.youtube.com/watch?v=ePOyy6Wlk4s", + }, }, genres: [ "animation", diff --git a/api/src/models/utils/keyset-paginate.ts b/api/src/models/utils/keyset-paginate.ts index 76fd33bb..0307367f 100644 --- a/api/src/models/utils/keyset-paginate.ts +++ b/api/src/models/utils/keyset-paginate.ts @@ -26,9 +26,9 @@ export const keysetPaginate = < }: { table: Table<"pk" | Sort["sort"][number]["key"]>; after: string | undefined; - sort: Sort; + sort: Sort | undefined; }) => { - if (!after) return undefined; + if (!after || !sort) return undefined; const cursor: After = JSON.parse( Buffer.from(after, "base64").toString("utf-8"), ); diff --git a/api/tests/manual.ts b/api/tests/manual.ts index 0fcd9d2f..3835aa2c 100644 --- a/api/tests/manual.ts +++ b/api/tests/manual.ts @@ -1,7 +1,7 @@ import { db, migrate } from "~/db"; import { shows, videos } from "~/db/schema"; import { madeInAbyss, madeInAbyssVideo } from "~/models/examples"; -import { createSerie, createVideo } from "./helpers"; +import { createSerie, createVideo, getSerie } from "./helpers"; // test file used to run manually using `bun tests/manual.ts` @@ -13,3 +13,5 @@ const [_, vid] = await createVideo(madeInAbyssVideo); console.log(vid); const [__, ser] = await createSerie(madeInAbyss); console.log(ser); +const [___, got] = await getSerie(madeInAbyss.slug, { with: ["translations"] }); +console.log(got); From fc60fcc7c4f86ac114aaf66b1b56499137baed6e Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 9 Mar 2025 01:39:41 +0100 Subject: [PATCH 12/17] Cleanup video join in entries --- api/src/controllers/entries.ts | 25 +++++++++++++++---------- api/src/db/utils.ts | 8 ++++++++ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index c18a9787..120cb8b2 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -9,7 +9,13 @@ import { shows, videos, } from "~/db/schema"; -import { getColumns, sqlarr } from "~/db/utils"; +import { + coalesce, + getColumns, + jsonbAgg, + jsonbBuildObject, + sqlarr, +} from "~/db/utils"; import { Entry, type EntryKind, @@ -107,17 +113,16 @@ async function getEntries({ const { guess, createdAt, updatedAt, ...videosCol } = getColumns(videos); const videosQ = db - .select({ slug: entryVideoJoin.slug, ...videosCol }) + .select({ + videos: coalesce( + jsonbAgg(jsonbBuildObject({ slug: entryVideoJoin.slug, ...videosCol })), + sql`'[]'::jsonb`, + ), + }) .from(entryVideoJoin) .where(eq(entryVideoJoin.entryPk, entries.pk)) .leftJoin(videos, eq(videos.pk, entryVideoJoin.videoPk)) .as("videos"); - const videosJ = db - .select({ - videos: sql`coalesce(json_agg("videos"), '[]'::json)`.as("videos"), - }) - .from(videosQ) - .as("videos_json"); const { kind, @@ -132,7 +137,7 @@ async function getEntries({ .select({ ...entryCol, ...transCol, - videos: videosJ.videos, + videos: videosQ.videos, // specials don't have an `episodeNumber` but a `number` field. number: episodeNumber, @@ -150,7 +155,7 @@ async function getEntries({ }) .from(entries) .innerJoin(transQ, eq(entries.pk, transQ.pk)) - .leftJoinLateral(videosJ, sql`true`) + .leftJoinLateral(videosQ, sql`true`) .where( and( filter, diff --git a/api/src/db/utils.ts b/api/src/db/utils.ts index f2fe23df..59ef88ab 100644 --- a/api/src/db/utils.ts +++ b/api/src/db/utils.ts @@ -94,10 +94,18 @@ export function values(items: Record[]) { }; } +export const coalesce = (val: SQLWrapper, def: SQLWrapper) => { + return sql`coalesce(${val}, ${def})`; +}; + export const jsonbObjectAgg = (key: SQLWrapper, value: SQLWrapper) => { return sql`jsonb_object_agg(${sql.join([key, value], sql.raw(","))})`; }; +export const jsonbAgg = (val: SQLWrapper) => { + return sql`jsonb_agg(${val})`; +}; + export const jsonbBuildObject = (select: Record) => { const query = sql.join( Object.entries(select).flatMap(([k, v]) => { From f9ff6c00d7f89f133d90f0c5d4ecede0bcc299fc Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 9 Mar 2025 14:01:22 +0100 Subject: [PATCH 13/17] Add studios in new `with=` handling --- api/src/controllers/shows/logic.ts | 148 +++++++++++------------------ 1 file changed, 53 insertions(+), 95 deletions(-) diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index ae783dcd..65b551dd 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -1,15 +1,24 @@ import type { StaticDecode } from "@sinclair/typebox"; -import { type SQL, and, eq, sql } from "drizzle-orm"; +import { type SQL, and, eq, exists, sql } from "drizzle-orm"; import { db } from "~/db"; -import { showTranslations, shows, studioTranslations } from "~/db/schema"; import { + showStudioJoin, + showTranslations, + shows, + studioTranslations, + studios, +} from "~/db/schema"; +import { + coalesce, getColumns, + jsonbAgg, jsonbBuildObject, jsonbObjectAgg, sqlarr, } from "~/db/utils"; import type { MovieStatus } from "~/models/movie"; -import { SerieStatus } from "~/models/serie"; +import { SerieStatus, SerieTranslation } from "~/models/serie"; +import { Studio } from "~/models/studio"; import { type FilterDef, Genre, @@ -108,17 +117,56 @@ export async function getShows({ const relationsSql = buildRelations(relations, (x) => { switch (x) { - case "studios": case "videos": case "translations": { // we wrap that in a sql`` instead of using the builder because of this issue // https://github.com/drizzle-team/drizzle-orm/pull/1674 const { pk, language, ...trans } = getColumns(showTranslations); - return sql`${db + return sql`${db .select({ json: jsonbObjectAgg(language, jsonbBuildObject(trans)) }) .from(showTranslations) .where(eq(showTranslations.pk, shows.pk))}`; } + case "studios": { + const { pk: _, ...studioCol } = getColumns(studios); + const studioTransQ = db + .selectDistinctOn([studioTranslations.pk]) + .from(studioTranslations) + .where( + !fallbackLanguage + ? eq(showTranslations.language, sql`any(${sqlarr(languages)})`) + : undefined, + ) + .orderBy( + studioTranslations.pk, + sql`array_position(${sqlarr(languages)}, ${studioTranslations.language}`, + ) + .as("t"); + const { pk, language, ...studioTrans } = getColumns(studioTransQ); + + return sql`${db + .select({ + json: coalesce( + jsonbAgg(jsonbBuildObject({ ...studioTrans, ...studioCol })), + sql`'[]'::jsonb`, + ), + }) + .from(studios) + .leftJoin(studioTransQ, eq(studios.pk, studioTransQ.pk)) + .where( + exists( + db + .select() + .from(showStudioJoin) + .where( + and( + eq(showStudioJoin.studioPk, studios.pk), + eq(showStudioJoin.showPk, shows.pk), + ), + ), + ), + )}`; + } } }); @@ -163,93 +211,3 @@ export async function getShows({ ) .limit(limit); } - -export async function getShow( - id: string, - { - languages, - preferOriginal, - relations, - filters, - }: { - languages: string[]; - preferOriginal: boolean | undefined; - relations: ("translations" | "studios" | "videos")[]; - filters: SQL | undefined; - }, -) { - const ret = await db.query.shows.findFirst({ - extras: { - airDate: sql`${shows.startAir}`.as("airDate"), - status: sql`${shows.status}`.as("status"), - isAvailable: sql`${shows.availableCount} != 0`.as("isAvailable"), - }, - where: and(isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id), filters), - with: { - selectedTranslation: selectTranslationQuery(showTranslations, languages), - ...(preferOriginal && { - originalTranslation: { - columns: { - poster: true, - thumbnail: true, - banner: true, - logo: true, - }, - }, - }), - ...(relations.includes("translations") && { - translations: { - columns: { - pk: false, - }, - }, - }), - ...(relations.includes("studios") && { - studios: { - with: { - studio: { - columns: { - pk: false, - }, - with: { - selectedTranslation: selectTranslationQuery( - studioTranslations, - languages, - ), - }, - }, - }, - }, - }), - }, - }); - if (!ret) return null; - const translation = ret.selectedTranslation[0]; - if (!translation) return { show: null, language: null }; - const ot = ret.originalTranslation; - const show = { - ...ret, - ...translation, - kind: ret.kind as any, - ...(ot && { - ...(ot.poster && { poster: ot.poster }), - ...(ot.thumbnail && { thumbnail: ot.thumbnail }), - ...(ot.banner && { banner: ot.banner }), - ...(ot.logo && { logo: ot.logo }), - }), - ...(ret.translations && { - translations: Object.fromEntries( - ret.translations.map( - ({ language, ...translation }) => [language, translation] as const, - ), - ), - }), - ...(ret.studios && { - studios: ret.studios.map((x: any) => ({ - ...x.studio, - ...x.studio.selectedTranslation[0], - })), - }), - }; - return { show, language: translation.language }; -} From aab38f6a89433485d4c10c0912abd486e080f3ea Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 9 Mar 2025 16:26:28 +0100 Subject: [PATCH 14/17] Rework & type relations --- api/src/controllers/shows/logic.ts | 126 +++++++++++++---------------- api/src/db/utils.ts | 19 +++-- api/src/models/utils/index.ts | 1 + api/src/models/utils/relations.ts | 26 ++++++ 4 files changed, 96 insertions(+), 76 deletions(-) create mode 100644 api/src/models/utils/relations.ts diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index 65b551dd..695517b4 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -17,16 +17,15 @@ import { sqlarr, } from "~/db/utils"; import type { MovieStatus } from "~/models/movie"; -import { SerieStatus, SerieTranslation } from "~/models/serie"; -import { Studio } from "~/models/studio"; +import { SerieStatus, type SerieTranslation } from "~/models/serie"; +import type { Studio } from "~/models/studio"; import { type FilterDef, Genre, type Image, Sort, - isUuid, + buildRelations, keysetPaginate, - selectTranslationQuery, sortToSql, } from "~/models/utils"; @@ -69,14 +68,60 @@ export const showSort = Sort( }, ); -const buildRelations = ( - relations: R[], - toSql: (relation: R) => SQL, -) => { - return Object.fromEntries(relations.map((x) => [x, toSql(x)])) as Record< - R, - SQL - >; +const showRelations = { + translations: () => { + const { pk, language, ...trans } = getColumns(showTranslations); + return db + .select({ + json: jsonbObjectAgg( + language, + jsonbBuildObject(trans), + ).as("json"), + }) + .from(showTranslations) + .where(eq(showTranslations.pk, shows.pk)) + .as("translations"); + }, + studios: ({ languages }: { languages: string[] }) => { + const { pk: _, ...studioCol } = getColumns(studios); + const studioTransQ = db + .selectDistinctOn([studioTranslations.pk]) + .from(studioTranslations) + .orderBy( + studioTranslations.pk, + sql`array_position(${sqlarr(languages)}, ${studioTranslations.language}`, + ) + .as("t"); + const { pk, language, ...studioTrans } = getColumns(studioTransQ); + + return db + .select({ + json: coalesce( + jsonbAgg(jsonbBuildObject({ ...studioTrans, ...studioCol })), + sql`'[]'::jsonb`, + ).as("json"), + }) + .from(studios) + .leftJoin(studioTransQ, eq(studios.pk, studioTransQ.pk)) + .where( + exists( + db + .select() + .from(showStudioJoin) + .where( + and( + eq(showStudioJoin.studioPk, studios.pk), + eq(showStudioJoin.showPk, shows.pk), + ), + ), + ), + ) + .as("studios"); + }, + // only available for movies + videos: () => { + throw new Error(); + }, }; export async function getShows({ @@ -115,61 +160,6 @@ export async function getShows({ .as("t"); const { pk, ...transCol } = getColumns(transQ); - const relationsSql = buildRelations(relations, (x) => { - switch (x) { - case "videos": - case "translations": { - // we wrap that in a sql`` instead of using the builder because of this issue - // https://github.com/drizzle-team/drizzle-orm/pull/1674 - const { pk, language, ...trans } = getColumns(showTranslations); - return sql`${db - .select({ json: jsonbObjectAgg(language, jsonbBuildObject(trans)) }) - .from(showTranslations) - .where(eq(showTranslations.pk, shows.pk))}`; - } - case "studios": { - const { pk: _, ...studioCol } = getColumns(studios); - const studioTransQ = db - .selectDistinctOn([studioTranslations.pk]) - .from(studioTranslations) - .where( - !fallbackLanguage - ? eq(showTranslations.language, sql`any(${sqlarr(languages)})`) - : undefined, - ) - .orderBy( - studioTranslations.pk, - sql`array_position(${sqlarr(languages)}, ${studioTranslations.language}`, - ) - .as("t"); - const { pk, language, ...studioTrans } = getColumns(studioTransQ); - - return sql`${db - .select({ - json: coalesce( - jsonbAgg(jsonbBuildObject({ ...studioTrans, ...studioCol })), - sql`'[]'::jsonb`, - ), - }) - .from(studios) - .leftJoin(studioTransQ, eq(studios.pk, studioTransQ.pk)) - .where( - exists( - db - .select() - .from(showStudioJoin) - .where( - and( - eq(showStudioJoin.studioPk, studios.pk), - eq(showStudioJoin.showPk, shows.pk), - ), - ), - ), - )}`; - } - } - }); - return await db .select({ ...getColumns(shows), @@ -189,7 +179,7 @@ export async function getShows({ logo: sql`coalesce(nullif(${shows.original}->'logo', 'null'::jsonb), ${transQ.logo})`, }), - ...relationsSql, + ...buildRelations(relations, showRelations, { languages }), }) .from(shows) [fallbackLanguage ? "innerJoin" : "leftJoin"]( diff --git a/api/src/db/utils.ts b/api/src/db/utils.ts index 59ef88ab..fea4e8ef 100644 --- a/api/src/db/utils.ts +++ b/api/src/db/utils.ts @@ -1,5 +1,6 @@ import { type ColumnsSelection, + InferColumnsDataTypes, type SQL, type SQLWrapper, type Subquery, @@ -94,24 +95,26 @@ export function values(items: Record[]) { }; } -export const coalesce = (val: SQLWrapper, def: SQLWrapper) => { - return sql`coalesce(${val}, ${def})`; +export const coalesce = (val: SQL, def: SQLWrapper) => { + return sql`coalesce(${val}, ${def})`; }; -export const jsonbObjectAgg = (key: SQLWrapper, value: SQLWrapper) => { - return sql`jsonb_object_agg(${sql.join([key, value], sql.raw(","))})`; +export const jsonbObjectAgg = (key: SQLWrapper, value: SQL) => { + return sql< + Record + >`jsonb_object_agg(${sql.join([key, value], sql.raw(","))})`; }; -export const jsonbAgg = (val: SQLWrapper) => { - return sql`jsonb_agg(${val})`; +export const jsonbAgg = (val: SQL) => { + return sql`jsonb_agg(${val})`; }; -export const jsonbBuildObject = (select: Record) => { +export const jsonbBuildObject = (select: Record) => { const query = sql.join( Object.entries(select).flatMap(([k, v]) => { return [sql.raw(`'${k}'`), v]; }), sql.raw(", "), ); - return sql`jsonb_build_object(${query})`; + return sql`jsonb_build_object(${query})`; }; diff --git a/api/src/models/utils/index.ts b/api/src/models/utils/index.ts index 1d0c1fa4..53ea1dff 100644 --- a/api/src/models/utils/index.ts +++ b/api/src/models/utils/index.ts @@ -9,3 +9,4 @@ export * from "./sort"; export * from "./keyset-paginate"; export * from "./db-metadata"; export * from "./original"; +export * from "./relations"; diff --git a/api/src/models/utils/relations.ts b/api/src/models/utils/relations.ts new file mode 100644 index 00000000..1e5088a7 --- /dev/null +++ b/api/src/models/utils/relations.ts @@ -0,0 +1,26 @@ +import { type SQL, type Subquery, sql } from "drizzle-orm"; +import type { SelectResultField } from "drizzle-orm/query-builders/select.types"; + +export const buildRelations = < + R extends string, + P extends object, + Rel extends Record Subquery>, +>( + enabled: R[], + relations: Rel, + params: P, +) => { + // we wrap that in a sql`` instead of using the builder because of this issue + // https://github.com/drizzle-team/drizzle-orm/pull/1674 + return Object.fromEntries( + enabled.map((x) => [x, sql`${relations[x](params)}`]), + ) as { + [P in R]?: SQL< + ReturnType["_"]["selectedFields"] extends { + [key: string]: infer TValue; + } + ? SelectResultField + : never + >; + }; +}; From d61573668b67264b4802d93db1f50da3bbd2e471 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 9 Mar 2025 16:46:04 +0100 Subject: [PATCH 15/17] Use original with collections too --- api/src/controllers/shows/collections.ts | 13 +++++++++---- api/src/controllers/shows/logic.ts | 4 ++-- api/src/controllers/shows/series.ts | 2 +- api/src/db/utils.ts | 2 +- api/src/models/collections.ts | 15 ++++++++------- api/src/models/movie.ts | 3 --- api/src/models/serie.ts | 3 --- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/api/src/controllers/shows/collections.ts b/api/src/controllers/shows/collections.ts index 4ba78d66..a845524a 100644 --- a/api/src/controllers/shows/collections.ts +++ b/api/src/controllers/shows/collections.ts @@ -21,7 +21,7 @@ import { processLanguages, } from "~/models/utils"; import { desc } from "~/models/utils/descriptions"; -import { getShow, getShows, showFilters, showSort } from "./logic"; +import { getShows, showFilters, showSort } from "./logic"; export const collections = new Elysia({ prefix: "/collections", @@ -41,11 +41,16 @@ export const collections = new Elysia({ set, }) => { const langs = processLanguages(languages); - const ret = await getShow(id, { + const [ret] = await getShows({ + limit: 1, + filter: and( + isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id), + eq(shows.kind, "collection"), + ), languages: langs, + fallbackLanguage: langs.includes("*"), preferOriginal, relations, - filters: eq(shows.kind, "collection"), }); if (!ret) { return error(404, { @@ -60,7 +65,7 @@ export const collections = new Elysia({ }); } set.headers["content-language"] = ret.language; - return ret.show; + return ret; }, { detail: { diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index 695517b4..23eda8e7 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -143,7 +143,7 @@ export async function getShows({ languages: string[]; fallbackLanguage?: boolean; preferOriginal?: boolean; - relations?: ("translations" | "studios" | "videos")[]; + relations?: (keyof typeof showRelations)[]; }) { const transQ = db .selectDistinctOn([showTranslations.pk]) @@ -182,7 +182,7 @@ export async function getShows({ ...buildRelations(relations, showRelations, { languages }), }) .from(shows) - [fallbackLanguage ? "innerJoin" : "leftJoin"]( + [fallbackLanguage ? "innerJoin" : ("leftJoin" as "innerJoin")]( transQ, eq(shows.pk, transQ.pk), ) diff --git a/api/src/controllers/shows/series.ts b/api/src/controllers/shows/series.ts index 8af3a9d3..779ae39a 100644 --- a/api/src/controllers/shows/series.ts +++ b/api/src/controllers/shows/series.ts @@ -14,7 +14,7 @@ import { processLanguages, } from "~/models/utils"; import { desc } from "~/models/utils/descriptions"; -import { getShow, getShows, showFilters, showSort } from "./logic"; +import { getShows, showFilters, showSort } from "./logic"; export const series = new Elysia({ prefix: "/series", tags: ["series"] }) .model({ diff --git a/api/src/db/utils.ts b/api/src/db/utils.ts index fea4e8ef..baa3658c 100644 --- a/api/src/db/utils.ts +++ b/api/src/db/utils.ts @@ -106,7 +106,7 @@ export const jsonbObjectAgg = (key: SQLWrapper, value: SQL) => { }; export const jsonbAgg = (val: SQL) => { - return sql`jsonb_agg(${val})`; + return sql`jsonb_agg(${val})`; }; export const jsonbBuildObject = (select: Record) => { diff --git a/api/src/models/collections.ts b/api/src/models/collections.ts index 62a5c489..91f6f8df 100644 --- a/api/src/models/collections.ts +++ b/api/src/models/collections.ts @@ -7,6 +7,7 @@ import { Genre, Image, Language, + Original, Resource, SeedImage, TranslationRecord, @@ -27,14 +28,7 @@ const BaseCollection = t.Object({ descrpition: "Date of the last item of the collection", }), ), - originalLanguage: t.Nullable( - Language({ - description: "The language code this movie was made in.", - }), - ), - nextRefresh: t.String({ format: "date-time" }), - externalId: ExternalId(), }); @@ -56,6 +50,9 @@ export const Collection = t.Intersect([ CollectionTranslation, BaseCollection, DbMetadata, + t.Object({ + original: Original, + }), ]); export type Collection = Prettify; @@ -71,6 +68,9 @@ export const SeedCollection = t.Intersect([ t.Omit(BaseCollection, ["kind", "startAir", "endAir", "nextRefresh"]), t.Object({ slug: t.String({ format: "slug" }), + originalLanguage: Language({ + description: "The language code this collection's items were made in.", + }), translations: TranslationRecord( t.Intersect([ t.Omit(CollectionTranslation, [ @@ -84,6 +84,7 @@ export const SeedCollection = t.Intersect([ thumbnail: t.Nullable(SeedImage), banner: t.Nullable(SeedImage), logo: t.Nullable(SeedImage), + latinName: t.Optional(Original.properties.latinName), }), ]), ), diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index c64bcdef..dfd2bd3a 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -26,11 +26,8 @@ const BaseMovie = t.Object({ runtime: t.Nullable( t.Number({ minimum: 0, description: "Runtime of the movie in minutes." }), ), - airDate: t.Nullable(t.String({ format: "date" })), - nextRefresh: t.String({ format: "date-time" }), - externalId: ExternalId(), }); diff --git a/api/src/models/serie.ts b/api/src/models/serie.ts index 252e090e..00f6eb7c 100644 --- a/api/src/models/serie.ts +++ b/api/src/models/serie.ts @@ -35,12 +35,9 @@ const BaseSerie = t.Object({ description: "Average runtime of all episodes (in minutes.)", }), ), - startAir: t.Nullable(t.String({ format: "date" })), endAir: t.Nullable(t.String({ format: "date" })), - nextRefresh: t.String({ format: "date-time" }), - externalId: ExternalId(), }); From 34926dab516449486fec093ed99b43681ce3e528 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 9 Mar 2025 16:50:20 +0100 Subject: [PATCH 16/17] Fix entries's video retrieval --- api/src/controllers/entries.ts | 10 ++++++++-- api/src/models/video.ts | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index 120cb8b2..e132b96e 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -41,6 +41,7 @@ import { sortToSql, } from "~/models/utils"; import { desc } from "~/models/utils/descriptions"; +import type { EmbeddedVideo } from "~/models/video"; const entryFilters: FilterDef = { kind: { @@ -115,9 +116,14 @@ async function getEntries({ const videosQ = db .select({ videos: coalesce( - jsonbAgg(jsonbBuildObject({ slug: entryVideoJoin.slug, ...videosCol })), + jsonbAgg( + jsonbBuildObject({ + slug: entryVideoJoin.slug, + ...videosCol, + }), + ), sql`'[]'::jsonb`, - ), + ).as("videos"), }) .from(entryVideoJoin) .where(eq(entryVideoJoin.entryPk, entries.pk)) diff --git a/api/src/models/video.ts b/api/src/models/video.ts index b01a11bd..a61644aa 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -73,7 +73,7 @@ export const Video = t.Intersect([Resource(), SeedVideo, DbMetadata]); export type Video = Prettify; // type used in entry responses -export const EmbeddedVideo = t.Omit(Video, ["createdAt", "updatedAt"]); +export const EmbeddedVideo = t.Omit(Video, ["guess", "createdAt", "updatedAt"]); export type EmbeddedVideo = Prettify; registerExamples(Video, bubbleVideo); From 4b46963effea52726176300d7f39f7e3231b8bc5 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 9 Mar 2025 18:13:30 +0100 Subject: [PATCH 17/17] Rework /studios/:id to use relational query --- api/src/controllers/shows/logic.ts | 6 +- api/src/controllers/studios.ts | 168 ++++++++++++++++++----------- api/src/models/studio.ts | 1 + api/src/models/utils/language.ts | 16 --- api/src/models/utils/original.ts | 2 +- api/src/models/utils/relations.ts | 4 +- api/tests/movies/get-movie.test.ts | 2 +- 7 files changed, 114 insertions(+), 85 deletions(-) diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index 23eda8e7..d32740ed 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -89,7 +89,7 @@ const showRelations = { .from(studioTranslations) .orderBy( studioTranslations.pk, - sql`array_position(${sqlarr(languages)}, ${studioTranslations.language}`, + sql`array_position(${sqlarr(languages)}, ${studioTranslations.language})`, ) .as("t"); const { pk, language, ...studioTrans } = getColumns(studioTransQ); @@ -158,13 +158,11 @@ export async function getShows({ sql`array_position(${sqlarr(languages)}, ${showTranslations.language})`, ) .as("t"); - const { pk, ...transCol } = getColumns(transQ); return await db .select({ ...getColumns(shows), - ...transCol, - lanugage: transQ.language, + ...getColumns(transQ), // movie columns (status is only a typescript hint) status: sql`${shows.status}`, diff --git a/api/src/controllers/studios.ts b/api/src/controllers/studios.ts index a9e9580c..68b3a3d3 100644 --- a/api/src/controllers/studios.ts +++ b/api/src/controllers/studios.ts @@ -1,4 +1,5 @@ -import { and, eq, exists, sql } from "drizzle-orm"; +import type { StaticDecode } from "@sinclair/typebox"; +import { type SQL, and, eq, exists, sql } from "drizzle-orm"; import Elysia, { t } from "elysia"; import { db } from "~/db"; import { @@ -7,7 +8,12 @@ import { studioTranslations, studios, } from "~/db/schema"; -import { getColumns, sqlarr } from "~/db/utils"; +import { + getColumns, + jsonbBuildObject, + jsonbObjectAgg, + sqlarr, +} from "~/db/utils"; import { KError } from "~/models/error"; import { Movie } from "~/models/movie"; import { Serie } from "~/models/serie"; @@ -18,11 +24,11 @@ import { Filter, Page, Sort, + buildRelations, createPage, isUuid, keysetPaginate, processLanguages, - selectTranslationQuery, sortToSql, } from "~/models/utils"; import { desc } from "~/models/utils/descriptions"; @@ -30,6 +36,83 @@ import { getShows, showFilters, showSort } from "./shows/logic"; const studioSort = Sort(["slug", "createdAt"], { default: ["slug"] }); +const studioRelations = { + translations: () => { + const { pk, language, ...trans } = getColumns(studioTranslations); + return db + .select({ + json: jsonbObjectAgg( + language, + jsonbBuildObject(trans), + ).as("json"), + }) + .from(studioTranslations) + .where(eq(studioTranslations.pk, shows.pk)) + .as("translations"); + }, +}; + +export async function getStudios({ + after, + limit, + query, + sort, + filter, + languages, + fallbackLanguage = true, + relations = [], +}: { + after?: string; + limit: number; + query?: string; + sort?: StaticDecode; + filter?: SQL; + languages: string[]; + fallbackLanguage?: boolean; + preferOriginal?: boolean; + relations?: (keyof typeof studioRelations)[]; +}) { + const transQ = db + .selectDistinctOn([studioTranslations.pk]) + .from(studioTranslations) + .where( + !fallbackLanguage + ? eq(studioTranslations.language, sql`any(${sqlarr(languages)})`) + : undefined, + ) + .orderBy( + studioTranslations.pk, + sql`array_position(${sqlarr(languages)}, ${studioTranslations.language})`, + ) + .as("t"); + + return await db + .select({ + ...getColumns(studios), + ...getColumns(transQ), + ...buildRelations(relations, studioRelations), + }) + .from(studios) + [fallbackLanguage ? "innerJoin" : ("leftJoin" as "innerJoin")]( + transQ, + eq(studios.pk, transQ.pk), + ) + .where( + and( + filter, + query ? sql`${transQ.name} %> ${query}::text` : undefined, + keysetPaginate({ table: studios, after, sort }), + ), + ) + .orderBy( + ...(query + ? [sql`word_similarity(${query}::text, ${transQ.name})`] + : sortToSql(sort, studios)), + studios.pk, + ) + .limit(limit); +} + export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] }) .model({ studio: Studio, @@ -45,21 +128,12 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] }) set, }) => { const langs = processLanguages(languages); - const ret = await db.query.studios.findFirst({ - where: isUuid(id) ? eq(studios.id, id) : eq(studios.slug, id), - with: { - selectedTranslation: selectTranslationQuery( - studioTranslations, - langs, - ), - ...(relations.includes("translations") && { - translations: { - columns: { - pk: false, - }, - }, - }), - }, + const [ret] = await getStudios({ + limit: 1, + filter: isUuid(id) ? eq(studios.id, id) : eq(studios.slug, id), + languages: langs, + fallbackLanguage: langs.includes("*"), + relations, }); if (!ret) { return error(404, { @@ -67,20 +141,14 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] }) message: `No studio with the id or slug: '${id}'`, }); } - const tr = ret.selectedTranslation[0]; - set.headers["content-language"] = tr.language; - return { - ...ret, - ...tr, - ...(ret.translations && { - translations: Object.fromEntries( - ret.translations.map( - ({ language, ...translation }) => - [language, translation] as const, - ), - ), - }), - }; + if (!ret.language) { + return error(422, { + status: 422, + message: "Accept-Language header could not be satisfied.", + }); + } + set.headers["content-language"] = ret.language; + return ret; }, { detail: { @@ -150,35 +218,13 @@ export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] }) request: { url }, }) => { const langs = processLanguages(languages); - const transQ = db - .selectDistinctOn([studioTranslations.pk]) - .from(studioTranslations) - .orderBy( - studioTranslations.pk, - sql`array_position(${sqlarr(langs)}, ${studioTranslations.language}`, - ) - .as("t"); - const { pk, ...transCol } = getColumns(transQ); - - const items = await db - .select({ - ...getColumns(studios), - ...transCol, - }) - .from(studios) - .where( - and( - query ? sql`${transQ.name} %> ${query}::text` : undefined, - keysetPaginate({ table: studios, after, sort }), - ), - ) - .orderBy( - ...(query - ? [sql`word_similarity(${query}::text, ${transQ.name})`] - : sortToSql(sort, studios)), - studios.pk, - ) - .limit(limit); + const items = await getStudios({ + limit, + after, + query, + sort, + languages: langs, + }); return createPage(items, { url, sort, limit }); }, { diff --git a/api/src/models/studio.ts b/api/src/models/studio.ts index 53d7ccdb..c7b8beff 100644 --- a/api/src/models/studio.ts +++ b/api/src/models/studio.ts @@ -12,6 +12,7 @@ export const StudioTranslation = t.Object({ name: t.String(), logo: t.Nullable(Image), }); +export type StudioTranslation = typeof StudioTranslation.static; export const Studio = t.Intersect([ Resource(), diff --git a/api/src/models/utils/language.ts b/api/src/models/utils/language.ts index 2f902d63..0de608b0 100644 --- a/api/src/models/utils/language.ts +++ b/api/src/models/utils/language.ts @@ -107,19 +107,3 @@ export const AcceptLanguage = ({ ` : ""), }); - -export const selectTranslationQuery = ( - translationTable: Table & { language: Column }, - languages: string[], -) => ({ - columns: { - pk: false, - } as const, - where: !languages.includes("*") - ? eq(translationTable.language, sql`any(${sqlarr(languages)})`) - : undefined, - orderBy: [ - sql`array_position(${sqlarr(languages)}, ${translationTable.language})`, - ], - limit: 1, -}); diff --git a/api/src/models/utils/original.ts b/api/src/models/utils/original.ts index 5e51b11f..e7f18821 100644 --- a/api/src/models/utils/original.ts +++ b/api/src/models/utils/original.ts @@ -5,7 +5,7 @@ import { Language } from "./language"; export const Original = t.Object({ language: Language({ description: "The language code this was made in.", - examples: ["ja"] + examples: ["ja"], }), name: t.String({ description: "The name in the original language", diff --git a/api/src/models/utils/relations.ts b/api/src/models/utils/relations.ts index 1e5088a7..2b800024 100644 --- a/api/src/models/utils/relations.ts +++ b/api/src/models/utils/relations.ts @@ -8,12 +8,12 @@ export const buildRelations = < >( enabled: R[], relations: Rel, - params: P, + params?: P, ) => { // we wrap that in a sql`` instead of using the builder because of this issue // https://github.com/drizzle-team/drizzle-orm/pull/1674 return Object.fromEntries( - enabled.map((x) => [x, sql`${relations[x](params)}`]), + enabled.map((x) => [x, sql`${relations[x](params!)}`]), ) as { [P in R]?: SQL< ReturnType["_"]["selectedFields"] extends { diff --git a/api/tests/movies/get-movie.test.ts b/api/tests/movies/get-movie.test.ts index 687e0dd8..4b5d6252 100644 --- a/api/tests/movies/get-movie.test.ts +++ b/api/tests/movies/get-movie.test.ts @@ -11,7 +11,7 @@ beforeAll(async () => { await db.delete(shows); await db.insert(videos).values(bubbleVideo); const [ret, body] = await createMovie(bubble); - expect(ret.status).toBe(201) + expect(ret.status).toBe(201); bubbleId = body.id; });