tv-anarchy/v2/pillars/library-display.md
Natalie 4a2ceb9781 feat(offline): inline star-to-keep and trash-to-cull on cache rows
Surface the existing pin (keep-from-cull) and per-file delete actions as
visible inline buttons on each offline cache row instead of context-menu-only:
a star toggles protection from auto-cull (and restore-if-missing), a trash
culls that file early. Aligns wording/icons to the star metaphor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 00:12:41 -04:00

7.5 KiB

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. 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
    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.

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
// 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 contentKeylibrary-titles.md
LocalLLMEpisodeRefiner M2 MLX on cache miss → write library row
LocalLLMTitleRefiner Legacy show-level; migrate to episode refiner
PlayerController ThindisplayTitleep.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.jsonlibrary section (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)