Multi-artist tag settings: implement artist_separator + feat_in_title + populate _artists_list
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/<id>`. 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.
This commit is contained in:
parent
3ee7a140db
commit
c11a5b7eab
4 changed files with 355 additions and 3 deletions
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
292
tests/metadata/test_multi_artist_tag_settings.py
Normal file
292
tests/metadata/test_multi_artist_tag_settings.py
Normal file
|
|
@ -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"]
|
||||
|
|
@ -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/<id>`. 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/<source>/<album_id>` 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/<id>/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/<id>/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' },
|
||||
|
|
|
|||
Loading…
Reference in a new issue