tv-anarchy/v2/pillars/library-display.md

220 lines
7.5 KiB
Markdown
Raw Permalink Normal View History

# Library — episode display names
**Job:** Every surface shows a **canonical episode display name** — never a raw
release filename.
One-liner: *S01E04 · Episode Title — not `Show.S01E04.720p.WEB-DL.x264-GROUP.mkv`.*
Parent: [library.md](./library.md). Consumers: all pillars that render catalog
or playback labels (Library UI, Watch Player/queue, iOS, bridge, MCP).
---
## Problem (v1)
| Layer | Today | Issue |
|-------|-------|-------|
| **Scan** | `LibraryScanner.episodeLabel` → filename sans extension | Dots, quality, release group in UI |
| **Catalog** | `CachedEpisode.label` stores raw string | Library tab shows torrent names |
| **Player** | `PlayerController.displayTitle` re-parses when match found | Watch fixes some cases; inconsistent |
| **Enrichment** | `FilenameParser` + TMDB sidecars exist | Not wired into scan-time `label` |
```304:307:Sources/TVAnarchyCore/Library/LibraryScanner.swift
private static func episodeLabel(_ path: String) -> String {
let base = (path as NSString).lastPathComponent
return (base as NSString).deletingPathExtension
}
```
v2 Library **owns** display names at index time. Watch/Player **reads** them —
no per-surface filename hacks.
---
## Canonical fields (target)
Extend `CachedEpisode` (tolerant decode — new fields optional):
| Field | Type | Purpose |
|-------|------|---------|
| `path` | `String` | Identity / playback routing (unchanged) |
| `season`, `episode` | `Int` | Structural (unchanged) |
| `displayName` | `String` | **Primary UI string** — always use in views |
| `episodeTitle` | `String?` | Human title only — "The Honking" |
| `label` | `String` | *Deprecated* — alias of `displayName` on encode; decode migrates |
Movies (`CachedShow.kind == .movie`): `displayName` on the single synthetic
episode = parsed movie title (no SxxEyy).
---
## Display format rules
### Series
| Has `episodeTitle`? | `displayName` |
|---------------------|-----------------|
| Yes | `S01E04 · The Honking` |
| No (parse failed) | `S01E04` |
| Specials (S00) | `S00E01 · Title` or `Special · Title` |
**Never include** in `displayName`: resolution, codec, source tag, release
group, bracketed junk. Those belong in optional **release badge** metadata
(future Download `quality` / file picker), not the episode row.
### Movies
| `displayName` |
|---------------|
| `Inception` (from parse or TMDB) |
### Show-level
`CachedShow.name` stays folder-normalized show title (existing
`normalizeShowName`). Episode display is per-episode.
---
## Resolution chain (at scan + on enrich)
Run once when building/updating `CachedEpisode`; persist in `library.json`.
**Lookup our Title Library first** — MLX on M2 only on cache miss, always write
back. Full dataset spec: [library-titles.md](./library-titles.md).
```
1. Title Library (contentKey) ← local dataset we build and keep
2. FilenameParser + SxxEyy tail ← deterministic
3. .meta sidecar ← migrate into library
4. MLX episode refiner (M2) ← once per contentKey, then stored
5. One-shot TVmaze/TMDB episode ← ingest into library, not live UI
6. Fallback ← S01E04 only
7. Never ← raw filename
```
```swift
// Sketch — LibraryDisplayNames.resolve(path:meta:)
enum LibraryDisplayNames {
static func resolve(path: String, meta: EpisodeMeta?) -> (displayName: String, episodeTitle: String?) {
let parsed = FilenameParser.parse(path: path)
let code = parsed.season.flatMap { s in parsed.episode.map { e in
String(format: "S%02dE%02d", s, e) } }
let title = meta?.episodeTitle
?? (parsed.title.count >= 2 ? parsed.title : nil)
?? titleAfterSxxEyy(path)
if let title, let code { return ("\(code) · \(title)", title) }
if let code { return (code, nil) }
return (parsed.title.isEmpty ? "Unknown" : parsed.title, nil)
}
}
```
**Re-resolve** when:
- Metadata enrichment completes (`MetaWriter` callback)
- Manual title edit in Metadata tab (future)
- Net `grouping` does not change episode titles (show-level only)
---
## Module ownership
| Module | Role |
|--------|------|
| `LibraryDisplayNames` | **New** — format rules + resolution chain |
| `LibraryScanner` | Call at episode build; stop using raw `episodeLabel` |
| `FilenameParser` | Deterministic parse (existing) |
| `MetaWriter` / sidecars | Episode title from TMDB |
| `TitleLibrary` | Persistent rows keyed by `contentKey` — [library-titles.md](./library-titles.md) |
| `LocalLLMEpisodeRefiner` | M2 MLX on cache miss → write library row |
| `LocalLLMTitleRefiner` | Legacy show-level; migrate to episode refiner |
| `PlayerController` | **Thin**`displayTitle``ep.displayName` / `shortEpisodeTitle(episodeTitle)` |
| `PlaylistController` | Queue items use `displayName` |
| Bridge / MCP | Expose `displayName` in library API |
---
## Consumer contract
**Rule:** UI and APIs use `CachedEpisode.displayName` (or
`PlayerController.label` which returns it). **Forbidden** for display:
`(path as NSString).lastPathComponent`.
| Surface | v2 behavior |
|---------|-------------|
| Library tab episode rows | `displayName` |
| Home Continue / Recently Added | `displayName` via library |
| Player hero | `episodeTitle` or short form of `displayName` |
| Queue rail chips | `S01E03` + `episodeTitle` from library |
| iOS `EpisodeRow` | `displayName` |
| MCP `media/library` | Include `displayName` per episode |
Watch `shortEpisodeTitle` becomes a **parser** on `displayName` for compact
hero — or reads `episodeTitle` directly when set.
---
## Settings (Library pillar)
| Setting | Default | Effect |
|---------|---------|--------|
| `episodeDisplayStyle` | `codeAndTitle` | `codeAndTitle` \| `titleOnly` \| `codeOnly` |
| `preferEnrichedTitles` | `true` | Sidecar beats filename parse |
Stored in `settings.json``library` section ([settings.md](./settings.md)).
---
## Migration
| Step | Action |
|------|--------|
| 1 | Add `displayName` / `episodeTitle` to model; tolerant decode |
| 2 | On load, backfill `displayName` from `label` via resolution chain if missing |
| 3 | Scanner writes new fields on every scan |
| 4 | Switch views to `displayName` |
| 5 | `label` encodes as `displayName` for one release; remove raw fallback paths |
No `library.json` version bump required — absent fields backfill on read.
---
## Tests
| Test | Asserts |
|------|---------|
| `LibraryDisplayNamesTests` | Psych S01E04 → `S01E04 · …`, not dotted filename |
| `LibraryDisplayNamesTests` | `2160p.x265.GROUP` → refiner or SxxEyy only |
| `LibraryScannerTests` | Scanned episodes have non-filename `displayName` |
| `PlayerDisplayTests` | `displayTitle` uses `displayName` without re-parse |
| Golden files | Fixture dirname → expected `displayName` list |
Fixture example:
```
Psych.S01E04.720p.WEB-DL.x264-GROUP.mkv
→ displayName: "S01E04 · High Noon-ish"
→ episodeTitle: "High Noon-ish" (when sidecar present)
```
---
## v2 phase
| Track | Deliverable |
|-------|-------------|
| **Doc** | This file ✅ |
| **PR 1** | `LibraryDisplayNames` + scanner integration |
| **PR 2** | Meta enrich writes `episodeTitle` → re-resolve |
| **PR 3** | Player/queue/iOS read `displayName` only |
| **PR 4** | Bridge + `AppLocalAPI` fields |
**Exit:** Library tab and Player never show `*.720p.WEB-DL.x264-*` strings for
indexed episodes.
---
## Out of scope
- Renaming files on disk to match display names
- Net federation of display names (local catalog truth; Net `grouping` is show-level)
- Download search result titles (torrent index names — different domain)