parseEpisode was handed the file's whole relative path, but the bare-number
pattern is anchored at the start of a name. Handed "Show/Season 3/06 -
Title.mkv" it could never match, so every file named that way was filed as a
standalone film at the library root instead of an episode.
Found by running a real scan against a real tree, not by reading the code:
the unit test on parseEpisode passed a bare file name, which is exactly the
input the call site was not providing. The unit test was right and the
caller was wrong, which is a class of bug a helper test cannot see.
parseEpisode now gets path.Base(rel), matching episodeTitle two lines below
it, so the function really is a function of a file name and its directory as
its signature and doc already claimed.
Both scan-level cases are added to the tree test, so the call site is now
covered and not just the helper. Verified red-green: reverting the call site
fails "bare leading number under a Season directory".
The schema landed in 8d6ea6d; this fills it. A video library now builds
library -> series -> season -> episode, with episodes keeping kind='video'
and the two parents standing in for directories that have no file of their
own.
Three naming shapes are recognised, in order:
- a full marker, S01E02 or 1x02
- an episode-only marker, E05 / ep5 / Episode 5, which older rips and anime
use. With no season directory the season is 1, which is what those
libraries mean by it.
- a bare leading number, "05 - Title.mkv", trusted only under a season
directory. Without that anchor it is indistinguishable from a film whose
name starts with a number.
Every marker is delimited by non-alphanumeric characters, which is the guard
that stops "Blade.Runner.1999x264.mkv" reading as season 99 episode 264 -
the failure every release-tagged file would otherwise hit. The episode-only
pattern caps at three digits for the same reason: four would let
"Movie.E.1999.mkv" read as episode 1999, and years have four digits while
episode numbers do not.
Idempotency comes from ON CONFLICT (library_id, path) rather than a lookup,
so a duplicate series row is impossible rather than merely unlikely, and a
rescan leaves the tree byte-identical.
Verified red-green: neutering parseEpisode fails the episode tests, and
removing the episode-only branch fails the four patterns it covers.
Known limits, marked in the code: a multi-episode file (S01E02E03) parses
as its first episode only, and library has no separate 'tv' kind, so a video
library mixing films and shows builds both shapes side by side.
A bare-number episode keeps the cleaned file name as its title rather than
the text after the number. TV metadata replaces the title once the series
lookup lands, so it is not worth guessing at now.
Four extractions, each chosen because the two halves have different callers,
different failure modes or different testing needs. Not a line-count exercise.
internal/home out of internal/media
- media serves item RESOURCES (detail, file, cover, progress); home computes
AGGREGATES (the five shelves). The 2328-line package was doing both.
- media keeps POST /items/{id}/view because it is item-scoped and must stay in
the package that authorizes the item; it imports home.RecordView for that one
thing. home does not import media.
- Verified live: all five shelves non-empty, and two consecutive /home calls
return different explore ids, which is the non-determinism the Explore rewrite
exists for.
internal/scanner out of internal/library
- The scanner walks the filesystem and reconciles; library CRUD is an admin HTTP
surface. Different callers (a job vs a request), different failure modes.
- The registry deliberately STAYED in internal/library: three of its four callers
are not the scanner (modules register types, handlers serve the list, metadata
reads the group mode, subsonic calls CleanTitle). It is a property of the kind,
not of the scan. The one genuinely scanner-owned helper became
library.ExtensionsFor.
- Two tests moved with the code rather than being rewritten; they now exercise
the boundary by calling library.ListMissing across it.
- Verified live: first scan updates, rescan reports unchanged=N, so change
detection still short-circuits.
internal/providers out of internal/metadata
- Providers talk to third-party APIs; the enrich job owns the matching policy and
the database writes. The split's whole point is that a provider is testable
WITHOUT a database, so a DB-free test was added and OpenLibrary gained a
BaseURL override to make it possible.
- providers imports zero internal packages — a true leaf.
- The matching policy (searchTitle, artistHint, pickBest) is untouched. It exists
because real false matches were found.
dto packages out of internal/jellyfin and internal/subsonic
- The wire types and their mapping are now separate from the handler logic, so
the contract with clients we do not control is reviewable on its own.
- Both dto packages are PURE: zero references to sql.DB or net/http, verified by
grep. That is what makes them reviewable.
- No field name, tag or error code changed.
internal/arch did its job: it failed the build twice during this phase, once for
each new package that was not yet placed in a layer. That is the rule working,
not an obstacle.
Verified: gofmt clean, build exit 0, vet exit 0, 23 packages pass, svelte-check
0/0, and end to end against a running server — home shelves, a queued scan
reporting unchanged, Jellyfin /Items, and Subsonic ping.
Process note: one agent (dto) stopped mid-task and left the tree red — the dto
packages unlayered and its test still referencing constants that had moved. The
arch test caught it immediately. I finished it by hand: exported constant
references in the test, and both dto packages placed at layer 3.
Still open from Phase 1: internal/jellyfin has no test files, catalog is
item-centric while Subsonic is file-first, and subsonic loadAlbum now scopes to
the item's own library.