From 57e7b4dc0f68c1fd4fd91629a4cce00d6d36a2d8 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Tue, 9 Jun 2026 11:41:54 -0400 Subject: [PATCH] =?UTF-8?q?feat(schema):=20add=20series=E2=86=92season?= =?UTF-8?q?=E2=86=92episode=20hierarchy=20fields=20(0.9.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the media browse contract so clients can render a TV/anime tree instead of a flat list: - media-item.schema.json: add `season` to the `type` enum and the optional hierarchy fields `parent_id`, `season_number`, `episode_number`, and `episode_title` (sourced from metadata_json). - library-query.schema.json: document the `parentId` and `topLevel` scope params (fetch a series' children / return only top-level items) plus the pre-existing `libraryId` scope. - SchemaPaths: add mediaItem() + libraryQuery() path resolvers. - Lock the new contract with SchemaPathsTest assertions. - Bump Version::VERSION to 0.9.0 + CHANGELOG. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 24 ++++++++++++++ schemas/library-query.schema.json | 15 +++++++++ schemas/media-item.schema.json | 24 ++++++++++++-- src/Schema/SchemaPaths.php | 28 +++++++++++++++++ src/Version.php | 2 +- tests/Schema/SchemaPathsTest.php | 52 +++++++++++++++++++++++++++++++ 6 files changed, 142 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a23fff3..8d34e9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes to `detain/phlix-shared` are documented here. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.0] — 2026-06-09 + +### Added +- `schemas/media-item.schema.json` — series hierarchy fields so the browse API + can describe a TV/anime tree instead of a flat list: + - `type` enum gains `season` (alongside the existing `series`/`episode`), so + the discriminator can carry the full series→season→episode hierarchy. + - `parent_id` (uuid|null) — the parent media item (episode→season→series); + null for top-level items (movies, series). Browse surfaces request + top-level items only so a series library shows shows, not every episode. + - `season_number` (integer|null, min 0) — from `metadata_json.season`; season + 0 / a null number on a series episode denotes Specials. + - `episode_number` (integer|null, min 0) — from `metadata_json.episode`; + orders episodes within a season. + - `episode_title` (string|null) — per-episode title, distinct from `name`. +- `schemas/library-query.schema.json` — query parameters for the new hierarchy + navigation (and the previously-undocumented per-library scope): + - `parentId` (uuid) — fetch the direct children (seasons/episodes) of one + item for the series detail page. + - `topLevel` (boolean) — return only items with no parent (movies + series), + excluding seasons/episodes; ignored when `search` is set so search still + spans the whole library. + - `libraryId` (uuid) — documents the existing per-library scope parameter. + ## [0.8.0] — 2026-06-01 ### Added diff --git a/schemas/library-query.schema.json b/schemas/library-query.schema.json index 70e92fe..b23a52c 100644 --- a/schemas/library-query.schema.json +++ b/schemas/library-query.schema.json @@ -75,6 +75,21 @@ "description": "Number of items to skip for pagination (offset = page * limit).", "minimum": 0, "default": 0 + }, + "libraryId": { + "type": "string", + "description": "Scope the result (and its total) to a single library by id. Omitted/blank means an all-libraries query. Drives the per-library Browse rails and the dedicated library page.", + "format": "uuid" + }, + "parentId": { + "type": "string", + "description": "Scope the result to the direct children of one media item by id — used to fetch the seasons/episodes of a series (or the episodes of a season) for the series detail page. Mutually exclusive with `topLevel`.", + "format": "uuid" + }, + "topLevel": { + "type": "boolean", + "description": "When true, return only top-level items (those with no parent: movies and series), excluding seasons and episodes. Browse rails and library grids set this so a series library shows shows rather than a flat dump of every episode. Mutually exclusive with `parentId`; ignored when `search` is set so search still spans the whole library.", + "default": false } }, "additionalProperties": false diff --git a/schemas/media-item.schema.json b/schemas/media-item.schema.json index ffe6e19..086a48e 100644 --- a/schemas/media-item.schema.json +++ b/schemas/media-item.schema.json @@ -18,8 +18,28 @@ }, "type": { "type": "string", - "description": "Media type discriminator.", - "enum": ["movie", "series", "episode", "audio", "image"] + "description": "Media type discriminator. `series`/`season`/`episode` form the TV/anime hierarchy: a `series` (the show) parents `season` items, which parent `episode` items. Standalone shows may parent `episode` items directly (no `season` row), in which case the client derives season grouping from `season_number`.", + "enum": ["movie", "series", "season", "episode", "audio", "image"] + }, + "parent_id": { + "type": ["string", "null"], + "description": "UUID of the parent media item, or null for a top-level item. Forms the series→season→episode hierarchy: an episode's parent is its season (or, when no season row exists, the series); a season's parent is its series. Top-level items (movies, series) have null. Browse surfaces request top-level items only so a series library shows shows (not a flat dump of every episode).", + "format": "uuid" + }, + "season_number": { + "type": ["integer", "null"], + "description": "Season number this item belongs to, sourced from metadata_json.season. Set on `season` and `episode` items; null for movies/series/standalone. Season 0 (or a null number on an episode of a series) denotes Specials.", + "minimum": 0 + }, + "episode_number": { + "type": ["integer", "null"], + "description": "Episode number within its season, sourced from metadata_json.episode. Set on `episode` items; null otherwise. Used to order episodes within a season.", + "minimum": 0 + }, + "episode_title": { + "type": ["string", "null"], + "description": "Per-episode title (distinct from `name`, which may be the series name), sourced from metadata_json.episode_title. Null when unknown or not an episode.", + "maxLength": 500 }, "path": { "type": "string", diff --git a/src/Schema/SchemaPaths.php b/src/Schema/SchemaPaths.php index 25506c6..1201115 100644 --- a/src/Schema/SchemaPaths.php +++ b/src/Schema/SchemaPaths.php @@ -62,6 +62,34 @@ public static function webhookEvents(): string return self::dir() . '/webhook-events.json'; } + /** + * Absolute path to the media-item JSON Schema (draft 2020-12) — the + * canonical client-facing shape returned by the browse API, including the + * series→season→episode hierarchy fields. + * + * @return non-empty-string Absolute path to `media-item.schema.json`. + * + * @since 0.9.0 + */ + public static function mediaItem(): string + { + return self::dir() . '/media-item.schema.json'; + } + + /** + * Absolute path to the library-query JSON Schema (draft 2020-12) — the + * query parameters accepted by the browse API (filters, paging, and the + * `libraryId`/`parentId`/`topLevel` scoping parameters). + * + * @return non-empty-string Absolute path to `library-query.schema.json`. + * + * @since 0.9.0 + */ + public static function libraryQuery(): string + { + return self::dir() . '/library-query.schema.json'; + } + /** * Prevent instantiation — this class is a static path resolver only. */ diff --git a/src/Version.php b/src/Version.php index 1e92544..199044f 100644 --- a/src/Version.php +++ b/src/Version.php @@ -24,7 +24,7 @@ final class Version * * @var non-empty-string */ - public const VERSION = '0.7.0'; + public const VERSION = '0.9.0'; /** * Prevent instantiation — static marker only. diff --git a/tests/Schema/SchemaPathsTest.php b/tests/Schema/SchemaPathsTest.php index 38c8277..1f13e62 100644 --- a/tests/Schema/SchemaPathsTest.php +++ b/tests/Schema/SchemaPathsTest.php @@ -48,6 +48,58 @@ public function test_webhook_events_path_points_at_an_existing_valid_json_file() $this->assertIsArray($decoded, 'webhook-events.json must decode to a JSON object.'); } + public function test_media_item_path_has_the_right_filename(): void + { + $this->assertStringEndsWith('/schemas/media-item.schema.json', SchemaPaths::mediaItem()); + } + + public function test_library_query_path_has_the_right_filename(): void + { + $this->assertStringEndsWith('/schemas/library-query.schema.json', SchemaPaths::libraryQuery()); + } + + public function test_media_item_schema_declares_the_series_hierarchy(): void + { + $path = SchemaPaths::mediaItem(); + $this->assertFileExists($path); + + $schema = json_decode((string) file_get_contents($path), true); + $this->assertIsArray($schema, 'media-item.schema.json must decode to a JSON object.'); + + $properties = $schema['properties'] ?? []; + $this->assertIsArray($properties); + + // The `season` type discriminator must exist alongside series/episode so + // the contract can carry the full series→season→episode hierarchy. + $typeProp = $properties['type'] ?? []; + $this->assertIsArray($typeProp); + $typeEnum = $typeProp['enum'] ?? []; + $this->assertIsArray($typeEnum); + foreach (['movie', 'series', 'season', 'episode', 'audio', 'image'] as $expected) { + $this->assertContains($expected, $typeEnum, "media-item type enum must include '{$expected}'."); + } + + // The hierarchy/ordering fields the series detail page relies on. + foreach (['parent_id', 'season_number', 'episode_number', 'episode_title'] as $field) { + $this->assertArrayHasKey($field, $properties, "media-item schema must declare '{$field}'."); + } + } + + public function test_library_query_schema_declares_the_hierarchy_scope_params(): void + { + $path = SchemaPaths::libraryQuery(); + $this->assertFileExists($path); + + $schema = json_decode((string) file_get_contents($path), true); + $this->assertIsArray($schema, 'library-query.schema.json must decode to a JSON object.'); + + $properties = $schema['properties'] ?? []; + $this->assertIsArray($properties); + foreach (['libraryId', 'parentId', 'topLevel'] as $param) { + $this->assertArrayHasKey($param, $properties, "library-query schema must declare '{$param}'."); + } + } + public function test_constructor_is_private(): void { $reflection = new ReflectionClass(SchemaPaths::class);