diff --git a/api/README.md b/api/README.md index eb50f915..709637a5 100644 --- a/api/README.md +++ b/api/README.md @@ -8,7 +8,7 @@ The many-to-many relation between entries (episodes/movies) & videos is NOT a mi erDiagram shows { guid id PK - kind kind "serie|movie" + kind kind "serie|movie|collection" string(128) slug UK genre[] genres int rating "From 0 to 100" @@ -20,6 +20,7 @@ erDiagram jsonb external_id guid studio_id FK string original_language + guid collection_id FK } show_translations { guid id PK, FK @@ -37,6 +38,7 @@ erDiagram } shows ||--|{ show_translations : has shows |o--|| entries : has + shows |o--|| shows : has_collection entries { guid id PK @@ -70,23 +72,6 @@ erDiagram } video }|--|{ entries : for - collections { - guid id PK - string(256) slug UK - datetime added_date - datetime next_refresh - } - - collection_translations { - guid id PK, FK - string language PK - string name "NN" - jsonb poster - jsonb thumbnail - } - collections ||--|{ collection_translations : has - collections |o--|{ shows : has - seasons { guid id PK string(256) slug UK diff --git a/api/drizzle/0009_collections.sql b/api/drizzle/0009_collections.sql new file mode 100644 index 00000000..b6599877 --- /dev/null +++ b/api/drizzle/0009_collections.sql @@ -0,0 +1,3 @@ +ALTER TYPE "kyoo"."show_kind" ADD VALUE 'collection';--> statement-breakpoint +ALTER TABLE "kyoo"."shows" ADD COLUMN "collection_pk" integer;--> statement-breakpoint +ALTER TABLE "kyoo"."shows" ADD CONSTRAINT "shows_collection_pk_shows_pk_fk" FOREIGN KEY ("collection_pk") REFERENCES "kyoo"."shows"("pk") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/api/drizzle/meta/0009_snapshot.json b/api/drizzle/meta/0009_snapshot.json new file mode 100644 index 00000000..e2783cdb --- /dev/null +++ b/api/drizzle/meta/0009_snapshot.json @@ -0,0 +1,969 @@ +{ + "id": "7a04670c-5fb9-4535-b6be-dc291b8b0b09", + "prevId": "5c17dd71-409a-4c80-870d-f12386676738", + "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()" + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "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": {}, + "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": {}, + "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()" + }, + "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": {} + } + }, + "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_language": { + "name": "original_language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "collection_pk": { + "name": "collection_pk", + "type": "integer", + "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()" + }, + "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.entry_video_join": { + "name": "entry_video_join", + "schema": "kyoo", + "columns": { + "entry": { + "name": "entry", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "video": { + "name": "video", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "entry_video_join_entry_entries_pk_fk": { + "name": "entry_video_join_entry_entries_pk_fk", + "tableFrom": "entry_video_join", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["entry"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entry_video_join_video_videos_pk_fk": { + "name": "entry_video_join_video_videos_pk_fk", + "tableFrom": "entry_video_join", + "tableTo": "videos", + "schemaTo": "kyoo", + "columnsFrom": ["video"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entry_video_join_entry_video_pk": { + "name": "entry_video_join_entry_video_pk", + "columns": ["entry", "video"] + } + }, + "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, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "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 ea8303e2..28c40aaf 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1738064522937, "tag": "0008_entries", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1740872363604, + "tag": "0009_collections", + "breakpoints": true } ] } diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index 83f209ac..86eb0b52 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -1,5 +1,6 @@ import { type SQL, and, eq, exists, sql } from "drizzle-orm"; import { Elysia, t } from "elysia"; +import { db } from "~/db"; import { entries, entryVideoJoin, showTranslations, shows } from "~/db/schema"; import { getColumns, sqlarr } from "~/db/utils"; import { KError } from "~/models/error"; @@ -25,7 +26,6 @@ import { sortToSql, } from "~/models/utils"; import { desc } from "~/models/utils/descriptions"; -import { db } from "../db"; const movieFilters: FilterDef = { genres: { diff --git a/api/src/controllers/seed/insert/collection.ts b/api/src/controllers/seed/insert/collection.ts new file mode 100644 index 00000000..a93fa443 --- /dev/null +++ b/api/src/controllers/seed/insert/collection.ts @@ -0,0 +1,69 @@ +import { sql } from "drizzle-orm"; +import { db } from "~/db"; +import { showTranslations, shows } from "~/db/schema"; +import { conflictUpdateAllExcept } from "~/db/utils"; +import type { SeedCollection } from "~/models/collections"; +import type { SeedMovie } from "~/models/movie"; +import type { SeedSerie } from "~/models/serie"; +import { processOptImage } from "../images"; + +type ShowTrans = typeof showTranslations.$inferInsert; + +export const insertCollection = async ( + collection: SeedCollection | undefined, + show: (({ kind: "movie" } & SeedMovie) | ({ kind: "serie" } & SeedSerie)) & { + nextRefresh: string; + }, +) => { + if (!collection) return null; + const { translations, ...col } = collection; + + return await db.transaction(async (tx) => { + const [ret] = await tx + .insert(shows) + .values({ + kind: "collection", + status: "unknown", + startAir: show.kind === "movie" ? show.airDate : show.startAir, + endAir: show.kind === "movie" ? show.airDate : show.endAir, + nextRefresh: show.nextRefresh, + ...col, + }) + .onConflictDoUpdate({ + target: shows.slug, + set: { + ...conflictUpdateAllExcept(shows, [ + "pk", + "id", + "slug", + "createdAt", + "startAir", + "endAir", + ]), + startAir: sql`least(${shows.startAir}, excluded.start_air)`, + endAir: sql`greatest(${shows.endAir}, excluded.end_air)`, + }, + }) + .returning({ pk: shows.pk, id: shows.id, slug: shows.slug }); + + const trans: ShowTrans[] = Object.entries(translations).map( + ([lang, tr]) => ({ + pk: ret.pk, + language: lang, + ...tr, + poster: processOptImage(tr.poster), + thumbnail: processOptImage(tr.thumbnail), + logo: processOptImage(tr.logo), + banner: processOptImage(tr.banner), + }), + ); + await tx + .insert(showTranslations) + .values(trans) + .onConflictDoUpdate({ + target: [showTranslations.pk, showTranslations.language], + set: conflictUpdateAllExcept(showTranslations, ["pk", "language"]), + }); + return ret; + }); +}; diff --git a/api/src/controllers/seed/insert/shows.ts b/api/src/controllers/seed/insert/shows.ts index 8986e33e..48e14d62 100644 --- a/api/src/controllers/seed/insert/shows.ts +++ b/api/src/controllers/seed/insert/shows.ts @@ -2,6 +2,7 @@ import { eq, sql } from "drizzle-orm"; import { db } from "~/db"; import { showTranslations, shows } from "~/db/schema"; import { conflictUpdateAllExcept } from "~/db/utils"; +import type { SeedCollection } from "~/models/collections"; import type { SeedMovie } from "~/models/movie"; import type { SeedSerie } from "~/models/serie"; import { getYear } from "~/utils"; @@ -12,7 +13,10 @@ type ShowTrans = typeof showTranslations.$inferInsert; export const insertShow = async ( show: Show, - translations: SeedMovie["translations"] | SeedSerie["translations"], + translations: + | SeedMovie["translations"] + | SeedSerie["translations"] + | SeedCollection["translations"], ) => { return await db.transaction(async (tx) => { const ret = await insertBaseShow(tx, show); @@ -77,13 +81,14 @@ async function insertBaseShow( // if at this point ret is still undefined, we could not reconciliate. // simply bail and let the caller handle this. - const [{ id }] = await db - .select({ id: shows.id }) + const [{ pk, id }] = await db + .select({ pk: shows.pk, id: shows.id }) .from(shows) .where(eq(shows.slug, show.slug)) .limit(1); return { status: 409 as const, + pk, id, slug: show.slug, }; diff --git a/api/src/controllers/seed/movies.ts b/api/src/controllers/seed/movies.ts index eeab21f5..968461d2 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 { insertCollection } from "./insert/collection"; import { insertEntries } from "./insert/entries"; import { insertShow } from "./insert/shows"; import { guessNextRefresh } from "./refresh"; @@ -11,6 +12,12 @@ export const SeedMovieResponse = t.Object({ videos: t.Array( t.Object({ slug: t.String({ format: "slug", examples: ["bubble-v2"] }) }), ), + collection: t.Nullable( + t.Object({ + id: t.String({ format: "uuid" }), + slug: t.String({ format: "slug", examples: ["sawano-collection"] }), + }), + ), }); export type SeedMovieResponse = typeof SeedMovieResponse.static; @@ -31,14 +38,21 @@ export const seedMovie = async ( seed.slug = `random-${getYear(seed.airDate)}`; } - const { translations, videos, ...bMovie } = seed; + const { translations, videos, collection, ...bMovie } = seed; const nextRefresh = guessNextRefresh(bMovie.airDate ?? new Date()); + const col = await insertCollection(collection, { + kind: "movie", + nextRefresh, + ...seed, + }); + const show = await insertShow( { kind: "movie", startAir: bMovie.airDate, nextRefresh, + collectionPk: col?.pk, ...bMovie, }, translations, @@ -65,5 +79,6 @@ export const seedMovie = async ( id: show.id, slug: show.slug, videos: entry.videos, + collection: col, }; }; diff --git a/api/src/controllers/seed/series.ts b/api/src/controllers/seed/series.ts index c8c2667d..53e9e777 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 { insertCollection } from "./insert/collection"; import { insertEntries } from "./insert/entries"; import { insertSeasons } from "./insert/seasons"; import { insertShow } from "./insert/shows"; @@ -35,6 +36,15 @@ export const SeedSerieResponse = t.Object({ slug: t.String({ format: "slug", examples: ["made-in-abyss-s1e1"] }), }), ), + collection: t.Nullable( + t.Object({ + id: t.String({ format: "uuid" }), + slug: t.String({ + format: "slug", + examples: ["made-in-abyss-collection"], + }), + }), + ), }); export type SeedSerieResponse = typeof SeedSerieResponse.static; @@ -55,13 +65,20 @@ export const seedSerie = async ( seed.slug = `random-${getYear(seed.startAir)}`; } - const { translations, seasons, entries, extras, ...serie } = seed; + const { translations, seasons, entries, extras, collection, ...serie } = seed; const nextRefresh = guessNextRefresh(serie.startAir ?? new Date()); + const col = await insertCollection(collection, { + kind: "serie", + nextRefresh, + ...seed, + }); + const show = await insertShow( { kind: "serie", nextRefresh, + collectionPk: col?.pk, ...serie, }, translations, @@ -82,5 +99,6 @@ export const seedSerie = async ( seasons: retSeasons, entries: retEntries, extras: retExtras, + collection: col, }; }; diff --git a/api/src/db/schema/shows.ts b/api/src/db/schema/shows.ts index 6745eed0..744f0554 100644 --- a/api/src/db/schema/shows.ts +++ b/api/src/db/schema/shows.ts @@ -1,5 +1,6 @@ import { relations, sql } from "drizzle-orm"; import { + type AnyPgColumn, check, date, index, @@ -16,7 +17,11 @@ import { entries } from "./entries"; import { seasons } from "./seasons"; import { image, language, schema } from "./utils"; -export const showKind = schema.enum("show_kind", ["serie", "movie"]); +export const showKind = schema.enum("show_kind", [ + "serie", + "movie", + "collection", +]); export const showStatus = schema.enum("show_status", [ "unknown", "finished", @@ -78,6 +83,10 @@ export const shows = schema.table( endAir: date(), originalLanguage: language(), + collectionPk: integer().references((): AnyPgColumn => shows.pk, { + onDelete: "set null", + }), + externalId: externalid(), createdAt: timestamp({ withTimezone: true, mode: "string" }) diff --git a/api/src/models/collections.ts b/api/src/models/collections.ts new file mode 100644 index 00000000..db393f3e --- /dev/null +++ b/api/src/models/collections.ts @@ -0,0 +1,83 @@ +import { t } from "elysia"; +import type { Prettify } from "elysia/dist/types"; +import { bubbleImages, duneCollection, registerExamples } from "./examples"; +import { + ExternalId, + Genre, + Image, + Resource, + SeedImage, + TranslationRecord, +} from "./utils"; + +const BaseCollection = t.Object({ + genres: t.Array(Genre), + rating: t.Nullable(t.Integer({ minimum: 0, maximum: 100 })), + startAir: t.Nullable( + t.String({ + format: "date", + descrpition: "Date of the first item of the collection", + }), + ), + endAir: t.Nullable( + t.String({ + format: "date", + descrpition: "Date of the last item of the collection", + }), + ), + + createdAt: t.String({ format: "date-time" }), + nextRefresh: t.String({ format: "date-time" }), + + externalId: ExternalId, +}); + +export const CollectionTranslation = t.Object({ + name: t.String(), + description: t.Nullable(t.String()), + tagline: t.Nullable(t.String()), + aliases: t.Array(t.String()), + tags: t.Array(t.String()), + + poster: t.Nullable(Image), + thumbnail: t.Nullable(Image), + banner: t.Nullable(Image), + logo: t.Nullable(Image), +}); + +export const Collection = t.Intersect([ + Resource(), + CollectionTranslation, + BaseCollection, +]); +export type Collection = Prettify; + +export const SeedCollection = t.Intersect([ + t.Omit(BaseCollection, ["startAir", "endAir", "createdAt", "nextRefresh"]), + t.Object({ + slug: t.String({ format: "slug" }), + translations: TranslationRecord( + t.Intersect([ + t.Omit(CollectionTranslation, [ + "poster", + "thumbnail", + "banner", + "logo", + ]), + t.Object({ + poster: t.Nullable(SeedImage), + thumbnail: t.Nullable(SeedImage), + banner: t.Nullable(SeedImage), + logo: t.Nullable(SeedImage), + }), + ]), + ), + }), +]); +export type SeedCollection = Prettify; + +registerExamples(Collection, { + ...duneCollection, + ...duneCollection.translations.en, + ...bubbleImages, +}); diff --git a/api/src/models/examples/dune-2021.ts b/api/src/models/examples/dune-2021.ts index 64f5985f..fa2b017f 100644 --- a/api/src/models/examples/dune-2021.ts +++ b/api/src/models/examples/dune-2021.ts @@ -1,5 +1,5 @@ -import type { SeedMovie } from "../movie"; -import type { Video } from "../video"; +import type { SeedMovie } from "~/models/movie"; +import type { Video } from "~/models/video"; export const duneVideo: Video = { id: "c9a0d02e-6b8e-4ac1-b431-45b022ec0708", @@ -38,8 +38,8 @@ export const dune: SeedMovie = { originalLanguage: "en", externalId: { themoviedatabase: { - dataId: "496243", - link: "https://www.themoviedb.org/movie/496243", + dataId: "438631", + link: "https://www.themoviedb.org/movie/438631-dune", }, imdb: { dataId: "tt1160419", diff --git a/api/src/models/examples/dune-collection.ts b/api/src/models/examples/dune-collection.ts new file mode 100644 index 00000000..c0b636aa --- /dev/null +++ b/api/src/models/examples/dune-collection.ts @@ -0,0 +1,29 @@ +import type { SeedCollection } from "~/models/collections"; + +export const duneCollection: SeedCollection = { + slug: "dune-collection", + translations: { + en: { + name: " Dune Collection", + tagline: "A mythic and emotionally charged hero's journey.", + description: + "The saga of Paul Atreides and his rise to power on the deadly planet Arrakis.", + aliases: [], + tags: ["sci-fi", "adventure", "drama", "action", "epic"], + poster: + "https://image.tmdb.org/t/p/original/wD57HqZ6fXwwDdfQLo4hXLRwGV1.jpg", + thumbnail: + "https://image.tmdb.org/t/p/original/k2ocXnNkmvE6rJomRkExIStFq3v.jpg", + banner: null, + logo: "https://image.tmdb.org/t/p/original/5nDsd3u1c6kDphbtIqkHseLg7HL.png", + }, + }, + genres: ["adventure", "science-fiction"], + rating: 80, + externalId: { + themoviedatabase: { + dataId: "726871", + link: "https://www.themoviedb.org/collection/726871-dune-collection", + }, + }, +}; diff --git a/api/src/models/examples/index.ts b/api/src/models/examples/index.ts index 2fe6e6a8..7bc7a3ba 100644 --- a/api/src/models/examples/index.ts +++ b/api/src/models/examples/index.ts @@ -32,3 +32,7 @@ export const registerExamples = ( export * from "./bubble"; export * from "./made-in-abyss"; +export * from "./dune-1984"; +export * from "./dune-2021"; +export * from "./dune-collection"; +export * from "./others"; diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index a6630fb7..d0c0aa7f 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -1,5 +1,6 @@ import { t } from "elysia"; import type { Prettify } from "~/utils"; +import { SeedCollection } from "./collections"; import { bubble, registerExamples } from "./examples"; import { bubbleImages } from "./examples/bubble"; import { @@ -85,6 +86,7 @@ export const SeedMovie = t.Intersect([ ]), ), videos: t.Optional(t.Array(t.String({ format: "uuid" }))), + collection: t.Optional(SeedCollection), }), ]); export type SeedMovie = Prettify; diff --git a/api/src/models/serie.ts b/api/src/models/serie.ts index 7344a9c3..dafeeefe 100644 --- a/api/src/models/serie.ts +++ b/api/src/models/serie.ts @@ -1,4 +1,6 @@ import { t } from "elysia"; +import type { Prettify } from "~/utils"; +import { SeedCollection } from "./collections"; import { SeedEntry, SeedExtra } from "./entry"; import { bubbleImages, madeInAbyss, registerExamples } from "./examples"; import { SeedSeason } from "./season"; @@ -57,7 +59,7 @@ export const SerieTranslation = t.Object({ export type SerieTranslation = typeof SerieTranslation.static; export const Serie = t.Intersect([Resource(), SerieTranslation, BaseSerie]); -export type Serie = typeof Serie.static; +export type Serie = Prettify; export const SeedSerie = t.Intersect([ t.Omit(BaseSerie, ["createdAt", "nextRefresh"]), @@ -77,6 +79,7 @@ export const SeedSerie = t.Intersect([ seasons: t.Array(SeedSeason), entries: t.Array(SeedEntry), extras: t.Optional(t.Array(SeedExtra)), + collection: t.Optional(SeedCollection), }), ]); export type SeedSerie = typeof SeedSerie.static; diff --git a/api/tests/collection/seed-collection.test.ts b/api/tests/collection/seed-collection.test.ts new file mode 100644 index 00000000..b1946813 --- /dev/null +++ b/api/tests/collection/seed-collection.test.ts @@ -0,0 +1,24 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import { createMovie } from "tests/helpers"; +import { expectStatus } from "tests/utils"; +import { db } from "~/db"; +import { shows } from "~/db/schema"; +import { dune } from "~/models/examples/dune-2021"; +import { duneCollection } from "~/models/examples/dune-collection"; + +beforeAll(async () => { + await db.delete(shows); +}); + +describe("Collection seeding", () => { + it("Can create a movie with a collection", async () => { + const [resp, body] = await createMovie({ + ...dune, + collection: duneCollection, + }); + expectStatus(resp, body).toBe(201); + expect(body.id).toBeString(); + expect(body.slug).toBe("dune"); + expect(body.collection.slug).toBe("dune-collection"); + }); +});