Real-world regression triggered by the album-bundle work earlier in
2.6.3. Tracks with full Spotify metadata were importing as
``01 - <title>`` under ``Artist - Album/`` (no year), even when the
source filename carried the correct track number and Spotify's
release_date was available.
Investigation via DB inspection of stored wishlist rows:
```
"Never Gonna Give You Up" → track_number=None, release_date=""
"idfc" → track_number=1, release_date=""
"No Sleep Till Brooklyn" → track_number=1, release_date=""
```
Source-of-truth Spotify metadata had release_date AND real track
positions, but the wishlist row was poisoned. Three regressions
compounded the loss:
**Fix A — ``track_object_to_dict`` (``core/wishlist/payloads.py:295``)
preserved only album.name during Track→dict conversion.**
Pre-fix:
```python
album_name = "Unknown Album"
if hasattr(track_object, "album") and track_object.album:
if hasattr(track_object.album, "name"):
album_name = track_object.album.name
else:
album_name = str(track_object.album)
result = {
...
"album": {"name": album_name}, # ← release_date / images / etc. all dropped
...
}
```
When a wishlist payload arrived as a Track dataclass instead of a
raw spotify_data dict, the Track→dict conversion stripped
release_date, images, album_type, total_tracks, id, and album-level
artists. Every wishlist row added through this path landed in the
DB with ``album={'name': X}`` only.
Post-fix: three branches handle the three album shapes
- ``album_attr`` is a dict → ``dict(album_attr)`` preserves every key
- ``album_attr`` is a sub-object → pull all common Album-dataclass
attrs (id, release_date, album_type, total_tracks, images, ...)
- ``album_attr`` is a bare string → build a dict from the track
object's adjacent attrs (release_date, album_id, album_type, ...)
and surface ``image_url`` as ``album.images``
**Fix B — ``core/discovery/playlist.py:309`` only added
``track_number`` / ``disc_number`` keys when truthy.**
Pre-fix:
```python
matched_data = { 'id': ..., 'name': ..., ... } # no track_number / disc_number
if track_number:
matched_data['track_number'] = track_number
if disc_number:
matched_data['disc_number'] = disc_number
```
Deezer-sourced matches always hit this branch with ``track_number=None``
because the cache enrichment at line 304 reads ``_raw.get('track_number')``
literally, but Deezer's raw shape uses ``track_position``. So the key
was omitted from ``matched_data``, downstream consumers couldn't
distinguish "missing key" from "value is 1", and the chain silently
filled 1.
Post-fix: keys are ALWAYS present (None when unknown). Also adds a
``best_match.track_number`` fallback so the Track-dataclass-mapped
value (which DOES include ``track_position``→``track_number``
mapping) gets used when the cache lookup misses.
**Fix C — Pipeline only consulted ``album_info.track_number`` before
falling to the filename (``core/imports/pipeline.py:645``).**
VA-collection source files like ``417 Fountains of Wayne - Stacys
Mom.flac`` have a leading playlist-position number that isn't the
album track number. The previous chain (album_info → filename →
floor-1) couldn't recover the real position because the filename
extractor either returned 417 (wrong) or None (caught by the floor).
But the wishlist payload's ``track_info.spotify_data.track_number``
HAD the right answer all along — Spotify says Stacy's Mom is track
3 on Welcome Interstate Managers.
Post-fix: resolution chain extracted into ``core/imports/track_number.py:resolve_track_number``
as a pure function:
1. ``album_info.track_number`` (album-bundle dispatch authoritative)
2. ``track_info.track_number`` (per-track flow payload)
3. ``track_info.spotify_data.track_number`` (nested fallback)
4. ``extract_explicit_track_number(file_path)`` (filename, returns
0 when no numeric prefix — vs the default helper that returns 1)
5. Caller (pipeline) applies the final >=1 floor
Each step coerces to a positive int or falls through to the next.
Pure function = unit-testable in isolation = single place to fix
the rule.
**Test coverage (37 new tests):**
- ``tests/wishlist/test_payloads.py`` (+4) — Track→dict conversion
preserves full album dict (dict / object / string album shapes) +
None-track-number stays None.
- ``tests/discovery/test_discovery_playlist.py`` (+2) — matched_data
always includes track_number/disc_number keys (None when unknown)
+ falls back to best_match attrs when cache misses.
- ``tests/imports/test_track_number_resolver.py`` (+16) — every
resolution-chain branch pinned: album_info-wins, track_info
fallback, spotify_data nested, JSON-string parsing, garbage-string
fall-through, zero / negative / non-numeric / string-numeric
coercion, filename fallback, explicit extractor vs default
extractor semantics, defensive None inputs, VA-collection
filename behaviour, all-sources-missing → None.
1571 wider-suite tests pass (wishlist + imports + discovery +
downloads + metadata). Ruff clean.
**Migration note:** existing wishlist rows that were saved under
the OLD ``track_object_to_dict`` (with stripped album metadata) still
have ``release_date=''`` in the DB blob. Those won't self-heal — the
next attempt loads from the poisoned blob. Users can remove + re-add
those tracks to refresh, or wait for the next sync run that
re-discovers them with full metadata. No automatic migration shipped
in this PR (scope creep — the forward path is fixed, backfill is a
separate concern).
105 lines
4.1 KiB
Python
105 lines
4.1 KiB
Python
"""Pure-function resolver for the import pipeline's track_number lookup.
|
|
|
|
Lifted from ``core/imports/pipeline.py`` so the multi-source fallback
|
|
chain can be unit-tested in isolation. The pipeline integration is
|
|
one call site that delegates to ``resolve_track_number`` and then
|
|
applies the >=1 floor as the last-resort default.
|
|
|
|
Resolution order (first valid positive int wins):
|
|
|
|
1. ``album_info.track_number`` — set by upstream album-info builders
|
|
when they have authoritative track position data (e.g. the
|
|
album-bundle dispatch from ``core/downloads/master.py``).
|
|
2. ``track_info.track_number`` — Spotify-shaped track dict carried
|
|
on the per-task download context. Populated by the per-track
|
|
flow when the wishlist payload still has Spotify's position.
|
|
3. ``track_info.spotify_data.track_number`` — nested spotify_data
|
|
dict inside track_info; common for wishlist-loop payloads that
|
|
wrapped the source spotify dict under an outer envelope.
|
|
4. ``extract_track_number_from_filename(file_path)`` — last resort
|
|
when none of the metadata sources carried the value.
|
|
|
|
Pre-fix, the pipeline only consulted ``album_info`` and fell straight
|
|
to the filename when it was None. That broke for VA-collection
|
|
source files like ``417 Fountains of Wayne - Stacys Mom.flac`` where
|
|
the leading number isn't the album track position — extract returned
|
|
None or the wrong number, post-process defaulted to 1, and every
|
|
such wishlist import landed as ``01 - <title>`` regardless of the
|
|
real source position.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any, Optional
|
|
|
|
from core.imports.filename import extract_explicit_track_number
|
|
|
|
|
|
def _coerce_positive(value: Any) -> Optional[int]:
|
|
"""Coerce ``value`` to a positive int, or return None when the
|
|
value is missing / non-numeric / non-positive. Centralised so
|
|
every check in ``resolve_track_number`` applies the same rules."""
|
|
try:
|
|
v = int(value)
|
|
return v if v >= 1 else None
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _coerce_spotify_data(track_info: Any) -> dict:
|
|
"""Extract the nested ``spotify_data`` dict from a track_info
|
|
payload, coercing string-JSON shapes and bad inputs to an empty
|
|
dict so the caller can use ``.get`` safely."""
|
|
if not isinstance(track_info, dict):
|
|
return {}
|
|
raw = track_info.get('spotify_data')
|
|
if isinstance(raw, dict):
|
|
return raw
|
|
if isinstance(raw, str):
|
|
try:
|
|
parsed = json.loads(raw)
|
|
return parsed if isinstance(parsed, dict) else {}
|
|
except (ValueError, TypeError):
|
|
return {}
|
|
return {}
|
|
|
|
|
|
def resolve_track_number(
|
|
album_info: Any,
|
|
track_info: Any,
|
|
file_path: str,
|
|
) -> Optional[int]:
|
|
"""Walk the resolution chain and return the first valid positive
|
|
int found, or None when every source is missing / unusable.
|
|
|
|
Caller is responsible for the final default-1 floor — leaving
|
|
that out of this function so tests can pin "everything missing
|
|
returns None" separate from the floor behaviour.
|
|
"""
|
|
album_info = album_info if isinstance(album_info, dict) else {}
|
|
track_info = track_info if isinstance(track_info, dict) else {}
|
|
spotify_data = _coerce_spotify_data(track_info)
|
|
|
|
resolved = (
|
|
_coerce_positive(album_info.get('track_number'))
|
|
or _coerce_positive(track_info.get('track_number'))
|
|
or _coerce_positive(spotify_data.get('track_number'))
|
|
)
|
|
if resolved is not None:
|
|
return resolved
|
|
|
|
# Filename fallback — use the EXPLICIT extractor variant which
|
|
# returns 0 when no numeric prefix is recognised (vs. the default
|
|
# variant that silently returns 1 for the unknown case). We want
|
|
# "unknown" to stay unknown here so the pipeline's final
|
|
# default-1 floor is the single source of that fallback —
|
|
# otherwise this resolver would silently fill 1 and the
|
|
# downstream floor logic would have no effect.
|
|
if not file_path:
|
|
return None
|
|
try:
|
|
from_filename = extract_explicit_track_number(file_path)
|
|
except Exception:
|
|
from_filename = None
|
|
return _coerce_positive(from_filename)
|