Four selection-quality fixes on the SoulSync-made discover playlists.
None change public method signatures; all are tightenings on what's
already there.
(1) Diversity for Hidden Gems + Discovery Shuffle
Both used to be `RANDOM() LIMIT N` with no diversity. Could return
50 tracks from one artist or 20 from one album if the discovery
pool happened to be skewed. Both now over-fetch 3x and run the
existing `_apply_diversity_filter`:
- Hidden Gems: max 2 per album, 3 per artist
- Discovery Shuffle: max 2 per album, 2 per artist (tighter — shuffle
should feel maximally varied)
(2) Source-aware popularity thresholds
`popularity >= 60` for "Popular Picks" and `popularity < 40` for
"Hidden Gems" was Spotify-shaped (0-100 scale). Deezer writes its
`rank` value into that column (often six-digit integers); iTunes
writes nothing meaningful. For Deezer-primary users:
- Popular Picks pulled essentially everything (rank >= 60 = all)
- Hidden Gems pulled essentially nothing (rank < 40 = none)
New `_get_popularity_thresholds(source)` helper returns per-source
values:
- Spotify: (60, 40) — the existing 0-100 scale
- Deezer: (500_000, 100_000) — ballpark from real rank values
- iTunes / unknown: (None, None) — skip the popularity filter
entirely, fall back to random + diversity
`get_popular_picks` and `get_hidden_gems` now consult the helper.
When threshold is None they skip the popularity SQL filter. Diversity
+ ID gate still apply.
(3) Push genre keyword filter into SQL
`get_genre_playlist` used to fetch `limit=1_000_000` rows into Python
then run a substring keyword filter on `artist_genres`. Bad on big
discovery pools.
Now the keyword OR chain is generated as SQL placeholders:
AND (artist_genres LIKE ? OR artist_genres LIKE ? OR ...)
Each placeholder gets `f'%{keyword.lower()}%'` via `extra_params`.
`fetch_limit` drops back to `limit * 10`. `_genre_matches` Python
helper deleted (only intra-file caller; verified via grep).
Parent-genre expansion via `GENRE_MAPPING` preserved — keywords list
feeds the LIKE chain unchanged.
(4) Filter out tracks already in library
Discovery pool can include tracks the user already owns. Hidden Gems
/ Shuffle / Popular Picks shouldn't surface those.
`_select_discovery_tracks` gained `exclude_owned: bool = True`
parameter. When True, adds a correlated NOT EXISTS subquery against
the `tracks` table covering all 3 source IDs:
AND NOT EXISTS (
SELECT 1 FROM tracks t WHERE
(t.spotify_track_id IS NOT NULL AND t.spotify_track_id = discovery_pool.spotify_track_id)
OR (t.itunes_track_id IS NOT NULL AND t.itunes_track_id = discovery_pool.itunes_track_id)
OR (t.deezer_id IS NOT NULL AND t.deezer_id = discovery_pool.deezer_track_id)
)
Note column-name asymmetry: tracks.deezer_id vs
discovery_pool.deezer_track_id. Inline comment marks the trap. All
5 public discovery methods automatically benefit (default True).
Seasonal Playlist doesn't go through the helper so it's unaffected
(curated content, dedup is wrong intent there).
Tests
12 new tests in `tests/test_personalized_playlists_id_gate.py` (27
total in the file):
- Hidden Gems + Discovery Shuffle apply diversity (cap proven by
inserting 10 same-artist + same-album rows and asserting return
count ≤ per-album cap)
- Popularity thresholds: Spotify (60, 40), Deezer larger scale,
iTunes None / None
- Popular Picks skips threshold filter when None
- Genre playlist pushes filter to SQL (parent + child genre expansion)
- Owned-track exclusion: filtered when match, kept when no match,
opt-out flag works
- Deezer column-name asymmetry pinned (regression footgun)
Test fixture re-added the minimal `tracks` table (4 columns: id,
spotify_track_id, itunes_track_id, deezer_id) — only what the new
NOT EXISTS subquery needs to join. Plus `insert_library_track`
helper.
Verification
- 27/27 in this test file pass (15 prior + 12 new)
- 2232/2232 full suite green
- ruff clean
LOC delta:
- core/personalized_playlists.py: 1030 → 1101 (+71)
- tests/test_personalized_playlists_id_gate.py: 352 → 616 (+264)
The original gate baked into `_select_discovery_tracks` only checked
Spotify + iTunes:
AND (spotify_track_id IS NOT NULL OR itunes_track_id IS NOT NULL)
For Deezer-primary users, discovery_pool rows have populated
`deezer_track_id` but NULL Spotify + NULL iTunes IDs. The gate
filtered every row out — Time Machine, Genre Browser, Hidden Gems,
Discovery Shuffle, Popular Picks all rendered "no tracks found" for
every tab on every Deezer-primary install.
Extended the gate to include `deezer_track_id` and added that column
to the standard SELECT column tuple. `_build_track_dict` already
exposed `deezer_track_id` in its output shape, so frontend rendering
needed no changes.
Regression pinned via new test
`test_discovery_helper_accepts_deezer_only_id_rows` — inserts a row
with NULL Spotify + NULL iTunes but a populated `deezer_track_id`
and asserts it survives the gate.
2220/2220 full suite green.
Owner decision: not worth shipping. The four library-driven personalized
sections were stubbed returning [] for ages because their schema
prereqs didn't exist; the prior commit re-enabled them by routing
through a new `_select_library_tracks` helper. Owner reviewed and chose
to delete the sections entirely instead.
Removed everywhere:
- `core/personalized_playlists.py` — `get_recently_added`,
`get_top_tracks`, `get_forgotten_favorites`, `get_familiar_favorites`
+ the `_select_library_tracks` helper (no other callers; verified
via grep).
- `web_server.py` — 4 route handlers
(`/api/discover/personalized/recently-added`, `top-tracks`,
`forgotten-favorites`, `familiar-favorites`).
- `webui/index.html` — 4 `<div class="discover-section">` blocks
(`#personalized-recently-added`, `#personalized-top-tracks`,
`#personalized-forgotten-favorites`,
`#personalized-familiar-favorites`).
- `webui/static/discover.js` — 4 load functions
(`loadPersonalizedRecentlyAdded`, `loadPersonalizedTopTracks`,
`loadPersonalizedForgottenFavorites`, `loadFamiliarFavorites`),
plus their entries in `loadDiscoverPage`'s Promise.all, plus
4 module-level state vars + 6 dead branches across
`openDownloadModalForDiscoverPlaylist` / `startDiscoverPlaylistSync`
and the sync-progress / rehydrate dispatchers.
- `webui/static/helper.js` — 4 tooltip / docs entries.
- `webui/static/sync-spotify.js` — 1 stale rehydrate dispatcher
branch (`discover_familiar_favorites`) caught during the global
grep pass.
- `tests/test_personalized_playlists_id_gate.py` — 3 library-method
tests + the test infrastructure that supported them
(`tracks` schema, `insert_library_track` helper). Documentation
header updated to reflect the deletion.
Net: -527 / +2 lines across 7 files.
What stays:
- Daily Mixes (also in personalized package, intentionally paused —
separate decision).
- Popular Picks + Hidden Gems + Discovery Shuffle (alive, not
affected by this deletion).
- All 14 tests in the personalized-playlists test file still pass.
- The PersonalizedPlaylistsService lift from the prior commit
(`_select_discovery_tracks` etc) — those are still in active use
by the surviving discovery_pool methods.
DISCOVER_TRACK_SELECTION_REVIEW.md at repo root contains historical
references to the four deleted endpoints. Treated as historical
context (same policy as WHATS_NEW), left alone.
2219/2219 full suite green (was 2222 - 3 deleted tests = 2219).
JS parses clean, ruff clean.
User-facing bug found in the discover-page audit: multiple sections
(hidden gems, discovery shuffle, popular picks, decade browser,
genre browser) had no `WHERE (spotify_track_id IS NOT NULL OR
itunes_track_id IS NOT NULL ...)` gate. Tracks with no source IDs
in the discovery pool got displayed, the user clicked download, the
download silently failed because there was nothing to look up.
Lift + gate
`PersonalizedPlaylistsService` had 5 selection methods that all shared
the same shape — connect to DB, run a SELECT against `discovery_pool`
with different WHERE clauses, optionally apply diversity, return
list of track dicts. ~366 lines of business logic, ~55% of which was
repeated boilerplate.
Three new private helpers consolidate everything:
- `_select_discovery_tracks(*, source, extra_where, extra_params,
order_by, fetch_limit, extra_columns)` — shared SELECT against
`discovery_pool`. The mandatory ID gate is hard-coded into the
WHERE clause: no opt-out flag, every method inherits it for free.
Plus the source filter and the blacklist filter — same shape every
selector needs.
- `_apply_diversity_filter(tracks, *, max_per_album, max_per_artist,
limit)` — per-album / per-artist cap loop, returns trimmed list.
Lifted from the inline duplicates in decade / genre / popular_picks.
- `_compute_adaptive_diversity_limits(tracks, *, relaxed=False)` —
step-function tiers based on unique-artist count. `relaxed=True`
gives the slightly looser limits the genre playlist used vs the
decade playlist.
Re-enable 4 library methods
`get_recently_added`, `get_top_tracks`, `get_forgotten_favorites`,
`get_familiar_favorites` were all stubs (`return []`) because they
predated the schema columns they need. Schema now has them:
`tracks.created_at`, `tracks.play_count`, `tracks.last_played`, and
the source ID columns added in earlier work.
New `_select_library_tracks(*, where_clause, params, order_by, limit)`
helper mirrors the discovery selector but targets the `tracks` table
joined against `albums` + `artists`. Mandatory ID gate lives in the
helper too: every library method automatically rejects rows where
spotify_track_id, itunes_track_id, deezer_id,
musicbrainz_recording_id, AND audiodb_id are all NULL.
Selection rules:
- `get_recently_added` — ORDER BY created_at DESC
- `get_top_tracks` — WHERE play_count > 0 ORDER BY play_count DESC
- `get_forgotten_favorites` — WHERE play_count > 5 AND last_played
< (now - 90 days) ORDER BY play_count DESC
- `get_familiar_favorites` — WHERE play_count BETWEEN 3 AND 15
Tests
`tests/test_personalized_playlists_id_gate.py` — 17 tests pinning:
- `_select_discovery_tracks` filters NULL-id rows, honors source +
blacklist + extra_where
- `_apply_diversity_filter` caps per-album + per-artist + stops at
limit
- `_compute_adaptive_diversity_limits` returns the right tier for
unique-artist count + relaxed flag
- All 5 discovery methods (decade, popular_picks, hidden_gems,
discovery_shuffle, genre is exercised via the helper) reject
NULL-id rows
- All 4 library methods reject NULL-id rows + honor their
play-count rules
Behavior preserved
Same diversity tiers, same over-fetch multipliers (10x for decade /
genre, 3x for popular_picks), same `popularity DESC, RANDOM()`
ordering, same `popularity >= 60` / `< 40` thresholds, same
blacklist filter. Public method signatures unchanged — `web_server.py`
needs zero edits.
Net file: 1089 → ~1170 LOC (helpers + docstrings), but actual
business logic across the 9 methods went from ~418 lines down to
~195 (-53%).
2222/2222 full suite green (was 2205 + 17 new). Ruff clean.
Closes#513 (s66jones).
The artist detail page already showed a "Popular on Last.fm" sidebar —
list of an artist's top tracks by playcount, with a play button per row
but no download action. Issue #513 wanted a way to grab those tracks
the same way zotify let users grab "top X songs" without pulling the
full discography.
Pulls from the configured primary metadata source (Spotify
`artist_top_tracks`, Deezer `/artist/{id}/top`) when available, falls
back to the existing Last.fm display-only mode for sources that don't
expose popularity ranking (iTunes / Discogs / MusicBrainz). Source
label in the section title shifts to match.
Each row gets a hover-revealed download button that wishlists the
single track via the existing /api/add-album-to-wishlist endpoint
(preserves the track's real album metadata, so the wishlist worker
later places the file in its proper album folder).
A "Download All" footer button opens the standard download modal in
PLAYLIST context, not album context — the virtual playlist_id is
`top_tracks_<source>_<artistId>` which doesn't match any of the
album-prefix checks in `startMissingTracksProcess` (downloads.js).
That keeps `is_album_download=false`, so the master worker doesn't
inject a wrapper context as `_explicit_album_context`. Each track
downloads using its own real album metadata, files land in proper
per-album folders on disk (not a fake "Top Tracks" folder).
Backend additions:
- `SpotifyClient.get_artist_top_tracks(artist_id, country, limit)` —
wraps `spotipy.artist_top_tracks`, returns up to 10 tracks for the
market (Spotify's API cap). UI-side limit trim only.
- `DeezerClient.get_artist_top_tracks(artist_id, limit)` — wraps
`/artist/{id}/top?limit=N`, converts Deezer's raw shape to the same
Spotify-compatible dict layout (id, name, artists, album with
album_type / total_tracks / images, duration_ms, track_number,
disc_number) so downstream code doesn't branch on source.
- `GET /api/artist/<id>/top-tracks` — dispatches to whichever client
matches the primary source. Resolves per-source artist IDs from the
DB row first (matching what /discography already does) so a Spotify
ID in the URL still works when Deezer is primary, and vice versa.
Returns `{success, source, tracks, resolved_artist_id}` on hit;
`{success: False, reason: 'unsupported_source' | 'spotify_not_authenticated'
| 'deezer_unavailable' | 'no_tracks_found'}` on miss so the frontend
can decide whether to fall through to Last.fm.
Frontend:
- `_loadArtistTopTracks` tries the metadata source first, falls
through to the legacy `/api/artist/0/lastfm-top-tracks` call if the
source can't deliver. Section title and per-row UI shift based on
which source answered.
- New per-row `.hero-top-track-download` button (hover-revealed).
- New `.hero-top-tracks-download-all` footer button — only visible
when metadata-source mode rendered the list (Last.fm fallback hides
it since rows have no track IDs to download).
Tests: 10 new tests pin the client methods —
- Spotify: returns track list, honors UI limit cap, returns empty when
unauthed / artist_id missing / API throws.
- Deezer: shape conversion to Spotify-compatible dict, empty when no
data / artist_id missing, limit clamping at upper bound, default
fallback when limit=0, malformed entries skipped.
The Flask endpoint dispatcher itself isn't covered by the new test
file because importing web_server at test-collection time spins up
worker threads that race with caplog-using tests elsewhere in the
suite (specifically test_library_reorganize_orchestrator). Endpoint
verified manually; the underlying client methods (the load-bearing
logic) are covered.
2204/2204 full suite green (was 2194 + 10 new).
Discord report (corruption [BWC]): downloads coming through as the
instrumental cut when a vocal track was requested. The verification
step's `_normalize` function strips parentheticals and version-suffix
tags ("(Instrumental)", "- Live", etc) so legitimate name variations
don't false-fail the title-similarity check. That also means "In My
Feelings" and "In My Feelings (Instrumental)" both normalize to "in
my feelings", title similarity is 1.0, and the wrong cut passes
verification.
Detect the version label on each side BEFORE normalization runs. If
the expected and matched recordings disagree on version (one is
original, the other is instrumental / live / acoustic / remix /
etc), return FAIL — the fingerprint identified a real song, just
not the version the caller asked for.
Reuses `MusicMatchingEngine.detect_version_type` so the same regex
patterns the pre-download Soulseek matcher applies also drive
post-download verification. No duplicated tables.
Also gates the secondary fallback scan, so a wrong-version variant
sitting in the same fingerprint cluster can't win the loop after
the best match has already been version-rejected.
6 tests pin the behavior:
- instrumental returned for vocal request → FAIL
- vocal returned for instrumental request → FAIL
- live vs acoustic → FAIL
- matching versions on both sides → PASS
- original-to-original happy path → PASS (regression guard)
- secondary scan skips wrong-version recordings → not PASS
2194/2194 full suite green (was 2188 + 6 new).
Catches the silent excepts the awk-based earlier sweeps missed:
- Bare `except:` followed by `pass` (also swallows KeyboardInterrupt
and SystemExit — actively wrong). Upgraded to `except Exception as
e: logger.debug("...: %s", e)`. ~14 sites across connection_detect,
soulseek_client, listenbrainz_manager, watchlist_scanner,
youtube_client, navidrome_client, jellyfin_client, web_server.
- `except Exception:` + pass that the awk pattern missed (e.g.
multi-line or unusual whitespace). ~31 sites across automation_engine,
database_update_worker, music_database, spotify_client, web_server,
others.
- 14 legitimate cleanup sites left silent with explicit `# noqa: S110`
+ comment explaining why (atexit handlers, finally-block conn.close
calls). Logging during shutdown can itself crash because file handles
get torn down before the handler fires.
Also enables `S110` rule in pyproject.toml so this pattern fails CI
going forward — drift fails at PR review instead of at runtime against
a wedged worker thread. Tests path keeps S110 ignored (test fixtures
legitimately use try-except-pass for cleanup).
Adds a WHATS_NEW entry to helper.js summarizing the full #369 sweep.
Verified: `python -m ruff check .` → All checks passed.
Verified: `python -m pytest tests/` → 2188 passed.
Closes#369
Across all background workers (Spotify/Tidal/Deezer/Qobuz/iTunes/
Discogs/Genius/AudioDB/MusicBrainz/Last.fm/SoulID + the metadata-update
worker) and the repair-job scanners. All converted to
`logger.debug("...: %s", e)`.
Two `_e` renames in genius_worker and soulid_worker where outer scope
was already binding `e`. Two finally-block sites in repair_jobs/
library_reorganize.py left silent (conn.close on shutdown path).
Refs #369
These files had silent `except Exception: pass` blocks but no module
logger. Added `import logging` + `logger = logging.getLogger(__name__)`
at the top of each, then replaced the silent excepts with
`logger.debug(...)`.
- core/replaygain.py — 4 sites (id3 txxx + vorbis + mp4 atom reads)
- core/wishlist/presence.py — 3 sites (wishlist row parsing + queries)
- core/runtime_state.py — 1 site (activity toast emit)
- core/automation/signals.py — 1 site (collect known signals)
- core/download_engine/rate_limit.py — 1 site (plugin rate_limit_policy)
- api/system.py — 1 site (hydrabase status probe)
- api/search.py — 1 site (hydrabase search)
Refs #369
Mostly progress-callback try/except (caller-provided fns we don't
control) and best-effort file-move / DB-cleanup paths in the
auto-fixers. All converted to `logger.debug(...)`. No control-flow
changes.
Refs #369
Discord report: Duplicate Detector card said "372 findings" and Cover
Art Filler said "60 findings", but clicking the Findings tab's Pending
filter showed 0. User read it as "findings aren't being created" —
looked like a detector bug.
Actual cause: the badge sourced ``last_run.findings_created``
(historical "found in last scan") without considering current state.
After the user (or bulk-fix automation) resolved or dismissed those
findings, they no longer appeared on the Pending tab — but the badge
kept showing the last-scan number in red urgent styling.
Backend was correct end-to-end: detectors create pending rows,
bulk-fix moves them to resolved, Findings tab filters by status.
Only the badge display lied about current state.
Fix:
- ``RepairWorker._get_pending_count_by_job()`` — single SQL aggregation
returning ``{job_id: pending_count}`` for every job with pending
findings. O(1) lookup per job instead of N round trips.
- ``get_all_job_info()`` calls it once per request and adds
``pending_findings_count`` to each job's API response.
- ``enrichment.js`` job card now branches on the count:
- ``> 0`` → red ``"X pending"`` badge (urgent, action needed)
- ``= 0`` AND last scan found something → muted grey ``"X found in
last scan"`` (historical context, no action needed)
- New CSS class ``.repair-flow-badge.findings-historical`` for the
muted slate color so the two states are visually distinct.
User-visible result with the screenshotted state (372 dup / 60 cover-
art findings, all resolved):
- Before: red "372 findings" / "60 findings" — implied 432 things to
do, but Findings tab showed 0 pending
- After: grey "372 found in last scan" / "60 found in last scan" —
the badge text tells the user the count is historical, no surprise
when Pending is empty
Tests: 3 new tests in ``tests/test_create_finding_dedup_counter.py``
pin the per-job pending count helper:
- returns ``{job_id: count}`` based on status='pending' rows only;
resolved + dismissed rows excluded
- empty dict when no pending findings exist
- gracefully returns ``{}`` on DB error (badge falls back to
historical count via the existing JS ``or 0`` safety)
2188/2188 full suite green. Pure UI/state-display fix — no detector
logic, no backend behavior change.
GitHub issue #499 (@bafoed). Big initial sync of Spotify playlists
worked for 2-3 hours then downloads silently stopped:
- 3 active tasks stuck in "Searching" state, replaced every ~10 min
by different ones
- slskd UI showed no actual searches happening
- Debug log: orphaned-task count grew over time, no jobs executed
- Container restart was the only fix (bought another 2-3 hours)
- Not a rate limit (rates showed 0/min)
Root cause: ``core/soulseek_client.py`` constructed
``aiohttp.ClientSession()`` with no timeout at four sites. When slskd
hung on a request (overloaded, transient network blip, internal
stall), the HTTP call blocked indefinitely — and the worker thread
blocked with it. The download executor only has
``ThreadPoolExecutor(max_workers=3)``, so once 3 worker threads were
wedged on hung calls, no further downloads could start.
Batch-level "stuck detection" (10-minute timer in
``check_batch_completion_v2``) was correctly marking tasks
``not_found`` and trying to start replacements, but the executor pool
was exhausted — replacements queued forever inside the executor with
no thread to run them. Symptom: tasks rotating every ~10 min at the
batch level while the underlying executor stayed wedged.
Fix: bounded ``aiohttp.ClientTimeout`` (total 120s, connect 15s,
sock_read 60s) on every slskd ``ClientSession`` construction. Module-
level constant ``_SLSKD_DEFAULT_TIMEOUT`` so the four sites stay in
lockstep — future sites get the same protection by reusing the
constant.
Why these timeouts are safe:
- Every slskd API call is metadata-level (search submission, status
polls, download enqueue, transfer state queries). None stream
files — slskd handles file transfer via its own peer-to-peer
infrastructure entirely outside our HTTP requests.
- Legitimate metadata calls finish in seconds. 120s ceiling is
~50× the normal latency.
Timeout handling:
- ``asyncio.TimeoutError`` caught explicitly BEFORE the generic
``except Exception`` — surfaces "slskd timed out" specifically in
logs (debuggable instead of buried as "Error making API request").
- Returns None to the caller (same code path as a 5xx response or
any other failure). No new error path; callers already handle
None as "request failed".
- Worker thread unblocks immediately → executor pool stays healthy
→ downloads keep flowing.
Sites updated:
- ``_make_request`` (general /api/v0/ helper, line 152) — used for
every slskd API operation
- ``_make_direct_request`` (non-/api/v0/ helper, line 235)
- ``_explore_api_endpoints`` Swagger fetch (line 1566) — diagnostic
- ``_explore_api_endpoints`` per-endpoint probe (line 1617) —
diagnostic
Tests: 3 new tests in ``tests/downloads/test_soulseek_pinning.py``
pin:
- ``_SLSKD_DEFAULT_TIMEOUT`` is bounded (total set, ≤300s ceiling,
connect ≤60s) — guards against future regressions that drop or
unbound the timeout
- ``_make_request`` returns None on ``asyncio.TimeoutError`` rather
than raising — pins the caller contract
- ``_make_direct_request`` returns None on ``asyncio.TimeoutError``
2185/2185 full suite green.
Closes#499.
GitHub issue #500 (@bafoed). Library Reorganize repair job moved
album tracks to single-template paths because of a fragile
classification heuristic. Concrete symptom: a track at
``Surf Curse/Surf Curse - Nothing Yet (2017)/01 - Christine F.flac``
got proposed for a move to
``Surf Curse/Surf Curse - Christine F/Surf Curse - Christine F.flac``
(single template) instead of staying under the album folder.
Root cause: the job had its own tag-reading + transfer-folder-walk +
template-application implementation. The classification was
``is_album = (group_size > 1)`` where ``group_size`` was the count
of same-album tracks currently sitting in the transfer folder being
scanned. Two failure modes:
- only one track of an album was in the transfer folder (rest already
moved to the library, or not yet downloaded), or
- album tags varied slightly across tracks (e.g. ``"Buds"`` vs
``"Buds (Bonus)"``)
Either case gave a 1-element group → routed through the SINGLE
template → wrong destination.
Rewrite — delegate to the per-album planner the artist-detail
"Reorganize" modal already uses:
- ``core.library_reorganize.preview_album_reorganize`` for path
computation (DB-driven, knows the album has N tracks regardless of
how many sit in transfer; album-vs-single is structurally correct)
- ``core.reorganize_queue.enqueue_many`` for apply mode; the queue
worker dispatches via ``reorganize_album`` which handles file move
+ post-processing + DB update + sidecar through the same code path
the per-album modal uses
Job's per-album loop:
- iterate albums for the active media server only (matches the artist-
detail modal's scope; multi-server users won't have the job touch
the inactive server's files at paths they can't see)
- preview each album, catch exceptions per-album so one bad row
doesn't abort the scan
- branch on planner status:
- ``no_album`` / ``no_tracks`` (race: album deleted mid-scan) →
skip silently
- ``no_source_id`` (album never enriched) → emit ONE album-level
"needs enrichment first" finding (vs N per-track findings cluttering
the UI)
- ``planned`` → filter mismatched tracks (matched + new_path +
not unchanged + file_exists), emit per-track findings (dry-run)
or collect album for bulk enqueue (apply)
- bulk enqueue at end of loop using the queue's correct return-shape
(``{'enqueued': N, 'already_queued': M, 'total': K}``)
What's gone (~500 LOC):
- ``_read_tag_metadata`` / ``_get_audio_quality`` / transfer-folder walk
- ``_load_album_years`` / ``_lookup_years_from_api`` (planner does this)
- ``_apply_path_template`` / ``_build_path_from_template``
- direct ``shutil.move`` + sidecar move logic (queue handles)
- the fragile ``is_album = group_size > 1`` heuristic — structurally gone
- ``move_sidecars`` setting (no longer applicable; queue's post-process
re-downloads cover art at the destination)
What stays:
- dry-run vs apply toggle
- ``file_organization.enabled`` gate
- stop / pause respect
- progress reporting
- findings for the UI
Cleaner separation of concerns:
- this job: DB-known tracks at wrong paths (active server only)
- ``orphan_file_detector``: files on disk with no DB entry
- ``dead_file_cleaner``: DB entries pointing to nonexistent files
Tests: 12 tests in ``tests/test_library_reorganize.py`` pin the
delegation contract — every status branch, every track-filter case,
exception handling, apply-mode enqueue payload, active-server scope,
estimate-scope shape. Three obsolete ``_lookup_years_*`` tests removed
(year handling moved to planner).
Closes#500 (the misclassification half — orphan + dead-file are
downstream sync-gap symptoms, separate concern).
GitHub issue #501 (@Tacobell444). After manually matching an album to
a specific source ID via the match-chip UI, clicking "Enrich" on that
album would fuzzy-search by name and overwrite the manual match with
whatever the search returned — or revert the match status to
``not_found`` if name search missed. Reorganize then read the now-
wrong ID and moved files to the wrong destination.
Root cause was in the per-source enrichment workers'
``_process_*_individual`` methods. Several workers (Spotify, iTunes)
ran search-by-name unconditionally with no check for an existing
stored ID. Others (Deezer, Tidal, Qobuz) skipped on existing-ID but
without refreshing metadata — preserved the ID but didn't actually
honor the user's intent of "use this match to pull fresh data".
Cin-shape lift: same fix needed in 5 workers, so extracted the shared
behavior into ``core/enrichment/manual_match_honoring.py``:
honor_stored_match(
db, entity_table, entity_id, id_column,
client_fetch_fn, on_match_fn, log_prefix,
) -> bool
Per-worker variability (DB column name, client fetch method, response
shape) plugs in via callbacks. Workers call the helper at the top of
``_process_album_individual`` / ``_process_track_individual``; if it
returns True, the manual match was honored and the search-by-name
fallback is skipped. If False (no stored ID, fetch failed, or empty
response), the worker's existing search-by-name flow runs as before.
Workers wired:
- spotify_worker — album + track (was overwriting; now honors)
- itunes_worker — album + track (was overwriting; now honors)
- deezer_worker — album + track (was skip-on-id; now refreshes)
- tidal_worker — album + track (was skip-on-id; now refreshes)
- qobuz_worker — album + track (was skip-on-id; now refreshes)
Workers left alone (already correct):
- discogs_worker — already had inline stored-ID fast path that
refreshes metadata. Same behavior, just inline; refactoring to use
the shared helper would be churn for zero behavior change.
- audiodb_worker — same — inline fast path with full metadata refresh.
- musicbrainz_worker — preserves existing MBID and marks status,
which is the correct behavior for MB (the MBID itself is the match
payload — no separate metadata fetch).
- lastfm_worker / genius_worker — name-based services with no
source-specific IDs to honor. Inherent re-search per call.
Reorganize fixed indirectly — it always honored stored IDs correctly
via ``library_reorganize._extract_source_ids``. The "Reorganize broken"
symptom was downstream of broken Enrich corrupting the stored ID.
Tests:
- ``tests/enrichment/test_manual_match_honoring.py`` — 11 tests
pinning the shared helper contract: stored-ID fast path, no-ID
fallthrough, empty-string treated as no ID, missing row, fetch
exception caught and falls through, fetch returns None falls
through, callback exceptions propagate, configurable table +
column, defensive table-name whitelist.
- Per-worker wiring NOT tested individually — the workers depend
on live DB / client objects that are heavy to mock. The shared
helper's contract is pinned; per-worker call sites are short
enough to verify by code review.
2173/2173 full suite green.
Closes#501.
Followup to the all-libraries-mode commit. Without dedup, a Plex Home
family where two users both have "Drake" in their music libraries
would see "Drake" twice in SoulSync's library list — Plex returns
distinct ratingKeys for each section's copy of the same artist.
Dedup design — applied selectively, NOT everywhere:
- ``_dedupe_artists(artists)``: groups by lowercased title, picks
the canonical entry by ``leafCount`` (more tracks wins). Active
ONLY in all-libraries mode; single-library mode is a no-op fast
path with zero behavior change.
- ``_dedupe_albums(albums)``: same but keys on
(lowercased parentTitle, lowercased title) so two artists with
identically-titled albums (e.g. self-titled releases) stay
separate.
Applied to:
- ``get_all_artists()`` — public listing for the library view
- ``get_library_stats()`` — count matches what user sees in the list
Deliberately NOT applied to:
- ``get_all_artist_ids()`` / ``get_all_album_ids()`` — these feed
removal detection (compare returned ratingKey set against DB-linked
ratingKeys to decide what's been removed). Deduping here would falsely
flag non-canonical ratingKeys as "removed" and prune SoulSync's DB
tracks that are linked to them. Pinned by two CRITICAL tests.
- ``_all_tracks()`` — track count stays raw because the same track
in two sections IS two distinct files / Plex entries, not a logical
duplicate.
- ``_search_general()`` and ``search_tracks`` Stage 1/2 — search
results stay raw so cross-section matches aren't lost. Stage 1
may miss cross-section tracks for the same artist but Stage 2's
server-wide track search catches them.
Logging: when raw vs deduped artist counts differ, ``get_all_artists``
logs both so users can see "Found 4697 artists across all music
sections (4521 unique after cross-section dedup)" — surfaces the
overlap clearly.
Tests: 8 new tests in test_plex_all_libraries.py pin:
- canonical pick by leafCount (artists + albums)
- case-insensitive name match
- single-library no-op path (zero behavior change for those users)
- album dedup keys on (artist, title) so different-artist same-title
albums stay separate
- ``get_all_artists`` listing applies dedup
- ``get_all_artist_ids`` does NOT dedup (CRITICAL — removal detection)
- ``get_all_album_ids`` does NOT dedup (CRITICAL — removal detection)
- ``get_library_stats`` uses deduped counts for artists/albums but
raw count for tracks
Existing pre-stat test updated to use distinct mock instances —
``[MagicMock()] * 5`` creates five references to one mock which now
correctly collapses under dedup.
71/71 media_server tests green, 2162/2162 full suite green.
Honest known limitation acknowledged in WHATS_NEW + version modal:
write-back (genre / poster / metadata updates) targets one
ratingKey at a time — only updates the canonical section's copy of
an artist if it exists in multiple. Other section's copy stays
unchanged. Document and revisit if it matters.
GitHub issue #505 (PopeBruhLXIX): users with multiple Plex music
libraries (e.g. one per Plex Home user, or two folder roots split
across separate library sections) only saw one library inside SoulSync
because the connection settings forced you to pick a single library
section. SoulSync's PlexClient stored exactly one ``self.music_library``
section reference and every read scanned only that one.
This change adds an opt-in "All Libraries (combined)" dropdown option
that flips the client into a server-wide read mode where every read
method (``get_all_artists`` / ``get_all_album_ids`` /
``search_tracks`` / ``get_library_stats`` / etc) dispatches through
``server.library.search(libtype=...)`` instead of querying a single
section. One Plex API call replaces N per-section iterations; Plex
handles the aggregation server-side.
Implementation:
- ``ALL_LIBRARIES_SENTINEL`` (``'__all_libraries__'``) — module-level
constant used as the saved DB preference value when the user picks
the synthetic "All Libraries" entry. Detection is one string compare
in ``_find_music_library`` / ``set_music_library_by_name``. Existing
preferences (real library names) are unaffected.
- ``self._all_libraries_mode`` (private flag) + ``is_all_libraries_mode()``
(public accessor for external callers). When True, ``music_library``
may stay None — ``is_fully_configured()`` recognizes the mode and
still returns True so dispatch sites don't bail.
- New private helpers ``_can_query``, ``_get_music_sections``,
``_all_artists``, ``_all_albums``, ``_all_tracks``, ``_search_general``,
``_search_artists_by_name``. Single dispatch point for the
section-vs-server branch — every read method funnels through them
so future drift fails at one place.
- New public helpers for downstream callers:
- ``get_recently_added_albums(maxresults, libtype)`` — used by
DatabaseUpdateWorker's deep-scan recent-content sweep
- ``get_recently_updated_albums(limit)`` — same
- ``get_music_library_locations()`` — returns folder roots, used
by web_server.py's file-path resolver
- ``trigger_library_scan`` and ``is_library_scanning`` fan out across
every music section in all-libraries mode.
- ``get_available_music_libraries`` prepends a synthetic
``{'title': 'All Libraries (combined)', 'value': sentinel}`` entry
ONLY when more than one music library exists. Single-library users
don't get the extra option. ``value`` field is the canonical
identifier the frontend submits to ``/api/plex/select-music-library``
(real libraries: title; synthetic: sentinel string). Backward-
compatible — entries without ``value`` fall back to ``title``.
Three crash points fixed in downstream consumers (would have failed
during a deep scan after the user picked all-libraries mode):
1. ``database_update_worker.py:411`` — bailed out with "No music
library found in Plex" because ``not self.media_client.music_library``
evaluated True in all-libraries mode (music_library is None there).
Now uses ``is_fully_configured()`` which recognizes the mode.
This was the root cause of the deep scan never starting.
2. ``database_update_worker.py:_get_recent_albums_plex`` — reached
``self.media_client.music_library.recentlyAdded()`` /
``.search()`` directly, AttributeError in all-libraries mode.
Now routes through the new helper methods.
3. ``web_server.py:10947`` (file-path resolver) — accessed
``music_library.locations``; gated on ``music_library`` truthy so
it didn't crash, but silently skipped all-libraries-mode locations.
Now uses ``get_music_library_locations()`` which unions across
sections.
Plus polish:
- ``/api/plex/clear-library`` also resets ``_all_libraries_mode``
so a fresh "select library" flow doesn't inherit stale mode state.
- ``/api/plex/music-libraries`` surfaces "All Libraries (combined)"
as ``current_library`` when in mode (settings UI displays correctly).
- Frontend ``loadPlexMusicLibraries`` uses ``library.value || library.title``
so the sentinel-keyed option submits the sentinel string, not the
human-readable label. Pre-select match handles both paths.
Honest tradeoffs (documented as known limitations):
- Same artist appearing in multiple Plex sections shows as separate
entries in SoulSync (no dedup). Plex returns distinct ratingKeys
for each. Cosmetic; revisit if it bites users.
- Write-back (genre / poster updates) targets one ratingKey at a time
— only updates that section's copy. Other sections' copies stay
unchanged.
- All-libraries mode includes any audiobook library that Plex
classifies as ``type='artist'``. Edge case, opt-in only.
Tests: 21 new tests in tests/media_server/test_plex_all_libraries.py
pin both single-library mode (regression guard) and all-libraries mode
for every refactored method. Existing test_plex_pinning.py fixture
updated to initialize the new flag. 63/63 media_server tests green,
2148/2148 full suite green.
Two fixes.
(1) Discography endpoint now does server-side per-source ID resolution.
When the user clicked Download Discography on a library artist, the
endpoint received whichever artist ID the frontend happened to pick
(spotify_artist_id || itunes_artist_id || deezer_id || library_db_id)
and dispatched it as-is to whichever source it queried. If the picked
ID didn't match the queried source's ID format, the lookup returned
wrong-artist results (numeric ID collisions) or fell back to a fuzzy
name search that picked a wrong artist.
Two reproducible cases:
- 50 Cent's library row had DB id 194687 — coincidentally a real
Deezer artist ID for "Young Hot Rod". When the frontend's
/enhanced fetch silently fell back to the DB id, the backend
sent 194687 to Deezer, and Deezer returned Young Hot Rod's
50 albums in 50 Cent's discography modal.
- Weird Al's library row had a stored Spotify ID. The frontend
sent that to Deezer, which rejected the alphanumeric ID and
fell back to fuzzy name search — which picked The Beatles
somehow, returning 45 Beatles albums.
The mechanism for per-source ID dispatch already exists in
``MetadataLookupOptions.artist_source_ids``, and the watchlist scanner
already uses it; the on-demand discography endpoint just wasn't wired
to it. Fix: when the URL artist_id matches a library row by ANY stored
ID (DB id, spotify_artist_id, itunes_artist_id, deezer_id, or
musicbrainz_id), pull every stored provider ID and pass them as
``artist_source_ids``. Each source gets its OWN stored ID regardless
of which one the URL carries. When the URL ID is a non-library
source-native ID and the row lookup misses entirely, behavior is
identical to before (single-ID dispatch fallback).
Logged the resolved per-source ID dict at INFO so future "wrong artist
showed up" diagnostics are immediately legible in app.log.
(2) Logger namespace fix in core/artists/quality.py and
core/metadata/multi_source_search.py.
Both modules used ``logging.getLogger(__name__)`` which resolves to
``core.artists.quality`` / ``core.metadata.multi_source_search`` —
neither under the ``soulsync`` namespace where the file handler is
wired. Result: every [Enhance], [MultiSourceSearch], and direct-lookup
INFO line was being written to a logger with no handlers and silently
dropped. App log showed the slow-request warning but no diagnostic
detail. Switched both to ``get_logger()`` from utils.logging_config so
the soulsync.* namespace picks them up. Same content, now actually
lands in app.log. Confirmed working in live test:
``[Enhance] Direct lookup matched: deezer ID 1476162252 → 'Desastre'``
No behavior change in any other caller. Empty ``artist_source_ids``
(no library row matched) reaches lookup as ``None`` → identical to
current single-ID dispatch path. Logger fix is pure routing — no
content change.
Followup on the previous Enhance refactor. Multi-source parallel text
search closed the worst case (users with no Spotify/Deezer getting
"unknown artist - unknown album - unknown track" wishlist entries),
but text search itself is still fragile against messy library tags:
"Title (Live)", featured artists in the artist field, etc. Download
Discography never had this problem because it resolves albums by stable
ID, not by name.
Enhance now does the same thing for tracks: for every metadata source
the user has configured, if the library track has the corresponding
stored ID (spotify_track_id / deezer_id / itunes_track_id / soul_id),
call client.get_track_details(stored_id) directly and convert to the
wishlist payload. First success wins. The user's configured primary
source is tried first so a Deezer-primary user gets Deezer payloads on
the wishlist entry (correct cover art / album shape) even when other
sources also have stored IDs for the same track.
Multi-source parallel text search stays as the fallback for tracks
with no stored IDs (e.g. manually imported, never enriched). Empty-
field rejection still gates the wishlist add.
Implementation:
- _STORED_ID_COLUMNS: source name → DB column mapping
(Discogs intentionally omitted — release-based, no per-track IDs)
- _enhanced_to_wishlist_payload: converts the get_track_details
intermediate "enhanced" shape (artists as [str]) to wishlist shape
(artists as [{'name': str}]). Spotify's raw_data is already in
wishlist shape, returned as-is when detected (preserves full
album.images that the enhanced top-level fields drop)
- _try_direct_lookup_all_sources: iterates sources preferred-first,
calls get_track_details on each that has both a stored ID and a
configured client, returns first complete-metadata payload
- spotify_client field removed from ArtistQualityDeps (no longer
used — Spotify direct lookup now flows through the generic
per-source loop using the entry from search_sources)
- _try_upgrade_to_rich_payload removed (was Spotify-only with broken
shape semantics for non-Spotify sources; search-fallback now uses
_build_payload_from_track consistently)
- get_primary_source() consulted to set the per-call preferred source
for direct-lookup priority
Also fixed a stale UI string: the Enhance modal toast read "Matching
tracks to Spotify and adding to wishlist..." regardless of which
sources were actually configured. Now reads "Matching tracks across
metadata sources...".
Tests:
- _build_deps mirrors web_server._resolve_search_sources: passing
spotify=spotify_obj auto-prepends ('spotify', spotify_obj) to
search_sources (Spotify is always added when configured in prod)
- 5 new tests pin the direct-lookup behavior:
- test_direct_lookup_via_deezer_id_skips_text_search
- test_direct_lookup_via_itunes_id_skips_text_search
- test_direct_lookup_prefers_user_primary_source
- test_direct_lookup_falls_through_to_text_search_when_no_stored_ids
- test_direct_lookup_failure_falls_through_to_text_search
- Reframed enhanced-format and search-fallback tests for the new
payload-build path (no album-image side call, search-fallback uses
_build_payload_from_track consistently)
- 22/22 quality tests green, 2133/2133 full suite green.
Track Redownload had been doing parallel multi-source metadata search
across every configured source the whole time; Enhance Quality was
running a single-source primary fallback that returned junk matches
with empty fields when the primary was iTunes (Discord report:
"unknown artist - unknown album - unknown track" wishlist entries
for users with neither Spotify nor Deezer connected).
Lift the redownload search into core/metadata/multi_source_search.py
and point both flows at it. Same scoring, same per-source query
optimization (Deezer's structured artist:/track: form), same
current-match flagging via stored source IDs.
ArtistQualityDeps now takes get_metadata_search_sources (returns
[(name, client), ...] for every configured source) instead of the
single-primary get_metadata_fallback_client + get_metadata_fallback_source.
Spotify direct-lookup stays as a fast-path optimization (only Spotify
exposes get_track_details(id) returning rich raw payload); when it
doesn't fire, the multi-source parallel search picks the cross-source
best match. Empty-field matches still rejected before wishlist add.
Tests: _build_deps helper updated to accept the new search_sources
contract while preserving fallback_client/fallback_source ergonomics.
Reframed tests for the new semantics — direct-lookup is no longer
gated on Spotify being the active primary; failure reason now lists
every searched source. Added a test pinning the no-sources-configured
prompt. 17/17 quality tests green, 2128/2128 full suite green.
Discord report: clicking Enhance Quality on an artist with neither
Spotify nor Deezer connected added tracks to the wishlist as
"unknown artist - unknown album - unknown track".
Root cause was structural. core/artists/quality.py had a hardcoded
Spotify-direct → Spotify-search → iTunes-fallback chain that ignored
the user's configured primary metadata source. When Spotify wasn't
connected, every track fell through to an iTunes-only fallback that
occasionally returned matches with empty fields (cleared the 0.7
confidence threshold but missing artist / album / title). Those
empty strings propagated through the wishlist payload normalizer's
truthy-check passthrough at core/wishlist/payloads.py:77-80 and the
UI rendered them as "Unknown" defaults.
Rewrote the flow source-agnostic:
- ArtistQualityDeps gains get_metadata_fallback_source. Flow resolves
the user's active primary source once up front.
- New _build_payload_from_track helper produces the Spotify-shaped
wishlist payload from any source's Track object — single place
that knows how to construct it (replaces the duplicate construction
in the Spotify-search and iTunes-fallback paths).
- New _search_match helper does generic confidence-scored search
against any client implementing search_tracks(query, limit). Same
0.7 threshold, same album-bonus weighting as before.
- New _has_complete_metadata validator rejects matches with empty
title / album / artists before they reach the wishlist.
- _spotify_direct_lookup kept as a Spotify-only optimization (only
Spotify exposes get_track_details(id) returning rich raw payload);
other sources fall through to search.
- Failure reason now names the active source: "No usable {source}
match — connect another metadata source for better coverage".
Result: Discogs users get a Discogs search. Hydrabase users get a
Hydrabase search. iTunes users get an iTunes search with empty-field
rejection. Spotify keeps its direct-lookup fast path.
6 new tests pin the architectural change:
- Primary-source dispatch routes to the configured client (Discogs,
not Spotify) when Spotify isn't primary
- Spotify direct-lookup is gated on Spotify being the active primary
(skipped when Discogs is configured even if track has spotify_track_id)
- Empty title / album / artists fields all reject the match
- Failure reason names the active source
Going line-by-line through the engine package + boot wiring. Five
small things worth fixing before Cin reads it:
(1) MediaServerEngine class docstring still claimed to be a "single
entry point for cross-server library operations" — but the prior
honesty pass cut all the cross-server dispatch wrappers because they
had no callers. Class is really lookup + small accessors now.
Docstring rewritten to match.
(2) configured_clients() had a dead `not hasattr(client, 'is_connected')`
branch. is_connected is in REQUIRED_METHODS so every client the
registry yields here implements it. Branch removed; comment notes
the reasoning.
(3) types.py imported `datetime` and `Dict` but used neither —
dead imports dropped.
(4) types.py docstring claimed "all four servers" defined an
XTrackInfo dataclass. Actually only Plex / Jellyfin / Navidrome
did; SoulSync uses richer per-track wrappers. Fixed.
(5) web_server.py boot:
- media_server_engine added to the chained `= None` declaration
so it's always defined before the try/except, defending against
the rare path where engine init AND fallback both raise.
- Outer engine init failure logger now uses exc_info=True for full
traceback (boot-time issues are rare but worth diagnosing).
- Nested fallback failure now logs explicitly instead of silently
leaving media_server_engine as None.
Tests: 2121 still pass.
Pre-review audit found premature abstraction + lying docstrings.
Cut what isn't used, made the rest match what's actually shipped.
(1) Engine: dropped 7 cross-server dispatch wrappers that had ZERO
production callers (ensure_connection / get_all_artists /
get_all_album_ids / search_tracks / trigger_library_scan /
is_library_scanning / get_library_stats / get_recently_added_albums).
Every consumer reaches the active client directly via
sync_service._get_active_media_client() or engine.client(name).
Engine surface shrinks to client(name) / active_client() /
active_server / is_connected() (the one wrapper that has callers —
4 dashboard status sites) / configured_clients() / reload_config().
~150 lines deleted, 5 dead-method tests removed.
(2) Contract Protocol body trimmed to match REQUIRED_METHODS exactly
(is_connected, ensure_connection, get_all_artists, get_all_album_ids).
The other 5 methods that were declared in the Protocol
"required" section weren't actually required — Plex doesn't
implement get_recently_added_albums, Jellyfin doesn't implement
search_tracks, SoulSync doesn't implement most of them. Static
contract now matches runtime conformance test. Optional methods
moved to a KNOWN_PER_SERVER_METHODS data-only listing with audited
per-server coverage notes — discoverability without false promises.
(3) Engine module docstring + __init__.py docstring no longer
overclaim "33+ chains collapsed" — only 4 uniform-shape chains
were collapsed; ~18 server-specific chains stay explicit per the
"lift what's truly shared" standard. Phrasing now matches reality.
(4) types.py docstring claimed TrackInfo.from_jellyfin_dict and
TrackInfo.from_navidrome_dict exist as classmethods. They don't —
only from_plex_track / from_plex_playlist do. Jellyfin and Navidrome
construct TrackInfo inline at their call sites today. Docstring
now honest about that + flags the lift as a clean followup.
(5) Engine line 95 comment "backward-compat for source-specific
reaches" was misleading — there is no legacy alternative being
preserved; engine.client(name) IS the canonical access pattern.
Section header rewritten.
Tests: 2121 pass (was 2126; -5 dead-method pin tests).
Three nits I missed in the prior pass:
(1) Two more exception swallows still at logger.debug — the
get_recently_added_albums wrapper and the configured_clients
inner loop. My earlier sed only matched single-line patterns.
Both now log at warning level so a broken Plex / Jellyfin
surfaces in the boot log instead of silently returning [].
(2) registry.py module docstring still claimed it replaced
"33 hand-maintained dispatch sites" — same overclaim I fixed
on the engine docstring. Server-specific chains stay explicit
in web_server.py per the "lift what's truly shared" standard;
the registry just owns name → client lookup. Docstring rewritten
to match reality.
Five tightening passes anticipating Cin / JohnBaumb's review nits:
(1) Engine no longer reaches into ``registry._instances`` private
attr. New public ``MediaServerRegistry.set_instance(name, client)``
method — engine constructor calls it for the ``clients=`` pre-built
case so internal storage stays encapsulated.
(2) Engine module docstring no longer overclaims. Originally said it
"Replaces the historic 33+ if/elif chains" — but only the four
uniform-shape ``is_connected`` chains were collapsed. The 19 chains
that do server-specific work (Plex raw API vs Jellyfin / Navidrome
client methods returning different shapes) stay explicit per the
"lift what's truly shared" standard. Docstring rewritten to say
exactly that.
(3) Per-method exception swallows upgraded from ``logger.debug`` to
``logger.warning``. Returning safe defaults stays the right behavior
for a read-side engine (Plex offline shouldn't crash the app), but
silent debug-level swallowing made debugging hard — JohnBaumb pushed
the download engine to surface real errors. Same treatment here:
default still safe, but the warning tells you Plex is down.
(4) ``_safe_init_media_client`` in web_server.py now logs the
exception type + traceback. Broad ``except Exception`` is still
intentional (any failure means that one server can't be used; the
others stay up) but the boot log now distinguishes config errors
(ConnectionError, AuthenticationError) from import / dependency
failures.
(5) Two new tests pin the encapsulation + fallback contracts:
- ``test_engine_with_empty_clients_dict_is_safe_to_use`` — empty
engine returns safe defaults on every method, doesn't raise.
- ``test_engine_uses_registry_set_instance_not_private_attr`` — spy
on registry.set_instance verifies engine uses the public method.
Plex / Jellyfin / Navidrome each defined a near-identical
XTrackInfo (id / title / artist / album / duration / track_number /
year / rating) and XPlaylistInfo (id / title / description /
duration / leaf_count / tracks). Three classes that grew up by
copy-paste — not a real contract difference.
Lifted both to core/media_server/types.py as canonical TrackInfo +
PlaylistInfo. Per-server constructors live as classmethods on the
unified class (TrackInfo.from_plex_track, PlaylistInfo.from_plex_playlist)
matching the metadata Album.from_X_dict pattern Cin's POC uses.
Heavy plexapi imports stay lazy under TYPE_CHECKING.
- core/plex_client.py / jellyfin_client.py / navidrome_client.py:
per-server XTrackInfo / XPlaylistInfo dataclass definitions
removed; each module now imports TrackInfo + PlaylistInfo from
the neutral package and uses the shared name internally.
- core/matching_engine.py: was annotating callers with PlexTrackInfo
even though sync_service hands it Jellyfin / Navidrome instances
at runtime when those servers are active. Annotation is now the
unified TrackInfo, so signatures match the actual contract.
- services/sync_service.py: same import + annotation update.
Per-server web_server.py globals (plex_client / jellyfin_client /
navidrome_client / soulsync_library_client) are gone. The engine now
owns the per-server client instances; web_server.py constructs them
inline into the engine init and routes everything through
media_server_engine.client('<name>').
Multi-client consumers refactored to take the engine instead of
separate per-server kwargs:
- services/sync_service.py: PlaylistSyncService.__init__ now takes
media_server_engine. Internal _get_active_media_client resolves the
active server's client through self._engine.client(name) instead of
the per-server self.X_client attributes.
- core/listening_stats_worker.py: ListeningStatsWorker takes
media_server_engine. The plex/jellyfin/navidrome dispatch in _poll
collapses to engine.client(active_server) (gated to those three
servers — SoulSync standalone has no listening data).
- core/web_scan_manager.py: WebScanManager takes media_server_engine
instead of the hand-keyed media_clients dict that drifted out of
sync with the engine.
- core/discovery/sync.py: SyncDeps holds media_server_engine instead
of plex_client / jellyfin_client. Playlist-image dispatch routes
through engine.client(name).
Web_server.py:
- Per-server globals removed from the chained `= None` init line
+ their try/except construction blocks. Replaced with a
_safe_init_media_client(factory, name) helper that captures
per-server init failures + passes the resulting clients straight
into the MediaServerEngine init dict.
- All construction sites (PlaylistSyncService, WebScanManager,
ListeningStatsWorker, SyncDeps, library_check) updated to receive
the engine instead of per-server clients.
Test fixtures (tests/discovery/test_discovery_sync.py) gain a
_FakeMediaServerEngine stub + the SyncDeps build helper passes
that instead of separate plex/jellyfin clients.
Apply the Cin-1 / Cin-2 pattern from the download refactor PR to the
media server engine PR before review.
Cin-1 — explicit inheritance:
- PlexClient, JellyfinClient, NavidromeClient, SoulSyncClient now
explicitly inherit MediaServerClient instead of relying on
structural typing alone. Pre-change a reader of plex_client.py
had no way to know the class was supposed to satisfy the contract.
- Removed the engine + registry re-exports from
core/media_server/__init__.py to break the circular import that
the inheritance change introduced (importing the package now
triggered a chain that loaded clients before their base class
resolved). Submodules import directly: from
core.media_server.engine import MediaServerEngine, etc.
- Conformance test now also asserts isinstance() / issubclass()
against MediaServerClient — drift in any class fails at the test
boundary instead of at runtime.
Cin-2 — generic accessors + singleton:
- engine.configured_clients() — replaces the legacy per-server
`if X and X.is_connected(): clients[name] = X` chains in
web_server.py.
- engine.reload_config(name=None) — generic dispatch, so callers
pass the server name instead of reaching for plex_client.reload_config()
directly.
- get_media_server_engine() / set_media_server_engine() singleton
factory matching the get_metadata_engine() / get_download_orchestrator()
shape. web_server.py boots via set_media_server_engine(...) so
factory + global handle share state.
- 7 new tests pin the accessors + singleton behaviour.
Four stale doc/comment references caught by Copilot's pass:
- core/download_plugins/base.py: TYPE_CHECKING comment said the
shared dataclasses lived in core.soulseek_client. They were moved
to core.download_plugins.types in this PR. Comment updated.
- core/qobuz_client.py: reload_credentials docstring still referenced
soulseek_client.client('qobuz') after the global rename to
download_orchestrator. Updated to download_orchestrator.client(...).
- webui/static/helper.js: the older WHATS_NEW entries for the plugin
contract + engine refactor still claimed backward-compat
self.<source> attributes were preserved. Followup commits in the
same PR removed them. Each entry now flags the followup explicitly
and points at the "Drop Backward-Compat Per-Source Attrs" entry
above it so the changelog is internally consistent.
- docs/download-engine-refactor-plan.md: Compatibility commitments
section listed orchestrator.<source> attribute preservation as a
guarantee. Cin's review pass removed those attrs (and renamed the
global handle from soulseek_client to download_orchestrator) — both
are breaking changes for in-tree callers (which were migrated) and
in-flight branches (which will need to update). Section rewritten
to document the actual outcome.
Two findings from JohnBaumb on the engine refactor.
(1) Every download client returned None when self._engine was None,
just logging an error. The orchestrator's download_with_fallback
treated None as "source declined", so the user got no feedback —
download silently disappeared. Now each client raises a RuntimeError
on the engine-not-wired path. download_with_fallback already catches
plugin exceptions, logs a warning, and tries the next source — so
the visible behavior is "real error in logs + fallback to next
source" instead of "silent drop". Six clients touched (deezer, hifi,
qobuz, soundcloud, tidal, youtube). Pinning tests updated to expect
raise.
(2) Monitor's engine.get_all_downloads() walked every plugin
including soulseek, but the same monitor loop already pulled slskd
transfers via the transfers/downloads endpoint a few lines earlier —
soulseek's records were being fetched twice per tick. Same issue in
web_server.py's get_cached_transfer_data path. Added an exclude
parameter to engine.get_all_downloads(); both call sites now pass
('soulseek',). New test pins the exclude semantic.
Also fixed a stray 8-space over-indent on the for-loop body in
get_cached_transfer_data (cosmetic, JohnBaumb flagged the same
pattern in monitor.py earlier).
Per JohnBaumb: the single state_lock serialized progress callbacks
across every source. Pre-refactor each client owned its own download
lock, so Deezer / YouTube / Tidal workers never blocked each other.
Multi-source concurrent downloads under the unified lock fought for
the same RLock on every progress update.
Replaced the engine-wide state_lock with per-source RLocks. Each
source gets its own lock, lazily created via _source_lock() on first
use (meta-lock guards the create-race). All record mutations
(add/update/update_unless_state/remove/get/iter) take only that
source's lock — Deezer progress updates no longer block Tidal writes.
Cancelled-preserve semantics still hold because cancel + worker
terminal write target the same source, so they share that source's
lock. New test pins lock independence: holding source-A's lock from
one thread does not block a write on source-B from another.
Per JohnBaumb's review: iter_records_for_source() walked every
(source, id) tuple across the entire engine state to filter one
source — O(total_records) instead of O(source_records). Fine in
practice because total active downloads is usually small, but the
shape was wrong.
Switched the engine's _records storage from a single composite-key
dict (Dict[Tuple[str, str], DownloadRecord]) to a nested dict
(Dict[str, Dict[str, DownloadRecord]]). Per-source iteration now
only touches that source's bucket. add/get/update/remove all
adjusted to the nested layout. remove_record drops the empty source
bucket so future iterations don't see stale source keys.
Public surface unchanged. New test pins the empty-bucket-cleanup
behavior.
Three small follow-ups from the Copilot review of the rename PR:
- services/sync_service.py: PlaylistSyncService.__init__'s
download_orchestrator parameter was annotated as SoulseekClient,
which was misleading (the object passed is the DownloadOrchestrator
with .search_and_download_best, .download, etc — not a SoulseekClient).
Switched the import + annotation to DownloadOrchestrator so type
checking + IDE help match reality.
- tests/test_qobuz_credential_sync.py: docstring still referenced the
old soulseek_client global handle; updated to download_orchestrator
to match the rest of the codebase.
- core/downloads/monitor.py: the `for download in all_downloads` body
was over-indented (8 spaces past the for instead of 4) — purely
cosmetic but easy to mis-edit. Re-indented to one level.
A "type beat" is an instrumental track produced in another artist's
style, uploaded to SoundCloud and tagged with that artist's name to
game search ranking. They show up as candidates for major-label
tracks (e.g. "Eminem - Greatest (Kamikaze) Type Beat - Sit Down" for
"Greatest" by Eminem) and have nothing to do with the real song.
Add 'type beat' to the version-keyword list so the scorer applies the
0.4x penalty + flags the result as wrong_version. Currently the
matcher rejects them via low text-similarity scores anyway, but the
explicit keyword makes the rejection deterministic and gives a clear
diagnostic in the logs / modal.
The earlier validation-only filter only ran in the auto-search
scoring path. SoundCloud preview snippets still leaked through:
- The candidate-review modal cached raw search results (pre-validation),
so previews were visible and clickable for manual retry — and the
manual-pick download path bypassed validation entirely, downloading
the preview anyway.
- The not-found raw-results cache stored unfiltered top-20s.
Lift the preview filter into a reusable filter_soundcloud_previews()
helper and apply it at every entry point: validation scoring (still),
modal-cache fallback when validation drops everything, and the
not-found raw-results path. Previews now never reach the cache, the
matcher, or the manual-pick UI. Drops candidates < 35s or below half
the expected duration, gated on expected > 60s so genuine short
tracks still pass. 7 new unit tests pin the helper.
Also fixed a silent regression in core/downloads/task_worker.py's
hybrid-fallback path. Cin-5 dropped the per-source attrs from the
orchestrator (orch.soulseek, orch.youtube, etc.), but the fallback
loop still resolved sources via getattr(orch, '<src>', None) — every
lookup silently returned None, so remaining_sources came back empty
and the fallback never ran. Now uses orch.client(name) like the rest
of the codebase. Updated the test fake to expose client() too — the
old test was passing because the loop was effectively dead.
SoundCloud serves a ~30s preview clip for tracks gated behind Go+
or login (extremely common for major-label uploads — what's actually
on SoundCloud is bootlegs, fan reuploads, type beats, and these
previews). yt-dlp accepts the preview as the download payload, the
post-download integrity check catches the duration mismatch and
quarantines the file, but the user only sees "all candidates failed"
with no obvious explanation.
Filter at validation time when we know expected_duration: drop
SoundCloud candidates whose duration is below half the expected
length OR within ~5s of the 30s preview boundary, gated on
expected being non-trivially long (>60s) so genuinely short tracks
still pass through.
Two architectural cleanups on top of the download engine refactor.
(1) Shared dataclasses move to neutral plugin package.
TrackResult, AlbumResult, DownloadStatus, SearchResult lived in
core/soulseek_client.py for historical reasons — every other plugin
imported them from the soulseek module just to satisfy the contract,
coupling 8 clients to a sibling source for type imports only. Moved
them to the new core/download_plugins/types.py module and updated all
14 import sites across the deezer/hifi/lidarr/qobuz/soundcloud/tidal/
youtube clients, the engine, matching engine, redownload helper, and
tests. Clean break, no backward-compat re-export.
(2) web_server.py boots the orchestrator via the singleton factory.
After construction it now calls set_download_orchestrator(...) so
get_download_orchestrator() returns the same instance the global
handle points at instead of lazily building a separate orchestrator.
Matches the get_metadata_engine() pattern.
The global handle in web_server.py was named soulseek_client for
historical reasons but the type has long been DownloadOrchestrator,
not SoulseekClient. Renamed the global plus every parameter/attribute
that carried the legacy name.
- web_server.py: global var renamed; all 99 references updated.
- api/, core/downloads/*, core/search/*, core/streaming/*,
services/sync_service.py: parameter names, dataclass fields, and
init() arg names renamed.
- Test fixtures (CandidatesDeps, MasterDeps, SearchDeps, etc.) and
the _build_deps helpers updated accordingly.
The core.soulseek_client module path and SoulseekClient class name
(the actual soulseek-only client) are unchanged — only the orchestrator
handle renamed. Module imports of TrackResult/AlbumResult/DownloadStatus
from core.soulseek_client preserved.
Removed the eight backward-compat attribute aliases on the orchestrator
(soulseek, youtube, tidal, qobuz, hifi, deezer_dl, lidarr, soundcloud).
External callers and the orchestrator's own internals now reach clients
through the generic alias-aware client(name) accessor.
- core/downloads/{master,monitor,validation}.py: migrated to client().
Monitor's per-source aggregation loop replaced with a single
engine.get_all_downloads() call.
- core/search/{orchestrator,stream}.py: migrated; stream.py drops the
hand-built mode-to-client dict.
- web_server.py: migrated /api/deezer/arl-* + tidal client lookup.
- core/download_orchestrator.py: internal self.soulseek /
self.deezer_dl reaches now route through self.client(); attr
assignments dropped from __init__; module docstring updated.
- Test fakes (_FakeSoulseek, _FakeSoulseekWithYT) expose client(name)
instead of stuffing per-source attributes.
- Conformance test re-pinned to the client() accessor contract.
Three correctness fixes from kettui's PR review plus the web_server
migration to generic accessors.
- Engine alias map: register_plugin accepts aliases tuple; get_plugin
+ cancel_download resolve through it. Fixes deezer_dl cancels
silently routing to soulseek.
- Orchestrator hybrid_order normalization: _resolve_source_chain
routes raw config names through registry.get_spec() so legacy
deezer_dl entries don't drop deezer from hybrid mode.
- Atomic update_record_unless_state on the engine: holds state_lock
across the check + write. Both _mark_terminal AND the success path
use it now so a Cancelled state set mid-impl can't be clobbered.
- web_server.py: 30 soulseek_client.<source> reaches migrated to
client("<source>"); shutdown-check setup migrated to generic
registry iteration; 4 hifi reload sites use reload_instances('hifi').
- 18 new tests pin every fix.
Cin's review feedback: external callers reach per-source clients
via attribute access (orch.hifi.reload_instances()) — needs
generic accessors so the registry IS the single source of truth.
Adds:
- orch.client(name) — public accessor for a per-source client.
Resolves canonical names (deezer) AND legacy aliases (deezer_dl).
- orch.configured_clients() — returns {name: client} for every
initialized AND is_configured() == True source. Replaces the
6+ if/hasattr/is_configured chain Cin called out:
if hasattr(orch, 'soulseek') and orch.soulseek and \
orch.soulseek.is_configured(): ...
- orch.reload_instances(source=None) — generic dispatch for
source-specific reload calls. Replaces orch.hifi.reload_instances()
with orch.reload_instances('hifi').
- get_download_orchestrator() / set_download_orchestrator()
singleton factory matching Cin's get_metadata_engine pattern in
PR #498. web_server.py can install the orchestrator it builds
at boot so future callers grab via the factory instead of
importing the legacy `soulseek_client` global.
Phase Cin-3/Cin-4 will replace existing call sites; this commit
just provides the surface so those migrations are mechanical.
Suite still green (335 download tests + 6 new generic-accessor
tests).
Cin's review feedback: the plugin contract was discoverable only
from the registry, not from the client files themselves. Reading
`youtube_client.py` cold gave no signal that the class participates
in the DownloadSourcePlugin contract.
Every download client class now inherits DownloadSourcePlugin
explicitly:
- SoulseekClient(DownloadSourcePlugin)
- YouTubeClient(DownloadSourcePlugin)
- TidalDownloadClient(DownloadSourcePlugin)
- QobuzClient(DownloadSourcePlugin)
- HiFiClient(DownloadSourcePlugin)
- DeezerDownloadClient(DownloadSourcePlugin)
- SoundcloudClient(DownloadSourcePlugin)
- LidarrDownloadClient(DownloadSourcePlugin)
Adjustments:
- core/download_plugins/base.py — moved TrackResult/AlbumResult/
DownloadStatus imports under TYPE_CHECKING since they're only
used in type annotations. Without this, clients inheriting the
contract create a circular import.
- core/download_plugins/__init__.py — drops DownloadPluginRegistry
re-export. Importing the package no longer triggers the registry's
eager client imports (which would also be circular for clients
that import from the package). Callers that need the registry
import it directly: `from core.download_plugins.registry import
DownloadPluginRegistry`.
Suite still green (335 download tests).
Two sites in web_server.py replaced:
- /status route's media-server connectivity check (4-way if/elif
for plex/jellyfin/navidrome/soulsync) → engine.is_connected()
- /api/playlists endpoint's server_connected check (3-way if/elif)
→ engine.is_connected()
Engine reads active_server config + dispatches to the right client
with internal connection caching preserved (the underlying clients
all cache is_connected() calls).
Engine constructor now accepts a pre-built clients={...} dict so
web_server.py wires the same instances as its existing per-client
globals — no double-init.
Suite still green. Per-server clients still accessible via
engine.client(name) for source-specific reaches.
`MediaServerEngine` reads the active server from config + dispatches
to the corresponding registered client. Per-server reaches still
work through `engine.client(name)`.
Required-method dispatch (is_connected, ensure_connection,
get_all_artists, get_all_album_ids) returns safe defaults when
the active client failed to initialize OR when the method raises.
Optional-method dispatch (search_tracks, trigger_library_scan,
is_library_scanning, get_library_stats, get_recently_added_albums)
checks hasattr first — SoulSync standalone has no
trigger_library_scan or get_library_stats, engine no-ops with
appropriate defaults instead of forcing every client to declare
stub methods.
10 new engine tests pin: active-server resolution, required
dispatch routing, exception safety, missing-optional-method
fallback shape. Suite still green (1951 passed).
Engine isn't on any production code path yet — Phase C migrates
the 33 web_server.py dispatch sites to call engine.method()
instead of hand-branching by active_server name.
`core/media_server/` package with the Protocol contract that
every media server client (Plex, Jellyfin, Navidrome, SoulSync
standalone) satisfies, plus the registry that holds them.
Required methods conservatively limited to the four every server
truly implements today: is_connected, ensure_connection,
get_all_artists, get_all_album_ids. Other generic methods
(search_tracks, trigger_library_scan, get_recently_added_albums,
etc.) are listed as OPTIONAL — present on most servers but not
all (SoulSync has no library-scan API since it walks the filesystem
directly; Jellyfin uses a different search shape). Phase B's
engine adapters route around the gaps with per-server fallback
instead of forcing every client to declare a no-op stub.
Same registry shape as the download plugin registry — single
source of truth for which servers exist + name resolution. Adding
a 5th server (Subsonic, Emby, etc.) becomes one register call
plus the new client class.
5 conformance tests pin every server class implements every
required method. Plan doc at docs/media-server-engine-refactor-plan.md.
Pure additive — no consumer routes through the contract or
registry yet. Suite still green (1921 passed).