diff --git a/api/README.md b/api/README.md index 709637a5..df7a1dcf 100644 --- a/api/README.md +++ b/api/README.md @@ -6,159 +6,168 @@ The many-to-many relation between entries (episodes/movies) & videos is NOT a mi ```mermaid erDiagram - shows { - guid id PK - kind kind "serie|movie|collection" - string(128) slug UK - genre[] genres - int rating "From 0 to 100" - status status "NN" - datetime added_date - date start_air - date end_air "null for movies" - datetime next_refresh - jsonb external_id - guid studio_id FK - string original_language + shows { + guid id PK + kind kind "serie|movie|collection" + string(128) slug UK + genre[] genres + int rating "From 0 to 100" + status status "NN" + datetime added_date + date start_air + date end_air "null for movies" + datetime next_refresh + jsonb external_id + guid studio_id FK + string original_language guid collection_id FK - } - show_translations { - guid id PK, FK - string language PK - string name "NN" - string tagline - string[] aliases - string description - string[] tags - string trailerUrl - jsonb poster - jsonb banner - jsonb logo - jsonb thumbnail - } - shows ||--|{ show_translations : has - shows |o--|| entries : has - shows |o--|| shows : has_collection + } + show_translations { + guid id PK, FK + string language PK + string name "NN" + string tagline + string[] aliases + string description + string[] tags + string trailerUrl + jsonb poster + jsonb banner + jsonb logo + jsonb thumbnail + } + shows ||--|{ show_translations : has + shows |o--|| entries : has + shows |o--|| shows : has_collection - entries { - guid id PK - string(256) slug UK - guid show_id FK, UK - %% Order is absolute number. - uint order "NN" - uint season_number UK - uint episode_number UK "NN" - type type "episode|movie|special|extra" - date air_date - uint runtime - jsonb thumbnail - datetime next_refresh - jsonb external_id - } - entry_translations { - guid id PK, FK - string language PK - string name - string description - } - entries ||--|{ entry_translations : has + entries { + guid id PK + string(256) slug UK + guid show_id FK, UK + %% Order is absolute number. + uint order "NN" + uint season_number UK + uint episode_number UK "NN" + type type "episode|movie|special|extra" + date air_date + uint runtime + jsonb thumbnail + datetime next_refresh + jsonb external_id + } + entry_translations { + guid id PK, FK + string language PK + string name + string description + } + entries ||--|{ entry_translations : has - video { - guid id PK - string path "NN" - uint rendering "dedup for duplicates part1/2" - uint part - uint version "max version is preferred rendering" - } - video }|--|{ entries : for + video { + guid id PK + string path "NN" + uint rendering "dedup for duplicates part1/2" + uint part + uint version "max version is preferred rendering" + } + video }|--|{ entries : for - seasons { - guid id PK - string(256) slug UK - guid show_id FK - uint season_number "NN" - datetime added_date - date start_air - date end_air - datetime next_refresh - jsonb external_id - } + seasons { + guid id PK + string(256) slug UK + guid show_id FK + uint season_number "NN" + datetime added_date + date start_air + date end_air + datetime next_refresh + jsonb external_id + } - season_translations { - guid id PK,FK - string language PK - string name - string description - jsonb poster - jsonb banner - jsonb logo - jsonb thumbnail - } - seasons ||--|{ season_translations : has - seasons ||--o{ entries : has - shows ||--|{ seasons : has + season_translations { + guid id PK,FK + string language PK + string name + string description + jsonb poster + jsonb banner + jsonb logo + jsonb thumbnail + } + seasons ||--|{ season_translations : has + seasons ||--o{ entries : has + shows ||--|{ seasons : has - watched_shows { - guid show_id PK, FK - guid user_id PK, FK - status status "completed|watching|droped|planned" - uint seen_entry_count "NN" - } - shows ||--|{ watched_shows : has + users { + guid id PK + } - watched_entries { - guid entry_id PK, FK - guid user_id PK, FK - uint time "in seconds, null of finished" - uint progress "NN, from 0 to 100" - datetime played_date - } - entries ||--|{ watched_entries : has + watched_shows { + guid show_id PK, FK + guid user_id PK, FK + status status "completed|watching|dropped|planned" + uint seen_entry_count "NN" + guid next_entry FK + } + shows ||--|{ watched_shows : has + users ||--|{ watched_shows : has + watched_shows ||--|o entries : next_entry - roles { - guid show_id PK, FK - guid staff_id PK, FK - uint order - type type "actor|director|writer|producer|music|other" - jsonb character_image - } + history { + int id PK + guid entry_id FK + guid user_id FK + uint time "in seconds, null of finished" + uint progress "NN, from 0 to 100" + datetime played_date + } + entries ||--|{ history : part_of + users ||--|{ history : has - role_translations { - string language PK - string character_name - } - roles||--o{ role_translations : has - shows ||--|{ roles : has + roles { + guid show_id PK, FK + guid staff_id PK, FK + uint order + type type "actor|director|writer|producer|music|other" + jsonb character_image + } - staff { - guid id PK - string(256) slug UK - jsonb image - datetime next_refresh - jsonb external_id - } + role_translations { + string language PK + string character_name + } + roles||--o{ role_translations : has + shows ||--|{ roles : has - staff_translations { - guid id PK,FK - string language PK - string name "NN" - } - staff ||--|{ staff_translations : has - staff ||--|{ roles : has + staff { + guid id PK + string(256) slug UK + jsonb image + datetime next_refresh + jsonb external_id + } - studios { - guid id PK - string(128) slug UK - jsonb logo - datetime next_refresh - jsonb external_id - } + staff_translations { + guid id PK,FK + string language PK + string name "NN" + } + staff ||--|{ staff_translations : has + staff ||--|{ roles : has - studio_translations { - guid id PK,FK - string language PK - string name - } - studios ||--|{ studio_translations : has - shows ||--|{ studios : has + studios { + guid id PK + string(128) slug UK + jsonb logo + datetime next_refresh + jsonb external_id + } + + studio_translations { + guid id PK,FK + string language PK + string name + } + studios ||--|{ studio_translations : has + shows }|--|{ studios : has ``` diff --git a/api/drizzle/0010_studios.sql b/api/drizzle/0010_studios.sql new file mode 100644 index 00000000..10ac7ff3 --- /dev/null +++ b/api/drizzle/0010_studios.sql @@ -0,0 +1,38 @@ +CREATE TABLE "kyoo"."show_studio_join" ( + "show" integer NOT NULL, + "studio" integer NOT NULL, + CONSTRAINT "show_studio_join_show_studio_pk" PRIMARY KEY("show","studio") +); +--> statement-breakpoint +CREATE TABLE "kyoo"."studio_translations" ( + "pk" integer NOT NULL, + "language" varchar(255) NOT NULL, + "name" text NOT NULL, + "logo" jsonb, + CONSTRAINT "studio_translations_pk_language_pk" PRIMARY KEY("pk","language") +); +--> statement-breakpoint +CREATE TABLE "kyoo"."studios" ( + "pk" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "kyoo"."studios_pk_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "id" uuid DEFAULT gen_random_uuid() NOT NULL, + "slug" varchar(255) NOT NULL, + "external_id" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + CONSTRAINT "studios_id_unique" UNIQUE("id"), + CONSTRAINT "studios_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +ALTER TABLE "kyoo"."entries" ADD COLUMN "updated_at" timestamp with time zone NOT NULL;--> statement-breakpoint +ALTER TABLE "kyoo"."seasons" ADD COLUMN "updated_at" timestamp with time zone NOT NULL;--> statement-breakpoint +ALTER TABLE "kyoo"."shows" ADD COLUMN "updated_at" timestamp with time zone NOT NULL;--> statement-breakpoint +ALTER TABLE "kyoo"."videos" ADD COLUMN "updated_at" timestamp with time zone NOT NULL;--> statement-breakpoint +ALTER TABLE "kyoo"."show_studio_join" ADD CONSTRAINT "show_studio_join_show_shows_pk_fk" FOREIGN KEY ("show") REFERENCES "kyoo"."shows"("pk") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "kyoo"."show_studio_join" ADD CONSTRAINT "show_studio_join_studio_studios_pk_fk" FOREIGN KEY ("studio") REFERENCES "kyoo"."studios"("pk") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "kyoo"."studio_translations" ADD CONSTRAINT "studio_translations_pk_studios_pk_fk" FOREIGN KEY ("pk") REFERENCES "kyoo"."studios"("pk") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "studio_name_trgm" ON "kyoo"."studio_translations" USING gin ("name" gin_trgm_ops);--> statement-breakpoint +CREATE INDEX "entry_kind" ON "kyoo"."entries" USING hash ("kind");--> statement-breakpoint +CREATE INDEX "entry_order" ON "kyoo"."entries" USING btree ("order");--> statement-breakpoint +CREATE INDEX "entry_name_trgm" ON "kyoo"."entry_translations" USING gin ("name" gin_trgm_ops);--> statement-breakpoint +CREATE INDEX "season_name_trgm" ON "kyoo"."season_translations" USING gin ("name" gin_trgm_ops);--> statement-breakpoint +CREATE INDEX "season_nbr" ON "kyoo"."seasons" USING btree ("season_number"); \ No newline at end of file diff --git a/api/drizzle/0011_join_rename.sql b/api/drizzle/0011_join_rename.sql new file mode 100644 index 00000000..a806f70a --- /dev/null +++ b/api/drizzle/0011_join_rename.sql @@ -0,0 +1,20 @@ +ALTER TABLE "kyoo"."show_studio_join" RENAME COLUMN "show" TO "show_pk";--> statement-breakpoint +ALTER TABLE "kyoo"."show_studio_join" RENAME COLUMN "studio" TO "studio_pk";--> statement-breakpoint +ALTER TABLE "kyoo"."entry_video_join" RENAME COLUMN "entry" TO "entry_pk";--> statement-breakpoint +ALTER TABLE "kyoo"."entry_video_join" RENAME COLUMN "video" TO "video_pk";--> statement-breakpoint +ALTER TABLE "kyoo"."show_studio_join" DROP CONSTRAINT "show_studio_join_show_shows_pk_fk"; +--> statement-breakpoint +ALTER TABLE "kyoo"."show_studio_join" DROP CONSTRAINT "show_studio_join_studio_studios_pk_fk"; +--> statement-breakpoint +ALTER TABLE "kyoo"."entry_video_join" DROP CONSTRAINT "entry_video_join_entry_entries_pk_fk"; +--> statement-breakpoint +ALTER TABLE "kyoo"."entry_video_join" DROP CONSTRAINT "entry_video_join_video_videos_pk_fk"; +--> statement-breakpoint +ALTER TABLE "kyoo"."show_studio_join" DROP CONSTRAINT "show_studio_join_show_studio_pk";--> statement-breakpoint +ALTER TABLE "kyoo"."entry_video_join" DROP CONSTRAINT "entry_video_join_entry_video_pk";--> statement-breakpoint +ALTER TABLE "kyoo"."show_studio_join" ADD CONSTRAINT "show_studio_join_show_pk_studio_pk_pk" PRIMARY KEY("show_pk","studio_pk");--> statement-breakpoint +ALTER TABLE "kyoo"."entry_video_join" ADD CONSTRAINT "entry_video_join_entry_pk_video_pk_pk" PRIMARY KEY("entry_pk","video_pk");--> statement-breakpoint +ALTER TABLE "kyoo"."show_studio_join" ADD CONSTRAINT "show_studio_join_show_pk_shows_pk_fk" FOREIGN KEY ("show_pk") REFERENCES "kyoo"."shows"("pk") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "kyoo"."show_studio_join" ADD CONSTRAINT "show_studio_join_studio_pk_studios_pk_fk" FOREIGN KEY ("studio_pk") REFERENCES "kyoo"."studios"("pk") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "kyoo"."entry_video_join" ADD CONSTRAINT "entry_video_join_entry_pk_entries_pk_fk" FOREIGN KEY ("entry_pk") REFERENCES "kyoo"."entries"("pk") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "kyoo"."entry_video_join" ADD CONSTRAINT "entry_video_join_video_pk_videos_pk_fk" FOREIGN KEY ("video_pk") REFERENCES "kyoo"."videos"("pk") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/api/drizzle/meta/0010_snapshot.json b/api/drizzle/meta/0010_snapshot.json new file mode 100644 index 00000000..4a27520a --- /dev/null +++ b/api/drizzle/meta/0010_snapshot.json @@ -0,0 +1,1265 @@ +{ + "id": "8aedfd6c-095c-496f-a2fb-369b4c7d1911", + "prevId": "7a04670c-5fb9-4535-b6be-dc291b8b0b09", + "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_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()" + }, + "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": { + "name": "show", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "studio": { + "name": "studio", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "show_studio_join_show_shows_pk_fk": { + "name": "show_studio_join_show_shows_pk_fk", + "tableFrom": "show_studio_join", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "show_studio_join_studio_studios_pk_fk": { + "name": "show_studio_join_studio_studios_pk_fk", + "tableFrom": "show_studio_join", + "tableTo": "studios", + "schemaTo": "kyoo", + "columnsFrom": ["studio"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "show_studio_join_show_studio_pk": { + "name": "show_studio_join_show_studio_pk", + "columns": ["show", "studio"] + } + }, + "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": { + "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()" + }, + "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/0011_snapshot.json b/api/drizzle/meta/0011_snapshot.json new file mode 100644 index 00000000..414d7975 --- /dev/null +++ b/api/drizzle/meta/0011_snapshot.json @@ -0,0 +1,1265 @@ +{ + "id": "6237922f-c7f2-4e4f-9206-acccdd2009a7", + "prevId": "8aedfd6c-095c-496f-a2fb-369b4c7d1911", + "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_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()" + }, + "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, + "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": { + "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 28c40aaf..91c1bbb1 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -71,6 +71,20 @@ "when": 1740872363604, "tag": "0009_collections", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1740950531468, + "tag": "0010_studios", + "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1741014917375, + "tag": "0011_join_rename", + "breakpoints": true } ] } diff --git a/api/src/controllers/seed/insert/entries.ts b/api/src/controllers/seed/insert/entries.ts index 4ee855e5..a3100cbf 100644 --- a/api/src/controllers/seed/insert/entries.ts +++ b/api/src/controllers/seed/insert/entries.ts @@ -141,8 +141,8 @@ export const insertEntries = async ( .select( db .select({ - entry: sql`vids.entryPk::integer`.as("entry"), - video: sql`${videos.pk}`.as("video"), + entryPk: sql`vids.entryPk::integer`.as("entry"), + videoPk: sql`${videos.pk}`.as("video"), slug: computeVideoSlug( sql`${show.slug}::text`, sql`vids.needRendering::boolean`, @@ -154,7 +154,7 @@ export const insertEntries = async ( .onConflictDoNothing() .returning({ slug: entryVideoJoin.slug, - entryPk: entryVideoJoin.entry, + entryPk: entryVideoJoin.entryPk, }); return retEntries.map((entry) => ({ diff --git a/api/src/controllers/seed/insert/studios.ts b/api/src/controllers/seed/insert/studios.ts new file mode 100644 index 00000000..291801d7 --- /dev/null +++ b/api/src/controllers/seed/insert/studios.ts @@ -0,0 +1,55 @@ +import { db } from "~/db"; +import { showStudioJoin, studioTranslations, studios } from "~/db/schema"; +import { conflictUpdateAllExcept } from "~/db/utils"; +import type { SeedStudio } from "~/models/studio"; +import { processOptImage } from "../images"; + +type StudioI = typeof studios.$inferInsert; +type StudioTransI = typeof studioTranslations.$inferInsert; + +export const insertStudios = async (seed: SeedStudio[], showPk: number) => { + if (!seed.length) return []; + + return await db.transaction(async (tx) => { + const vals: StudioI[] = seed.map((x) => { + const { translations, ...item } = x; + return item; + }); + + const ret = await tx + .insert(studios) + .values(vals) + .onConflictDoUpdate({ + target: studios.slug, + set: conflictUpdateAllExcept(studios, [ + "pk", + "id", + "slug", + "createdAt", + ]), + }) + .returning({ pk: studios.pk, id: studios.id, slug: studios.slug }); + + const trans: StudioTransI[] = seed.flatMap((x, i) => + Object.entries(x.translations).map(([lang, tr]) => ({ + pk: ret[i].pk, + language: lang, + name: tr.name, + logo: processOptImage(tr.logo), + })), + ); + await tx + .insert(studioTranslations) + .values(trans) + .onConflictDoUpdate({ + target: [studioTranslations.pk, studioTranslations.language], + set: conflictUpdateAllExcept(studioTranslations, ["pk", "language"]), + }); + + await tx + .insert(showStudioJoin) + .values(ret.map((studio) => ({ showPk: showPk, studioPk: studio.pk }))) + .onConflictDoNothing(); + return ret; + }); +}; diff --git a/api/src/controllers/seed/movies.ts b/api/src/controllers/seed/movies.ts index 968461d2..f3efef96 100644 --- a/api/src/controllers/seed/movies.ts +++ b/api/src/controllers/seed/movies.ts @@ -4,6 +4,7 @@ import { getYear } from "~/utils"; import { insertCollection } from "./insert/collection"; import { insertEntries } from "./insert/entries"; import { insertShow } from "./insert/shows"; +import { insertStudios } from "./insert/studios"; import { guessNextRefresh } from "./refresh"; export const SeedMovieResponse = t.Object({ @@ -18,6 +19,12 @@ export const SeedMovieResponse = t.Object({ slug: t.String({ format: "slug", examples: ["sawano-collection"] }), }), ), + studios: t.Array( + t.Object({ + id: t.String({ format: "uuid" }), + slug: t.String({ format: "slug", examples: ["disney"] }), + }), + ), }); export type SeedMovieResponse = typeof SeedMovieResponse.static; @@ -38,7 +45,7 @@ export const seedMovie = async ( seed.slug = `random-${getYear(seed.airDate)}`; } - const { translations, videos, collection, ...bMovie } = seed; + const { translations, videos, collection, studios, ...bMovie } = seed; const nextRefresh = guessNextRefresh(bMovie.airDate ?? new Date()); const col = await insertCollection(collection, { @@ -74,11 +81,14 @@ export const seedMovie = async ( }, ]); + const retStudios = await insertStudios(studios, show.pk); + return { updated: show.updated, id: show.id, slug: show.slug, videos: entry.videos, collection: col, + studios: retStudios, }; }; diff --git a/api/src/controllers/seed/series.ts b/api/src/controllers/seed/series.ts index 53e9e777..f1761b92 100644 --- a/api/src/controllers/seed/series.ts +++ b/api/src/controllers/seed/series.ts @@ -5,6 +5,7 @@ import { insertCollection } from "./insert/collection"; import { insertEntries } from "./insert/entries"; import { insertSeasons } from "./insert/seasons"; import { insertShow } from "./insert/shows"; +import { insertStudios } from "./insert/studios"; import { guessNextRefresh } from "./refresh"; export const SeedSerieResponse = t.Object({ @@ -45,6 +46,12 @@ export const SeedSerieResponse = t.Object({ }), }), ), + studios: t.Array( + t.Object({ + id: t.String({ format: "uuid" }), + slug: t.String({ format: "slug", examples: ["mappa"] }), + }), + ), }); export type SeedSerieResponse = typeof SeedSerieResponse.static; @@ -65,7 +72,15 @@ export const seedSerie = async ( seed.slug = `random-${getYear(seed.startAir)}`; } - const { translations, seasons, entries, extras, collection, ...serie } = seed; + const { + translations, + seasons, + entries, + extras, + collection, + studios, + ...serie + } = seed; const nextRefresh = guessNextRefresh(serie.startAir ?? new Date()); const col = await insertCollection(collection, { @@ -92,6 +107,8 @@ export const seedSerie = async ( (extras ?? []).map((x) => ({ ...x, kind: "extra", extraKind: x.kind })), ); + const retStudios = await insertStudios(studios, show.pk); + return { updated: show.updated, id: show.id, @@ -100,5 +117,6 @@ export const seedSerie = async ( entries: retEntries, extras: retExtras, collection: col, + studios: retStudios, }; }; diff --git a/api/src/controllers/series.ts b/api/src/controllers/series.ts deleted file mode 100644 index 6c6f65b6..00000000 --- a/api/src/controllers/series.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Elysia, t } from "elysia"; -import { Serie } from "~/models/serie"; - -export const series = new Elysia({ prefix: "/series" }) - .model({ - serie: Serie, - error: t.Object({}), - }) - .get("/:id", () => "hello" as unknown as Serie, { - response: { 200: "serie" }, - }); diff --git a/api/src/controllers/shows/collections.ts b/api/src/controllers/shows/collections.ts index bee34a2d..4ba78d66 100644 --- a/api/src/controllers/shows/collections.ts +++ b/api/src/controllers/shows/collections.ts @@ -10,6 +10,8 @@ import { import { KError } from "~/models/error"; import { duneCollection } from "~/models/examples"; import { Movie } from "~/models/movie"; +import { Serie } from "~/models/serie"; +import { Show } from "~/models/show"; import { AcceptLanguage, Filter, @@ -171,6 +173,34 @@ export const collections = new Elysia({ }, }, ) + .guard({ + params: t.Object({ + id: t.String({ + description: "The id or slug of the collection.", + example: duneCollection.slug, + }), + }), + query: t.Object({ + sort: showSort, + filter: t.Optional(Filter({ def: showFilters })), + query: t.Optional(t.String({ description: desc.query })), + limit: t.Integer({ + minimum: 1, + maximum: 250, + default: 50, + description: "Max page size.", + }), + after: t.Optional(t.String({ description: desc.after })), + preferOriginal: t.Optional( + t.Boolean({ + description: desc.preferOriginal, + }), + ), + }), + headers: t.Object({ + "accept-language": AcceptLanguage({ autoFallback: true }), + }), + }) .get( "/:id/movies", async ({ @@ -216,32 +246,6 @@ export const collections = new Elysia({ }, { detail: { description: "Get all movies in a collection" }, - params: t.Object({ - id: t.String({ - description: "The id or slug of the collection.", - example: duneCollection.slug, - }), - }), - query: t.Object({ - sort: showSort, - filter: t.Optional(Filter({ def: showFilters })), - query: t.Optional(t.String({ description: desc.query })), - limit: t.Integer({ - minimum: 1, - maximum: 250, - default: 50, - description: "Max page size.", - }), - after: t.Optional(t.String({ description: desc.after })), - preferOriginal: t.Optional( - t.Boolean({ - description: desc.preferOriginal, - }), - ), - }), - headers: t.Object({ - "accept-language": AcceptLanguage({ autoFallback: true }), - }), response: { 200: Page(Movie), 404: { @@ -297,34 +301,8 @@ export const collections = new Elysia({ }, { detail: { description: "Get all series in a collection" }, - params: t.Object({ - id: t.String({ - description: "The id or slug of the collection.", - example: duneCollection.slug, - }), - }), - query: t.Object({ - sort: showSort, - filter: t.Optional(Filter({ def: showFilters })), - query: t.Optional(t.String({ description: desc.query })), - limit: t.Integer({ - minimum: 1, - maximum: 250, - default: 50, - description: "Max page size.", - }), - after: t.Optional(t.String({ description: desc.after })), - preferOriginal: t.Optional( - t.Boolean({ - description: desc.preferOriginal, - }), - ), - }), - headers: t.Object({ - "accept-language": AcceptLanguage({ autoFallback: true }), - }), response: { - 200: Page(Movie), + 200: Page(Serie), 404: { ...KError, description: "No collection found with the given id or slug.", @@ -374,34 +352,8 @@ export const collections = new Elysia({ }, { detail: { description: "Get all series & movies in a collection" }, - params: t.Object({ - id: t.String({ - description: "The id or slug of the collection.", - example: duneCollection.slug, - }), - }), - query: t.Object({ - sort: showSort, - filter: t.Optional(Filter({ def: showFilters })), - query: t.Optional(t.String({ description: desc.query })), - limit: t.Integer({ - minimum: 1, - maximum: 250, - default: 50, - description: "Max page size.", - }), - after: t.Optional(t.String({ description: desc.after })), - preferOriginal: t.Optional( - t.Boolean({ - description: desc.preferOriginal, - }), - ), - }), - headers: t.Object({ - "accept-language": AcceptLanguage({ autoFallback: true }), - }), response: { - 200: Page(Movie), + 200: Page(Show), 404: { ...KError, description: "No collection found with the given id or slug.", diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index 5b37f15d..d2f0a840 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -1,7 +1,7 @@ import type { StaticDecode } from "@sinclair/typebox"; import { type SQL, and, eq, sql } from "drizzle-orm"; import { db } from "~/db"; -import { showTranslations, shows } from "~/db/schema"; +import { showTranslations, shows, studioTranslations } from "~/db/schema"; import { getColumns, sqlarr } from "~/db/utils"; import type { MovieStatus } from "~/models/movie"; import { SerieStatus } from "~/models/serie"; @@ -12,6 +12,7 @@ import { Sort, isUuid, keysetPaginate, + selectTranslationQuery, sortToSql, } from "~/models/utils"; @@ -130,7 +131,7 @@ export async function getShow( }: { languages: string[]; preferOriginal: boolean | undefined; - relations: ("translations" | "videos")[]; + relations: ("translations" | "studios" | "videos")[]; filters: SQL | undefined; }, ) { @@ -141,18 +142,7 @@ export async function getShow( }, where: and(isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id), filters), with: { - selectedTranslation: { - columns: { - pk: false, - }, - where: !languages.includes("*") - ? eq(showTranslations.language, sql`any(${sqlarr(languages)})`) - : undefined, - orderBy: [ - sql`array_position(${sqlarr(languages)}, ${showTranslations.language})`, - ], - limit: 1, - }, + selectedTranslation: selectTranslationQuery(showTranslations, languages), originalTranslation: { columns: { poster: true, @@ -175,6 +165,23 @@ export async function getShow( }, }, }), + ...(relations.includes("studios") && { + studios: { + with: { + studio: { + columns: { + pk: false, + }, + with: { + selectedTranslation: selectTranslationQuery( + studioTranslations, + languages, + ), + }, + }, + }, + }, + }), }, }); if (!ret) return null; @@ -184,6 +191,7 @@ export async function getShow( const show = { ...ret, ...translation, + kind: ret.kind as any, ...(ot?.preferOriginal && { ...(ot.poster && { poster: ot.poster }), ...(ot.thumbnail && { thumbnail: ot.thumbnail }), @@ -197,6 +205,12 @@ export async function getShow( ), ), }), + ...(ret.studios && { + studios: ret.studios.map((x: any) => ({ + ...x.studio, + ...x.studio.selectedTranslation[0], + })), + }), }; return { show, language: translation.language }; } diff --git a/api/src/controllers/shows/movies.ts b/api/src/controllers/shows/movies.ts index 7f023f30..44e0c0f4 100644 --- a/api/src/controllers/shows/movies.ts +++ b/api/src/controllers/shows/movies.ts @@ -65,7 +65,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) preferOriginal: t.Optional( t.Boolean({ description: desc.preferOriginal }), ), - with: t.Array(t.UnionEnum(["translations", "videos"]), { + with: t.Array(t.UnionEnum(["translations", "studios", "videos"]), { default: [], description: "Include related resources in the response.", }), diff --git a/api/src/controllers/shows/series.ts b/api/src/controllers/shows/series.ts index 3c3a371b..f84a4d5a 100644 --- a/api/src/controllers/shows/series.ts +++ b/api/src/controllers/shows/series.ts @@ -65,7 +65,7 @@ export const series = new Elysia({ prefix: "/series", tags: ["series"] }) preferOriginal: t.Optional( t.Boolean({ description: desc.preferOriginal }), ), - with: t.Array(t.UnionEnum(["translations"]), { + with: t.Array(t.UnionEnum(["translations", "studios"]), { default: [], description: "Include related resources in the response.", }), diff --git a/api/src/controllers/shows/shows.ts b/api/src/controllers/shows/shows.ts index ee0a598f..49b837dd 100644 --- a/api/src/controllers/shows/shows.ts +++ b/api/src/controllers/shows/shows.ts @@ -2,10 +2,8 @@ import { and, isNull, sql } from "drizzle-orm"; import { Elysia, t } from "elysia"; import { db } from "~/db"; import { shows } from "~/db/schema"; -import { Collection } from "~/models/collections"; import { KError } from "~/models/error"; -import { Movie } from "~/models/movie"; -import { Serie } from "~/models/serie"; +import { Show } from "~/models/show"; import { AcceptLanguage, Filter, @@ -16,8 +14,6 @@ import { import { desc } from "~/models/utils/descriptions"; import { getShows, showFilters, showSort } from "./logic"; -const Show = t.Union([Movie, Serie, Collection]); - export const showsH = new Elysia({ prefix: "/shows", tags: ["shows"] }) .model({ show: Show, diff --git a/api/src/controllers/studios.ts b/api/src/controllers/studios.ts new file mode 100644 index 00000000..a9e9580c --- /dev/null +++ b/api/src/controllers/studios.ts @@ -0,0 +1,412 @@ +import { and, eq, exists, sql } from "drizzle-orm"; +import Elysia, { t } from "elysia"; +import { db } from "~/db"; +import { + showStudioJoin, + shows, + studioTranslations, + studios, +} from "~/db/schema"; +import { getColumns, sqlarr } from "~/db/utils"; +import { KError } from "~/models/error"; +import { Movie } from "~/models/movie"; +import { Serie } from "~/models/serie"; +import { Show } from "~/models/show"; +import { Studio, StudioTranslation } from "~/models/studio"; +import { + AcceptLanguage, + Filter, + Page, + Sort, + createPage, + isUuid, + keysetPaginate, + processLanguages, + selectTranslationQuery, + sortToSql, +} from "~/models/utils"; +import { desc } from "~/models/utils/descriptions"; +import { getShows, showFilters, showSort } from "./shows/logic"; + +const studioSort = Sort(["slug", "createdAt"], { default: ["slug"] }); + +export const studiosH = new Elysia({ prefix: "/studios", tags: ["studios"] }) + .model({ + studio: Studio, + "studio-translation": StudioTranslation, + }) + .get( + "/:id", + async ({ + params: { id }, + headers: { "accept-language": languages }, + query: { with: relations }, + error, + 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, + }, + }, + }), + }, + }); + if (!ret) { + return error(404, { + status: 404, + 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, + ), + ), + }), + }; + }, + { + detail: { + description: "Get a studio by id or slug", + }, + params: t.Object({ + id: t.String({ + description: "The id or slug of the collection to retrieve.", + example: "mappa", + }), + }), + query: t.Object({ + with: t.Array(t.UnionEnum(["translations"]), { + default: [], + description: "Include related resources in the response.", + }), + }), + headers: t.Object({ + "accept-language": AcceptLanguage(), + }), + response: { + 200: "studio", + 404: { + ...KError, + description: "No collection found with the given id or slug.", + }, + 422: KError, + }, + }, + ) + .get( + "random", + async ({ error, redirect }) => { + const [studio] = await db + .select({ slug: studios.slug }) + .from(studios) + .orderBy(sql`random()`) + .limit(1); + if (!studio) + return error(404, { + status: 404, + message: "No studios in the database.", + }); + return redirect(`/studios/${studio.slug}`); + }, + { + detail: { + description: "Get a random studio.", + }, + response: { + 302: t.Void({ + description: + "Redirected to the [/studios/{id}](#tag/studios/GET/studios/{id}) route.", + }), + 404: { + ...KError, + description: "No studios in the database.", + }, + }, + }, + ) + .get( + "", + async ({ + query: { limit, after, query, sort }, + headers: { "accept-language": languages }, + 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); + return createPage(items, { url, sort, limit }); + }, + { + detail: { description: "Get all studios" }, + query: t.Object({ + sort: studioSort, + query: t.Optional(t.String({ description: desc.query })), + limit: t.Integer({ + minimum: 1, + maximum: 250, + default: 50, + description: "Max page size.", + }), + after: t.Optional(t.String({ description: desc.after })), + }), + headers: t.Object({ + "accept-language": AcceptLanguage({ autoFallback: true }), + }), + response: { + 200: Page(Studio), + 422: KError, + }, + }, + ) + .guard({ + params: t.Object({ + id: t.String({ + description: "The id or slug of the studio.", + example: "mappa", + }), + }), + query: t.Object({ + sort: showSort, + filter: t.Optional(Filter({ def: showFilters })), + query: t.Optional(t.String({ description: desc.query })), + limit: t.Integer({ + minimum: 1, + maximum: 250, + default: 50, + description: "Max page size.", + }), + after: t.Optional(t.String({ description: desc.after })), + preferOriginal: t.Optional( + t.Boolean({ + description: desc.preferOriginal, + }), + ), + }), + headers: t.Object({ + "accept-language": AcceptLanguage({ autoFallback: true }), + }), + }) + .get( + "/:id/shows", + async ({ + params: { id }, + query: { limit, after, query, sort, filter, preferOriginal }, + headers: { "accept-language": languages }, + request: { url }, + error, + }) => { + const [studio] = await db + .select({ pk: studios.pk }) + .from(studios) + .where(isUuid(id) ? eq(studios.id, id) : eq(studios.slug, id)) + .limit(1); + + if (!studio) { + return error(404, { + status: 404, + message: `No studios with the id or slug: '${id}'.`, + }); + } + + const langs = processLanguages(languages); + const items = await getShows({ + limit, + after, + query, + sort, + filter: and( + exists( + db + .select() + .from(showStudioJoin) + .where( + and( + eq(showStudioJoin.studioPk, studio.pk), + eq(showStudioJoin.showPk, shows.pk), + ), + ), + ), + filter, + ), + languages: langs, + preferOriginal, + }); + return createPage(items, { url, sort, limit }); + }, + { + detail: { description: "Get all series & movies made by a studio." }, + response: { + 200: Page(Show), + 404: { + ...KError, + description: "No collection found with the given id or slug.", + }, + 422: KError, + }, + }, + ) + .get( + "/:id/movies", + async ({ + params: { id }, + query: { limit, after, query, sort, filter, preferOriginal }, + headers: { "accept-language": languages }, + request: { url }, + error, + }) => { + const [studio] = await db + .select({ pk: studios.pk }) + .from(studios) + .where(isUuid(id) ? eq(studios.id, id) : eq(studios.slug, id)) + .limit(1); + + if (!studio) { + return error(404, { + status: 404, + message: `No studios with the id or slug: '${id}'.`, + }); + } + + const langs = processLanguages(languages); + const items = await getShows({ + limit, + after, + query, + sort, + filter: and( + eq(shows.kind, "movie"), + exists( + db + .select() + .from(showStudioJoin) + .where( + and( + eq(showStudioJoin.studioPk, studio.pk), + eq(showStudioJoin.showPk, shows.pk), + ), + ), + ), + filter, + ), + languages: langs, + preferOriginal, + }); + return createPage(items, { url, sort, limit }); + }, + { + detail: { description: "Get all movies made by a studio." }, + response: { + 200: Page(Movie), + 404: { + ...KError, + description: "No collection found with the given id or slug.", + }, + 422: KError, + }, + }, + ) + .get( + "/:id/series", + async ({ + params: { id }, + query: { limit, after, query, sort, filter, preferOriginal }, + headers: { "accept-language": languages }, + request: { url }, + error, + }) => { + const [studio] = await db + .select({ pk: studios.pk }) + .from(studios) + .where(isUuid(id) ? eq(studios.id, id) : eq(studios.slug, id)) + .limit(1); + + if (!studio) { + return error(404, { + status: 404, + message: `No studios with the id or slug: '${id}'.`, + }); + } + + const langs = processLanguages(languages); + const items = await getShows({ + limit, + after, + query, + sort, + filter: and( + eq(shows.kind, "serie"), + exists( + db + .select() + .from(showStudioJoin) + .where( + and( + eq(showStudioJoin.studioPk, studio.pk), + eq(showStudioJoin.showPk, shows.pk), + ), + ), + ), + filter, + ), + languages: langs, + preferOriginal, + }); + return createPage(items, { url, sort, limit }); + }, + { + detail: { description: "Get all series made by a studio." }, + response: { + 200: Page(Serie), + 404: { + ...KError, + description: "No collection found with the given id or slug.", + }, + 422: KError, + }, + }, + ); diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index 5462d3ae..6a19f23b 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -42,78 +42,77 @@ export const videosH = new Elysia({ prefix: "/videos", tags: ["videos"] }) return error(201, oldRet); // TODO: this is a huge untested wip - // biome-ignore lint/correctness/noUnreachable: leave me alone - const vidsI = db.$with("vidsI").as( - db.insert(videos).values(body).onConflictDoNothing().returning({ - pk: videos.pk, - id: videos.id, - path: videos.path, - guess: videos.guess, - }), - ); - - const findEntriesQ = db - .select({ - guess: videos.guess, - entryPk: entries.pk, - showSlug: shows.slug, - // TODO: handle extras here - // guessit can't know if an episode is a special or not. treat specials like a normal episode. - kind: sql` - case when ${entries.kind} = 'movie' then 'movie' else 'episode' end - `.as("kind"), - season: entries.seasonNumber, - episode: entries.episodeNumber, - }) - .from(entries) - .leftJoin(entryVideoJoin, eq(entryVideoJoin.entry, entries.pk)) - .leftJoin(videos, eq(videos.pk, entryVideoJoin.video)) - .leftJoin(shows, eq(shows.pk, entries.showPk)) - .as("find_entries"); - - const hasRenderingQ = db - .select() - .from(entryVideoJoin) - .where(eq(entryVideoJoin.entry, findEntriesQ.entryPk)); - - const ret = await db - .with(vidsI) - .insert(entryVideoJoin) - .select( - db - .select({ - entry: findEntriesQ.entryPk, - video: vidsI.pk, - slug: computeVideoSlug( - findEntriesQ.showSlug, - sql`exists(${hasRenderingQ})`, - ), - }) - .from(vidsI) - .leftJoin( - findEntriesQ, - and( - eq( - sql`${findEntriesQ.guess}->'title'`, - sql`${vidsI.guess}->'title'`, - ), - // TODO: find if @> with a jsonb created on the fly is - // better than multiples checks - sql`${vidsI.guess} @> {"kind": }::jsonb`, - inArray(findEntriesQ.kind, sql`${vidsI.guess}->'type'`), - inArray(findEntriesQ.episode, sql`${vidsI.guess}->'episode'`), - inArray(findEntriesQ.season, sql`${vidsI.guess}->'season'`), - ), - ), - ) - .onConflictDoNothing() - .returning({ - slug: entryVideoJoin.slug, - entryPk: entryVideoJoin.entry, - id: vidsI.id, - path: vidsI.path, - }); - return error(201, ret as any); + // const vidsI = db.$with("vidsI").as( + // db.insert(videos).values(body).onConflictDoNothing().returning({ + // pk: videos.pk, + // id: videos.id, + // path: videos.path, + // guess: videos.guess, + // }), + // ); + // + // const findEntriesQ = db + // .select({ + // guess: videos.guess, + // entryPk: entries.pk, + // showSlug: shows.slug, + // // TODO: handle extras here + // // guessit can't know if an episode is a special or not. treat specials like a normal episode. + // kind: sql` + // case when ${entries.kind} = 'movie' then 'movie' else 'episode' end + // `.as("kind"), + // season: entries.seasonNumber, + // episode: entries.episodeNumber, + // }) + // .from(entries) + // .leftJoin(entryVideoJoin, eq(entryVideoJoin.entry, entries.pk)) + // .leftJoin(videos, eq(videos.pk, entryVideoJoin.video)) + // .leftJoin(shows, eq(shows.pk, entries.showPk)) + // .as("find_entries"); + // + // const hasRenderingQ = db + // .select() + // .from(entryVideoJoin) + // .where(eq(entryVideoJoin.entry, findEntriesQ.entryPk)); + // + // const ret = await db + // .with(vidsI) + // .insert(entryVideoJoin) + // .select( + // db + // .select({ + // entry: findEntriesQ.entryPk, + // video: vidsI.pk, + // slug: computeVideoSlug( + // findEntriesQ.showSlug, + // sql`exists(${hasRenderingQ})`, + // ), + // }) + // .from(vidsI) + // .leftJoin( + // findEntriesQ, + // and( + // eq( + // sql`${findEntriesQ.guess}->'title'`, + // sql`${vidsI.guess}->'title'`, + // ), + // // TODO: find if @> with a jsonb created on the fly is + // // better than multiples checks + // sql`${vidsI.guess} @> {"kind": }::jsonb`, + // inArray(findEntriesQ.kind, sql`${vidsI.guess}->'type'`), + // inArray(findEntriesQ.episode, sql`${vidsI.guess}->'episode'`), + // inArray(findEntriesQ.season, sql`${vidsI.guess}->'season'`), + // ), + // ), + // ) + // .onConflictDoNothing() + // .returning({ + // slug: entryVideoJoin.slug, + // entryPk: entryVideoJoin.entry, + // id: vidsI.id, + // path: vidsI.path, + // }); + // return error(201, ret as any); }, { body: t.Array(SeedVideo), diff --git a/api/src/db/schema/entries.ts b/api/src/db/schema/entries.ts index 28b3192c..0513c552 100644 --- a/api/src/db/schema/entries.ts +++ b/api/src/db/schema/entries.ts @@ -2,6 +2,7 @@ import { relations, sql } from "drizzle-orm"; import { check, date, + index, integer, jsonb, primaryKey, @@ -70,11 +71,17 @@ export const entries = schema.table( createdAt: timestamp({ withTimezone: true, mode: "string" }) .notNull() .defaultNow(), + updatedAt: timestamp({ withTimezone: true, mode: "string" }) + .notNull() + .$onUpdate(() => sql`now()`), nextRefresh: timestamp({ withTimezone: true, mode: "string" }).notNull(), }, (t) => [ unique().on(t.showPk, t.seasonNumber, t.episodeNumber), check("order_positive", sql`${t.order} >= 0`), + + index("entry_kind").using("hash", t.kind), + index("entry_order").on(t.order), ], ); @@ -91,7 +98,10 @@ export const entryTranslations = schema.table( tagline: text(), poster: image(), }, - (t) => [primaryKey({ columns: [t.pk, t.language] })], + (t) => [ + primaryKey({ columns: [t.pk, t.language] }), + index("entry_name_trgm").using("gin", sql`${t.name} gin_trgm_ops`), + ], ); export const entryRelations = relations(entries, ({ one, many }) => ({ diff --git a/api/src/db/schema/index.ts b/api/src/db/schema/index.ts index c817ce8c..4b5707eb 100644 --- a/api/src/db/schema/index.ts +++ b/api/src/db/schema/index.ts @@ -1,4 +1,5 @@ export * from "./entries"; export * from "./seasons"; export * from "./shows"; +export * from "./studios"; export * from "./videos"; diff --git a/api/src/db/schema/seasons.ts b/api/src/db/schema/seasons.ts index 48e444b1..52f913c0 100644 --- a/api/src/db/schema/seasons.ts +++ b/api/src/db/schema/seasons.ts @@ -1,4 +1,4 @@ -import { relations } from "drizzle-orm"; +import { relations, sql } from "drizzle-orm"; import { date, index, @@ -45,11 +45,15 @@ export const seasons = schema.table( createdAt: timestamp({ withTimezone: true, mode: "string" }) .notNull() .defaultNow(), + updatedAt: timestamp({ withTimezone: true, mode: "string" }) + .notNull() + .$onUpdate(() => sql`now()`), nextRefresh: timestamp({ withTimezone: true, mode: "string" }).notNull(), }, (t) => [ unique().on(t.showPk, t.seasonNumber), index("show_fk").using("hash", t.showPk), + index("season_nbr").on(t.seasonNumber), ], ); @@ -66,7 +70,10 @@ export const seasonTranslations = schema.table( thumbnail: image(), banner: image(), }, - (t) => [primaryKey({ columns: [t.pk, t.language] })], + (t) => [ + primaryKey({ columns: [t.pk, t.language] }), + index("season_name_trgm").using("gin", sql`${t.name} gin_trgm_ops`), + ], ); export const seasonRelations = relations(seasons, ({ one, many }) => ({ diff --git a/api/src/db/schema/shows.ts b/api/src/db/schema/shows.ts index 744f0554..5b7459ed 100644 --- a/api/src/db/schema/shows.ts +++ b/api/src/db/schema/shows.ts @@ -5,7 +5,6 @@ import { date, index, integer, - jsonb, primaryKey, smallint, text, @@ -15,7 +14,8 @@ import { } from "drizzle-orm/pg-core"; import { entries } from "./entries"; import { seasons } from "./seasons"; -import { image, language, schema } from "./utils"; +import { showStudioJoin } from "./studios"; +import { externalid, image, language, schema } from "./utils"; export const showKind = schema.enum("show_kind", [ "serie", @@ -54,20 +54,6 @@ export const genres = schema.enum("genres", [ "talk", ]); -export const externalid = () => - jsonb() - .$type< - Record< - string, - { - dataId: string; - link: string | null; - } - > - >() - .notNull() - .default({}); - export const shows = schema.table( "shows", { @@ -92,6 +78,9 @@ export const shows = schema.table( createdAt: timestamp({ withTimezone: true, mode: "string" }) .notNull() .defaultNow(), + updatedAt: timestamp({ withTimezone: true, mode: "string" }) + .notNull() + .$onUpdate(() => sql`now()`), nextRefresh: timestamp({ withTimezone: true, mode: "string" }).notNull(), }, (t) => [ @@ -141,6 +130,7 @@ export const showsRelations = relations(shows, ({ many, one }) => ({ }), entries: many(entries, { relationName: "show_entries" }), seasons: many(seasons, { relationName: "show_seasons" }), + studios: many(showStudioJoin, { relationName: "ssj_show" }), })); export const showsTrRelations = relations(showTranslations, ({ one }) => ({ show: one(shows, { diff --git a/api/src/db/schema/studios.ts b/api/src/db/schema/studios.ts new file mode 100644 index 00000000..1c772ee6 --- /dev/null +++ b/api/src/db/schema/studios.ts @@ -0,0 +1,89 @@ +import { relations, sql } from "drizzle-orm"; +import { + index, + integer, + primaryKey, + text, + timestamp, + uuid, + varchar, +} from "drizzle-orm/pg-core"; +import { shows } from "./shows"; +import { externalid, image, language, schema } from "./utils"; + +export const studios = schema.table("studios", { + pk: integer().primaryKey().generatedAlwaysAsIdentity(), + id: uuid().notNull().unique().defaultRandom(), + slug: varchar({ length: 255 }).notNull().unique(), + externalId: externalid(), + + createdAt: timestamp({ withTimezone: true, mode: "string" }) + .notNull() + .defaultNow(), + updatedAt: timestamp({ withTimezone: true, mode: "string" }) + .notNull() + .$onUpdate(() => sql`now()`), +}); + +export const studioTranslations = schema.table( + "studio_translations", + { + pk: integer() + .notNull() + .references(() => studios.pk, { onDelete: "cascade" }), + language: language().notNull(), + name: text().notNull(), + logo: image(), + }, + (t) => [ + primaryKey({ columns: [t.pk, t.language] }), + index("studio_name_trgm").using("gin", sql`${t.name} gin_trgm_ops`), + ], +); + +export const showStudioJoin = schema.table( + "show_studio_join", + { + showPk: integer() + .notNull() + .references(() => shows.pk, { onDelete: "cascade" }), + studioPk: integer() + .notNull() + .references(() => studios.pk, { onDelete: "cascade" }), + }, + (t) => [primaryKey({ columns: [t.showPk, t.studioPk] })], +); + +export const studioRelations = relations(studios, ({ many }) => ({ + translations: many(studioTranslations, { + relationName: "studio_translations", + }), + selectedTranslation: many(studioTranslations, { + relationName: "studio_selected_translation", + }), + showsJoin: many(showStudioJoin, { relationName: "ssj_studio" }), +})); +export const studioTrRelations = relations(studioTranslations, ({ one }) => ({ + studio: one(studios, { + relationName: "studio_translations", + fields: [studioTranslations.pk], + references: [studios.pk], + }), + selectedTranslation: one(studios, { + relationName: "studio_selected_translation", + fields: [studioTranslations.pk], + references: [studios.pk], + }), +})); +export const ssjRelations = relations(showStudioJoin, ({ one }) => ({ + show: one(shows, { + relationName: "ssj_show", + fields: [showStudioJoin.showPk], + references: [shows.pk], + }), + studio: one(studios, { + relationName: "ssj_studio", + fields: [showStudioJoin.studioPk], + references: [studios.pk], + }), +})); diff --git a/api/src/db/schema/utils.ts b/api/src/db/schema/utils.ts index 24e49251..02f6e8af 100644 --- a/api/src/db/schema/utils.ts +++ b/api/src/db/schema/utils.ts @@ -6,3 +6,17 @@ export const language = () => varchar({ length: 255 }); export const image = () => jsonb().$type<{ id: string; source: string; blurhash: string }>(); + +export const externalid = () => + jsonb() + .$type< + Record< + string, + { + dataId: string; + link: string | null; + } + > + >() + .notNull() + .default({}); diff --git a/api/src/db/schema/videos.ts b/api/src/db/schema/videos.ts index abe1049f..877f3c9d 100644 --- a/api/src/db/schema/videos.ts +++ b/api/src/db/schema/videos.ts @@ -26,6 +26,9 @@ export const videos = schema.table( createdAt: timestamp({ withTimezone: true, mode: "string" }) .notNull() .defaultNow(), + updatedAt: timestamp({ withTimezone: true, mode: "string" }) + .notNull() + .$onUpdate(() => sql`now()`), }, (t) => [ check("part_pos", sql`${t.part} >= 0`), @@ -36,15 +39,15 @@ export const videos = schema.table( export const entryVideoJoin = schema.table( "entry_video_join", { - entry: integer() + entryPk: integer() .notNull() .references(() => entries.pk, { onDelete: "cascade" }), - video: integer() + videoPk: integer() .notNull() .references(() => videos.pk, { onDelete: "cascade" }), slug: varchar({ length: 255 }).notNull().unique(), }, - (t) => [primaryKey({ columns: [t.entry, t.video] })], + (t) => [primaryKey({ columns: [t.entryPk, t.videoPk] })], ); export const videosRelations = relations(videos, ({ many }) => ({ @@ -56,12 +59,12 @@ export const videosRelations = relations(videos, ({ many }) => ({ export const evjRelations = relations(entryVideoJoin, ({ one }) => ({ video: one(videos, { relationName: "evj_video", - fields: [entryVideoJoin.video], + fields: [entryVideoJoin.videoPk], references: [videos.pk], }), entry: one(entries, { relationName: "evj_entry", - fields: [entryVideoJoin.entry], + fields: [entryVideoJoin.entryPk], references: [entries.pk], }), })); diff --git a/api/src/elysia.ts b/api/src/elysia.ts index 3dd80ac0..1df911cf 100644 --- a/api/src/elysia.ts +++ b/api/src/elysia.ts @@ -6,6 +6,7 @@ import { collections } from "./controllers/shows/collections"; import { movies } from "./controllers/shows/movies"; import { series } from "./controllers/shows/series"; import { showsH } from "./controllers/shows/shows"; +import { studiosH } from "./controllers/studios"; import { videosH } from "./controllers/videos"; import type { KError } from "./models/error"; @@ -48,4 +49,5 @@ export const app = new Elysia() .use(entriesH) .use(seasonsH) .use(videosH) + .use(studiosH) .use(seed); diff --git a/api/src/index.ts b/api/src/index.ts index 9a38beea..65e74f53 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -63,6 +63,7 @@ app Can be used for administration or third party apps. `, }, + { name: "studios", description: "Routes about studios" }, ], }, }), diff --git a/api/src/models/collections.ts b/api/src/models/collections.ts index 99958154..452a7868 100644 --- a/api/src/models/collections.ts +++ b/api/src/models/collections.ts @@ -2,6 +2,7 @@ import { t } from "elysia"; import type { Prettify } from "elysia/dist/types"; import { bubbleImages, duneCollection, registerExamples } from "./examples"; import { + DbMetadata, ExternalId, Genre, Image, @@ -33,10 +34,9 @@ const BaseCollection = t.Object({ }), ), - createdAt: t.String({ format: "date-time" }), nextRefresh: t.String({ format: "date-time" }), - externalId: ExternalId, + externalId: ExternalId(), }); export const CollectionTranslation = t.Object({ @@ -56,6 +56,7 @@ export const Collection = t.Intersect([ Resource(), CollectionTranslation, BaseCollection, + DbMetadata, ]); export type Collection = Prettify; @@ -68,13 +69,7 @@ export const FullCollection = t.Intersect([ export type FullCollection = Prettify; export const SeedCollection = t.Intersect([ - t.Omit(BaseCollection, [ - "kind", - "startAir", - "endAir", - "createdAt", - "nextRefresh", - ]), + t.Omit(BaseCollection, ["kind", "startAir", "endAir", "nextRefresh"]), t.Object({ slug: t.String({ format: "slug" }), translations: TranslationRecord( diff --git a/api/src/models/entry/base-entry.ts b/api/src/models/entry/base-entry.ts index 451422a1..2a59b32b 100644 --- a/api/src/models/entry/base-entry.ts +++ b/api/src/models/entry/base-entry.ts @@ -12,7 +12,6 @@ export const BaseEntry = () => ), thumbnail: t.Nullable(Image), - createdAt: t.String({ format: "date-time" }), nextRefresh: t.String({ format: "date-time" }), }); diff --git a/api/src/models/entry/episode.ts b/api/src/models/entry/episode.ts index 2372abe7..3c87b28e 100644 --- a/api/src/models/entry/episode.ts +++ b/api/src/models/entry/episode.ts @@ -1,7 +1,13 @@ import { t } from "elysia"; import type { Prettify } from "~/utils"; import { bubbleImages, madeInAbyss, registerExamples } from "../examples"; -import { EpisodeId, Resource, SeedImage, TranslationRecord } from "../utils"; +import { + DbMetadata, + EpisodeId, + Resource, + SeedImage, + TranslationRecord, +} from "../utils"; import { BaseEntry, EntryTranslation } from "./base-entry"; export const BaseEpisode = t.Intersect([ @@ -19,11 +25,12 @@ export const Episode = t.Intersect([ Resource(), EntryTranslation(), BaseEpisode, + DbMetadata, ]); export type Episode = Prettify; export const SeedEpisode = t.Intersect([ - t.Omit(BaseEpisode, ["thumbnail", "createdAt", "nextRefresh"]), + t.Omit(BaseEpisode, ["thumbnail", "nextRefresh"]), t.Object({ thumbnail: t.Nullable(SeedImage), translations: TranslationRecord(EntryTranslation()), diff --git a/api/src/models/entry/extra.ts b/api/src/models/entry/extra.ts index eb702b18..1d033753 100644 --- a/api/src/models/entry/extra.ts +++ b/api/src/models/entry/extra.ts @@ -1,7 +1,7 @@ import { t } from "elysia"; import { type Prettify, comment } from "~/utils"; import { madeInAbyss, registerExamples } from "../examples"; -import { SeedImage } from "../utils"; +import { DbMetadata, SeedImage } from "../utils"; import { Resource } from "../utils/resource"; import { BaseEntry } from "./base-entry"; @@ -31,11 +31,11 @@ export const BaseExtra = t.Intersect( }, ); -export const Extra = t.Intersect([Resource(), BaseExtra]); +export const Extra = t.Intersect([Resource(), BaseExtra, DbMetadata]); export type Extra = Prettify; export const SeedExtra = t.Intersect([ - t.Omit(BaseExtra, ["thumbnail", "createdAt"]), + t.Omit(BaseExtra, ["thumbnail"]), t.Object({ slug: t.String({ format: "slug" }), thumbnail: t.Nullable(SeedImage), diff --git a/api/src/models/entry/movie-entry.ts b/api/src/models/entry/movie-entry.ts index 8773fbf7..95fcc501 100644 --- a/api/src/models/entry/movie-entry.ts +++ b/api/src/models/entry/movie-entry.ts @@ -2,6 +2,7 @@ import { t } from "elysia"; import { type Prettify, comment } from "~/utils"; import { bubbleImages, madeInAbyss, registerExamples } from "../examples"; import { + DbMetadata, ExternalId, Image, Resource, @@ -18,7 +19,7 @@ export const BaseMovieEntry = t.Intersect( minimum: 1, description: "Absolute playback order. Can be mixed with episodes.", }), - externalId: ExternalId, + externalId: ExternalId(), }), BaseEntry(), ], @@ -42,11 +43,12 @@ export const MovieEntry = t.Intersect([ Resource(), MovieEntryTranslation, BaseMovieEntry, + DbMetadata, ]); export type MovieEntry = Prettify; export const SeedMovieEntry = t.Intersect([ - t.Omit(BaseMovieEntry, ["thumbnail", "createdAt", "nextRefresh"]), + t.Omit(BaseMovieEntry, ["thumbnail", "nextRefresh"]), t.Object({ slug: t.Optional(t.String({ format: "slug" })), thumbnail: t.Nullable(SeedImage), diff --git a/api/src/models/entry/special.ts b/api/src/models/entry/special.ts index 94d91683..248ff9d7 100644 --- a/api/src/models/entry/special.ts +++ b/api/src/models/entry/special.ts @@ -1,7 +1,13 @@ import { t } from "elysia"; import { type Prettify, comment } from "~/utils"; import { bubbleImages, madeInAbyss, registerExamples } from "../examples"; -import { EpisodeId, Resource, SeedImage, TranslationRecord } from "../utils"; +import { + DbMetadata, + EpisodeId, + Resource, + SeedImage, + TranslationRecord, +} from "../utils"; import { BaseEntry, EntryTranslation } from "./base-entry"; export const BaseSpecial = t.Intersect( @@ -29,11 +35,12 @@ export const Special = t.Intersect([ Resource(), EntryTranslation(), BaseSpecial, + DbMetadata, ]); export type Special = Prettify; export const SeedSpecial = t.Intersect([ - t.Omit(BaseSpecial, ["thumbnail", "createdAt", "nextRefresh"]), + t.Omit(BaseSpecial, ["thumbnail", "nextRefresh"]), t.Object({ thumbnail: t.Nullable(SeedImage), translations: TranslationRecord(EntryTranslation()), diff --git a/api/src/models/entry/unknown-entry.ts b/api/src/models/entry/unknown-entry.ts index 284bf856..efe1d380 100644 --- a/api/src/models/entry/unknown-entry.ts +++ b/api/src/models/entry/unknown-entry.ts @@ -1,8 +1,7 @@ import { t } from "elysia"; import { type Prettify, comment } from "~/utils"; -import { bubbleImages, registerExamples } from "../examples"; -import { youtubeExample } from "../examples/others"; -import { Resource } from "../utils/resource"; +import { bubbleImages, registerExamples, youtubeExample } from "../examples"; +import { DbMetadata, Resource } from "../utils"; import { BaseEntry, EntryTranslation } from "./base-entry"; export const BaseUnknownEntry = t.Intersect( @@ -28,6 +27,7 @@ export const UnknownEntry = t.Intersect([ Resource(), UnknownEntryTranslation, BaseUnknownEntry, + DbMetadata, ]); export type UnknownEntry = Prettify; diff --git a/api/src/models/examples/bubble.ts b/api/src/models/examples/bubble.ts index 0246333c..7ea2399c 100644 --- a/api/src/models/examples/bubble.ts +++ b/api/src/models/examples/bubble.ts @@ -9,6 +9,7 @@ export const bubbleVideo: Video = { part: null, version: 1, createdAt: "2024-11-23T15:01:24.968Z", + updatedAt: "2024-11-23T15:01:24.968Z", }; export const bubble: SeedMovie = { @@ -60,6 +61,7 @@ export const bubble: SeedMovie = { }, }, videos: [bubbleVideo.id], + studios: [], }; export const bubbleImages = { diff --git a/api/src/models/examples/dune-1984.ts b/api/src/models/examples/dune-1984.ts index 6567d8c9..d12b94e1 100644 --- a/api/src/models/examples/dune-1984.ts +++ b/api/src/models/examples/dune-1984.ts @@ -9,6 +9,7 @@ export const dune1984Video: Video = { part: null, version: 1, createdAt: "2024-12-02T11:45:12.968Z", + updatedAt: "2024-12-02T11:45:12.968Z", }; export const dune1984: SeedMovie = { @@ -47,6 +48,7 @@ export const dune1984: SeedMovie = { }, }, videos: [dune1984Video.id], + studios: [], }; export const dune1984Images = { diff --git a/api/src/models/examples/dune-2021.ts b/api/src/models/examples/dune-2021.ts index fa2b017f..d3b7b59f 100644 --- a/api/src/models/examples/dune-2021.ts +++ b/api/src/models/examples/dune-2021.ts @@ -9,6 +9,7 @@ export const duneVideo: Video = { part: null, version: 1, createdAt: "2024-12-02T10:10:24.968Z", + updatedAt: "2024-12-02T10:10:24.968Z", }; export const dune: SeedMovie = { @@ -47,6 +48,7 @@ export const dune: SeedMovie = { }, }, videos: [duneVideo.id], + studios: [], }; export const duneImages = { diff --git a/api/src/models/examples/made-in-abyss.ts b/api/src/models/examples/made-in-abyss.ts index 58be76c4..6fd213e1 100644 --- a/api/src/models/examples/made-in-abyss.ts +++ b/api/src/models/examples/made-in-abyss.ts @@ -16,6 +16,7 @@ export const madeInAbyssVideo: Video = { from: "guessit", }, createdAt: "2024-11-23T15:01:24.968Z", + updatedAt: "2024-11-23T15:01:24.968Z", }; export const madeInAbyss = { @@ -242,4 +243,21 @@ export const madeInAbyss = { video: "3cd436ee-01ff-4f45-ba98-654282531234", }, ], + studios: [ + { + slug: "kinema-citrus", + translations: { + en: { + name: "Kinema Citrus", + logo: "https://image.tmdb.org/t/p/original/Lf0udeB7OwHoFJ0XIxVwfyGOqE.png", + }, + }, + externalId: { + themoviedatabase: { + dataId: "16738", + link: "https://www.themoviedb.org/company/16738", + }, + }, + }, + ], } satisfies SeedSerie; diff --git a/api/src/models/movie.ts b/api/src/models/movie.ts index aaae338f..4f3f7089 100644 --- a/api/src/models/movie.ts +++ b/api/src/models/movie.ts @@ -1,9 +1,10 @@ import { t } from "elysia"; import type { Prettify } from "~/utils"; import { SeedCollection } from "./collections"; -import { bubble, registerExamples } from "./examples"; -import { bubbleImages } from "./examples/bubble"; +import { bubble, bubbleImages, registerExamples } from "./examples"; +import { SeedStudio, Studio } from "./studio"; import { + DbMetadata, ExternalId, Genre, Image, @@ -33,10 +34,9 @@ const BaseMovie = t.Object({ }), ), - createdAt: t.String({ format: "date-time" }), nextRefresh: t.String({ format: "date-time" }), - externalId: ExternalId, + externalId: ExternalId(), }); export const MovieTranslation = t.Object({ @@ -58,6 +58,7 @@ export const Movie = t.Intersect([ Resource(), MovieTranslation, BaseMovie, + DbMetadata, // t.Object({ isAvailable: t.Boolean() }), ]); export type Movie = Prettify; @@ -67,12 +68,13 @@ export const FullMovie = t.Intersect([ t.Object({ translations: t.Optional(TranslationRecord(MovieTranslation)), videos: t.Optional(t.Array(Video)), + studios: t.Optional(t.Array(Studio)), }), ]); export type FullMovie = Prettify; export const SeedMovie = t.Intersect([ - t.Omit(BaseMovie, ["kind", "createdAt", "nextRefresh"]), + t.Omit(BaseMovie, ["kind", "nextRefresh"]), t.Object({ slug: t.String({ format: "slug", examples: ["bubble"] }), translations: TranslationRecord( @@ -88,6 +90,7 @@ export const SeedMovie = t.Intersect([ ), videos: t.Optional(t.Array(t.String({ format: "uuid" }))), collection: t.Optional(SeedCollection), + studios: t.Array(SeedStudio), }), ]); export type SeedMovie = Prettify; diff --git a/api/src/models/season.ts b/api/src/models/season.ts index cd90b215..225bbce9 100644 --- a/api/src/models/season.ts +++ b/api/src/models/season.ts @@ -1,6 +1,7 @@ import { t } from "elysia"; import type { Prettify } from "~/utils"; import { bubbleImages, madeInAbyss, registerExamples } from "./examples"; +import { DbMetadata } from "./utils"; import { SeasonId } from "./utils/external-id"; import { Image, SeedImage } from "./utils/image"; import { TranslationRecord } from "./utils/language"; @@ -11,7 +12,6 @@ export const BaseSeason = t.Object({ startAir: t.Nullable(t.String({ format: "date" })), endAir: t.Nullable(t.String({ format: "date" })), - createdAt: t.String({ format: "date-time" }), nextRefresh: t.String({ format: "date-time" }), externalId: SeasonId, @@ -27,11 +27,16 @@ export const SeasonTranslation = t.Object({ }); export type SeasonTranslation = typeof SeasonTranslation.static; -export const Season = t.Intersect([Resource(), SeasonTranslation, BaseSeason]); -export type Season = typeof Season.static; +export const Season = t.Intersect([ + Resource(), + SeasonTranslation, + BaseSeason, + DbMetadata, +]); +export type Season = Prettify; export const SeedSeason = t.Intersect([ - t.Omit(BaseSeason, ["createdAt", "nextRefresh"]), + t.Omit(BaseSeason, ["nextRefresh"]), t.Object({ translations: TranslationRecord( t.Intersect([ diff --git a/api/src/models/serie.ts b/api/src/models/serie.ts index cc8ff78e..a20c8795 100644 --- a/api/src/models/serie.ts +++ b/api/src/models/serie.ts @@ -4,11 +4,17 @@ import { SeedCollection } from "./collections"; import { SeedEntry, SeedExtra } from "./entry"; import { bubbleImages, madeInAbyss, registerExamples } from "./examples"; import { SeedSeason } from "./season"; -import { ExternalId } from "./utils/external-id"; -import { Genre } from "./utils/genres"; -import { Image, SeedImage } from "./utils/image"; -import { Language, TranslationRecord } from "./utils/language"; -import { Resource } from "./utils/resource"; +import { SeedStudio, Studio } from "./studio"; +import { + DbMetadata, + ExternalId, + Genre, + Image, + Language, + Resource, + SeedImage, + TranslationRecord, +} from "./utils"; export const SerieStatus = t.UnionEnum([ "unknown", @@ -18,7 +24,7 @@ export const SerieStatus = t.UnionEnum([ ]); export type SerieStatus = typeof SerieStatus.static; -export const BaseSerie = t.Object({ +const BaseSerie = t.Object({ kind: t.Literal("serie"), genres: t.Array(Genre), rating: t.Nullable(t.Integer({ minimum: 0, maximum: 100 })), @@ -38,10 +44,9 @@ export const BaseSerie = t.Object({ }), ), - createdAt: t.String({ format: "date-time" }), nextRefresh: t.String({ format: "date-time" }), - externalId: ExternalId, + externalId: ExternalId(), }); export const SerieTranslation = t.Object({ @@ -59,19 +64,25 @@ export const SerieTranslation = t.Object({ }); export type SerieTranslation = typeof SerieTranslation.static; -export const Serie = t.Intersect([Resource(), SerieTranslation, BaseSerie]); +export const Serie = t.Intersect([ + Resource(), + SerieTranslation, + BaseSerie, + DbMetadata, +]); export type Serie = Prettify; export const FullSerie = t.Intersect([ Serie, t.Object({ translations: t.Optional(TranslationRecord(SerieTranslation)), + studios: t.Optional(t.Array(Studio)), }), ]); export type FullMovie = Prettify; export const SeedSerie = t.Intersect([ - t.Omit(BaseSerie, ["kind", "createdAt", "nextRefresh"]), + t.Omit(BaseSerie, ["kind", "nextRefresh"]), t.Object({ slug: t.String({ format: "slug" }), translations: TranslationRecord( @@ -89,6 +100,7 @@ export const SeedSerie = t.Intersect([ entries: t.Array(SeedEntry), extras: t.Optional(t.Array(SeedExtra)), collection: t.Optional(SeedCollection), + studios: t.Array(SeedStudio), }), ]); export type SeedSerie = typeof SeedSerie.static; diff --git a/api/src/models/show.ts b/api/src/models/show.ts new file mode 100644 index 00000000..ccaaf35a --- /dev/null +++ b/api/src/models/show.ts @@ -0,0 +1,6 @@ +import { t } from "elysia"; +import { Collection } from "./collections"; +import { Movie } from "./movie"; +import { Serie } from "./serie"; + +export const Show = t.Union([Movie, Serie, Collection]); diff --git a/api/src/models/studio.ts b/api/src/models/studio.ts new file mode 100644 index 00000000..53d7ccdb --- /dev/null +++ b/api/src/models/studio.ts @@ -0,0 +1,41 @@ +import { t } from "elysia"; +import type { Prettify } from "elysia/dist/types"; +import { bubbleImages, madeInAbyss, registerExamples } from "./examples"; +import { DbMetadata, ExternalId, Resource, TranslationRecord } from "./utils"; +import { Image, SeedImage } from "./utils/image"; + +const BaseStudio = t.Object({ + externalId: ExternalId(), +}); + +export const StudioTranslation = t.Object({ + name: t.String(), + logo: t.Nullable(Image), +}); + +export const Studio = t.Intersect([ + Resource(), + StudioTranslation, + BaseStudio, + DbMetadata, +]); +export type Studio = Prettify; + +export const SeedStudio = t.Intersect([ + BaseStudio, + t.Object({ + slug: t.String({ format: "slug" }), + translations: TranslationRecord( + t.Intersect([ + t.Omit(StudioTranslation, ["logo"]), + t.Object({ + logo: t.Nullable(SeedImage), + }), + ]), + ), + }), +]); +export type SeedStudio = Prettify; + +const ex = madeInAbyss.studios[0]; +registerExamples(Studio, { ...ex, ...ex.translations.en, ...bubbleImages }); diff --git a/api/src/models/utils/db-metadata.ts b/api/src/models/utils/db-metadata.ts new file mode 100644 index 00000000..ea2f9b7a --- /dev/null +++ b/api/src/models/utils/db-metadata.ts @@ -0,0 +1,6 @@ +import { t } from "elysia"; + +export const DbMetadata = t.Object({ + createdAt: t.String({ format: "date-time" }), + updatedAt: t.String({ format: "date-time" }), +}); diff --git a/api/src/models/utils/external-id.ts b/api/src/models/utils/external-id.ts index d449f4d5..806c790d 100644 --- a/api/src/models/utils/external-id.ts +++ b/api/src/models/utils/external-id.ts @@ -1,14 +1,14 @@ import { t } from "elysia"; import { comment } from "../../utils"; -export const ExternalId = t.Record( - t.String(), - t.Object({ - dataId: t.String(), - link: t.Nullable(t.String({ format: "uri" })), - }), -); -export type ExternalId = typeof ExternalId.static; +export const ExternalId = () => + t.Record( + t.String(), + t.Object({ + dataId: t.String(), + link: t.Nullable(t.String({ format: "uri" })), + }), + ); export const EpisodeId = t.Record( t.String(), diff --git a/api/src/models/utils/index.ts b/api/src/models/utils/index.ts index 70c08626..4326dfc6 100644 --- a/api/src/models/utils/index.ts +++ b/api/src/models/utils/index.ts @@ -7,3 +7,4 @@ export * from "./filters"; export * from "./page"; export * from "./sort"; export * from "./keyset-paginate"; +export * from "./db-metadata"; diff --git a/api/src/models/utils/language.ts b/api/src/models/utils/language.ts index 76f141e3..b1d0bcf3 100644 --- a/api/src/models/utils/language.ts +++ b/api/src/models/utils/language.ts @@ -4,7 +4,9 @@ import { type TSchema, type TString, } from "@sinclair/typebox"; +import { type Column, type Table, eq, sql } from "drizzle-orm"; import { t } from "elysia"; +import { sqlarr } from "~/db/utils"; import { comment } from "../../utils"; import { KErrorT } from "../error"; @@ -106,3 +108,19 @@ 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/video.ts b/api/src/models/video.ts index 3fa52afb..7f8f6681 100644 --- a/api/src/models/video.ts +++ b/api/src/models/video.ts @@ -1,10 +1,9 @@ -import { type TSchema, t } from "elysia"; -import { comment } from "../utils"; +import { t } from "elysia"; +import { type Prettify, comment } from "~/utils"; import { bubbleVideo, registerExamples } from "./examples"; +import { DbMetadata, Resource } from "./utils"; -export const Video = t.Object({ - id: t.String({ format: "uuid" }), - slug: t.String({ format: "slug" }), +export const SeedVideo = t.Object({ path: t.String(), rendering: t.String({ description: comment` @@ -30,8 +29,6 @@ export const Video = t.Object({ "Kyoo will prefer playing back the highest `version` number if there are multiples rendering.", }), - createdAt: t.String({ format: "date-time" }), - guess: t.Optional( t.Recursive((Self) => t.Object( @@ -69,8 +66,9 @@ export const Video = t.Object({ ), ), }); -export type Video = typeof Video.static; -registerExamples(Video, bubbleVideo); - -export const SeedVideo = t.Omit(Video, ["id", "slug", "createdAt"]); export type SeedVideo = typeof SeedVideo.static; + +export const Video = t.Intersect([Resource(), SeedVideo, DbMetadata]); +export type Video = Prettify; + +registerExamples(Video, bubbleVideo); diff --git a/api/tests/helpers/index.ts b/api/tests/helpers/index.ts index 62e4bb29..e051fdff 100644 --- a/api/tests/helpers/index.ts +++ b/api/tests/helpers/index.ts @@ -1,5 +1,6 @@ export * from "./movies-helper"; export * from "./series-helper"; +export * from "./studio-helper"; export * from "./videos-helper"; export * from "~/elysia"; diff --git a/api/tests/helpers/movies-helper.ts b/api/tests/helpers/movies-helper.ts index f2317ea6..8ba77603 100644 --- a/api/tests/helpers/movies-helper.ts +++ b/api/tests/helpers/movies-helper.ts @@ -4,7 +4,10 @@ import type { SeedMovie } from "~/models/movie"; export const getMovie = async ( id: string, - { langs, ...query }: { langs?: string; preferOriginal?: boolean }, + { + langs, + ...query + }: { langs?: string; preferOriginal?: boolean; with?: string[] }, ) => { const resp = await app.handle( new Request(buildUrl(`movies/${id}`, query), { diff --git a/api/tests/helpers/series-helper.ts b/api/tests/helpers/series-helper.ts index 5997aeea..5c4dac8f 100644 --- a/api/tests/helpers/series-helper.ts +++ b/api/tests/helpers/series-helper.ts @@ -16,6 +16,27 @@ export const createSerie = async (serie: SeedSerie) => { return [resp, body] as const; }; +export const getSerie = async ( + id: string, + { + langs, + ...query + }: { langs?: string; preferOriginal?: boolean; with?: string[] }, +) => { + const resp = await app.handle( + new Request(buildUrl(`series/${id}`, query), { + method: "GET", + headers: langs + ? { + "Accept-Language": langs, + } + : {}, + }), + ); + const body = await resp.json(); + return [resp, body] as const; +}; + export const getSeasons = async ( serie: string, { diff --git a/api/tests/helpers/studio-helper.ts b/api/tests/helpers/studio-helper.ts new file mode 100644 index 00000000..fb9fe255 --- /dev/null +++ b/api/tests/helpers/studio-helper.ts @@ -0,0 +1,49 @@ +import { buildUrl } from "tests/utils"; +import { app } from "~/elysia"; + +export const getStudio = async ( + id: string, + { langs, ...query }: { langs?: string; preferOriginal?: boolean }, +) => { + const resp = await app.handle( + new Request(buildUrl(`studios/${id}`, query), { + method: "GET", + headers: langs + ? { + "Accept-Language": langs, + } + : {}, + }), + ); + const body = await resp.json(); + return [resp, body] as const; +}; + +export const getShowsByStudio = async ( + studio: string, + { + langs, + ...opts + }: { + filter?: string; + limit?: number; + after?: string; + sort?: string | string[]; + query?: string; + langs?: string; + preferOriginal?: boolean; + }, +) => { + const resp = await app.handle( + new Request(buildUrl(`studios/${studio}/shows`, opts), { + method: "GET", + headers: langs + ? { + "Accept-Language": langs, + } + : {}, + }), + ); + const body = await resp.json(); + return [resp, body] as const; +}; 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 5e03cb56..30d5932f 100644 --- a/api/tests/movies/get-all-movies-with-null.test.ts +++ b/api/tests/movies/get-all-movies-with-null.test.ts @@ -41,6 +41,7 @@ describe("with a null value", () => { airDate: null, originalLanguage: null, externalId: {}, + studios: [], }); }); diff --git a/api/tests/series/studios.test.ts b/api/tests/series/studios.test.ts new file mode 100644 index 00000000..a23f9494 --- /dev/null +++ b/api/tests/series/studios.test.ts @@ -0,0 +1,64 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import { getSerie, getShowsByStudio, getStudio } from "tests/helpers"; +import { expectStatus } from "tests/utils"; +import { seedSerie } from "~/controllers/seed/series"; +import { madeInAbyss } from "~/models/examples"; + +beforeAll(async () => { + await seedSerie(madeInAbyss); +}); + +describe("Get by studio", () => { + it("Invalid slug", async () => { + const [resp, body] = await getShowsByStudio("sotneuhn", { langs: "en" }); + + expectStatus(resp, body).toBe(404); + expect(body).toMatchObject({ + status: 404, + message: expect.any(String), + }); + }); + it("Get serie from studio", async () => { + const [resp, body] = await getShowsByStudio(madeInAbyss.studios[0].slug, { + langs: "en", + }); + + expectStatus(resp, body).toBe(200); + expect(body.items).toBeArrayOfSize(1); + expect(body.items[0].slug).toBe(madeInAbyss.slug); + }); +}); + +describe("Get a studio", () => { + it("Invalid slug", async () => { + const [resp, body] = await getStudio("sotneuhn", { langs: "en" }); + + expectStatus(resp, body).toBe(404); + expect(body).toMatchObject({ + status: 404, + message: expect.any(String), + }); + }); + it("Get by id", async () => { + const slug = madeInAbyss.studios[0].slug; + const [resp, body] = await getStudio(slug, { langs: "en" }); + + expectStatus(resp, body).toBe(200); + expect(body.slug).toBe(slug); + }); + it("Get using /shows?with=", async () => { + const [resp, body] = await getSerie(madeInAbyss.slug, { + langs: "en", + with: ["studios"], + }); + + expectStatus(resp, body).toBe(200); + expect(body.slug).toBe(madeInAbyss.slug); + expect(body.studios).toBeArrayOfSize(1); + const studio = madeInAbyss.studios[0]; + expect(body.studios[0]).toMatchObject({ + slug: studio.slug, + name: studio.translations.en.name, + }); + }); +});