# Library — Title Library (local dataset)

**Job:** Own a **persistent, local episode-title database** keyed by `contentKey` —
lookup first, MLX on M2 only on miss, enrich once and keep forever.

One-liner: *Our library of episode names — not live API calls per row.*

Parent: [library-display.md](./library-display.md). MLX runs on-device via
`recommender/` (Apple Silicon + macOS).

---

## Why not filename → MLX every scan?

| Approach | Problem |
|----------|---------|
| Raw filename as `label` | UI shows release noise |
| TMDB per browse | Network, keys, show-level only today — no episode titles in enrich |
| `title-refinements.json` (filename → title) | Wrong key; same episode, two rips = two LLM calls; no show/SxxEyy context |
| MLX on every degenerate parse | Slow scan; model load per subprocess |

**v2:** Build **Title Library** — a dataset this install (and later Net friends)
accumulates. Resolution is **database hit → regex → MLX once → store**.

---

## Title Library vs sidecars

| Store | Key | Holds | Role |
|-------|-----|-------|------|
| **Title Library** | `contentKey` | `episodeTitle`, `displayName`, provenance | **Canonical** display dataset |
| `.meta` sidecar | file `path` | parse + poster + show enrich | Per-file release metadata |
| `title-refinements.json` | filename string | show-level guess | **Legacy** — migrate into library |
| `library.json` | show `rootDir` | `CachedEpisode.displayName` | Catalog snapshot (denormalized cache) |

Sidecars stay path-specific (quality, codec). **Display names** resolve through
Title Library, then copy into `CachedEpisode` at scan.

---

## `contentKey` (stable row id)

```text
{canonicalShowKey}/s{season}e{episode}
```

From `ContentID.canonical(show:episode)` — same episode across 1080p/720p rips
shares one Title Library row. See `ContentID.swift`.

---

## Schema (planned)

Path: `~/.local/state/tv-anarchy/titles/`

```
titles/
├── index.json              # version, row count, last compact
├── by-key.jsonl            # append-only observations → compacted
└── episodes.sqlite         # optional query index (same data)
```

### Row (`TitleRecord`)

```jsonc
{
  "contentKey": "psych/s01e04",
  "showName": "Psych",
  "season": 1,
  "episode": 4,
  "episodeTitle": "Shawn vs. the Red Phantom",
  "displayName": "S01E04 · Shawn vs. the Red Phantom",
  "provenance": "tvmaze",       // regex | mlx | tmdb | tvmaze | anilist | manual | net
  "confidence": 0.92,
  "sources": ["tvmaze:ep-12345"],
  "updatedAt": "2026-06-21T12:00:00Z"
}
```

| Field | Notes |
|-------|-------|
| `provenance` | How we learned the title — governs merge precedence |
| `confidence` | Manual > enrich API > MLX > regex tail |
| `sources` | External ids for dedupe — never re-fetch same source |

---

## Resolution chain (v2)

```
1. Title Library lookup(contentKey)     ← OUR dataset, instant
2. library.json denorm hit              ← same scan session
3. FilenameParser + SxxEyy tail         ← free, deterministic
4. .meta sidecar parsed fields          ← migrate row into library
5. MLX episode refiner (M2, cache miss) ← run once, WRITE library row
6. One-shot enrich API (TVmaze/TMDB ep) ← ingest into library, not live UI
7. Fallback                             ← S01E04 code only
8. Never                                ← raw filename
```

**MLX is last resort before external API, and always persists its answer.**

---

## MLX on Apple Silicon (M2)

Existing stack:

| Piece | Path |
|-------|------|
| Swift caller | `LocalLLMTitleRefiner` → extend to `LocalLLMEpisodeRefiner` |
| Model | `mlx-community/Qwen2.5-1.5B-Instruct-4bit` via `mlx_lm` |
| Runtime | `uv run python -m media_rec.*` on macOS client machines |
| Guard | Session kill-switch + **per-contentKey** cache in Title Library |

### Episode-aware refiner (new — `episode_refiner.py`)

Input JSON (not bare filename):

