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
pull/575/head
BoulderBadgeDad 4 weeks ago committed by GitHub
commit 63f313d0c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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",
]

@ -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()

@ -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…
Cancel
Save