Embed highest-resolution album art across all art paths

User report: embedded album art came out ~600x600 while the cover.jpg in
the folder was high-res. The cover.jpg path upgraded the source CDN URL
to its highest resolution, but the tag-embed path fetched the raw URL —
so iTunes art embedded at its 600x600 default, Spotify at 640, Deezer at
1000. The "Write Tags to File" retag path had the same gap (Deezer-only
upgrade), and MusicBrainz art was worse still: every Cover Art Archive
URL is built as the /front-250 thumbnail, so MB-sourced downloads
embedded 250x250.

Factor the resolution upgrade + fetch into two shared helpers in
core/metadata/artwork.py and route every art path through them:

  _upgrade_art_url(url) — bump to the source's highest resolution:
    - Spotify (i.scdn.co)      -> original master (~2000px+)
    - iTunes (mzstatic.com)    -> 3000x3000
    - Deezer (dzcdn)           -> 1900x1900
    - Cover Art Archive        -> /front original (was /front-250)
  _fetch_art_bytes(url) — upgrade, fetch, and fall back once to the
    original size if the CDN refuses the larger one (non-regressive).

Now consistent across: embed-into-tags (post-process), folder cover.jpg
(post-process), and the enhanced-library "Write Tags to File" retag flow.
The YouTube path already upgraded via Album.from_spotify_album, unchanged.
De-duplicates the per-source upgrade code that was copied across sites
and drops the now-unused urllib import from tag_writer.

Not covered (follow-up): Last.fm / Amazon / Tidal / Qobuz have no
explicit upgrade yet — some already serve full-res, others may hand over
a capped size that passes through unchanged.

Tests: new tests/metadata/test_artwork_resolution.py pins every upgrade
(Spotify 300/640->master, iTunes 100/600->3000, Deezer->1900, CAA
thumbnail->original, unrecognized/empty unchanged) and the fetch
fallback. Updated the two tag_writer fallback tests to patch the network
at its new home in artwork.
This commit is contained in:
BoulderBadgeDad 2026-05-28 13:21:53 -07:00
parent ff974c0b5c
commit c9ad4f496f
4 changed files with 254 additions and 99 deletions

View file

