Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions schemas/library-query.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 22 additions & 2 deletions schemas/media-item.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 28 additions & 0 deletions src/Schema/SchemaPaths.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
2 changes: 1 addition & 1 deletion src/Version.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
52 changes: 52 additions & 0 deletions tests/Schema/SchemaPathsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading