# 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)