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