From 5ed1234ac57579483ee0eaaade658fd5d40294b9 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 3 Apr 2026 17:30:38 +0200 Subject: [PATCH 01/12] Hide unaired nextups --- api/src/controllers/profiles/nextup.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/src/controllers/profiles/nextup.ts b/api/src/controllers/profiles/nextup.ts index f7ec7264..8107f4bd 100644 --- a/api/src/controllers/profiles/nextup.ts +++ b/api/src/controllers/profiles/nextup.ts @@ -1,4 +1,4 @@ -import { and, eq, sql } from "drizzle-orm"; +import { and, eq, isNotNull, lt, or, sql } from "drizzle-orm"; import Elysia, { t } from "elysia"; import { auth } from "~/auth"; import { db } from "~/db"; @@ -118,6 +118,10 @@ export const nextup = new Elysia({ tags: ["profiles"] }) .leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk)) .where( and( + or( + lt(entries.airDate, sql`now()`), + isNotNull(entries.availableSince), + ), filter, query ? sql`${transQ.name} %> ${query}::text` : undefined, keysetPaginate({ after, sort }), From 29f731a64cbd638b91957c99a2b660d480142080 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 3 Apr 2026 17:30:38 +0200 Subject: [PATCH 02/12] Add `criticalToStory` bool in entries --- api/drizzle/0031_critical.sql | 3 + api/drizzle/meta/0031_snapshot.json | 2022 +++++++++++++++++++++++ api/drizzle/meta/_journal.json | 7 + api/src/controllers/profiles/history.ts | 1 + api/src/controllers/videos.ts | 1 + api/src/db/schema/entries.ts | 2 + api/src/models/entry/base-entry.ts | 1 + front/src/models/entry.ts | 2 + 8 files changed, 2039 insertions(+) create mode 100644 api/drizzle/0031_critical.sql create mode 100644 api/drizzle/meta/0031_snapshot.json diff --git a/api/drizzle/0031_critical.sql b/api/drizzle/0031_critical.sql new file mode 100644 index 00000000..95469536 --- /dev/null +++ b/api/drizzle/0031_critical.sql @@ -0,0 +1,3 @@ +ALTER TABLE "kyoo"."entries" ADD COLUMN "critical_to_story" boolean;--> statement-breakpoint +UPDATE "kyoo"."entries" SET "critical_to_story" = true;--> statement-breakpoint +ALTER TABLE "kyoo"."entries" ALTER COLUMN "critical_to_story" SET NOT NULl;--> statement-breakpoint diff --git a/api/drizzle/meta/0031_snapshot.json b/api/drizzle/meta/0031_snapshot.json new file mode 100644 index 00000000..ce4f959b --- /dev/null +++ b/api/drizzle/meta/0031_snapshot.json @@ -0,0 +1,2022 @@ +{ + "id": "bb9072e3-4c3a-44a0-8dae-f21259a5172b", + "prevId": "34fcf5bf-c0a7-4730-a705-0e7fe759d126", + "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 + }, + "critical_to_story": { + "name": "critical_to_story", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "available_since": { + "name": "available_since", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "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": true, + "default": 0 + }, + "played_date": { + "name": "played_date", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "external": { + "name": "external", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "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.images": { + "name": "images", + "schema": "kyoo", + "columns": { + "pk": { + "name": "pk", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "images_pk_seq", + "schema": "kyoo", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "id": { + "name": "id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blurhash": { + "name": "blurhash", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "targets": { + "name": "targets", + "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 + }, + "status": { + "name": "status", + "type": "img_status", + "typeSchema": "kyoo", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "downloaded_at": { + "name": "downloaded_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "imgqueue_sort": { + "name": "imgqueue_sort", + "columns": [ + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "images_id_unique": { + "name": "images_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 + }, + "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 (3) 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 (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) 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": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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 (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "next_refresh": { + "name": "next_refresh", + "type": "date", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kind": { + "name": "kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hash", + "with": {} + }, + "slug": { + "name": "slug", + "columns": [ + { + "expression": "slug", + "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"] + }, + "kind_slug": { + "name": "kind_slug", + "nullsNotDistinct": false, + "columns": ["kind", "slug"] + } + }, + "policies": {}, + "checkConstraints": { + "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 (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) 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 (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) 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": { + "evj_video_pk": { + "name": "evj_video_pk", + "columns": [ + { + "expression": "video_pk", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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 (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) 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 (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_played_at": { + "name": "last_played_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) 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.img_status": { + "name": "img_status", + "schema": "kyoo", + "values": ["pending", "link", "ready"] + }, + "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 67e517a9..cb73196e 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -218,6 +218,13 @@ "when": 1774974162419, "tag": "0030_external_hist", "breakpoints": true + }, + { + "idx": 31, + "version": "7", + "when": 1775236194683, + "tag": "0031_critical", + "breakpoints": true } ] } diff --git a/api/src/controllers/profiles/history.ts b/api/src/controllers/profiles/history.ts index c8c2155a..9222e324 100644 --- a/api/src/controllers/profiles/history.ts +++ b/api/src/controllers/profiles/history.ts @@ -198,6 +198,7 @@ async function updateWatchlist( and( eq(nextEntry.showPk, entries.showPk), ne(nextEntry.kind, "extra"), + nextEntry.criticalToStory, gt(nextEntry.order, entries.order), ), ) diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index d670d55f..d2d934d6 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -277,6 +277,7 @@ function getNextVideoEntry({ eq(vids.part, sql`${videos.part} ${sql.raw(prev ? "-" : "+")} 1`), ), ), + entries.criticalToStory, ), ) .orderBy( diff --git a/api/src/db/schema/entries.ts b/api/src/db/schema/entries.ts index e35309ae..8bd5e6ab 100644 --- a/api/src/db/schema/entries.ts +++ b/api/src/db/schema/entries.ts @@ -1,5 +1,6 @@ import { relations, sql } from "drizzle-orm"; import { + boolean, check, date, index, @@ -68,6 +69,7 @@ export const entries = schema.table( airDate: date(), runtime: integer(), thumbnail: image(), + criticalToStory: boolean().notNull(), externalId: entry_extid(), diff --git a/api/src/models/entry/base-entry.ts b/api/src/models/entry/base-entry.ts index 99d24df2..f66944b5 100644 --- a/api/src/models/entry/base-entry.ts +++ b/api/src/models/entry/base-entry.ts @@ -11,6 +11,7 @@ export const BaseEntry = () => }), ), thumbnail: t.Nullable(Image), + criticalToStory: t.Boolean(), }); export const EntryTranslation = () => diff --git a/front/src/models/entry.ts b/front/src/models/entry.ts index 59816537..a8f47788 100644 --- a/front/src/models/entry.ts +++ b/front/src/models/entry.ts @@ -14,6 +14,8 @@ const Base = z.object({ runtime: z.number().nullable(), thumbnail: KImage.nullable(), + criticalToStory: z.boolean(), + createdAt: zdate(), updatedAt: zdate(), From 7bfe463cc2cd1dd52ef8a14b4e81fa3992a0f1a2 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 3 Apr 2026 18:46:09 +0200 Subject: [PATCH 03/12] Don't skip episodes in `/api/videos/:slug?with=next` --- api/src/controllers/videos.ts | 2 +- api/src/models/full-video.ts | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index d2d934d6..063312a6 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -247,7 +247,7 @@ function getNextVideoEntry({ .leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk)) .crossJoinLateral(entryVideosQ) .leftJoin(entryVideoJoin, eq(entries.pk, entryVideoJoin.entryPk)) - .innerJoin(vids, eq(vids.pk, entryVideoJoin.videoPk)) + .leftJoin(vids, eq(vids.pk, entryVideoJoin.videoPk)) .where( and( // either way it needs to be of the same show diff --git a/api/src/models/full-video.ts b/api/src/models/full-video.ts index bf94237c..a63bf4e6 100644 --- a/api/src/models/full-video.ts +++ b/api/src/models/full-video.ts @@ -16,10 +16,12 @@ export const FullVideo = t.Composite([ previous: t.Optional( t.Nullable( t.Object({ - video: t.String({ - format: "slug", - examples: ["made-in-abyss-s1e12"], - }), + video: t.Nullable( + t.String({ + format: "slug", + examples: ["made-in-abyss-s1e12"], + }), + ), entry: Entry, }), ), @@ -27,10 +29,12 @@ export const FullVideo = t.Composite([ next: t.Optional( t.Nullable( t.Object({ - video: t.String({ - format: "slug", - examples: ["made-in-abyss-dawn-of-the-deep-soul"], - }), + video: t.Nullable( + t.String({ + format: "slug", + examples: ["made-in-abyss-dawn-of-the-deep-soul"], + }), + ), entry: Entry, }), ), From d25297e604814a4cb38ff403809c5e0ce7ff8f62 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 3 Apr 2026 18:46:09 +0200 Subject: [PATCH 04/12] Handle critical to story in scanner --- scanner/scanner/models/entry.py | 1 + scanner/scanner/providers/themoviedatabase.py | 1 + scanner/scanner/providers/thetvdb.py | 2 ++ 3 files changed, 4 insertions(+) diff --git a/scanner/scanner/models/entry.py b/scanner/scanner/models/entry.py index b8f4071f..84902e26 100644 --- a/scanner/scanner/models/entry.py +++ b/scanner/scanner/models/entry.py @@ -15,6 +15,7 @@ class Entry(Model): runtime: int | None air_date: date | None thumbnail: str | None + criticalToStory: bool # Movie-specific fields slug: str | None diff --git a/scanner/scanner/providers/themoviedatabase.py b/scanner/scanner/providers/themoviedatabase.py index 0d4fe60c..5ded09b0 100644 --- a/scanner/scanner/providers/themoviedatabase.py +++ b/scanner/scanner/providers/themoviedatabase.py @@ -556,6 +556,7 @@ class TheMovieDatabase(Provider): if episode["air_date"] else None, thumbnail=self._map_image(episode["still_path"]), + criticalToStory=True, slug=None, season_number=episode["season_number"], episode_number=episode["episode_number"], diff --git a/scanner/scanner/providers/thetvdb.py b/scanner/scanner/providers/thetvdb.py index 5d3eb21c..8ffde797 100644 --- a/scanner/scanner/providers/thetvdb.py +++ b/scanner/scanner/providers/thetvdb.py @@ -541,6 +541,8 @@ class TVDB(Provider): thumbnail=f"https://artworks.thetvdb.com{entry['image']}" if entry["image"] else None, + # Mark specials as non-critical, waiting for https://github.com/thetvdb/v4-api/issues/350 + criticalToStory=entry["seasonNumber"] != 0 or entry["isMovie"], slug=None, season_number=entry["seasonNumber"], episode_number=entry["number"], From c7003d4bd8e832d80f7d80edf55907f14185296a Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 3 Apr 2026 19:26:21 +0200 Subject: [PATCH 05/12] Fix specials orders with tvdb --- scanner/scanner/providers/thetvdb.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scanner/scanner/providers/thetvdb.py b/scanner/scanner/providers/thetvdb.py index 8ffde797..3590fa63 100644 --- a/scanner/scanner/providers/thetvdb.py +++ b/scanner/scanner/providers/thetvdb.py @@ -622,7 +622,7 @@ class TVDB(Provider): ) # handle specials and such that are between seasons - for entry in ret: + for entry in reversed(ret): if entry.order != 0: continue @@ -637,7 +637,11 @@ class TVDB(Provider): ) after = min((x.order for x in ret if x.order > before), default=before) entry.order = (before + after) / 2 - elif entry.extra["airs_before_season"] is not None: + + for entry in ret: + if entry.order != 0: + continue + if entry.extra["airs_before_season"] is not None: before = ( next( ( From d1aeadacee4f7f1387f6f2da518ea177be0e4c2d Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 3 Apr 2026 19:26:21 +0200 Subject: [PATCH 06/12] Rename `criticalToStory` to `content` enum --- api/drizzle/0031_critical.sql | 3 --- api/drizzle/0031_entry-content.sql | 4 ++++ api/drizzle/meta/0031_snapshot.json | 14 ++++++++++---- api/drizzle/meta/_journal.json | 4 ++-- api/src/controllers/profiles/history.ts | 2 +- api/src/controllers/seed/movies.ts | 1 + api/src/controllers/videos.ts | 2 +- api/src/db/schema/entries.ts | 10 ++++++++-- api/src/models/entry/base-entry.ts | 9 ++++++++- api/src/models/entry/extra.ts | 2 +- api/src/models/examples/made-in-abyss.ts | 7 +++++++ front/src/models/entry.ts | 2 +- scanner/scanner/models/entry.py | 10 +++++++++- scanner/scanner/providers/themoviedatabase.py | 4 ++-- scanner/scanner/providers/thetvdb.py | 10 +++++++--- 15 files changed, 62 insertions(+), 22 deletions(-) delete mode 100644 api/drizzle/0031_critical.sql create mode 100644 api/drizzle/0031_entry-content.sql diff --git a/api/drizzle/0031_critical.sql b/api/drizzle/0031_critical.sql deleted file mode 100644 index 95469536..00000000 --- a/api/drizzle/0031_critical.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE "kyoo"."entries" ADD COLUMN "critical_to_story" boolean;--> statement-breakpoint -UPDATE "kyoo"."entries" SET "critical_to_story" = true;--> statement-breakpoint -ALTER TABLE "kyoo"."entries" ALTER COLUMN "critical_to_story" SET NOT NULl;--> statement-breakpoint diff --git a/api/drizzle/0031_entry-content.sql b/api/drizzle/0031_entry-content.sql new file mode 100644 index 00000000..9236cb5f --- /dev/null +++ b/api/drizzle/0031_entry-content.sql @@ -0,0 +1,4 @@ +CREATE TYPE "kyoo"."entry_content" AS ENUM('story', 'recap', 'filler', 'ova');--> statement-breakpoint +ALTER TABLE "kyoo"."entries" ADD COLUMN "content" "kyoo"."entry_content";--> statement-breakpoint +UPDATE "kyoo"."entries" SET content = 'story';-->statement-breakpoint +ALTER TABLE "kyoo"."entries" ALTER COLUMN "content" SET NOT NULl;--> statement-breakpoint diff --git a/api/drizzle/meta/0031_snapshot.json b/api/drizzle/meta/0031_snapshot.json index ce4f959b..265515ea 100644 --- a/api/drizzle/meta/0031_snapshot.json +++ b/api/drizzle/meta/0031_snapshot.json @@ -1,5 +1,5 @@ { - "id": "bb9072e3-4c3a-44a0-8dae-f21259a5172b", + "id": "13b1ed8d-f166-4d39-ba67-39e8c4cfd786", "prevId": "34fcf5bf-c0a7-4730-a705-0e7fe759d126", "version": "7", "dialect": "postgresql", @@ -93,9 +93,10 @@ "primaryKey": false, "notNull": false }, - "critical_to_story": { - "name": "critical_to_story", - "type": "boolean", + "content": { + "name": "content", + "type": "entry_content", + "typeSchema": "kyoo", "primaryKey": false, "notNull": true }, @@ -1939,6 +1940,11 @@ } }, "enums": { + "kyoo.entry_content": { + "name": "entry_content", + "schema": "kyoo", + "values": ["story", "recap", "filler", "ova"] + }, "kyoo.entry_type": { "name": "entry_type", "schema": "kyoo", diff --git a/api/drizzle/meta/_journal.json b/api/drizzle/meta/_journal.json index cb73196e..8f855cf1 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -222,8 +222,8 @@ { "idx": 31, "version": "7", - "when": 1775236194683, - "tag": "0031_critical", + "when": 1775238108619, + "tag": "0031_entry-content", "breakpoints": true } ] diff --git a/api/src/controllers/profiles/history.ts b/api/src/controllers/profiles/history.ts index 9222e324..83207fb8 100644 --- a/api/src/controllers/profiles/history.ts +++ b/api/src/controllers/profiles/history.ts @@ -198,7 +198,7 @@ async function updateWatchlist( and( eq(nextEntry.showPk, entries.showPk), ne(nextEntry.kind, "extra"), - nextEntry.criticalToStory, + eq(nextEntry.content, "story"), gt(nextEntry.order, entries.order), ), ) diff --git a/api/src/controllers/seed/movies.ts b/api/src/controllers/seed/movies.ts index cf95c312..60b759f6 100644 --- a/api/src/controllers/seed/movies.ts +++ b/api/src/controllers/seed/movies.ts @@ -96,6 +96,7 @@ export const seedMovie = async ( { ...movie, kind: "movie", + content: "story", order: 1, thumbnail: (movie.originalLanguage ? translations[movie.originalLanguage] diff --git a/api/src/controllers/videos.ts b/api/src/controllers/videos.ts index 063312a6..e7a877ec 100644 --- a/api/src/controllers/videos.ts +++ b/api/src/controllers/videos.ts @@ -277,7 +277,7 @@ function getNextVideoEntry({ eq(vids.part, sql`${videos.part} ${sql.raw(prev ? "-" : "+")} 1`), ), ), - entries.criticalToStory, + eq(entries.content, "story"), ), ) .orderBy( diff --git a/api/src/db/schema/entries.ts b/api/src/db/schema/entries.ts index 8bd5e6ab..1a20562e 100644 --- a/api/src/db/schema/entries.ts +++ b/api/src/db/schema/entries.ts @@ -1,6 +1,5 @@ import { relations, sql } from "drizzle-orm"; import { - boolean, check, date, index, @@ -25,6 +24,13 @@ export const entryType = schema.enum("entry_type", [ "extra", ]); +export const entryContent = schema.enum("entry_content", [ + "story", + "recap", + "filler", + "ova", +]); + export const entry_extid = () => jsonb() .$type< @@ -69,7 +75,7 @@ export const entries = schema.table( airDate: date(), runtime: integer(), thumbnail: image(), - criticalToStory: boolean().notNull(), + content: entryContent().notNull(), externalId: entry_extid(), diff --git a/api/src/models/entry/base-entry.ts b/api/src/models/entry/base-entry.ts index f66944b5..435cb10d 100644 --- a/api/src/models/entry/base-entry.ts +++ b/api/src/models/entry/base-entry.ts @@ -1,6 +1,13 @@ import { t } from "elysia"; import { Image } from "../utils/image"; +export const EntryContent = t.Union([ + t.Literal("story"), + t.Literal("recap"), + t.Literal("filler"), + t.Literal("ova"), +]); + export const BaseEntry = () => t.Object({ airDate: t.Nullable(t.String({ format: "date" })), @@ -11,7 +18,7 @@ export const BaseEntry = () => }), ), thumbnail: t.Nullable(Image), - criticalToStory: t.Boolean(), + content: EntryContent, }); export const EntryTranslation = () => diff --git a/api/src/models/entry/extra.ts b/api/src/models/entry/extra.ts index ed951ab1..d0446408 100644 --- a/api/src/models/entry/extra.ts +++ b/api/src/models/entry/extra.ts @@ -22,7 +22,7 @@ export const BaseExtra = t.Composite( kind: ExtraType, name: t.String(), }), - t.Omit(BaseEntry(), ["nextRefresh", "airDate"]), + t.Omit(BaseEntry(), ["nextRefresh", "airDate", "content"]), ], { description: comment` diff --git a/api/src/models/examples/made-in-abyss.ts b/api/src/models/examples/made-in-abyss.ts index da3b55fa..72c9f3d0 100644 --- a/api/src/models/examples/made-in-abyss.ts +++ b/api/src/models/examples/made-in-abyss.ts @@ -175,6 +175,7 @@ export const madeInAbyss = { entries: [ { kind: "episode", + content: "story", order: 13, seasonNumber: 1, episodeNumber: 13, @@ -203,6 +204,7 @@ export const madeInAbyss = { }, { kind: "special", + content: "ova", // between s1e13 & movie (which has 13.5 for the `order field`) order: 13.25, number: 3, @@ -230,6 +232,7 @@ export const madeInAbyss = { }, { kind: "movie", + content: "story", slug: "made-in-abyss-dawn-of-the-deep-soul", order: 13.5, translations: { @@ -257,6 +260,7 @@ export const madeInAbyss = { }, { kind: "episode", + content: "story", order: 14, seasonNumber: 2, episodeNumber: 1, @@ -284,6 +288,7 @@ export const madeInAbyss = { }, { kind: "episode", + content: "story", order: 15, seasonNumber: 2, episodeNumber: 2, @@ -311,6 +316,7 @@ export const madeInAbyss = { }, { kind: "episode", + content: "story", order: 16, seasonNumber: 2, episodeNumber: 3, @@ -338,6 +344,7 @@ export const madeInAbyss = { }, { kind: "episode", + content: "story", order: 17, seasonNumber: 2, episodeNumber: 4, diff --git a/front/src/models/entry.ts b/front/src/models/entry.ts index a8f47788..6f0c2c24 100644 --- a/front/src/models/entry.ts +++ b/front/src/models/entry.ts @@ -14,7 +14,7 @@ const Base = z.object({ runtime: z.number().nullable(), thumbnail: KImage.nullable(), - criticalToStory: z.boolean(), + content: z.enum(["story", "recap", "filler", "ova"]), createdAt: zdate(), updatedAt: zdate(), diff --git a/scanner/scanner/models/entry.py b/scanner/scanner/models/entry.py index 84902e26..96a8787e 100644 --- a/scanner/scanner/models/entry.py +++ b/scanner/scanner/models/entry.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import date +from enum import Enum from typing import Any, Literal from pydantic import Field @@ -9,13 +10,20 @@ from ..utils import Language, Model from .metadataid import EpisodeId, MetadataId +class EntryContent(str, Enum): + STORY = "story" + RECAP = "recap" + FILLER = "filler" + OVA = "ova" + + class Entry(Model): kind: Literal["episode", "movie", "special"] order: float runtime: int | None air_date: date | None thumbnail: str | None - criticalToStory: bool + content: EntryContent # Movie-specific fields slug: str | None diff --git a/scanner/scanner/providers/themoviedatabase.py b/scanner/scanner/providers/themoviedatabase.py index 5ded09b0..8c470020 100644 --- a/scanner/scanner/providers/themoviedatabase.py +++ b/scanner/scanner/providers/themoviedatabase.py @@ -11,7 +11,7 @@ from aiohttp import ClientResponseError, ClientSession from langcodes import Language from ..models.collection import Collection, CollectionTranslation -from ..models.entry import Entry, EntryTranslation +from ..models.entry import Entry, EntryContent, EntryTranslation from ..models.genre import Genre from ..models.metadataid import EpisodeId, MetadataId, SeasonId from ..models.movie import Movie, MovieStatus, MovieTranslation, SearchMovie @@ -556,7 +556,7 @@ class TheMovieDatabase(Provider): if episode["air_date"] else None, thumbnail=self._map_image(episode["still_path"]), - criticalToStory=True, + content=EntryContent.STORY, slug=None, season_number=episode["season_number"], episode_number=episode["episode_number"], diff --git a/scanner/scanner/providers/thetvdb.py b/scanner/scanner/providers/thetvdb.py index 3590fa63..54351e0e 100644 --- a/scanner/scanner/providers/thetvdb.py +++ b/scanner/scanner/providers/thetvdb.py @@ -11,7 +11,7 @@ from langcodes.data_dicts import LANGUAGE_REPLACEMENTS from ..cache import cache from ..models.collection import Collection, CollectionTranslation -from ..models.entry import Entry, EntryTranslation +from ..models.entry import Entry, EntryContent, EntryTranslation from ..models.genre import Genre from ..models.metadataid import EpisodeId, MetadataId, SeasonId from ..models.movie import Movie, MovieStatus, MovieTranslation, SearchMovie @@ -541,8 +541,12 @@ class TVDB(Provider): thumbnail=f"https://artworks.thetvdb.com{entry['image']}" if entry["image"] else None, - # Mark specials as non-critical, waiting for https://github.com/thetvdb/v4-api/issues/350 - criticalToStory=entry["seasonNumber"] != 0 or entry["isMovie"], + # Mark specials as ova, waiting for https://github.com/thetvdb/v4-api/issues/350 + content=( + EntryContent.STORY + if entry["seasonNumber"] != 0 or entry["isMovie"] + else EntryContent.OVA + ), slug=None, season_number=entry["seasonNumber"], episode_number=entry["number"], From 6d8e60820e28c460612162f56ff8d484264616bc Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 3 Apr 2026 19:58:06 +0200 Subject: [PATCH 07/12] Filter out non-critcal non-available episodes --- api/src/controllers/entries.ts | 1 + api/src/models/entry/base-entry.ts | 7 +------ front/src/ui/details/season.tsx | 9 +++++++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index 70913a45..ecfd15b2 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -83,6 +83,7 @@ export const entryFilters: FilterDef = { order: { column: entries.order, type: "float" }, runtime: { column: entries.runtime, type: "float" }, airDate: { column: entries.airDate, type: "date" }, + content: { column: entries.content, type: "enum", values: EntryContent.enum }, playedDate: { column: entryProgressQ.playedDate, type: "date" }, isAvailable: { column: isNotNull(entries.availableSince), type: "bool" }, }; diff --git a/api/src/models/entry/base-entry.ts b/api/src/models/entry/base-entry.ts index 435cb10d..f98e0784 100644 --- a/api/src/models/entry/base-entry.ts +++ b/api/src/models/entry/base-entry.ts @@ -1,12 +1,7 @@ import { t } from "elysia"; import { Image } from "../utils/image"; -export const EntryContent = t.Union([ - t.Literal("story"), - t.Literal("recap"), - t.Literal("filler"), - t.Literal("ova"), -]); +export const EntryContent = t.UnionEnum(["story", "recap", "filler", "ova"]); export const BaseEntry = () => t.Object({ diff --git a/front/src/ui/details/season.tsx b/front/src/ui/details/season.tsx index 79599655..d18726ce 100644 --- a/front/src/ui/details/season.tsx +++ b/front/src/ui/details/season.tsx @@ -246,8 +246,13 @@ EntryList.query = ( path: ["api", "series", slug, "entries"], params: { query, - // TODO: use a better filter, it removes specials and movies - filter: season ? `seasonNumber ge ${season}` : undefined, + filter: [ + // TODO: use a better filter, it removes specials and movies + season && `seasonNumber ge ${season}`, + "(kind eq episode or isAvailable eq true or content eq story)", + ] + .filter((x) => x) + .join(" and "), includeSeasons: true, }, infinite: true, From 066e50f8b58d7f8a0d5f46b23262e9fbae5eeebf Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 4 Apr 2026 10:51:56 +0200 Subject: [PATCH 08/12] Add borders on hover to staff cards --- front/src/ui/details/staff.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/front/src/ui/details/staff.tsx b/front/src/ui/details/staff.tsx index 79b0b4c4..41a2fa39 100644 --- a/front/src/ui/details/staff.tsx +++ b/front/src/ui/details/staff.tsx @@ -21,11 +21,17 @@ export const CharacterCard = ({ return ( -

+

{name}

From c8c23a8f65b8254fe38eca84e8c73f9ffa1df83c Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 4 Apr 2026 10:51:56 +0200 Subject: [PATCH 09/12] Handle missing video files in player --- front/public/translations/en.json | 6 ++- front/src/models/video.ts | 10 ++++- front/src/ui/player/controls/error-popup.tsx | 29 +++++++++++++ front/src/ui/player/controls/index.tsx | 4 +- front/src/ui/player/index.tsx | 43 ++++++++++++++++---- 5 files changed, 79 insertions(+), 13 deletions(-) create mode 100644 front/src/ui/player/controls/error-popup.tsx diff --git a/front/public/translations/en.json b/front/public/translations/en.json index 6fe0a950..e7992e29 100644 --- a/front/public/translations/en.json +++ b/front/public/translations/en.json @@ -49,7 +49,7 @@ "part": "Part {{number}}", "videos-map": "Edit video mappings", "remap": "Remap", - "staff-as":"as {{character}}", + "staff-as": "as {{character}}", "staff-kind": { "actor": "Actor", "director": "Director", @@ -253,7 +253,9 @@ "transmux": "Original", "auto": "Auto", "notInPristine": "Unavailable in pristine", - "unsupportedError": "Video codec not supported, transcoding in progress..." + "unsupportedError": "Video codec not supported, transcoding in progress...", + "not-available": "{{entry}} is not available on kyoo yet, ask your server admins about it", + "fatal": "Fatal playback error" }, "search": { "empty": "No result found. Try a different query." diff --git a/front/src/models/video.ts b/front/src/models/video.ts index e5ae2ee9..e06b5334 100644 --- a/front/src/models/video.ts +++ b/front/src/models/video.ts @@ -49,8 +49,14 @@ export const FullVideo = Video.extend({ playedDate: zdate().nullable(), videoId: z.string().nullable(), }), - previous: z.object({ video: z.string(), entry: Entry }).nullable().optional(), - next: z.object({ video: z.string(), entry: Entry }).nullable().optional(), + previous: z + .object({ video: z.string().nullable(), entry: Entry }) + .nullable() + .optional(), + next: z + .object({ video: z.string().nullable(), entry: Entry }) + .nullable() + .optional(), show: Show.optional().nullable(), }); export type FullVideo = z.infer; diff --git a/front/src/ui/player/controls/error-popup.tsx b/front/src/ui/player/controls/error-popup.tsx new file mode 100644 index 00000000..651bfc9a --- /dev/null +++ b/front/src/ui/player/controls/error-popup.tsx @@ -0,0 +1,29 @@ +import Close from "@material-symbols/svg-400/rounded/close-fill.svg"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { Heading, IconButton, P } from "~/primitives"; +import { cn } from "~/utils"; + +export const ErrorPopup = ({ + message, + dismiss, +}: { + message: string; + dismiss: () => void; +}) => { + const { t } = useTranslation(); + return ( + + + {t("player.fatal")} +

{message}

+
+ +
+ ); +}; diff --git a/front/src/ui/player/controls/index.tsx b/front/src/ui/player/controls/index.tsx index 1a29b047..cc13e608 100644 --- a/front/src/ui/player/controls/index.tsx +++ b/front/src/ui/player/controls/index.tsx @@ -20,6 +20,7 @@ export const Controls = ({ chapters, playPrev, playNext, + forceShow, }: { player: VideoPlayer; showHref?: string; @@ -31,6 +32,7 @@ export const Controls = ({ chapters: Chapter[]; playPrev: (() => boolean) | null; playNext: (() => boolean) | null; + forceShow?: boolean; }) => { const isTouch = useIsTouch(); @@ -56,7 +58,7 @@ export const Controls = ({ { ); const playModeState = useState(defaultPlayMode); const [playMode, setPlayMode] = playModeState; + const [playbackError, setPlaybackError] = useState(); const player = useVideoPlayer( { uri: `${apiUrl}/api/videos/${slug}/${playMode === "direct" ? "direct" : "master.m3u8"}?clientId=${clientId}`, @@ -101,18 +104,39 @@ export const Player = () => { }, [player, info?.fonts]); const router = useRouter(); + const { t } = useTranslation(); const playPrev = useCallback(() => { if (!data?.previous) return false; + if (!data.previous.video) { + setPlaybackError({ + status: "not-available", + message: t("player.not-available", { + entry: `${entryDisplayNumber(data.previous.entry)} ${data.previous.entry.name}`, + }), + }); + return true; + } + setPlaybackError(undefined); setStart("0"); setSlug(data.previous.video); return true; - }, [data?.previous, setSlug, setStart]); + }, [data?.previous, setSlug, setStart, t]); const playNext = useCallback(() => { if (!data?.next) return false; + if (!data.next.video) { + setPlaybackError({ + status: "not-available", + message: t("player.not-available", { + entry: `${entryDisplayNumber(data.next.entry)} ${data.next.entry.name}`, + }), + }); + return true; + } + setPlaybackError(undefined); setStart("0"); setSlug(data.next.video); return true; - }, [data?.next, setSlug, setStart]); + }, [data?.next, setSlug, setStart, t]); useProgressObserver( player, @@ -148,7 +172,6 @@ export const Player = () => { }; }, []); - const [playbackError, setPlaybackError] = useState(); useEvent(player, "onError", (error) => { if ( error.code === "source/unsupported-content-type" && @@ -157,9 +180,6 @@ export const Player = () => { setPlayMode("hls"); else setPlaybackError({ status: error.code, message: error.message }); }); - if (playbackError) { - throw playbackError; - } return ( @@ -201,10 +221,17 @@ export const Player = () => { : data?.path } chapters={info?.chapters ?? []} - playPrev={data?.previous?.video ? playPrev : null} - playNext={data?.next?.video ? playNext : null} + playPrev={data?.previous ? playPrev : null} + playNext={data?.next ? playNext : null} + forceShow={!!playbackError} /> + {playbackError && ( + setPlaybackError(undefined)} + /> + )} ); }; From a9e054ee54cd197f496fef3f09304d35926d531b Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 4 Apr 2026 11:33:32 +0200 Subject: [PATCH 10/12] Properly display unavailable box-entries --- front/src/components/entries/entry-box.tsx | 8 ++++++-- front/src/components/items/context-menus.tsx | 14 +++++++------- front/src/ui/home/news.tsx | 2 +- front/src/ui/home/nextup.tsx | 2 +- front/src/ui/profile/index.tsx | 2 +- 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/front/src/components/entries/entry-box.tsx b/front/src/components/entries/entry-box.tsx index 4666a43d..a22de70b 100644 --- a/front/src/components/entries/entry-box.tsx +++ b/front/src/components/entries/entry-box.tsx @@ -36,7 +36,7 @@ export const EntryBox = ({ serieSlug: string | null; name: string | null; description: string | null; - href: string; + href: string | null; thumbnail: KImage | null; watchedPercent: number; videos: Entry["videos"]; @@ -51,7 +51,11 @@ export const EntryBox = ({ href={moreOpened || videos.length > 1 ? undefined : href} onPress={videos.length > 1 ? onSelectVideos : undefined} onLongPress={() => setMoreOpened(true)} - className={cn("group w-[350px] items-center p-1 outline-0", className)} + className={cn( + "group w-[350px] items-center p-1 outline-0", + href === null && "opacity-50", + className, + )} {...props} > - {account && ( - markAsSeenMutation.mutate()} - /> - )} {serieSlug && ( )} + {account && ( + markAsSeenMutation.mutate()} + /> + )} {/* { name={`${item.show!.name} ${entryDisplayNumber(item)}`} description={item.name} thumbnail={item.thumbnail ?? item.show!.thumbnail} - href={item.href ?? "#"} + href={item.href} watchedPercent={item.progress.percent} videos={item.videos} onSelectVideos={() => diff --git a/front/src/ui/home/nextup.tsx b/front/src/ui/home/nextup.tsx index e961ffa0..5b2211ae 100644 --- a/front/src/ui/home/nextup.tsx +++ b/front/src/ui/home/nextup.tsx @@ -66,7 +66,7 @@ export const NextupList = () => { name={`${item.show!.name} ${entryDisplayNumber(item)}`} description={item.name} thumbnail={item.thumbnail ?? item.show!.thumbnail} - href={item.href ?? "#"} + href={item.href} watchedPercent={item.progress.percent} videos={item.videos} onSelectVideos={() => diff --git a/front/src/ui/profile/index.tsx b/front/src/ui/profile/index.tsx index efd2e988..da5e6c80 100644 --- a/front/src/ui/profile/index.tsx +++ b/front/src/ui/profile/index.tsx @@ -103,7 +103,7 @@ const ProfileHeader = ({ } description={item.name} thumbnail={item.thumbnail ?? item.show?.thumbnail ?? null} - href={item.href ?? "#"} + href={item.href} watchedPercent={item.progress.percent} videos={item.videos} onSelectVideos={() => {}} From 18150fc5d13789bfb0e181daba86788913126a41 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 4 Apr 2026 11:40:42 +0200 Subject: [PATCH 11/12] Cleanup history page --- api/src/controllers/entries.ts | 1 + api/src/controllers/profiles/history.ts | 8 +++--- front/src/query/fetch-infinite.tsx | 7 ++++- front/src/ui/profile/index.tsx | 35 +++++++++++++++++++++---- 4 files changed, 42 insertions(+), 9 deletions(-) diff --git a/api/src/controllers/entries.ts b/api/src/controllers/entries.ts index ecfd15b2..35c2c042 100644 --- a/api/src/controllers/entries.ts +++ b/api/src/controllers/entries.ts @@ -31,6 +31,7 @@ import { MovieEntry, Special, } from "~/models/entry"; +import { EntryContent } from "~/models/entry/base-entry"; import { KError } from "~/models/error"; import { madeInAbyss } from "~/models/examples"; import { Season } from "~/models/season"; diff --git a/api/src/controllers/profiles/history.ts b/api/src/controllers/profiles/history.ts index 83207fb8..4f18908f 100644 --- a/api/src/controllers/profiles/history.ts +++ b/api/src/controllers/profiles/history.ts @@ -20,6 +20,7 @@ import { coalesce, sqlarr } from "~/db/utils"; import { Entry } from "~/models/entry"; import { KError } from "~/models/error"; import { SeedHistory } from "~/models/history"; +import { Show } from "~/models/show"; import { AcceptLanguage, createPage, @@ -367,14 +368,15 @@ export const historyH = new Elysia({ tags: ["profiles"] }) query, sort, filter: and( - isNotNull(entryProgressQ.playedDate), - eq(entryProgressQ.external, false), + isNotNull(historyProgressQ.playedDate), + eq(historyProgressQ.external, false), ne(entries.kind, "extra"), filter, ), languages: langs, userId: sub, progressQ: historyProgressQ, + relations: ["show"], })) as Entry[]; return createPage(items, { url, sort, limit, headers }); @@ -387,7 +389,7 @@ export const historyH = new Elysia({ tags: ["profiles"] }) "accept-language": AcceptLanguage({ autoFallback: true }), }), response: { - 200: Page(Entry), + 200: Page(t.Intersect([Entry, t.Object({ show: Show })])), }, }, ) diff --git a/front/src/query/fetch-infinite.tsx b/front/src/query/fetch-infinite.tsx index 0c60aca0..de640c90 100644 --- a/front/src/query/fetch-infinite.tsx +++ b/front/src/query/fetch-infinite.tsx @@ -24,6 +24,7 @@ export const InfiniteFetch = ({ query, placeholderCount = 4, incremental = false, + getKey, getItemType, getItemSizeMult, getStickyIndices, @@ -43,6 +44,7 @@ export const InfiniteFetch = ({ placeholderCount?: number; layout: Layout; horizontal?: boolean; + getKey?: (item: Data, index: number) => string; getItemType?: (item: Data, index: number) => Type; getItemSizeMult?: (item: Data, index: number, type: Type) => number; getStickyIndices?: (items: Data[]) => number[]; @@ -94,7 +96,10 @@ export const InfiniteFetch = ({ renderItem={({ item, index }) => item ? : } - keyExtractor={(item: any, index) => (item ? item.id : index + 1)} + keyExtractor={(item: any, index) => { + if (!item) return index + 1; + return getKey ? getKey(item, index) : item.id; + }} horizontal={layout.layout === "horizontal"} numColumns={layout.layout === "horizontal" ? 1 : numColumns} onEndReached={ diff --git a/front/src/ui/profile/index.tsx b/front/src/ui/profile/index.tsx index da5e6c80..877e01a4 100644 --- a/front/src/ui/profile/index.tsx +++ b/front/src/ui/profile/index.tsx @@ -3,12 +3,14 @@ import Cancel from "@material-symbols/svg-400/rounded/cancel-fill.svg"; import CheckCircle from "@material-symbols/svg-400/rounded/check_circle-fill.svg"; import Replay from "@material-symbols/svg-400/rounded/replay.svg"; import Clock from "@material-symbols/svg-400/rounded/schedule-fill.svg"; +import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { View } from "react-native"; import { EntryBox, entryDisplayNumber } from "~/components/entries"; +import { EntrySelect } from "~/components/entries/select"; import { ItemGrid, itemMap } from "~/components/items"; import { Entry, Show, type User, User as UserModel } from "~/models"; -import { Avatar, H1, H3, P, Tabs } from "~/primitives"; +import { Avatar, H1, H3, P, Tabs, usePopup } from "~/primitives"; import { Fetch, InfiniteFetch, type QueryIdentifier } from "~/query"; import { EmptyView } from "~/ui/empty-view"; import { useQueryState } from "~/utils"; @@ -58,6 +60,25 @@ const ProfileHeader = ({ setStatus: (value: WatchlistFilter) => void; }) => { const { t } = useTranslation(); + const [setPopup, closePopup] = usePopup(); + + const openEntrySelect = useCallback( + (entry: { + displayNumber: string; + name: string | null; + videos: Entry["videos"]; + }) => { + setPopup( + , + ); + }, + [setPopup, closePopup], + ); return ( @@ -90,6 +111,7 @@ const ProfileHeader = ({ `${x.id}-${x.progress.playedDate?.toISOString()}`} Empty={} Render={({ item }) => ( {}} + onSelectVideos={() => + openEntrySelect({ + displayNumber: entryDisplayNumber(item), + name: item.name, + videos: item.videos, + }) + } /> )} Loader={EntryBox.Loader} @@ -170,9 +198,6 @@ ProfilePage.historyQuery = (slug: string): QueryIdentifier => ({ parser: Entry, infinite: true, path: ["api", "profiles", slug, "history"], - params: { - with: ["show"], - }, }); ProfilePage.userQuery = (slug: string): QueryIdentifier => ({ From 885e409bc8bc442f876908ed828a6bea7cdf595e Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 4 Apr 2026 11:49:58 +0200 Subject: [PATCH 12/12] Fix types --- api/src/controllers/profiles/history.ts | 2 +- api/src/controllers/seed/insert/entries.ts | 1 + api/tests/series/nextup.test.ts | 2 +- front/src/ui/details/header.tsx | 1 - front/src/ui/details/staff.tsx | 1 + 5 files changed, 4 insertions(+), 3 deletions(-) diff --git a/api/src/controllers/profiles/history.ts b/api/src/controllers/profiles/history.ts index 4f18908f..e024dcc6 100644 --- a/api/src/controllers/profiles/history.ts +++ b/api/src/controllers/profiles/history.ts @@ -377,7 +377,7 @@ export const historyH = new Elysia({ tags: ["profiles"] }) userId: sub, progressQ: historyProgressQ, relations: ["show"], - })) as Entry[]; + })) as (Entry & { show: Show })[]; return createPage(items, { url, sort, limit, headers }); }, diff --git a/api/src/controllers/seed/insert/entries.ts b/api/src/controllers/seed/insert/entries.ts index 6569aa73..3e58fa69 100644 --- a/api/src/controllers/seed/insert/entries.ts +++ b/api/src/controllers/seed/insert/entries.ts @@ -59,6 +59,7 @@ export const insertEntries = record( const { translations, videos, video, ...entry } = seed; return { ...entry, + content: entry.kind !== "extra" ? entry.content : "story", showPk: show.pk, slug: generateSlug(show.slug, seed), thumbnail: enqueueOptImage(imgQueue, { diff --git a/api/tests/series/nextup.test.ts b/api/tests/series/nextup.test.ts index ce34f600..3353d745 100644 --- a/api/tests/series/nextup.test.ts +++ b/api/tests/series/nextup.test.ts @@ -28,7 +28,7 @@ beforeAll(async () => { }); const miaEntrySlug = `${madeInAbyss.slug}-s1e13`; -const miaNextEntrySlug = `${madeInAbyss.slug}-sp3`; +const miaNextEntrySlug = "made-in-abyss-dawn-of-the-deep-soul"; describe("nextup", () => { it("Watchlist populates nextup", async () => { diff --git a/front/src/ui/details/header.tsx b/front/src/ui/details/header.tsx index c398f11a..90668bb3 100644 --- a/front/src/ui/details/header.tsx +++ b/front/src/ui/details/header.tsx @@ -37,7 +37,6 @@ import { HR, IconButton, IconFab, - Image, ImageBackground, LI, Link, diff --git a/front/src/ui/details/staff.tsx b/front/src/ui/details/staff.tsx index 41a2fa39..066dcfd4 100644 --- a/front/src/ui/details/staff.tsx +++ b/front/src/ui/details/staff.tsx @@ -3,6 +3,7 @@ import { View } from "react-native"; import { type KImage, Role } from "~/models"; import { Container, H2, Link, P, Poster, Skeleton, SubP } from "~/primitives"; import { InfiniteGrid, type QueryIdentifier } from "~/query"; +import { cn } from "~/utils"; import { EmptyView } from "../empty-view"; export const CharacterCard = ({