@ -256,6 +256,79 @@ def _browser_safe_image_url(url: str) -> str:
return url return url
def _upgrade_art_url(art_url: str) -> str:
"""Rewrite a source CDN art URL to the highest resolution that source
serves, so embedded tag art is as sharp as the cover.jpg in the folder.
- Spotify (i.scdn.co): request the original uploaded master (~2000px+).
- iTunes (mzstatic.com): bump the size segment to 3000x3000.
- Deezer (dzcdn): rewrite to 1900x1900 (CDN serves larger than the API's
1000px cover_xl).
Unrecognized URLs are returned unchanged. Both the embed and cover.jpg
paths call this so the two never diverge in quality again.
"""
if not art_url:
return art_url
if "i.scdn.co" in art_url:
try:
from core.spotify_client import _upgrade_spotify_image_url
return _upgrade_spotify_image_url(art_url)
except Exception as e:
logger.debug("upgrade spotify image url failed: %s", e)
elif "mzstatic.com" in art_url:
return re.sub(r"\d+x\d+bb", "3000x3000bb", art_url)
elif "dzcdn" in art_url:
try:
from core.deezer_client import _upgrade_deezer_cover_url
return _upgrade_deezer_cover_url(art_url)
except Exception as e:
logger.debug("upgrade deezer image url failed: %s", e)
elif "coverartarchive.org" in art_url:
# MusicBrainz art comes in as Cover Art Archive thumbnails
# (/front-250, also -500/-1200 — see musicbrainz_search._cover_art_url).
# The bare /front is the original full-resolution upload, which always
# exists when any front art does, so rewrite the size segment back to
# it. Critical for MB-as-source users: without this, embedded art is
# the 250px search thumbnail. `_fetch_art_bytes` falls back to the
# sized URL if /front is ever refused.
return re.sub(r"/front-\d+", "/front", art_url)
return art_url
def _fetch_art_bytes(art_url: str):
"""Fetch artwork bytes at the highest resolution the source serves.
Upgrades the URL via `_upgrade_art_url`, then fetches. If the upgraded
(larger) size is refused by the CDN, retry once with the original URL so
we never regress vs. the un-upgraded behavior. Empirically the upgrade
works for every album tested; the fallback just defends the edge case.
Returns `(image_data, mime_type)` or `(None, None)` on failure.
"""
if not art_url:
return None, None
upgraded = _upgrade_art_url(art_url)
try:
with urllib.request.urlopen(upgraded, timeout=10) as response:
return response.read(), (response.info().get_content_type() or "image/jpeg")
except Exception as fetch_err:
if upgraded != art_url:
logger.info(
"Upgraded art URL refused (%s); retrying original size", fetch_err
)
try:
with urllib.request.urlopen(art_url, timeout=10) as response:
return response.read(), (response.info().get_content_type() or "image/jpeg")
except Exception as retry_err:
logger.error("Art fetch failed after fallback: %s", retry_err)
return None, None
logger.error("Art fetch failed: %s", fetch_err)
return None, None
def embed_album_art_metadata(audio_file, metadata: dict): def embed_album_art_metadata(audio_file, metadata: dict):
cfg = get_config_manager() cfg = get_config_manager()
symbols = get_mutagen_symbols() symbols = get_mutagen_symbols()
@ -284,9 +357,7 @@ def embed_album_art_metadata(audio_file, metadata: dict):
if not art_url: if not art_url:
logger.warning("No album art URL available for embedding.") logger.warning("No album art URL available for embedding.")
return return
with urllib.request.urlopen(art_url, timeout=10) as response: image_data, mime_type = _fetch_art_bytes(art_url)
image_data = response.read()
mime_type = response.info().get_content_type() or "image/jpeg"
if not image_data: if not image_data:
logger.error("Failed to download album art data.") logger.error("Failed to download album art data.")
@ -365,59 +436,13 @@ def download_cover_art(album_info: dict, target_dir: str, context: dict = None):
art_url = images[0].get("url", "") art_url = images[0].get("url", "")
if art_url: if art_url:
logger.info("Using cover art URL from album context") logger.info("Using cover art URL from album context")
if art_url and "i.scdn.co" in art_url:
try:
from core.spotify_client import _upgrade_spotify_image_url
art_url = _upgrade_spotify_image_url(art_url)
except Exception as e:
logger.debug("upgrade spotify image url failed: %s", e)
elif art_url and "mzstatic.com" in art_url:
art_url = re.sub(r"\d+x\d+bb", "3000x3000bb", art_url)
elif art_url and "dzcdn" in art_url:
# 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. Without this upgrade
# users embedding cover art via Deezer get visibly
# blurry covers in their library / phone player (Discord
# report from Tim, 2026-05). Same shape as the iTunes
# mzstatic upgrade above + Spotify scdn upgrade.
try:
from core.deezer_client import _upgrade_deezer_cover_url
art_url = _upgrade_deezer_cover_url(art_url)
except Exception as e:
logger.debug("upgrade deezer image url failed: %s", e)
if not art_url: if not art_url:
logger.warning("No cover art URL available for download.") logger.warning("No cover art URL available for download.")
return return
# Fetch with one fallback level: if we upgraded a Deezer # Upgrade to the source's highest resolution (Spotify master /
# URL above and the CDN happens to refuse the larger size # iTunes 3000 / Deezer 1900) with a one-level fallback — shared
# for this specific album, retry with the original URL so # with the tag-embed path so cover.jpg and embedded art match.
# we never regress vs. pre-upgrade behavior. Empirically image_data, _ = _fetch_art_bytes(art_url)
# 1900 works for every album tested but defending against
# the edge case keeps the fix strictly non-regressive.
original_url = album_info.get("album_image_url")
if context and not original_url:
album_ctx = get_import_context_album(context)
original_url = album_ctx.get("image_url") or original_url
try:
with urllib.request.urlopen(art_url, timeout=10) as response:
image_data = response.read()
except Exception as fetch_err:
if (
"dzcdn" in art_url
and original_url
and original_url != art_url
):
logger.info(
"Deezer CDN refused upgraded cover URL (%s); "
"retrying with original size", fetch_err,
)
with urllib.request.urlopen(original_url, timeout=10) as response:
image_data = response.read()
else:
raise
if not image_data: if not image_data:
return return

