Two confirmed-from-code-reading bugs in the wishlist retry chain.
Both cause downstream post-process to render every retried file as
``01 - <title>`` without year in the folder path, even when the
source slskd file had the correct track number embedded and Spotify
had the album release date.
**Bug A — track_number defaults to 1 at every link in the chain.**
Pre-fix: ``.get('track_number', 1)`` defaulted at four sites:
- ``core/wishlist/payloads.py:121`` ``ensure_wishlist_track_format``
- ``core/wishlist/payloads.py:282`` Track-object conversion
- ``core/imports/context.py:421`` legacy album-info builder
- ``core/imports/pipeline.py:645`` final processing read
Each step "filled in" 1 when the upstream had dropped the key. The
downstream filename-extract fallback at ``pipeline.py:652`` ONLY
runs when the value is None — pre-filled 1 never matched, so the
fallback never fired, so the source filename's track number (e.g.
``08. No Sleep Till Brooklyn.flac``) was discarded in favour of the
default-1.
Fix: change every default from ``1`` to ``None`` along the chain.
The pipeline already has the right detect-and-recover logic — it
just needs the chain to stop poisoning it. Final ``< 1`` floor at
``pipeline.py:660`` still defaults to 1 as last resort, so callers
that genuinely have nothing still produce a valid number.
**Bug B — release_date dropped from cancelled-task wishlist payload.**
Pre-fix: ``build_cancelled_task_wishlist_payload`` only ``setdefault``ed
``name`` / ``album_type`` / ``images`` on the album dict. The
release_date field copy was load-bearing (when input was a dict, the
``dict(album_raw)`` copy preserved it), but when input was a bare
string the constructed dict had only name + album_type — no
release_date / total_tracks / etc.
Fix:
- Explicit comment on the dict-shape branch that release_date survives
via the unconditional ``dict(album_raw)`` copy + setdefault
semantics — so a future refactor that switches to a stricter copy
doesn't silently strip the field.
- String-shape branch now pulls release_date from
``track_info.album_release_date`` or ``track_info.release_date``
when present so the round-trip preserves the year for the path
template.
- track_data shape itself now carries ``track_number`` / ``disc_number``
at the top level (Bug A intersect — was dropping it entirely).
**Tests:** 4 new in tests/wishlist/test_payloads.py:
- ``test_ensure_wishlist_track_format_preserves_real_track_number``
- ``test_ensure_wishlist_track_format_keeps_missing_track_number_as_none``
- ``test_build_cancelled_task_wishlist_payload_preserves_track_number``
- ``test_build_cancelled_task_wishlist_payload_string_album_pulls_release_date_from_track_info``
14 payload tests pass; 879 across wishlist + imports + downloads
suites still green; 1410 wider suite all pass. Ruff clean.
Commits 2 + 3 of 3 in PR 2/4 of the wishlist-album-bundle issue fix
series. Commit 1 (94ba1d73) instrumented staging-match so the next
wishlist run produces the evidence we need to diagnose bug C
(staging-match silently drops album-bundle wishlist tracks); that
fix lands in a follow-up PR after the user's next reproduction run.
247 lines
8.7 KiB
Python
247 lines
8.7 KiB
Python
from types import SimpleNamespace
|
|
|
|
from core.wishlist import payloads
|
|
|
|
|
|
def test_sanitize_track_data_for_processing_normalizes_artists_and_album():
|
|
track = {
|
|
"name": "Song",
|
|
"album": 123,
|
|
"artists": [{"name": "Artist One"}, "Artist Two", SimpleNamespace(name="Artist Three")],
|
|
}
|
|
|
|
out = payloads.sanitize_track_data_for_processing(track)
|
|
|
|
assert out["album"] == "123"
|
|
assert out["artists"] == ["Artist One", "Artist Two", "namespace(name='Artist Three')"]
|
|
|
|
|
|
def test_get_track_artist_name_prefers_artists_list_then_artist_field():
|
|
assert payloads.get_track_artist_name({"artists": [{"name": "Artist One"}]}) == "Artist One"
|
|
assert payloads.get_track_artist_name({"artist": "Solo Artist"}) == "Solo Artist"
|
|
assert payloads.get_track_artist_name({}) == "Unknown Artist"
|
|
|
|
|
|
def test_ensure_spotify_track_format_preserves_existing_shape():
|
|
track = {
|
|
"id": "sp-1",
|
|
"name": "Song",
|
|
"artists": [{"name": "Artist One"}],
|
|
"album": {"name": "Album", "album_type": "ep", "total_tracks": 4},
|
|
}
|
|
|
|
out = payloads.ensure_spotify_track_format(track)
|
|
|
|
assert out is track
|
|
|
|
|
|
def test_ensure_spotify_track_format_builds_webui_shape():
|
|
track = {
|
|
"name": "Song",
|
|
"artist": "Artist One",
|
|
"album": {"name": "Album One", "release_date": "2024-01-01"},
|
|
"duration_ms": 1234,
|
|
"track_number": 7,
|
|
"disc_number": 2,
|
|
"preview_url": "https://example.test/preview",
|
|
"external_urls": {"spotify": "https://open.spotify.com/track/1"},
|
|
"popularity": 42,
|
|
}
|
|
|
|
out = payloads.ensure_spotify_track_format(track)
|
|
|
|
assert out["name"] == "Song"
|
|
assert out["artists"] == [{"name": "Artist One"}]
|
|
assert out["album"]["name"] == "Album One"
|
|
assert out["album"]["album_type"] == "album"
|
|
assert out["album"]["total_tracks"] == 0
|
|
assert out["source"] == "webui_modal"
|
|
|
|
|
|
def test_ensure_wishlist_track_format_aliases_the_spotify_helper():
|
|
track = {
|
|
"name": "Song",
|
|
"artist": "Artist One",
|
|
"album": {"name": "Album One"},
|
|
}
|
|
|
|
out = payloads.ensure_wishlist_track_format(track)
|
|
|
|
assert out["name"] == "Song"
|
|
assert out["artists"] == [{"name": "Artist One"}]
|
|
assert out["album"]["name"] == "Album One"
|
|
|
|
|
|
def test_extract_spotify_track_from_modal_info_converts_trackresult_like_object():
|
|
track_info = {
|
|
"spotify_track": SimpleNamespace(
|
|
title="Song Two",
|
|
artist="Artist Two",
|
|
album="Album Two",
|
|
)
|
|
}
|
|
|
|
out = payloads.extract_spotify_track_from_modal_info(track_info)
|
|
|
|
assert out["source"] == "trackresult"
|
|
assert out["name"] == "Song Two"
|
|
assert out["artists"] == [{"name": "Artist Two"}]
|
|
assert out["album"]["name"] == "Album Two"
|
|
|
|
|
|
def test_extract_spotify_track_from_modal_info_reconstructs_from_slskd_result():
|
|
track_info = {
|
|
"slskd_result": SimpleNamespace(
|
|
title="Song Three",
|
|
artist="Artist Three",
|
|
album="Album Three",
|
|
)
|
|
}
|
|
|
|
out = payloads.extract_spotify_track_from_modal_info(track_info)
|
|
|
|
assert out["reconstructed"] is True
|
|
assert out["name"] == "Song Three"
|
|
assert out["artists"] == [{"name": "Artist Three"}]
|
|
assert out["album"]["name"] == "Album Three"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# track_number / disc_number preservation through the wishlist payload
|
|
# helpers — pins the bug A fix from PR 2/4. Pre-fix the helpers
|
|
# defaulted missing numbers to 1, which locked every wishlist retry
|
|
# to track 01 because the import pipeline's filename-extract fallback
|
|
# only fires when the value is None (not the pre-filled 1).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_ensure_wishlist_track_format_preserves_real_track_number():
|
|
"""Real track positions must survive the format helper. Pre-fix
|
|
the helper read ``track_info.get('track_number', 1)`` which always
|
|
returned 1 if the upstream payload had dropped the key — the
|
|
desired number was lost on every round-trip."""
|
|
track = {
|
|
"name": "No Sleep Till Brooklyn",
|
|
"artist": "Beastie Boys",
|
|
"album": {"name": "Licensed to Ill", "release_date": "1986-11-15"},
|
|
"track_number": 8,
|
|
"disc_number": 1,
|
|
}
|
|
out = payloads.ensure_wishlist_track_format(track)
|
|
assert out["track_number"] == 8
|
|
assert out["disc_number"] == 1
|
|
|
|
|
|
def test_ensure_wishlist_track_format_keeps_missing_track_number_as_none():
|
|
"""When the upstream payload doesn't carry a track number, the
|
|
helper must NOT pre-fill 1 — that poisons the chain and locks the
|
|
file to track 01. Leave None so the import pipeline's filename
|
|
fallback at ``core/imports/pipeline.py:652`` can fire."""
|
|
track = {
|
|
"name": "Mystery Track",
|
|
"artist": "Artist",
|
|
"album": {"name": "Album"},
|
|
}
|
|
out = payloads.ensure_wishlist_track_format(track)
|
|
assert out["track_number"] is None
|
|
assert out["disc_number"] is None
|
|
|
|
|
|
def test_build_cancelled_task_wishlist_payload_preserves_track_number():
|
|
"""Cancellation→re-add path was the worst offender — the payload
|
|
builder dropped track_number from the saved data entirely (didn't
|
|
even include the key). Next wishlist cycle saw missing key →
|
|
helper defaulted to 1 → file imported as 01. Now both the
|
|
cancellation payload AND the helper preserve real positions."""
|
|
task = {
|
|
"track_info": {
|
|
"id": "trk-1", "name": "Brass Monkey",
|
|
"artists": [{"name": "Beastie Boys"}],
|
|
"album": {"name": "Licensed to Ill", "release_date": "1986-11-15"},
|
|
"track_number": 11,
|
|
"disc_number": 1,
|
|
},
|
|
"playlist_name": "Wishlist",
|
|
"playlist_id": "p1",
|
|
}
|
|
out = payloads.build_cancelled_task_wishlist_payload(task)
|
|
td = out["track_data"]
|
|
assert td["track_number"] == 11
|
|
assert td["disc_number"] == 1
|
|
# Album release_date survives the round-trip so the path template
|
|
# renders the year in the folder name.
|
|
assert td["album"]["release_date"] == "1986-11-15"
|
|
|
|
|
|
def test_build_cancelled_task_wishlist_payload_string_album_pulls_release_date_from_track_info():
|
|
"""When the source ``album`` field is a bare string, the payload
|
|
builder constructs an album dict from scratch — it must pull
|
|
release_date / album_image_url / etc. from the adjacent
|
|
track_info fields rather than dropping them silently."""
|
|
task = {
|
|
"track_info": {
|
|
"id": "trk-2", "name": "Song",
|
|
"artists": [{"name": "Artist"}],
|
|
"album": "Bare String Album",
|
|
"release_date": "2020-06-01",
|
|
},
|
|
}
|
|
out = payloads.build_cancelled_task_wishlist_payload(task)
|
|
album = out["track_data"]["album"]
|
|
assert album["name"] == "Bare String Album"
|
|
assert album["release_date"] == "2020-06-01"
|
|
|
|
|
|
def test_ensure_wishlist_track_format_defaults_non_dict_album_to_album_type():
|
|
"""When ``album`` arrives as a non-dict (legacy/reconstruction path) we
|
|
must not stamp ``album_type='single'`` — that lies about the origin
|
|
and routes the wishlist requeue through the single_path template
|
|
instead of album_path, dumping album tracks into the Singles tree.
|
|
Default to 'album' / total_tracks=0 (unknown) so downstream code can
|
|
fall through to the real release-type detection logic."""
|
|
track = {
|
|
"name": "Song",
|
|
"artist": "Artist One",
|
|
"album": "Album From Legacy String",
|
|
}
|
|
|
|
out = payloads.ensure_wishlist_track_format(track)
|
|
|
|
assert out["album"]["name"] == "Album From Legacy String"
|
|
assert out["album"]["album_type"] == "album"
|
|
assert out["album"]["total_tracks"] == 0
|
|
|
|
|
|
def test_extract_spotify_track_from_modal_info_slskd_reconstruction_defaults_to_album():
|
|
"""Slskd-result reconstruction is a last-resort path; defaulting to
|
|
``album_type='single'`` corrupted the requeue routing for album
|
|
batches. Same fix as ensure_wishlist_track_format: default 'album'."""
|
|
track_info = {
|
|
"slskd_result": SimpleNamespace(
|
|
title="Song Three",
|
|
artist="Artist Three",
|
|
album="Album Three",
|
|
)
|
|
}
|
|
|
|
out = payloads.extract_spotify_track_from_modal_info(track_info)
|
|
|
|
assert out["album"]["album_type"] == "album"
|
|
assert out["album"]["total_tracks"] == 0
|
|
|
|
|
|
def test_extract_wishlist_track_from_modal_info_uses_track_data_key():
|
|
track_info = {
|
|
"track_data": {
|
|
"id": "track-1",
|
|
"name": "Song Four",
|
|
"artists": [{"name": "Artist Four"}],
|
|
"album": {"name": "Album Four"},
|
|
}
|
|
}
|
|
|
|
out = payloads.extract_wishlist_track_from_modal_info(track_info)
|
|
|
|
assert out["id"] == "track-1"
|
|
assert out["name"] == "Song Four"
|
|
assert out["artists"] == [{"name": "Artist Four"}]
|