soulsync/core/downloads/track_metadata_backfill.py
Broque Thomas 45ecf2730d Wishlist: harden Spotify backfill — poisoned tn=1 can't mask lean album
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.
2026-05-27 16:47:26 -07:00

175 lines
6.6 KiB
Python

"""Track-position resolution + album-context hydration helper.
Lifted out of ``core/downloads/candidates.py`` to break a quiet
regression. The pre-extract code fetched detailed track data from
Spotify only when ``track_number`` was missing — the same API call
*also* backfilled the lean ``spotify_album_context`` (release_date,
total_tracks, image_url) but only as a side-effect of the
track_number branch. When a wishlist row carried a poisoned
``track_number=1`` (older payload helpers defaulted missing values
to 1), the conditional short-circuited, the API call never fired,
and the album context stayed lean — producing folders without a
year subfolder for residual per-track wishlist downloads.
The fix splits the two concerns: ``track_number`` resolution
follows its precedence chain (track_info → track object → API),
but album hydration runs whenever ``spotify_album_context`` is
missing any of release_date / total_tracks regardless of whether
``track_number`` was already known. A single API call still serves
both — the side-effect coupling is gone but the network cost
isn't paid twice.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, NamedTuple, Optional
logger = logging.getLogger(__name__)
class ResolvedTrackMetadata(NamedTuple):
"""Result of ``hydrate_download_metadata``.
``track_number`` is ``None`` when every source — track_info,
track object, API — failed to produce a positive integer. The
caller (candidates.py) treats ``None`` as "fall back to the
setdefault(0)" path so the existing 0-floor sentinel behaviour
is preserved.
"""
track_number: Optional[int]
disc_number: int
source: str # 'track_info' | 'track_object' | 'api' | 'none'
def _positive_int(value: Any) -> Optional[int]:
if isinstance(value, bool):
return None
if isinstance(value, int):
return value if value > 0 else None
if isinstance(value, str) and value.isdigit():
coerced = int(value)
return coerced if coerced > 0 else None
return None
def _album_is_lean(album_context: Any) -> bool:
"""An album context is "lean" when it lacks release_date OR
total_tracks. Both fields are required downstream:
``release_date`` drives the year folder, ``total_tracks`` drives
the TRCK tag denominator + the album-vs-single classification
in ``build_import_album_info``."""
if not isinstance(album_context, dict):
return True
if not album_context.get('release_date'):
return True
if not album_context.get('total_tracks'):
return True
return False
def _backfill_album_context(
album_context: Dict[str, Any], detailed_track: Dict[str, Any]
) -> None:
"""Copy missing fields from ``detailed_track['album']`` into the
in-place album_context dict. Existing values are preserved —
never overwritten — because the caller's context may carry
fields the API doesn't (e.g. enhanced search shapes can include
artists arrays the bare track endpoint omits)."""
dt_album = detailed_track.get('album')
if not isinstance(dt_album, dict) or not isinstance(album_context, dict):
return
for key in ('release_date', 'album_type', 'total_tracks', 'id'):
if not album_context.get(key) and dt_album.get(key):
album_context[key] = dt_album[key]
if not album_context.get('image_url'):
images = dt_album.get('images')
if isinstance(images, list) and images:
first = images[0]
if isinstance(first, dict) and first.get('url'):
album_context['image_url'] = first['url']
def hydrate_download_metadata(
track: Any,
track_info: Any,
spotify_album_context: Dict[str, Any],
spotify_client: Any,
) -> ResolvedTrackMetadata:
"""Resolve track position and hydrate lean album context.
Steps:
1. ``track_info['track_number']`` when positive
2. ``track.track_number`` when truthy
3. ``spotify_client.get_track_details(track.id)`` — fires when
EITHER track_number unresolved OR album_context lean. The
same call serves both concerns; only one round-trip per task.
``spotify_album_context`` is mutated in place when API returns
richer data. Returns the resolved track_number / disc_number /
source. ``track_number=None`` signals "no usable value found";
the caller decides whether to floor it to 1 or leave 0.
"""
ti = track_info if isinstance(track_info, dict) else {}
# Step 1: track_info top-level — wishlist + frontend payloads.
tn = _positive_int(ti.get('track_number'))
if tn is not None:
dn = _positive_int(ti.get('disc_number')) or 1
source = 'track_info'
else:
tn = None
dn = 1
source = 'none'
# Step 2: track object — Spotify Track dataclass from search.
if tn is None:
track_tn = getattr(track, 'track_number', None)
coerced = _positive_int(track_tn)
if coerced is not None:
tn = coerced
dn = _positive_int(getattr(track, 'disc_number', None)) or 1
source = 'track_object'
needs_api_for_tn = tn is None
needs_api_for_album = _album_is_lean(spotify_album_context)
track_id = getattr(track, 'id', None)
if (needs_api_for_tn or needs_api_for_album) and track_id:
try:
detailed = spotify_client.get_track_details(track_id)
except Exception as e: # noqa: BLE001 — defensive log + continue
logger.error("[Context] API track details failed: %s", e)
detailed = None
if isinstance(detailed, dict):
if needs_api_for_tn:
api_tn = _positive_int(detailed.get('track_number'))
if api_tn is not None:
tn = api_tn
dn = _positive_int(detailed.get('disc_number')) or 1
source = 'api'
logger.info(
"[Context] Resolved track_number=%d disc_number=%d from API",
tn, dn,
)
if needs_api_for_album and isinstance(spotify_album_context, dict):
_backfill_album_context(spotify_album_context, detailed)
logger.info(
"[Context] Backfilled album context from API "
"(release_date=%r, total_tracks=%r)",
spotify_album_context.get('release_date'),
spotify_album_context.get('total_tracks'),
)
return ResolvedTrackMetadata(track_number=tn, disc_number=dn, source=source)
__all__ = [
'ResolvedTrackMetadata',
'hydrate_download_metadata',
]