soulsync/tests/test_itunes_album_tracks_limit.py
BoulderBadgeDad 462a722cce #918 follow-up: self-heal stale truncated iTunes album-tracks cache
The limit=200 fix only helped FRESH fetches. The metadata cache is persistent
(SQLite, 30-day TTL), so any album whose tracklist was cached at 50 BEFORE the
fix keeps returning 50 from cache in every window that loads it (line returned
the cached entry without revalidating) — which is why the Standard-view add-album
modal still showed 50 while a freshly-fetched album in the download window showed
the full list. Same album, different cache state.

Fix: get_album_tracks now marks freshly-fetched entries '_complete'. On a cache
hit, a legacy entry without that flag is revalidated against the album's known
trackCount (from collection metadata, unaffected by the bug) and re-fetched if
short. The '_complete' flag makes the heal one-time and avoids a re-fetch loop on
region-restricted albums where available tracks < trackCount.

Tests: stale-truncated -> refetch+heal; _complete -> trusted; legacy-complete and
unknown-trackCount -> trusted (no regression). Fresh fetch carries _complete.
2026-06-24 09:37:14 -07:00

156 lines
6 KiB
Python

"""iTunes album-track fetch must request the full album, not the 50-entity default (#918).
The iTunes Lookup API returns only 50 related entities unless `limit` is passed (max 200),
so albums >50 tracks were truncated to the first 50 in the download window. get_album_tracks
must pass limit=200.
"""
from __future__ import annotations
from core import itunes_client as ic
class _Cache:
"""Cache stub: always a miss; any write method is a no-op."""
def get_entity(self, *a, **k):
return None
def __getattr__(self, _name):
return lambda *a, **k: None
_RESULTS = [
{'wrapperType': 'collection', 'collectionId': 123, 'collectionName': 'Big OST',
'artistName': 'Composer', 'trackCount': 70, 'artworkUrl100': 'http://x/100x100bb.jpg'},
{'wrapperType': 'track', 'kind': 'song', 'trackId': 1, 'trackName': 'Track 1',
'trackNumber': 1, 'discNumber': 1, 'artistName': 'Composer', 'trackTimeMillis': 1000},
]
def test_get_album_tracks_requests_limit_200(monkeypatch):
client = ic.iTunesClient(country='US')
captured = {}
def fake_lookup(**params):
captured.update(params)
return _RESULTS
monkeypatch.setattr(client, '_lookup', fake_lookup)
monkeypatch.setattr(ic, 'get_metadata_cache', lambda: _Cache())
result = client.get_album_tracks('123')
assert captured.get('limit') == 200 # #918: full album, not the iTunes 50 default
assert captured.get('entity') == 'song'
assert captured.get('id') == '123'
assert result is not None
assert result.get('_complete') is True # fresh fetch is marked complete
# ── #918 follow-up: self-heal a stale truncated cache ─────────────────────────
# The metadata cache is persistent (30-day TTL), so a tracks entry written before
# the limit=200 fix stays truncated at 50 and is served to EVERY window (e.g. the
# Standard-view add-album modal) until it expires. get_album_tracks must detect a
# legacy entry shorter than the album's known trackCount and re-fetch.
class _StubCache:
"""Holds entities so cache hits/stores can be asserted."""
def __init__(self, entities=None):
self.entities = dict(entities or {})
self.stored = {}
def get_entity(self, source, entity_type, entity_id):
return self.entities.get((source, entity_type, entity_id))
def store_entity(self, source, entity_type, entity_id, data):
self.stored[(source, entity_type, entity_id)] = data
def store_entities_bulk(self, *a, **k):
pass
def _collection(track_count):
return {'wrapperType': 'collection', 'collectionId': 123, 'collectionName': 'Big OST',
'artistName': 'Composer', 'trackCount': track_count, 'artworkUrl100': 'http://x/100x100bb.jpg'}
def _track(n):
return {'wrapperType': 'track', 'kind': 'song', 'trackId': n, 'trackName': f'T{n}',
'trackNumber': n, 'discNumber': 1, 'artistName': 'Composer', 'trackTimeMillis': 1000}
def _full_results(n):
return [_collection(n)] + [_track(i) for i in range(1, n + 1)]
def _items(n):
return [{'id': str(i)} for i in range(n)]
def test_stale_truncated_legacy_cache_is_refetched(monkeypatch):
"""A 50-item legacy entry (no _complete) for a 70-track album → re-fetch full."""
client = ic.iTunesClient(country='US')
cache = _StubCache({
('itunes', 'album', '123_tracks'): {'items': _items(50), 'total': 50}, # legacy, truncated
('itunes', 'album', '123'): _collection(70), # real trackCount=70
})
monkeypatch.setattr(ic, 'get_metadata_cache', lambda: cache)
calls = {'n': 0}
def fake_lookup(**params):
calls['n'] += 1
return _full_results(70)
monkeypatch.setattr(client, '_lookup', fake_lookup)
result = client.get_album_tracks('123')
assert calls['n'] == 1 # re-fetched (didn't trust the stale 50)
assert len(result['items']) == 70
assert result['_complete'] is True
assert cache.stored[('itunes', 'album', '123_tracks')]['_complete'] is True # healed in cache
def test_complete_cache_is_trusted_no_refetch(monkeypatch):
"""A _complete entry is returned as-is even if shorter than trackCount
(region-restricted album) — must NOT loop re-fetching."""
client = ic.iTunesClient(country='US')
complete = {'items': _items(50), 'total': 50, '_complete': True}
cache = _StubCache({
('itunes', 'album', '123_tracks'): complete,
('itunes', 'album', '123'): _collection(70),
})
monkeypatch.setattr(ic, 'get_metadata_cache', lambda: cache)
def boom(**params):
raise AssertionError('must not re-fetch a _complete entry')
monkeypatch.setattr(client, '_lookup', boom)
assert client.get_album_tracks('123') is complete
def test_legacy_complete_cache_not_refetched(monkeypatch):
"""A legacy entry whose length already meets trackCount is fine — no re-fetch."""
client = ic.iTunesClient(country='US')
legacy = {'items': _items(30), 'total': 30} # no _complete, but complete by count
cache = _StubCache({
('itunes', 'album', '123_tracks'): legacy,
('itunes', 'album', '123'): _collection(30),
})
monkeypatch.setattr(ic, 'get_metadata_cache', lambda: cache)
monkeypatch.setattr(client, '_lookup', lambda **p: (_ for _ in ()).throw(AssertionError('no refetch')))
assert client.get_album_tracks('123') is legacy
def test_legacy_cache_without_album_meta_is_trusted(monkeypatch):
"""trackCount unknown (album meta not cached) → trust the cache, don't re-fetch
(safe fallback; no regression for direct get_album_tracks callers)."""
client = ic.iTunesClient(country='US')
legacy = {'items': _items(50), 'total': 50} # no _complete, no album meta
cache = _StubCache({('itunes', 'album', '123_tracks'): legacy})
monkeypatch.setattr(ic, 'get_metadata_cache', lambda: cache)
monkeypatch.setattr(client, '_lookup', lambda **p: (_ for _ in ()).throw(AssertionError('no refetch')))
assert client.get_album_tracks('123') is legacy