soulsync/tests/metadata/test_deezer_cover_url_upgrade.py
Broque Thomas 8a4c0dc92a Deezer cover-art download: fallback to original URL on CDN refusal
Defensive followup. If Deezer CDN ever refuses the upgraded
1900×1900 URL for a specific album (rare — empirically tested 4
albums and none hit it), pre-fix would have succeeded with the
1000×1000 URL and post-fix would have failed entirely.

Both download sites now retry with the original URL when the
upgraded URL fails:

- `core/metadata/artwork.py::download_cover_art` — auto post-process
  flow. Resolves the original URL from album_info / context the same
  way the existing path does.
- `core/tag_writer.py::download_cover_art` — captures the original
  URL before upgrade so the retry has it without a second context
  lookup.

Strictly non-regressive: worst plausible post-fix case is now
identical to pre-fix (cover at 1000×1000 succeeds). Fallback only
fires on the rare CDN-refusal edge.

Tests added (2):

- `test_tag_writer_retries_with_original_on_failure` — upgraded URL
  raises, original succeeds, both attempts logged in call order
- `test_tag_writer_no_fallback_for_non_dzcdn_url` — non-Deezer URLs
  go through unchanged, no fallback path triggered (single attempt)

Verification:
- 18/18 helper + integration tests pass
- 2561 full suite passes
- Ruff clean
2026-05-10 18:29:36 -07:00

