Show actionable missing album tracks in the enhanced library from canonical metadata, with a practical Manage flow for Add to Library or I Have This.
Implement I Have This as a non-destructive copy/import path: copy the chosen existing file, run normal post-processing with the missing track context, insert the real library row, and inherit album identity tags from target siblings so Navidrome does not split albums.
Improve the modal with selectable search results, visible import progress, disabled controls during import, and missing-track row styling.
- _SOULSYNC_BASE_VERSION in web_server.py
- WHATS_NEW key + date in helper.js (strips unreleased flag from Amazon entries)
- fallback version string in helper.js
- Artist cards, hero section, and enhanced view now show Amazon Music badges
when amazon_id is populated (AMAZON_LOGO_URL constant, orange #FF9900 brand)
- Enhanced view artist and album match status rows include amazon_match_status
chip with click-to-rematch via openManualMatchModal
- getServiceUrl: added amazon (album/track ASIN → music.amazon.com) and fixed
missing discogs entries; serviceLabels adds tidal/qobuz/amazon
- Enhanced view enhanced-artist-id-badges includes amazon_id entry
- DB SELECTs for library artists list and artist detail now return amazon_id;
both response dicts include the field
- watchlist_artists migration adds amazon_artist_id column
- Watchlist config GET: amazon_artist_id in SELECT/WHERE/response (index 18)
- Watchlist artists list response includes amazon_artist_id
- link-provider endpoint: amazon added to valid_providers and col_map
- _populateLinkedProviderSection: amazonId param + Amazon Music source row
- Watchlist card source badges render Amazon pill (watchlist-source-amazon CSS)
- _openSourceSearch labels map includes amazon
- service_search: amazon_worker injected via init(); _search_service amazon branch
uses search_artists/albums/tracks, same {id,name,image,extra} return shape
- _SERVICE_ID_COLUMNS: amazon → amazon_id for artist/album/track
- _init_service_search call passes amazon_worker_obj
- amazon_client._fetch_album_metas: 5-minute TTL cache per ASIN — cached hits
skip _rate_limit() and HTTP call entirely; fixes ~10s artist detail load
- registry.py: removed amazon from METADATA_SOURCE_PRIORITY and
METADATA_SOURCE_LABELS — T2Tunes has no discography API, cannot serve as a
primary metadata source; Amazon remains a download source + ASIN enricher
- Settings metadata source dropdown and help text updated accordingly
Adds full parity with Deezer/Qobuz/Tidal/Discogs in every dashboard
UI layer — orb button, live tooltip, WebSocket push, rate speedometer.
- webui/index.html: Amazon enrichment orb button after Discogs
- webui/static/amazon.svg: local icon (a + smile, same pattern as
hydrabase.png — avoids external URL dependency)
- webui/static/style.css: Amazon button/spinner/tooltip CSS with
FF9900 brand color; added to mobile tooltip suppress list
- webui/static/worker-orbs.js: Amazon orb in WORKER_DEFS [255,153,0]
- webui/static/api-monitor.js: Amazon in rate gauge services list,
label, and color map
- webui/static/enrichment.js: updateAmazonEnrichmentStatusFromData,
toggleAmazonEnrichment, DOMContentLoaded init + 2s poll
- webui/static/core.js: socket.on enrichment:amazon-enrichment listener
- web_server.py: amazon-enrichment added to _emit_enrichment_status_loop
workers dict so WebSocket pushes fire every 2s
Background worker matching library artists/albums/tracks to Amazon ASINs
via T2Tunes search. Follows same 6-tier priority queue as Deezer/iTunes/
Spotify/Qobuz/Tidal workers. Backfills artist thumbnails from album cover
stand-ins (T2Tunes exposes no direct artist images).
- core/amazon_worker.py: new AmazonWorker class with full parity
- database/music_database.py: expand _add_amazon_columns to cover
amazon_id/amazon_match_status/amazon_last_attempted on artists,
albums, and tracks (was artists-only)
- web_server.py: import, init, register in enrichment panel, add to
scan pause/resume dicts and rate monitor key map
- helper.js: WHATS_NEW 2.5.3 entry for enrichment worker
- All search_raw calls switched from single-type to types="track,album" — T2Tunes only
returns results when both types are requested together
- _fetch_album_metas: parallel fetch (up to 5 workers) of album cover art via
album_metadata(asin) — T2Tunes search results carry no image URLs
- search_tracks: populates image_url, release_date, total_tracks from album meta
- search_artists: strips feat. credits via _primary_artist() so "Artist feat. X" and
"Artist ft. Y" collapse to one "Artist" entry; uses album cover as artist image
stand-in (same approach as iTunes — T2Tunes has no artist images)
- search_albums: name-based dedup (display_name + artist key) instead of ASIN-based;
populates image_url, release_date, total_tracks from album meta (cap 10 ASIN fetches)
- _strip_edition(): strips [Explicit]/(Explicit) from track/album names — explicit is
the default version; Clean/Edited/Censored labels kept as-is so they stay distinct
- get_album(): applies _strip_edition to name and _primary_artist to artist so
MusicBrainz preflight matching doesn't fail on "[Explicit]" album names
- get_album_tracks(): populates track_number and disc_number from T2TunesStreamInfo
instead of hardcoding None — fixes track ordering in multi-track album downloads
- get_artist() / get_artist_albums(): _unslugify() converts slug artist IDs back to
search names; _primary_artist() in comparison handles feat-annotated results
- SOURCE_ONLY_ARTIST_SOURCES: added "amazon" so artist detail page doesn't 404
- build_source_only_artist_detail: added amazon_client param + dispatch branch
- web_server.py: resolve amazon_client in _build_source_only_artist_detail wrapper;
add source_override=="amazon" branch in get_spotify_album_tracks endpoint
- 77 tests covering all above paths; all pass
- Add 'amazon' to VALID_SOURCES (and transitively VALID_STREAM_SOURCES)
in core/search/orchestrator.py so the backend accepts it as a
requested source without returning 400
- Add resolve_client('amazon') case — mirrors musicbrainz pattern,
gets the cached AmazonClient from the metadata registry
- Add 'amazon' to _alternate_sources() so it appears as a tab when
another source is primary (always available, no credentials)
- Add SERVICE_CONFIG_REGISTRY entry 'amazon': {'always': True} so
/api/settings/config-status reports it as configured
- Add SOURCE_LABELS['amazon'] and SOURCE_ORDER entry in
shared-helpers.js so both enhanced search and global search show
the Amazon Music tab
- Add 'amazon' to _ALWAYS_CONFIGURED_SOURCES so the picker never
dims the tab (no credentials required)
- Add .enh-tab-amazon.active CSS (Amazon orange #FF9900)
- 3530 tests pass
Wires AmazonClient into the metadata source registry following the
exact same pattern as DeezerClient. No existing source paths touched.
- Add get_album_metadata / get_artist_info / get_artist_albums_list
aliases to AmazonClient (mirrors DeezerClient interface aliases)
- Register amazon in METADATA_SOURCE_PRIORITY and METADATA_SOURCE_LABELS
- Add _get_amazon_factory() + get_amazon_client() to registry.py
- Add amazon branch to get_client_for_source(); thread amazon_client_factory
kwarg through get_primary_client() and get_primary_source_status()
- Re-export get_amazon_client from the core.metadata_service shim
- Add Amazon Music option to Settings metadata source dropdown
- 3530 tests pass
`validation.py` had amazon absent from `_streaming_sources`, causing
Amazon TrackResult objects (bitrate=None, size=0) to fall through to
the Soulseek P2P code path and get rejected by
`filter_results_by_quality_preference`. Every album track was marked
not found.
Fix: add 'amazon' to every streaming-source guard tuple/set that was
previously missing it:
- core/downloads/validation.py — primary bug fix (quality-filter bypass)
- core/downloads/status.py — _STREAMING_SOURCE_NAMES frozenset
- core/downloads/task_worker.py — hybrid fallback client map
- core/imports/side_effects.py — || filename→stream-id extraction
- web_server.py — is_streaming_source, transfer list display,
candidate source label, _try_source_reuse, _store_batch_source
- tests/test_download_plugin_conformance.py — registry count + parametrize
Also updates the 2.5.3 What's New entry to drop the stale
"not yet wired" disclaimer.
Follows the exact same standard as Tidal, Qobuz, HiFi, and Deezer.
registry.py — import + register AmazonDownloadClient as 'amazon'.
amazon_download_client.py — read amazon_download.quality / allow_fallback
from config on init; pass quality as preferred_codec to AmazonClient;
_download_sync codec waterfall respects allow_fallback flag.
download_orchestrator.py — reload_settings() updates preferred_codec +
allow_fallback on the live client after a settings save. 'amazon' added
to _streaming_sources so search_and_download_best routes it correctly.
api_call_tracker.py — 'amazon' registered in RATE_LIMITS (120/min),
SERVICE_LABELS, and SERVICE_ORDER so API call monitoring shows Amazon.
web_server.py — 'amazon_download' added to the settings service loop.
'amazon' added to serverless_sources (no slskd probe needed). Streaming
file-finder extended to handle amazon username + ||asin||title encoding
(extension-less fuzzy match, same as Tidal/Qobuz/HiFi). New endpoint:
GET /api/amazon/test-connection → checks T2Tunes proxy status.
webui/index.html — amazon-download-settings-container: quality dropdown
(flac/opus/eac3), allow-fallback checkbox, test-connection button.
webui/static/settings.js — 'Amazon Music' added to HYBRID_SOURCES,
_hybridSourceEnabled, allSources mode list, loadSettings(), saveSettings()
payload, updateDownloadSourceUI() show/hide + auto-test. New
testAmazonConnection() function.
core/amazon_client.py — T2Tunes-backed metadata client following the
DeezerClient/iTunesClient contract. Exposes search_tracks, search_artists,
search_albums, get_track_details, get_album, get_album_tracks, get_artist,
get_artist_albums, get_track_features. T2TunesStreamInfo dataclass captures
the hex decryption key returned by the proxy (CENC/AES-128). Handles the
"stremeable" API typo. 0.5 s rate-limit guard + api_call_tracker.
core/amazon_download_client.py — DownloadSourcePlugin backed by the above
client. Codec waterfall: FLAC → Opus → EAC3. Downloads the encrypted MP4
container, decrypts with ffmpeg -decryption_key, yields the native audio
file (.flac / .opus / .eac3). Not yet wired into the app source registry —
validated in isolation only; see tests/tools/.
tools/t2tunes_probe.py + tools/t2tunes_media_plan.py — standalone CLI tools
used for live API exploration during development.
tests/tools/test_amazon_client.py — 72 unit tests (all mocked).
tests/tools/test_amazon_download_client.py — 52 unit tests (all mocked).
124 tests pass.
Snapshots now track when their source data changes. Watchlist scan
emits stale flags on the playlists whose underlying pool just got
refreshed; the next pipeline run sees the flag and regenerates the
snapshot before syncing, so the server playlist never lags the source.
Schema:
- new `is_stale INTEGER NOT NULL DEFAULT 0` column on
`personalized_playlists`, plus an idempotent ADD COLUMN migration
in `ensure_personalized_schema` for installs created before this PR.
- `PlaylistRecord.is_stale: bool = False` exposed on the dataclass so
callers can branch on freshness without re-querying.
Manager:
- new `mark_kinds_stale(kinds, profile_id=None)` flips the flag in
bulk for a list of kinds (used by upstream data refreshers).
- `_persist_snapshot` clears `is_stale = 0` on successful refresh.
- SELECT statements + `_row_to_record` updated to read the column
(with tuple-form length guard for safety).
Pipeline:
- `_build_payloads_for_kinds` now branches: refresh_first=True OR
`existing.is_stale` -> refresh_playlist, else read existing
snapshot. So the auto-refresh kicks in without needing the user to
toggle the refresh-each-run option.
Watchlist scanner emits stale flags at three sites:
- after `update_discovery_pool_timestamp` -> marks pool-fed kinds
stale: hidden_gems, discovery_shuffle, popular_picks, time_machine,
genre_playlist, daily_mix.
- after release_radar `save_curated_playlist` -> marks `fresh_tape`.
- after discovery_weekly `save_curated_playlist` -> marks `archives`.
All three calls go through a module-level `_mark_personalized_kinds_stale`
helper that builds a PersonalizedPlaylistManager with `deps=None` (only
DB access is needed for the flag update — no generator dispatch). Each
call is wrapped in try/except so a flag failure can never abort the
scan itself.
Tests:
- new `TestStaleFlag` class in `test_personalized_manager.py` (6
tests): default-false, single-kind flip, multi-kind, profile
scoping, refresh-clears, empty-list noop.
- two new pipeline tests pin the auto-refresh dispatch:
`test_stale_snapshot_auto_refreshes_even_without_refresh_first`
and `test_non_stale_snapshot_skips_refresh`.
- existing stub-manager `SimpleNamespace` returns gained
`is_stale=False` so the new attribute read doesn't AttributeError.
Full suite: 3391 pass.
User-facing WHATS_NEW entry added under 2.5.2 (above the prior
pipeline auto-sync entry) describing the auto-refresh behavior.
The picker was rendering as a narrow centered column overlapping the
description text because:
1. The outer `.config-row` defaults to flex-direction:row with the
label on the left and the input on the right at fixed width — works
for a select / textbox, breaks for a tall scrolling multi-select.
2. Inner `<label>` rows in the picker were inheriting
`.placed-block-config label` (uppercase / 50px min-width /
letter-spacing 0.5px) so each row turned into a 50-pixel-wide
uppercase chip.
Fixes:
- Outer wrapper switched to `flex-direction:column;align-items:stretch`
+ `width:100%;box-sizing:border-box` on the picker div.
- Inner row + section-header inline styles override font-size,
text-transform, letter-spacing, and min-width so the picker rows
render at normal text size with proper full-width alignment.
Variant rows indent under their kind header at 20px so the visual
grouping is obvious.
The action was registered + the block declared, but the automation
builder's per-action config renderer didn't have a case for
`personalized_pipeline` so users only saw the bare card with the
generic delay-minutes input — no way to select which playlists to
sync. This commit adds the multi-select picker.
Backend:
- `core/personalized/api.list_kinds(manager=...)` now optionally
takes a manager and includes the resolved variant list per kind
(calls each spec's variant_resolver(deps) when present). Singleton
kinds get an empty `variants` list. Variant-bearing kinds
(time_machine / genre_playlist / daily_mix / seasonal_mix) get
their full enumerated set.
- `web_server.py` `/api/personalized/kinds` route now passes a built
manager so the variants list lands in the response.
Frontend:
- `webui/static/stats-automations.js` `_renderBlockConfigFields`
gains a `personalized_pipeline` branch that renders a scrollable
multi-select picker:
- Singletons (Hidden Gems, Discovery Shuffle, Popular Picks,
Fresh Tape, The Archives) = one checkbox row per kind
- Variant kinds = a section header + one checkbox row per variant
(e.g. Time Machine: 1960s/1970s/.../2020s; Seasonal: halloween/
christmas/valentines/summer/spring/autumn)
- Pre-checks rows that match the existing `kinds` config on edit
- New `_autoLoadPersonalizedKinds(slotKey)` fetches `/api/personalized/kinds`
(cached after first load), renders the picker DOM, and pre-checks
saved selections via `data-kind` / `data-variant` attributes on
the checkboxes.
- `_renderBuilderCanvas` calls the loader for any `cfg-*-kinds-picker`
it finds in the freshly-rendered slots.
- The save-time `_collectActionConfig` walks the picker's checked
inputs (matched by `data-kind` attribute) and emits
`{kinds: [{kind, variant?}, ...], refresh_first, skip_wishlist}`
in the same shape the handler expects.
Tests:
- `tests/automation/test_automation_blocks.py::_FIELD_TYPES` adds
'personalized_playlist_select' so the block-shape regression test
accepts the new field type. (Test was failing because it whitelists
every field type used across all blocks.)
- 189 automation + personalized API tests pass; full suite intact.
Follow-up to the personalized-playlists standardization PR. New
`personalized_pipeline` automation action syncs selected discover-
page playlists (Hidden Gems / Discovery Shuffle / Time Machine /
Genre / Daily Mix / Fresh Tape / The Archives / Seasonal Mix) to
the active media server + queues missing tracks for download.
Same pattern as the existing mirrored `playlist_pipeline` but two
phases instead of four — no REFRESH (no external source to re-pull)
and no DISCOVER (manager-backed snapshots are already metadata-
matched). Pipeline shape:
SNAPSHOT → SYNC → WISHLIST
Where SNAPSHOT either reads the persisted track list from
`PersonalizedPlaylistManager` (default) or refreshes it first when
`refresh_first=true` (cron use case: regenerate Hidden Gems nightly
and sync the fresh set).
Shared helper extraction:
PHASE 3 (SYNC loop) + PHASE 4 (WISHLIST tail) lifted out of mirrored
`playlist_pipeline` into `core/automation/handlers/_pipeline_shared.py`
as `run_sync_and_wishlist(deps, automation_id, playlists, sync_one_fn,
sync_id_for_fn, ...)`. Both pipelines call it. Mirrored injects
`auto_sync_playlist` as the per-playlist sync function; personalized
injects a thin wrapper that launches `_run_sync_task` directly with
a pre-built tracks_json. Same sync-state polling / progress emission
/ status counting / wishlist trigger logic — 0 duplication.
Files added:
- core/automation/handlers/_pipeline_shared.py
- core/automation/handlers/personalized_pipeline.py
- tests/automation/test_handlers_personalized_pipeline.py
Files changed:
- core/automation/handlers/playlist_pipeline.py: PHASE 3+4 replaced
with shared helper call (~100 lines deleted, 1 helper invocation
added; behavior identical).
- core/automation/deps.py: new `build_personalized_manager` field
(lazy builder so the pipeline gets a fresh PersonalizedPlaylistManager
per run).
- core/automation/handlers/__init__.py + registration.py: register
`personalized_pipeline` action with the shared `pipeline_running`
guard so it can't overlap mirrored.
- core/automation/blocks.py: new `personalized_pipeline` block
declaration with config_fields (kinds multi-select, refresh_first,
skip_wishlist).
- web_server.py: thread `_build_personalized_manager` into
AutomationDeps construction.
- All 5 automation test fixtures: `_build_deps` adds
`build_personalized_manager=lambda: None` stub.
- tests/automation/test_handler_registration.py:
EXPECTED_ACTION_NAMES + EXPECTED_GUARDED_ACTIONS gain
`personalized_pipeline`.
Trigger schema:
{
"_automation_id": "...",
"kinds": [
{"kind": "hidden_gems"},
{"kind": "time_machine", "variant": "1980s"},
{"kind": "seasonal_mix", "variant": "halloween"}
],
"refresh_first": false,
"skip_wishlist": false
}
Tests (14 new, 178 automation total):
- _track_to_sync_shape: basic shape, source ID fallback chain,
no-id returns empty string
- empty config / non-list kinds / empty kinds list all return
error + clear pipeline_running flag
- _build_payloads_for_kinds: skips invalid entries, skips kinds
with no tracks, refresh_first vs ensure dispatch, payload shape
+ sync_id format, manager exception swallowed continues
- _sync_personalized_playlist: launches background thread + returns
status='started'
- happy path: stubbed sync_states drives helper to completion, flag
cleaned up
Full suite: 3383 passed.
Note: the trigger UI block declares config_fields but the frontend
doesn't yet render the `personalized_playlist_select` multi-select
type — usable today via API; polished UI ships in a follow-up
frontend PR.
User-facing summary of the standardization work — all 8 personalized
discover-page playlists unified behind one storage layer, manager,
and REST surface. Prerequisite for the playlist pipeline integration
landing in the next PR.
Recent activity items on the dashboard all rendered 'NaNmo ago'
because the formatter parsed `activity.time` (a human label like
'Now' / 'Just now') with `new Date(...)` -> Invalid Date -> NaN
arithmetic -> 'NaNmo ago'.
Backend (`core/runtime_state.add_activity_item`) has always emitted
`activity.timestamp` (Unix epoch seconds) alongside the label.
Frontend now uses the epoch for relative-time formatting via a new
local `_activityTimeAgo` helper:
- typeof timestamp === 'number' -> diff against Date.now() in ms
- < 60s -> 'Just now'
- < 60m -> 'Nm ago'
- < 24h -> 'Nh ago'
- < 30d -> 'Nd ago'
- otherwise 'Nmo ago'
- falls back to the literal `activity.time` label only when no
timestamp is present (legacy items / future shapes)
Both call sites in api-monitor.js (initial render + timestamp-only
refresh path) updated to the new helper.
The first token-leak fix scrubbed the artwork URL fixer's own log
calls. This catches three more sites that ALSO leaked tokens, plus
one upstream gap that let URL-encoded tokens slip through the
redactor.
Three sites in `web_server.py` (artist endpoint at line 8765-8773):
- "Artist image before fix: '...'" -- logged the raw image_url with
the auth token in plain form.
- "Artist image after fix: '...'" -- logged the URL-encoded form
after it had been wrapped in the image proxy
(`/api/image-proxy?url=<percent-encoded-token>`).
- "Final artist data being sent: {...}" -- dumped the entire
artist_info dict on every render, including the image_url field.
All three were dev-time debug noise. Removed entirely. The "No
artist image URL found" warning at line 8770 stays (no URL, just
the artist name).
One site in `core/discovery/sync.py:402`:
- "[PLAYLIST IMAGE] image_url=..." -- logged the playlist poster URL
during sync. Same auth-token leak risk for Plex / Jellyfin
playlists. Changed to log only `has_image=True/False`.
Upstream gap in `_redact_url_secrets`:
- The original regex only matched plain query params (`?key=value`).
When an auth-bearing URL gets wrapped inside another URL's query
string (our `/api/image-proxy?url=<encoded>` flow) the auth params
end up percent-encoded -- `%3FX-Plex-Token%3D...` -- and slipped
through.
- New second pattern catches the URL-encoded form. Both passes run
on every redact call; idempotent.
Verified manually:
/api/image-proxy?url=...%3FX-Plex-Token%3DABC...
-> /api/image-proxy?url=...%3FX-Plex-Token%3D***REDACTED***
6 artwork tests pass.
The artwork URL normalizer was logging the full constructed media-
server URL on every cover-art lookup at INFO level, including the
auth query params (X-Plex-Token / X-Emby-Token / Subsonic t+s+p).
Those lines pile up in app.log on disk -- anyone with read access to
the log file gains full read access to the user's media server.
Also dropped the noisy per-call "Plex/Jellyfin/Navidrome config -
base_url: ..., token: ..." INFO lines that fired on every thumbnail.
Even the truncated `token[:10]` form is enough partial-known-plaintext
to be uncomfortable to leak.
- New `_redact_url_secrets` helper masks the values of X-Plex-Token,
X-Emby-Token, api_key, apikey, Subsonic t / s / p, generic token /
password query params. Regex anchored on `?` or `&` boundary so
short keys like `t` don't false-match inside `format=Jpg`.
- "Fixed URL: ..." log calls moved from INFO to DEBUG so they don't
persist by default, and the URL passed in is run through the
redactor first.
- Per-call "Plex config - ..." / "Jellyfin config - ..." /
"Navidrome config - ..." INFO lines removed entirely. Config
inspection has dedicated UI; per-thumbnail spam belongs to no one.
- Error-path logging (line 149) also routed through the redactor in
case the failing URL had auth params attached.
Users with existing app.log files containing the leaked tokens
should rotate / wipe the log. Plex tokens can be regenerated by
signing out of all devices in Plex settings; Jellyfin api_keys can
be revoked from the dashboard; Navidrome users should rotate the
account password.
Issue #607 (AfonsoG6) -- two AcoustID problems:
1. Live recordings false-quarantining as "Version mismatch: expected
'... (Live at Venue)' (live) but file is '...' (original)" because
MusicBrainz often stores the recording entity with a bare title --
the venue / live annotation lives on the release entity, not the
recording. The audio fingerprint correctly identifies the live
recording, but the title-text comparison flagged it as wrong.
New pure helper `core/matching/version_mismatch.py:is_acceptable_version_mismatch`
accepts the mismatch only when:
- One-sided AND involves 'live': exactly one side is 'live' and
the other is bare 'original'. Two-sided mismatches stay strict.
- Fingerprint score >= 0.85 (stricter than the existing 0.80
minimum -- escape valve only fires when AcoustID is more
confident than its own threshold).
- Bare title similarity >= 0.70.
- Artist similarity >= 0.60.
Other version markers (instrumental, remix, acoustic, demo, etc)
stay strict -- those have distinct fingerprints AND MB always
annotates them in the recording title. The existing
test_acoustid_version_mismatch.py suite passes unchanged.
2. Audio-mismatch failure message reported "identified as '' by ''
(artist=100%)" when AcoustID returned multiple recordings -- prior
code mixed `recordings[0]`'s strings (which can be empty) with
`best_rec`'s scores. Now uses `matched_title` / `matched_artist`
consistently in both the high-confidence-skip path and the final
fail message.
Issue #608 (AfonsoG6) -- quarantine modal:
3. Approve / Delete buttons silently no-op'd when the filename
contained an apostrophe -- the unescaped quote broke the inline JS
in the onclick handler. Now wraps the id via
`escapeHtml(JSON.stringify(id))`, which round-trips quotes /
backslashes / unicode / newlines safely through the HTML attribute
to JS string boundary.
4. Bonus UX: quarantine entry expanded view now shows source uploader
(username) and original soulseek filename when the sidecar carries
that context -- helps trace which uploader the bad file came from.
Backend exposes `source_username` + `source_filename` fields from
`sidecar.context.original_search_result`. Degrades to '' on legacy
thin sidecars.
Tests:
- 23 new boundary tests in tests/matching/test_version_mismatch.py
pin every shape: equal versions trivial, one-sided live both
directions, threshold floors (each just below default -> reject),
two-sided strict, non-live one-sided strict (covers exact
test_instrumental_returned_for_vocal_request_fails scenario),
custom-threshold overrides.
- 4 existing test_acoustid_version_mismatch.py tests pass unchanged.
- 507 AcoustID / matching / imports tests pass.
Adds an opt-in alternative metadata source for reorganize. The
existing API path (query Spotify / iTunes / Deezer / Discogs /
Hydrabase for the canonical tracklist) stays the default and is
unchanged. The new tag mode reads each file's embedded tags as the
source of truth instead -- useful for well-enriched libraries where
API drift can produce inconsistent renames, and avoids API calls
entirely.
- New pure helper `core/library/reorganize_tag_source.py` adapts the
output of `read_embedded_tags` (the same mutagen path the audit-
trail modal uses) to the `api_album` / `api_track` shapes that
`_build_post_process_context` already consumes. Handles ID3-style
"5/12" track + disc shapes, multi-value Artists tags, year
normalization across 5 date formats, releasetype canonical tokens,
multi-artist string splits across 9 separators.
- `plan_album_reorganize` accepts `metadata_source: 'api' | 'tags'`
(default 'api') and `resolve_file_path_fn`. Tag mode branches into
a new `_plan_from_tags` that reads each track's file and produces
per-item `api_album` + `api_track` instead of a shared one.
- `_run_post_process_for_track` accepts a per-item `api_album`
override so each file's own album metadata flows through post-
process (not a single shared dict).
- `total_discs` in tag mode honors the `totaldiscs` tag and the
trailing `/N` of an ID3 `discnumber = "1/2"`. Partial-album
reorganize still routes into the correct `Disc N/` subfolder when
the tag knows the total even if not all discs are present locally.
- Bare `discnumber = "1"` no longer poisons `total_discs` -- it
carries no total signal.
- `reorganize_album` surfaces a tag-mode-specific error when no
files are readable, instead of the API-mode "run enrichment first"
message which would mislead in tag mode.
- `QueueItem.metadata_source` field, `enqueue` / `enqueue_many`
pass-through, runner injects `item.metadata_source` into
`reorganize_album`.
- `web_server.py` endpoints accept `mode` body param. Falls back to
the `library.reorganize_metadata_source` config setting, then to
'api'. Strict allowlist (api / tags) -- anything else falls back.
- Frontend: per-album modal + reorganize-all modal both grow a new
"Metadata Mode" dropdown above the source picker. Tag mode hides
the source picker (irrelevant). Choice persisted in localStorage.
Both preview + execute fetches send `mode` in body.
Tests:
- 49 boundary tests on the pure helper pin every shape: ID3 "5/12",
multi-artist split, year normalization, releasetype validation,
total_discs precedence, defensive paths.
- 6 planner-level integration tests pin the wiring: tag-mode with
good tags, partial-disc with totaldiscs tag, file missing,
some-match-some-fail, defensive resolve_file_path_fn=None,
API-mode regression guard.
- All 3171 tests pass; 52 existing reorganize tests unchanged.
Two-layer accent glow that follows the cursor across the bento grid:
- Soft halo (1280px, blur 48) lerps toward target with a delay; bright
inner core (540px, blur 18, screen-blended) lerps faster.
- Both layers gently pulse on different rhythms so the blob feels alive
even when stationary.
- Target = cursor position when hovering any .dash-card; otherwise the
grid center (idle resting position). On leaving cards/gap, blob waits
1.5s before drifting back to center -- a small dwell that lets it
feel intentional rather than skittish.
- Card backgrounds darkened to near-black with stronger borders for
contrast against the accent glow.
Performance:
- requestAnimationFrame loop runs only while the blob is moving and
idles when settled at the target.
- Two-pass per frame: read all getBoundingClientRect() first, then
write CSS vars in a second pass -- one layout flush per frame
instead of one per card.
- IntersectionObserver snaps to grid center the first time the
dashboard becomes visible (handles the case where home page is
hidden at attach time).
Honors the existing reduce-effects setting:
- CSS hides both blob layers via body.reduce-effects.
- JS MutationObserver on body class kills the rAF loop when toggled
on; re-snaps to center and restarts when toggled off.
- prefers-reduced-motion media query disables the pulse animations.
Replaces the old stacked dashboard with a bento grid: services, stats,
library, syncs, tools, activity, enrichment each live in their own card.
- 3-col on desktop (>=1500px), 2-col on laptop, 2-col tighter on tablet,
1-col stack on mobile (<700px). Sub-grids inside each card adapt at
every breakpoint (service tiles 3-2-1, stat cards 3-2, gauge tiles
10-5-4-3-2).
- Cards use the user's accent color for glow + hover border + CTA icons
(was hardcoded per-card hues).
- Mount fade-up with per-card stagger; subtle bloom drift; reduced-motion
honored.
- Enrichment row collapses the per-service gauge tile (hides the 3-stat
row, scales the gauge SVG to fill the tile width) so all 10 services
fit on one row at desktop.
- Recent syncs stacks vertically inside its bento card instead of
overflowing horizontally.
- Every existing id, button, and JS hook preserved -- no behavior change,
pure visual + responsive overhaul.
Discord report (netti93). The download flow runs `enhance_file_metadata`
(clears all tags) then `generate_lrc_file` (writes .lrc sidecar AND
embeds USLT). The retag flow only ran the first half — `enhance_file_metadata`
cleared USLT and there was no follow-up to restore it.
Two coordinated fixes (no new setting per kettui scope discipline —
user described it as "might even be an idea," consistency was the
load-bearing ask).
Fix 1 — retag calls generate_lrc_file after enhance
`core/library/retag.py:execute_retag` now invokes
`deps.generate_lrc_file` right after the `enhance_file_metadata`
call, mirroring the download pipeline. New `generate_lrc_file`
field on `RetagDeps`, defaults to None for backward compat with
any test caller that builds RetagDeps without it. Web_server's
`_build_retag_deps()` factory wires in the real
`core.metadata.lyrics.generate_lrc_file`.
Placement matters — runs BEFORE `safe_move_file` so the helper
sees the audio file at its current path with its existing sidecar
(which retag hasn't moved yet). After the embed, the audio file
gets moved with USLT now present; the sidecar move step that
follows is unaffected.
Fix 2 — create_lrc_file re-embeds from existing sidecar
`core/lyrics_client.py:create_lrc_file` used to early-return True
when an .lrc / .txt sidecar already existed (skipping the LRClib
fetch). For the retag case the sidecar is already there, so the
shortcut hit and USLT was never re-written. Now the helper reads
the existing sidecar and calls `_embed_lyrics` with its content
before returning. Empty / unreadable sidecars short-circuit
silently — defensive, no crash. Download flow unaffected because
no sidecar exists at fetch time.
7 boundary tests pin: existing .lrc triggers re-embed, existing
.txt triggers re-embed, empty sidecar skips embed, unreadable
sidecar swallows error, no sidecar falls through to LRClib (download
path regression guard), RetagDeps.generate_lrc_file field accepted,
field optional for backward compat.
Full suite: 3120 passed.
Discord report (netti93): downloaded album tracks were tagged with
TRCK = "6/0" instead of "6/13" when source data was incomplete. The
retag tool wrote correct "6/13" because core/tag_writer.py already
handled the case.
Trace: core/metadata/enrichment.py:105 formatted unconditionally as
f"{track_number}/{total_tracks}" and many album-dict construction
sites pass total_tracks: 0 (per types.py, 0 means "unknown" — not a
real count). That 0 propagated straight to disk.
Fix at the consumer boundary so every album-dict constructor stays
unchanged. Lifted to pure helper
core/metadata/track_number_format.py:format_track_number_tag that
drops the /N suffix when total is 0 / None / negative — emits just
"6" instead. Matches retag's behavior + ID3 spec convention (TRCK
can be "N" or "N/M"). MP4 trkn tuple gets the same treatment via
format_track_number_tuple returning (6, 0) per spec's "unknown
total" marker.
Wired into all three format-write sites in enrichment.py: ID3 (TRCK),
Vorbis (tracknumber), MP4 (trkn). When source data has correct
total_tracks (album downloads via the metadata-source pipeline,
retag flow), behavior unchanged — still writes "6/13".
16 boundary tests pin every shape: known total / zero total / none
total / none track / zero track / negative inputs / string coercion
/ unparseable strings / floats truncate.
Full suite: 3113 passed.
Closes#587. Three coordinated fixes per codex's diagnosis. AcoustID
verification gate left intact — these fixes target the upstream
scanner false-positive surface plus a separate retag-path gap.
Bug 1 — scanner used recordings[0] as authoritative
`core/repair_jobs/acoustid_scanner.py:_scan_file` only checked the
top fingerprint match's metadata. AcoustID often returns multiple
recordings per fingerprint (sample collisions, multi-MB-record
cases) and the wrong-credited recording can outrank the right-
credited one. Foxxify case 2 (Nana / Nana): top match credited the
wrong artist while a lower-ranked candidate matched the user's
expected metadata exactly.
Lifted the verifier's all-candidates check to a shared pure helper
`core/matching/acoustid_candidates.py:find_matching_recording`. Both
verifier and scanner can now ask "given these candidates, does ANY
of them match expected (title, artist)?" with the same contract.
Scanner suppresses the finding when any candidate matches.
Bug 2 — no duration check guards against fingerprint hash collisions
Foxxify case 3: 17-minute mashup edit fingerprinted to a 5-minute
late-70s Japanese hiphop track (different songs, fingerprint hash
collision on a sampled section). Scanner had no signal to detect
this and would have recommended retagging the 17-min file as the
5-min track.
`duration_mismatches_strongly` in the same helper module flags drifts
beyond max(60s, 35%). Scanner now skips findings when the candidate's
duration disagrees strongly with the file's expected duration. Loaded
duration via the existing tracks SQL (added `t.duration` to the
SELECT). Returns False when either side is unknown — no behavior
change for older rows without duration data.
Bug 3 — scanner retag bypassed multi-value ARTISTS tag setting
`core/repair_worker.py:_fix_wrong_song` called `write_tags_to_file`
with single-string artist updates. The writer only wrote TPE1
(single string) and never read the user's
`metadata_enhancement.tags.write_multi_artist` config. Multi-value
ARTISTS tags got stripped on every retag, contradicting the
post-download enrichment pipeline's behavior.
Per codex's pick (option B over routing through enhance_file_metadata),
extended `write_tags_to_file` with an optional `artists_list`
parameter. Each format-specific writer respects the config flag the
same way enrichment.py does:
- ID3: TPE1 stays as joined display string + TXXX:Artists multi-value
- Vorbis/Opus/FLAC: `artist` display string + `artists` multi-value key
- MP4: \xa9ART as list when on, single string when off
Scanner retag derives the per-artist list by splitting AcoustID's
credit through the existing `split_artist_credit` helper (same
separators the matching layer already uses).
Backward compatible: callers that don't pass `artists_list` get the
exact same single-string write as before. No regression for the
write_artist_image button or any other tag_writer caller.
15 tests on the candidate helper + duration guard.
13 tests on the tag_writer multi-value path (write/skip/single/
no-list cases for FLAC + the config-gate helper).
4 new scanner regression tests pinning lower-ranked candidate
suppression, no-suppression when no candidate matches, duration
mismatch skip, no-skip when duration matches.
Existing scanner tests updated for the new 11-column SQL select
(added duration column to fake schema + test row tuples).
Full suite: 3097 passed. Ruff clean.
Closes#586. Follow-up to #442 — Cyrillic / kanji canonical names
weren't bridging cross-script comparisons. Reporter case: "Dmitry
Yablonsky" tracks quarantined as audio mismatch with file identified
as "Русская филармония, Дмитрий Яблонский" (4% artist sim) even
though the Cyrillic spelling is just the Russian transliteration.
Codex diagnosed three layered bugs in the alias resolution chain.
This fixes all three.
Bug 1 — fetch_artist_aliases ignores canonical name + sort-name
`core/musicbrainz_service.py:fetch_artist_aliases` only read
`data['aliases']`. For artists where MB's canonical `name` IS the
cross-script form (and the Latin spelling lives only in aliases —
or vice versa), the missing direction never made it into the
returned list. Fix: include both `data['name']` and `data['sort-name']`
alongside the explicit alias entries (deduped, also pulls each
alias entry's sort-name when present).
Bug 2 — lookup_artist_aliases ran search in strict mode only
Strict mode queries `artist:"..."` only and skips MB's alias and
sortname indexes. Cross-script searches found nothing under strict
because the user's Latin input never matches a Cyrillic canonical
name in the artist index. Fix: lifted the search-and-score logic
to a private helper `_search_and_score_artists(name, strict=)` and
fall back to non-strict when strict returns empty OR all results
fail the trust gate. Non-strict (bare query) hits all indexes.
Bug 3 — trust gate weighted local similarity 70%
Combined score = local_sim * 0.7 + mb_score/100 * 0.3. Cross-script
pairs have local sim ~0 → combined ~0.30 → below the 0.85 threshold
→ cached as empty even when MB's own confidence was 100. Fix: added
an MB-only escape — when MB score is >= 95 AND the result is
unambiguous (top result's MB score leads the runner-up by >= 5),
accept regardless of local similarity. The existing combined-score
path stays intact for same-script matches (#442 Hiroyuki Sawano
case still passes via that path).
12 new tests pin every layer:
- fetch_artist_aliases canonical-name inclusion + dedup against
alias entries + missing-canonical handling + exception path
- strict-then-non-strict fallback (empty-strict + low-strict-score)
- trust gate MB-only escape + low-confidence rejection + ambiguity
rejection (two artists same MB score) + same-script regression
- end-to-end reporter scenario with the real `artist_names_match`
helper proving the bridge works for "Русская филармония, Дмитрий
Яблонский" vs expected "Dmitry Yablonsky"
Existing alias tests in `test_artist_alias_service.py` updated to
reflect: canonical name now appears in `fetch_artist_aliases`
output, lookup makes 2 search calls (strict + non-strict fallback)
on first cache miss instead of 1.
Full suite: 3065 passed.
Closes#589. Tracks from MTV Unplugged / Live At / unplugged albums
consistently failed AcoustID verification with "Version mismatch:
expected (live) but file is (original)". Two upstream bugs fed into
the false positive — the AcoustID gate itself was correctly catching
the wrong file Tidal had selected. Codex diagnosed all three layers,
this fixes the two upstream causes and leaves the verifier alone.
Bug 1 — album-scoped library check false-misses owned albums
`core/downloads/master.py:184` scored "Shy Away (MTV Unplugged Live)"
(source title from playlist) vs "Shy Away" (local DB stored title)
with raw string similarity. Massive length asymmetry → ~0.3 → below
the 0.7 threshold → marked missing. Combined with the
`allow_duplicates and batch_is_album` short-circuit that disables
the global fallback for album downloads, the user's already-owned
album re-triggered every track for download. Explains the screenshot
showing "0 found / 7 missing" on an album the user manually placed.
New pure helper `core/matching/album_context_title.py:strip_redundant_album_suffix`
strips trailing parenthetical / bracket / dash suffixes whose tokens
are fully subsumed by the album context — at least one version
marker (live / unplugged / acoustic / session / concert / tour)
overlapping with the album, and every other token is either a
known marker, a year, a tolerated noise word, or a word from the
album title. Album-context-implied "live" added when the album
mentions unplugged / concert / tour / session.
Wired into the album-confirmed scope ONLY (not global matching).
Compares both raw and normalized source titles per album track and
takes the max similarity, so the helper returning the input
unchanged (when album doesn't imply version context) preserves
the pre-fix behavior.
Bug 2 — Tidal qualifier filter only ran on fallback searches
`core/tidal_download_client.py:345` set `is_fallback = attempt_idx > 0`
and only filtered when `is_fallback and required_qualifiers`. Primary
search returned all results unfiltered, so a query for "Shy Away
(MTV Unplugged Live)" could accept the studio cut if Tidal happened
to rank it first. Now the qualifier filter applies to BOTH primary
and fallback search attempts — log message updated to indicate
which path triggered.
Bug 3 — qualifier check ignored album.name
The legacy `_track_name_contains_qualifiers` only inspected the
track name. For concert / unplugged releases the live signal
typically lives in the album title, not the track title. New
`_track_matches_qualifiers` accepts a track object and inspects
both `track.name` AND `track.album.name`. Legacy helper preserved
to keep its existing test contract.
AcoustID version-mismatch gate at core/acoustid_verification.py
left intact — it correctly catches genuinely-wrong files that slip
through upstream filters. The In My Feelings (Instrumental) test
that pins this behavior continues to pass.
19 tests on the album-context helper covering MTV Unplugged
variants, dash/parens/brackets suffix shapes, year tolerance,
plural-form markers, the implied-live set, anti-regression cases
(instrumental/remix on a studio album must NOT be stripped),
empty/none defensive paths.
13 tests on the Tidal qualifier helper covering legacy
track-name-only behavior preserved, qualifier in track name alone,
qualifier in album name alone (the MTV Unplugged scenario),
multi-qualifier requirements, no-qualifiers always passes,
defensive against missing track.album, word-boundary avoiding
substring false-matches, _extract_qualifiers picking up live +
unplugged from the user's exact reporter query.
Full suite: 3053 passed.
Closes#588. Contributing-artist tagging worked for some tracks but
silently dropped them for others — most reproducibly when the album
had been fetched before the per-track post-process ran.
Trace: get_track_details cache check used `track_position in cached`
as the "full payload" sentinel. Both `/track/<id>` AND
`/album/<id>/tracks` set track_position. Only `/track/<id>` sets the
`contributors` array. When album-tracks data hit the cache first,
get_track_details returned the partial record →
_build_enhanced_track found no contributors → metadata-source
contributors-upgrade silently fell back to single-artist.
Reporter's case (Andrea Botez - Sacrifice): the album fetch logged
"Retrieved 4 tracks for album 673558211" before the post-process,
which cached all 4 tracks as partial records. The contributors-
upgrade then hit the partial cache and the upgrade log line never
fired because len(upgraded) was never > 1.
Lifted cache-validity to a pure helper `_is_full_track_payload` that
requires BOTH `track_position` AND `contributors` key presence. Empty
list `[]` is valid — single-artist tracks fetched via `/track/<id>`
carry it explicitly. Partial cache hits fall through to a fresh
`/track/<id>` fetch, which writes the full payload back to cache.
11 boundary tests pin every shape: full payload, single-artist with
empty contributors list, partial album-tracks shape, search-result
shape, none/non-dict, and the cache-hit/cache-miss/api-failure paths
on get_track_details (including the exact reporter-scenario
regression).
Full suite: 3021 passed.
Closes#585. When a Spotify source track had a versioned suffix not
present in the local file ("Iron Man - 2012 - Remaster" vs "Iron Man"),
the auto-matcher missed the pair. User could click Find & Add to pick
the right local file — that worked, file got added to the Plex
playlist — but the source track stayed in Missing while the added
file appeared in Extra, because the matcher kept no record of the
user-confirmed pairing. On the next sync the source track re-tried
to download.
Fix: every Find & Add selection now writes a (spotify_track_id →
server_track_id) override into sync_match_cache at confidence=1.0.
The matching algorithm runs an override pass BEFORE the existing
exact and fuzzy passes, so any user-confirmed pair short-circuits
straight to "matched" without going through title normalization.
Covers every mismatch class — dash-suffix remasters, covers /
karaoke, alt masters, cross-language titles, typo'd local files.
- core/sync/match_overrides.py (new) — pure helpers
resolve_match_overrides + record_manual_match. 18 boundary tests
pin: cache hits, cache misses falling through to normal matching,
stale-cache (server track removed) handled gracefully, str/int
id coercion, partial cache hits, defensive against non-dict
inputs and DB exceptions.
- web_server.py — get_server_playlist_tracks runs the override
pre-pass before exact/fuzzy matching. server_playlist_add_track
accepts source_track_id + source_title + source_artist and
persists the override after every successful add (Plex / Jellyfin
/ Navidrome). source_track_id added to source_tracks payload so
the frontend has it.
- webui/static/pages-extra.js — _serverSelectTrack sends
source_track_id + source_title + source_artist when adding a
track from a mirrored playlist context.
- Sync match cache schema unchanged — already had UNIQUE
(spotify_track_id, server_source) which fits the override
semantics perfectly. Manual overrides distinguished from
auto-discovered matches by confidence=1.0.
Full suite: 3010 passed.
Standalone Quarantine button + modal felt out of place — duplicated
the chrome of the existing Library History modal but with worse
styling and behavior. Folded the quarantine list into the existing
modal as a third tab next to Downloads + Server Imports.
UI changes:
- Removed the standalone Quarantine button on the Downloads page
header and the standalone modal HTML
- Added third tab to library-history-tabs with a count badge
- loadLibraryHistory dispatches to loadQuarantineList when the
quarantine tab is active
- Quarantine entries render as library-history-entry cards using
the exact same class chrome as Downloads + Imports (thumb
placeholder, title + meta, badge, relative time via
formatHistoryTime, expandable details panel)
- Per-row actions styled as lh-audit-btn to match the existing
Audit button look
- Approve / Recover / Delete now use the themed showConfirmDialog
+ showToast — no more native browser alert / confirm
Backend endpoints + pure helpers + tests unchanged from f4cff78f.
WHATS_NEW entry rewritten to reflect the actual final UX.
Closes#584. Quarantined files used to sit in ss_quarantine/ with a
thin sidecar — no UI, no recovery, no way to see what got dropped.
This adds the management surface the user needs without going to the
filesystem.
UI: new "Quarantine" button on the downloads page header opens a
modal with every quarantined file (filename, expected track/artist,
reason, when, size). Three actions per row:
- Approve (one-click): restores the file, re-runs the post-process
pipeline with ONLY the failing check skipped, lands in the library
with full tags + lyrics + scan
- Recover (legacy fallback): moves to Staging for thin-sidecar
entries that lack the embedded context Approve needs
- Delete: permanent removal of file + sidecar
Per-check bypass: context['_skip_quarantine_check'] = 'integrity' /
'acoustid' / 'bit_depth'. Skips ONLY the named check — other quality
gates stay live. No blanket bypass-all flag.
Sidecar expansion: move_to_quarantine now persists the full
json-serializable context via serialize_quarantine_context (drops
non-JSON-safe values, walks nested dicts/lists/sets, str-coerces
unknown objects) plus the trigger name. Existing thin sidecars are
detected and routed to Recover instead of Approve.
Pure helpers in core/imports/quarantine.py: list_quarantine_entries
/ delete_quarantine_entry / approve_quarantine_entry /
recover_to_staging / serialize_quarantine_context. 27 tests pin
every shape: orphan files / orphan sidecars / corrupt sidecars /
collision-safe filename restoration / full-context vs thin-sidecar
dispatch / json round-trip safety.
Four new endpoints in web_server.py — thin glue around the helpers:
GET /api/quarantine/list, DELETE /api/quarantine/<id>,
POST /api/quarantine/<id>/approve, POST /api/quarantine/<id>/recover.
Download modal status differentiates "🛡️ Quarantined" from
"❌ Failed" so recoverable files are visible at a glance — checked
against the error_message text, no schema change needed.
Pipeline changes are three minimal per-check conditionals at the
existing quarantine sites in core/imports/pipeline.py. Each
move_to_quarantine call now passes its trigger name so the sidecar
records which check fired.
Full suite: 2992 passed.
Previously hardcoded at 3s (5s for tracks >10min) — files drifting
past that got quarantined with no user override. Live recordings,
alternate masterings, and some legitimate uploads routinely drift
further.
New setting `post_processing.duration_tolerance_seconds`. Default 0
means "use auto-scaled defaults" (unchanged behavior for users who
don't touch it). Positive value overrides the per-track defaults.
Capped at 60s — past that the check is effectively off.
Logic lifted to pure helper `resolve_duration_tolerance` in
file_integrity.py. Coerces every plausible input (None / empty /
zero / negative / unparseable / above-cap / numeric string / float)
to either a float override or None for auto. 12 tests pin every
shape.
Wired into `core/imports/pipeline.py` at the integrity-check call
site — runs for ALL matched downloads (Soulseek / Tidal / Qobuz /
HiFi / YouTube / Deezer-direct) since they all share that pipeline.
Settings UI input under Settings → Metadata → Post-Processing.
Soulseek matched-download contexts populate `original_search_result`
with `artist` (singular string) and no `artists` list — the full
multi-artist array lives on `track_info` (the matched Spotify track
object). `extract_source_metadata` only read `original_search.artists`,
so the Soulseek path always fell through to the single-artist branch
and TPE1 ended up with the primary artist only. Deezer-direct
downloads were unaffected because their context populates
`original_search.artists` as a proper list.
Lifted artist resolution into a pure helper
`core/metadata/artist_resolution.py:resolve_track_artists` that walks
`original_search.artists` → `track_info.artists` → `artist_dict.name`
fallback chain. Normalizes mixed list-item shapes (Spotify-style
dicts, bare strings, anything else stringified) and drops empty
entries.
13 new tests pin the resolution order, fallback chain, mixed-shape
normalization, whitespace stripping, and empty/none handling. The
existing `_artists_list` no-fall-through test in
`test_multi_artist_tag_settings.py` was updated to reflect the new
contract (always populated; multi-value write still gated on
`len > 1`) plus a new regression test for the Soulseek shape.
Composes with the existing Deezer per-track upgrade (still fires when
single-artist + track_id available) and feat_in_title /
artist_separator settings (still drive the joined ARTIST string
downstream).
Enhanced search + global search popover always opened with the
Spotify icon active even when the user's primary metadata source
was Deezer / iTunes / Discogs / etc.
Trace: shared-helpers.js createSearchController reads
/status.metadata_source to pick the initial active icon, then
gates with SOURCE_LABELS[src]. Backend returns metadata_source
as a dict ({source, connected, response_time, ...}) — used
elsewhere for connection-state display — so SOURCE_LABELS[<dict>]
was always undefined, the guard never fired, and activeSource
silently stayed at the hardcoded 'spotify' default.
Fix reads .source off the dict (with fallback to plain-string for
forward compat). Other consumers already used ?.source — this was
the only stale call site.
Audit-trail PR added two buttons to the Downloads page — one always
visible next to the 'Batches' panel title, one inside the collapsible
'Recent History' header. User wants only the Recent History one.
Removes the panel-header button + the unused
.adl-batch-panel-header-actions style. Recent History button +
the original Dashboard button remain.
Discord report: prolific artists (Bach, Beatles complete box,
deep dance/electronic catalogues) only showed ~50 entries in the
"Download Discography" modal.
`MetadataLookupOptions(limit=50, max_pages=0)` was hardcoded at
three call sites. Spotify's `max_pages=0` already paginates
through everything (per-page is clamped to 10 internally), so
Spotify-primary users were unaffected. But Deezer / iTunes /
Discogs / Hydrabase all honor the outer `limit` as a hard cap,
so non-Spotify users were silently clipped.
Bump `limit` to 200 at all three call sites — matches iTunes's
and Discogs's own internal caps and covers near-everyone's full
catalogue. Spotify behavior unchanged.
- web_server.py:9221 — discography endpoint (modal)
- web_server.py:8700 — artist-detail discography view
- core/artist_source_detail.py:129 — source-specific artist detail
Keep the page chrome sync helpers in shell-bridge.js so React and legacy routing share one implementation.
This preserves the sidebar breadcrumb and discover download bar behavior without shadowing the legacy shell helpers in init.js.
- keep getCurrentPageId off the legacy shell bridge surface
- leave page-id lookup on the router side where it is actually used
- align the bridge tests and type definitions with the slimmer API
- Route Issues to the React host even while the shell is still booting
- Ignore stale bootstrap work when navigation changes mid-load
- Clear artist-detail state when leaving the page so browser back can reach Library
- Add smoke coverage for the artist-detail back-navigation path
- Re-sync the active shell page on popstate
- Keep React routes like /issues on the React host after back/forward navigation
- Preserve the existing legacy page activation path for non-React routes
- Expose SoulSyncWorkflowActions from the shell bridge
- Route album download and wishlist actions to the legacy modal helpers
- Fall back to showToast for workflow notifications
- Unblock the issue modal download button by wiring the real host contract
- Move issue detail selection into route search so the modal is deep-linkable and back-button friendly.
- Normalize issue category and detail params before they reach the loader.
- Keep the legacy shell URL in sync for React-owned home pages.
- Preserve the legacy issues-tour hooks on the React issues page.
- Add Escape handling, focus trapping, and focus restore to the issue detail modal.
- Add route and helper coverage for the new search-state behavior.
- Delete the static issues page renderer and detail modal helpers
- Keep the React issues route as the only implementation
- Drop the dead mobile CSS and troubleshooter hook that only targeted the removed shell
Keep React-owned pages out of the legacy page activator during initial bootstrap, and switch the visible React host before paint when the shell mounts.
That removes the refresh flash on /issues while preserving the legacy-page behavior and browser-history stability.
Verified with the router tests and the issues smoke suite.
- re-render the React shell when legacy profile bootstrap selects or refreshes a profile
- keep the initial page fallback so direct loads still activate the legacy shell chrome
- preserve the smoke coverage for direct loads and browser history
- normalize old downloads and artists page ids back to search
- keep home-page and access checks aligned with the current route ids
- let profile edit forms save modern ids while still reading old rows
- reuse the create-form page controls when rendering edit forms
- preserve existing home_page and allowed_pages IDs that the old whitelist hid
- keep mandatory pages checked so saves do not drop them
- Mount a React-owned issue domain host and bridge report issue actions through it
- Add typed issue creation helpers, report payload types, and shared album workflow launchers
- Expand issue detail UI parity with metadata, links, track details, and admin actions
- Remove legacy static issue modal/list/detail code and update tests for the React bridge
- File-based routing with tanstack router
- Persist top-level navigation state in url, even for most legacy pages
- Striving for an intuitive and simple folder structure where
route-related code is colocated, but the amount of files is still
kept to a minimum
- Replace native fetch with `ky`
- Familiar api, but more polished
Closes#572 (rhwc).
Navidrome has no API for setting an artist image — it reads
`artist.jpg` (or `folder.jpg`) from the artist folder during
library scans. SoulSync's `update_artist_poster` for Navidrome
was a no-op, so users only ever saw album-art-derived thumbnails
as the artist photo.
- new "Write Artist Image" button on artist detail page
- POST /api/artist/<id>/write-image-to-disk derives the artist
folder from any track's resolved file_path (reuses
_resolve_library_file_path so docker mount translation +
library.music_paths probes from #558 apply), fetches the photo
from the configured metadata source priority chain, downloads
with content-type validation, writes atomically via
`<filename>.tmp + os.replace`
- when active server is Navidrome, triggers a library scan
immediately so the file is picked up
- respects existing artist.jpg (frontend prompts 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 sep / empty / non-string), URL
picking (missing attr / whitespace / non-string), download
(non-image content-type / 404 / timeout / empty body), atomic
write (replace / temp-cleanup-on-failure / overwrite guard /
missing folder).
- new "Audit" button on each download row in the library history
modal opens a second modal visualizing the download lifecycle as
an interactive horizontal stepper (request → source → match →
verify → process → place) with click-to-expand detail cards
- hero header with album art + track title + meta line + status
pills (source / quality / acoustid result)
- three tabs: Lifecycle / Tags / Lyrics
- Tags tab reads the audio file live via mutagen at audit-open
time via new GET /api/library/history/<id>/file-tags endpoint;
file is the single source of truth so background enrichment
writes (audiodb / lastfm / genius / replaygain / lyrics fetch)
show up too. flat key/value rows stacked vertically (label-above-
value) so long MBIDs / URLs / joined genre lists wrap cleanly.
source IDs grouped per-service into 2-col sub-card grid.
- Lyrics tab renders the full transcript with dimmed timecodes.
- post-processing step infers observable changes from source-vs-
final state (format conversion, file rename via tag template,
folder template).
- "Download History" button also added to the Downloads page batch
panel header so it's reachable outside the dashboard.
- mobile responsive: tabs + stepper scroll horizontally, modal
goes full-screen, hero stacks below 480px.
19 helper tests pin the mutagen reader: id3 (TIT2/TPE1/TALB + TXXX
+ USLT + APIC), vorbis (FLAC dict + _id/_url passthrough), file
metadata (format / bitrate / duration), defensive paths (empty /
missing file / mutagen returns None / mutagen raises), stringify
edge cases (list / tuple / int / frame-with-text / whitespace).
- legacy duck-typed builder only checked the `album_type` key; deezer
uses `record_type`, tidal uses `type` (uppercase), some flattened
musicbrainz shapes use `primary-type` — all defaulted to album, so
EPs and singles ended up filed under Album/ in user templates that
reference $albumtype
- widen lookup to album_type / record_type / type / primary-type and
route through new pure `_normalize_album_type` helper that
case-folds + validates against the canonical token set
(album / single / ep / compilation), unknown → album
- typed-converter path (spotify / deezer / itunes / discogs / mb /
hydrabase / qobuz) unchanged — those were already correct
Discord report (CAL).
Adds an explicit field to the Album dataclass in core/metadata/types.py
and the client-level Album dataclasses in deezer_client.py,
itunes_client.py, and hydrabase_client.py (the legacy discography path
reads from client objects, not typed dicts).
Deezer extracts explicit_lyrics (int→bool), iTunes extracts
collectionExplicitness ('explicit' string), Hydrabase forwards the
explicit field from the server response. Spotify, Discogs, MusicBrainz,
Qobuz, and Tidal have no explicit signal and stay None.
The flag threads through both builder functions in discography.py and
renders as a small "E" badge next to explicit titles in the discography
download modal and artist-detail page cards.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Card titles in the discography modal now display their full text
across multiple lines rather than being cut off with an ellipsis.
Artwork and the selection checkbox are pinned to the top of the card
so they align with the first line of text when titles wrap.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- new soulseek.search_min_delay_seconds knob forces a gap between
consecutive searches; smooths the burst pattern that trips ISP
anti-abuse (Reddit report: Bell Canada cuts the WAN after rapid
peer-connection spikes) even when the existing 35/220 sliding-window
cap isn't hit
- throttle math lifted to a pure compute_search_wait_seconds helper so
the gate logic is testable independent of asyncio.sleep + the
singleton client
- new field on settings → connections → soulseek; default 0 = disabled
so existing users see no change
15 helper-boundary tests pin defaults / no-throttle, sliding-window
cap (legacy), min-delay (the new burst-smoother), max-of-both gates,
and defensive paths.
- music_source / spotify_connected / spotify_rate_limited were reading
a non-existent 'spotify' key on _status_cache and silently falling
through to the missing-value default (always 'unknown' / False).
Routed through the canonical accessors get_primary_source +
get_spotify_status now.
- added hydrabase_connected, youtube_available, hifi_instance_count,
and always_available_metadata_sources so the debug dump reflects
the full service surface
- removed a local re-import of get_spotify_status that was making
python 3.12 treat the name as function-scoped, breaking the new
lambda above it (NameError on free variable) — module-level import
already exists
11 endpoint-level tests pin music_source / spotify_* / hydrabase_* /
youtube_available / always_available_metadata_sources / hifi_instance_count
and the defensive fall-through paths when each lookup raises.
- new track_already_owned helper wraps db.check_track_exists at
the same confidence threshold the discography backfill repair job
uses (0.7) — name+artist+album, format-agnostic so blasphemy-mode
libraries (flac → mp3 + delete original) match correctly
- endpoint runs the check after the artist + content-type filters and
before add_to_wishlist, so a second discography click on the same
artist no longer re-queues every track that already downloaded
- per-album response carries a new tracks_skipped_owned counter
alongside the existing artist/content/wishlist skip categories
Discord report (Skowl).
- drop tracks where the requested artist isn't named in track.artists
(keeps features, drops compilation / appears_on contamination)
- honor watchlist.global_include_live/remixes/acoustic/instrumentals
the same way the discography backfill repair job already does
- surface per-album skip counts in the ndjson stream (artist mismatch
+ content filter) so the ui can show what was filtered
Closes#559.
GitHub issue #558: clicking Auto-Fill / Fix Selected on the Album
Completeness findings page returned a flat "Could not determine album
folder from existing tracks" error with no diagnostic. Reporter is on
Navidrome on Docker — the path resolver in
`core/library/path_resolver.py` couldn't find any of the album's tracks
on disk because Navidrome's Subsonic API doesn't expose filesystem
library paths the way Plex's API does (probed in #476). Default
settings → `library.music_paths` empty → no base directories to probe →
silent None. User had no signal about what to configure.
Not a regression of #476 — that fix targeted Plex auto-discovery and
worked correctly for it. Navidrome was never covered because the
protocol gives the resolver nothing to probe.
Fix scoped to the diagnostic surface, not auto-magic discovery:
- Added `resolve_library_file_path_with_diagnostic` returning
`(resolved, ResolveAttempt)`. ResolveAttempt records what the resolver
tried — `raw_path_existed`, `base_dirs_tried`, `had_config_manager`,
`had_plex_client`. Pure data, no rendering opinions.
- Legacy `resolve_library_file_path` becomes a thin wrapper that
drops the attempt; every existing call site is unchanged.
- `RepairWorker._fix_incomplete_album` now uses the diagnostic helper
and renders a multi-part error via `_build_unresolvable_album_folder_error`:
names the active media server, shows one sample DB-recorded path,
lists every base directory the resolver actually probed, and points
the user at Settings → Library → Music Paths as the actionable fix.
- Distinguishes empty-base-dirs vs tried-and-failed cases so the user
knows whether to add a mount or fix the existing one.
- No auto-probing of common Docker conventions (`/music`, `/media`, etc).
Speculative — could resolve to wrong dirs on the suffix-walk if a
conventional path happens to contain a partial collision. User stays
in control.
12 new tests:
- 7 in `tests/library/test_path_resolver.py`: tuple-shape contract,
raw-path-existed short-circuit, base-dirs listed even on walk
failure, had-flags reflect caller inputs, no-base-dirs returns
None with empty attempt, legacy `resolve_library_file_path`
delegates correctly across happy / suffix-walk / failure paths.
- 8 in `tests/test_repair_worker_unresolvable_folder_error.py`:
active server name in error, sample DB path verbatim, base dirs
listed, empty-base-dirs phrased differently, Settings hint always
present, defensive against None attempt / missing sample / missing
config_manager.
Full pytest sweep: 2774 passed.
Reported: Clear History button on the Import page left zombie rows
behind. Every survivor showed "⧗ Processing" status from 2-9 days ago.
Trace: `_record_in_progress` inserts a `status='processing'` row up-front
so the UI can render the in-flight import while it runs; `_finalize_result`
updates it to `completed`/`failed` when the import finishes. When the
worker is killed mid-import (server restart, crash), the row never gets
finalized — stays at `processing` forever. The clear-history endpoint's
SQL `DELETE ... WHERE status IN (...)` listed every terminal status but
omitted `processing`, so zombies survived every click.
Fix: add `processing` to the delete list, but guard against nuking
genuinely-live imports by intersecting against the worker's
`_snapshot_active()` map — any folder hash currently registered in
`_active_imports` is excluded from the delete via an `AND folder_hash
NOT IN (...)` clause. `pending_review` deliberately left out so user
still has to approve/reject those explicitly.
One endpoint touched (`/api/auto-import/clear-completed` in
web_server.py). No worker changes — guard reuses the existing
`_snapshot_active()` method that the UI poller already calls.
5 new tests in `tests/imports/test_auto_import_clear_completed_endpoint.py`:
- Zombie `processing` rows swept, live `processing` row preserved
(folder_hash currently in `_active_imports` survives)
- Response count matches actual delete count
- Empty active-set branch (unparameterized DELETE) — pinned because
an empty SQL `IN ()` would be a syntax error
- Worker-unavailable returns 500 (pre-existing guard not regressed)
- `pending_review` rows always survive — never auto-swept
Full pytest sweep: 2758 passed (one pre-existing flaky timing test
on `test_import_singles_parallel.py` failed under full-suite CPU load,
passes in isolation in 2.95s — unrelated to this change).
Discord report: 16 Bandcamp indie albums sat in staging because
auto-import couldn't identify them, but the manual search bar at the
bottom of the Import Music tab found the same albums fine. Trace:
`_search_metadata_source` only queried `get_primary_source()` — single
source, no fallback. Meanwhile `search_import_albums` (manual search bar)
already iterated `get_source_priority(get_primary_source())` and broke
on the first source with results. Asymmetric behavior, same album: manual
worked, auto-import didn't.
Fix: lift `_search_metadata_source` to use the same source-chain pattern.
Try primary first; if it returns nothing OR scores below the 0.4
threshold, fall through to the next source in priority order. First
source producing a strong-enough match wins. Result dict carries the
`source` that actually matched (not the primary name) so downstream
`_match_tracks` calls the right client. Defensive per-source try/except
so a rate-limited or auth-failed source doesn't abort the chain.
Unconfigured sources (client=None) silently skipped.
Cin-shape lift: scoring math extracted to pure `_score_album_search_result`
helper so the weight tweaks (album 50% / artist 20% / track-count 30%)
are pinned at the function boundary, independent of the orchestrator
(per-source iteration, exception containment, threshold check). Weight
constants exposed at module level (`_ALBUM_NAME_WEIGHT`,
`_ARTIST_NAME_WEIGHT`, `_TRACK_COUNT_WEIGHT`) — greppable, bumpable in
one place. Pre-extraction these were magic numbers inline.
27 new tests:
- 9 integration tests in `test_auto_import_multi_source_fallback.py`:
primary-success path unchanged (no fallback fires, only primary client
called), primary-empty falls through, primary-weak-score falls through,
first fallback success stops the chain (no wasted API calls on
remaining sources), all-sources-fail returns None, per-source
exception contained, unconfigured-source skipped, result `source`
field reflects winning source, `identification_confidence` from
winning source.
- 18 helper tests in `test_album_search_scoring.py`: weights sum to
1.0, album weight dominant (invariant pin), perfect-match returns 1.0,
per-component contribution (album / artist / track-count), Bandcamp
vs streaming track-count mismatch (7-files vs 4-tracks case still
scores ~0.87 above threshold), zero-track-count and zero-file
guards, huge-mismatch non-negative guard, list-of-strings artist
shape, missing `.name` / `.artists` / `None` total_tracks edge cases.
Backwards compatible: single-source users see no change — chain just
has one entry. Existing test `test_search_metadata_source_extracts_artist_id_from_dict_artist`
needed one extra patch line for `get_source_priority`.
Full pytest sweep: 2754 passed.
Two follow-ups to the multi-artist tag settings PR:
1. Deezer contributors upgrade — closes the "known limitation"
flagged in the prior commit. Deezer's `/search` endpoint only
returns the primary artist for each track; the full contributors
array (feat., remix collaborators, producers credited as artists)
lives on `/track/<id>` and gets parsed by `_build_enhanced_track`.
Without the upgrade Deezer-sourced tracks never got multi-artist
tags even with the right settings on.
Fix in `core/metadata/source.py`: when source==deezer AND the
search response had a single artist AND a track_id is available,
fetch full track details via `get_deezer_client().get_track_details`
and replace `all_artists` with the upgraded list.
- One extra API call per affected Deezer track
- Skipped when search already returned multiple (no-op fast path)
- Skipped for non-Deezer sources (Spotify/Tidal/iTunes search
responses already include all artists)
- Skipped when no track_id is available
- Defensive try/except: on /track/<id> failure (network error,
deezer client unavailable), fall through to the search-result
list — never lose the data we already had
2. Double-append guard hardened with a word-boundary regex.
Prior commit checked for `"feat." not in title.lower() and "(ft."
not in title.lower()` — too narrow. Source platforms produce
wildly different feat-marker conventions: "(feat. X)", "(Feat X)",
"(FEAT X)", "(Featuring X)", "[feat. X]", "ft. X" (no parens),
"FT. X", etc. Any of these as the SOURCE title would cause a
double-append: `"Track (Feat X) (feat. Y)"`.
Replaced with `re.search(r'\b(?:feat|feat\.|featuring|ft|ft\.)\b',
title, IGNORECASE)`. Word-boundary regex catches every common
variant. Substring matches like "Aftermath" containing `ft`
correctly fall through to the append path (pinned by a regression
test).
16 new tests (29 total in the file):
- 9 parametrized variants of the double-append guard
- 1 substring guard ("Aftermath")
- 6 Deezer upgrade scenarios (fires when expected, doesn't fire
for non-Deezer / multi-artist search / no track_id, defensive
fall-through on failure, no false-positive when /track/<id>
confirms single artist)
Full pytest 2727 passed.
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.
Track enrichment was stuck in a constant retry loop. Logs showed
nothing but `Read timed out. (read timeout=10)` from
`lookup_track_by_id` repeating against the same track ID. AudioDB
itself was being hammered nonstop with no progress.
Cause: when an entity already has `audiodb_id` populated (from a
manual match or earlier scan) but `audiodb_match_status` is still
NULL — an inconsistent state some import paths can leave behind —
the worker tries a direct ID lookup. If that lookup fails (returns
None on timeout, which AudioDB's `track.php` endpoint hits
frequently because it's slow), the prior code logged "preserving
manual match" and returned WITHOUT marking status. Row stayed NULL
→ queue's NULL-status filter picked it up next tick → tried direct
lookup → timed out → returned → infinite loop.
The "preserve manual match" intent was correct: don't fall through
to the name-search path because that could overwrite a manually-set
`audiodb_id` with a wrong guess. Bug was the missing `_mark_status`
call before the early return.
Fix:
* `_process_item` direct-lookup-failure branch now calls
`_mark_status(item_type, item_id, 'error')` before returning. The
existing `audiodb_id` is preserved (column not touched). Queue's
NULL-status filter no longer re-picks the row.
* `_get_next_item` retry-cutoff queue priorities (4/5/6) extended
from `audiodb_match_status = 'not_found'` to
`audiodb_match_status IN ('not_found', 'error')`. Same `retry_days`
window. Transient AudioDB outages still recover automatically;
permanently-broken IDs eventually get re-attempted once a month
rather than staying errored forever.
5 new tests in `tests/test_audiodb_worker_stuck_track.py` use a real
SQLite DB (not mocks) so the SQL queries are actually exercised:
- lookup-returns-None marks status='error' (no infinite loop)
- lookup-raises-exception marks status='error' (defensive)
- lookup-success preserves the existing match-success path
- error-status row past retry-cutoff gets picked up again
- error-status row within cutoff stays skipped (loop prevention
works)
Only triggers for entities in the inconsistent `audiodb_id` set +
`match_status` NULL state. Happy path and already-matched /
already-not-found rows unchanged. Full pytest 2698 passed.
Closes#553.
Discord report: container refused to start after pulling latest.
Logs showed `mkdir: cannot create directory '/app/Staging':
Permission denied`. `set -e` in entrypoint.sh then aborted the script
and the container restart-looped.
Root cause traced to commit 70e1750 (2026-05-08, image-bloat fix):
the Dockerfile chown was changed from `chown -R /app` to a scoped
chown on specific subdirs to avoid a redundant layer that was
duplicating the entire /app tree. Side effects:
1. `/app` itself went from soulsync:soulsync (via the recursive walk)
to root:root (Docker WORKDIR default — never re-chowned).
2. `/app/Staging` was the only runtime mount-point dir NOT pre-baked
into the image — every other bind-mountable dir (config, logs,
downloads, Transfer, MusicVideos, scripts) was in the Dockerfile's
`mkdir -p` + `chown` list. Staging was left to the entrypoint.
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.
Fix has three parts:
1. **Dockerfile** — added `/app/Staging` to the runtime mkdir +
scoped chown list. Closes the asymmetry with the other bind-
mountable dirs. Image now ships with the directory pre-baked
owned soulsync:soulsync so the entrypoint mkdir is a guaranteed
no-op even when bind-mount perms are weird.
2. **entrypoint.sh mkdir + chown** — both now have `|| true` so any
future bind-mount permission quirk surfaces as a log line, not
a `set -e` crash + restart loop. Previously only the chown had
the `|| true` suffix; mkdir was bare.
3. **entrypoint.sh writability audit** — new loop at the end of
the setup phase runs `gosu soulsync test -w "$dir"` against
every bind-mountable dir. When a dir isn't writable by the
soulsync user, logs a loud warning with the exact host-side
`chown` command needed to fix it. 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). This is the diagnostic that
would have surfaced the root cause without needing the user to
share a container-restart screenshot.
Zero behavior change for users whose containers were already
starting fine. Defensive against the rootless/podman config that
broke after the image-bloat refactor.
Verified shell syntax with `bash -n entrypoint.sh`. Full pytest
2693 passed (no Python touched).
Two-part fix to the Your Albums "Download Missing" flow on Discover.
Part A — UX redesign
The prior `downloadMissingYourAlbums()` ran a per-album loop that
fired direct-download tasks via `openDownloadMissingModalForYouTube`.
Reported as silently failing — "Queuing 2/2" toast with no actual
transfer activity. Even when downloads worked, bypassing the
wishlist meant no retry / dedup / rate-limit / source-fallback
handling.
Replaced with a selectable-grid modal mirroring the Download
Discography pattern from the library page. Click the download
button → opens a checkbox grid showing every missing album (cover,
title, artist, year, track count, source) → user picks what they
actually want → click "Add to Wishlist" → each album's tracks get
resolved + queued through the existing wishlist auto-download
processor. NDJSON progress stream renders ✓/✗ per album.
New JS helpers:
- `_openYourAlbumsBatchModal(missingAlbums)` — builds the modal
- `_renderYourAlbumsBatchCard(row, index)` — per-album card
- `_yourAlbumsBatchSelectAll(select)` — bulk toggle
- `_updateYourAlbumsBatchFooterCount()` — live count + button text
- `_closeYourAlbumsBatchModal()` — overlay teardown
- `_startYourAlbumsBatchAddToWishlist()` — submit handler, NDJSON
progress consumer
- `_yourAlbumsPickSource(album)` — picks the single best source-id
per row (priority: spotify → deezer → tidal → discogs)
Reuses the `.discog-*` CSS classes from the library Download
Discography modal — no new CSS. Reuses the existing
`/api/artist/<id>/download-discography` endpoint. The endpoint's URL
artist_id param is functionally unused (per-album payload carries
everything — verified by reading the endpoint body), so the modal
posts with placeholder `your-albums` and gets multi-artist
resolution for free without backend changes.
Part B — Tidal album resolution
Reported as the original bug: clicking download on Tidal-only albums
did nothing because `/api/discover/album/<source>/<album_id>` had no
`tidal` branch and `tidal_client` had no `get_album_tracks` method.
`core/tidal_client.py`: new `get_album_tracks(album_id, limit=None)`
method. Two-phase: cursor-walk
`/v2/albums/<id>/relationships/items?include=items` for track refs +
position metadata (`meta.trackNumber` + `meta.volumeNumber`),
batch-hydrate via existing `_get_tracks_batch` for artist/album
names. Returns `Track` objects with `track_number` and `disc_number`
attached. Sort by (disc, track) so multi-disc compilations render in
album order.
`web_server.py`: new `'tidal'` source branch in
`/api/discover/album/<source>/<album_id>`. Resolves album metadata
via `get_album`, tracks via `get_album_tracks`, cover art via inline
`?include=coverArt` lookup. Same response shape as Spotify/Deezer
branches.
`webui/static/discover.js`:
- `tidal_album_id` added to `trySources` for the single-album click
flow (`openYourAlbumDownload`)
- Same source picker drives the new batch modal
- Virtual-id generation includes `tidal_album_id` so Tidal-only
albums get stable identifiers across discover-album-* / your-
albums-* contexts
10 new tests in `tests/test_tidal_album_tracks.py` pin:
- Single-page walk + hydration
- Multi-page cursor chain
- Multi-disc sort order (disc 1 → 2 in track order each)
- `limit` short-circuit at page boundary
- No-token short-circuit (no API call)
- HTTP error returns empty
- 429 raises (propagates to `rate_limited` decorator for retry)
- Forward-compat type filter (skips non-track entries)
- Partial-batch hydration failure containment
- Empty-album short-circuit (no batch call)
Full pytest: 2693 passed.
Follow-up to the prior compilation-album scanner fix. That patch
made the scanner read `tracks.track_artist` (per-track artist
column) via COALESCE so compilation tracks would compare against
the right value. But tracks downloaded BEFORE the `track_artist`
column existed have track_artist=NULL — COALESCE falls back to
album artist (the curator) and the wrong-comparison case returns.
Fix: explicit 3-tier resolution in `_scan_file`:
1. DB `tracks.track_artist` if populated → trust it. Respects
manual edits from the enhanced library view (user who curated
the DB value but didn't re-tag the file gets their edit
respected, not overridden by stale file tag).
2. File's ARTIST tag via mutagen if present → use it. Tidal /
Spotify / Deezer all write the per-track artist into the
audio file at download time regardless of SoulSync's DB
schema, so it's ground truth even when the DB column is
stale or NULL. File is already open for fingerprinting so
mutagen tag-read is essentially free.
3. Album artist → final fallback for files without proper ARTIST
tags AND no DB track_artist. Existing pre-fix behavior.
`_load_db_tracks` SELECT now surfaces `track_artist` (raw, may be
empty/NULL via NULLIF) and `album_artist` separately in addition
to the COALESCE'd `artist` field — so `_scan_file` can tell the
difference between 'DB has a curated value' and 'DB fell back to
album artist'. Without this distinction, the file-tag fallback
would create false positives when DB is curated but file is stale.
5 new tests (11 total in the file) pin:
- File-tag-trumps-DB resolves the legacy NULL case (DB says
'Andromedik' (album curator), file says 'Eclypse', AcoustID
says 'Eclypse' → no flag)
- Tag-missing falls back to album artist (preserves existing
genuine-mismatch contract — file without tag + AcoustID
mismatch still flags)
- Mutagen exception swallowed (debug log, fall-through)
- File-tag matches DB → no behavioral change
- DB curated value trumps stale file tag (false-positive guard
— user edited DB without re-tagging file shouldn't get flagged)
Two existing test fixtures (`_make_context` callers) updated to
the new 10-column row shape.
SQL behavior verified empirically against real SQLite: NULL and
empty-string both flow through NULLIF → None in Python →
file-tag-fallback path. Modern populated values trump file tag.
Discord: Discover → Your Albums (and Your Artists) was returning
nothing for Tidal users regardless of how many albums/artists they'd
favorited. Audit found `get_favorite_albums` and `get_favorite_artists`
called the deprecated `/v2/favorites?filter[type]=ALBUMS|ARTISTS`
endpoint — that endpoint returns 404 for personal favorites because
it's scoped to collections the third-party app created itself. The
V1 fallback (`/v1/users/<id>/favorites/...`) is also dead because
modern OAuth tokens carry `collection.read` instead of the legacy
`r_usr` scope V1 demands (returns 403).
Same root cause as the favorited-tracks fix from #502.
Fix: rewire to the working V2 user-collection endpoints —
`/v2/userCollectionAlbums/me/relationships/items` and
`/v2/userCollectionArtists/me/relationships/items` — using the
same cursor-paginated pattern shipped for tracks.
Architecture:
* ID enumeration lifted into a generic
`_iter_collection_resource_ids(path, expected_type, max_ids)`
helper so tracks / albums / artists all share one walker. Three
thin wrappers preserve the per-resource public surface
(`_iter_collection_track_ids`, `_iter_collection_album_ids`,
`_iter_collection_artist_ids`). Net deduped ~80 lines that would
otherwise be three near-identical copies.
* Batch hydration via `/v2/{albums|artists}?filter[id]=...&include=...`
with extended JSON:API include semantics. One request returns up
to 20 albums + their artists + cover artworks all in `included[]`
(or 20 artists + their profile artworks). Three static helpers
parse the response:
- `_build_included_maps(included)` → indexes the array by type
so per-resource lookup is O(1) per relationship ref
- `_first_artist_name(rels, artists_map)` → resolves primary
artist from relationships block; '' on missing/unknown
- `_first_artwork_url(rel, artworks_map)` → picks `files[0]`
(Tidal returns artwork files largest-first, so this gets the
highest-resolution variant — typically 1280×1280)
* Public methods (`get_favorite_albums`, `get_favorite_artists`)
preserve the prior return shape — list of dicts matching what
`database.upsert_liked_album` / `upsert_liked_artist` consume —
so the discover aggregator path in `web_server.py` stays
byte-identical. No caller changes needed.
* Deleted ~240 lines of dead code: the V2-favorites paths AND the
V1 fallback paths from the old method bodies. Both are dead
against modern OAuth tokens.
24 new tests in `tests/test_tidal_favorite_albums_artists.py` pin:
* Cursor-walker dispatch (album/artist iters pass correct path +
expected_type to the generic walker)
* Included-map building (groups by type, skips items missing id)
* Artist + artwork relationship resolution (full + missing rels +
unknown id + no files cases)
* Batch hydration parse for albums (full attributes, missing
relationships fall through to defaults, type-filter excludes
non-album entries, `filter[id]` param is comma-joined)
* Batch hydration parse for artists (same shape coverage)
* End-to-end orchestrator behavior (walk → batch → return,
empty-input short-circuits without API call, BATCH_SIZE chunking
on 41 IDs → 20/20/1, exception-from-iter returns [])
Endpoint paths empirically verified against live Tidal API:
`userCollectionArtists/me/relationships/items` returned 200 + 5
real artist refs for the test account. `userCollectionAlbums/...`
returned 200 + empty (account has 0 album favorites currently)
but the response shape is correct. The deprecated
`/v2/favorites?filter[type]=ALBUMS` returned 404. The V1
`/v1/users/<id>/favorites/albums` returned 403 with explicit
"Token is missing required scope. Required scopes: r_usr" message.
WHATS_NEW entry under existing '2.5.1' block.
Full pytest: 2678 passed.
Discord report (CJFC, 2026-04-26): syncing a Spotify playlist to the
server overwrote anything manually added to the server-side playlist.
The fix adds a per-sync mode picker next to the Sync button on the
playlist details modal — Replace (default, current delete-recreate
behavior) or Append only (preserves existing tracks, only adds new
ones). Useful when the source platform caps playlist size and the
user is manually building beyond it on the server.
Implementation:
* New `append_to_playlist(name, tracks)` method on Plex / Jellyfin /
Navidrome clients. Each uses the server's NATIVE append API:
- Plex: `existing_playlist.addItems(new_tracks)`
- Jellyfin: `POST /Playlists/<id>/Items?Ids=...&UserId=...`
- Navidrome: Subsonic `updatePlaylist?songIdToAdd=...`
Falls back to `create_playlist` when the playlist doesn't exist
yet (first sync). No delete-recreate, no backup playlist created
(preserves playlist creation date + metadata + non-soulsync-managed
tracks).
* Dedup-by-server-native-id (ratingKey for Plex, GUID for Jellyfin,
song-id for Navidrome) — never re-adds a track already on the
playlist. Server-native identity, not fuzzy title+artist match,
so it can't false-collide.
* `sync_service.sync_playlist` accepts `sync_mode='replace'|'append'`
kwarg. Single if/else branch dispatches to `append_to_playlist` or
`update_playlist`. Threaded through `core/discovery/sync.run_sync_task`
and the `/api/sync/start` HTTP handler. Validation on the API rejects
unknown mode strings (defaults to 'replace').
* Frontend: per-playlist `<select id="sync-mode-${id}">` rendered next
to the Sync button in both modal renderers (sync-spotify.js for
Spotify playlists, sync-services.js for Deezer ARL playlists).
`startPlaylistSync` reads the select at click time; missing select
(other callers like discover.js) defaults to 'replace' so backward
compat preserved without per-call-site updates.
* SoulSync standalone has no playlist methods at all and the modal
hides the Sync button entirely on it via `_isSoulsyncStandalone` —
dispatch never reaches that path, no defensive fallback needed.
15 new tests pin per-server append behavior:
- missing playlist → create_playlist delegation
- dedup filtering (existing IDs skipped, only new tracks added)
- empty new-track set short-circuits without API call
- failure paths return False without raising
- contract listing (KNOWN_PER_SERVER_METHODS includes
'append_to_playlist'; Plex / Jellyfin / Navidrome all implement)
Plus tests/discovery/test_discovery_sync.py fake `sync_playlist`
fixture got `sync_mode='replace'` default to match the new signature
(was breaking after the kwarg add; now passing).
WHATS_NEW entry under new '2.6.0' block (hidden by
`_getLatestWhatsNewVersion` until next release bump).
Closes CJFC discord request.
Adds the user's Tidal favorited tracks ("My Collection" in the Tidal
app) as a virtual playlist alongside their real playlists, mirroring
how Spotify's "Liked Songs" is treated.
Reporter (yug1900) located the working endpoint after the prior
`/v2/favorites?filter[type]=TRACKS` attempt returned empty data —
that endpoint is scoped to collections the third-party app created
itself, not personal favorites. Real endpoint:
GET /v2/userCollectionTracks/me/relationships/items
?countryCode=US&locale=en-US&include=items
Cursor-paginated (20 per page, follow `links.next` with
`page[cursor]=...` until exhausted). Response only carries
track-level attributes — artist + album NAMES come back as
relationship-link stubs, not embedded data.
Implementation:
* Two-phase fetch — `_iter_collection_track_ids` walks the cursor
chain to enumerate every track id (cheap, IDs only), then
`get_collection_tracks` batch-hydrates 20 IDs at a time through
the existing `_get_tracks_batch` helper which already knows how
to `include=artists,albums`. No duplication of the JSON:API
artist/album parse, no new dataclass shape.
* Virtual playlist `tidal-favorites` appended to the end of
`/api/tidal/playlists`. ID intentionally has no colon —
sync-services.js renderer interpolates IDs into CSS selectors
via template literals (`#tidal-card-${p.id} .foo`) and a `:`
would parse as a CSS pseudo-class operator.
* `tidal_client.get_playlist("tidal-favorites")` recognizes the
virtual id and dispatches to the collection path internally, so
every per-id consumer gets it for free: detail endpoint, mirror
auto-refresh automation, "build Spotify discovery from Tidal
playlist" flow.
OAuth scope expansion:
* Added `collection.read` to both OAuth flows (the
`core/tidal_client.py::authenticate` standalone path AND the
`web_server.py::auth_tidal` web flow — they were independent
scope strings that both needed updating).
* Added `prompt=consent` to both flows — without it Tidal silently
returns a token carrying only the ORIGINAL scope set even after
re-authentication, because Tidal treats the existing
authorization as still valid.
* New `disconnect()` method + `POST /api/tidal/disconnect`
endpoint + Disconnect button next to Authenticate in Settings →
Connections → Tidal — required for users whose existing token
predates the scope expansion (forces a clean grant).
Reconnect-needed UI hint:
* `_collection_needs_reconnect` flag set on 401/403 from the
collection endpoint, cleared on next successful walk, NOT set
on 5xx (transient server errors must not falsely tell the user
to reconnect).
* Listing endpoint reads the flag and surfaces a placeholder card
titled "Favorite Tracks (reconnect Tidal to enable)" with a
description pointing at Settings, so the user has something
visible to act on instead of a silently missing row.
Diagnostic logging — collection request URL + response status +
first 300 bytes of body now logged at info level so future "why
is my collection empty" reports can be diagnosed from app.log
without needing live reproduction.
22 new tests pin: cursor walk (full chain, max-ids cap mid-page +
at page boundary), auth gates (no token / 401 / 403 all bail
clean), reconnect-flag lifecycle (set on 401/403, cleared on next
successful walk, NOT set on 5xx), forward-compat type filter
(non-track entries skipped), count helper, batch hydration
delegation + chunking at the 20-per-batch cap, partial-batch
failure containment, virtual-id dispatch (real playlist ids still
flow through the normal path).
Closes#502.
Phase B of foxxify discord report. Pre-#524 manual-import bug left
some albums in the library with `artist=Unknown Artist` and `album.title
= <numeric album_id>`. Reorganize couldn't place them (no usable
metadata source ID) and emitted a generic "run enrichment first" hint
that doesn't apply — enrichment can't fix these rows. The right tool
is the existing `Fix Unknown Artists` repair job (reads file tags,
re-resolves metadata, re-tags + moves files).
Discoverability gap, not a logic gap. Reorganize now detects the bad-
metadata shape (Unknown Artist OR album.title that's a 6+ digit
numeric id) and emits a clear "run the Fix Unknown Artists repair
job" hint at both reason-emit sites (planner + executor). No
duplication of fixer logic.
WHATS_NEW entry covers both Phase A (orphan-format sibling handling,
already committed in d944a16) and Phase B since they ship in the same
PR for the same reporter.
20 new tests pin helpers + reason routing.
Discord report (Skowl): downloaded a compilation album ("High Tea
Music: Vol 1") where every track has a different artist (Eclypse,
Andromedik, T & Sugah, Gourski, etc.) and the AcoustID scanner
flagged every single track as Wrong Song. The file tags had the
correct per-track artist (e.g. "Eclypse" for "City Lights"), but
the scanner compared against the album-level artist ("Andromedik",
the curator). Raw similarity 12% → Wrong Song flag.
# Why the prior multi-value fix didn't help
Foxxify's case (just-merged PR): AcoustID returned multi-value
credit "Okayracer, aldrch & poptropicaslutz!" — primary IS in the
credit. Splitting found it.
Skowl's case: both sides single-value but DIFFERENT artists.
Splitter has nothing to find — Eclypse simply isn't in "Andromedik".
Different bug.
# Cause
Scanner SQL at `core/repair_jobs/acoustid_scanner.py:281` joined
the `artists` table via `tracks.artist_id` which points at the
ALBUM artist (the curator/label-name applied to every row in a
compilation). The `tracks.track_artist` column already holds the
correct per-track artist for compilations — populated by every
server-scan path (Plex `originalTitle`, Jellyfin `ArtistItems`,
Navidrome per-track `artist`) AND the auto-import / direct-download
post-process flow (`record_soulsync_library_entry` writes it when
different from album artist). Scanner just wasn't reading it.
# Fix
```sql
SELECT t.id, t.title,
COALESCE(NULLIF(t.track_artist, ''), ar.name) AS artist,
...
```
Prefers per-track artist when populated, falls back to album artist
for legacy rows / single-artist albums where `track_artist` is NULL.
`NULLIF(t.track_artist, '')` handles the empty-string-instead-of-null
case some legacy rows might have.
# Composes with Foxxify's multi-value fix
For the rare compilation track where AcoustID ALSO returns a
multi-value credit (e.g. compilation track has multiple credited
performers), both paths work together — `track_artist` gives the
correct expected primary, then the helper splits the credit and
finds it.
# Tests added (2)
- `test_load_db_tracks_prefers_track_artist_for_compilation` —
reporter's exact case: track with `track_artist='Eclypse'` AND
`artist_id` pointing at album artist 'Andromedik' resolves to
'Eclypse'. Second track with NULL `track_artist` falls back to
album artist 'Andromedik' (single-artist + legacy compat).
- `test_load_db_tracks_falls_back_when_track_artist_empty_string`
— empty string in `track_artist` (some legacy rows) → NULLIF
returns NULL → COALESCE falls back to album artist.
Both use a real SQLite DB so the COALESCE/NULLIF logic + JOIN
runs against actual schema (SimpleNamespace fakes can't simulate
JOINs).
# Verification
- 6/6 scanner tests pass (2 new + 4 existing)
- 2586 full suite passes (+2 from prior commit)
- Ruff clean
Discord report (Foxxify): the AcoustID scanner repair job flagged
multi-artist tracks as Wrong Song because AcoustID returns the
FULL credit ("Okayracer, aldrch & poptropicaslutz!") while the
library DB carries only the primary artist ("Okayracer"). Raw
SequenceMatcher similarity scored ~43% — well below the 60%
threshold — so the scanner created a finding even though the
audio was correct. User couldn't fix without lowering the global
artist threshold to ~30% (which would let real mismatches through).
# Fix
Extended the shared `core/matching/artist_aliases.py::artist_names_match`
helper (originally lifted for #441) with credit-token splitting.
When the actual artist string contains common separators —
- punctuation: `,` `&` `;` `/` `+`
- keywords (whitespace-bounded): `feat.` `ft.` `featuring` `with`
`vs.` `x`
— the helper splits into individual contributors and checks each
against the expected artist. Primary-in-credit cases now resolve
at 100% instead of 43%.
Two pattern groups because punctuation separators don't need
surrounding whitespace, but keyword separators MUST be
whitespace-bounded — otherwise we'd split artists with `x` /
`with` etc. in their names ("JAY-X" → "JAY-" / "" issue).
Composes with the existing alias path: cross-script multi-artist
credits ("Hiroyuki Sawano" expected, "澤野弘之, FeaturedJp"
actual) work via alias-token-against-credit-token compare.
# Wire-in
Scanner at `core/repair_jobs/acoustid_scanner.py:202` replaces
the raw `SequenceMatcher` call with `artist_names_match`. Pass
RAW artist strings (not pre-normalised by `_normalize`) so the
splitter can recognise separators — `_normalize` strips ALL
punctuation, which destroyed the very tokens the splitter needs.
The AcoustID post-download verifier (`core/acoustid_verification.py`)
already routes through `_alias_aware_artist_sim` which calls the
same helper — gets the multi-value benefit automatically without
a separate wire-in.
# New `split_artist_credit` exported helper
Pure-function helper for callers who want token-level access to
the credit list (debugging, UI, future per-token enrichment). Same
splitter logic, exposed as a top-level function.
# Tests added (14)
`tests/matching/test_artist_aliases.py` (+11):
- `TestSplitArtistCredit` — parametrised across 12 credit-string
formats (comma, ampersand, semicolon, slash, plus, feat./ft./
featuring, with, vs., x, single-token, empty), drops empty
tokens, strips per-token whitespace
- `TestMultiValueCreditMatching` — reporter's exact case
(Okayracer in 3-artist credit → 100%), primary in middle/end of
credit, genuine-mismatch still fails, single-token actual falls
through to direct compare, multi-value composes with aliases,
threshold still respected
`tests/test_acoustid_scanner.py` (+3):
- Reporter's case end-to-end through `_scan_file` — fingerprint
99% / title 100% / multi-artist credit → no finding created
- Genuine artist mismatch still creates finding (no false
suppression of real mismatches)
- `JobResultStub` minimal scaffold for the integration tests
# Verification
- 14 new tests pass (49 helper + 5 scanner total in their files)
- 110 matching + scanner tests pass total
- 2584 full suite passes (+25 from baseline 2559)
- Ruff clean
- Reporter's exact case (Okayracer in `Okayracer, aldrch &
poptropicaslutz!`) now scores 100% match → no Wrong Song flag
Discord report (Tim): downloaded cover art via Deezer metadata
source came out visibly blurry in Navidrome / on phones — large
displays exposed the limited resolution.
# Cause
Deezer's API returns `cover_xl` URLs at 1000×1000. The underlying
CDN actually serves up to 1900×1900 by rewriting the size segment
in the URL path (same trick the iTunes mzstatic + Spotify scdn
upgrades already use). SoulSync wasn't doing the rewrite — every
Deezer-sourced cover got embedded at 1000×1000 regardless of how
much higher resolution the CDN had available.
# Verified empirically
```
$ for size in 1000 1400 1800 1900 2000; do curl -I "...{size}x{size}-..."; done
1000: 200 OK 106 KB
1400: 200 OK 198 KB
1800: 200 OK 331 KB
1900: 200 OK 371 KB
2000: 403 Forbidden
```
1900 is the safe ceiling. Above that the CDN returns 403. CDN
serves source-native bytes when source < target (smaller-source
albums get same bytes whether we ask for 1000 or 1900), so asking
for 1900 universally is safe.
# Fix
New `_upgrade_deezer_cover_url(url, target_size=1900)` helper in
`core/deezer_client.py`. Pure function, mirrors the
`_upgrade_spotify_image_url` pattern that already lives in
`core/spotify_client.py`. Defensive on every input shape:
- Empty / None → returned as-is
- Non-Deezer URL (no `dzcdn`) → returned as-is
- No size segment in URL → returned as-is
- Already at/above target → returned as-is (idempotent, never
downgrades)
Applied at both cover-download sites:
- `core/metadata/artwork.py::download_cover_art` — auto post-process
flow. Mirrors the existing iTunes mzstatic upgrade right above it.
- `core/tag_writer.py::download_cover_art` — enhanced library view's
"Write Tags to File" feature.
# Scope discipline
- Helper applied at the DOWNLOAD boundary, not the source extraction
point in `deezer_client.py`. Means cached entries in the metadata
cache + DB row `image_url` columns keep the original 1000×1000 URL
Deezer's API returned. Future CDN behavior changes only affect the
download path, not stored data.
- Pre-existing `prefer_caa_art` toggle (Settings → Library →
Post-Processing) untouched — orthogonal workaround for users who
want even higher quality (MusicBrainz Cover Art Archive, often
3000×3000+).
- iTunes / Spotify upgrade paths untouched — they already worked.
# Tests added (16)
`tests/metadata/test_deezer_cover_url_upgrade.py`:
- Standard upgrade: default target 1900 on cover URL, alternate
dzcdn host (`e-cdns-images.dzcdn.net` vs `cdn-images.dzcdn.net`),
artist picture URLs (same path pattern), 500×500 source upgrades
too
- Custom target size: smaller target = no-op (never downgrade),
larger target works
- Idempotent: already at/above target returned unchanged
- Defensive on non-Deezer URLs: parametrised across 5 hosts
(Spotify scdn, iTunes mzstatic, MB CAA, Last.fm, random) — all
returned untouched
- Defensive on malformed Deezer URL (no size segment) → returned
as-is
- Empty / None handling
# Verification
- 16/16 helper tests pass
- 560/560 metadata + imports tests pass (no regression)
- 2559 full suite passes
- Ruff clean
# Bug
Plex servers with the music library named anything other than "Music"
(Música, Musique, Musik, Musica, 音乐, موسيقى, etc.) hit this error
after every import cycle:
soulsync.plex_client - ERROR - Failed to trigger library scan
for 'Music': Invalid library section: Music
soulsync.web_scan_manager - ERROR - Failed to initiate PLEX
library scan via web
Side effect: `wishlist.processing` kept reporting "Missing from
media server after sync" for tracks that DID import correctly, so
they got perpetually re-added to the wishlist.
# Root cause
`_find_music_library` correctly auto-detects the music section by
`section.type == 'artist'` and stores it on `self.music_library` —
works for any locale because the type is language-neutral. Read
methods (`get_artists`, etc.) route through `_get_music_sections`
which returns `[self.music_library]`, so they never had the bug.
But `trigger_library_scan` and `is_library_scanning` ignored
`self.music_library` and called
`self.server.library.section(library_name)` directly with the
hardcoded `"Music"` default. `server.library.section('Music')`
raises `NotFound` on any server whose section isn't literally
named "Music".
# Fix
Both methods now prefer `self.music_library` first, fall back to
literal `library_name` lookup only when auto-detection hasn't
populated the cached reference (test fixtures, edge cases).
`is_library_scanning`'s activity-feed match also corrected to
filter by the resolved section's actual title — the prior code
matched `library_name.lower() in activity_title.lower()` which
defaults to "music" and would never match activities for
non-English sections.
`trigger_library_scan`'s success log line now surfaces the actual
section title (`Música`) instead of the unused `library_name`
default ("Music") — confusing when debugging on non-English servers.
# Tests added (13)
`tests/media_server/test_plex_non_english_section_name.py`:
- `test_uses_auto_detected_section_regardless_of_locale` — parametrised
across 6 locale variants (Música, Musique, Musik, Musica, 音乐, موسيقى).
Each verifies trigger_library_scan calls the auto-detected
section's `update()`, NOT a literal-name fallback. Stub raises
AssertionError on `server.library.section()` so a regression that
re-introduces the fallback fails loudly.
- `test_falls_back_to_literal_lookup_when_no_auto_detection` —
backward compat: music_library=None → literal lookup as before.
- `test_explicit_library_name_arg_used_only_when_no_auto_detection` —
auto-detected wins over explicit kwarg when both available.
- `test_logs_correct_section_label_on_success` — log line surfaces
resolved section title.
- 4 symmetric tests for is_library_scanning covering refreshing-attr
check, activity-feed title match, no-match for unrelated sections,
fallback path.
# Verification
- 13 new tests pass
- 84/84 media_server tests pass (no regression in the existing
Plex / Jellyfin / Navidrome suite)
- 2458 full suite passes (+13 from baseline)
- Ruff clean
Live-API verification revealed advanced-syntax queries hurt more
than they help on this endpoint. Switching the import-modal Deezer
search back to free-text + local rerank.
# What live testing showed
Hit Deezer's public API with both query forms for the issue #534
case (`Dirty White Boy` + `Foreigner`):
**Free-text (`q=Dirty White Boy Foreigner`):**
- Returns 21 results
- Real Foreigner Head Games studio cut at #1
- Live versions at #2-10
- Karaoke / cover variants at #11-15
**Advanced (`q=track:"Dirty White Boy" artist:"Foreigner"`):**
- Returns 12 results
- "(2008 Remaster)" at #1 — canonical Head Games cut MISSING from
top 8 entirely
- Live + alt-album versions follow
Advanced syntax DOES filter karaoke at the API level (none in the
12-result set vs. 5 at positions 11-15 in free-text), but it has
its own ranking bias that surfaces remasters / "Best Of" cuts
ahead of the canonical recording. Net regression for the user-
facing goal.
# Fix
1. Endpoint reverts to free-text query with local rerank applied.
2. Local rerank gains "remaster" / "remastered" / "reissue"
patterns under VARIANT_TAG_PATTERNS (soft 0.4× penalty — user
may want them but they shouldn't outrank the original).
3. Client kwarg support (`track=` / `artist=` / `album=`) preserved
for future opt-in callers (e.g. exact-match flows where API-
level filtering matters more than ranking).
# Verified end-to-end against live Deezer API
Re-ran the exact #534 case through the live API + new rerank.
Top 15 results post-rerank:
1. Dirty White Boy — Foreigner — Head Games ← REAL CUT AT TOP
2-10. Various Live versions
11-15. Karaoke / cover / tribute variants ← BURIED
Real Foreigner Head Games studio cut at #1, exactly the user's
ask.
# Tests
- `test_relevance.py` — variant tag patterns extended; existing
tests still pass (50 tests).
- `test_search_match_endpoints.py::test_joins_track_and_artist_into_free_text_query`
— replaces `test_passes_track_and_artist_as_kwargs`; verifies
endpoint sends free-text join, NOT field-scoped kwargs (the
prior test asserted the wrong direction now).
- Karaoke-burying assertion at the endpoint still pins the
user-visible behaviour.
- Client kwarg path tests untouched (still pin advanced-syntax
construction for future opt-in callers).
# Verification
- 75 relevance + endpoint + query tests pass
- 2445 full suite passes
- Ruff clean
- Live Deezer API shows real cut at #1 post-rerank
Defensive followup to the relevance fix. Deezer's advanced search
syntax (`artist:"X"`) is documented as substring match, but in
practice it's brittle on artist name variants ("Foreigner [US]",
"The Foreigner") and on tracks indexed under non-canonical title
spellings. When the advanced query returns nothing, we'd previously
land at "No matches" — a regression vs. pre-fix behaviour where
free-text would have returned a less-relevant but non-empty set.
Fix: when the advanced query returns 0 results AND the caller used
field-scoped kwargs, fall back to a free-text join of the same
kwargs and re-query. Caller-side rerank still tightens whatever the
fallback returns, so the worst-case post-fix behaviour is the
pre-fix behaviour — never strictly worse.
Pulled the cache + parse + store dance into a private helper
(`_search_tracks_with_query`) so the orchestration can call it
twice (advanced → fallback) without code duplication. Single API
call when the advanced query has results — no wasted requests.
Diagnostic logger.debug fires when the fallback triggers so we can
see in production whether it's happening (and to which queries).
# Tests added (4)
- `test_falls_back_to_free_text_when_advanced_empty` — advanced
query returns 0, free-text returns hits; client returns the
free-text hits + both API calls fire.
- `test_no_fallback_when_advanced_query_has_results` — single hit
on advanced query → no second API call.
- `test_no_fallback_when_legacy_free_text_call` — legacy callers
already exhausted the only path; empty result is final.
- `test_no_fallback_when_query_unchanged` — empty kwargs path
doesn't trigger the fallback branch (used_advanced=False).
# Existing tests updated
The 4 prior `TestSearchTracksQueryWiring` + `TestSearchTracksCacheKey`
tests were stubbing `_api_get` to return empty `{'data': []}` and
asserting `assert_called_once`. With the new fallback, those stubs
trigger a second API call and the assertions break — even though
the FIRST call construction is what the tests cared about. Updated
the stubs to return one fake hit so the fallback doesn't fire, and
switched to `call_args_list[0]` for first-call inspection.
# Verification
- 18/18 deezer query tests pass (14 prior + 4 new)
- 2445 full suite passes (+4 from prior commit)
- Ruff clean
# Background
User reported (#534) that the import-modal "Search for Match" dialog
returned irrelevant results when Deezer was the metadata source.
Searching `Dirty White Boy` + `Foreigner` returned 5+ karaoke /
"originally performed by" / "in the style of" / "re-recorded" /
tribute-band results ranked above the actual Foreigner studio cut
from Head Games. User had to scroll past the junk every time, or
fall back to iTunes search which is much slower.
# Root cause — two layers
1. **Endpoint joined `track + artist` into free-text query.**
`/api/deezer/search_tracks` was passing `q=Dirty White Boy Foreigner`
to Deezer's `/search/track` API. Deezer fuzzy-matches that
string across title / lyrics / artist / album / contributors and
orders by global popularity — anything that appears across many
compilations outranks the canonical recording.
2. **No local rerank.** None of the search-modal endpoints applied
any post-filtering. Deezer's API order shipped straight to the
user.
# Fix — same architectural shape Cin would build
## Layer 1: field-scoped query at the client boundary
`core/deezer_client.py::search_tracks()` now accepts optional
`track`, `artist`, `album` kwargs. When provided, builds Deezer's
advanced search syntax: `q=track:"X" artist:"Y" album:"Z"`. Massive
relevance improvement because each term matches the right field
instead of fuzzy-matching everywhere.
Backward compat preserved: legacy free-text `query=` callers still
work unchanged. Field-scoped path takes precedence when both are
provided. Empty input fast-fails without an API call. Embedded
double-quotes stripped (Deezer's syntax has no escape mechanism).
## Layer 2: provider-neutral relevance reranker
New `core/metadata/relevance.py` module — pure-function rerank over
the canonical `Track` dataclass. Composable scoring:
- **Cover/karaoke patterns** (multiplier 0.05, effectively buries):
matches "karaoke", "originally performed by", "in the style of",
"made famous by", "tribute", "vocal version", "backing track",
"cover version", "re-recorded", "cover by", etc. across title,
album, AND artist fields. Catches the screenshot's exact junk:
artist credits like "Pop Music Workshop" / "The Karaoke Channel"
/ "Foreigner Tribute Band".
- **Variant tags** (multiplier 0.4): live / acoustic / demo /
instrumental / remix / radio edit / club mix etc. — softer
penalty since the user MAY want them. Skipped entirely when the
expected_title contains the same tag (so searching
"Track (Live)" still ranks Live versions first).
- **Exact artist boost** (multiplier 1.5): primary artist exactly
matches expected_artist after normalisation. Single strongest
signal for "this is the canonical recording".
- **Title + artist similarity** via SequenceMatcher (parentheticals
+ punctuation stripped before comparison).
- **Album-type weighting**: album=1.0 > single/ep=0.85 > compilation=0.7.
Compilations are more likely tribute / karaoke repackages.
Each component is a standalone function so tests pin them
individually without standing up the full pipeline.
## Wired at three search-modal endpoints
- `/api/deezer/search_tracks` — uses both layers (field-scoped
query + rerank).
- `/api/itunes/search_tracks` — uses rerank only (iTunes API has
no advanced-syntax search, but karaoke / cover variants still
leak through and need the local penalty).
- `/api/spotify/search_tracks` — already builds field-scoped
`track:X artist:Y` query; rerank added as the consistency safety
net so all three sources behave the same from the user's
perspective.
Other Deezer call sites (matching engine, watchlist scanner,
auto-import single-track ID) deliberately not touched in this PR
— they have their own elaborate scoring pipelines tuned to their
specific contexts and aren't surfacing the user-reported issue.
Per Cin: "don't refactor beyond what the task requires."
# Tests
71 new tests across 3 files:
- `tests/metadata/test_relevance.py` (50 tests) — every scoring
component pinned individually + the issue #534 screenshot
reproduced as a regression test (real Foreigner cut wins after
rerank, karaoke variants drop to bottom).
- `tests/metadata/test_deezer_search_query.py` (14 tests) —
advanced-syntax query construction, field-scoped wiring at the
client boundary, free-text path unchanged, kwargs win when
ambiguous, limit clamping, cache key consistency.
- `tests/imports/test_search_match_endpoints.py` (7 tests) —
end-to-end through Flask test client: Deezer endpoint passes
kwargs not joined query; karaoke buried at bottom for all three
sources; legacy query param still works without rerank.
# Verification
- 2441 full suite passes (+71 from baseline 2370)
- 0 failures (the prior watchdog flake fix held)
- Ruff clean across all changed files
- JS parses clean (`node -c webui/static/helper.js`)
# Architectural standards followed
- **Logic at the right boundary.** Query construction lives in the
client (every caller benefits from one change). Rerank lives in
a neutral module (`core/metadata/relevance.py`) over the
canonical `Track` dataclass — works for any source, not Deezer-
specific.
- **Explicit > implicit.** Every scoring rule has its own named
function. Pattern tables are module-level constants tests can
introspect.
- **Scope discipline.** Audited every Deezer search call site;
fixed the user-reported one + the consistent siblings. Did NOT
speculatively normalise every Deezer call across the codebase.
- **Backward compat.** Free-text `query=` callers untouched. Kwargs
added to existing client method signature with safe defaults.
- **Tests pin contract at correct boundary.** Pure-function rerank
tests don't mock anything; client-query tests stub at `_api_get`;
endpoint tests run through the real Flask app.
Two pre-existing parity gaps in `record_soulsync_library_entry` that
the prior parity commits left untouched. Both close real holes
between auto-import writes and what the soulsync_client deep scan
would have produced.
# Gap 1: Album duration was the first-imported track's duration
`record_soulsync_library_entry` is called once per track. The album
INSERT only fires for the FIRST track of a new album (subsequent
tracks find the album row already exists). The INSERT was passing
`duration_ms` — `track_info["duration_ms"]` — as the album's
`duration` column. That's the duration of one track, not the album
total. Compare to `SoulSyncAlbum.duration` in soulsync_client which
is `sum(t.duration for t in self._tracks)`.
Fix:
- Worker computes `album_total_duration_ms = sum(...)` across every
matched track and threads it onto context as
`album.duration_ms`.
- side_effects reads that value (or falls back to the per-track
duration for legacy non-auto-import callers) and writes it as the
album row's `duration`.
# Gap 2: Re-imports of the same artist/album were insert-only
When the SELECT-by-id or SELECT-by-name found an existing soulsync
artist or album row, the function skipped completely — no UPDATE
path. Meant: artist genres / thumb / source-id reflected ONLY
whatever the FIRST imported album supplied, never refreshing as
more albums by that artist landed. Ten more imports later, the
artist row still held whatever the first random import wrote.
Conservative fix: when an existing row matches, run an UPDATE that
fills only the columns whose current value is NULL or empty. Never
overwrites populated values — protects manual edits +
enrichment-worker writes the same way the scanner UPDATE path
preserves enrichment columns.
Implementation note: the empty-check happens in Python, NOT SQL.
Initial pass tried `COALESCE(NULLIF(col, ''), NULLIF(col, 0), ?)`
but SQLite's `NULLIF(text_col, 0)` returns the original text value
instead of NULL — different types, no coercion. So the SQL-only
conditional was unreliable on text columns. New helper does
`SELECT cols FROM table WHERE id`, compares each column in Python,
and emits UPDATE clauses only for the ones that need filling.
Allowlist defense: f-string column names go through
`_SOULSYNC_FILLABLE_COLUMNS` validation before interpolation.
Misuse adding new columns without an allowlist update fails closed
(logger.debug + skip).
# Tests added (4)
- `test_album_duration_uses_album_total_not_single_track` —
album with single-track context carrying explicit
`album.duration_ms = 2_500_000` writes 2_500_000 to the album row,
not the per-track 200_000 fallback.
- `test_re_import_fills_empty_artist_fields` — first import lands
artist with empty thumb + empty genres; second import for same
artist with thumb + genres present updates the existing row.
- `test_re_import_does_not_clobber_populated_artist_fields` —
first import writes rich genres + thumb; second import with
worse / different metadata leaves the existing row untouched.
- `test_re_import_fills_empty_source_id_when_missing` — first
import had no source artist ID; second import does — fills the
empty `spotify_artist_id` column on the existing row.
# Verification
- 10/10 side-effects tests pass (including 4 new + 4 from prior
parity commit + 2 history/provenance)
- 217 imports tests pass (no regression)
- 2369 full suite passes (+4 from prior, +22 PR-total from baseline 2347)
- 1 pre-existing flake (`test_watchdog_warns_about_stuck_workers`,
passes in isolation, unrelated)
- Ruff clean
Cin pre-review followup. Two small parity gaps the prior commits left
open:
# 1. Genre tags land on the standalone artists row
`soulsync_client._scan_transfer` aggregates the GENRE tag across every
track in an album and surfaces it on `SoulSyncAlbum.genres` (which the
DatabaseUpdateWorker writes to the artists+albums row). Auto-import
was hardcoding `'spotify_artist': {'genres': []}` so the imported
artists row landed with empty genres — felt hollow compared to a
Plex/Jellyfin scan, which both pull genres from their respective APIs.
Fix:
- `_read_file_tags` now reads the GENRE tag (mutagen easy mode handles
MP3/FLAC/M4A consistently; some files carry multiple genres so it's
always returned as a list).
- `_process_matches` aggregates genres from each matched file's tags
into a deduped insertion-order list. Dedup is case-insensitive but
preserves original casing — so "Hip-Hop, Rap, Trap" reads naturally
in the JSON column instead of "hip-hop, rap, trap".
- Worker context's `spotify_artist['genres']` carries the aggregated
list, which `record_soulsync_library_entry` already filters via
`core.genre_filter.filter_genres` and writes to the artists row.
# 2. Defensive str() cast for ISRC + MBID
`_build_album_track_entry` already coerces ISRC + MBID to string today
(via `str(isrc) if isrc else ''`). But if a future metadata-source
client returns int / None for either ID, the worker would propagate
the wrong type and side_effects.py's `.strip()` would AttributeError.
Cheap insurance: explicit `str()` cast in the worker before assignment
to track_info. Future-proofs against client drift.
# Tests added (3, in test_auto_import_context_shape.py):
- `test_context_aggregates_genres_from_track_tags` — multi-file
album with overlapping genre lists produces deduped, insertion-
ordered, original-case-preserved result. Stubs `_read_file_tags`
with monkeypatch so we don't need real audio.
- `test_context_genres_empty_when_no_tags` — files without GENRE
tag → empty list. Standalone library write handles gracefully
(genres column stays empty / NULL).
- `test_context_isrc_mbid_coerced_to_string` — hostile types
(int 12345678, None, int 999) coerced to safe strings before
reaching track_info.
# Verification
- 14/14 context-shape tests pass (11 prior + 3 new)
- 213 imports tests pass (no regression)
- 2365 full suite passes (+3 from prior, +18 PR-total)
- 1 pre-existing flake (`test_watchdog_warns_about_stuck_workers`,
passes in isolation)
- Ruff clean
Cin pre-review followup to the standalone library parity commit. The
prior commit fixed `spotify_artist['id']` from the wrong copy-paste
value (`identification['album_id']`) to read from
`identification['artist_id']`, but the identification dict produced
by `_search_metadata_source` and `_search_single_track` never set
`artist_id` — both extracted artist NAME from the search response
and discarded the source ID sitting right next to it. Net effect of
the prior commit: artists row source-id stayed NULL, just for a more
honest reason than before.
Now properly extracted:
- `_search_metadata_source` reads `best_result.artists[0]['id']`
alongside the artist name and returns it on the identification dict
as `artist_id`.
- `_search_single_track` does the same for single-track identification.
- `_identify_single`'s tag-based-confidence path forwards
`result.get('artist_id')` so the artist source-id propagates even
when high-confidence local tags override the search result's name.
Result: identification dict now carries `artist_id` whenever the
metadata source returned an artist with an ID. The worker context
already plumbs it onto `spotify_artist['id']` and
`spotify_album['artists'][0]['id']`, so the standalone library write
finally populates `<source>_artist_id` on the artists row.
Tests added (3, in `test_auto_import_context_shape.py`):
- `test_context_artist_id_uses_identification_artist_id` — when the
identification dict carries `artist_id`, context propagates it
onto `spotify_artist['id']` AND
`spotify_album['artists'][0]['id']`. Pins that the prior copy-
paste bug (artist['id'] = album_id) doesn't return.
- `test_context_artist_id_is_empty_when_identification_missing_it` —
fallback case (filename-only identification): context gets empty
string, NOT album_id. Honest failure mode.
- `test_search_metadata_source_extracts_artist_id_from_dict_artist`
— black-box test of `_search_metadata_source`: feed it a
spotify-shaped result with `artists[0]['id']` and verify
identification dict carries it forward.
Verification:
- 11/11 context-shape tests pass (8 prior + 3 new)
- 210 imports tests pass (no regression)
- 2362 full suite passes (+3 from prior commit, +15 PR-total)
- 1 pre-existing flake (`test_watchdog_warns_about_stuck_workers`,
passes in isolation)
- Ruff clean