The Your Albums Discogs collection sync stored bare release_ids while
search/discography now store tagged ('r<id>') ones (#848). This didn't cause a live
bug — the pool dedups by normalized name, and discogs_release_id is only ever
re-fetched (which handles bare via release-first) — but it left the "type travels
with the ID" invariant half-applied. Now the collection sync tags its IDs too, so
every stored Discogs album ID is uniform and a future ID comparison can't be tripped
by mixed forms.
Collection items are always releases, so they're tagged 'r<id>'. Test locks the
stored value + that a tagged collection ID routes only to /releases (never /masters).
Closes the forgot-login-password gap. A per-profile recovery question + answer lets
a locked-out user reset their own password.
- DB: additive recovery_question + recovery_answer_hash columns (idempotent
migration). set/get-question/verify/has methods; answer is hashed (pbkdf2) and
matched forgivingly (trim + lowercase + collapse whitespace). No recovery set →
never verifies.
- Endpoints (allowlisted in the login gate so they work pre-auth):
GET /api/auth/recovery-question?username= (generic 404 when absent),
POST /api/auth/recovery-reset {username, answer, new_password} — brute-force
limited; a correct answer sets the new password + authenticates the session.
POST /api/profiles/<id>/set-recovery (admin or self) to configure it.
Tests: set/get/verify, forgiving match, hashed-not-plaintext, no-recovery-never-
verifies, full reset flow (wrong answer rejected + password intact; correct answer
resets), unknown-user 404. 25 tests pass. Next: the Settings + login-screen UI.
The UI that makes opt-in login usable. Off by default → your LAN setup is unchanged
(none of this appears unless security.require_login is on).
- Login screen overlay (reuses the launch-PIN styling): username + password →
/api/auth/login → reload into the app. Shown when /api/profiles/current reports
login_required (checked before profile selection).
- POST /api/profiles/<id>/set-password (admin, or self) to set/clear a login
password, distinct from the PIN.
- Settings → Security: "Login password (admin account)" field + a "Require login"
toggle (with the anti-lockout note). Wired into the existing settings load/save.
- Sign-out button in the profile bar, revealed only in login mode (login_mode flag
on /api/profiles/current); soulsyncLogout() → /api/auth/logout → reload.
Tests: set-password sets/clears + verifies; /api/profiles/current signals
login_required. 20 login/password tests pass; 64 script-split integrity pass.
Remaining (small follow-up): a password field in the Manage Profiles edit form so
admins can set OTHER profiles' passwords from the UI (the endpoint already exists).
The backend auth for opt-in username/password mode (security.require_login, default
off → zero change; the launch PIN + picker behave exactly as today).
- core/security/login_gate.py: pure gate (mirrors launch_lock) — when login mode is
on, an unauthenticated session reaches only the page shell, /api/auth/login,
/api/auth/logout, /api/profiles/current, /api/setup/status, and the key-authed
/api/v1 API. Deliberately does NOT expose the profile list pre-auth (you type your
name, not pick from a roster).
- _enforce_login before_request enforces it; _enforce_launch_pin no-ops when login
mode is on (login replaces the shared PIN, per design).
- POST /api/auth/login (username = profile name, case-insensitive; brute-force
limited per IP; generic error so names don't leak) + POST /api/auth/logout.
- Anti-lockout: the settings save refuses to turn ON login mode until the admin
account has a password.
Tests: gate blocks→login→access→logout→blocked; case-insensitive username; wrong
password / passwordless profile / unknown user all 401 generically; login list not
exposed pre-auth; can't enable login without an admin password. 12 tests pass. Next:
the login screen + set-password UI + the toggle (increment 3).
Lets SoulSync sit behind Authelia/Authentik/oauth2-proxy as the gatekeeper: when
security.auth_proxy_header names a header (e.g. Remote-User), a request carrying it
is treated as already-authenticated and passes the launch lock — the proxy did the
login (with 2FA).
- core/security/auth_proxy.py: trusted_proxy_user(get_header, header_name) — returns
the user iff the configured header is present + non-empty; empty header name (the
default) → always None → feature off.
- _enforce_launch_pin ORs it into pin_verified. OFF by default, so a direct install
is unaffected AND a client-spoofed header does nothing unless the operator opted in.
- Doc'd in Support/REVERSE-PROXY.md with the must-strip-client-headers warning.
This is the lightweight Tier 3 (auth-proxy integration), not a full per-user login —
the proxy owns identity; SoulSync trusts it.
Tests: helper off/on/blank/exception-safe; integration — trusted header passes the
gate, no header is locked, and (the safety pin) a spoofed header is IGNORED when the
feature is off. 6 tests pass.
A publicly-exposed instance gated only by the launch PIN was brute-forceable. Added
a lenient in-memory failed-attempt limiter (core/security/rate_limit.py): 10 wrong
PINs from one IP within 5 min → 429 with Retry-After, failures age out on their own
(self-heal, no persistent lockout), and a CORRECT entry clears that IP instantly.
Wired into /api/profiles/verify-launch-pin. By design it can only ever trigger on a
flood of WRONG PINs — correct entry, a couple of typos, or a no-PIN install are
never affected, so normal use sees no change. Keyed per-IP so an attacker can't
lock out a legit user.
Tests: limiter is lenient under threshold, trips on a flood, success clears it,
failures self-heal, per-IP isolation; endpoint returns 429 after 10 wrong PINs with
Retry-After. 6 tests pass.
Tier 1 of "secure behind a reverse proxy". STRICTLY opt-in so direct/LAN installs
are byte-for-byte unchanged.
- core/security/reverse_proxy.py: apply_reverse_proxy_mode(app, config_get) — a
no-op unless security.trust_reverse_proxy=true. When OFF (default), the app is
untouched: no ProxyFix, X-Forwarded-* stays UNtrusted (a direct client can't
spoof its IP/scheme), session cookie keeps Flask defaults. When ON (operator is
behind nginx/Caddy/Traefik with TLS): trust one proxy hop's X-Forwarded-*, and
mark the session cookie Secure + SameSite=Lax. Any config error → safe no-op,
never breaks startup.
- Wired once at app init.
- Support/REVERSE-PROXY.md: nginx (with the Socket.IO Upgrade headers people
always miss) / Caddy / Traefik configs, the setting, and the "put auth in front
(Authelia/Authentik/oauth2-proxy)" recommendation + the off-for-plain-HTTP note.
Tests: off (and missing-key, and a config exception) is a strict no-op — not
ProxyFix-wrapped, cookie defaults intact; on wraps ProxyFix + secures the cookie;
and the real web_server app is NOT in proxy mode by default. 5 tests pass.
Per the original intent, "Sync" is now a single-artist deep scan: it uses the SAME
reconciliation source as the whole-library deep scan instead of a separate
disk-existence check.
- Phase 1 already calls the deep-scan worker's _process_artist_with_content; now it
passes seen_track_ids so the pull collects the server's current track IDs for the
artist (existing + new), exactly as the library deep scan does.
- Phase 2 stale = (artist's DB tracks for this server) − seen, then
delete_stale_tracks(server_source) — identical mechanism to deep scan, scoped to
one artist. The old os.path.exists disk check (which could mass-delete on an
unreachable mount) is gone.
- Removal only runs when the server pull SUCCEEDED — no trustworthy 'seen' set
(no server, unreachable, or a failed pull) → skip, never delete. The
is_implausible_stale_removal guard (>50% unseen) stays as the same safety net
deep scan has for a flaky response. @admin_only retained.
Tests rewritten for the server-diff model: removes only tracks the server no longer
has; guard skips when most are unseen; a failed pull skips removal entirely;
admin-only. 8 tests pass.
The enhanced-tab "Sync" button's stale-removal phase deleted any track whose file
wasn't on disk, with NO guard — so if the music storage was momentarily
unavailable (sleeping NAS, dropped mount, unmounted Docker volume, WSL hiccup),
os.path.exists returned False for EVERY file and one click wiped the whole artist
(tracks + their now-"empty" albums) from the DB. The deep-scan path already had a
50%-stale safety net (#828); this endpoint never got one.
- New core/library/stale_guard.py: is_implausible_stale_removal(missing, total) —
a tested rule (skip removal when missing > 50% of a >=5-track set), centralised
so every stale-removal site can share it.
- sync_artist_library: if the guard trips, SKIP removal (delete nothing), return
removal_skipped + warn; the frontend shows "storage may be offline — skipped"
instead of silently deleting. Empty-album cleanup now also only runs on the
non-skipped path and uses `album_id IS NOT NULL` (fixes the NOT IN-with-NULL
no-op). Frontend also refreshes the view on additions, not just removals.
- @admin_only on the endpoint — it deletes tracks + albums but was ungated, while
the sibling delete_album endpoint is gated.
Deep scan was already safe (different mechanism: server-diff + its own 50% guard).
Tests: guard unit rules; endpoint skips removal when all files missing (keeps the
tracks), removes only the genuinely-gone few otherwise, and 403s for non-admins.
7 new tests pass.
Reported via Find & Add (Billie Eilish "bad guy"): the track was in the library
and on Plex, but never showed in the modal's 20 results. Root cause (proven
against the real 307k-track DB): the search did `ORDER BY tracks.title`, which is
case-SENSITIVE in SQLite (BINARY collation sorts 'B' before 'b'). Billie's title
is lowercase "bad guy"; everyone else's is "Bad Guy", so all the capitalised ones
sorted first, filled the LIMIT, and her exact match landed at ~#25 — cut off.
- search_tracks now ranks by relevance: exact title match first (case-insensitive
via unidecode_lower), then prefix, then alphabetical — so an exact match can't
be sorted below the limit by a capital letter. Helps every caller.
- Added a rank-only `rank_artist` hint (never filters): Find & Add already knows
the source track's artist, so it now passes it and the exact title+artist match
floats to #1. Filtering was deliberately avoided — if the track is tagged under
a slightly different artist on the server, a filter would re-hide it.
Verified on the real DB: title-only "bad guy" now surfaces Billie at #4 (was
>#20); with the artist hint she's #1. Seam tests: lowercase exact title isn't
buried; rank hint floats the match without filtering; exact title beats a
superstring title. 10 tests pass.
Automations + auto-sync respect 'append' mode and preserve a server playlist's
description + cover image, but manually matching a missing track ("Find & add")
recreated the whole playlist and wiped them.
Root cause: the add-track endpoint's Jellyfin branch called
`update_playlist(<entire track list>)`, which deletes + recreates the playlist on
Jellyfin/Emby. Switched it to the purpose-built `append_to_playlist([the one
found track])` — the same in-place, dedupe-safe op the 'append' sync mode already
uses — so the playlist (and its description/image) is preserved and only the
missing track is added. append_to_playlist reads `.id` off the track, so the
endpoint now sets it (it previously only set ratingKey).
Plex (in-place addItems) and Navidrome (in-place Subsonic updatePlaylist) were
already non-destructive; Emby routes through the jellyfin branch, so this covers
it too.
Tests: the add-track endpoint appends in place and never calls update_playlist;
a link-to-existing-track touches nothing. 18 tests pass (incl. the existing
append-mode suite).
On fresh page load the Downloads pill now immediately reflects whether
Download Verification is enabled (calls _verifLoadConfig in
loadActiveDownloadsPage instead of only on first filter click).
Also changed /api/verification/config to check the `acoustid.enabled`
toggle rather than the raw api_key string — matches the UI setting
"Enable Download Verification".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ⚠ Unverified filter rows gain actions: inline play (range-streamed from the
history file path, server-side only), YouTube compare, Approve -> new
human_verified status (tag + history + tracks; AcoustID scanner skips these
entirely), Delete (file + entry)
- API: /api/verification/<id>/stream|approve|delete (path only from DB row)
- backfill: history rows with acoustid_result='fail' that exist at all were
imported despite the failure = force_imported (covers pre-fix fallback
imports like the user's 'My Ordinary Life')
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The playlist source registry built the Spotify/Tidal adapters with a client
GETTER (resolved fresh on every read), but web_server passed `lambda: <global
client>`. Swapped those to get_spotify_client_for_profile /
get_tidal_client_for_profile.
Combined with part 1 (the engine running each automation as its owner), an
auto-sync pipeline now reads its source playlist through the OWNER's account:
- interactive sync → the user's session profile,
- background automation → the automation owner (via core.profile_context),
- admin / profile 1 → the global client, so the admin's existing auto-sync
pipelines pull exactly as before.
The adapters re-resolve per read, so a singleton registry is fine. Deezer/Qobuz
getters left global (their playlist login is tangled with downloads — deferred).
Tests: the Spotify/Tidal source adapters resolve the global client under admin
and re-resolve through the profile context per call (unconnected → safe global
fallback). 27 endpoint/profile tests pass.
Background automations had no session, so get_current_profile_id() fell back to
admin (1) — wrong for a non-admin's scheduled job. Now the engine declares the
automation's owner around handler execution via a contextvar
(core/profile_context.py), and get_current_profile_id() consults it only when
there's NO web request. So:
- a real logged-in request always wins (foreground unchanged),
- admin + system automations are profile 1 → resolve to admin exactly as before
(the 8 admin-owned auto-sync pipelines behave identically),
- only non-admin-owned automations gain their correct identity, deep through the
whole call chain (incl. the per-profile client resolvers) — no threading
profile_id through dozens of signatures.
Reset in a finally so a pooled thread can't leak the override to the next job.
Tests: contextvar set/reset/nested; get_current_profile_id honours the override
only outside a request (a real session still wins); and end-to-end — the engine
runs a non-admin automation as profile 4, an admin one as 1, an explicit trigger
profile overrides the owner, and the context resets even when the handler raises.
27 + 4 tests pass.
Part 2 (next): point the sync handlers' source-playlist READ at
get_spotify_client_for_profile so a non-admin's auto-sync pulls THEIR playlist.
Third service (the easy one — ListenBrainz already had a working per-profile
token path). Consolidated all per-profile streaming accounts into the My Accounts
modal:
- My Accounts gains a ListenBrainz row with a token-paste connect (a new 'token'
service type alongside the OAuth-popup ones), reusing the existing
/api/profiles/me/listenbrainz save + the generic disconnect.
- Connections API reports listenbrainz status (connected + username).
- Personal Settings (the gear modal) dropped its Spotify/Tidal/ListenBrainz
sections — those duplicated My Accounts — and now shows only the per-profile
server-library selection (non-admin) or a pointer note (admin). The old
renderPersonalSettings{Spotify,Tidal,LB} functions are left defined but unused.
So every per-profile account connection (Spotify, Tidal, ListenBrainz) now lives
in one place. Tests: LB connect status + disconnect via the generic endpoint.
23 endpoint tests pass; 64 integrity tests pass.
Second service. Each profile connects its own Tidal; its playlist reads use that
account, everything else stays global. The gotcha vs Spotify: TidalClient loads
AND saves tokens to one global slot (tidal_tokens), so a naive per-profile client
would clobber the admin's tokens on refresh.
- get_tidal_client_for_profile builds a dedicated TidalClient seeded with the
profile's tokens, refreshed via the shared/global app creds, and OVERRIDES its
_save_tokens to persist to the PROFILE row — never the global slot. Admin
(profile 1) + unconnected profiles use the global client unchanged. Cached per
profile + evicted on (dis)connect.
- DB: set_profile_tidal_tokens / get_profile_tidal (encrypted); the OAuth callback
now uses them + evicts the cached client.
- Wired the Tidal playlist reads (list + tracks) to the per-profile client; the
module import line left intact.
- My Accounts: Tidal row (Connect via /auth/tidal?profile_id=, status, Disconnect).
Connections API extended; disconnect made generic (/<service>/disconnect).
Admin sees "managed in Settings" for every service.
Tests: per-profile token refresh writes to the profile and leaves the global
tidal_tokens untouched (the safety guarantee); connect status + disconnect;
admin/unconnected → global client. 22 endpoint tests pass.
First service of the per-profile playlist-auth feature. Each profile connects
its OWN Spotify account through the shared (admin's) app, getting its own token;
used for that profile's playlist reads. Admin + unconnected profiles + all
background workers keep using the global/admin client — fully non-regressive.
- Shared-app OAuth: get_spotify_client_for_profile + the /auth/spotify init &
callback now use the GLOBAL app creds (falling back from any legacy per-profile
app creds) with the profile's own token cache, and show_dialog=true forces the
account chooser so a user can't silently inherit the admin's Spotify session.
The builder gates on the profile's own token cache existing — no cache → global.
- My Accounts modal (new, all-profile-accessible via the profile bar): one-click
Connect/Disconnect Spotify + connection status (account name). GET
/api/profiles/me/connections + POST .../spotify/disconnect; admin's Spotify is
read-only here (managed in Settings).
- Wired the request-scoped reads to the per-profile client: the playlist LIST,
the playlist TRACKS view, liked-songs count, and user info — so a connected
user sees and opens THEIR OWN (incl. private) playlists, not the admin's.
Tests: builder falls back to the global client for admin/None/unconnected (the
non-regression guarantee); connections status reports unconnected; admin
disconnect rejected. 124 profile/spotify/gate/integrity tests pass.
Still on the global account (next step): sync/download jobs run in background
workers with no profile context — stamping the requesting profile onto the job
is the remaining wiring. Other services (Tidal/Deezer/Qobuz/Last.fm/ListenBrainz)
follow this same pattern.
Correctness (the modal was lying): "Spotify (no auth)" is a COMPOSITE the
Settings page stores as fallback_source='spotify' + metadata.spotify_free=true,
not a literal 'spotify_free' value. The modal read the raw fallback_source and
showed plain "Spotify" as active even when Settings clearly said "(no auth)".
The endpoint now mirrors that mapping both ways — reports active='spotify_free'
when the flag is set, and switching to it writes fallback_source=spotify +
spotify_free=true (and clears the flag for any other source). Modal + Settings
now always agree.
Visual: the modal itself (not just the cards) is richer now —
- a hero header per tab: big brand-logo disc + "Active <kind> source" eyebrow +
the active name + a one-liner + an Active pill, all tinted by the brand color
with a soft radial glow (the Manage-Workers hero feel);
- the panel gained brand-tinted radial depth instead of flat black.
Test: spotify_free composite round-trips like Settings (stored split + reported
as spotify_free; flag clears on switch). 15 endpoint + 64 integrity tests pass.
Visual rework toward the Manage Workers feel:
- Cards are now circular brand-logo discs on white, with each service's brand
color (Spotify green, Deezer purple, Plex gold, …) driving the logo ring +
active glow/gradient + hover lift. Replaces the flat emoji tiles.
- The left rail is alive: each tab shows its category + the CURRENT active
choice's logo and label (e.g. "Metadata · Deezer"), with the active tab in a
brand-tinted gradient + accent bar — mirroring the worker rows.
Correctness fix (answers "modal says spotify, settings says spotify (no auth)"):
the modal read the RAW configured source, but the rest of the app shows the
EFFECTIVE one. get_primary_source() silently downgrades a configured 'spotify'
to the default (deezer) when Spotify isn't authenticated — so configured and
effective diverge. The endpoint now returns `effective` alongside `active`, and
the Metadata panel shows a note ("Configured source isn't connected — actually
using Deezer right now") whenever they differ. Settings was never broken; the
modal just wasn't showing the resolved source.
78 tests pass (integrity + endpoints); smoke confirms configured spotify →
effective deezer surfaces, spotify_free stays itself.
Replaces the basic credential-pill quick-switch with a Manage-Workers-styled
modal (topbar + left rail + panel, entrance animation, brand-logo cards).
- Sidebar Service Status: whole panel opens the modal; clicking the Metadata /
Media Server / Download rows deep-links straight to that tab. Removed the
"switch ▸" hover text.
- Three tabs: Metadata (source logo cards, unavailable ones dimmed), Server
(Plex/Jellyfin/Navidrome/SoulSync logos), Download (Single⇄Hybrid segmented
toggle; Hybrid shows a draggable priority list). Logos reuse SOURCE_LABELS +
HYBRID_SOURCES; active card gets an accent ring + check.
- Admin writes the GLOBAL active source/server/download (reuses the same setters
+ client reloads as the Settings save, so changes take effect immediately).
Non-admins see it read-only (editable=false) — the per-profile override is the
next layer.
Backend: GET /api/profiles/me/active-sources (any profile; reports editable),
POST /api/profiles/active-sources (@admin_only; validates against the allowed
metadata/server/download lists, applies + reloads). New service-switch.js
(registered + in the integrity registry); old modal removed from
credential-sets.js (admin Connected Accounts manager stays).
Tests: 14 endpoint tests — read shape, admin sets metadata/hybrid+order
(reflected), bad-value 400s, non-admin read-only + 403 on write. 64 integrity
tests pass; real-app smoke confirms render + deep-links + the full set/reflect
cycle.
Adversarial line-by-line review of the feature diff turned up:
- /api/database/update/stop was NOT @admin_only while its sibling start_update
was — a non-admin could abort a library scan. Gated.
- /api/metadata-cache/evict was NOT gated while its clear siblings were. Gated.
- validate_credential_payload now treats whitespace-only values as missing, so
a blank-but-spacey secret can't be saved to fail confusingly later.
Tests updated: both endpoints added to the admin-gating matrix; a whitespace-only
validation case added. 42 credential/gating tests pass.
Review also confirmed (no change needed): migration is idempotent + additive +
O(1); encryption round-trips with a non-dict guard; no SQL injection; stale
selections fall back to None safely; no secret ever returned to the browser;
the hybrid-drag index math is correct in both directions; the new resolver is
fully DORMANT (zero runtime callers) so existing client behaviour is untouched;
and @admin_only is a no-op for single-profile installs (default profile = admin).
Lets any profile pick which admin-created credential set is active for it,
without creating/seeing secrets:
- GET /api/profiles/me/services per-service options (id+label only) +
this profile's selected_id (stale-safe)
- POST /api/profiles/me/services/select {service, credential_id|null}
Not admin-gated by design — it only writes a per-profile pointer and exposes no
secrets. Validates the chosen set exists AND belongs to that service (can't
select a tidal set under spotify), and rejects unsupported services. null clears
back to the global/admin default.
Tests: a non-admin reads options + selects + clears (no secret in the response),
and selection rejects wrong-service / nonexistent / unsupported. 10 endpoint
tests total.
The audit found these were UI-hidden but API-open — any profile (or the
anonymous default-admin) could call them directly. Added @admin_only to the 15
that mutate SHARED/global state:
- DB: update, backup, backups DELETE, restore, vacuum
- library: track DELETE, album DELETE, tracks delete-batch, clear-match
- plex clear-library; metadata-cache clear + clear-musicbrainz
- internal API keys: list, generate, revoke
Deliberately NOT gated: profile-scoped own-data ops like /api/wishlist/clear
(clears the caller's OWN wishlist via profile_id) — gating that would wrongly
block a non-admin from managing their own data. Verified by test.
Zero change for single-profile installs (the default profile IS admin), so
existing users are unaffected; only genuine non-admin profiles get 403.
Tests: non-admin → 403 on all 15 (the 403 fires before the view body, so no
destructive op runs); admin not blocked on the read-only one; wishlist/clear
stays open to non-admins (over-gating guard). 17 tests.
Admin-only CRUD over the named credential sets from Phase 0:
- GET /api/credentials list all sets grouped by service (NO secrets)
- POST /api/credentials create {service,label,payload}, validated
- PUT /api/credentials/<id> update label and/or payload (partial)
- DELETE /api/credentials/<id> delete (clears any profile selections → fallback)
All four are @admin_only (non-admin → 403), payloads validated via
core.credentials.store, secrets never returned to the browser. Additive — no
existing endpoint or behaviour changes.
Tests: real web_server app + Flask test client (8) — create/list/update/delete
roundtrip, payload never leaks in list, missing-field/unsupported-service/blank-
label/duplicate(409)/404 validation, and the non-admin 403 gate on every write.
Verified the web_server import coexists with the rest of the suite (175 mixed
tests pass).
Bumps _SOULSYNC_BASE_VERSION 2.6.8 → 2.6.9, the docker-publish workflow's
default version tag, and adds the 2.6.9 What's New entry (15 items, security
fixes first: #832 launch-PIN enforcement and the settings-secret leak, then
#833/#831/#830/#829/#828/#827/#825/#824/#823/#740, Spotify (no auth), multi-
artist tags, decimal-volume dedup).
Found during the #832 audit: GET /api/settings returned dict(config_data) — and
config_data is DECRYPTED in memory — so every API key, OAuth secret, Plex/
Jellyfin token, and service password went to the browser in cleartext. Fernet
"encrypted at rest" protects a leaked DB file; it does nothing once the API
hands the plaintext to the client (devtools, HAR captures, an XSS, a screen
share, or a non-PIN'd LAN viewer).
Fix (centralized in ConfigManager):
- redacted_config() deep-copies config and replaces every _SENSITIVE_PATHS value
that's actually set with REDACTED_SENTINEL; unset secrets stay empty so the UI
still shows "not configured". Dict-valued secrets (tidal/qobuz OAuth sessions)
collapse to the sentinel too. GET /api/settings now serves this copy.
- set() ignores a write of REDACTED_SENTINEL to a sensitive path, so the masked
placeholder round-tripped by an unchanged settings form can never overwrite
the real secret. A real value still saves; an empty value still clears.
Frontend: secret inputs are type=password, so the sentinel renders as dots
(looks like a saved secret). _wireRedactedSecrets() clears the mask on focus so
editing types fresh rather than onto the sentinel, and re-masks on blur if left
untouched — so an unchanged secret round-trips the sentinel (kept), an edited
one saves the new value, and a deliberately emptied one clears.
Tests: every sensitive path masks; unset stays empty; dict secrets mask; live
config not mutated; sentinel round-trip keeps the real secret; real value
overwrites; empty clears; sentinel on a non-secret path writes normally.
9 new tests; 518 config-touching tests pass (1 pre-existing soundcloud mock
failure, unrelated — fails identically on a clean tree).
the-hang-man: tracks with an apostrophe (e.g. "I'm Upset") deleted the DB row
but left the file. The library DB stored the title with U+2019 (the curly form
Spotify/Apple metadata uses) while the file was written to disk with U+0027
(ASCII). _resolve_library_file_path compared the curly path byte-for-byte via
os.path.exists, missed every time, and reported "could not be deleted".
Fix: resolve confusable-tolerantly. New core/library/path_resolve.find_on_disk
descends the path component by component, taking an exact match when present and
otherwise folding a small set of typographic look-alikes (curly vs straight
quotes, en/em dash, ellipsis, nbsp) for the comparison ONLY — it never renames,
just finds the file that's actually there. Exact matches always win per
component, so paths that already resolved are byte-for-byte unaffected. This
also fixes existing mismatched files (no re-import) and every caller of
_resolve_library_file_path (sidecar cleanup, dead-file checks, streaming), not
just delete.
Case is deliberately NOT folded: a case-sensitive dataset (ext4/ZFS) can hold
names differing only by case, and folding could resolve the wrong file. The
reported failure is purely typographic.
Tests: real temp-file fixtures exercising the actual byte mismatch — curly-DB →
ascii-disk resolves, exact still works, confusable in a folder component, exact
wins when both encodings present, genuinely-different name does NOT collide,
missing file → None. 10 new tests; 949 resolver-adjacent tests pass.
Beckid: the admin launch PIN was a CLIENT-SIDE overlay only. `launch_pin_required`
just told the frontend to draw a fixed div over the app — removing it (Safari
"Hide Distracting Items", devtools, or any non-browser client like curl) gave
full unauthenticated access to every /api/* endpoint, because the server never
checked it. Anyone who reverse-proxies SoulSync publicly was wide open.
Fix: a before_request gate (_enforce_launch_pin) that rejects every request from
an unverified session while security.require_pin_on_launch is on. The decision
is a pure, unit-tested helper (core/security/launch_lock.request_is_locked) so
the allow/deny matrix can't silently regress. Allowed while locked: the page
shell + static assets, the unlock flow (current/list/select/verify/reset/logout),
and the public REST API /api/v1/ (its own @require_api_key governs it) — EXCEPT
/api/v1/api-keys-internal*, the "no auth required" key-management endpoints,
which stay locked so an attacker can't mint an API key and walk in the side door.
Everything else (data, settings, profile create/edit/delete/set-pin, socket.io)
is blocked.
A blocked top-level browser navigation (deep link / refresh on a sub-page like
/dashboard) is redirected to the root lock screen instead of dumping raw JSON —
detected via Sec-Fetch-Mode: navigate / Accept: text/html (is_html_navigation).
Programmatic fetch/XHR still get the JSON 401 so the frontend can react.
Also fixed the verified flag: get_current_profile POPPED launch_pin_verified
(one page load), but an enforced gate needs it to persist — now READ, so
verification lasts the session (until logout/expiry). No-ops entirely when
require_pin_on_launch is off (default).
Tests: full allow/deny matrix + navigation detection. 20 gate tests + 232
profile/security tests pass.
Boulder: the live display was a cramped ~600px box showing a fraction of the
data the scan already tracks, with no animation and no history.
Live scan deck (replaces the three-column box, full width):
- Header: pulsing live dot, "x / y artists" progress text, and two live
counter chips (found / added) that pop when they change.
- Animated progress bar (artist index / total) with a shimmer sweep.
- Stage: artist avatar with accent glow + name + readable phase line
("Checking album 2 of 5"), album art + album + current track.
- "Added to wishlist this run" feed: taller, bigger art, slide-in animation
that plays once per new track (feed re-renders only when it changes).
- All data was already in scan_state (current_artist_index, total_artists,
tracks_found/added_this_scan, current_phase) — just never displayed. The
legacy fullscreen-modal markup shares element ids and lacks the new ones,
so it keeps working untouched.
Scan History (persistent):
- New watchlist_scan_runs table — one row per run (status, timestamps,
artists/found/added counts) + the full track ledger JSON. Saved at scan
completion AND cancellation; idempotent on run_id; pruned to the last 100
runs. Wishlist rows erode as tracks download, so this is the durable record.
- GET /api/watchlist/scan/history (runs) + /history/<run_id>/tracks (ledger).
- New History button on the Watchlist page → modal in the origins/blocklist
house style: run cards (date, cancelled chip, artists/found/added stats)
expanding into the Added / Skipped track lists with art and badges.
Tests: save+fetch with ledger, idempotent re-save, prune keeps newest,
unknown-run empty, cancelled runs recorded. 398 watchlist/wishlist/history
tests pass; JS syntax-checked; all rendered strings escaped.
Tacobell444 (#707 follow-up): the scan summary said "New tracks: 19 • Added to
wishlist: 10" with no way to see which tracks those were — you had to scan your
wishlist and guess what was new.
Scan ledger: the scanner now records a per-run scan_track_events list (track,
artist, album, thumb, status added|skipped — skipped = found-new but declined
by add_to_wishlist: already queued or blocklisted; capped at 500). The status
endpoint already serializes scan_state, so the payload flows free. The
completed (and cancelled) scan summary on the Watchlist page gets a
"Show tracks" toggle expanding a styled list — Added section + Skipped section
with badges, reusing the live-feed row styling.
Download Origins grouping: the modal now groups entries by what triggered them
(watchlist artist / playlist name) with collapsible headers + counts instead of
a flat list with a per-row badge. Entries arrive newest-first so groups order
themselves by their newest download. Same row markup, checkboxes/delete intact.
Provenance: watchlist adds now stamp scan_run_id into wishlist source_info, so
per-run grouping is queryable later (future "what did run X add" views).
Tests: per-run ledger seam test (added + skipped statuses, album/artist fields,
FIFO unchanged). 316 watchlist/wishlist tests pass; JS syntax-checked.
Sokhi: "downloads searching for way too many tracks at once" — a wishlist run
that fanned out into ~one batch per album. Verified the actual search/download
concurrency IS capped at 3 (single shared missing_download_executor), so it
wasn't really hammering slskd — but the display showed ~20 "searching" and the
batch list was a mess.
Root cause: run_full_missing_tracks_process was supposed to "block its album-pool
worker for the whole search+download" (that's what the dedicated album_bundle_
executor is for), but it RETURNED the instant it had STARTED the downloads. So
the album pool only throttled the fast analysis phase — every album batch blew
through analysis and immediately dumped its tracks into the shared download pool,
all pre-marked 'searching'. The intended serialization never happened.
Fix: add serialize= to run_full_missing_tracks_process. Album-bundle batches
(dispatched on album_bundle_executor) pass serialize=True and now hold their pool
slot via _wait_for_batch_drain() until every task in the batch reaches a terminal
state — so only ~N albums are in flight at once. The wait is passive (downloads
are driven by the monitor + completion callbacks on other threads, so no
deadlock) and bails on shutdown, a removed batch, or a safety cap. The residual /
playlist / manual paths run on the SHARED pool and pass serialize=False (blocking
there would steal a real download worker), so they're unchanged.
Tests: _wait_for_batch_drain returns immediately when all-terminal, waits until
tasks finish, bails on shutdown, respects the cap, handles a missing batch. 975
download/wishlist tests pass (only the pre-existing soundcloud /app failures).
carlosjfcasero: "append" sync mode still recreated the playlist (wiping image +
description) on both the sync-page auto-sync and the Playlist Pipeline. Root
cause: _run_sync_task defaulted sync_mode='replace', and every AUTOMATED caller
omits the mode — auto_sync_playlist (mirrored auto-sync + pipeline), the
iTunes-link sync, and Wing It. So those paths always replaced, ignoring the
user's chosen mode entirely. (Manual sync + the per-source discovery path already
passed a mode, which is why it only bit automated runs.)
Fix: when no mode is passed, _run_sync_task resolves the user's configured global
"Playlist sync mode" (normalize_sync_mode(None, playlist_sync.mode)) — the same
thing _submit_sync_task already does — instead of hardcoding 'replace'. The
global default is still 'replace', so users who never changed it are unaffected;
only those who set Append/Reconcile get the corrected behavior.
Tests: normalize_sync_mode(None,'append')→'append' (and 'replace' unchanged);
auto_sync_playlist must not force a mode (no sync_mode kwarg / no 7th positional)
so the resolution can happen. 896 sync/automation/discovery/playlist tests pass.
Part 1 stopped existing full dates being destroyed; this adds first-class support
for full release dates so they can be set + persisted instead of truncated to a
year at the DB layer.
- Schema: new nullable `release_date TEXT` on the albums table (idempotent
ALTER-ADD-COLUMN repair on startup + the live CREATE). NULL = year-only, every
reader falls back to albums.year, so it ships safe/dormant.
- Tag writer: write_tags_to_file + build_tag_diff prefer db_data['release_date']
(the full date) over the year int; _date_to_write writes the full date. When
there's no release_date it's exactly Part-1 behavior (year, preserving an
equally-specific existing file date).
- Retag read path: SELECT al.release_date in the tag-preview/write queries and
thread it into _build_library_tag_db_data.
- Manual edit: release_date added to ALBUM_EDITABLE_FIELDS + a "Release Date"
field (YYYY-MM-DD, validated client-side) in the album editor; the artist-album
query returns it so existing values show. User-set dates are authoritative.
- Enrichment: Spotify + iTunes workers store the source's full release_date
(YYYY-MM / YYYY-MM-DD) when present, only when empty — never clobbering a
manual value.
Tests: writer uses release_date over year + overrides an existing file date;
falls back to year when absent; diff compares the full date. Migration verified
idempotent + enrichment no-clobber. 1435 tag/retag/db/library tests pass.
A parsed link is unambiguously a Tidal/Qobuz /track/ URL (no false positives),
so if its source isn't connected or the track can't be resolved, return a clear
400 ("Tidal isn't connected — … or search by name") instead of silently
running a useless search of the raw URL text. The frontend already surfaces the
400's error message in the modal.
When a track shows "Not found", the manual search now accepts a pasted Tidal or
Qobuz track link, not just a typed query (CubeComming: the fuzzy search misses
versions; he can find the track on Tidal but can't get it to appear).
How it works (robust, reuses the proven path): parse the link → (source,
track_id) → fetch the track via the source client's get_track → build a clean
"artist title (version)" query → run THAT source's normal search → bubble the
result whose id matches the link to the top. So the candidate is a normal,
already-downloadable streaming result — no hand-built download encoding — and
it downloads through the existing verified flow.
Degrades gracefully: if the source isn't connected or the link can't be
resolved, it falls back to a normal text search of the raw input — the user is
never worse off than typing it themselves. Scoped to Tidal + Qobuz (the
streaming sources that download by track id, with public track URLs); Soulseek
can't take a link (P2P, no ids), YouTube/SoundCloud are URL-native via a
different path (future).
- core/downloads/track_link.py: pure parse_download_track_link (tidal/qobuz
/track/<id>, slug/region suffixes, scheme-less) + query_from_track_payload
(per-source title/artist, Tidal version-append).
- manual-search endpoint: link detection → resolve → restrict to that source →
id-match bubble.
- placeholder hint mentions pasting a link; maxlength 200→300 for long URLs.
Tests: 14 (parser shapes + payload extraction incl. remix version-append +
qobuz performer/album-artist fallback). JS valid.
Closes the last acquisition gap — user-initiated downloads. A blocklist isn't
a censor, so search + discography stay fully visible; instead the download
ACTION is gated, visibly and overridably:
- Download modal (start-missing-process): an up-front check — if the WHOLE
album or artist being downloaded is blocklisted, return 409 {blocked:true}
with the entity, before starting a batch. The modal shows "X is blocklisted
— download anyway?" and re-POSTs with ignore_blocklist:true on confirm
(threaded onto the batch so the Phase 2a per-track filter skips it).
Scattered single-track bans still fall through to the 2a filter quietly.
- Manual /api/download (search-result download): source-file-centric, so it
matches the blocked ARTIST by name; same 409 + confirm + override. search.js
now sends artist/title so the guard has something to match.
- Precedence confirmed: force-download overrides "already owned", NOT a ban
(the 2a filter runs on the force-expanded missing list).
Frontend: shared confirmBlockedDownload() helper; modal + search callers
handle the blocked response and retry with the override.
Tests: manual download blocked-by-name / unrelated-allowed / override-passes,
and the modal up-front 409 for a blocked album. 8 blocklist API tests pass.
Completes Phase 1 on top of the backend (43c798a7):
- Cross-source backfill: core/blocklist/backfill.py is a pure injected-resolver
core (resolve only missing sources, never raises); core/blocklist/runtime.py
wires the real metadata clients with a confident name-match (exact
significant-token equality; album/track also require the parent artist when
both expose one — no wrong IDs hung on an entry). Resolution runs
synchronously at add time, so a ban is cross-source from the first scan;
the artist name-fallback in matching covers any gap.
- API: GET/POST/DELETE /api/blocklist (profile-scoped) + /api/blocklist/search
(thin wrapper over the manual-match service search on the active source, so
the modal needn't know the source). Add resolves the other sources before
storing.
- Modal (webui/static/blocklist.js): tabbed Artists/Albums/Tracks in the
revamp design language (accent light-edge, pill tabs, debounced search with
spinner + out-of-order guard, per-result Block, "currently blocked" list
with a match-status star and per-row remove). Opened by a new "Blocklist"
button on the watchlist page, next to Download Origins.
Tests: 5 backfill (fill-missing-only, None/exception handling, arg shape) + 4
API (search proxy, add→backfill→list→delete round trip, validation). Modal
registered in the script-split onclick-coverage test; JS syntax-checked.
Self-review of df929dc0 found one gap: the crossfade preloader hits
/stream/library-audio with the file PATH, which 404s for a streamed (not
disk-mounted) Navidrome track — main playback worked, crossfade didn't.
/stream/library-audio now uses the same _build_library_stream_url fallback on
a disk-miss (resolving the song id from the new track_id param, or a DB
lookup by path), and the preloader passes next.id. Crossfade now works for
streamed libraries too.
Review also confirmed (no change needed): /api/stream/status returns only
status/progress/track_info/error_message — the Subsonic token in stream_url
never reaches the browser; it stays server-side and the browser only hits
/stream/audio. Proxy verified live: Range forwarded, 206 + Content-Range/
Accept-Ranges passthrough, body streamed in 64KB chunks, upstream closed.
mlody95pl: Navidrome sync works, playback fails ("Failed to resume playback").
Root cause: SoulSync plays library tracks by reading the file off its OWN
disk (/api/library/play → resolve path → serve bytes). "Report Real Path"
gives the correct path STRING, but that's Navidrome's container path — the
files still have to be mounted into the SoulSync container to open them, and
the user's compose has /music commented out. So disk resolution 404s.
Navidrome is a streaming server, so requiring a disk mirror to play from it is
the real limitation. Now, when a library file isn't on SoulSync's disk and the
active server is Navidrome, playback streams through the server's own Subsonic
/rest/stream API — no mount needed:
- NavidromeClient.build_stream_url(song_id, max_bitrate) — token-authed
/rest/stream URL (mirrors build_cover_art_url; password never exposed).
- /api/library/play: on disk-miss, _build_library_stream_url (Navidrome-only;
uses the song id sent by the player, or a DB lookup by file_path) sets a
session stream_url instead of failing.
- /stream/audio: proxies that stream_url with Range passthrough so HTML5
seeking works, streaming upstream bytes through in 64KB chunks (no full-file
buffering).
- session state gains stream_url; the two library-play callers now send the
track's server id.
Disk playback is unchanged (file_path path still wins when the file resolves),
so Plex/Jellyfin and mounted-Navidrome setups behave exactly as before.
Tests: 7 on the URL builder (auth shape, no-transcode default, maxBitRate,
guards) + 4 on the play-fallback routing (navidrome-only, passed-id vs
DB-lookup, none). 200 navidrome/stream/media-server tests pass.
Follow-up to 603b7a2a (tokens in the config store): /api/settings GET
returns the whole config dict, which would now include the OAuth access +
refresh tokens. The settings UI has no field for them — strip the key from
the response. dict(config_data) is a SHALLOW copy of live state, so the
section is rebuilt rather than popped in place (verified: response clean,
live config intact, token survives a settings save since POST merges
per-key).
Boulder: "Taylor Swift shows only 8 albums, nothing before 2022, no singles,
no EPs" — for every artist (actually: every WATCHLIST artist). Traced live:
get_artist_albums caches its result under an UNQUALIFIED key (no limit/page
info), and the watchlist's new-release probe (limit=5, max_pages=1 — the
April "reduce watchlist API calls ~90%" optimization) stored its truncated
single page in that same slot. The artist detail page reads the cache first,
so a watchlisted artist's page showed only the newest handful of releases —
newest-first, hence "nothing before 2022" — re-poisoned on every scan, with a
30-day TTL. When the source-priority fetch comes back tiny, the page's
fallback path quietly serves it, so the symptom looked like a discography
filter bug. Not related to the #808 matching change (that is a pure max(),
provably additive).
Three pieces:
- get_artist_albums tracks whether the fetch stopped while more pages
existed (truncated) and only caches COMPLETE discographies. Individual
albums keep their opportunistic caching — they're complete entities
regardless of pagination. A small real discography that fits one page
stays cacheable even under max_pages=1.
- MetadataCache.purge_artist_album_lists(): delete the already-poisoned
album-list entries (TTL would have kept them for weeks); lists rebuild
lazily on the next artist-page visit.
- one-time startup purge in web_server, config-guarded
(maintenance.album_cache_purge_v1), mirroring the startup-repair pattern.
Tests: truncated probe never stores the list (but still returns its page),
complete multi-page fetch caches, and a genuinely-small one-page discography
under max_pages=1 still caches. 1087 spotify/cache/watchlist/artist tests
pass.
carlosjfcasero: 'Champagne Supernova (OurVinyl Sessions)' is in the library
but the artist page shows it unowned and wishlist cleanup never removes it.
Measured with the real catalogs: Deezer/iTunes title the TRACK with the
qualifier while the library track is bare (the qualifier lives in the album
title) — and _calculate_track_confidence crushed that pair to ~0.17: the
"clean" titles keep parenthetical words, so the length-ratio penalty treats
'Champagne Supernova' vs 'Champagne Supernova (OurVinyl Sessions)' as
different songs. (Also confirmed: the OurVinyl release is absent from
Deezer's discography for the artist, so the standard page's 25-release list
not showing it is the source catalog, not a bug.)
Fix 1 — core.text.title_match.strip_redundant_context_qualifiers: a
parenthetical qualifier whose text appears (word-bounded) in the db track's
ALBUM title — or in the other title — restates release context and is
stripped for a comparison variant scored with its own length guard. Genuine
version markers keep their penalty: '(Live)' on a studio album appears in no
context and still blocks; '(Live)' on 'Live at Wembley' correctly matches —
owning the live album IS owning the live cut. Wired into
_calculate_track_confidence, so every check_track_exists consumer (wishlist
cleanup, discography dedup, repair jobs) benefits.
Fix 2 — the artist-page ownership endpoint's album gate: when album-aware
narrowing eliminates EVERY library candidate (the source's album naming just
doesn't resemble the library's — 'Jillette Johnson | OurVinyl Sessions' vs
'Champagne Supernova (OurVinyl Sessions)' ~0.5), fall back to artist-wide
title matching instead of declaring everything unowned off a failed
album-NAME comparison.
Tests: 8 — the exact reported pair end-to-end through check_track_exists,
word-boundary containment ('live' in 'alive' doesn't count), version-marker
safety both ways, and prefix songs still blocked. 1125 matching/wishlist/
library tests pass.
User ask: "a modal that lists the tracks downloaded via watchlist" — extended,
as discussed, to playlists too. One modal, two tabs, opened from the Watchlist
page (watchlist tab preselected) and the Sync page (playlists tab) — same
shared-modal-different-entry-points UX as the rest of the app.
The data: library_history recorded which SERVICE a file came from but never
what TRIGGERED it. New origin/origin_context columns (migration + index) are
written once at the import chokepoint via core/downloads/origin.py, a pure
tested deriver that reads, in priority: an explicit _dl_origin stamp (set at
batch-task creation for direct playlist batches, where the playlist context
otherwise only survived in folder mode), the wishlist provenance already
riding in track_info.source_info (watchlist_artist_name / playlist_name —
watchlist_scanner has stamped these for ages), and the folder-mode playlist
thread. Manual downloads stay unclassified by design. History starts from
now — provenance can't be conjured retroactively.
API: GET /api/download-origins?origin=watchlist|playlist (paged) and POST
/api/download-origins/delete — deletes the file on disk (resolved through the
shared container/host path resolver), the matching library track row, and the
history entries; a file that refuses deletion keeps its row and reports the
error instead of lying.
UI: webui/static/origin-history.js — tabbed modal in the revamp design
language (accent light-edge, pill tabs, entry rows reusing the
library-history-entry components), per-row delete + select-all bulk delete
with honest result toasts, empty/loading states, per-tab totals.
Tests: 8 — deriver priority/shapes (incl. the exact watchlist_scanner
source_info shape and JSON-string survival), origin filtering + counts,
row fetch/delete isolation between origins, delete-track-by-path.
Measured during a live album download: ~4m15s per track in post-processing
(normal is ~20s), with the time vanishing silently inside embed_source_ids —
up to 5 MusicBrainz calls per track crawling against a degraded musicbrainz.org
while the MB enrichment worker kept eating the same ~1 req/s per-IP budget.
Only Spotify/Last.fm/Genius were in the yield set; MusicBrainz, Deezer, iTunes,
Discogs etc. kept grinding through downloads.
Policy (new core/enrichment/yield_policy, tested):
- downloads active -> ALL enrichment workers yield (post-processing touches
every metadata source). listening-stats (local-only) and repair
(user-scheduled) intentionally keep running.
- discovery active -> the API-contention five yield (spotify/itunes/deezer/
discogs/hydrabase) — discovery never paused anything before, despite the
pause helper literally defaulting to label='discovery'.
- user overrides and user-paused bookkeeping keep their existing semantics;
the dashboard yield_reason label now says WHICH foreground work caused it.
Observability (the 4-minute silence can never come back):
- every source lookup is timed; >2s logs a warning NAMING the source and
duration (core/metadata/source.py _call_source_lookup)
- the pipeline always logs "Metadata enhancement took X.Xs" per track
7 policy tests (incl. the motivating case: MB yields to downloads, keeps
running during discovery); 277 pipeline/enrichment tests pass.
A user reports ~0.7 MiB/s RSS growth; the one theory offered so far
(connection leak) was debunked, so instead of guessing: measure. New
core/diagnostics/memory_tracker wraps tracemalloc behind three GET endpoints
the user can drive from a browser:
/api/debug/memory/start begin tracing + baseline snapshot (idempotent)
/api/debug/memory/report top allocation sites by GROWTH since the baseline
(?top=N), with traced totals + process RSS so we
can see how much of the real growth tracing
accounts for; 15-frame tracebacks name the caller
/api/debug/memory/stop end tracing, free trace bookkeeping
Opt-in by design — tracemalloc shadows every allocation while active, so it
never runs by default. RSS via psutil with a /proc fallback.
Tests: report-without-tracking returns a hint (not an error); a real
start->hog->report->stop roundtrip attributes a genuine 5MB allocation to the
test file (fun fact encoded in the test: 'x'*1000 constant-folds into ONE
shared string and traces as ~40KB — the hog must allocate at runtime); the
stat formatter is duck-typed and unit-tested.
The tile's liveness was wired to sync:progress / discovery:progress — both
ROOM-scoped (only clients watching a specific playlist receive them), so the
dashboard tile would basically never light. And the scheduled auto-sync runs
as an automation, reporting on automation:progress — the wrong tile.
The 1s sync emitter now also sends an UNSCOPED sync:active heartbeat while any
playlist work is running anywhere: manual per-playlist syncs (sync_states),
the UI-triggered mirrored pipeline (playlist_pipeline_progress_states), and
scheduled auto-sync pipelines (running automations whose action_type is
playlist_pipeline / sync_playlist / refresh_mirrored). Emitted only while
active; the tile's 6s freshness decay handles the off. The dashboard listens
for the heartbeat alongside the (kept) room-scoped signals.
The payload has carried daily_budget {used, limit, exhausted} forever and the
dashboard rendered none of it. The avatar disc now wears a conic progress rim
that fills as the day's real-API budget is spent — green to 70%, amber to 95%,
red after — and flips purple once the worker has bridged to Spotify Free for
the rest of the day (using_free now included in the emit payload). Tooltip
carries the exact used/limit numbers.
Since the per-listener stream sessions refactor (Phase 3b), every browser gets
its own stream session — but the 1s 'tool:stream' socket broadcast still read
the legacy GLOBAL state (the DEFAULT session no real browser uses), so it told
every client "stopped" forever. The frontend skipped HTTP polling whenever the
WebSocket was up, so it only ever saw that wrong broadcast: the backend prep
downloaded the track, moved it into the session's stream folder and sat at
"ready" while the mini player showed nothing. Proxy users whose WebSockets
don't connect fell back to HTTP polling (session-correct) and streamed fine —
which is why this hid so well.
Fix: stream status is inherently per-listener, so stop pretending a global
broadcast can carry it —
- web_server.py: remove the 'tool:stream' emit from the tool-progress loop
(the broadcast thread has no request context; it can only ever see DEFAULT)
- media-player.js: the status poller always polls /api/stream/status (resolves
the caller's own session from the cookie); drop the dead broadcast handler
- core.js: unwire the 'tool:stream' socket listener
Observability fix that made this undebuggable: core/streaming/prepare.py used
getLogger(__name__) — outside the soulsync.* namespace where handlers attach —
so every prep log line (including failures) vanished from app.log. Moved to
get_logger("streaming.prepare") + a regression test locking the namespace.
34 streaming tests pass; ruff clean; web_server compiles; JS syntax-checked.
The existing fallback (pipeline.py:1084) only ran inside
post_process_matched_download_with_verification — i.e. when a file *was*
downloaded and AcoustID retries were fully exhausted. If the retry *search*
itself found zero valid candidates (source returned nothing, or all failed
HiFi validation), the task was marked not_found and the fallback was never
reached, even though the quarantine already held N version-mismatch entries.
Fix: add try_version_mismatch_fallback to TaskWorkerDeps; in the "no valid
candidates" path of task_worker, invoke it before marking not_found when
is_quarantine_retry. Wired in _build_task_worker_deps via a new helper
(_try_version_mismatch_fallback_for_worker) that calls
try_accept_version_mismatch_fallback directly with the track's title and
artist and a reprocess lambda over _post_process_matched_download_with_verification.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Users manually match an album to the regular edition, but enrichment/
repair keeps treating it as the deluxe (missing songs, renumbered tracks).
Root cause: an album has TWO identities — the enrichment match
(spotify_album_id, which manual-match sets and the worker already honors)
and a SEPARATE canonical version pin (canonical_album_id, added by #777).
The canonical pin is what track-number repair / reorganize / missing-track
detection actually read, and library_manual_match never wrote it — so it
was resolved independently and landed on the deluxe edition.
(So #777 did NOT solve #758: it added canonical pinning, but manual
matches didn't write the pin.)
Fix: a manual ALBUM match on a canonical-recognised source now also pins
AND locks the canonical version to the chosen release:
- new canonical_locked column (same migration pattern as the other
canonical cols).
- set_album_canonical(..., locked=False) gains an atomic WHERE-clause
guard: an auto write can't overwrite a locked pin; a manual write
(locked=True) always wins. get_album_canonical exposes `locked`.
- library_manual_match pins canonical for album matches via the pure
should_pin_manual_canonical(entity_type, source).
The auto resolve job already skips already-pinned albums, so the lock is
protected on two fronts; the new guard also covers any future
re-resolution. A new manual match still overrides.
18 tests: the pure gate (+ a sync-invariant test vs _ALBUM_ID_COLUMNS)
and the DB lock seam (auto can't clobber a manual lock; manual overrides;
auto-over-auto still works). Additive — locked defaults False, so the
auto path is unchanged unless a manual lock exists. Full suite clean.
#798 follow-up. The enrichment worker's own loop already bridges to the
no-creds Spotify Free source during a rate-limit ban (its guard checks
is_spotify_metadata_available()). But the resume button's pre-check
(_spotify_resume_pre_check) blocked resume on ANY rate-limit with no
awareness of Free — so a Free-opted-in user who got rate-limited was
locked out of restarting the worker, unable to fall through to the free
API.
Fix: the resume guard now mirrors the worker. Block only when
rate-limited AND nothing can serve (plain auth, no Free) — where resuming
would just sleep out the ban. When Free is available it serves during the
ban (is_spotify_authenticated() is False while banned, so
is_spotify_metadata_available() reports the free source), so resume is
allowed and the worker bridges via Free, then returns to real auth once
the ban lifts. Stays real-API-first; Free is only the bridge.
The rule is pinned in a pure helper should_block_rate_limited_resume()
next to the other gate functions, with 3 tests. Full suite clean (only
pre-existing soundcloud /app env failures remain).
The post-scan reconcile previously ran AFTER the worker's 'finished'
signal, which flips db_update_state status to 'finished'. Automations
wait for a scan by polling that status, so they stopped waiting before
the reconcile ran — and the dashboard/Tools card showed "Completed" then
flipped to "Reading file tags…". For incremental scans this was
invisible (sub-second); for a full refresh it was a real gap (a chained
automation would fire minutes before the IDs were filled).
Fix: the worker now routes completion through _emit_finished(), which
runs self.post_scan_hook (the reconcile) FIRST, then emits 'finished'.
The hook is injected by the web layer (it owns path resolution). So:
- status stays 'running' through the reconcile,
- the reconcile pushes its phase ("Reading file tags for N new tracks…")
and per-track progress through the SAME db_update_state callbacks the
scan already uses — so automations, the dashboard card, and the Tools
page all see it for free and wait for it,
- 'finished' is emitted exactly once, AFTER the reconcile — race-free, no
status blip a poll could catch,
- best-effort: a hook exception never blocks 'finished', so a scan can't
get stranded as perpetually 'running'.
Both scan entry points (_run_database_update_task, _run_deep_scan_task)
set the hook before run()/run_deep_scan(); the redundant post-run calls
are removed.
5 ordering tests pin the contract (hook-before-finished, finished still
fires without a hook, hook exception doesn't block finished, hook gets
the worker). Full suite clean (only pre-existing soundcloud /app env
failures remain).
Extends the manual "Import IDs from File Tags" backfill so newly-scanned
files get their embedded provider IDs pulled into the DB automatically —
no button press needed to keep up with new music.
How it works:
- insert_or_update_media_track now returns 'inserted' / 'updated' / False
(truthy-compatible; existing `if track_success` callers unaffected) so
the scan worker can tell a genuinely new row from an update.
- DatabaseUpdateWorker collects the ids it newly INSERTED this run
(self._new_track_ids) across all insert paths (Plex/Jellyfin/deep).
- After run()/run_deep_scan(), web_server calls _reconcile_after_scan(),
which gap-fills embedded IDs for just those new tracks. Runs as a
post-scan pass (the scan loop itself is untouched/fast — the media
server API never exposes these custom IDs, so the file must be read
once regardless; batching at the end keeps it out of the hot loop and
best-effort so it can never abort a scan). A progress phase ("Reading
file tags for N new tracks…") surfaces the full-refresh tail.
Shared engine:
- New reconcile_library() in core does the paging + lazy parent-map
loading (only loads albums/artists actually referenced — cheap when
scoped to a few new tracks) + per-page commits. BOTH the manual button
and the scan hook call it, so there's one tested orchestration, no
duplication. The backfill job was refactored onto it.
Same hardened safety: gap-fill only, atomically guarded against
overwrite, schema-introspected, idempotent. Scoped to new arrivals for
incremental/deep; full refresh re-inserts everything as new (recovering
the IDs a full-refresh wipe destroys).
+10 reconcile tests (reconcile_library scope/idempotency/progress/stop +
the engine). Full suite clean (only pre-existing soundcloud /app env
failures remain).
Files SoulSync (or MusicBrainz Picard) already tagged carry Spotify /
iTunes / MusicBrainz / Deezer / Tidal / AudioDB / Genius / Last.fm IDs in
their metadata. Enrichment workers gate their queues on
{provider}_match_status IS NULL, so reading those IDs back and gap-filling
the {provider}_id + match_status='matched' columns lets the workers skip
the API lookup entirely — big API savings on an already-tagged library.
New manual job in Tools -> Database & Scanning ("Import IDs from File
Tags"): scans every library file, reads embedded IDs, fills any that are
missing in the DB. Background job + progress card, mirroring the
write-tags-batch pattern.
core/library/embedded_id_reconcile.py (pure + tested):
- plan_reconcile(): gap-fill plan for a track + its album + artist. Only
empty id columns are planned; a disagreeing embedded id is a conflict,
never applied.
- apply_reconcile_plan(): one guarded UPDATE per id column —
WHERE id=? AND (col IS NULL OR col=''). The guard makes the fill atomic:
if an enrichment worker matched the same entity between our read and
this write, the UPDATE affects 0 rows instead of clobbering it. Columns
are introspected so a schema missing a provider's columns is skipped.
- reconcile_track_row(): per-track orchestration (id extraction, plan ->
apply, keeping the in-memory parent maps fresh for sibling tracks).
Job hardening: paged track scan (bounded memory), per-page commits (don't
starve concurrent workers), per-file try/finally (one bad file can't abort
the run), counters from real rowcount.
Scope: 19 column-fills across 8 providers. MB *recording* (track) id is
left out (UFID frame the reader doesn't surface; Vorbis key ambiguous) —
MB album+artist are covered. Amazon/ASIN deliberately excluded (ASIN is a
different namespace than the worker's amazon_id). All target columns
verified against the live schema.
Purely additive: new module, two new endpoints, one new Tools card —
no existing behavior changed. 20 unit tests (incl. the concurrency guard).
Full suite clean (only pre-existing soundcloud /app env failures remain).
AcoustID returns a recording's title/artist in their ORIGINAL script
(e.g. "久石譲" for Joe Hisaishi) while SoulSync's expected metadata is
romanized/English. A correct download then fails verification on two
walls: the title can never clear the 0.70 similarity bar cross-script,
and the only skip path that ignores the title required a near-perfect
0.95 fingerprint plus a resolved alias. Result: every non-English
artist trips it. Two complementary fixes, per the reporter's two ideas.
Graceful fix (automatic):
- New pure core/matching/script_compat.py detects when two strings are
in genuinely different writing systems (CJK/Hangul/Cyrillic/Greek/
Arabic/Hebrew/Thai vs Latin). Accented Latin (Beyoncé, Sigur Rós)
stays Latin — no false trigger.
- acoustid_verification.py: when the EXPECTED artist and the matched
artist span scripts AND the artist is confirmed via the existing
MusicBrainz alias bridge, SKIP instead of quarantine, without the
0.95 floor (the 0.80 trust floor already gates the fingerprint).
- Deliberately narrow: keyed on the ARTIST spanning scripts + being
confirmed. A same-script artist with only a cross-script title keeps
the stricter 0.95 floor, so the #607 wrong-file protection (Kendrick
R.O.T.C, low-fingerprint Japanese-title) is untouched.
Per-request toggle (manual escape hatch):
- New "Skip AcoustID verification" checkbox in the download-missing
modal beside "Force Download All".
- skip_acoustid threads request -> batch -> per-track track_info ->
download context (same path as _playlist_folder_mode), landing on
the existing _skip_quarantine_check='acoustid' bypass. No new
mechanism; only the AcoustID gate is bypassed (integrity/bit-depth
still run).
Tests:
- tests/matching/test_script_compat.py — script-boundary cases.
- test_acoustid_skip_logic.py — Joe Hisaishi SKIPs at 0.85; unconfirmed
cross-script artist still FAILs; same-script low-fingerprint still
FAILs.
- test_downloads_candidates.py — toggle injects the bypass; absent
toggle keeps verification.
Full suite: 5169 passed; only pre-existing soundcloud /app env failures
remain. Zero regressions.
The Settings dropdown reverted 'Spotify Free' because _isMetadataSourceSelectable
reads _lastStatusPayload.spotify.free_installed, but that payload comes from the
WebSocket status:update push (_build_status_payload) — which sent the raw spotify
dict. The availability flags were only added to the GET /status endpoint, so the
frontend never saw free_installed and bounced the selection.
Extract _spotify_status_with_availability() (metadata_available + free_installed)
and use it in BOTH _build_status_payload (WebSocket) and get_status (HTTP poll),
so they can't drift. Now 'Spotify Free' is selectable when SpotipyFree is
installed.
Consistency fix: Spotify Free is now its own entry in the metadata-source
dropdown (alongside Spotify / iTunes / Deezer / MusicBrainz) instead of a
side-toggle. Stored as fallback_source='spotify' + spotify_free=true so all
downstream 'spotify' routing and the spotify_* columns are unchanged.
Refined gate model (no toggle):
- Connected user (has credentials) -> official; bridges to free AUTOMATICALLY
during a rate-limit ban (no opt-in needed).
- No-auth user -> must pick 'Spotify Free' in the dropdown; then free serves.
- Never opted into Spotify (no creds, didn't pick it) -> free never runs, so no
surprise scraping. _free_wanted() = has_credentials OR picked-spotify-free is
the guard.
- AUTHED + healthy -> official always; free never opens.
UI: dropdown gains 'Spotify Free (no credentials)' (selectable when the package
is installed — surfaced via status.free_installed, since selecting it is the
opt-in and can't depend on having selected it); load/save map the dropdown value
to the (fallback_source, spotify_free) pair; old checkbox removed.
Gate model pinned by 6 scenario tests (connected/healthy, connected/ratelimited
bridge, no-auth picked, no-auth not-opted-in, package-missing). 117 tests green.
Surfaces the opt-in Spotify Free source so it's usable end-to-end:
- Settings: 'Enable Spotify Free (no credentials)' toggle that saves
metadata.spotify_free (load + save wired). Clear best-effort/limitations note.
- config-status: adds spotify.metadata_available (configured OR free-available),
keeping the configured flag = has-credentials so the Connections indicator
stays honest. Search source picker shows Spotify when metadata_available.
- status payload: adds spotify.metadata_available; the Settings primary-source
selector now allows picking Spotify when authed OR free-available.
Verified gate composition: OFF by default (no surprise scraping); ON + no auth +
installed -> available & serving; AUTHED -> official always wins (free never
runs); missing package -> gracefully unavailable. JS + integrity + 111 tests green.
A manually-fixed mirrored track silently reverted to 'Wing It' after re-running
discovery. Two compounding causes:
- extra_data is MERGED on save (update_mirrored_track_extra_data), and the
manual-fix DB write (web_server.py) didn't clear the prior wing_it_fallback
flag — so a track fixed after being a Wing It stub kept wing_it_fallback=True.
- the Playlist Pipeline pre-scan checked wing_it_fallback BEFORE manual_match
(if/elif), so the stale flag won: the track was re-discovered and, on a miss,
fell back to Wing It — discarding the user's pick.
Fix: extracted the pre-scan gate into core.discovery.manual_match.should_rediscover
(manual_match checked FIRST = authoritative, regardless of leftover flags), and
the manual-fix write now also clears wing_it_fallback/unmatched_by_user. Behavior
is identical for every other branch — only the manual-vs-wing-it ordering changes.
Tested at the seam incl. the exact regression (wing_it_fallback + manual_match
both set -> skip). 227 discovery tests green.
After the duplicate-id ambiguity guard, an owned artist reached via a source
link (e.g. Kendrick's Deezer link) fell back to the bare source-only view
instead of the rich library view, because the URL path carries no name and the
duplicated id alone can't be matched.
get_artist_detail now resolves the source artist's name (reusing the #775
link-resolver's per-source artist fetch) when the id lookup is ambiguous and no
name was passed, then retries the library upgrade by name. So an owned artist
lands on the full library view; a genuinely-unowned one still renders
source-only (now with its name pre-resolved). Unique ids are unaffected.
Follow-up to the bare-ID footgun: a bare number like 525046 carries no
source and no entity type, so it resolved to whatever album happened to own
that id (a user pasting Kendrick's Deezer artist id got an unrelated album).
Now the resolver accepts provider URLs (and the explicit spotify: URI) only;
a bare/unrecognized string is rejected and the dropdown surfaces a hint to
paste a full link. URL parsing + album/track resolution are unchanged.
New 'Link / ID' input on the Search page: paste a Spotify / Apple Music /
MusicBrainz / Deezer URL (or a bare ID) and it's looked up directly on the
owning source — no fuzzy search, no scoring.
- core/search/by_id.py: source-agnostic parser (URL domain/path or bare-ID
format -> source,kind,id; numeric IDs fan out, first hit wins) + per-source
get-by-id dispatch + adapters projecting each provider's dict onto the
standard album/track card shape.
- /api/enhanced-search/by-id: thin additive route over resolve_identifier.
- Frontend: dedicated input that adopts the resolved source as active and
renders through the existing dropdown + download/import flow.
Purely additive — existing files are insertion-only; the resolver runs only
behind the new route. 29 seam tests cover parsing, shaping, fan-out, and
not-found.
The real path for discovery-modal syncs (Spotify-Public/Tidal/Deezer/Qobuz/
YouTube/iTunes-link/ListenBrainz/Beatport) is _start_source_sync ->
_submit_sync_task, which called _run_sync_task WITHOUT a sync_mode — so it used
the 'replace' default and never consulted the setting. (My earlier fix only
covered /api/sync/start, which these endpoints don't use — hence the log still
showing POST /api/spotify-public/sync/start ... mode: replace.)
_submit_sync_task now resolves the configured default via normalize_sync_mode
and passes it through, so all source syncs honor Settings > Playlist sync mode.
start_playlist_sync validated the resolved mode with 'if sync_mode not in
(replace, append): sync_mode = replace' — a pre-existing clamp two lines below
the config read I added, which silently downgraded a configured 'reconcile' to
'replace'. So config=reconcile resolved correctly then got clobbered, and every
sync ran replace regardless of the setting (and incognito didn't help — it's
backend, not cache).
Replace the hand-rolled clamp with a pure normalize_sync_mode(requested,
configured) helper (VALID_SYNC_MODES includes reconcile) so the resolution is
testable and can't silently drop a mode again. Regression tests cover
reconcile-from-config, request-overrides-config, and unknown->replace.
Replace mode (default) deletes + recreates the server playlist every sync,
which wipes its custom image, description, and identity. Add an opt-in
'reconcile' sync mode that edits the existing playlist in place — adds the
tracks now in the source, removes the ones gone — without destroying the
object, so the user's custom art/description survive.
- Pure planner plan_playlist_reconcile(current, desired) -> {add, remove}.
- Per-client reconcile_playlist: Plex addItems/removeItems on the same object;
Navidrome Subsonic updatePlaylist delta (songIdToAdd / descending
songIndexToRemove); Jellyfin add + remove-by-PlaylistItemId on /Playlists/{id}/Items.
- sync_service: reconcile branch with a replace FALLBACK (if a server's in-place
edit is unavailable/fails, sync still succeeds destructively — logged loudly).
- Default stays 'replace' (no behavior change). New Settings > Playlist sync mode
picker (replace/reconcile/append) backed by playlist_sync.mode; per-request
sync_mode still overrides.
- Reconcile skips the post-sync source-image push so a custom poster isn't
re-clobbered (the bug).
Tests: planner (add/remove/dedupe/order/empty) + reconcile-or-replace dispatch
(success / false-fallback / exception-fallback / no-method). Per-server in-place
API calls need dev validation against real Plex/Jellyfin/Navidrome.
NOTE: opt-in only; default behavior unchanged.
The mlm_save endpoint already fetches+validates the library track, so capture
its file_path and persist it on the manual match. This makes matches created
via the Manual Library Match tool re-resolvable after a rescan re-keys the
track (#787), matching the durability the Find & Add path now has.
Find & Add on the playlist-sync page only wrote sync_match_cache, which is
DELETEd wholesale after every DB scan — so the source->library pairing (and
the user's manual matches) reverted to 'extra'/red-dot on the next shallow
scan. The three match stores (sync_match_cache, manual_library_track_matches,
discovery extra_data) were disconnected and all pointed at tracks.id, which a
rescan re-keys (esp. Jellyfin/Navidrome GUIDs).
Unify the match so it's one durable fact, recorded once, honored everywhere:
- Find & Add also writes a durable manual_library_track_matches row (one-way;
the manual-match tool has no playlist to act on, so no reverse). Carries the
library file path.
- New library_file_path column (idempotent migration) + find_track_id_by_file_path:
re-resolve a stale library_track_id after a rescan re-keys the track, and
self-heal the row.
- The sync compare display's override lookup now falls back to the durable
manual match (resolve_durable_match_server_id) when sync_match_cache misses —
so the pairing persists across a scan instead of reverting to a red dot.
Purely additive: only adds matches when the cache returns nothing.
Tests: durable resolver (valid / stale-reresolve+self-heal / no-match / not-in-
playlist / missing-methods), file_path persistence + find_track_id_by_file_path.
The old per-download Retag Tool was limited (only native-pipeline downloads,
100-group cap, manual per-group) and did the wrong thing — it moved/reorganized
files instead of just tagging. It's superseded by the new Library Re-tag job
(whole-library, in-place) + the enhanced-library 'Write Tags' button.
Removed: the post-download record_retag_download ingestion hook (stops writing
retag_groups on every download), core/library/retag.py, the web_server state +
deps + /api/retag/* endpoints + the tool:retag WebSocket emit, the dashboard
card + both modals (index.html), the core.js socket handler, and the tools-page
wiring + help entry (wishlist-tools.js). Updated the import-pipeline test.
Verified: web_server parses, app + core imports OK, 392 tests pass, no live
references to removed symbols.
Left as inert (harmless) for a careful follow-up sweep: the retag_groups/
retag_tracks tables + their DB CRUD methods (no longer written/read), and the
now-orphaned retag JS helper functions (no entry point/wiring/socket calls them;
interspersed with wishlist functions, so not blind-deleted).
write_tags_to_file wrote the core fields + cover but never the source IDs
(Spotify/iTunes/MusicBrainz) the import post-process embeds. Added a focused
source.embed_known_source_ids() that writes ALREADY-KNOWN ids (from db_data)
via the canonical, Picard-compatible frame writer the import uses
(_write_embedded_metadata) — no API re-fetch, frames correct by construction.
write_tags_to_file now calls it whenever db_data carries id keys.
Fed from both paths: the enhanced-library 'Write Tags' button now carries the
track's spotify/itunes/musicbrainz ids, and the Library Re-tag job stamps the
matched album/track source ids onto each track. So both now write the full tag
set, not a subset.
- Bump _SOULSYNC_BASE_VERSION 2.6.5 -> 2.6.6 (the single source of truth that
propagates to the UI, backups, and the update check).
- Add the 2.6.6 What's New block (qBittorrent 5.2.0 login fix, Cover Art Filler
on-disk detection + file embedding + stricter matching, recommendations
explainability + Discover section, organize-by-playlist #780, nav/scroll perf
#783, dashboard mobile polish).
- Finalize the 2.6.5 block: it shipped in tag 2.6.5 but was left flagged
unreleased (so its notes never displayed) — stripped the flag + dated it per
the file's own release convention.
Adds get_recommendation_sources() — for each recommended similar artist it
resolves the polymorphic similar_artists.source_artist_id back to the display
names of the user's OWN artists (library + watchlist) that list it, by matching
against every provider-id column on both tables. The /api/discover/similar-artists
endpoint now attaches a 'because' array per recommendation so the UI can show
'because you have X, Y, Z' instead of just a count.
Seam tests cover: library + watchlist resolution across different provider-id
columns, dedup + name-sort, max_per cap, orphan source omission, profile scoping.
THE root cause of 'orb frozen, click does nothing visibly': when a socket is
connected, the orbs don't poll — update*Status() bails on socketConnected and
relies on server pushes. similar_artists was missing from BOTH the server emit
loop (_emit_enrichment_status_loop's workers dict) and the client dispatch
(core.js socket.on('enrichment:<id>')), so the orb never received status → never
updated. Clicks DID pause the backend (modal showed paused), but the orb visual
was frozen. Added the worker to the emit loop + the socket.on handler.
- get_stats now reports PERSISTENT counts from the DB (matched/not_found/pending
+ a progress.artists breakdown) instead of in-memory session counters, so the
dashboard orb tooltip and the Manage modal agree (was showing 0 vs 14 after a
restart) and it survives restarts — same approach as the other workers.
- Orb tooltip reads progress.artists ('Artists: 14 / 15 (93%)') like the rest.
- Worker now defaults to ON (running) instead of opt-in-paused; still honors a
saved pause across restarts. It self-paces (~3s/artist) and backs off on
MusicMap outages, so the orb spins/active like the others when there's work.
10 seam tests pass.
SoulSync standalone matches library tracks without Plex fetchItem,
reports missing counts correctly, and skips server playlist writes.
Automation re-syncs when the mirror grows; after sync finishes, starts
organize download (organize-by-playlist) or wishlist processing.
UI: Spotify URL playlist-folder controls, organize toggle layout in the
discovery modal, reload organize preference when reopening Download Missing.
Co-authored-by: Cursor <cursoragent@cursor.com>
Closes the gap where similar artists only existed for WATCHLIST artists: a new
background worker populates them for the whole LIBRARY, slotting into the
existing enrichment-worker pattern (bubble + Manage Enrichment Workers modal,
status/pause/resume, matched/not_found/pending/errors).
Per source-matched library artist → get_musicmap_similar_artists(name, 25)
(the same matcher the artist-detail page uses: fetches MusicMap names, matches
each to the user's source chain — primary + active fallbacks — returns only
matched artists) → store via add_or_update_similar_artist keyed by the artist's
metadata source id, the SAME key the watchlist scanner + artist map use, so the
two cooperate (idempotent upsert + retry_days window).
- core/similar_artists_worker.py: pure seams (pick_source_artist_id,
map_payload_to_store_kwargs, process_artist) + the threaded worker; skips
artists not yet source-matched; classifies not_found vs transient error
(retry after 30d).
- DB migration: similar_artists_match_status / _last_attempted on artists
(mirrors every other source worker's tracking columns).
- Registered in EnrichmentService + instantiated in web_server, DEFAULT-PAUSED
(opt-in) like Amazon — MusicMap is scraped/outage-prone + this is library-wide.
- SERVICE_ENTITY_SUPPORT['similar_artists']=('artist',) so the modal breakdown
('artists with / without similars') + Retry work; manual-match (inapplicable
to a relationship) is gated out via relationship:true.
- 10 seam tests; existing 80 enrichment tests still pass.
Note: keys under profile 1 (single-profile setups); multi-profile is future work.
The on-canvas overlay text can't be copied (and can't be grabbed mid-freeze), so
when perf mode is on ('d'), the frontend now also POSTs the render timings to
/api/discover/artist-map/perf ~1.5x/sec, which logs them as [ARTMAP-PERF] in
app.log. Lets the bottleneck be diagnosed from the server side with no manual
copying.
Persist organize_by_playlist on mirrored playlists and run playlist-folder
downloads from the auto-sync pipeline instead of the global wishlist phase.
Register SoulSync library rows after playlist-folder post-processing, route
failed organize batches to the wishlist correctly, and skip sync-time
unmatched wishlist only when organize download handles retries.
Invalidate stale playlist track caches on refresh (Spotify and Deezer ARL),
re-mirror on refetch, and improve standalone playlist modals (re-analysis,
Open in Mirrored). Add filesystem missing-track detection and tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
The no-auth 'add by link' path scrapes Spotify's embed widget, which only ever
contains ~100 tracks and can't paginate — so big public playlists got
truncated. This adds an in-house anonymous fetch that pulls the FULL list:
- core/spotify_public_api.py: reads the anonymous web-player accessToken Spotify
already embeds in its own open.spotify.com page HTML (no app credentials, and
no rotating TOTP secret for us to maintain), then paginates
/v1/playlists/{id}/tracks 100 at a time until the whole playlist is pulled.
Returns the embed scraper's exact shape. Pure helpers + injected http_get so
it's unit-testable without the network.
- core/spotify_public_scraper.fetch_spotify_public(): tries the full fetch for
playlists; on ANY failure (or for albums) falls back to scrape_spotify_embed.
Worst case == today's behaviour, so the link path can't regress.
- web_server: the link-tab endpoint and the authed flow's last-resort scrape
now both go through fetch_spotify_public.
Scoped entirely to the spotify_public_* (no-auth) path — the authenticated
playlist sync is untouched. 11 tests (token extraction, normalisation,
pagination past 100, and the embed-fallback orchestration).
Caveat: rides Spotify's undocumented page-embedded token — expected to break
when they change their page; it degrades to the embed fallback, never crashes.
Needs a live click-through to confirm the token path works end to end (can't
hit Spotify from the test env).
Per-worker processing-order override + UI polish.
Feature — pin an entity group to enrich first:
- Each worker normally runs artist -> album -> track. A user can pin one
group (artist/album/track) to run first from the modal; the worker keeps
that group first until it's exhausted, then resumes the normal chain.
- core/worker_utils.py: read_enrichment_priority() (reads
<service>_enrichment_priority each loop, live) + priority_pending_item()
(shared, whitelisted query returning the worker's expected item shape;
Spotify/iTunes get album_individual/track_individual via a type map).
- A guarded ~6-line hook at the top of all 11 workers' _get_next_item.
CRITICAL: when nothing is pinned (default) the hook returns immediately,
so default enrichment order is byte-identical to before. Discogs (no track)
and Genius (no album) only honor their supported entities.
- core/enrichment/api.py: GET/POST /api/enrichment/<id>/priority (+ config_get
hook); POST validates the entity against what the source enriches.
- 14 new tests (helper shapes, exhaustion, route get/set/clear/validate).
UI:
- Refined hero header: identity + inline status left, single Pause right,
'now enriching' quiet sub-line; overall coverage % moved into the stats
section ('82% matched · 1,203 of 1,460'). Hero gently pulses while running.
- New processing-order strip: artist→album→track steps showing the live phase
(pulsing 'now'), pinned group ('first' + 📌), and done/remaining; click a
step to pin it, click again for auto.
py_compile clean across all 11 workers; 52 enrichment tests green.
Dashboard 'enrichment bubbles' could pause/hover but offered no way to
*manage* a worker. This adds a full management modal opened from a new
header button, covering all 11 enrichment sources.
Backend (testable core helper + seam tests; no live-DB dependency):
- core/enrichment/unmatched.py: pure, whitelisted SQL builders for the
unmatched browser. service/entity validated against a support map (never
interpolated raw); search + pagination bound as params; tracks join albums
for artwork; limit capped at 200.
- database/music_database.py: get_enrichment_unmatched() +
get_enrichment_breakdown() (the breakdown splits matched/not_found/pending,
which the existing get_stats().progress lumps together).
- core/enrichment/api.py: GET /api/enrichment/<id>/{unmatched,breakdown} on
the existing blueprint + a db_getter hook.
- web_server.py: wire db_getter=get_database.
- tests/enrichment/test_unmatched.py: 19 tests across builders, DB methods,
and Flask routes.
Frontend (vanilla, matches app conventions):
- webui/static/enrichment-manager.js: worker rail with live status + coverage
micro-bars, accent-themed detail panel (hero header, segmented matched/
not_found/pending stat cards, current item, pause/resume), and a searchable
paginated unmatched browser with inline manual match (reusing
search-service + manual-match) and retry (clear-match re-queues).
- Polish: entrance/exit motion, scroll-lock, Escape, refresh control,
flicker-free polling (in-place updates), skeleton loaders, relative
timestamps, per-worker accent theming, real dashboard logos reused at
runtime (with the same invert/circle treatment), responsive rail.
- index.html: header button + script include. style.css: full styling.
Reuses existing pause/resume, status, and manual search+assign endpoints.
Backend tests green (19 new + 11 existing enrichment tests).
The sync editor renders server covers as <img src="/api/navidrome/cover/{id}">,
but no Flask route ever served that path — so every Navidrome cover 404'd, on
every album, art or not. The source (left) side then went blank too: a source
row with no native art (e.g. YouTube, which provides none at mirror time) falls
back to borrowing the matched server track's cover — i.e. that same dead route.
So both sides collapsed to nothing.
Fix:
- New NavidromeClient.build_cover_art_url(cover_id) — builds the absolute,
Subsonic-authenticated getCoverArt URL (base_url + token/salt), keeping
credentials server-side. Uses a FIXED cover-art salt so the URL is
deterministic for a given (server, password, cover_id): a rotating salt (as
in _generate_auth_params) would make every request a unique URL → image-cache
miss every time + a dead, never-reused cache row per fetch. Token auth doesn't
require a unique salt, and the password is never exposed (only its salted md5).
- New route /api/navidrome/cover/<cover_id> — resolves that URL and streams the
image through the shared image cache (same pattern as /api/image-proxy), with
a private max-age so the browser caches by the stable route URL.
Effect: server side works for any album that has art in Navidrome; matched
source rows with no native art now borrow the (now-working) server cover.
Unmatched YouTube rows stay blank — no image exists anywhere to show.
Tests: tests/test_navidrome_cover_url.py (8) — URL structure + salted-token auth
(never the raw password), determinism (same id -> same URL so the cache hits;
different id/password -> different URL), optional size, and the not-connected /
no-id / no-credentials guards.
Caveats: not executed against a live Navidrome (no server in CI) — the URL
builder is unit-tested; the route's cache→HTTP→bytes round-trip is read-verified
only. Scope is the sync editor's Navidrome route; Plex/Jellyfin server-cover
branches and any other modals using a different mechanism are untouched.
Three compounding bugs hit tracks whose source metadata is YouTube/streaming-
shaped — title "Artist - Song", artist "Official Artist"/"Artist - Topic"/
"ArtistVEVO" (reported: "Arctic Monkeys - Do I Wanna Know?" by "Official Arctic
Monkeys"). Server-agnostic — affects Plex/Jellyfin/Navidrome, not just the
reporter's Navidrome.
Bug A — the match fails. The confidence scorer and the editor's reconcile both
compared the raw "Artist - Song" title against the library's clean "Song"; the
length-ratio penalty + floor drove it to ~0.18 (NO-MATCH), so the track showed
unmatched while its server copy showed as an orphan "extra". New pure
core/text/source_title.py (clean_source_artist / strip_artist_prefix /
canonical_source_track) strips the channel/video decoration, applied at BOTH
matching seams: services/sync_service._find_track_in_media_server (tries raw
then canonical, keeps the best) and the editor reconcile. Conservative: a title
prefix is stripped only when it equals the artist, so "Self-Titled", "Jay-Z",
and "Marvin Gaye" (by another artist) are untouched, and the canonical form is
an additional best-of candidate so it can only help.
Bug B — manual matches never persisted. get_server_playlist_tracks built the
per-source entry WITHOUT source_track_id, so "Find & add" posted an empty id
and _persist_find_and_add_match returned early. The match reverted to "extra"
on reload and re-adding looped. The editor's 3-pass matcher is now lifted to a
pure, tested core.sync.playlist_reconcile.reconcile_playlist that includes
source_track_id (the frontend at pages-extra.js:1836 already reads + sends it).
Bug C — manual match duplicated + delete wiped all copies. "Find & add" always
inserted, so linking a source to an already-present server track appended a
duplicate (pos 72, 73...); remove filtered out EVERY entry with the target id.
New pure core.sync.playlist_edit (plan_playlist_add: link-don't-duplicate when
the target is already present; remove_one_occurrence: drop a single copy) wired
into the Plex/Jellyfin/Navidrome add + remove branches.
Tests (extreme): tests/test_source_title.py (35), tests/test_playlist_reconcile.py
(11 — incl. the reported case, parity for override/exact/fuzzy/extra, and
duplicate-server handling), tests/test_playlist_edit.py (12). 286 matching/sync
tests still pass.
Caveats: the sync_service change and the add/remove/editor endpoints are
read-verified, not executed against a live media server (none in CI). The pure
cores they call are exhaustively unit-tested; output-shape parity of the
reconcile lift is covered. Delete removes the first matching copy (duplicates
are identical, so harmless).
After an update, installs became unusable: the Amazon enrichment worker runs by
default, the default public T2Tunes proxy (t2tunes.site) was returning
503 'Amazon Music API is not initialized', and the worker treated every album
as an individual error -- logging an ERROR per item, churning network + DB
continuously across the whole library, and marking every row 'error' (a state
the retry tiers never re-attempt, so even after the proxy recovered nothing
re-enriched). The reporter couldn't reach the UI to turn it off.
Two-part fix:
1. Source-outage circuit breaker (core/amazon_outage.py, pure + tested):
- is_source_outage(exc) distinguishes a whole-source outage (HTTP 5xx,
'not initialized', connection failure, non-JSON error page) from a real
per-item miss (404, transient 400, etc.).
- On an outage the worker now leaves the item UNTOUCHED (so it's retried once
the proxy recovers instead of being permanently burned to 'error'), logs
ONCE per streak, and backs off with next_poll_delay_seconds() -- escalating
30s -> 60s -> ... capped at 30 min -- instead of grinding every 2s. It
auto-resumes the normal cadence the moment the source answers (success OR a
non-outage error both clear the streak).
- AmazonClientError now carries status_code so detection doesn't rely on
message parsing.
2. Opt-in by default (web_server.py): amazon_enrichment_paused now defaults to
True. Because enrichment depends on an external public proxy that can be
down, it stays paused unless the user explicitly enables it -- a proxy outage
can no longer take down installs that never opted in. (Behaviour change:
anyone on the old auto-on default is now paused; re-enable in Settings.)
Together: on update the worker is paused -> no flood -> UI accessible; opted-in
users are protected from future outages by the breaker.
Tests: tests/test_amazon_outage.py (12) pin the classifier across every error
surface (incl. the exact 503 'not initialized' case) and the back-off schedule
(monotonic, capped). 157 Amazon tests pass; lint clean.
Note: could not reproduce the exact 'UI fully unreachable' mechanism remotely
(WAL + 8 gthreads shouldn't hard-lock); the fix removes the flood/churn that is
the practical cause and defaults the feature off.
Lets users pick which providers' cover art to use and in what priority,
generalizing the single prefer_caa_art toggle into an ordered, mix-and-match
list (Sokhi's request). Fully opt-in: default album_art_order is [], so every
existing install is byte-for-byte unchanged until the user enables sources.
How it works:
- Per album, walk the user's ordered sources top-to-bottom; the first source
that actually has THIS album's cover wins. A miss falls through to the next;
if all miss, the download's own art is kept (today's default). The worst case
is always exactly the cover you'd get today -- never wrong art, never an
error into the download.
- Connection-gated: a source is only tried when the user is connected to it
(free sources CAA/Deezer/iTunes/AudioDB always; Spotify only when
authenticated). Tidal/Qobuz/HiFi deferred (cover-URL construction + no clean
core accessor -- not shipping unverified extraction).
- Album-match validated: a source's art is used only when the album it returns
matches the requested artist+album (significant-token subset, tolerant of
Deluxe/Remastered/articles/feat./multi-artist). A loose top search hit for a
different record is treated as a miss -> guarantees no wrong-album art.
- The list supersedes the legacy prefer_caa_art toggle: when album_art_order is
non-empty it is the sole authority (add 'caa' to the list to use Cover Art
Archive), and prefer_caa_art is neutralized for both the embedded-tag art and
cover.jpg paths. With an empty list, prefer_caa_art behaves exactly as before.
Implementation:
- core/metadata/art_sources.py: pure resolver -- effective_art_order (config +
legacy back-compat) and resolve_cover_art (ordered walk + fallback,
exception-safe per source). No network/config/DB; fully unit-testable.
- core/metadata/art_lookup.py: availability gating, per-source lookups against
existing clients (Deezer/iTunes/AudioDB/Spotify search + CAA via MBID),
album-match validation, per-album caching, and select_preferred_art_url --
the single gate the pipeline calls (no-op unless an explicit list is set).
- core/metadata/artwork.py: wired into embed_album_art_metadata and
download_cover_art, gated so no configured list == current behavior.
- web_server.py: GET /api/metadata/art-sources (connected sources only).
- config/settings.py: default album_art_order: [].
- webui (index.html + settings.js): reorderable list in Core Features reusing
the hybrid-source-list pattern + real service logos (with emoji fallback);
load/save wired through the existing metadata_enhancement settings flow.
loadArtSourceOrder populates the saved order synchronously (filtered to known
sources, not availability) so a save before the availability fetch resolves,
or a temporarily-disconnected source, can never wipe the saved order.
Tests: 40 unit/seam tests (resolver ordering/fallback/back-compat, availability,
per-source extraction, album-match validation incl. wrong-album/wrong-artist
rejection, caching, exception-safety, the off-by-default gate). Full metadata
suite still green (610 passed) -- the gated integration changes nothing when no
list is configured.
Note: the settings UI (DOM-heavy, not unit-testable in the JS harness) and the
live per-source art-fetch quality are validated by manual testing.
New GET /api/downloads/task/<id>/detail merges the live download task with its
library_history row (the data the Download History cards show) into one payload
the upcoming track-detail modal renders: status kind, title/artist/album,
source, quality, final location, AcoustID verdict, and expected-vs-downloaded.
Assembly + status classification live in core/downloads/track_detail.py as a
pure, importable build_track_detail()/classify_status_kind() (9 unit tests);
the endpoint is thin glue that looks up the matching history row by track.
Accepting a quarantined item re-imported the file correctly, but the download
modal kept showing 'Quarantined'. The re-import ran through the inner pipeline,
which doesn't mark task completion (that's the verification wrapper's job), and
the sidecar context had no task_id anyway (popped before quarantine).
The chooser's Accept now sends the originating task_id, and the endpoint
re-runs the import through the verification wrapper with that task_id (+ batch_id
looked up from the task), so the task is marked completed only after the file is
verified moved — the row flips to Completed on the next poll. Manager-tab
approvals (no task_id, no JSON body — handled via get_json(silent=True)) keep
the original inner-pipeline path.
Also clear has-candidates + the quarantine dataset on every status render so a
row that goes quarantined -> completed doesn't keep a stale chooser attached.
Clicking a quarantined track's status used to open the generic search modal,
identical to a plain failure — no way to review or recover the file. It now
opens a chooser:
- Listen: streams the file in-app via a new /api/quarantine/<id>/stream
endpoint (range-supported; the real audio Content-Type is recovered from the
sidecar since the on-disk file ends in .quarantined).
- Accept & Import: existing /approve (restore + re-import, gates bypassed).
- Search for a different result: the existing candidates modal (old behavior).
Non-quarantine failures (not_found / failed / cancelled) are unchanged — a
single click listener routes by dataset set at render time, so a task that
fails then later quarantines can't end up double-bound.
Also fixes the Accept failure on Windows: the Listen stream holds an open file
handle, so the subsequent restore move hit WinError 32 ('file in use') and the
endpoint mislabeled it 'thin sidecar'. Accept now releases the audio handle
before approving, and approve/recover moves retry briefly on transient OS locks
(_move_with_retry). Accept also auto-falls-back to Recover-to-Staging for
genuinely thin/orphaned sidecars.
Tests: stream-info resolution (sidecar + filename-fallback + missing), and
_move_with_retry success/give-up.
The save endpoint coerced library_track_id with int(), which rejected
every non-numeric id with "Invalid library track id". Library ids are
str(ratingKey) — numeric for Plex but GUIDs/hashes for Navidrome,
Jellyfin, and other Subsonic servers — and are stored in the TEXT
tracks.id column, so the coercion broke manual matching on every
non-Plex server.
Replace the int() coercion with a normalize_library_track_id() helper
that trims and rejects only empty input, passing the opaque string id
straight through. Plex numeric ids are unaffected (SQLite INTEGER
affinity still stores a clean numeric string as an int, so existing
matches are byte-identical) and no schema migration is needed (the
INTEGER column already stores non-numeric ids as text).
Tests: pure-helper cases (numeric/GUID/whitespace/empty) plus a real-DB
round-trip proving a GUID id saves, reads back unchanged, and enriches.
Post-incident hardening. A WAL-mode DB corrupted (most likely an interrupted
write during a hard restart), and the backup routine made it unrecoverable:
it (a) never checked integrity, so src.backup() faithfully copied the corrupt
pages into every rolling backup, and (b) pruned oldest-by-mtime, so each new
corrupt backup evicted the last good one. Result: all snapshots poisoned.
New core/db_integrity.py (pure, unit-tested):
- quick_check()/is_healthy(): fast read-only PRAGMA quick_check probe.
- safe_backup(): verifies the SOURCE is healthy BEFORE the Online-Backup copy
and the RESULT after; refuses + discards rather than save a corrupt copy.
- prune_backups(): rotation that NEVER deletes the most-recent verified-healthy
backup, even to honor max_keep — so a run of bad backups can't drop your last
good snapshot.
Wired into BOTH backup paths (the /api/database/backup endpoint and the
auto_backup_database automation handler) — they now refuse on integrity failure
(409 / error status, existing backups untouched) and prune safely.
Tests: tests/test_db_integrity.py (8) using REAL temp DBs incl. a physically
corrupted one — proves refuse-corrupt-source, discard-corrupt-result, and the
exact incident scenario (newest backups corrupt -> the older healthy one is
protected from pruning). Existing maintenance-handler backup test still green
(29 passed). compile + ruff clean.
NOTE: this prevents silent backup poisoning; it does NOT stop the underlying
corruption. Follow-ups still worth doing: WAL-checkpoint on clean shutdown +
a periodic live-DB integrity alert (so corruption is caught on day 1).
listening_history was populated ONLY from the media server; the web player
recorded nothing. Now a play heard ~10s logs to listening_history AND bumps
tracks.play_count/last_played — so the existing 'recently played' query reflects
actual SoulSync listening, and the Phase-2 smart-radio recency signal gets real
data.
- core/playback/play_log.build_play_event(): pure, DB-agnostic normalizer from
player payload -> listening_history event shape. Caller supplies the
timestamp (stays pure). Composite/streamed ids never become the int
db_track_id; bool ids rejected; missing title -> skip. 9 unit tests.
- MusicDatabase.record_web_player_play(): inserts the history row + increments
play_count/last_played for the library track in one call.
- /api/library/log-play: thin endpoint, server-side timestamp, best-effort
(logging failure never 500s / never affects playback).
- Frontend: npMaybeLogPlay on timeupdate fires once per track at the 10s
threshold (flag reset in setTrackInfo, set-before-fetch so it can't
double-fire), fully fire-and-forget.
Pure builder is unit-tested; the DB write can't run in-sandbox (real DB throws)
so it's a thin straightforward insert+update. JS + web_server parse clean.
Self-audit of the revamp surface found real bugs, now fixed:
- DOUBLE-ADVANCE race: crossfade starts ~6s before track end, but when the
track actually 'ended' fired, onAudioEnded ALSO advanced — two skips.
onAudioEnded now bails when npXfadeActive (crossfade owns the advance).
- STRAY CROSSFADE on manual skip/stop: skipping or stopping mid-fade left the
interval running, firing npFinishCrossfade on top of the manual change, and
left the second <audio> playing. Added npCancelCrossfade() (clears the timer,
tears down the 2nd audio, restores main volume) called at the top of
playQueueItem and in handleStop. The fade interval also self-checks
npXfadeActive each tick. npFinishCrossfade clears all flags cleanly so the
legitimate handoff isn't treated as an abort.
- stream_start: moved 'global stream_background_task' to function top (it was
declared inside an if-block — parsed, but brittle/bad form).
web_server parses; 76 streaming+radio tests pass; JS syntax clean; CSS balance
unchanged from HEAD.
Wires the StreamStateStore (Phase 3a) into the live routes so each browser/
device gets its OWN playback instead of every client sharing one global
stream_state. Fixes the long-standing limit where a second tab/device/listener
would hijack the one playback.
- _stream_session_id(): stable per-browser id stored in the Flask session
cookie (falls back to DEFAULT when no request context — e.g. the socket
broadcast thread — so single-user behavior is identical).
- _current_stream_state(): the StreamSession for the calling browser.
- Routes rewired to the caller's session + its own lock: /api/library/play,
/api/stream/start, /api/stream/status, /stream/audio, /api/stream/stop.
- Background tasks tracked per session (stream_tasks[sid]) instead of one
global Future, so stopping/replacing one listener's stream doesn't cancel
another's. Executor bumped 1 -> 4 workers so concurrent listeners don't
queue behind each other.
- Per-session staging: named sessions stage under Stream/<sid>/Stream so
listeners never clear each other's files; default session keeps flat Stream/.
- /stream/status now returns THIS listener's state, which is what the frontend
polls — so per-listener works without touching the socket broadcast (left
serving the default session, now vestigial for status).
Isolation invariant covered by tests/streaming/test_stream_state_store.py
(distinct sessions independent, default stable). The route-level cookie wiring
+ actual two-client no-collision behavior need live multi-client verification
(can't be tested without booting Flask + separate cookies) — EXPERIMENTAL,
needs Boulder to test with 2 browsers/devices. 33 streaming tests still pass;
web_server parses.