Deezer playlists: tag the REAL album track number, not the playlist index
A downloaded track was getting the wrong track number (e.g. 'Apologize' from Shock Value tagged track 1 instead of 16). Root cause: Deezer PLAYLIST track objects don't carry `track_position` (only `/track/<id>` and `/album/<id>/tracks` do — even the album object's embedded tracks omit it, verified against the live API). Both Deezer playlist builders numbered tracks by their enumerate INDEX, which poisoned the real album track number — and that value rides into the wishlist and onto the file tag. Resolve the authoritative position from each album's `/album/<id>/tracks` (cache-first, handles multi-disc) via a shared resolve_album_track_positions() helper, used by both deezer_client.get_playlist and deezer_download_client.get_playlist_tracks. Falls back to the index only if the album lookup fails (no regression). Seam tests for the helper.
This commit is contained in:
parent
cc7d48b736
commit
7bbd069147
3 changed files with 146 additions and 17 deletions
|
|
@ -117,6 +117,48 @@ def _is_full_track_payload(payload: Optional[Dict[str, Any]]) -> bool:
|
||||||
return 'track_position' in payload and 'contributors' in payload
|
return 'track_position' in payload and 'contributors' in payload
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_album_track_positions(session, base_url, album_ids, cache=None, sleep_s=0.2):
|
||||||
|
"""Build ``{str(track_id): track_position}`` for a set of Deezer album ids.
|
||||||
|
|
||||||
|
Deezer PLAYLIST and SEARCH track objects (and even the album object's embedded
|
||||||
|
``tracks.data``) omit ``track_position`` — only ``/album/<id>/tracks`` and
|
||||||
|
``/track/<id>`` carry it. So numbering playlist tracks by their playlist index
|
||||||
|
silently poisons the real album track number, which then rides onto the
|
||||||
|
downloaded file's tag. This resolves the authoritative position per album
|
||||||
|
(cache-first, best-effort — a failed album just isn't in the map)."""
|
||||||
|
import time as _time
|
||||||
|
positions: Dict[str, int] = {}
|
||||||
|
for aid in album_ids:
|
||||||
|
aid = str(aid)
|
||||||
|
at_list = None
|
||||||
|
if cache:
|
||||||
|
try:
|
||||||
|
ct = cache.get_entity('deezer', 'album_tracks', aid)
|
||||||
|
if ct and ct.get('data'):
|
||||||
|
at_list = ct['data']
|
||||||
|
except Exception: # noqa: BLE001 - cache is best-effort
|
||||||
|
at_list = None
|
||||||
|
if at_list is None:
|
||||||
|
try:
|
||||||
|
if sleep_s:
|
||||||
|
_time.sleep(sleep_s) # respect Deezer rate limits
|
||||||
|
r = session.get(f"{base_url}/album/{aid}/tracks", params={'limit': 500}, timeout=10)
|
||||||
|
if getattr(r, 'ok', False):
|
||||||
|
at_list = (r.json() or {}).get('data', [])
|
||||||
|
if cache and at_list is not None:
|
||||||
|
try:
|
||||||
|
cache.store_entity('deezer', 'album_tracks', aid, {'data': at_list})
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
except Exception: # noqa: BLE001 - never let metadata resolution break the fetch
|
||||||
|
at_list = None
|
||||||
|
for at in (at_list or []):
|
||||||
|
tp = at.get('track_position')
|
||||||
|
if at.get('id') and tp:
|
||||||
|
positions[str(at['id'])] = tp
|
||||||
|
return positions
|
||||||
|
|
||||||
|
|
||||||
# ==================== Dataclasses (match iTunesClient / SpotifyClient format) ====================
|
# ==================== Dataclasses (match iTunesClient / SpotifyClient format) ====================
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -1357,6 +1399,16 @@ class DeezerClient:
|
||||||
|
|
||||||
raw_tracks.extend(page_tracks)
|
raw_tracks.extend(page_tracks)
|
||||||
|
|
||||||
|
# Real album track positions — playlist tracks don't carry track_position,
|
||||||
|
# so numbering by playlist index would poison the downloaded file's tag.
|
||||||
|
album_ids = {str(t.get('album', {}).get('id')) for t in raw_tracks if t.get('album', {}).get('id')}
|
||||||
|
try:
|
||||||
|
from core.metadata.cache import get_metadata_cache
|
||||||
|
_cache = get_metadata_cache()
|
||||||
|
except Exception:
|
||||||
|
_cache = None
|
||||||
|
track_positions = resolve_album_track_positions(self.session, self.BASE_URL, album_ids, _cache)
|
||||||
|
|
||||||
# Normalize tracks
|
# Normalize tracks
|
||||||
tracks: List[Dict[str, Any]] = []
|
tracks: List[Dict[str, Any]] = []
|
||||||
for i, t in enumerate(raw_tracks, start=1):
|
for i, t in enumerate(raw_tracks, start=1):
|
||||||
|
|
@ -1368,7 +1420,8 @@ class DeezerClient:
|
||||||
'artists': [artist_name],
|
'artists': [artist_name],
|
||||||
'album': t.get('album', {}).get('title', ''),
|
'album': t.get('album', {}).get('title', ''),
|
||||||
'duration_ms': t.get('duration', 0) * 1000,
|
'duration_ms': t.get('duration', 0) * 1000,
|
||||||
'track_number': i,
|
# REAL album position; the playlist index is a last resort only.
|
||||||
|
'track_number': track_positions.get(str(t.get('id'))) or i,
|
||||||
})
|
})
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
|
|
|
||||||
|
|
@ -417,6 +417,12 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
||||||
if aid:
|
if aid:
|
||||||
album_ids.add(str(aid))
|
album_ids.add(str(aid))
|
||||||
album_release_dates = {}
|
album_release_dates = {}
|
||||||
|
# Deezer PLAYLIST tracks do NOT carry `track_position` (only `/track/<id>`
|
||||||
|
# and `/album/<id>/tracks` do), so numbering them by their playlist index
|
||||||
|
# poisons the real album track number — which then rides into the wishlist
|
||||||
|
# and onto the downloaded file's tag (e.g. 'Apologize' tagged track 1 instead
|
||||||
|
# of 16). Resolve the REAL position from each album's track list (cache-first).
|
||||||
|
track_positions: Dict[str, int] = {} # str(track_id) -> album track_position
|
||||||
try:
|
try:
|
||||||
from core.metadata.cache import get_metadata_cache
|
from core.metadata.cache import get_metadata_cache
|
||||||
cache = get_metadata_cache()
|
cache = get_metadata_cache()
|
||||||
|
|
@ -429,24 +435,32 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
||||||
cached = cache.get_entity('deezer', 'album', aid)
|
cached = cache.get_entity('deezer', 'album', aid)
|
||||||
if cached and cached.get('release_date'):
|
if cached and cached.get('release_date'):
|
||||||
album_release_dates[aid] = cached['release_date']
|
album_release_dates[aid] = cached['release_date']
|
||||||
continue
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("cache get_entity album release_date: %s", e)
|
logger.debug("cache get_entity album release_date: %s", e)
|
||||||
# Cache miss — fetch from API
|
# Cache miss — fetch from API
|
||||||
try:
|
if aid not in album_release_dates:
|
||||||
time.sleep(0.3) # Respect rate limits
|
try:
|
||||||
a_resp = self._session.get(f'https://api.deezer.com/album/{aid}', timeout=10)
|
time.sleep(0.3) # Respect rate limits
|
||||||
if a_resp.ok:
|
a_resp = self._session.get(f'https://api.deezer.com/album/{aid}', timeout=10)
|
||||||
a_data = a_resp.json()
|
if a_resp.ok:
|
||||||
album_release_dates[aid] = a_data.get('release_date', '')
|
a_data = a_resp.json()
|
||||||
# Store in metadata cache for future use
|
album_release_dates[aid] = a_data.get('release_date', '')
|
||||||
if cache:
|
# Store in metadata cache for future use
|
||||||
try:
|
if cache:
|
||||||
cache.store_entity('deezer', 'album', aid, a_data)
|
try:
|
||||||
except Exception as e:
|
cache.store_entity('deezer', 'album', aid, a_data)
|
||||||
logger.debug("cache store_entity album release_date: %s", e)
|
except Exception as e:
|
||||||
except Exception as e:
|
logger.debug("cache store_entity album release_date: %s", e)
|
||||||
logger.debug("fetch deezer album release_date %s: %s", aid, e)
|
except Exception as e:
|
||||||
|
logger.debug("fetch deezer album release_date %s: %s", aid, e)
|
||||||
|
# Real album track positions (separate endpoint — playlist tracks AND the
|
||||||
|
# album object's embedded tracks both omit track_position). Cache-first.
|
||||||
|
try:
|
||||||
|
from core.deezer_client import resolve_album_track_positions
|
||||||
|
track_positions = resolve_album_track_positions(
|
||||||
|
self._session, 'https://api.deezer.com', album_ids, cache)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("resolve deezer album track positions: %s", e)
|
||||||
|
|
||||||
tracks = []
|
tracks = []
|
||||||
for i, t in enumerate(raw_tracks, start=1):
|
for i, t in enumerate(raw_tracks, start=1):
|
||||||
|
|
@ -467,7 +481,9 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
||||||
'id': album_id,
|
'id': album_id,
|
||||||
},
|
},
|
||||||
'duration_ms': t.get('duration', 0) * 1000,
|
'duration_ms': t.get('duration', 0) * 1000,
|
||||||
'track_number': i,
|
# REAL album position (resolved above); the playlist index is a last
|
||||||
|
# resort only when the album lookup failed, never the default.
|
||||||
|
'track_number': track_positions.get(str(t.get('id'))) or t.get('track_position') or i,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
60
tests/test_deezer_track_positions.py
Normal file
60
tests/test_deezer_track_positions.py
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
"""Deezer playlist tracks must carry the REAL album track_position, not their
|
||||||
|
playlist index — otherwise the downloaded file is tagged with the wrong track
|
||||||
|
number (e.g. 'Apologize' from Shock Value tagged track 1 instead of 16)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from core.deezer_client import resolve_album_track_positions
|
||||||
|
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
def __init__(self, data, ok=True):
|
||||||
|
self._d, self.ok = data, ok
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._d
|
||||||
|
|
||||||
|
|
||||||
|
class _Session:
|
||||||
|
"""Fake requests session returning /album/<id>/tracks payloads."""
|
||||||
|
|
||||||
|
def __init__(self, by_album, fail_for=()):
|
||||||
|
self.by_album, self.fail_for, self.calls = by_album, set(fail_for), []
|
||||||
|
|
||||||
|
def get(self, url, params=None, timeout=None):
|
||||||
|
aid = url.rstrip('/').split('/')[-2] # …/album/<aid>/tracks
|
||||||
|
self.calls.append(aid)
|
||||||
|
if aid in self.fail_for:
|
||||||
|
return _Resp(None, ok=False)
|
||||||
|
return _Resp({'data': self.by_album.get(aid, [])})
|
||||||
|
|
||||||
|
|
||||||
|
def test_maps_track_id_to_real_album_position():
|
||||||
|
sess = _Session({'119606': [
|
||||||
|
{'id': 100, 'track_position': 16}, {'id': 101, 'track_position': 2}]})
|
||||||
|
pos = resolve_album_track_positions(sess, 'https://api.deezer.com', {'119606'}, sleep_s=0)
|
||||||
|
assert pos == {'100': 16, '101': 2} # real positions, not 1/2 enumerate
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_first_skips_the_network():
|
||||||
|
class _Cache:
|
||||||
|
def __init__(self): self.stored = {}
|
||||||
|
def get_entity(self, src, kind, aid):
|
||||||
|
return {'data': [{'id': 7, 'track_position': 9}]} if kind == 'album_tracks' else None
|
||||||
|
def store_entity(self, *a, **k): pass
|
||||||
|
sess = _Session({})
|
||||||
|
pos = resolve_album_track_positions(sess, 'https://api.deezer.com', {'42'}, cache=_Cache(), sleep_s=0)
|
||||||
|
assert pos == {'7': 9} and sess.calls == [] # served from cache, no HTTP
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_album_is_simply_absent_not_fatal():
|
||||||
|
sess = _Session({'1': [{'id': 5, 'track_position': 3}]}, fail_for={'2'})
|
||||||
|
pos = resolve_album_track_positions(sess, 'https://api.deezer.com', {'1', '2'}, sleep_s=0)
|
||||||
|
assert pos == {'5': 3} # album 2 failed → just missing
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_position_is_ignored():
|
||||||
|
# Deezer sometimes returns 0/None for odd entries — don't poison the map with them
|
||||||
|
sess = _Session({'1': [{'id': 5, 'track_position': 0}, {'id': 6}, {'id': 7, 'track_position': 4}]})
|
||||||
|
pos = resolve_album_track_positions(sess, 'https://api.deezer.com', {'1'}, sleep_s=0)
|
||||||
|
assert pos == {'7': 4}
|
||||||
Loading…
Reference in a new issue