From c11a5b7eab668ad3d0ffc6806572fb88f4dc9f67 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 11 May 2026 15:16:42 -0700 Subject: [PATCH] Multi-artist tag settings: implement artist_separator + feat_in_title + populate _artists_list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three settings on Settings → Metadata → Tags were partially or completely unimplemented. Reporter (Netti93) traced each one. (1) `write_multi_artist` only "worked" because of a never-populated `_artists_list` field. `core/metadata/source.py` built `metadata["artist"]` as a hardcoded ", "-joined string but never assigned `metadata["_artists_list"]`. `core/metadata/enrichment.py` line 107 reads that field and gates the multi-value tag write on `len(_artists_list) > 1` — always saw an empty list, silently no-op'd the write. (2) `artist_separator` (default ", ") was referenced in the UI + settings.js save path but ZERO Python code read the value. Every multi-artist track ended up with hardcoded ", " regardless of what the user picked. (3) `feat_in_title` (when true: pull featured artists into the title as " (feat. X, Y)" and leave only primary in the ARTIST tag — Picard convention) had no implementation at all. Fix in source.py: * Populate `_artists_list` from the search response's artists array * Read `feat_in_title` and `artist_separator` configs * When `feat_in_title=True` and >1 artist: ARTIST = primary only, append "(feat. X, Y)" to title with double-append guard * Else: ARTIST = artists joined with `artist_separator` * Single-artist case unaffected by either setting Double-append guard uses a word-boundary regex catching all common "feat" variants source platforms produce — `feat`, `feat.`, `featuring`, `ft`, `ft.` — case-insensitive. Substring matches (e.g. "Aftermath" containing "ft") correctly fall through to the append path. Fix in enrichment.py ID3 branch: * TPE1 stays as the display string (with separator or primary-only per the user's settings) * Multi-value list goes to a separate `TXXX:Artists` frame (Picard convention) when `write_multi_artist` is on * Pre-fix the ID3 path wrote TPE1 twice — single-string then list — and the second `add` overwrote the first, clobbering both the configured separator AND the feat_in_title semantics. Vorbis path was already correct (separate "artist" + "artists" keys). Known limitation (flagged in WHATS_NEW): Deezer's `/search` endpoint only returns the primary artist. The full contributors array lives on `/track/`. Enrichment uses search-result data so Deezer- sourced tracks may still get only the primary artist until a follow- up commit wires the per-track contributors fetch into the enrichment flow. Spotify, Tidal, and iTunes search responses include all artists so they work now. 23 new tests in `tests/metadata/test_multi_artist_tag_settings.py`: * `_artists_list` populated for multi/single/no-artist cases * `artist_separator` drives ARTIST string (default ", " + custom ";" + custom "; " + " & ") * Single-artist case unaffected by either setting * `feat_in_title=True` pulls featured to title, leaves primary in ARTIST * `feat_in_title` no-op for single artist * Double-append guard recognizes 9 source-title variants ("(feat. X)", "(Feat. X)", "(FEAT X)", "(feat X)", "(Featuring X)", "[feat. X]", "ft. X", "(ft X)", "FT. X") * Substring guard test pins "Aftermath" doesn't false-positive * Combined-settings precedence: feat_in_title wins ARTIST string but `_artists_list` carries everyone for multi-value tag Full pytest 2711 passed. --- core/metadata/enrichment.py | 14 +- core/metadata/source.py | 51 ++- .../test_multi_artist_tag_settings.py | 292 ++++++++++++++++++ webui/static/helper.js | 1 + 4 files changed, 355 insertions(+), 3 deletions(-) create mode 100644 tests/metadata/test_multi_artist_tag_settings.py diff --git a/core/metadata/enrichment.py b/core/metadata/enrichment.py index 6317cedf..051426f8 100644 --- a/core/metadata/enrichment.py +++ b/core/metadata/enrichment.py @@ -110,9 +110,21 @@ def enhance_file_metadata(file_path: str, context: dict, artist: dict, album_inf if metadata.get("title"): audio_file.tags.add(symbols.TIT2(encoding=3, text=[metadata["title"]])) if metadata.get("artist"): + # TPE1 = display artist string (already joined by + # source.py with the configured separator, or + # primary-only when feat_in_title is on). audio_file.tags.add(symbols.TPE1(encoding=3, text=[metadata["artist"]])) + # When write_multi_artist is on, ALSO write the + # multi-value list to a TXXX:Artists frame (Picard + # convention). Keeps TPE1 as the display string AND + # exposes the per-artist list for media servers + # that read ARTISTS. Pre-fix this path overwrote + # TPE1 with the list, which clobbered the + # configured separator + feat_in_title semantics. if write_multi and len(artists_list) > 1: - audio_file.tags.add(symbols.TPE1(encoding=3, text=artists_list)) + audio_file.tags.add( + symbols.TXXX(encoding=3, desc='Artists', text=list(artists_list)) + ) if metadata.get("album_artist"): audio_file.tags.add(symbols.TPE2(encoding=3, text=[metadata["album_artist"]])) if metadata.get("album"): diff --git a/core/metadata/source.py b/core/metadata/source.py index bbe8dab9..9573dc03 100644 --- a/core/metadata/source.py +++ b/core/metadata/source.py @@ -917,8 +917,55 @@ def extract_source_metadata(context: dict, artist: dict, album_info: dict) -> di all_artists.append(artist_item) else: all_artists.append(str(artist_item)) - metadata["artist"] = ", ".join(all_artists) - logger.info("Metadata: Using all artists: '%s'", metadata["artist"]) + # Store the multi-artist list so the enrichment writer can emit + # proper multi-value ARTIST tags (TPE1 multi-value for ID3, + # "artists" key for Vorbis) when `write_multi_artist` is on. + # Without this assignment the field was always empty and the + # multi-artist write path silently no-op'd. + metadata["_artists_list"] = list(all_artists) + + # `feat_in_title` (when true): pull featured artists out of the + # ARTIST tag entirely and append "(feat. X, Y)" to the title. + # Matches Picard / Beets convention and lets media servers + # group by primary artist instead of treating "A, B & C" as a + # distinct artist string. + # `artist_separator`: when feat_in_title is off (or there's + # only one artist) and write_multi_artist is on, this is the + # delimiter used to join all artists into the single ARTIST + # string. Picard defaults to "; " — we default to ", " to + # preserve historical behavior for users who haven't touched + # the setting. + feat_in_title = cfg.get("metadata_enhancement.tags.feat_in_title", False) + artist_separator = cfg.get("metadata_enhancement.tags.artist_separator", ", ") + + if feat_in_title and len(all_artists) > 1: + metadata["artist"] = all_artists[0] + featured = all_artists[1:] + existing_title = metadata.get("title", "") or "" + # Don't double-append if the title already carries the + # featured artists. Source titles vary: "(feat. X)", + # "(featuring X)", "(ft. X)", "ft. X" (no parens), "[feat X]" + # (no period, brackets), etc. Word-boundary regex catches + # `feat`, `feat.`, `featuring`, `ft`, `ft.` regardless of + # surrounding punctuation. Case-insensitive. + import re as _feat_re + already_has_feat = bool(_feat_re.search( + r'\b(?:feat|feat\.|featuring|ft|ft\.)\b', + existing_title, + _feat_re.IGNORECASE, + )) + if existing_title and not already_has_feat: + metadata["title"] = f"{existing_title} (feat. {', '.join(featured)})" + logger.info( + "Metadata: feat_in_title — primary='%s', featured=%s, title='%s'", + metadata["artist"], featured, metadata["title"], + ) + else: + metadata["artist"] = artist_separator.join(all_artists) + logger.info( + "Metadata: Using all artists joined with %r: '%s'", + artist_separator, metadata["artist"], + ) else: metadata["artist"] = artist_dict.get("name", "") or get_import_clean_artist(context) logger.info("Metadata: Using primary artist: '%s'", metadata["artist"]) diff --git a/tests/metadata/test_multi_artist_tag_settings.py b/tests/metadata/test_multi_artist_tag_settings.py new file mode 100644 index 00000000..60e94f0a --- /dev/null +++ b/tests/metadata/test_multi_artist_tag_settings.py @@ -0,0 +1,292 @@ +"""Pin multi-artist tag-write settings (issue: 'Multi artists settings not working'). + +Three settings under `metadata_enhancement.tags`: + - `write_multi_artist` (bool) — write a separate multi-value tag + listing every artist (TXXX:Artists for ID3, "artists" key for + Vorbis). Picard convention. + - `artist_separator` (string, default ", ") — delimiter used to + join multiple artists into the single ARTIST/TPE1 string. + - `feat_in_title` (bool) — when true, ARTIST/TPE1 carries ONLY + the primary artist; featured artists get pulled out and + appended to the title as " (feat. X, Y)". + +Reporter (Netti93): all three were partially or completely +unimplemented. + - Bug 1: `_artists_list` field read by enrichment.py was never + populated by source.py → multi-value writes silently no-op'd. + - Bug 2: `artist_separator` referenced in UI but ZERO Python code + read it → always hardcoded ", ". + - Bug 3: `feat_in_title` referenced in UI but ZERO Python code + read it → no implementation at all. + +These tests pin the fixed `extract_source_metadata` behavior: + - `_artists_list` populated whenever search response has multiple artists + - `artist_separator` config drives the join character for ARTIST string + - `feat_in_title` pulls featured artists into title, leaves only + primary in ARTIST string + - Title-already-has-feat case isn't double-appended + - Single-artist case unaffected by either setting +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + + +def _make_cfg(overrides=None): + """Stub config_manager. Defaults match the unset-config case + so each test can selectively override.""" + overrides = overrides or {} + defaults = { + "metadata_enhancement.enabled": True, + "metadata_enhancement.tags.write_multi_artist": False, + "metadata_enhancement.tags.feat_in_title": False, + "metadata_enhancement.tags.artist_separator": ", ", + } + full = {**defaults, **overrides} + + cfg = MagicMock() + cfg.get.side_effect = lambda key, default=None: full.get(key, default) + return cfg + + +def _build_context(artists_list): + """Minimal context dict matching what extract_source_metadata reads.""" + return { + "original_search_result": { + "title": "Sample Track", + "artists": [{"name": a} for a in artists_list], + }, + "source": "spotify", + } + + +def _call_extract(artists_list, cfg_overrides=None): + """Helper: patches config + calls extract_source_metadata, returns the + metadata dict. Avoids the broader source-specific embedding loop by + only using fields the multi-artist branch touches.""" + from core.metadata import source as src_module + + context = _build_context(artists_list) + artist_dict = {"name": artists_list[0] if artists_list else ""} + album_info = {"album_name": "Sample Album"} + + with patch.object(src_module, "get_config_manager", return_value=_make_cfg(cfg_overrides)): + return src_module.extract_source_metadata(context, artist_dict, album_info) + + +# --------------------------------------------------------------------------- +# Bug 1: `_artists_list` populated +# --------------------------------------------------------------------------- + + +class TestArtistsListPopulated: + def test_multiple_artists_populate_list(self): + """Reporter's first bug — `_artists_list` field was always + empty. Verify it now contains every artist from the search + response.""" + meta = _call_extract(["Eminem", "Dr. Dre", "50 Cent"]) + assert meta.get("_artists_list") == ["Eminem", "Dr. Dre", "50 Cent"] + + def test_single_artist_still_populates_list(self): + """Edge: even single-artist case populates the list (length 1). + Avoids special-casing downstream — `len(_artists_list) > 1` + check in enrichment.py is the gate.""" + meta = _call_extract(["Solo Artist"]) + assert meta.get("_artists_list") == ["Solo Artist"] + + def test_no_artists_falls_through(self): + """When search response has no artists list, falls through to + the single-artist branch — no `_artists_list` written.""" + from core.metadata import source as src_module + + context = { + "original_search_result": {"title": "T", "artists": None}, + "source": "spotify", + } + with patch.object(src_module, "get_config_manager", return_value=_make_cfg()): + meta = src_module.extract_source_metadata(context, {"name": "X"}, {}) + assert "_artists_list" not in meta or meta.get("_artists_list") in (None, []) + + +# --------------------------------------------------------------------------- +# Bug 2: artist_separator drives ARTIST string +# --------------------------------------------------------------------------- + + +class TestArtistSeparator: + def test_default_separator_is_comma_space(self): + """Default preserves historical behavior — joining with ', ' + so users who haven't set the config see no behavior change.""" + meta = _call_extract(["A", "B", "C"]) + assert meta["artist"] == "A, B, C" + + def test_semicolon_separator(self): + """Reporter's exact case: artist_separator=';'. Picard convention.""" + meta = _call_extract(["A", "B", "C"], cfg_overrides={ + "metadata_enhancement.tags.artist_separator": ";", + }) + assert meta["artist"] == "A;B;C" + + def test_separator_with_space(self): + """Many users prefer '; ' (semi + space). Whatever string + the user puts in the config gets used verbatim — no trimming.""" + meta = _call_extract(["A", "B"], cfg_overrides={ + "metadata_enhancement.tags.artist_separator": "; ", + }) + assert meta["artist"] == "A; B" + + def test_separator_unused_for_single_artist(self): + """Single-artist case: separator irrelevant, ARTIST is just + the one name. No spurious trailing/leading separator.""" + meta = _call_extract(["Solo"], cfg_overrides={ + "metadata_enhancement.tags.artist_separator": ";", + }) + assert meta["artist"] == "Solo" + + +# --------------------------------------------------------------------------- +# Bug 3: feat_in_title — pull featured into title +# --------------------------------------------------------------------------- + + +class TestFeatInTitle: + def test_feat_in_title_pulls_featured_to_title(self): + """Reporter's third bug. With feat_in_title=true, ARTIST holds + only primary; title gets " (feat. ...)" appended for + all-but-first.""" + meta = _call_extract(["Eminem", "Dr. Dre", "50 Cent"], cfg_overrides={ + "metadata_enhancement.tags.feat_in_title": True, + }) + assert meta["artist"] == "Eminem" + assert "(feat. Dr. Dre, 50 Cent)" in meta["title"] + + def test_feat_in_title_off_uses_separator(self): + """When feat_in_title is off (default), all artists join the + ARTIST string per `artist_separator`. Title stays unchanged.""" + meta = _call_extract(["A", "B", "C"], cfg_overrides={ + "metadata_enhancement.tags.feat_in_title": False, + "metadata_enhancement.tags.artist_separator": " & ", + }) + assert meta["artist"] == "A & B & C" + assert "feat" not in meta["title"].lower() + + def test_feat_in_title_skips_when_only_one_artist(self): + """Single-artist case: feat_in_title is a no-op. ARTIST = the + single name, title untouched.""" + meta = _call_extract(["Solo"], cfg_overrides={ + "metadata_enhancement.tags.feat_in_title": True, + }) + assert meta["artist"] == "Solo" + assert "feat" not in meta["title"].lower() + + def test_feat_in_title_no_double_append_when_title_already_has_feat(self): + """Defensive: if the source title already includes 'feat.' or + '(ft.', don't append again. Common on remixes / collabs where + the platform stores the featured artist in the track name.""" + from core.metadata import source as src_module + + context = { + "original_search_result": { + "title": "Track (feat. Already Listed)", + "artists": [{"name": "Primary"}, {"name": "Featured"}], + }, + "source": "spotify", + } + cfg_overrides = {"metadata_enhancement.tags.feat_in_title": True} + with patch.object(src_module, "get_config_manager", return_value=_make_cfg(cfg_overrides)): + meta = src_module.extract_source_metadata(context, {"name": "Primary"}, {}) + + # Primary still pulled out of ARTIST... + assert meta["artist"] == "Primary" + # ...but title NOT double-appended (would be "(feat. X) (feat. Y)") + assert meta["title"].count("feat.") == 1 + + @pytest.mark.parametrize("source_title", [ + "Track (feat. X)", # standard parens + period + "Track (Feat. X)", # capitalized + "Track (FEAT X)", # all caps, no period + "Track (feat X)", # no period, parens + "Track (Featuring X)", # full word + "Track [feat. X]", # square brackets + "Track ft. X", # ft + period, no parens/brackets + "Track (ft X)", # ft no period, parens + "Track FT. X", # FT all caps + ]) + def test_double_append_guard_recognizes_feat_variants(self, source_title): + """Defensive: source platforms (spotify / tidal / deezer) use + wildly different title conventions for featured artists. Guard + must recognize all of them so we never double-append.""" + from core.metadata import source as src_module + + context = { + "original_search_result": { + "title": source_title, + "artists": [{"name": "Primary"}, {"name": "Featured"}], + }, + "source": "spotify", + } + cfg_overrides = {"metadata_enhancement.tags.feat_in_title": True} + with patch.object(src_module, "get_config_manager", return_value=_make_cfg(cfg_overrides)): + meta = src_module.extract_source_metadata(context, {"name": "Primary"}, {}) + + # Title left unchanged — no double-append for any variant + assert meta["title"] == source_title, ( + f"Variant {source_title!r} got double-appended → {meta['title']!r}" + ) + + def test_double_append_guard_does_NOT_falsely_match_substrings(self): + """Sanity: word-boundary regex must NOT match 'ft' or 'feat' + as part of bigger words like 'aftermath', 'shaft', 'feature'. + Otherwise titles containing those words would skip the + legitimate (feat. X) append.""" + from core.metadata import source as src_module + + context = { + "original_search_result": { + "title": "Aftermath", # contains 'ft' as substring + "artists": [{"name": "Primary"}, {"name": "Featured"}], + }, + "source": "spotify", + } + cfg_overrides = {"metadata_enhancement.tags.feat_in_title": True} + with patch.object(src_module, "get_config_manager", return_value=_make_cfg(cfg_overrides)): + meta = src_module.extract_source_metadata(context, {"name": "Primary"}, {}) + + # Should APPEND because 'ft' inside 'Aftermath' isn't a + # standalone "ft" feature marker + assert "(feat. Featured)" in meta["title"] + + +# --------------------------------------------------------------------------- +# Integration — settings combine correctly +# --------------------------------------------------------------------------- + + +class TestSettingsCombination: + def test_feat_in_title_overrides_separator_for_artist_string(self): + """When BOTH settings are on, feat_in_title wins for the + ARTIST string (primary only). Separator is irrelevant in + that branch but `_artists_list` still carries every artist + for the multi-value tag write.""" + meta = _call_extract(["A", "B", "C"], cfg_overrides={ + "metadata_enhancement.tags.feat_in_title": True, + "metadata_enhancement.tags.artist_separator": ";", + }) + assert meta["artist"] == "A" + assert "(feat. B, C)" in meta["title"] + # Multi-value list still complete — write_multi_artist would + # use this regardless of feat_in_title. + assert meta["_artists_list"] == ["A", "B", "C"] + + def test_all_three_off_default_behavior_preserved(self): + """Sanity: unset config → joined ARTIST, no title change, + list still populated. Picks up no behavior change for users + who haven't touched the settings.""" + meta = _call_extract(["A", "B"]) + assert meta["artist"] == "A, B" + assert meta["title"] == "Sample Track" + assert meta["_artists_list"] == ["A", "B"] diff --git a/webui/static/helper.js b/webui/static/helper.js index 4f62501d..599833ba 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3416,6 +3416,7 @@ const WHATS_NEW = { '2.5.1': [ // --- post-release patch work on the 2.5.1 line — entries hidden by _getLatestWhatsNewVersion until the build version bumps --- { date: 'Unreleased — 2.5.1 patch work' }, + { title: 'Multi-Artist Tag Settings Now Actually Work (artist_separator + feat_in_title + write_multi_artist)', desc: 'three settings on settings → metadata → tags were partially or completely unimplemented. (1) `write_multi_artist` only worked because of a never-populated `_artists_list` field — `core/metadata/source.py` built `metadata["artist"]` as a hardcoded ", "-joined string but never assigned `metadata["_artists_list"]`, so `core/metadata/enrichment.py:114` always saw an empty list and silently no-op\'d the multi-value tag write. (2) `artist_separator` (default ", ") was referenced in the UI + settings.js save path but ZERO python code read the value — every multi-artist track ended up with hardcoded ", " regardless of what the user picked. (3) `feat_in_title` (when true: pull featured artists into the title as " (feat. X, Y)" and leave only primary in the ARTIST tag — picard convention) had no implementation at all. fix in source.py: populate `_artists_list` from the search response\'s artists array, then build the ARTIST string per the user\'s settings — primary-only when feat_in_title is on (with featured names appended to title; double-append guarded for source titles that already include "feat."), else joined with the configured separator. fix in enrichment.py id3 path: writing TPE1 twice (single-string then list) was overwriting the configured separator. now keeps TPE1 as the display string and writes a separate `TXXX:Artists` frame for the multi-value list (picard convention). vorbis path was already correct (separate "artist" + "artists" keys). known limitation: deezer\'s `/search` endpoint only returns the primary artist — the full contributors array lives on `/track/`. enrichment uses search-result data so deezer-sourced tracks may still get only the primary artist until a later fix wires the per-track contributors fetch into the enrichment flow. spotify, tidal, itunes search responses include all artists so they work now. 13 new tests pin: `_artists_list` populated for multi/single/no-artist cases, separator drives ARTIST string (default + custom), single-artist case unaffected by either setting, feat_in_title pulls featured to title + leaves primary in ARTIST, feat_in_title no-op for single artist, double-append guard for source titles containing "feat.", combined-settings precedence (feat_in_title wins over separator for ARTIST string but `_artists_list` carries everyone for the multi-value tag).', page: 'settings' }, { title: 'AudioDB Enrichment: Track Worker No Longer Stuck In Infinite Retry Loop', desc: 'github issue #553: audiodb track enrichment "stuck" — constant requests, no progress, only error log was a 10s read-timeout from `lookup_track_by_id` repeating against the same track. trace: when an entity already has `audiodb_id` populated (from manual match or earlier scan) but `audiodb_match_status` is NULL, the worker tries a direct ID lookup. if it fails (returns None on timeout — audiodb\'s `track.php` endpoint is slow, 10s timeouts common), the prior code logged "preserving manual match" and returned WITHOUT marking status. row stayed NULL → queue picked it up next tick → tried direct lookup → timed out → returned → infinite loop. fix: (1) when direct lookup fails (None or exception), mark `audiodb_match_status="error"` so the queue\'s NULL-status filter stops re-picking the row on every tick. preserves the existing `audiodb_id` (no fallback to name-search guess that would overwrite a manual match). (2) extended the retry-after-cutoff queue priorities (4/5/6) to include `\'error\'` rows alongside `\'not_found\'` — same `retry_days=30` window. transient audiodb outages still recover automatically; permanently-broken IDs eventually get re-attempted once a month. only triggered for entities in the inconsistent state of `audiodb_id` set + `match_status` NULL — happy path and already-matched/already-not-found rows unchanged. 5 new tests pin: lookup-returns-none marks error (no infinite loop), lookup-raises-exception marks error, lookup-success preserves happy path, error-row-past-cutoff gets re-picked, error-row-within-cutoff stays skipped.', page: 'tools' }, { title: 'Docker: Container No Longer Restart-Loops On Bind-Mounted Staging Folder', desc: 'after pulling latest, the container refused to start. logs showed `mkdir: cannot create directory \'/app/Staging\': Permission denied`. cause traced back to the 2026-05-08 image-bloat fix (commit 70e1750) which changed the Dockerfile from `chown -R /app` to a scoped chown on specific subdirs (the recursive chown was duplicating the whole /app tree into a new layer and ballooning image size). side effect: `/app` itself went from soulsync:soulsync to root:root (Docker WORKDIR default), AND `/app/Staging` was left out of both the Dockerfile mkdir + chown list and only created at runtime by the entrypoint script. on rootless Docker / Podman where in-container "root" maps to a host UID, the entrypoint mkdir on `/app/Staging` could fail with EACCES depending on the bind-mount path\'s host ownership — `set -e` then aborted the script and the container restart-looped. fix: (1) Dockerfile now pre-bakes `/app/Staging` into the image alongside the other runtime mount points (mkdir + scoped chown) so the entrypoint mkdir is a guaranteed no-op even when bind-mount perms are weird. (2) entrypoint mkdir + chown both have `|| true` now so any future bind-mount permission quirk surfaces as a log line, not a restart loop. (3) new writability audit at the end of entrypoint setup — `gosu soulsync test -w` on every bind-mountable dir, logs a loud warning with the exact `chown` command to run on the host if perms mismatch the configured PUID/PGID. catches the underlying bind-mount perm issue that the restart-loop fix would otherwise mask (container starts, but auto-import / downloads write into unwritable dirs and fail silently). zero behavior change for users whose containers were already starting fine; defensive against the rootless/podman config that broke after the image-bloat refactor.', page: 'tools' }, { title: 'Your Albums: Download Missing Now Opens Selectable Modal + Tidal Resolution', desc: 'two-part fix to the your albums "download missing" flow on discover. (1) replaced the broken per-album direct-download loop with a selectable-grid modal mirroring the library page\'s download discography flow. clicking the download button now opens a checkbox grid showing every missing album (cover, title, artist, year, track count, source) with select all / deselect all controls. user picks what they actually want, hits "add to wishlist", each album\'s tracks get resolved + queued through the existing wishlist auto-download processor. matches the discography flow\'s per-album ndjson progress stream so users see ✓/✗ per album as it processes. previous loop fired direct downloads via `openDownloadMissingModalForYouTube` which the user reported as silently failing — "queuing 2/2" toast with no actual transfer activity. wishlist is the right destination for batch missing-album adds since it already handles retry, source fallback, dedup, and rate limiting. (2) added tidal source resolution. backend `/api/discover/album//` got a new `tidal` source branch that calls a NEW `tidal_client.get_album_tracks(album_id)` method — two-phase fetch (cursor-walk `/v2/albums//relationships/items?include=items` for track refs + position metadata, batch-hydrate via existing `_get_tracks_batch` for artist/album names). track refs carry `meta.trackNumber` + `meta.volumeNumber` so multi-disc compilations render in album order. inline `?include=coverArt` lookup pulls the album cover too. single-album click flow (`openYourAlbumDownload`) gets `tidal_album_id` added to `trySources`. virtual-id generation includes tidal_album_id for stable identifiers. backend reuses the existing `/api/artist//download-discography` endpoint — its url artist_id param is functionally unused (per-album payload carries everything), so the modal posts with placeholder `your-albums` and gets multi-artist resolution for free. 10 new tests pin the tidal album-tracks method: single-page walk + hydration, multi-page cursor chain, multi-disc sort order, limit short-circuit, no-token short-circuit, http error returns empty, 429 propagates to rate_limited decorator, forward-compat type filter, partial-batch failure containment, empty-album short-circuit.', page: 'discover' },