220 lines
7.5 KiB
Markdown
220 lines
7.5 KiB
Markdown
|
|
# 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)
|