Root cause: the only album-context backfill in the download path (hydrate_download_metadata) goes
through spotify_client.get_track_details — Spotify-only. An iTunes/Deezer-primary user's download
kept a lean context (no release_date), so the path dropped $year and the date defaulted to
YYYY-01-01 — until they ran a Reorganize, which reads the full album from the PRIMARY source. That
asymmetry IS the bug.
Fix: when the context is lean and the primary source isn't Spotify, hydrate it from that source via
get_album_for_source — the exact path Reorganize/Enrich use. Verified the primary source returns the
real data (live iTunes get_album for the reporter's album: release_date 2024-04-17, not 2024-01-01).
backfill_album_context_from_source is a pure, injected-fn seam: 6 tests (hydrate, no-op when
complete / spotify-primary / sentinel-id, stays-lean on None, swallows source errors). 552 downloads
tests green.
Residual per-track wishlist downloads (single tracks from different
albums, below the album-bundle threshold) were producing folders
without a year subfolder whenever the wishlist row carried a stale
``track_number=1`` from an older payload default.
Why: ``core/downloads/candidates.py`` had a single API-fetch branch
that served two concerns — resolving the track position AND
hydrating the lean ``spotify_album_context`` (release_date /
total_tracks / cover image) — gated entirely on track_number being
unresolved. When the wishlist row's ``track_number`` happened to
be 1 (a poisoned default rather than a real value), the gate
short-circuited and the album hydration the same call would have
done was skipped. Deezer-sourced discovery matches don't ship
release_date in their search-result album shape, so without the
backfill the folder lost its year.
The two concerns split:
- track_number resolution keeps its track_info → track object →
API precedence chain. track_info defaults still win.
- album hydration runs whenever release_date or total_tracks are
missing, independent of where (or whether) track_number was
resolved.
The single API round-trip still serves both — the cost contract
is preserved. The side-effect coupling is gone.
Lifted into ``core/downloads/track_metadata_backfill.py``
(``hydrate_download_metadata``) so the precedence chain is pinned
in isolation. 24 unit tests cover the precedence chain, the
poisoned-tn=1 regression case, defensive non-dict/None inputs,
the cost guard (API called at most once per invocation), and
disc_number resolution.
Also lands the upstream piece: ``core/wishlist/routes.py:_build_track_data``
no longer defaults ``track_number=1`` / ``disc_number=1`` /
``total_tracks=1`` / ``release_date=''`` when the library-modal add
payload omits them. Missing values now flow through as ``None`` so
the downstream pipeline can detect-and-recover instead of locking
to a fake position.