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>
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 (
MetaWritercallback) - Manual title edit in Metadata tab (future)
- Net
groupingdoes 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 |
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).
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
groupingis show-level) - Download search result titles (torrent index names — different domain)