```jsonc
{
  "contentKey": "psych/s01e04",
  "showName": "Psych",
  "season": 1,
  "episode": 4,
  "filename": "Psych.S01E04.720p.WEB-DL.x264-GROUP.mkv",
  "regexTitle": ""
}
```

Output:

```jsonc
{ "episodeTitle": "Shawn vs. the Red Phantom", "confidence": 0.75 }
```

Prompt includes show + SxxEyy so the model extracts **episode title**, not show
name. Plausibility check: title must not equal show name; length bounds.

**Only invoked when** Title Library has no row and regex tail is empty or
degenerate.

---

## Building the dataset (sources)

| Source | When | Provenance |
|--------|------|------------|
| **Scan + regex** | Every index | `regex` |
| **MLX M2** | Cache miss | `mlx` |
| **TVmaze episode API** | Enrichment job, once per contentKey | `tvmaze` |
| **TMDB episode API** | When keyed, once | `tmdb` |
| **AniList** | Anime category | `anilist` |
| **Manual edit** | Metadata / Library UI | `manual` (wins merge) |
| **Net edition** | Future `episode-titles` part | `net` |

Enrichment pipeline change: episode-level fetch **writes Title Library**, not
just show poster. Show enrich stays for posters; episode enrich feeds **our**
database.

---

## Merge policy (multiple observations)

Same `contentKey`, conflicting titles:

| Priority | Provenance |
|----------|------------|
| 1 | `manual` |
| 2 | `tvmaze`, `tmdb`, `anilist` (highest confidence) |
| 3 | `net` (friends, min sources — future) |
| 4 | `mlx` |
| 5 | `regex` |

Store all observations in jsonl; compacted row is merged view (like Net parts).

---

## Modules

| Module | Role |
|--------|------|
| `TitleLibrary` | CRUD + lookup by `contentKey` |
| `TitleLibraryStore` | `titles/by-key.jsonl` persistence |
| `LibraryDisplayNames` | Chain above → `displayName` format |
| `LocalLLMEpisodeRefiner` | Swift → `episode_refiner.py` |
| `EpisodeEnrichService` | One-shot TVmaze/TMDB ep → ingest |
| `LibraryScanner` | Lookup → write `CachedEpisode.displayName` |

Migrate `RefinementStore` (`title-refinements.json`) into Title Library on read:
filename + parsed SxxEyy → `contentKey` row.

---

## Settings (Library pillar)

| Setting | Default |
|---------|---------|
| `preferEnrichedTitles` | `true` — API ingest beats MLX |
| `useMLXEpisodeRefiner` | `true` — M2 on cache miss |
| `episodeDisplayStyle` | `codeAndTitle` |

Disable MLX → regex + library + API only (faster scans on machines without model).

---

## Net (future)

Optional part `episode-titles` (`friends` trust): export Title Library rows as
observations; subscribe to friend editions. **Still stored in local Title
Library** after merge — Net is sync, not live lookup.

---

## Phases

| PR | Deliverable |
|----|-------------|
| 1 | `TitleLibrary` schema + lookup/store |
| 2 | `LibraryDisplayNames` uses library first |
| 3 | `episode_refiner.py` + `LocalLLMEpisodeRefiner` |
| 4 | TVmaze/TMDB episode enrich → ingest |
| 5 | Migrate `title-refinements.json` → library |
| 6 | Manual edit UI → `manual` provenance |

**Exit:** Second scan of same show never calls MLX or TMDB for known episodes.

---

## Tests

| Test | Asserts |
|------|---------|
| `TitleLibraryTests` | Round-trip row by contentKey |
| `TitleLibraryMergeTests` | manual beats mlx |
| `EpisodeRefinerTests` | Psych S01E04 filename → episode title, not show name |
| `LibraryDisplayNamesTests` | library hit skips MLX |

---

## Out of scope

- Renaming files on disk to match titles
- Cloud-hosted title API as runtime dependency (ingest once OK)
- Training a custom model — use Qwen MLX instruct as **parser**, dataset is **our rows**