Five providers existed and none covered the "video" kind, so every movie
and show got a title from its filename and nothing else - no poster, no
plot, no cast, no year. That is the gap between "a folder of videos with a
nice web UI" and a media server.
TMDB keeps films and television in separate namespaces behind separate
endpoints, and a Query does not say which one it is. Rather than guessing
from the title, Search asks both at once and merges by TMDB popularity,
which is the only ranking signal available without a second request per
item.
Speed is the point, so the two requests run concurrently over one derived
context on a single shared client, and whichever fails first cancels the
other: one dead endpoint cannot hold the result hostage while the healthy
one is waited out. Verified red-green - serialising the two fetches makes
TestTMDBSearchesConcurrently fail at 2s.
Two corrections made in review, both found by reading the code rather than
trusting the report:
1. ExternalID was the bare TMDB id, so film 603 and show 603 - different
works - would have been stored identically in provider_ids. Namespaced
as movie/603 and tv/603. Nothing resolves an item from an external_id
today, so this was latent rather than broken, but the table is populated
as enrichment runs and correcting it later would have needed a data
migration. Pinned by TestTMDBExternalIDIsNamespaced.
2. Query.Year was added but never populated, so the year filter - the main
way to tell a remake from the original - never applied. catalog.Item
already carried Year; the enricher now passes it.
No cover bytes are fetched here: the provider returns image URLs and the
existing enricher caches what it uses. A missing key logs the env var and
skips, so the feature is discoverable from the boot log.
Not verified: the live TMDB API. Tests run against httptest servers only.
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.