soulsync/core/metadata/artwork.py
BoulderBadgeDad 1ca14d1c19 Cover art: surface read-only on the cover.jpg sidecar write too (Sokhi, #804 follow-up)
Sokhi still hit the read-only error after the statvfs fix (6c3e285a). Root
cause was a gap that fix didn't cover: read_only_fs was only set from the
per-file EMBED write, but download_cover_art SWALLOWS its own cover.jpg EROFS
(logs "Error downloading cover.jpg" and returns). So when an album's tracks
already have embedded art, the embed loop is skipped, only the cover.jpg
sidecar write runs, its EROFS is swallowed, and the filler reported success
while spamming the log — exactly Sokhi's case.

Fix (no blast radius — download_cover_art still never re-raises, since its
import-pipeline callers aren't wrapped): on EROFS it now records
'_cover_read_only' on the passed context instead of just logging; apply_art_to_
album_files passes a capture dict and promotes that to read_only_fs. So a
cover-only read-only album now correctly surfaces the read-only message instead
of a silent success.

Tests: +1 — embed skipped (track already arted) + cover.jpg read-only →
read_only_fs True. 472 cover/art/repair tests pass.
2026-06-08 08:34:36 -07:00

580 lines
24 KiB
Python

"""Album artwork helpers for metadata enrichment."""
from __future__ import annotations
import errno
import os
import re
import time
import urllib.request
from ipaddress import ip_address
from urllib.parse import quote, urlparse
from core.imports.context import get_import_context_album, get_import_context_artist
from core.metadata.common import (
get_config_manager,
get_image_dimensions,
get_mutagen_symbols,
)
from utils.logging_config import get_logger as _create_logger
__all__ = [
"embed_album_art_metadata",
"download_cover_art",
"is_internal_image_host",
"is_image_proxy_url",
"normalize_image_url",
]
logger = _create_logger("metadata.artwork")
# Query-string keys whose values must be masked when a media-server
# URL ends up in a log line. Plex uses X-Plex-Token, Jellyfin uses
# X-Emby-Token / api_key, Navidrome's Subsonic auth uses t (token) +
# s (salt) + p (password fallback). Logs end up persisted to disk —
# leaking any of these gives full read access to the user's library.
_REDACT_QUERY_KEYS = (
'x-plex-token', 'x-emby-token', 'api_key', 'apikey',
't', 's', 'p', 'token', 'password',
)
_REDACT_KEYS_ALT = '|'.join(re.escape(k) for k in _REDACT_QUERY_KEYS)
# Plain form: `?key=value` or `&key=value`. Anchored on `?` / `&` (or
# string start) so short keys like `t` only match at parameter
# boundaries — not as a substring of `format=Jpg`.
_REDACT_QUERY_RE = re.compile(
r'(?i)(?P<lead>^|[?&])(?P<key>' + _REDACT_KEYS_ALT + r')=(?P<val>[^&\s]+)'
)
# URL-encoded form: `%3Fkey%3Dvalue` or `%26key%3Dvalue`. The image
# proxy wraps the original URL via `?url=<encoded>`, so the auth
# params end up encoded inside another URL. Without this second pass
# the encoded form survives plain redaction and ships to logs intact.
_REDACT_QUERY_RE_ENCODED = re.compile(
r'(?i)(?P<lead>%3F|%26)(?P<key>' + _REDACT_KEYS_ALT + r')%3D(?P<val>[^%&\s]+?)(?=%26|&|\s|$)'
)
def _redact_url_secrets(url: str | None) -> str:
"""Mask sensitive query parameters in a URL so the result is safe
to log. Handles both the plain form (``?token=abc``) and the URL-
encoded form (``%3Ftoken%3Dabc``) — the latter shows up when an
auth-bearing URL is wrapped inside another URL's query string
(e.g. our `/api/image-proxy?url=<encoded-plex-url>` flow).
Returns ``''`` for None/empty input. Idempotent (safe to call on
already-redacted strings)."""
if not url:
return ''
out = str(url)
out = _REDACT_QUERY_RE.sub(
lambda m: f"{m.group('lead')}{m.group('key')}=***REDACTED***",
out,
)
out = _REDACT_QUERY_RE_ENCODED.sub(
lambda m: f"{m.group('lead')}{m.group('key')}%3D***REDACTED***",
out,
)
return out
def normalize_image_url(thumb_url: str | None) -> str | None:
"""Convert media-server image URLs into browser-safe URLs."""
if not thumb_url:
return None
try:
if is_image_proxy_url(thumb_url):
# Already normalized for browser use; avoid wrapping it in another proxy layer.
return thumb_url
# Check if it's a localhost URL or relative path that needs fixing
needs_fixing = (
thumb_url.startswith('http://localhost:') or
thumb_url.startswith('https://localhost:') or
thumb_url.startswith('http://127.0.0.1:') or
thumb_url.startswith('https://127.0.0.1:') or
thumb_url.startswith('http://host.docker.internal:') or
thumb_url.startswith('https://host.docker.internal:') or
(thumb_url.startswith('http://') and is_internal_image_host(thumb_url)) or
thumb_url.startswith('/library/') or # Plex relative paths
thumb_url.startswith('/Items/') or # Jellyfin relative paths
thumb_url.startswith('/api/') or # Old Navidrome API paths
thumb_url.startswith('/rest/') # Navidrome Subsonic API paths
)
if needs_fixing:
cfg = get_config_manager()
active_server = cfg.get_active_media_server()
logger.debug("Fixing URL: %s, Active server: %s", thumb_url, active_server)
if active_server == 'plex':
plex_config = cfg.get_plex_config()
plex_base_url = plex_config.get('base_url', '')
plex_token = plex_config.get('token', '')
if plex_base_url and plex_token:
# Extract the path from URL
if thumb_url.startswith('/library/'):
# Already a path
path = thumb_url
else:
# Full localhost URL, extract path
parsed = urlparse(thumb_url)
path = parsed.path
# Construct proper Plex URL with token
fixed_url = f"{plex_base_url.rstrip('/')}{path}?X-Plex-Token={plex_token}"
logger.debug("Fixed URL: %s", _redact_url_secrets(fixed_url))
return _browser_safe_image_url(fixed_url)
elif active_server == 'jellyfin':
jellyfin_config = cfg.get_jellyfin_config()
jellyfin_base_url = jellyfin_config.get('base_url', '')
jellyfin_token = jellyfin_config.get('api_key', '')
if jellyfin_base_url:
# Extract the path from URL
if thumb_url.startswith('/Items/') or thumb_url.startswith('/api/'):
# Already a path
path = thumb_url
else:
# Full localhost URL, extract path
parsed = urlparse(thumb_url)
path = parsed.path
# Construct proper Jellyfin URL with token
if jellyfin_token:
separator = '&' if '?' in path else '?'
fixed_url = f"{jellyfin_base_url.rstrip('/')}{path}{separator}X-Emby-Token={jellyfin_token}"
else:
fixed_url = f"{jellyfin_base_url.rstrip('/')}{path}"
logger.debug("Fixed URL: %s", _redact_url_secrets(fixed_url))
return _browser_safe_image_url(fixed_url)
elif active_server == 'navidrome':
navidrome_config = cfg.get_navidrome_config()
navidrome_base_url = navidrome_config.get('base_url', '')
navidrome_username = navidrome_config.get('username', '')
navidrome_password = navidrome_config.get('password', '')
if navidrome_base_url and navidrome_username and navidrome_password:
# Extract the path from URL
if thumb_url.startswith('/rest/'):
# Already a Subsonic API path
path = thumb_url
else:
# Full localhost URL, extract path
parsed = urlparse(thumb_url)
path = parsed.path
# Generate Subsonic API authentication
import hashlib
import secrets
salt = secrets.token_hex(6)
token = hashlib.md5((navidrome_password + salt).encode()).hexdigest()
# Add authentication parameters to the URL
separator = '&' if '?' in path else '?'
auth_params = f"u={navidrome_username}&t={token}&s={salt}&v=1.16.1&c=SoulSync&f=json"
# Construct proper Navidrome Subsonic URL
fixed_url = f"{navidrome_base_url.rstrip('/')}{path}{separator}{auth_params}"
logger.debug("Fixed URL: %s", _redact_url_secrets(fixed_url))
return _browser_safe_image_url(fixed_url)
logger.warning("No configuration found for %s or unsupported server type", active_server)
# Return a browser-safe URL even if no server-specific rebuild was possible.
return _browser_safe_image_url(thumb_url)
except Exception as exc:
logger.error("Error fixing image URL '%s': %s", _redact_url_secrets(thumb_url), exc)
return _browser_safe_image_url(thumb_url)
def is_image_proxy_url(url: str) -> bool:
"""Return True for SoulSync image proxy/cache URLs, absolute or relative."""
if not url:
return False
try:
parsed = urlparse(url)
return parsed.path == '/api/image-proxy' or parsed.path.startswith('/api/image-cache/')
except Exception:
return False
def is_internal_image_host(url: str) -> bool:
"""Return True when an image URL points at a host the browser likely cannot reach directly."""
try:
parsed = urlparse(url)
host = (parsed.hostname or '').strip('[]').lower()
if not host:
return False
if host in {'localhost', '127.0.0.1', '::1', 'host.docker.internal'}:
return True
# Single-label hosts are usually Docker service names or local LAN aliases.
if '.' not in host:
return True
try:
ip = ip_address(host)
return ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_reserved
except ValueError:
return False
except Exception:
return False
def _browser_safe_image_url(url: str) -> str:
"""Return a browser-safe image URL, proxying internal hosts through SoulSync."""
if not url:
return url
if is_image_proxy_url(url):
return url
if url.startswith('/api/image-proxy?url=') or url.startswith('/api/image-cache/'):
return url
if url.startswith('http://') or url.startswith('https://'):
try:
from core.image_cache import cached_image_url
cached_url = cached_image_url(url)
if cached_url:
return cached_url
except Exception as exc:
logger.debug("image cache URL registration failed: %s", exc)
if is_internal_image_host(url):
return f"/api/image-proxy?url={quote(url, safe='')}"
return url
# Relative media-server paths should already have been expanded before this point.
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 arrives as Cover Art Archive thumbnails
# (/front-250 — see musicbrainz_search._cover_art_url). Upgrade to the
# bare /front ORIGINAL — native resolution, frequently 3000px+ (#806:
# the old /front-1200 cap left MusicBrainz as the one source still
# below native while iTunes already shipped 3000x3000 — and bare
# /front URLs from release-group lookups bypassed the cap anyway,
# so the policy was inconsistent in practice). The original redirects
# to archive.org, which can be flaky, so `_fetch_art_bytes` inserts a
# /front-1200 midpoint fallback before the original-size URL:
# flakiness degrades to the old 1200px behavior, never below it.
return re.sub(r"/front(-\d+)?$", "/front", art_url)
return art_url
# Negative cache for CAA originals: art is fetched PER TRACK, and the bare
# /front original rides archive.org. During an archive.org outage every track
# would otherwise pay a 10s timeout before falling back — a 12-track album
# would eat +2 minutes. One failure puts originals on cooldown; fetches go
# straight to the 1200px CDN (the pre-#806 behavior, full speed) until then.
_caa_original_down_until = 0.0
_CAA_ORIGINAL_COOLDOWN_S = 600
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 walks a fallback chain so a
refused size degrades gracefully and never regresses below the original
URL's behavior. For Cover Art Archive that chain is
original (/front) -> 1200px CDN thumbnail -> the original sized URL.
Returns `(image_data, mime_type)` or `(None, None)` on failure.
"""
global _caa_original_down_until
if not art_url:
return None, None
upgraded = _upgrade_art_url(art_url)
is_caa_original = "coverartarchive.org" in upgraded and upgraded.endswith("/front")
attempts = []
if not (is_caa_original and time.time() < _caa_original_down_until):
attempts.append(upgraded)
if is_caa_original:
# Midpoint fallback: the 1200px CDN thumbnail (the pre-#806 behavior),
# tried BEFORE the original sized URL so a flaky archive.org degrades
# to 1200px — never all the way down to the 250px thumbnail.
attempts.append(upgraded + "-1200")
if art_url not in attempts:
attempts.append(art_url)
last_err = None
for i, candidate in enumerate(attempts):
try:
with urllib.request.urlopen(candidate, timeout=10) as response:
return response.read(), (response.info().get_content_type() or "image/jpeg")
except Exception as fetch_err:
last_err = fetch_err
if is_caa_original and candidate == upgraded:
# archive.org refused the original — cool down so the next
# tracks of this batch skip straight to the CDN thumbnail.
_caa_original_down_until = time.time() + _CAA_ORIGINAL_COOLDOWN_S
logger.info(
"CAA original refused (%s); using 1200px CDN for the next %d min",
fetch_err, _CAA_ORIGINAL_COOLDOWN_S // 60,
)
elif i < len(attempts) - 1:
logger.info("Art URL refused (%s); falling back to next size", fetch_err)
logger.error("Art fetch failed after %d attempt(s): %s", len(attempts), last_err)
return None, None
def _min_size_art_validator(min_px):
"""Build a ``(validate, cache)`` pair for the preferred-art resolver.
``validate(source, url)`` fetches the candidate cover, caches its bytes (so
the winning source isn't fetched twice), and accepts it only when its
shortest side is at least ``min_px``. A too-small cover — e.g. a low-res
Cover Art Archive upload — is rejected so the resolver falls through to the
next source instead of letting it win on priority alone. Images whose
dimensions can't be read are accepted (don't over-reject; the fallback is
still today's art). ``min_px <= 0`` disables the size gate entirely.
"""
cache = {}
def validate(_source, url):
res = _fetch_art_bytes(url)
cache[url] = res
data = res[0] if res else None
if not data:
return False
if not min_px or min_px <= 0:
return True
dims = get_image_dimensions(data)
if not dims:
return True
return min(dims[0] or 0, dims[1] or 0) >= min_px
return validate, cache
def embed_album_art_metadata(audio_file, metadata: dict):
cfg = get_config_manager()
symbols = get_mutagen_symbols()
if not symbols:
return False
try:
image_data = None
mime_type = None
# User-preferred cover-art source. When album_art_order is a non-empty
# list it is the SOLE authority for preferred art (put 'caa' in it to use
# Cover Art Archive), and the legacy prefer_caa_art toggle below is
# skipped. With no list this is a no-op and behavior is exactly as before.
album_art_order = cfg.get("metadata_enhancement.album_art_order")
art_list_active = isinstance(album_art_order, (list, tuple)) and len(album_art_order) > 0
try:
from core.metadata.art_lookup import select_preferred_art_url
_validate, _art_cache = _min_size_art_validator(
cfg.get("metadata_enhancement.min_art_size", 1000))
preferred_url = select_preferred_art_url(
metadata.get("album_artist") or metadata.get("artist"),
metadata.get("album"),
metadata,
album_art_order,
validate=_validate,
)
if preferred_url:
cached = _art_cache.get(preferred_url)
image_data, mime_type = cached if (cached and cached[0]) else _fetch_art_bytes(preferred_url)
except Exception as exc:
logger.debug("Preferred art-source selection failed: %s", exc)
release_mbid = metadata.get("musicbrainz_release_id")
if not image_data and not art_list_active and release_mbid and cfg.get("metadata_enhancement.prefer_caa_art", False):
try:
# 1200px CDN thumbnail, not the flaky bare /front original.
caa_url = f"https://coverartarchive.org/release/{release_mbid}/front-1200"
req = urllib.request.Request(caa_url, headers={"Accept": "image/*"})
with urllib.request.urlopen(req, timeout=10) as response:
image_data = response.read()
mime_type = response.info().get_content_type() or "image/jpeg"
if not image_data or len(image_data) <= 1000:
image_data = None
except Exception:
image_data = None
if not image_data:
art_url = metadata.get("album_art_url")
if not art_url:
logger.warning("No album art URL available for embedding.")
return False
image_data, mime_type = _fetch_art_bytes(art_url)
if not image_data:
logger.error("Failed to download album art data.")
return False
if isinstance(audio_file.tags, symbols.ID3):
audio_file.tags.add(symbols.APIC(encoding=3, mime=mime_type, type=3, desc="Cover", data=image_data))
elif isinstance(audio_file, symbols.FLAC):
picture = symbols.Picture()
picture.data = image_data
picture.type = 3
picture.mime = mime_type
width, height = get_image_dimensions(image_data)
picture.width = width or 640
picture.height = height or 640
picture.depth = 24
audio_file.add_picture(picture)
elif isinstance(audio_file, symbols.MP4):
fmt = symbols.MP4Cover.FORMAT_JPEG if "jpeg" in mime_type else symbols.MP4Cover.FORMAT_PNG
audio_file["covr"] = [symbols.MP4Cover(image_data, imageformat=fmt)]
logger.info("Album art successfully embedded.")
return True
except Exception as exc:
logger.error("Error embedding album art: %s", exc)
return False
def download_cover_art(album_info: dict, target_dir: str, context: dict = None):
cfg = get_config_manager()
if cfg.get("metadata_enhancement.cover_art_download", True) is False:
return
try:
cover_path = os.path.join(target_dir, "cover.jpg")
album_info = album_info or {}
release_mbid = album_info.get("musicbrainz_release_id")
# When a preferred-art priority list is configured it is the sole
# authority, so the legacy CAA toggle is neutralized for this whole
# function (it gates the existing-file upgrade logic too).
_art_order = cfg.get("metadata_enhancement.album_art_order")
_art_list_active = isinstance(_art_order, (list, tuple)) and len(_art_order) > 0
prefer_caa = cfg.get("metadata_enhancement.prefer_caa_art", False) and not _art_list_active
if os.path.exists(cover_path):
if release_mbid and prefer_caa:
try:
existing_size = os.path.getsize(cover_path)
if existing_size > 200_000:
return
is_upgrade = True
except Exception:
return
else:
return
else:
is_upgrade = False
image_data = None
# User-preferred cover-art source (no-op unless album_art_order is set).
# cover.jpg only supports the artist+album sources here (no MBID in
# album_info), which matches today's CAA-only special-casing.
try:
from core.metadata.art_lookup import select_preferred_art_url
artist_ctx = get_import_context_artist(context) if context else {}
_validate, _art_cache = _min_size_art_validator(
cfg.get("metadata_enhancement.min_art_size", 1000))
preferred_url = select_preferred_art_url(
(artist_ctx or {}).get("name"),
album_info.get("album_name"),
album_info,
cfg.get("metadata_enhancement.album_art_order"),
validate=_validate,
)
if preferred_url:
cached = _art_cache.get(preferred_url)
pref_data = cached[0] if (cached and cached[0]) else _fetch_art_bytes(preferred_url)[0]
if pref_data and len(pref_data) > 1000:
image_data = pref_data
except Exception as exc:
logger.debug("Preferred art-source selection failed: %s", exc)
if not image_data and release_mbid and prefer_caa:
try:
# 1200px CDN thumbnail, not the flaky bare /front original.
caa_url = f"https://coverartarchive.org/release/{release_mbid}/front-1200"
req = urllib.request.Request(caa_url, headers={"Accept": "image/*"})
with urllib.request.urlopen(req, timeout=10) as response:
image_data = response.read()
if not image_data or len(image_data) <= 1000:
image_data = None
except Exception:
image_data = None
if is_upgrade and not image_data:
logger.error("CAA upgrade failed - keeping existing cover.jpg")
return
if not image_data:
art_url = album_info.get("album_image_url")
if not art_url and context:
album_ctx = get_import_context_album(context)
art_url = album_ctx.get("image_url")
if not art_url and album_ctx.get("images"):
images = album_ctx.get("images", [])
if images and isinstance(images[0], dict):
art_url = images[0].get("url", "")
if art_url:
logger.info("Using cover art URL from album context")
if not art_url:
logger.warning("No cover art URL available for download.")
return
# Upgrade to the source's highest resolution (Spotify master /
# iTunes 3000 / Deezer 1900) with a one-level fallback — shared
# with the tag-embed path so cover.jpg and embedded art match.
image_data, _ = _fetch_art_bytes(art_url)
if not image_data:
return
with open(cover_path, "wb") as handle:
handle.write(image_data)
logger.info("Cover art downloaded to: %s", cover_path)
except Exception as exc:
# A read-only mount (EROFS) is a "can't write" condition the caller
# needs to surface (cover-art filler #804/Tim/Sokhi) — but we must NOT
# re-raise (import callers aren't wrapped here). Record it on the
# context so callers that care can detect it, instead of just spamming
# the log with a swallowed error.
if getattr(exc, "errno", None) == errno.EROFS:
if isinstance(context, dict):
context["_cover_read_only"] = True
logger.warning("cover.jpg write blocked — read-only filesystem: %s", cover_path)
else:
logger.error("Error downloading cover.jpg: %s", exc)