View file

@ -6,7 +6,6 @@ Reuses the same Mutagen patterns as _enhance_file_metadata in web_server.py.
import os import os
import logging import logging
import urllib.request
from typing import Dict, Any, Optional, List, Tuple from typing import Dict, Any, Optional, List, Tuple
from mutagen import File as MutagenFile from mutagen import File as MutagenFile
@ -205,48 +204,18 @@ def download_cover_art(cover_url: str) -> Optional[Tuple[bytes, str]]:
Download cover art once. Returns (image_data, mime_type) or None on failure. Download cover art once. Returns (image_data, mime_type) or None on failure.
Call this once per album, then pass the result to write_tags_to_file for each track. Call this once per album, then pass the result to write_tags_to_file for each track.
For Deezer CDN URLs, upgrades the size segment to 1900×1900 (CDN Delegates to ``core.metadata.artwork._fetch_art_bytes`` so the enhanced-
max). Mirrors the same upgrade in library-view "Write Tags to File" feature embeds the same highest-
``core.metadata.artwork.download_cover_art`` so the resolution cover the auto post-process flow does Spotify master
enhanced-library-view "Write Tags to File" feature embeds the same (~2000px), iTunes 3000×3000, and Deezer 1900×1900 with the same
high-resolution cover the auto post-process flow does. Falls back one-level fallback to the original size if the CDN refuses the upgrade.
to the original URL if the CDN refuses the upgraded size for a
specific album keeps the fix strictly non-regressive vs. the
pre-upgrade behaviour.
""" """
if not cover_url: if not cover_url:
return None return None
original_url = cover_url from core.metadata.artwork import _fetch_art_bytes
if 'dzcdn' in cover_url:
try: image_data, mime_type = _fetch_art_bytes(cover_url)
from core.deezer_client import _upgrade_deezer_cover_url return (image_data, mime_type) if image_data else None
cover_url = _upgrade_deezer_cover_url(cover_url)
except Exception as e:
logger.debug("upgrade deezer image url failed: %s", e)
try:
with urllib.request.urlopen(cover_url, timeout=15) as response:
image_data = response.read()
mime_type = response.info().get_content_type() or 'image/jpeg'
if image_data:
return (image_data, mime_type)
except Exception as e:
# Deezer CDN refused upgraded size for this album — retry with
# original URL so we never get less than pre-upgrade behaviour.
if 'dzcdn' in cover_url and cover_url != original_url:
logger.info(
"Deezer CDN refused upgraded cover URL (%s); retrying with original size", e,
)
try:
with urllib.request.urlopen(original_url, timeout=15) as response:
image_data = response.read()
mime_type = response.info().get_content_type() or 'image/jpeg'
if image_data:
return (image_data, mime_type)
except Exception as fallback_err:
logger.error(f"Error downloading cover art (fallback): {fallback_err}")
return None
logger.error(f"Error downloading cover art from {cover_url}: {e}")
return None
def write_tags_to_file(file_path: str, db_data: Dict[str, Any], def write_tags_to_file(file_path: str, db_data: Dict[str, Any],

View file

@ -0,0 +1,158 @@
"""Pin the shared artwork resolution-upgrade + fetch helpers.
Bug (Discord, user report): embedded album art came out ~600×600 while the
cover.jpg in the folder was high-res. Cause: only the cover.jpg path
upgraded the source CDN URL to its highest resolution (Spotify master /
iTunes 3000 / Deezer 1900); the tag-embed path and the "Write Tags to File"
retag path fetched the raw URL Spotify 640, iTunes 600, Deezer 1000.
Fix: one shared ``_upgrade_art_url`` + ``_fetch_art_bytes`` in
``core.metadata.artwork`` that every art path now calls, so embedded art is
always the same highest resolution as the folder cover.
"""
from __future__ import annotations
import pytest
from core.metadata.artwork import _upgrade_art_url, _fetch_art_bytes
# ---------------------------------------------------------------------------
# _upgrade_art_url — per-source resolution bump
# ---------------------------------------------------------------------------
class TestUpgradeArtUrl:
def test_spotify_album_art_upgraded_to_master(self):
# Spotify encodes size as the hex segment after 'ab67616d0000'.
# 1e02 = 300px, b273 = 640px; 82c1 = the original uploaded master.
url = 'https://i.scdn.co/image/ab67616d00001e02deadbeef'
assert _upgrade_art_url(url) == 'https://i.scdn.co/image/ab67616d000082c1deadbeef'
def test_spotify_640_also_upgraded(self):
url = 'https://i.scdn.co/image/ab67616d0000b273cafef00d'
assert _upgrade_art_url(url) == 'https://i.scdn.co/image/ab67616d000082c1cafef00d'
def test_itunes_600_upgraded_to_3000(self):
url = 'https://is1-ssl.mzstatic.com/image/thumb/abc/600x600bb.jpg'
assert _upgrade_art_url(url) == 'https://is1-ssl.mzstatic.com/image/thumb/abc/3000x3000bb.jpg'
def test_itunes_100_upgraded_to_3000(self):
url = 'https://is1-ssl.mzstatic.com/image/thumb/abc/100x100bb.jpg'
assert '3000x3000bb' in _upgrade_art_url(url)
def test_deezer_upgraded_to_1900(self):
url = 'https://cdn-images.dzcdn.net/images/cover/abc/1000x1000-000000-80-0-0.jpg'
assert '1900x1900' in _upgrade_art_url(url)
def test_caa_thumbnail_upgraded_to_original(self):
# MusicBrainz art arrives as /front-250 (and -500/-1200) thumbnails;
# /front is the original full-resolution upload.
url = 'https://coverartarchive.org/release/abc-123/front-250'
assert _upgrade_art_url(url) == 'https://coverartarchive.org/release/abc-123/front'
def test_caa_500_and_release_group_scope_upgraded(self):
assert _upgrade_art_url('https://coverartarchive.org/release/x/front-500') \
== 'https://coverartarchive.org/release/x/front'
assert _upgrade_art_url('https://coverartarchive.org/release-group/y/front-1200') \
== 'https://coverartarchive.org/release-group/y/front'
def test_caa_already_original_unchanged(self):
url = 'https://coverartarchive.org/release/abc/front'
assert _upgrade_art_url(url) == url
@pytest.mark.parametrize('url', [
'https://lastfm.freetls.fastly.net/i/u/770x0/x.jpg',
'https://example.com/random.jpg',
])
def test_unrecognized_url_unchanged(self, url):
assert _upgrade_art_url(url) == url
def test_empty_and_none_unchanged(self):
assert _upgrade_art_url('') == ''
assert _upgrade_art_url(None) is None
# ---------------------------------------------------------------------------
# _fetch_art_bytes — upgrade, fetch, fall back to original on CDN refusal
# ---------------------------------------------------------------------------
class _FakeResponse:
def __init__(self, data=b'art-bytes', ctype='image/jpeg'):
self._data = data
self._ctype = ctype
def read(self):
return self._data
def info(self):
ctype = self._ctype
class _Info:
def get_content_type(_self):
return ctype
return _Info()
def __enter__(self):
return self
def __exit__(self, *a):
pass
class TestFetchArtBytes:
def test_fetches_upgraded_url(self, monkeypatch):
"""The upgraded (high-res) URL is the one actually fetched."""
calls = []
def fake_urlopen(url, timeout=None):
calls.append(url)
return _FakeResponse(b'big-cover', 'image/png')
monkeypatch.setattr('core.metadata.artwork.urllib.request.urlopen', fake_urlopen)
data, mime = _fetch_art_bytes('https://i.scdn.co/image/ab67616d0000b273x')
assert data == b'big-cover'
assert mime == 'image/png'
# Fetched the master-res URL, not the 640 original.
assert calls == ['https://i.scdn.co/image/ab67616d000082c1x']
def test_falls_back_to_original_when_upgrade_refused(self, monkeypatch):
upgraded = 'https://is1-ssl.mzstatic.com/image/thumb/a/3000x3000bb.jpg'
original = 'https://is1-ssl.mzstatic.com/image/thumb/a/600x600bb.jpg'
calls = []
def fake_urlopen(url, timeout=None):
calls.append(url)
if url == upgraded:
raise Exception('403 Forbidden')
return _FakeResponse(b'orig-cover')
monkeypatch.setattr('core.metadata.artwork.urllib.request.urlopen', fake_urlopen)
data, mime = _fetch_art_bytes(original)
assert data == b'orig-cover'
assert calls == [upgraded, original] # tried big first, then fell back
def test_no_fallback_when_url_not_upgraded(self, monkeypatch):
"""If the upgrade is a no-op (unrecognized URL), a single failed
fetch returns (None, None) no pointless retry of the same URL."""
calls = []
def fake_urlopen(url, timeout=None):
calls.append(url)
raise Exception('network down')
monkeypatch.setattr('core.metadata.artwork.urllib.request.urlopen', fake_urlopen)
data, mime = _fetch_art_bytes('https://example.com/cover.jpg')
assert (data, mime) == (None, None)
assert calls == ['https://example.com/cover.jpg']
def test_empty_url_returns_none(self):
assert _fetch_art_bytes('') == (None, None)
assert _fetch_art_bytes(None) == (None, None)

View file

@ -153,7 +153,9 @@ class TestDownloadFallbackOnCdnRefusal:
def test_tag_writer_retries_with_original_on_failure(self, monkeypatch): def test_tag_writer_retries_with_original_on_failure(self, monkeypatch):
"""tag_writer.download_cover_art must fall back to the """tag_writer.download_cover_art must fall back to the
original URL when the upgraded URL fails.""" original URL when the upgraded URL fails. The fetch now lives in
the shared ``core.metadata.artwork._fetch_art_bytes`` helper that
tag_writer delegates to, so the network is patched there."""
from core import tag_writer from core import tag_writer
original_url = 'https://cdn-images.dzcdn.net/images/cover/abc/1000x1000-000000-80-0-0.jpg' original_url = 'https://cdn-images.dzcdn.net/images/cover/abc/1000x1000-000000-80-0-0.jpg'
@ -176,7 +178,7 @@ class TestDownloadFallbackOnCdnRefusal:
raise Exception("403 Forbidden") raise Exception("403 Forbidden")
return _FakeResponse() return _FakeResponse()
monkeypatch.setattr('core.tag_writer.urllib.request.urlopen', fake_urlopen) monkeypatch.setattr('core.metadata.artwork.urllib.request.urlopen', fake_urlopen)
result = tag_writer.download_cover_art(original_url) result = tag_writer.download_cover_art(original_url)
@ -185,8 +187,9 @@ class TestDownloadFallbackOnCdnRefusal:
assert call_log == [upgraded_url, original_url] assert call_log == [upgraded_url, original_url]
def test_tag_writer_no_fallback_for_non_dzcdn_url(self, monkeypatch): def test_tag_writer_no_fallback_for_non_dzcdn_url(self, monkeypatch):
"""Non-Deezer URLs go through unchanged — no upgrade, no """Non-Deezer URLs with no recognizable size segment go through
fallback. Fast path preserved.""" unchanged no upgrade, so a single fetch attempt with no
fallback."""
from core import tag_writer from core import tag_writer
spotify_url = 'https://i.scdn.co/image/abc' spotify_url = 'https://i.scdn.co/image/abc'
@ -196,10 +199,10 @@ class TestDownloadFallbackOnCdnRefusal:
call_log.append(url) call_log.append(url)
raise Exception("network error") raise Exception("network error")
monkeypatch.setattr('core.tag_writer.urllib.request.urlopen', fake_urlopen) monkeypatch.setattr('core.metadata.artwork.urllib.request.urlopen', fake_urlopen)
result = tag_writer.download_cover_art(spotify_url) result = tag_writer.download_cover_art(spotify_url)
assert result is None assert result is None
# Single attempt — no Deezer fallback path triggered # Single attempt — upgrade is a no-op for this URL, so no fallback
assert call_log == [spotify_url] assert call_log == [spotify_url]