diff --git a/core/library/artist_image.py b/core/library/artist_image.py new file mode 100644 index 00000000..85787264 --- /dev/null +++ b/core/library/artist_image.py @@ -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 + ``///...`` — 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 `.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", +] diff --git a/tests/library/test_artist_image.py b/tests/library/test_artist_image.py new file mode 100644 index 00000000..4bf3d5cf --- /dev/null +++ b/tests/library/test_artist_image.py @@ -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() diff --git a/web_server.py b/web_server.py index 0aa8b7d9..8283c224 100644 --- a/web_server.py +++ b/web_server.py @@ -8870,6 +8870,143 @@ def get_similar_artists(artist_name): "error": str(e) }), 500 +@app.route('/api/artist//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.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//image', methods=['GET']) def get_artist_image(artist_id): """Get an artist image URL using source-aware metadata resolution.""" diff --git a/webui/index.html b/webui/index.html index 359d38c7..01e98b77 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2391,6 +2391,12 @@ Enhance Quality +
diff --git a/webui/static/helper.js b/webui/static/helper.js index 8ebefb04..7384c970 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -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 `.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//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' }, ], diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 48abb356..60b395a5 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -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; diff --git a/webui/static/style.css b/webui/static/style.css index 6d08a283..88e5b2ec 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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 {