Write artist.jpg to artist folder so Navidrome shows real photos
Closes #572 (rhwc). Navidrome has no API for setting an artist image — it reads `artist.jpg` (or `folder.jpg`) from the artist folder during library scans. SoulSync's `update_artist_poster` for Navidrome was a no-op, so users only ever saw album-art-derived thumbnails as the artist photo. - new "Write Artist Image" button on artist detail page - POST /api/artist/<id>/write-image-to-disk derives the artist folder from any track's resolved file_path (reuses _resolve_library_file_path so docker mount translation + library.music_paths probes from #558 apply), fetches the photo from the configured metadata source priority chain, downloads with content-type validation, writes atomically via `<filename>.tmp + os.replace` - when active server is Navidrome, triggers a library scan immediately so the file is picked up - respects existing artist.jpg (frontend prompts before overwriting) so user-supplied photos aren't clobbered - works for plex / jellyfin too as a fallback layer — both servers also read artist.jpg from disk 26 tests pin the pure helpers in core/library/artist_image.py: folder derivation (trailing sep / empty / non-string), URL picking (missing attr / whitespace / non-string), download (non-image content-type / 404 / timeout / empty body), atomic write (replace / temp-cleanup-on-failure / overwrite guard / missing folder).
This commit is contained in:
parent
6390b09f70
commit
89246a7304
7 changed files with 636 additions and 0 deletions
161
core/library/artist_image.py
Normal file
161
core/library/artist_image.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"""Write `artist.jpg` to the artist's folder on disk.
|
||||
|
||||
Navidrome has no API for setting an artist image — it reads
|
||||
`artist.jpg` (or `artist.png` / `folder.jpg`) directly from the
|
||||
artist's folder during library scans. Plex and Jellyfin have API
|
||||
uploads (already implemented elsewhere), but their `read_from_disk`
|
||||
behavior also picks up `artist.jpg` as a fallback, so writing the
|
||||
file to disk is a portable mechanism that works for every server.
|
||||
|
||||
Pre-existing reference: issue #572 (rhwc) — Navidrome users only
|
||||
saw album-art-derived artist thumbnails. SoulSync's
|
||||
`update_artist_poster()` for Navidrome at `core/navidrome_client.py`
|
||||
was a NO-OP (returned True without doing anything).
|
||||
|
||||
This module is the pure helpers backing the new endpoint. No
|
||||
network, no DB, no Flask. Each function is testable in isolation
|
||||
with `tmp_path` fixtures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
|
||||
logger = get_logger("library.artist_image")
|
||||
|
||||
|
||||
_ARTIST_IMAGE_FILENAME = "artist.jpg"
|
||||
|
||||
# Reasonable timeout for the image download. Artist images from
|
||||
# Spotify/Deezer are typically 100-500KB so a generous timeout still
|
||||
# completes in a few seconds on a slow connection.
|
||||
_DEFAULT_IMAGE_DOWNLOAD_TIMEOUT = 30
|
||||
|
||||
|
||||
def derive_artist_folder(album_folder: str) -> str:
|
||||
"""Derive the artist's folder from an album's folder.
|
||||
|
||||
Standard SoulSync path templates produce
|
||||
``<library_root>/<artist>/<album>/...`` — so the artist folder is
|
||||
one level up from the album folder. Returns empty string for
|
||||
empty input; preserves the platform's path separator.
|
||||
|
||||
Doesn't validate that the result exists on disk. Caller checks.
|
||||
"""
|
||||
if not album_folder or not isinstance(album_folder, str):
|
||||
return ""
|
||||
# Trim trailing separator so dirname doesn't return the album
|
||||
# folder unchanged on inputs like "Music/Drake/Views/".
|
||||
trimmed = album_folder.rstrip("/").rstrip("\\")
|
||||
parent = os.path.dirname(trimmed)
|
||||
return parent or ""
|
||||
|
||||
|
||||
def pick_artist_image_url(artist_obj) -> Optional[str]:
|
||||
"""Return the URL to use for the artist image, if any.
|
||||
|
||||
Reads the `image_url` attribute from a typed Artist dataclass
|
||||
(Spotify / Deezer / Discogs / etc — every typed Artist exposes
|
||||
this). Source converters already pick the largest variant the
|
||||
provider returns (Spotify upgrades to 640+, Deezer uses
|
||||
`picture_xl` at ~1000px) so we don't need to re-rank here.
|
||||
|
||||
Returns None when the attribute is missing or empty.
|
||||
"""
|
||||
if artist_obj is None:
|
||||
return None
|
||||
image_url = getattr(artist_obj, "image_url", "")
|
||||
if not image_url or not isinstance(image_url, str):
|
||||
return None
|
||||
image_url = image_url.strip()
|
||||
return image_url or None
|
||||
|
||||
|
||||
def download_image_bytes(url: str, timeout: int = _DEFAULT_IMAGE_DOWNLOAD_TIMEOUT) -> Optional[bytes]:
|
||||
"""Fetch image bytes from a URL.
|
||||
|
||||
Returns None on any failure (HTTP error, timeout, non-image
|
||||
content-type, empty body). Caller surfaces a user-facing error.
|
||||
Doesn't raise.
|
||||
"""
|
||||
if not url or not isinstance(url, str):
|
||||
return None
|
||||
try:
|
||||
resp = requests.get(url, timeout=timeout, stream=True)
|
||||
except Exception as exc:
|
||||
logger.debug("artist image fetch failed for %s: %s", url, exc)
|
||||
return None
|
||||
if resp.status_code != 200:
|
||||
logger.debug("artist image fetch %s returned status %s", url, resp.status_code)
|
||||
return None
|
||||
content_type = (resp.headers.get("Content-Type") or "").lower()
|
||||
if "image" not in content_type:
|
||||
logger.debug("artist image URL %s returned non-image content-type %s", url, content_type)
|
||||
return None
|
||||
try:
|
||||
body = resp.content
|
||||
except Exception as exc:
|
||||
logger.debug("artist image read failed for %s: %s", url, exc)
|
||||
return None
|
||||
if not body:
|
||||
return None
|
||||
return body
|
||||
|
||||
|
||||
def write_artist_jpg(
|
||||
folder: str,
|
||||
image_bytes: bytes,
|
||||
*,
|
||||
overwrite: bool = False,
|
||||
) -> Tuple[bool, str]:
|
||||
"""Write `artist.jpg` to the given folder.
|
||||
|
||||
Returns ``(True, written_path)`` on success or ``(False, reason)``
|
||||
on failure. Atomic write via `<filename>.tmp` + os.replace so a
|
||||
partial write never leaves a corrupt file on disk.
|
||||
|
||||
When `overwrite=False` and the target file already exists,
|
||||
returns ``(False, 'file exists')`` without touching anything —
|
||||
respects user-supplied artist images.
|
||||
"""
|
||||
if not folder or not isinstance(folder, str):
|
||||
return False, "no folder provided"
|
||||
if not image_bytes:
|
||||
return False, "no image bytes"
|
||||
if not os.path.isdir(folder):
|
||||
return False, f"folder does not exist: {folder}"
|
||||
|
||||
target = os.path.join(folder, _ARTIST_IMAGE_FILENAME)
|
||||
if os.path.exists(target) and not overwrite:
|
||||
return False, "artist.jpg already exists; pass overwrite=True to replace"
|
||||
|
||||
tmp = target + ".tmp"
|
||||
try:
|
||||
with open(tmp, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
os.replace(tmp, target)
|
||||
except Exception as exc:
|
||||
# Best-effort cleanup of the partial temp file. Not worth
|
||||
# propagating any error here — primary write already failed.
|
||||
try:
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
except Exception: # noqa: S110 — cleanup, not critical
|
||||
pass
|
||||
return False, f"write failed: {exc}"
|
||||
|
||||
return True, target
|
||||
|
||||
|
||||
__all__ = [
|
||||
"derive_artist_folder",
|
||||
"pick_artist_image_url",
|
||||
"download_image_bytes",
|
||||
"write_artist_jpg",
|
||||
]
|
||||
238
tests/library/test_artist_image.py
Normal file
238
tests/library/test_artist_image.py
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
"""Pin the pure helpers in `core/library/artist_image.py`.
|
||||
|
||||
These back the new artist-image-to-disk feature added for issue
|
||||
#572 (Navidrome can't show real artist photos because Navidrome has
|
||||
no API for setting them — only reads `artist.jpg` from the artist
|
||||
folder on disk).
|
||||
|
||||
Tests are intentionally fixture-driven (tmp_path) so they actually
|
||||
exercise the filesystem code (atomic replace, overwrite guard,
|
||||
missing folder), not just mock interactions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# derive_artist_folder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeriveArtistFolder:
|
||||
def test_one_level_up_from_album(self):
|
||||
from core.library.artist_image import derive_artist_folder
|
||||
# POSIX path
|
||||
assert derive_artist_folder("/music/Drake/Views") == "/music/Drake"
|
||||
|
||||
def test_handles_trailing_slash(self):
|
||||
"""Caller might pass an album folder with a trailing slash.
|
||||
Without trimming, `os.path.dirname` returns the input unchanged
|
||||
— silently breaks the up-one-level contract."""
|
||||
from core.library.artist_image import derive_artist_folder
|
||||
assert derive_artist_folder("/music/Drake/Views/") == "/music/Drake"
|
||||
|
||||
def test_handles_trailing_backslash(self):
|
||||
"""Windows path with trailing separator."""
|
||||
from core.library.artist_image import derive_artist_folder
|
||||
result = derive_artist_folder(r"H:\Music\Drake\Views\\")
|
||||
# On Windows the trim leaves `H:\Music\Drake\Views`,
|
||||
# dirname returns `H:\Music\Drake`. On POSIX dirname might
|
||||
# treat backslashes as filename chars. Just verify the
|
||||
# trailing separator was stripped.
|
||||
assert "Drake" in result
|
||||
|
||||
def test_empty_string_returns_empty(self):
|
||||
from core.library.artist_image import derive_artist_folder
|
||||
assert derive_artist_folder("") == ""
|
||||
|
||||
def test_none_returns_empty(self):
|
||||
from core.library.artist_image import derive_artist_folder
|
||||
assert derive_artist_folder(None) == ""
|
||||
|
||||
def test_non_string_returns_empty(self):
|
||||
"""Defensive — caller might hand us a Path object or similar.
|
||||
Currently we require str; return empty rather than raise."""
|
||||
from core.library.artist_image import derive_artist_folder
|
||||
assert derive_artist_folder(42) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pick_artist_image_url
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPickArtistImageUrl:
|
||||
def test_returns_image_url_when_set(self):
|
||||
from core.library.artist_image import pick_artist_image_url
|
||||
artist = SimpleNamespace(image_url="https://example.com/drake.jpg")
|
||||
assert pick_artist_image_url(artist) == "https://example.com/drake.jpg"
|
||||
|
||||
def test_returns_none_when_empty_string(self):
|
||||
from core.library.artist_image import pick_artist_image_url
|
||||
assert pick_artist_image_url(SimpleNamespace(image_url="")) is None
|
||||
|
||||
def test_returns_none_when_attribute_missing(self):
|
||||
from core.library.artist_image import pick_artist_image_url
|
||||
assert pick_artist_image_url(SimpleNamespace()) is None
|
||||
|
||||
def test_returns_none_when_artist_is_none(self):
|
||||
from core.library.artist_image import pick_artist_image_url
|
||||
assert pick_artist_image_url(None) is None
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
from core.library.artist_image import pick_artist_image_url
|
||||
artist = SimpleNamespace(image_url=" https://example.com/drake.jpg ")
|
||||
assert pick_artist_image_url(artist) == "https://example.com/drake.jpg"
|
||||
|
||||
def test_returns_none_when_non_string(self):
|
||||
from core.library.artist_image import pick_artist_image_url
|
||||
# int / list / dict would all hit the `isinstance(..., str)` guard
|
||||
assert pick_artist_image_url(SimpleNamespace(image_url=42)) is None
|
||||
assert pick_artist_image_url(SimpleNamespace(image_url=["url"])) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# download_image_bytes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fake_response(status_code=200, content_type="image/jpeg", body=b"\x89PNG..."):
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.headers = {"Content-Type": content_type}
|
||||
resp.content = body
|
||||
return resp
|
||||
|
||||
|
||||
class TestDownloadImageBytes:
|
||||
def test_returns_bytes_on_success(self):
|
||||
from core.library import artist_image as ai
|
||||
fake = _fake_response(body=b"image-data-here")
|
||||
with patch.object(ai, "requests") as r:
|
||||
r.get.return_value = fake
|
||||
result = ai.download_image_bytes("https://example.com/x.jpg")
|
||||
assert result == b"image-data-here"
|
||||
|
||||
def test_returns_none_on_404(self):
|
||||
from core.library import artist_image as ai
|
||||
fake = _fake_response(status_code=404)
|
||||
with patch.object(ai, "requests") as r:
|
||||
r.get.return_value = fake
|
||||
assert ai.download_image_bytes("https://example.com/x.jpg") is None
|
||||
|
||||
def test_returns_none_on_non_image_content_type(self):
|
||||
"""Defensive: if a URL returns HTML or JSON (e.g. an error page),
|
||||
don't try to write it as artist.jpg."""
|
||||
from core.library import artist_image as ai
|
||||
fake = _fake_response(content_type="text/html")
|
||||
with patch.object(ai, "requests") as r:
|
||||
r.get.return_value = fake
|
||||
assert ai.download_image_bytes("https://example.com/x.jpg") is None
|
||||
|
||||
def test_returns_none_on_empty_body(self):
|
||||
from core.library import artist_image as ai
|
||||
fake = _fake_response(body=b"")
|
||||
with patch.object(ai, "requests") as r:
|
||||
r.get.return_value = fake
|
||||
assert ai.download_image_bytes("https://example.com/x.jpg") is None
|
||||
|
||||
def test_returns_none_on_exception(self):
|
||||
"""Network timeout / DNS failure / etc shouldn't raise to
|
||||
the caller — caller just sees None and surfaces a generic
|
||||
'image fetch failed' error to the user."""
|
||||
from core.library import artist_image as ai
|
||||
with patch.object(ai, "requests") as r:
|
||||
r.get.side_effect = RuntimeError("network down")
|
||||
assert ai.download_image_bytes("https://example.com/x.jpg") is None
|
||||
|
||||
def test_returns_none_for_empty_url(self):
|
||||
from core.library.artist_image import download_image_bytes
|
||||
assert download_image_bytes("") is None
|
||||
assert download_image_bytes(None) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# write_artist_jpg
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWriteArtistJpg:
|
||||
def test_writes_file_on_success(self, tmp_path):
|
||||
from core.library.artist_image import write_artist_jpg
|
||||
success, path = write_artist_jpg(str(tmp_path), b"image-bytes")
|
||||
assert success is True
|
||||
assert os.path.exists(path)
|
||||
assert open(path, "rb").read() == b"image-bytes"
|
||||
|
||||
def test_returns_failure_when_folder_missing(self, tmp_path):
|
||||
from core.library.artist_image import write_artist_jpg
|
||||
missing = str(tmp_path / "does-not-exist")
|
||||
success, reason = write_artist_jpg(missing, b"image-bytes")
|
||||
assert success is False
|
||||
assert "does not exist" in reason
|
||||
|
||||
def test_returns_failure_for_empty_bytes(self, tmp_path):
|
||||
from core.library.artist_image import write_artist_jpg
|
||||
success, reason = write_artist_jpg(str(tmp_path), b"")
|
||||
assert success is False
|
||||
assert "image bytes" in reason
|
||||
|
||||
def test_returns_failure_for_empty_folder(self):
|
||||
from core.library.artist_image import write_artist_jpg
|
||||
success, reason = write_artist_jpg("", b"image-bytes")
|
||||
assert success is False
|
||||
assert "folder" in reason
|
||||
|
||||
def test_respects_existing_file_without_overwrite(self, tmp_path):
|
||||
"""Default overwrite=False protects user-supplied artist.jpg
|
||||
from being clobbered by a programmatic update. User must opt
|
||||
in to overwrite."""
|
||||
from core.library.artist_image import write_artist_jpg
|
||||
target = tmp_path / "artist.jpg"
|
||||
target.write_bytes(b"user-supplied")
|
||||
|
||||
success, reason = write_artist_jpg(str(tmp_path), b"new-bytes")
|
||||
assert success is False
|
||||
assert "already exists" in reason
|
||||
# Existing file must be untouched.
|
||||
assert target.read_bytes() == b"user-supplied"
|
||||
|
||||
def test_overwrites_when_flag_set(self, tmp_path):
|
||||
from core.library.artist_image import write_artist_jpg
|
||||
target = tmp_path / "artist.jpg"
|
||||
target.write_bytes(b"old-bytes")
|
||||
|
||||
success, path = write_artist_jpg(str(tmp_path), b"new-bytes", overwrite=True)
|
||||
assert success is True
|
||||
assert target.read_bytes() == b"new-bytes"
|
||||
|
||||
def test_atomic_write_no_temp_left_on_success(self, tmp_path):
|
||||
"""`.tmp` artifact must be cleaned up by `os.replace`. Don't
|
||||
leave litter behind for the next backup / sync run to puzzle
|
||||
over."""
|
||||
from core.library.artist_image import write_artist_jpg
|
||||
success, _ = write_artist_jpg(str(tmp_path), b"image-bytes")
|
||||
assert success is True
|
||||
assert not (tmp_path / "artist.jpg.tmp").exists()
|
||||
|
||||
def test_atomic_write_cleans_temp_on_failure(self, tmp_path, monkeypatch):
|
||||
"""If `os.replace` fails (permission, cross-device, etc),
|
||||
the helper should remove the temp file rather than leaving
|
||||
a half-written `.tmp` on disk."""
|
||||
from core.library import artist_image as ai
|
||||
|
||||
def _failing_replace(src, dst):
|
||||
raise OSError("simulated replace failure")
|
||||
|
||||
monkeypatch.setattr(os, "replace", _failing_replace)
|
||||
success, reason = ai.write_artist_jpg(str(tmp_path), b"image-bytes")
|
||||
assert success is False
|
||||
assert "write failed" in reason
|
||||
# Temp must not be left behind
|
||||
assert not (tmp_path / "artist.jpg.tmp").exists()
|
||||
137
web_server.py
137
web_server.py
|
|
@ -8870,6 +8870,143 @@ def get_similar_artists(artist_name):
|
|||
"error": str(e)
|
||||
}), 500
|
||||
|
||||
@app.route('/api/artist/<artist_id>/write-image-to-disk', methods=['POST'])
|
||||
def write_artist_image_to_disk(artist_id):
|
||||
"""Write `artist.jpg` to the artist's folder on disk.
|
||||
|
||||
Issue #572 (rhwc): Navidrome has no API for setting an artist
|
||||
image — it reads `artist.jpg` from the artist's folder during
|
||||
library scans. SoulSync's `update_artist_poster` for Navidrome
|
||||
is a NO-OP today. This endpoint closes the gap by:
|
||||
|
||||
1. Resolving the artist's folder on disk via any of their albums'
|
||||
tracks (`_resolve_library_file_path` handles Docker mount
|
||||
translation + the same library-path probes #558 settled on)
|
||||
2. Fetching an artist photo URL from the configured metadata source
|
||||
priority chain (Spotify → Deezer → ... already wired through
|
||||
`core.metadata_service.get_artist_image_url`)
|
||||
3. Downloading the image bytes and writing `<artist>/artist.jpg`
|
||||
atomically via the pure helpers in `core/library/artist_image.py`
|
||||
4. Triggering a Navidrome library scan so the file gets picked
|
||||
up immediately
|
||||
|
||||
Request body (JSON, all optional):
|
||||
- ``image_url`` — explicit URL to use, bypassing metadata
|
||||
source resolution (useful for "use this exact photo" UX)
|
||||
- ``overwrite`` — when True, replace existing `artist.jpg`
|
||||
(default False respects user-supplied files)
|
||||
- ``source_override`` — pin the metadata source for URL
|
||||
resolution (e.g. ``"deezer"``)
|
||||
"""
|
||||
try:
|
||||
from core.library.artist_image import (
|
||||
derive_artist_folder,
|
||||
download_image_bytes,
|
||||
write_artist_jpg,
|
||||
)
|
||||
from core.metadata_service import get_artist_image_url as _get_artist_image_url
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
explicit_url = (data.get('image_url') or '').strip() or None
|
||||
overwrite = bool(data.get('overwrite', False))
|
||||
source_override = (data.get('source_override') or '').strip().lower() or None
|
||||
|
||||
db = get_database()
|
||||
try:
|
||||
artist_id_int = int(artist_id)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"success": False, "error": "Invalid artist id"}), 400
|
||||
|
||||
artist_row = db.get_artist(artist_id_int)
|
||||
if artist_row is None:
|
||||
return jsonify({"success": False, "error": "Artist not found"}), 404
|
||||
|
||||
# Find a track file on disk so we can derive the artist folder.
|
||||
# Walk albums in DB order; first one with a resolvable track wins.
|
||||
albums = db.get_albums_by_artist(artist_id_int)
|
||||
if not albums:
|
||||
return jsonify({"success": False,
|
||||
"error": "No albums for this artist; cannot derive folder."}), 400
|
||||
|
||||
resolved_track_path = None
|
||||
for album in albums:
|
||||
tracks = db.get_tracks_by_album(album.id)
|
||||
for tr in tracks:
|
||||
if not getattr(tr, 'file_path', None):
|
||||
continue
|
||||
candidate = _resolve_library_file_path(tr.file_path) or tr.file_path
|
||||
if candidate and os.path.exists(candidate):
|
||||
resolved_track_path = candidate
|
||||
break
|
||||
if resolved_track_path:
|
||||
break
|
||||
|
||||
if not resolved_track_path:
|
||||
return jsonify({"success": False,
|
||||
"error": "Could not locate any track file on disk to derive the artist folder. "
|
||||
"Configure Settings → Library → Music Paths to point at the library mount."}), 400
|
||||
|
||||
album_folder = os.path.dirname(resolved_track_path)
|
||||
artist_folder = derive_artist_folder(album_folder)
|
||||
if not artist_folder or not os.path.isdir(artist_folder):
|
||||
return jsonify({"success": False,
|
||||
"error": f"Resolved artist folder is invalid: {artist_folder!r}"}), 400
|
||||
|
||||
# Pick the image URL. Explicit override (from request body)
|
||||
# wins so users can paste a specific photo URL. Otherwise
|
||||
# resolve from the active metadata source.
|
||||
if explicit_url:
|
||||
image_url = explicit_url
|
||||
else:
|
||||
try:
|
||||
image_url = _get_artist_image_url(
|
||||
artist_id_int,
|
||||
source_override=source_override,
|
||||
artist_name=getattr(artist_row, 'name', None),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"artist image lookup failed: {exc}")
|
||||
image_url = None
|
||||
|
||||
if not image_url:
|
||||
return jsonify({"success": False,
|
||||
"error": "No artist image URL found from metadata sources."}), 404
|
||||
|
||||
image_bytes = download_image_bytes(image_url)
|
||||
if not image_bytes:
|
||||
return jsonify({"success": False,
|
||||
"error": f"Failed to download image from {image_url}"}), 502
|
||||
|
||||
success, detail = write_artist_jpg(artist_folder, image_bytes, overwrite=overwrite)
|
||||
if not success:
|
||||
return jsonify({"success": False, "error": detail}), 400
|
||||
|
||||
# If the active media server is Navidrome, trigger a scan so
|
||||
# the new file gets indexed without waiting for the next
|
||||
# automatic scan cycle.
|
||||
scan_triggered = False
|
||||
try:
|
||||
active_server = config_manager.get_active_media_server()
|
||||
if active_server == 'navidrome':
|
||||
nav = media_server_engine.client('navidrome')
|
||||
if nav is not None:
|
||||
nav.trigger_library_scan()
|
||||
scan_triggered = True
|
||||
except Exception as exc:
|
||||
logger.debug(f"Navidrome scan trigger after artist image write failed: {exc}")
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"written_to": detail,
|
||||
"image_url": image_url,
|
||||
"scan_triggered": scan_triggered,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing artist image to disk: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/artist/<artist_id>/image', methods=['GET'])
|
||||
def get_artist_image(artist_id):
|
||||
"""Get an artist image URL using source-aware metadata resolution."""
|
||||
|
|
|
|||
|
|
@ -2391,6 +2391,12 @@
|
|||
<span class="enhance-icon">⚡</span>
|
||||
<span class="enhance-text">Enhance Quality</span>
|
||||
</button>
|
||||
<button class="library-artist-write-image-btn" id="library-artist-write-image-btn"
|
||||
onclick="writeArtistImageToDisk()"
|
||||
title="Write artist.jpg to the artist folder on disk (Navidrome reads this)">
|
||||
<span class="write-image-icon">🖼️</span>
|
||||
<span class="write-image-text">Write Artist Image</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="artist-genres-container" id="artist-genres"></div>
|
||||
<div class="artist-hero-bio" id="artist-hero-bio" style="display:none;"></div>
|
||||
|
|
|
|||
|
|
@ -3416,6 +3416,7 @@ const WHATS_NEW = {
|
|||
'2.5.2': [
|
||||
// --- May 13, 2026 — 2.5.2 release ---
|
||||
{ date: 'May 13, 2026 — 2.5.2 release' },
|
||||
{ title: 'Artist Page: "Write Artist Image" Button (Real Artist Photos For Navidrome)', desc: 'github issue #572 (rhwc): navidrome shows album-art-derived thumbnails as artist photos because navidrome has no api for setting an artist image — it only reads `artist.jpg` from the artist folder during library scans. soulsync\'s `update_artist_poster` for navidrome was a no-op. new button on the artist detail page header writes `artist.jpg` to the artist\'s folder on disk: looks up any album track, resolves it through the path resolver (handles docker mount translation like #558 settled on), goes up one level to the artist folder, fetches the artist photo from the configured metadata source priority chain (spotify primary, fallback to deezer / discogs / etc), downloads with content-type validation + atomic write via `<filename>.tmp + os.replace`. when active server is navidrome, triggers a library scan immediately so the new file gets indexed. respects existing `artist.jpg` files (asks before overwriting) so user-supplied photos aren\'t clobbered. works for plex / jellyfin too as a fallback layer — both servers also read `artist.jpg` from disk. 26 tests pin the pure helpers in `core/library/artist_image.py`: folder derivation (trailing slash / backslash / empty / non-string), image url picking (missing attr / whitespace strip / non-string), download (non-image content-type / 404 / timeout / empty body), and write (atomic replace / temp-cleanup-on-failure / overwrite guard / missing folder).', page: 'library' },
|
||||
{ title: 'Library History: Per-Download Audit Trail Modal', desc: 'each download row in library history now has an "audit" button that opens a second modal visualizing the download lifecycle as a vertical chain of decision blocks: request → source selected → source match → verification → post processing → final placement. each step has a status (complete / partial / unknown / error) with a color-coded node, plus a card showing what was decided and the supporting metadata. post-processing step infers observable changes from source-vs-final state (format conversion, file rename via tag template, title/artist rewrite, folder template). new "embedded tags" section below the flow reads the audio file live via mutagen at audit-open time and surfaces every tag actually on the file — title / artist / album / album artist / date / genre / track # / disc # / bpm / mood / style / copyright / publisher / release type+status+country / barcode / catalog # / asin / isrc / replaygain values / cover-art status / lyrics / every source id (spotify, tidal, deezer, musicbrainz, audiodb, lastfm, genius, itunes, beatport ...). file is the single source of truth — a persisted snapshot would drift the moment a background enrichment worker writes more tags. clean fallback when file is missing or unreadable. 19 tests pin the pure mutagen reader: id3 path (TIT2/TPE1/TALB + TXXX user-defined frames + USLT + APIC cover-art), vorbis path (FLAC dict-style + pass-through for unknown _id / _url keys), mp4 stub, format+bitrate+duration metadata, defensive paths (empty path, missing file, mutagen returns None, mutagen raises), stringify edge cases (list / tuple / int / frame-with-text / whitespace). files: core/library/file_tags.py (new mutagen reader), web_server.py (new GET /api/library/history/<id>/file-tags endpoint), webui/index.html (audit-overlay modal), webui/static/wishlist-tools.js (renderer + async fetch + tag-grid render), webui/static/style.css (flow + tags section + lyrics block styles).', page: 'wishlist' },
|
||||
{ title: '$albumtype Folder Template Now Splits EPs / Singles For Non-Spotify Sources', desc: 'discord report (cal): downloading an artist\'s discography with `$albumtype` in the path template put every release under `Album/` regardless of actual type — eps, singles, all dumped into the album folder. trace: the legacy duck-typed album-info builder at `core/metadata/album_tracks.py:_build_album_info_legacy` only checked the `album_type` key. spotify uses `album_type` (lowercase) so spotify discographies worked. but deezer\'s api uses `record_type`, tidal uses `type` (uppercase ALBUM/EP/SINGLE), and some flattened musicbrainz shapes use `primary-type` — none of those matched, all defaulted to `album`. fix: widen the legacy lookup to check `album_type` / `record_type` / `type` / `primary-type` and route the value through a new pure `_normalize_album_type` helper that lowercases + validates against the canonical token set (`album` / `single` / `ep` / `compilation`) and falls back to `album` for unknowns. typed-converter path for spotify / deezer / itunes / discogs / musicbrainz / hydrabase / qobuz unchanged — they were already correct. tidal users were the main offender (no typed converter for dict-shaped tidal data). 25 new tests pin: case-insensitive normalization for each canonical type, compilation preserved (spotify supports it), unknown values default to album, defensive against none / empty / non-string inputs, multi-key precedence (`album_type` wins over `record_type`), each known source shape produces correct token, generic `type=track` / `type=artist` collision case defaults to album rather than poisoning the path.', page: 'tools' },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -7534,6 +7534,66 @@ async function playArtistRadio() {
|
|||
}
|
||||
}
|
||||
|
||||
async function writeArtistImageToDisk(overwrite) {
|
||||
// Issue #572: writes `artist.jpg` to the artist's folder so
|
||||
// Navidrome (which has no API for artist images) picks up a
|
||||
// real photo on its next scan. Plex/Jellyfin also read this
|
||||
// file as a fallback, so it's safe to run regardless of which
|
||||
// server is active.
|
||||
const artistId = artistDetailPageState.currentArtistId;
|
||||
if (!artistId) {
|
||||
showToast('No artist selected', 'error');
|
||||
return;
|
||||
}
|
||||
const btn = document.getElementById('library-artist-write-image-btn');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
const txt = btn.querySelector('.write-image-text');
|
||||
if (txt) txt.textContent = 'Writing…';
|
||||
}
|
||||
try {
|
||||
let resp = await fetch(`/api/artist/${artistId}/write-image-to-disk`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ overwrite: !!overwrite }),
|
||||
});
|
||||
let data = await resp.json();
|
||||
|
||||
// Default protect-existing path: surface a confirm so the user
|
||||
// explicitly opts in to clobbering a hand-picked artist.jpg.
|
||||
if (!data.success && /already exists/i.test(data.error || '')) {
|
||||
if (confirm('artist.jpg already exists in the folder. Overwrite with the new image?')) {
|
||||
resp = await fetch(`/api/artist/${artistId}/write-image-to-disk`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ overwrite: true }),
|
||||
});
|
||||
data = await resp.json();
|
||||
} else {
|
||||
showToast('Cancelled — existing artist.jpg kept', 'info');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!data.success) {
|
||||
showToast(`Failed: ${data.error || 'Unknown error'}`, 'error');
|
||||
return;
|
||||
}
|
||||
const scan = data.scan_triggered ? ' (Navidrome scan triggered)' : '';
|
||||
showToast(`artist.jpg written${scan}`, 'success');
|
||||
} catch (e) {
|
||||
showToast(`Write failed: ${e.message}`, 'error');
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
const txt = btn.querySelector('.write-image-text');
|
||||
if (txt) txt.textContent = 'Write Artist Image';
|
||||
}
|
||||
}
|
||||
}
|
||||
window.writeArtistImageToDisk = writeArtistImageToDisk;
|
||||
|
||||
|
||||
function openEnhanceQualityModal() {
|
||||
if (!_enhanceQualityData) return;
|
||||
const data = _enhanceQualityData;
|
||||
|
|
|
|||
|
|
@ -21321,6 +21321,39 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
display: none;
|
||||
}
|
||||
|
||||
/* ─── Write Artist Image Button (issue #572) ─── */
|
||||
.library-artist-write-image-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
outline: none;
|
||||
font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.library-artist-write-image-btn:hover:not(:disabled) {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.22);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.library-artist-write-image-btn:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.library-artist-write-image-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
/* ─── Artist Radio Button ─── */
|
||||
|
||||
.library-artist-radio-btn {
|
||||
|
|
|
|||
Loading…
Reference in a new issue