Merge pull request #574 from Nezreka/feature/write-artist-jpg-to-folder-for-navidrome
Write artist.jpg to artist folder so Navidrome shows real photos
This commit is contained in:
commit
63f313d0c2
7 changed files with 626 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",
|
||||||
|
]
|
||||||
228
tests/library/test_artist_image.py
Normal file
228
tests/library/test_artist_image.py
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
"""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_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)
|
"error": str(e)
|
||||||
}), 500
|
}), 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'])
|
@app.route('/api/artist/<artist_id>/image', methods=['GET'])
|
||||||
def get_artist_image(artist_id):
|
def get_artist_image(artist_id):
|
||||||
"""Get an artist image URL using source-aware metadata resolution."""
|
"""Get an artist image URL using source-aware metadata resolution."""
|
||||||
|
|
|
||||||
|
|
@ -2391,6 +2391,12 @@
|
||||||
<span class="enhance-icon">⚡</span>
|
<span class="enhance-icon">⚡</span>
|
||||||
<span class="enhance-text">Enhance Quality</span>
|
<span class="enhance-text">Enhance Quality</span>
|
||||||
</button>
|
</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>
|
||||||
<div class="artist-genres-container" id="artist-genres"></div>
|
<div class="artist-genres-container" id="artist-genres"></div>
|
||||||
<div class="artist-hero-bio" id="artist-hero-bio" style="display:none;"></div>
|
<div class="artist-hero-bio" id="artist-hero-bio" style="display:none;"></div>
|
||||||
|
|
|
||||||
|
|
@ -3416,6 +3416,7 @@ const WHATS_NEW = {
|
||||||
'2.5.2': [
|
'2.5.2': [
|
||||||
// --- May 13, 2026 — 2.5.2 release ---
|
// --- May 13, 2026 — 2.5.2 release ---
|
||||||
{ date: '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: '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' },
|
{ 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() {
|
function openEnhanceQualityModal() {
|
||||||
if (!_enhanceQualityData) return;
|
if (!_enhanceQualityData) return;
|
||||||
const data = _enhanceQualityData;
|
const data = _enhanceQualityData;
|
||||||
|
|
|
||||||
|
|
@ -21321,6 +21321,39 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
||||||
display: none;
|
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 ─── */
|
/* ─── Artist Radio Button ─── */
|
||||||
|
|
||||||
.library-artist-radio-btn {
|
.library-artist-radio-btn {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue