diff --git a/api/drizzle/0019_nextup.sql b/api/drizzle/0019_nextup.sql new file mode 100644 index 00000000..a4a24a86 --- /dev/null +++ b/api/drizzle/0019_nextup.sql @@ -0,0 +1 @@ +ALTER TABLE "kyoo"."watchlist" ADD COLUMN "last_played_at" timestamp with time zone; \ No newline at end of file diff --git a/api/drizzle/meta/0019_snapshot.json b/api/drizzle/meta/0019_snapshot.json new file mode 100644 index 00000000..7b6684b1 --- /dev/null +++ b/api/drizzle/meta/0019_snapshot.json @@ -0,0 +1,1845 @@ +{ + "id": "4a892e3b-fbc3-426f-a86b-b298c6f89d71", + "prevId": "c58caf63-2907-4c3a-8a8f-4f9eb2fe4491", + "version": "7", + "dialect": "postgresql", + "tables": { + "kyoo.entries": { + "name": "entries", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "entries_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "entry_type", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "extra_kind": { + "name": "extra_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "air_date": { + "name": "air_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "runtime": { + "name": "runtime", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "available_since": { + "name": "available_since", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "entry_kind": { + "name": "entry_kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "entry_order": { + "name": "entry_order", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entries_show_pk_shows_pk_fk": { + "name": "entries_show_pk_shows_pk_fk", + "tableFrom": "entries", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "entries_id_unique": { + "name": "entries_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "entries_slug_unique": { + "name": "entries_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "entries_showPk_seasonNumber_episodeNumber_unique": { + "name": "entries_showPk_seasonNumber_episodeNumber_unique", + "nullsNotDistinct": false, + "columns": ["show_pk", "season_number", "episode_number"] + } + }, + "policies": {}, + "checkConstraints": { + "order_positive": { + "name": "order_positive", + "value": "\"kyoo\".\"entries\".\"order\" >= 0" + } + }, + "isRLSEnabled": false + }, + "kyoo.entry_translations": { + "name": "entry_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "poster": { + "name": "poster", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "entry_name_trgm": { + "name": "entry_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "entry_translations_pk_entries_pk_fk": { + "name": "entry_translations_pk_entries_pk_fk", + "tableFrom": "entry_translations", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entry_translations_pk_language_pk": { + "name": "entry_translations_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.history": { + "name": "history", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "history_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "profile_pk": { + "name": "profile_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "entry_pk": { + "name": "entry_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "video_pk": { + "name": "video_pk", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "percent": { + "name": "percent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "time": { + "name": "time", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "played_date": { + "name": "played_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "history_play_date": { + "name": "history_play_date", + "columns": [ + { + "expression": "played_date", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "history_profile_pk_profiles_pk_fk": { + "name": "history_profile_pk_profiles_pk_fk", + "tableFrom": "history", + "tableTo": "profiles", + "schemaTo": "kyoo", + "columnsFrom": ["profile_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "history_entry_pk_entries_pk_fk": { + "name": "history_entry_pk_entries_pk_fk", + "tableFrom": "history", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["entry_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "history_video_pk_videos_pk_fk": { + "name": "history_video_pk_videos_pk_fk", + "tableFrom": "history", + "tableTo": "videos", + "schemaTo": "kyoo", + "columnsFrom": ["video_pk"], + "columnsTo": ["pk"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "percent_valid": { + "name": "percent_valid", + "value": "\"kyoo\".\"history\".\"percent\" between 0 and 100" + } + }, + "isRLSEnabled": false + }, + "kyoo.season_translations": { + "name": "season_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "poster": { + "name": "poster", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "banner": { + "name": "banner", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "season_name_trgm": { + "name": "season_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "season_translations_pk_seasons_pk_fk": { + "name": "season_translations_pk_seasons_pk_fk", + "tableFrom": "season_translations", + "tableTo": "seasons", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "season_translations_pk_language_pk": { + "name": "season_translations_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.seasons": { + "name": "seasons", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "seasons_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "start_air": { + "name": "start_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "end_air": { + "name": "end_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "show_fk": { + "name": "show_fk", + "columns": [ + { + "expression": "show_pk", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "season_nbr": { + "name": "season_nbr", + "columns": [ + { + "expression": "season_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "seasons_show_pk_shows_pk_fk": { + "name": "seasons_show_pk_shows_pk_fk", + "tableFrom": "seasons", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "seasons_id_unique": { + "name": "seasons_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "seasons_slug_unique": { + "name": "seasons_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "seasons_showPk_seasonNumber_unique": { + "name": "seasons_showPk_seasonNumber_unique", + "nullsNotDistinct": false, + "columns": ["show_pk", "season_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.show_translations": { + "name": "show_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "poster": { + "name": "poster", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "banner": { + "name": "banner", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "trailer_url": { + "name": "trailer_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "name_trgm": { + "name": "name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "tags": { + "name": "tags", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "show_translations_pk_shows_pk_fk": { + "name": "show_translations_pk_shows_pk_fk", + "tableFrom": "show_translations", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "show_translations_pk_language_pk": { + "name": "show_translations_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.shows": { + "name": "shows", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "shows_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "show_kind", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "genres": { + "name": "genres", + "type": "genres[]", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "runtime": { + "name": "runtime", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "show_status", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "start_air": { + "name": "start_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "end_air": { + "name": "end_air", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "original": { + "name": "original", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "collection_pk": { + "name": "collection_pk", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "entries_count": { + "name": "entries_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "available_count": { + "name": "available_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "next_refresh": { + "name": "next_refresh", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kind": { + "name": "kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "rating": { + "name": "rating", + "columns": [ + { + "expression": "rating", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "startAir": { + "name": "startAir", + "columns": [ + { + "expression": "start_air", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shows_collection_pk_shows_pk_fk": { + "name": "shows_collection_pk_shows_pk_fk", + "tableFrom": "shows", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["collection_pk"], + "columnsTo": ["pk"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "shows_id_unique": { + "name": "shows_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "shows_slug_unique": { + "name": "shows_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": { + "rating_valid": { + "name": "rating_valid", + "value": "\"kyoo\".\"shows\".\"rating\" between 0 and 100" + }, + "runtime_valid": { + "name": "runtime_valid", + "value": "\"kyoo\".\"shows\".\"runtime\" >= 0" + } + }, + "isRLSEnabled": false + }, + "kyoo.show_studio_join": { + "name": "show_studio_join", + "schema": "kyoo", + "columns": { + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "studio_pk": { + "name": "studio_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "show_studio_join_show_pk_shows_pk_fk": { + "name": "show_studio_join_show_pk_shows_pk_fk", + "tableFrom": "show_studio_join", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "show_studio_join_studio_pk_studios_pk_fk": { + "name": "show_studio_join_studio_pk_studios_pk_fk", + "tableFrom": "show_studio_join", + "tableTo": "studios", + "schemaTo": "kyoo", + "columnsFrom": ["studio_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "show_studio_join_show_pk_studio_pk_pk": { + "name": "show_studio_join_show_pk_studio_pk_pk", + "columns": ["show_pk", "studio_pk"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.studio_translations": { + "name": "studio_translations", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "studio_name_trgm": { + "name": "studio_name_trgm", + "columns": [ + { + "expression": "\"name\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "studio_translations_pk_studios_pk_fk": { + "name": "studio_translations_pk_studios_pk_fk", + "tableFrom": "studio_translations", + "tableTo": "studios", + "schemaTo": "kyoo", + "columnsFrom": ["pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "studio_translations_pk_language_pk": { + "name": "studio_translations_pk_language_pk", + "columns": ["pk", "language"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.studios": { + "name": "studios", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "studios_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "studios_id_unique": { + "name": "studios_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "studios_slug_unique": { + "name": "studios_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.roles": { + "name": "roles", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "roles_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "staff_pk": { + "name": "staff_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "role_kind", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "character": { + "name": "character", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "role_kind": { + "name": "role_kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "role_order": { + "name": "role_order", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "roles_show_pk_shows_pk_fk": { + "name": "roles_show_pk_shows_pk_fk", + "tableFrom": "roles", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "roles_staff_pk_staff_pk_fk": { + "name": "roles_staff_pk_staff_pk_fk", + "tableFrom": "roles", + "tableTo": "staff", + "schemaTo": "kyoo", + "columnsFrom": ["staff_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.staff": { + "name": "staff", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "staff_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latin_name": { + "name": "latin_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "staff_id_unique": { + "name": "staff_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "staff_slug_unique": { + "name": "staff_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.entry_video_join": { + "name": "entry_video_join", + "schema": "kyoo", + "columns": { + "entry_pk": { + "name": "entry_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "video_pk": { + "name": "video_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "entry_video_join_entry_pk_entries_pk_fk": { + "name": "entry_video_join_entry_pk_entries_pk_fk", + "tableFrom": "entry_video_join", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["entry_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entry_video_join_video_pk_videos_pk_fk": { + "name": "entry_video_join_video_pk_videos_pk_fk", + "tableFrom": "entry_video_join", + "tableTo": "videos", + "schemaTo": "kyoo", + "columnsFrom": ["video_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entry_video_join_entry_pk_video_pk_pk": { + "name": "entry_video_join_entry_pk_video_pk_pk", + "columns": ["entry_pk", "video_pk"] + } + }, + "uniqueConstraints": { + "entry_video_join_slug_unique": { + "name": "entry_video_join_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.videos": { + "name": "videos", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "videos_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rendering": { + "name": "rendering", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "part": { + "name": "part", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "guess": { + "name": "guess", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "videos_id_unique": { + "name": "videos_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + }, + "videos_path_unique": { + "name": "videos_path_unique", + "nullsNotDistinct": false, + "columns": ["path"] + } + }, + "policies": {}, + "checkConstraints": { + "part_pos": { + "name": "part_pos", + "value": "\"kyoo\".\"videos\".\"part\" >= 0" + }, + "version_pos": { + "name": "version_pos", + "value": "\"kyoo\".\"videos\".\"version\" >= 0" + } + }, + "isRLSEnabled": false + }, + "kyoo.profiles": { + "name": "profiles", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "profiles_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profiles_id_unique": { + "name": "profiles_id_unique", + "nullsNotDistinct": false, + "columns": ["id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.mqueue": { + "name": "mqueue", + "schema": "kyoo", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mqueue_created": { + "name": "mqueue_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "kyoo.watchlist": { + "name": "watchlist", + "schema": "kyoo", + "columns": { + "profile_pk": { + "name": "profile_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "show_pk": { + "name": "show_pk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "watchlist_status", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true + }, + "seen_count": { + "name": "seen_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_entry": { + "name": "next_entry", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_played_at": { + "name": "last_played_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "watchlist_profile_pk_profiles_pk_fk": { + "name": "watchlist_profile_pk_profiles_pk_fk", + "tableFrom": "watchlist", + "tableTo": "profiles", + "schemaTo": "kyoo", + "columnsFrom": ["profile_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watchlist_show_pk_shows_pk_fk": { + "name": "watchlist_show_pk_shows_pk_fk", + "tableFrom": "watchlist", + "tableTo": "shows", + "schemaTo": "kyoo", + "columnsFrom": ["show_pk"], + "columnsTo": ["pk"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watchlist_next_entry_entries_pk_fk": { + "name": "watchlist_next_entry_entries_pk_fk", + "tableFrom": "watchlist", + "tableTo": "entries", + "schemaTo": "kyoo", + "columnsFrom": ["next_entry"], + "columnsTo": ["pk"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "watchlist_profile_pk_show_pk_pk": { + "name": "watchlist_profile_pk_show_pk_pk", + "columns": ["profile_pk", "show_pk"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "score_percent": { + "name": "score_percent", + "value": "\"kyoo\".\"watchlist\".\"score\" between 0 and 100" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "kyoo.entry_type": { + "name": "entry_type", + "schema": "kyoo", + "values": ["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"] + }, + "kyoo.role_kind": { + "name": "role_kind", + "schema": "kyoo", + "values": ["actor", "director", "writter", "producer", "music", "other"] + }, + "kyoo.watchlist_status": { + "name": "watchlist_status", + "schema": "kyoo", + "values": ["watching", "rewatching", "completed", "dropped", "planned"] + } + }, + "schemas": { + "kyoo": "kyoo" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/api/drizzle/meta/_journal.json b/api/drizzle/meta/_journal.json index 31a27ee9..a16ce389 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1744053556621, "tag": "0018_history", "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1744120518941, + "tag": "0019_nextup", + "breakpoints": true } ] } diff --git a/api/src/base.ts b/api/src/base.ts index ca88e897..b4e285ef 100644 --- a/api/src/base.ts +++ b/api/src/base.ts @@ -3,6 +3,7 @@ import { auth } from "./auth"; import { entriesH } from "./controllers/entries"; import { imagesH } from "./controllers/images"; import { historyH } from "./controllers/profiles/history"; +import { nextup } from "./controllers/profiles/nextup"; import { watchlistH } from "./controllers/profiles/watchlist"; import { seasonsH } from "./controllers/seasons"; import { seed } from "./controllers/seed"; @@ -80,7 +81,10 @@ export const app = new Elysia({ prefix }) .use(seasonsH) .use(studiosH) .use(staffH) - .use(imagesH), + .use(imagesH) + .use(watchlistH) + .use(historyH) + .use(nextup), ) .guard( { @@ -95,6 +99,4 @@ export const app = new Elysia({ prefix }) permissions: ["core.write"], }, (app) => app.use(videosH).use(seed), - ) - .use(watchlistH) - .use(historyH); + ); diff --git a/api/src/controllers/profiles/history.ts b/api/src/controllers/profiles/history.ts index d37febc4..ec605ea1 100644 --- a/api/src/controllers/profiles/history.ts +++ b/api/src/controllers/profiles/history.ts @@ -1,15 +1,4 @@ -import { - and, - count, - eq, - exists, - gt, - isNotNull, - ne, - not, - or, - sql, -} from "drizzle-orm"; +import { and, count, eq, exists, gt, isNotNull, ne, sql } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import Elysia, { t } from "elysia"; import { auth, getUserInfo } from "~/auth"; @@ -181,6 +170,12 @@ export const historyH = new Elysia({ tags: ["profiles"] }) const vals = values( body.map((x) => ({ ...x, entryUseId: isUuid(x.entry) })), ).as("hist"); + const valEqEntries = sql` + case + when hist.entryUseId::boolean then ${entries.id} = hist.entry::uuid + else ${entries.slug} = hist.entry + end + `; const rows = await db .insert(history) @@ -195,19 +190,7 @@ export const historyH = new Elysia({ tags: ["profiles"] }) playedDate: sql`hist.playedDate::timestamptz`, }) .from(vals) - .innerJoin( - entries, - or( - and( - sql`hist.entryUseId::boolean`, - eq(entries.id, sql`hist.entry::uuid`), - ), - and( - not(sql`hist.entryUseId::boolean`), - eq(entries.slug, sql`hist.entry`), - ), - ), - ) + .innerJoin(entries, valEqEntries) .leftJoin(videos, eq(videos.id, sql`hist.videoId::uuid`)), ) .returning({ pk: history.pk }); @@ -223,10 +206,11 @@ export const historyH = new Elysia({ tags: ["profiles"] }) .where( and( eq(nextEntry.showPk, entries.showPk), + ne(nextEntry.kind, "extra"), gt(nextEntry.order, entries.order), ), ) - .orderBy(nextEntry.showPk, entries.order) + .orderBy(nextEntry.order) .limit(1) .as("nextEntryQ"); @@ -250,6 +234,11 @@ export const historyH = new Elysia({ tags: ["profiles"] }) ), ); + const showKindQ = db + .select({ k: shows.kind }) + .from(shows) + .where(eq(shows.pk, sql`excluded.show_pk`)); + await db .insert(watchlist) .select( @@ -273,9 +262,15 @@ export const historyH = new Elysia({ tags: ["profiles"] }) else 0 end `, - nextEntry: nextEntryQ.pk, + nextEntry: sql` + case + when hist.percent::integer >= 95 then ${nextEntryQ.pk} + else ${entries.pk} + end + `, score: sql`null`, startedAt: sql`hist.playedDate::timestamptz`, + lastPlayedAt: sql`hist.playedDate::timestamptz`, completedAt: sql` case when ${nextEntryQ.pk} is null then hist.playedDate::timestamptz @@ -286,19 +281,7 @@ export const historyH = new Elysia({ tags: ["profiles"] }) updatedAt: sql`now()`, }) .from(vals) - .leftJoin( - entries, - or( - and( - sql`hist.entryUseId::boolean`, - eq(entries.id, sql`hist.entry::uuid`), - ), - and( - not(sql`hist.entryUseId::boolean`), - eq(entries.slug, sql`hist.entry`), - ), - ), - ) + .leftJoin(entries, valEqEntries) .leftJoinLateral(nextEntryQ, sql`true`), ) .onConflictDoUpdate({ @@ -314,13 +297,18 @@ export const historyH = new Elysia({ tags: ["profiles"] }) else ${watchlist.status} end `, - seenCount: sql`${seenCountQ}`, + seenCount: sql` + case + when ${showKindQ} = 'movie' then excluded.seen_count + else ${seenCountQ} + end`, nextEntry: sql` case when ${watchlist.status} = 'completed' then null else excluded.next_entry end `, + lastPlayedAt: sql`excluded.last_played_at`, completedAt: coalesce( watchlist.completedAt, sql`excluded.completed_at`, diff --git a/api/src/controllers/profiles/nextup.ts b/api/src/controllers/profiles/nextup.ts new file mode 100644 index 00000000..6df5d360 --- /dev/null +++ b/api/src/controllers/profiles/nextup.ts @@ -0,0 +1,149 @@ +import { and, eq, sql } from "drizzle-orm"; +import Elysia, { t } from "elysia"; +import { auth } from "~/auth"; +import { db } from "~/db"; +import { entries, entryTranslations } from "~/db/schema"; +import { watchlist } from "~/db/schema/watchlist"; +import { getColumns, sqlarr } from "~/db/utils"; +import { Entry } from "~/models/entry"; +import { + AcceptLanguage, + Filter, + type FilterDef, + Page, + Sort, + createPage, + keysetPaginate, + processLanguages, + sortToSql, +} from "~/models/utils"; +import { desc } from "~/models/utils/descriptions"; +import { + entryFilters, + entryProgressQ, + entryVideosQ, + mapProgress, +} from "../entries"; + +const nextupSort = Sort( + // copy pasted from entrySort + adding new stuff + { + order: entries.order, + seasonNumber: entries.seasonNumber, + episodeNumber: entries.episodeNumber, + number: entries.episodeNumber, + airDate: entries.airDate, + + started: watchlist.startedAt, + added: watchlist.createdAt, + lastPlayed: watchlist.lastPlayedAt, + }, + { + default: ["-lastPlayed"], + tablePk: entries.pk, + }, +); + +const nextupFilters: FilterDef = { + ...entryFilters, +}; + +export const nextup = new Elysia({ tags: ["profiles"] }) + .use(auth) + .guard({ + query: t.Object({ + sort: nextupSort, + filter: t.Optional(Filter({ def: nextupFilters })), + 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 })), + }), + }) + .get( + "/profiles/me/nextup", + async ({ + query: { sort, filter, query, limit, after }, + headers: { "accept-language": languages }, + request: { url }, + jwt: { sub }, + }) => { + const langs = processLanguages(languages); + + const transQ = db + .selectDistinctOn([entryTranslations.pk]) + .from(entryTranslations) + .orderBy( + entryTranslations.pk, + sql`array_position(${sqlarr(langs)}, ${entryTranslations.language})`, + ) + .as("t"); + const { pk, name, ...transCol } = getColumns(transQ); + + const { + externalId, + order, + seasonNumber, + episodeNumber, + extraKind, + ...entryCol + } = getColumns(entries); + + const items = await db + .select({ + ...entryCol, + ...transCol, + videos: entryVideosQ.videos, + progress: mapProgress({ aliased: true }), + // specials don't have an `episodeNumber` but a `number` field. + number: episodeNumber, + + // assign more restrained types to make typescript happy. + externalId: sql`${externalId}`, + order: sql`${order}`, + seasonNumber: sql`${seasonNumber}`, + episodeNumber: sql`${episodeNumber}`, + name: sql`${name}`, + }) + .from(entries) + .innerJoin(watchlist, eq(watchlist.nextEntry, entries.pk)) + .innerJoin(transQ, eq(entries.pk, transQ.pk)) + .leftJoinLateral(entryVideosQ, sql`true`) + .leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk)) + .where( + and( + filter, + query ? sql`${transQ.name} %> ${query}::text` : undefined, + keysetPaginate({ after, sort }), + ), + ) + .orderBy( + ...(query + ? [sql`word_similarity(${query}::text, ${transQ.name})`] + : sortToSql(sort)), + entries.pk, + ) + .limit(limit) + .execute({ userId: sub }); + + return createPage(items, { url, sort, limit }); + }, + { + detail: { + description: "", + }, + headers: t.Object( + { + "accept-language": AcceptLanguage({ autoFallback: true }), + }, + { additionalProperties: true }, + ), + response: { + 200: Page(Entry), + }, + }, + ); diff --git a/api/src/controllers/profiles/watchlist.ts b/api/src/controllers/profiles/watchlist.ts index ee3ea827..3e6df843 100644 --- a/api/src/controllers/profiles/watchlist.ts +++ b/api/src/controllers/profiles/watchlist.ts @@ -8,12 +8,14 @@ import { watchStatusQ, } from "~/controllers/shows/logic"; import { db } from "~/db"; -import { shows } from "~/db/schema"; +import { entries, shows } from "~/db/schema"; import { watchlist } from "~/db/schema/watchlist"; import { conflictUpdateAllExcept, getColumns } from "~/db/utils"; +import { Entry } from "~/models/entry"; import { KError } from "~/models/error"; import { bubble, madeInAbyss } from "~/models/examples"; -import { Show } from "~/models/show"; +import { Movie } from "~/models/movie"; +import { Serie } from "~/models/serie"; import { AcceptLanguage, DbMetadata, @@ -32,18 +34,38 @@ async function setWatchStatus({ status, userId, }: { - show: { pk: number; kind: "movie" | "serie" }; - status: SerieWatchStatus; + show: + | { pk: number; kind: "movie" } + | { pk: number; kind: "serie"; entriesCount: number }; + status: Omit; userId: string; }) { const profilePk = await getOrCreateProfile(userId); + const firstEntryQ = db + .select({ pk: entries.pk }) + .from(entries) + .where(eq(entries.showPk, show.pk)) + .orderBy(entries.order) + .limit(1); + const [ret] = await db .insert(watchlist) .values({ ...status, profilePk: profilePk, + seenCount: + status.status === "completed" + ? show.kind === "movie" + ? 100 + : show.entriesCount + : 0, showPk: show.pk, + nextEntry: + status.status === "watching" || status.status === "rewatching" + ? sql`${firstEntryQ}` + : sql`null`, + lastPlayedAt: status.startedAt, }) .onConflictDoUpdate({ target: [watchlist.profilePk, watchlist.showPk], @@ -53,10 +75,32 @@ async function setWatchStatus({ "showPk", "createdAt", "seenCount", + "nextEntry", + "lastPlayedAt", ]), - // do not reset movie's progress during drop - ...(show.kind === "movie" && status.status !== "dropped" - ? { seenCount: sql`excluded.seen_count` } + ...(status.status === "completed" + ? { + seenCount: sql`excluded.seen_count`, + nextEntry: sql`null`, + } + : {}), + // only set seenCount & nextEntry when marking as "rewatching" + // if it's already rewatching, the history updates are more up-dated. + ...(status.status === "rewatching" + ? { + seenCount: sql` + case when ${watchlist.status} != 'rewatching' + then excluded.seen_count + else + ${watchlist.seenCount} + end`, + nextEntry: sql` + case when ${watchlist.status} != 'rewatching' + then excluded.next_entry + else + ${watchlist.nextEntry} + end`, + } : {}), }, }) @@ -115,6 +159,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] }) ), languages: langs, preferOriginal: preferOriginal ?? settings.preferOriginal, + relations: ["nextEntry"], userId: sub, }); return createPage(items, { url, sort, limit }); @@ -128,7 +173,18 @@ export const watchlistH = new Elysia({ tags: ["profiles"] }) { additionalProperties: true }, ), response: { - 200: Page(Show), + 200: Page( + t.Union([ + t.Intersect([Movie, t.Object({ kind: t.Literal("movie") })]), + t.Intersect([ + Serie, + t.Object({ + kind: t.Literal("serie"), + nextEntry: t.Optional(t.Nullable(Entry)), + }), + ]), + ]), + ), 422: KError, }, }, @@ -159,6 +215,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] }) ), languages: langs, preferOriginal: preferOriginal ?? settings.preferOriginal, + relations: ["nextEntry"], userId: uInfo.id, }); return createPage(items, { url, sort, limit }); @@ -179,7 +236,18 @@ export const watchlistH = new Elysia({ tags: ["profiles"] }) "accept-language": AcceptLanguage({ autoFallback: true }), }), response: { - 200: Page(Show), + 200: Page( + t.Union([ + t.Intersect([Movie, t.Object({ kind: t.Literal("movie") })]), + t.Intersect([ + Serie, + t.Object({ + kind: t.Literal("serie"), + nextEntry: t.Optional(t.Nullable(Entry)), + }), + ]), + ]), + ), 403: KError, 404: { ...KError, @@ -195,7 +263,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] }) "/series/:id/watchstatus", async ({ params: { id }, body, jwt: { sub }, error }) => { const [show] = await db - .select({ pk: shows.pk }) + .select({ pk: shows.pk, entriesCount: shows.entriesCount }) .from(shows) .where( and( @@ -211,7 +279,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] }) }); } return await setWatchStatus({ - show: { pk: show.pk, kind: "serie" }, + show: { pk: show.pk, kind: "serie", entriesCount: show.entriesCount }, userId: sub, status: body, }); @@ -224,7 +292,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] }) example: madeInAbyss.slug, }), }), - body: SerieWatchStatus, + body: t.Omit(SerieWatchStatus, ["seenCount"]), response: { 200: t.Intersect([SerieWatchStatus, DbMetadata]), 404: KError, @@ -258,8 +326,6 @@ export const watchlistH = new Elysia({ tags: ["profiles"] }) status: { ...body, startedAt: body.completedAt, - // for movies, watch-percent is stored in `seenCount`. - seenCount: body.status === "completed" ? 100 : 0, }, }); }, diff --git a/api/src/controllers/shows/logic.ts b/api/src/controllers/shows/logic.ts index 40b72b5f..71c6e567 100644 --- a/api/src/controllers/shows/logic.ts +++ b/api/src/controllers/shows/logic.ts @@ -196,11 +196,7 @@ const showRelations = { .limit(1) .as("firstEntry"); }, - nextEntry: ({ - languages, - }: { - languages: string[]; - }) => { + nextEntry: ({ languages }: { languages: string[] }) => { const transQ = db .selectDistinctOn([entryTranslations.pk]) .from(entryTranslations) diff --git a/api/src/db/schema/watchlist.ts b/api/src/db/schema/watchlist.ts index e3a32949..05ea33cf 100644 --- a/api/src/db/schema/watchlist.ts +++ b/api/src/db/schema/watchlist.ts @@ -30,6 +30,7 @@ export const watchlist = schema.table( score: integer(), startedAt: timestamp({ withTimezone: true, mode: "string" }), + lastPlayedAt: timestamp({ withTimezone: true, mode: "string" }), completedAt: timestamp({ withTimezone: true, mode: "string" }), createdAt: timestamp({ withTimezone: true, mode: "string" }) diff --git a/api/src/models/serie.ts b/api/src/models/serie.ts index 3e4c3f2a..8a9bd942 100644 --- a/api/src/models/serie.ts +++ b/api/src/models/serie.ts @@ -82,6 +82,7 @@ export const FullSerie = t.Intersect([ translations: t.Optional(TranslationRecord(SerieTranslation)), studios: t.Optional(t.Array(Studio)), firstEntry: t.Optional(Entry), + nextEntry: t.Optional(t.Nullable(Entry)), }), ]); export type FullSerie = Prettify; diff --git a/api/tests/helpers/series-helper.ts b/api/tests/helpers/series-helper.ts index f367542b..836fce43 100644 --- a/api/tests/helpers/series-helper.ts +++ b/api/tests/helpers/series-helper.ts @@ -184,7 +184,10 @@ export const getNews = async ({ return [resp, body] as const; }; -export const setSerieStatus = async (id: string, status: SerieWatchStatus) => { +export const setSerieStatus = async ( + id: string, + status: Omit, +) => { const resp = await app.handle( new Request(buildUrl(`series/${id}/watchstatus`), { method: "POST", diff --git a/api/tests/helpers/shows-helper.ts b/api/tests/helpers/shows-helper.ts index f1a138dd..80073a54 100644 --- a/api/tests/helpers/shows-helper.ts +++ b/api/tests/helpers/shows-helper.ts @@ -58,3 +58,33 @@ export const getWatchlist = async ( const body = await resp.json(); return [resp, body] as const; }; + +export const getNextup = async ( + id: string, + { + langs, + ...query + }: { + filter?: string; + limit?: number; + after?: string; + sort?: string | string[]; + query?: string; + langs?: string; + preferOriginal?: boolean; + }, +) => { + const resp = await app.handle( + new Request(buildUrl(`profiles/${id}/nextup`, query), { + method: "GET", + headers: langs + ? { + "Accept-Language": langs, + ...(await getJwtHeaders()), + } + : await getJwtHeaders(), + }), + ); + const body = await resp.json(); + return [resp, body] as const; +}; diff --git a/api/tests/series/nextup.test.ts b/api/tests/series/nextup.test.ts new file mode 100644 index 00000000..af25062b --- /dev/null +++ b/api/tests/series/nextup.test.ts @@ -0,0 +1,170 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import { + addToHistory, + createMovie, + createSerie, + getMovie, + getNextup, + getSerie, + getWatchlist, + setMovieStatus, + setSerieStatus, +} from "tests/helpers"; +import { expectStatus } from "tests/utils"; +import { db } from "~/db"; +import { entries, shows, videos } from "~/db/schema"; +import { bubble, madeInAbyss, madeInAbyssVideo } from "~/models/examples"; + +beforeAll(async () => { + await db.delete(shows); + await db.delete(entries); + await db.delete(videos); + // create video beforehand to test linking + await db.insert(videos).values(madeInAbyssVideo); + let [ret, body] = await createSerie(madeInAbyss); + expectStatus(ret, body).toBe(201); + [ret, body] = await createMovie(bubble); + expectStatus(ret, body).toBe(201); +}); + +const miaEntrySlug = `${madeInAbyss.slug}-s1e13`; +const miaNextEntrySlug = `${madeInAbyss.slug}-sp3`; + +describe("nextup", () => { + it("Watchlist populates nextup", async () => { + let [r, b] = await setMovieStatus(bubble.slug, { + status: "watching", + completedAt: null, + score: null, + }); + expectStatus(r, b).toBe(200); + [r, b] = await setSerieStatus(madeInAbyss.slug, { + status: "watching", + startedAt: "2024-12-22", + completedAt: null, + score: null, + }); + expectStatus(r, b).toBe(200); + + // only edit score, shouldn't change order + [r, b] = await setMovieStatus(bubble.slug, { + status: "watching", + completedAt: null, + score: 90, + }); + expectStatus(r, b).toBe(200); + + [r, b] = await getWatchlist("me", {}); + expectStatus(r, b).toBe(200); + expect(b.items).toBeArrayOfSize(2); + + const [resp, body] = await getNextup("me", {}); + expectStatus(resp, body).toBe(200); + expect(body.items).toBeArrayOfSize(2); + expect(body.items[0].slug).toBe(miaEntrySlug); + expect(body.items[0].progress).toMatchObject({ + percent: 0, + }); + expect(body.items[1].slug).toBe(bubble.slug); + expect(body.items[1].progress).toMatchObject({ + percent: 0, + }); + }); + + it("/series/:id?with=nextEntry", async () => { + const [resp, body] = await getSerie(madeInAbyss.slug, { + with: ["nextEntry"], + }); + expectStatus(resp, body).toBe(200); + expect(body.nextEntry).toBeObject(); + expect(body.nextEntry.slug).toBe(miaEntrySlug); + expect(body.nextEntry.progress).toMatchObject({ + percent: 0, + }); + }); + + it("history watching doesn't update", async () => { + let [resp, body] = await addToHistory("me", [ + { + entry: miaEntrySlug, + videoId: madeInAbyssVideo.id, + percent: 58, + time: 28 * 60 + 12, + playedDate: "2025-02-01", + }, + { + entry: bubble.slug, + videoId: null, + percent: 100, + time: 2 * 60, + playedDate: "2025-02-02", + }, + ]); + expectStatus(resp, body).toBe(201); + expect(body.inserted).toBe(2); + + [resp, body] = await getSerie(madeInAbyss.slug, { + with: ["nextEntry"], + }); + expectStatus(resp, body).toBe(200); + expect(body.nextEntry).toBeObject(); + expect(body.nextEntry.slug).toBe(miaEntrySlug); + expect(body.nextEntry.progress).toMatchObject({ + percent: 58, + time: 28 * 60 + 12, + videoId: madeInAbyssVideo.id, + playedDate: "2025-02-01T00:00:00+00:00", + }); + + [resp, body] = await getMovie(bubble.slug, {}); + expectStatus(resp, body).toBe(200); + expect(body.watchStatus).toMatchObject({ + percent: 100, + status: "completed", + completedAt: "2025-02-02 00:00:00+00", + }); + + [resp, body] = await getNextup("me", {}); + expectStatus(resp, body).toBe(200); + expect(body.items).toBeArrayOfSize(1); + expect(body.items[0].slug).toBe(miaEntrySlug); + expect(body.items[0].progress).toMatchObject({ + percent: 58, + time: 28 * 60 + 12, + videoId: madeInAbyssVideo.id, + playedDate: "2025-02-01 00:00:00+00", + }); + }); + + it("history completed picks next", async () => { + let [resp, body] = await addToHistory("me", [ + { + entry: miaEntrySlug, + videoId: madeInAbyssVideo.id, + percent: 98, + time: 28 * 60 + 12, + playedDate: "2025-02-05", + }, + ]); + expectStatus(resp, body).toBe(201); + expect(body.inserted).toBe(1); + + [resp, body] = await getSerie(madeInAbyss.slug, { + with: ["nextEntry"], + }); + expectStatus(resp, body).toBe(200); + expect(body.nextEntry).toBeObject(); + expect(body.nextEntry.slug).toBe(miaNextEntrySlug); + expect(body.nextEntry.progress).toMatchObject({ + percent: 0, + time: 0, + videoId: null, + playedDate: null, + }); + + [resp, body] = await getNextup("me", {}); + expectStatus(resp, body).toBe(200); + expect(body.items).toBeArrayOfSize(1); + expect(body.items[0].slug).toBe(miaNextEntrySlug); + }); +});