From ab5da0d5c60290d4867f095394d26a96b651317f Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 16 Dec 2025 12:17:50 +0100 Subject: [PATCH] Add back entry_pk in history --- api/drizzle/0025_remove-history-entry.sql | 3 - api/drizzle/meta/0025_snapshot.json | 2017 --------------------- api/drizzle/meta/_journal.json | 7 - api/src/controllers/entries.ts | 9 +- api/src/controllers/profiles/history.ts | 105 +- api/src/db/schema/history.ts | 8 +- api/src/models/history.ts | 14 +- api/src/websockets.ts | 14 +- api/tests/series/history.test.ts | 12 +- api/tests/series/nextup.test.ts | 7 +- 10 files changed, 94 insertions(+), 2102 deletions(-) delete mode 100644 api/drizzle/0025_remove-history-entry.sql delete mode 100644 api/drizzle/meta/0025_snapshot.json diff --git a/api/drizzle/0025_remove-history-entry.sql b/api/drizzle/0025_remove-history-entry.sql deleted file mode 100644 index b2cf1383..00000000 --- a/api/drizzle/0025_remove-history-entry.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE "kyoo"."history" DROP CONSTRAINT "history_entry_pk_entries_pk_fk"; ---> statement-breakpoint -ALTER TABLE "kyoo"."history" DROP COLUMN "entry_pk"; \ No newline at end of file diff --git a/api/drizzle/meta/0025_snapshot.json b/api/drizzle/meta/0025_snapshot.json deleted file mode 100644 index 9b1bf7b8..00000000 --- a/api/drizzle/meta/0025_snapshot.json +++ /dev/null @@ -1,2017 +0,0 @@ -{ - "id": "df11ca56-a8a6-4493-9826-bfc83815c91f", - "prevId": "cf102e61-dd73-4664-bbb5-b0b1597ba6e9", - "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 - }, - "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": true, - "default": 0 - }, - "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_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.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 - }, - "priority": { - "name": "priority", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "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.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.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 - }, - "entries_count": { - "name": "entries_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "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": { - "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[]", - "typeSchema": "kyoo", - "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.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.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" - ] - }, - "rendering_unique": { - "name": "rendering_unique", - "nullsNotDistinct": true, - "columns": [ - "rendering", - "part", - "version" - ] - } - }, - "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.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": [ - "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", - "crew", - "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 5e8257f8..f380477f 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -176,13 +176,6 @@ "when": 1763932730557, "tag": "0024_fix-season-count", "breakpoints": true - }, - { - "idx": 25, - "version": "7", - "when": 1765791459003, - "tag": "0025_remove-history-entry", - "breakpoints": true } ] } diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index a29568ab..54c9092a 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -45,19 +45,18 @@ import { desc as description } from "~/models/utils/descriptions"; import type { EmbeddedVideo } from "~/models/video"; export const entryProgressQ = db - .selectDistinctOn([entryVideoJoin.entryPk], { + .selectDistinctOn([history.entryPk], { percent: history.percent, time: history.time, - entryPk: entryVideoJoin.entryPk, + entryPk: history.entryPk, playedDate: history.playedDate, videoId: videos.id, }) .from(history) - .innerJoin(videos, eq(history.videoPk, videos.pk)) - .innerJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk)) + .leftJoin(videos, eq(history.videoPk, videos.pk)) .innerJoin(profiles, eq(history.profilePk, profiles.pk)) .where(eq(profiles.id, sql.placeholder("userId"))) - .orderBy(entryVideoJoin.entryPk, desc(history.playedDate)) + .orderBy(history.entryPk, desc(history.playedDate)) .as("progress"); export const entryFilters: FilterDef = { diff --git a/api/src/controllers/profiles/history.ts b/api/src/controllers/profiles/history.ts index 2e54e5cb..71af81ad 100644 --- a/api/src/controllers/profiles/history.ts +++ b/api/src/controllers/profiles/history.ts @@ -8,19 +8,13 @@ import { lte, ne, sql, + TransactionRollbackError, } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import Elysia, { t } from "elysia"; import { auth, getUserInfo } from "~/auth"; import { db, type Transaction } from "~/db"; -import { - entries, - entryVideoJoin, - history, - profiles, - shows, - videos, -} from "~/db/schema"; +import { entries, history, profiles, shows, videos } from "~/db/schema"; import { watchlist } from "~/db/schema/watchlist"; import { coalesce, sqlarr } from "~/db/utils"; import { Entry } from "~/models/entry"; @@ -30,6 +24,7 @@ import { AcceptLanguage, createPage, Filter, + isUuid, Page, processLanguages, } from "~/models/utils"; @@ -45,19 +40,27 @@ import { import { getOrCreateProfile } from "./profile"; export async function updateProgress(userPk: number, progress: SeedHistory[]) { - return db.transaction(async (tx) => { - const hist = await updateHistory(tx, userPk, progress); - if (hist.created.length + hist.updated.length !== progress.length) { - tx.rollback(); - } - // only return new and entries whose status has changed. - // we don't need to update the watchlist every 10s when watching a video. - await updateWatchlist(tx, userPk, [ - ...hist.created, - ...hist.updated.filter((x) => x.percent >= 95), - ]); - return { status: 201, inserted: hist.created.length }; - }); + try { + return await db.transaction(async (tx) => { + const hist = await updateHistory(tx, userPk, progress); + if (hist.created.length + hist.updated.length !== progress.length) { + tx.rollback(); + } + // only return new and entries whose status has changed. + // we don't need to update the watchlist every 10s when watching a video. + await updateWatchlist(tx, userPk, [ + ...hist.created, + ...hist.updated.filter((x) => x.percent >= 95), + ]); + return { status: 201, inserted: hist.created.length } as const; + }); + } catch (e) { + if (!(e instanceof TransactionRollbackError)) throw e; + return { + status: 404, + message: "Invalid entry id/slug in progress array", + } as const; + } } async function updateHistory( @@ -86,7 +89,9 @@ async function updateHistory( progress.filter((x) => existing.includes(x.videoId)), ); const newEntries = traverse( - progress.filter((x) => !existing.includes(x.videoId)), + progress + .filter((x) => !existing.includes(x.videoId)) + .map((x) => ({ ...x, entryUseid: isUuid(x.entry) })), ); const updated = @@ -113,6 +118,7 @@ async function updateHistory( ), ) .returning({ + entryPk: history.entryPk, videoPk: history.videoPk, percent: history.percent, playedDate: history.playedDate, @@ -128,6 +134,7 @@ async function updateHistory( .select({ profilePk: sql`${userPk}`.as("profilePk"), videoPk: videos.pk, + entryPk: entries.pk, percent: sql`hist.percent`.as("percent"), time: sql`hist.ts`.as("time"), playedDate: coalesce(sql`hist.played_date`, sql`now()`).as( @@ -135,14 +142,26 @@ async function updateHistory( ), }) .from(sql`unnest( + ${sqlarr(newEntries.entry)}::text[], + ${sqlarr(newEntries.entryUseid)}::boolean[], ${sqlarr(newEntries.videoId)}::uuid[], ${sqlarr(newEntries.time)}::integer[], ${sqlarr(newEntries.percent)}::integer[], ${sqlarr(newEntries.playedDate)}::timestamptz[] - ) as hist(video_id, ts, percent, played_date)`) - .innerJoin(videos, eq(videos.id, sql`hist.video_id`)), + ) as hist(entry, entry_use_id, video_id, ts, percent, played_date)`) + .innerJoin( + entries, + sql` + case + when hist.entry_use_id then ${entries.id} = hist.entry::uuid + else ${entries.slug} = hist.entry + end + `, + ) + .leftJoin(videos, eq(videos.id, sql`hist.video_id`)), ) .returning({ + entryPk: history.entryPk, videoPk: history.videoPk, percent: history.percent, playedDate: history.playedDate, @@ -155,7 +174,11 @@ async function updateHistory( async function updateWatchlist( tx: Transaction, userPk: number, - histArr: { videoPk: number; percent: number; playedDate: string }[], + histArr: { + entryPk: number; + percent: number; + playedDate: string; + }[], ) { if (histArr.length === 0) return; @@ -186,14 +209,10 @@ async function updateWatchlist( db .select() .from(history) - .leftJoin( - entryVideoJoin, - eq(history.videoPk, entryVideoJoin.videoPk), - ) .where( and( eq(history.profilePk, userPk), - eq(entryVideoJoin.entryPk, entries.pk), + eq(history.entryPk, entries.pk), ), ), ), @@ -248,15 +267,11 @@ async function updateWatchlist( updatedAt: sql`now()`.as("updatedAt"), }) .from(sql`unnest( - ${sqlarr(hist.videoPk)}::integer[], + ${sqlarr(hist.entryPk)}::integer[], ${sqlarr(hist.percent)}::integer[], ${sqlarr(hist.playedDate)}::timestamptz[] - ) as hist(video_pk, percent, played_date)`) - .innerJoin( - entryVideoJoin, - eq(sql`hist.video_pk`, entryVideoJoin.videoPk), - ) - .leftJoin(entries, eq(entries.pk, entryVideoJoin.entryPk)) + ) as hist(entry_pk, percent, played_date)`) + .innerJoin(entries, eq(entries.pk, sql`hist.entry_pk`)) .leftJoinLateral(nextEntryQ, sql`true`), ) .onConflictDoUpdate({ @@ -297,13 +312,12 @@ const historyProgressQ: typeof entryProgressQ = db .select({ percent: history.percent, time: history.time, - entryPk: entryVideoJoin.entryPk, + entryPk: history.entryPk, playedDate: history.playedDate, videoId: videos.id, }) .from(history) - .innerJoin(videos, eq(history.videoPk, videos.pk)) - .innerJoin(entryVideoJoin, eq(videos.pk, entryVideoJoin.videoPk)) + .leftJoin(videos, eq(history.videoPk, videos.pk)) .innerJoin(profiles, eq(history.profilePk, profiles.pk)) .where(eq(profiles.id, sql.placeholder("userId"))) .as("progress"); @@ -437,11 +451,8 @@ export const historyH = new Elysia({ tags: ["profiles"] }) async ({ body, jwt: { sub }, status }) => { const profilePk = await getOrCreateProfile(sub); - return db.transaction(async (tx) => { - const hist = await updateHistory(tx, profilePk, body); - await updateWatchlist(tx, profilePk, hist); - return status(201, { status: 201, inserted: hist.length }); - }); + const ret = await updateProgress(profilePk, body); + return status(ret.status, ret); }, { detail: { description: "Bulk add entries/movies to your watch history." }, @@ -454,6 +465,10 @@ export const historyH = new Elysia({ tags: ["profiles"] }) description: "The number of history entry inserted", }), }), + 404: { + ...KError, + description: "No entry found with the given id or slug.", + }, 422: KError, }, }, diff --git a/api/src/db/schema/history.ts b/api/src/db/schema/history.ts index 2e01e0a4..3df6258d 100644 --- a/api/src/db/schema/history.ts +++ b/api/src/db/schema/history.ts @@ -1,5 +1,6 @@ import { sql } from "drizzle-orm"; import { check, index, integer } from "drizzle-orm/pg-core"; +import { entries } from "./entries"; import { profiles } from "./profiles"; import { schema, timestamp } from "./utils"; import { videos } from "./videos"; @@ -11,7 +12,12 @@ export const history = schema.table( profilePk: integer() .notNull() .references(() => profiles.pk, { onDelete: "cascade" }), - videoPk: integer().notNull().references(() => videos.pk, { onDelete: "cascade" }), + // we need to attach an history to an entry because we want to keep history + // when we delete a video file + entryPk: integer() + .notNull() + .references(() => entries.pk, { onDelete: "cascade" }), + videoPk: integer().references(() => videos.pk, { onDelete: "set null" }), percent: integer().notNull().default(0), time: integer().notNull().default(0), playedDate: timestamp({ withTimezone: true, mode: "iso" }) diff --git a/api/src/models/history.ts b/api/src/models/history.ts index de6189a9..34064ff0 100644 --- a/api/src/models/history.ts +++ b/api/src/models/history.ts @@ -27,10 +27,12 @@ export const Progress = t.Object({ }); export type Progress = typeof Progress.static; -export const SeedHistory = t.Object({ - percent: Progress.properties.percent, - time: Progress.properties.time, - playedDate: Progress.properties.playedDate, - videoId: Progress.properties.videoId.anyOf[0], -}); +export const SeedHistory = t.Intersect([ + Progress, + t.Object({ + entry: t.String({ + description: "Id or slug of the entry/movie you watched", + }), + }), +]); export type SeedHistory = typeof SeedHistory.static; diff --git a/api/src/websockets.ts b/api/src/websockets.ts index 881efaf5..10da619c 100644 --- a/api/src/websockets.ts +++ b/api/src/websockets.ts @@ -1,9 +1,8 @@ import type { TObject, TString } from "@sinclair/typebox"; import Elysia, { type TSchema, t } from "elysia"; import { verifyJwt } from "./auth"; -import { updateHistory, updateWatchlist } from "./controllers/profiles/history"; +import { updateProgress } from "./controllers/profiles/history"; import { getOrCreateProfile } from "./controllers/profiles/profile"; -import { db } from "./db"; import { SeedHistory } from "./models/history"; const actionMap = { @@ -18,13 +17,10 @@ const actionMap = { async message(ws, body) { const profilePk = await getOrCreateProfile(ws.data.jwt.sub); - await db.transaction(async (tx) => { - const hist = await updateHistory(tx, profilePk, [ - { ...body, playedDate: null }, - ]); - await updateWatchlist(tx, profilePk, hist); - }); - ws.send({ response: "ok" }); + const ret = await updateProgress(profilePk, [ + { ...body, playedDate: null }, + ]); + ws.send(ret); }, }), }; diff --git a/api/tests/series/history.test.ts b/api/tests/series/history.test.ts index 0f2556fe..12b7cc2b 100644 --- a/api/tests/series/history.test.ts +++ b/api/tests/series/history.test.ts @@ -11,12 +11,7 @@ import { import { expectStatus } from "tests/utils"; import { db } from "~/db"; import { entries, shows, videos } from "~/db/schema"; -import { - bubble, - bubbleVideo, - madeInAbyss, - madeInAbyssVideo, -} from "~/models/examples"; +import { bubble, madeInAbyss, madeInAbyssVideo } from "~/models/examples"; beforeAll(async () => { await db.delete(shows); @@ -40,13 +35,15 @@ describe("Set & get history", () => { const [r, b] = await addToHistory("me", [ { + entry: miaEntrySlug, videoId: madeInAbyssVideo.id, percent: 58, time: 28 * 60 + 12, playedDate: "2025-02-01", }, { - videoId: bubbleVideo.id, + entry: bubble.slug, + videoId: null, percent: 100, time: 2 * 60, playedDate: "2025-02-02", @@ -73,6 +70,7 @@ describe("Set & get history", () => { it("Create duplicated history entry", async () => { const [r, b] = await addToHistory("me", [ { + entry: miaEntrySlug!, videoId: madeInAbyssVideo.id, percent: 100, time: 38 * 60, diff --git a/api/tests/series/nextup.test.ts b/api/tests/series/nextup.test.ts index ea6724db..b10ef224 100644 --- a/api/tests/series/nextup.test.ts +++ b/api/tests/series/nextup.test.ts @@ -13,7 +13,7 @@ import { import { expectStatus } from "tests/utils"; import { db } from "~/db"; import { entries, shows, videos } from "~/db/schema"; -import { bubble, bubbleVideo, madeInAbyss, madeInAbyssVideo } from "~/models/examples"; +import { bubble, madeInAbyss, madeInAbyssVideo } from "~/models/examples"; beforeAll(async () => { await db.delete(shows); @@ -86,13 +86,15 @@ describe("nextup", () => { 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", }, { - videoId: bubbleVideo.id, + entry: bubble.slug, + videoId: null, percent: 100, time: 2 * 60, playedDate: "2025-02-02", @@ -137,6 +139,7 @@ describe("nextup", () => { it("history completed picks next", async () => { let [resp, body] = await addToHistory("me", [ { + entry: miaEntrySlug, videoId: madeInAbyssVideo.id, percent: 98, time: 28 * 60 + 12,