205 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Pin the Deezer CDN cover-URL upgrade helper.
Discord report (Tim, 2026-05-XX): downloaded cover art via Deezer
metadata source comes out blurry — visibly low-res in Navidrome.
Cause: Deezer's API returns ``cover_xl`` URLs at 1000×1000 but the
underlying CDN serves up to 1900×1900 by rewriting the size segment
in the URL path. SoulSync wasn't doing the rewrite.
Helper: ``_upgrade_deezer_cover_url(url, target_size=1900)`` — pure
function, lifts to one boundary so cover-download sites don't each
re-implement the regex. Tests pin every input shape:
- Standard Deezer URL → upgraded to target
- Non-Deezer URL → returned unchanged
- Already at/above target → returned unchanged (no needless rewrite)
- Empty / None → returned as-is
- Custom target → applied correctly
- Picture URLs (artist) — same path pattern, also upgraded
"""
from __future__ import annotations
import pytest
from core.deezer_client import _upgrade_deezer_cover_url
# ---------------------------------------------------------------------------
# Standard upgrade — the headline case
# ---------------------------------------------------------------------------
class TestUpgradeStandardDeezerUrl:
def test_default_target_1900(self):
url = 'https://cdn-images.dzcdn.net/images/cover/abc123/1000x1000-000000-80-0-0.jpg'
upgraded = _upgrade_deezer_cover_url(url)
assert upgraded == 'https://cdn-images.dzcdn.net/images/cover/abc123/1900x1900-000000-80-0-0.jpg'
def test_alternate_dzcdn_host(self):
"""Both `cdn-images.dzcdn.net` and `e-cdns-images.dzcdn.net`
are valid Deezer CDN hosts. Helper must catch both."""
url = 'https://e-cdns-images.dzcdn.net/images/cover/xyz/1000x1000-000000-80-0-0.jpg'
upgraded = _upgrade_deezer_cover_url(url)
assert '1900x1900' in upgraded
assert upgraded.startswith('https://e-cdns-images.dzcdn.net/')
def test_artist_picture_url_also_upgrades(self):
"""Artist `picture_xl` URLs follow the same `/SIZExSIZE-` path
pattern and the same CDN. Same upgrade applies."""
url = 'https://cdn-images.dzcdn.net/images/artist/hash/1000x1000-000000-80-0-0.jpg'
upgraded = _upgrade_deezer_cover_url(url)
assert '1900x1900' in upgraded
def test_500x500_upgrades(self):
"""Some albums on Deezer only have cover_big (500×500). Helper
upgrades anything below target, not just 1000×1000."""
url = 'https://cdn-images.dzcdn.net/images/cover/abc/500x500-000000-80-0-0.jpg'
upgraded = _upgrade_deezer_cover_url(url)
assert '1900x1900' in upgraded
# ---------------------------------------------------------------------------
# Custom target size
# ---------------------------------------------------------------------------
class TestCustomTargetSize:
def test_smaller_target(self):
"""Caller can request a smaller size for bandwidth-sensitive
cases (mobile, thumbnails, etc.)."""
url = 'https://cdn-images.dzcdn.net/images/cover/abc/1000x1000-000000-80-0-0.jpg'
upgraded = _upgrade_deezer_cover_url(url, target_size=600)
# 1000 already > 600, so this is a no-op — never DOWNGRADE.
assert upgraded == url
def test_larger_target_works(self):
url = 'https://cdn-images.dzcdn.net/images/cover/abc/250x250-000000-80-0-0.jpg'
upgraded = _upgrade_deezer_cover_url(url, target_size=1400)
assert '1400x1400' in upgraded
# ---------------------------------------------------------------------------
# Already-upgraded URLs — no needless rewrite
# ---------------------------------------------------------------------------
class TestAlreadyUpgraded:
def test_already_at_target_returned_unchanged(self):
"""Re-running the upgrade on an already-upgraded URL should
be a no-op. Idempotent — important for cached URLs that may
have been rewritten by a previous SoulSync version."""
url = 'https://cdn-images.dzcdn.net/images/cover/abc/1900x1900-000000-80-0-0.jpg'
assert _upgrade_deezer_cover_url(url) == url
def test_above_target_returned_unchanged(self):
"""Defensive: if the URL is somehow LARGER than target, don't
downgrade. Cached URL from a future bigger-target setting,
manual edits, etc."""
url = 'https://cdn-images.dzcdn.net/images/cover/abc/3000x3000-000000-80-0-0.jpg'
assert _upgrade_deezer_cover_url(url) == url
# ---------------------------------------------------------------------------
# Defensive — non-Deezer URLs left untouched
# ---------------------------------------------------------------------------
class TestNonDeezerUrls:
@pytest.mark.parametrize('url', [
'https://i.scdn.co/image/spotify-id-thing', # Spotify
'https://is4-ssl.mzstatic.com/image/100x100bb.jpg', # iTunes
'https://coverartarchive.org/release/abc/front', # MB CAA
'https://lastfm.freetls.fastly.net/i/u/770x0/abc.jpg', # Last.fm
'https://example.com/random.jpg', # Random
])
def test_non_dzcdn_returned_unchanged(self, url):
"""Helper must NOT touch non-Deezer URLs. Mirrors the
defensive check pattern the iTunes and Spotify upgrade
helpers use."""
assert _upgrade_deezer_cover_url(url) == url
def test_dzcdn_url_without_size_segment_returned_unchanged(self):
"""Defensive: if Deezer ever changes URL format, don't crash
— return as-is and let the download attempt happen with the
original URL."""
url = 'https://cdn-images.dzcdn.net/images/cover/abc/some-other-format.jpg'
assert _upgrade_deezer_cover_url(url) == url
# ---------------------------------------------------------------------------
# Empty / None inputs
# ---------------------------------------------------------------------------
class TestEmptyInputs:
def test_empty_string(self):
assert _upgrade_deezer_cover_url('') == ''
def test_none(self):
assert _upgrade_deezer_cover_url(None) is None
# ---------------------------------------------------------------------------
# Download fallback — if upgraded URL 403s, retry with original
# ---------------------------------------------------------------------------
class TestDownloadFallbackOnCdnRefusal:
"""If Deezer CDN refuses the upgraded 1900×1900 URL for some
specific album (rare but possible — empirically tested 4 albums
and none hit this, but defending the edge keeps the fix
strictly non-regressive vs. pre-upgrade behaviour)."""
def test_tag_writer_retries_with_original_on_failure(self, monkeypatch):
"""tag_writer.download_cover_art must fall back to the
original URL when the upgraded URL fails."""
from core import tag_writer
original_url = 'https://cdn-images.dzcdn.net/images/cover/abc/1000x1000-000000-80-0-0.jpg'
upgraded_url = 'https://cdn-images.dzcdn.net/images/cover/abc/1900x1900-000000-80-0-0.jpg'
call_log = []
class _FakeResponse:
def read(self): return b'cover-bytes'
def info(self):
class _Info:
def get_content_type(_self): return 'image/jpeg'
return _Info()
def __enter__(self): return self
def __exit__(self, *a): pass
def fake_urlopen(url, timeout=None):
call_log.append(url)
if url == upgraded_url:
raise Exception("403 Forbidden")
return _FakeResponse()
monkeypatch.setattr('core.tag_writer.urllib.request.urlopen', fake_urlopen)
result = tag_writer.download_cover_art(original_url)
assert result == (b'cover-bytes', 'image/jpeg')
# Tried upgraded first, then fell back to original
assert call_log == [upgraded_url, original_url]
def test_tag_writer_no_fallback_for_non_dzcdn_url(self, monkeypatch):
"""Non-Deezer URLs go through unchanged — no upgrade, no
fallback. Fast path preserved."""
from core import tag_writer
spotify_url = 'https://i.scdn.co/image/abc'
call_log = []
def fake_urlopen(url, timeout=None):
call_log.append(url)
raise Exception("network error")
monkeypatch.setattr('core.tag_writer.urllib.request.urlopen', fake_urlopen)
result = tag_writer.download_cover_art(spotify_url)
assert result is None
# Single attempt — no Deezer fallback path triggered
assert call_log == [spotify_url]