soulsync/tests/metadata/test_deezer_cover_url_upgrade.py
Broque Thomas 80cf16339c Deezer cover art: upgrade CDN URL to 1900×1900 (was embedding 1000×1000)
Discord report (Tim): downloaded cover art via Deezer metadata
source came out visibly blurry in Navidrome / on phones — large
displays exposed the limited resolution.

# Cause

Deezer's API returns `cover_xl` URLs at 1000×1000. The underlying
CDN actually serves up to 1900×1900 by rewriting the size segment
in the URL path (same trick the iTunes mzstatic + Spotify scdn
upgrades already use). SoulSync wasn't doing the rewrite — every
Deezer-sourced cover got embedded at 1000×1000 regardless of how
much higher resolution the CDN had available.

# Verified empirically

```
$ for size in 1000 1400 1800 1900 2000; do curl -I "...{size}x{size}-..."; done
1000: 200 OK  106 KB
1400: 200 OK  198 KB
1800: 200 OK  331 KB
1900: 200 OK  371 KB
2000: 403 Forbidden
```

1900 is the safe ceiling. Above that the CDN returns 403. CDN
serves source-native bytes when source < target (smaller-source
albums get same bytes whether we ask for 1000 or 1900), so asking
for 1900 universally is safe.

# Fix

New `_upgrade_deezer_cover_url(url, target_size=1900)` helper in
`core/deezer_client.py`. Pure function, mirrors the
`_upgrade_spotify_image_url` pattern that already lives in
`core/spotify_client.py`. Defensive on every input shape:

- Empty / None → returned as-is
- Non-Deezer URL (no `dzcdn`) → returned as-is
- No size segment in URL → returned as-is
- Already at/above target → returned as-is (idempotent, never
  downgrades)

Applied at both cover-download sites:

- `core/metadata/artwork.py::download_cover_art` — auto post-process
  flow. Mirrors the existing iTunes mzstatic upgrade right above it.
- `core/tag_writer.py::download_cover_art` — enhanced library view's
  "Write Tags to File" feature.

# Scope discipline

- Helper applied at the DOWNLOAD boundary, not the source extraction
  point in `deezer_client.py`. Means cached entries in the metadata
  cache + DB row `image_url` columns keep the original 1000×1000 URL
  Deezer's API returned. Future CDN behavior changes only affect the
  download path, not stored data.
- Pre-existing `prefer_caa_art` toggle (Settings → Library →
  Post-Processing) untouched — orthogonal workaround for users who
  want even higher quality (MusicBrainz Cover Art Archive, often
  3000×3000+).
- iTunes / Spotify upgrade paths untouched — they already worked.

# Tests added (16)

`tests/metadata/test_deezer_cover_url_upgrade.py`:

- Standard upgrade: default target 1900 on cover URL, alternate
  dzcdn host (`e-cdns-images.dzcdn.net` vs `cdn-images.dzcdn.net`),
  artist picture URLs (same path pattern), 500×500 source upgrades
  too
- Custom target size: smaller target = no-op (never downgrade),
  larger target works
- Idempotent: already at/above target returned unchanged
- Defensive on non-Deezer URLs: parametrised across 5 hosts
  (Spotify scdn, iTunes mzstatic, MB CAA, Last.fm, random) — all
  returned untouched
- Defensive on malformed Deezer URL (no size segment) → returned
  as-is
- Empty / None handling

# Verification

- 16/16 helper tests pass
- 560/560 metadata + imports tests pass (no regression)
- 2559 full suite passes
- Ruff clean
2026-05-10 18:15:40 -07:00

140 lines
6 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