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.
The lock-in pass caught it before it shipped anywhere: the pill styling set
display:inline-flex on the status cells — which are <td>s — knocking them out
of table-cell layout and corrupting the row grid. The pill is now a centered
pseudo-element painted BEHIND the text (z-index -1 inside the cell's own
stacking context), so the cell's box model is untouched. State colors stay as
CSS vars on the td and cascade into the pseudo.
Also covers the secondary live-progress writer discovered in the same pass:
it stamps legacy download-downloading / download-complete classes instead of
data-state — both vocabularies now get the same pills (accent + breathe while
downloading, green when complete).
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 status cells were the last plain-text corner of the revamped modal, and
they're the most alive data in the app during a run. The renderer now stamps
data-state on the download-status cell and toggles .row-working on the row
(visual-only hooks; zero logic change). CSS turns both status columns into
state-colored pills — accent while searching/downloading, amber processing,
green completed, red failed, orange quarantined — and ONLY the actively-
working states breathe (opacity, compositor-only). The working row carries the
same accent edge treatment as hover, but earned by real work instead of the
mouse. prefers-reduced-motion respected.
- entrance: soft rise + settle, one-shot spring
- header light-sweep: the dashboard's signature strip (same keyframes, same
transform-only technique) drifting across both modal headers
- progress sheen: a light band scanning the FILL — it lives inside the fill's
clip, so zero progress shows nothing and motion is gated by real progress
- hero stats become glass chips with per-state color identity (found green,
missing amber, downloaded accent) and a top light-edge each
- download modal's close X matches the discovery one (circular ghost, rotates)
- press feel on every pill button (active scale)
- all of it honors prefers-reduced-motion; only transform/opacity animate
Both modals were functionally perfect but visually dated — flat dark panels,
heavy table grids, the discovery modal still wearing its legacy RED border.
Pure CSS override layer appended last in the cascade; markup and JS untouched.
Same design language as the dashboard pass, theme-aware via --accent-rgb:
- deep glass surface with an accent light-edge along the top
- progress bars -> rounded inset tracks with gradient accent fill + glow
- tables -> micro-label sticky headers, calm hairline rows, accent hover
glow with an inset edge bar, themed thin scrollbars, accent checkboxes
- download modal: the two stacked progress bars become side-by-side glass
cards; tracks toolbar with a pill selection counter; glass footer with
pill buttons (gradient primary, ghost secondary, soft-red danger)
- discovery modal: red border killed, kicker typography header, circular
rotating close button, carded progress + table, matching pill footer
CI builds with GHA layer caching (cache-from/to: gha), and the nightly RUN's
cache key is just the instruction text — the layer could pin a months-old
"nightly" across releases, silently defeating the channel's whole purpose.
Referencing COMMIT_SHA (already passed by docker-publish.yml) in the RUN makes
every new commit bust that one layer while everything above it stays cached.
YouTube now gates downloadable formats behind JS challenges (nsig); yt-dlp
needs a JavaScript runtime (Deno — its only default-enabled one) to solve
them, and its STABLE channel can lag months behind a breaking YouTube change.
Users hit "Requested format is not available" on every stream and music-video
download with no hint why. Neither piece is fixable via requirements.txt:
Deno isn't a Python package, and a version floor can only ever resolve stable.
- Dockerfile: install Deno in the runtime image (official installer,
auto-detects amd64/arm64, build fails early via `deno --version` if it
breaks; unzip added for the installer) and build with the yt-dlp NIGHTLY
channel (`pip install -U --pre "yt-dlp[default]"`) on top of requirements
- core/youtube_client.py: one-time startup warning when deno isn't on PATH,
naming the exact failure it causes and the install command — instead of
letting users debug a cryptic yt-dlp format error
- requirements.txt: annotate the yt-dlp line with the stable-lag caveat, the
nightly upgrade command, and the Deno requirement
- README: Deno + nightly notes in Prerequisites and the Python (No Docker)
install section; Docker bundles both automatically
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 three bento tiles had signature background animations that were pure
decoration. Each now SURGES while its subsystem is actually working, driven by
the live socket events — idle keeps the exact calm look they always had:
- Auto-Sync: the EQ bars dance fast + brighter, the playhead sweeps quicker
and the pulse dot races while a sync/discovery pipeline is running
(sync:progress / discovery:progress)
- Tools: the gear spins up 4x and brightens while a tool, scan, db-update or
repair job is running (tool:* / scan:media / repair:progress, with a shape-
tolerant "actually running" check so the 1s idle pushes don't light it)
- Automations: the flow nodes + line signals pulse at 2.5x while an automation
is firing (automation:progress)
Tiles carry .is-live while the last matching event is <6s old; a 2s interval
handles decay (no rAF, no per-frame JS).
GPU pass on the same tiles, same visuals:
- hero playhead animated `left` (layout + paint every frame, 9s loop) -> a
full-width strip whose 1.5px line is a static background, transform-only
- flow-node pulse animated background + box-shadow x3 nodes -> bright state
painted once on a pseudo, opacity breathes; added to reduced-motion kills
Audit of every dashboard animation. Already good and untouched: orb canvas
(cached glow sprites, no shadowBlur, stops on tab-hide/page-switch/scroll),
shimmer scan, sidebar orbs, embers, rl-blink (all transform/opacity), and the
reduce-effects global kill-switch. The offenders were infinite animations of
paint-bound properties — each repaints its region every frame, forever:
- avatar halo: animated box-shadow on every active bar -> the bright state is
painted once on a wrap pseudo and only its OPACITY breathes (the wrap exists
because the avatar clips overflow)
- rate-limited warn: animated filter:brightness -> a white-wash pseudo whose
opacity breathes
- active-fill glow: animated box-shadow -> static glow at the old midpoint,
breathing moved to the tip's opacity
- header sweep: animated background-position across the full-width band (on
all four headers sharing the class) -> a real child strip translated inside
an overflow-clipped wrap; transform+opacity, zero paint
- orb canvas: renders at ~20fps while fully asleep (drift is at crawl speed —
invisible) instead of 60fps for the hours the dashboard sits idle
Visual parity throughout; peak-flash (event-driven, 0.65s one-shot) keeps its
box-shadow since its duty cycle is negligible.
1-3 tiny accent sparks per socket update drift up from each bar's fill tip,
scaled by the real (unclamped) rate — motion strictly means API calls are
happening right now. Self-removing DOM nodes with a per-bar live cap of 6,
suppressed during cooldown and under reduced-effects mode. The taste-risk one
of the set: revert this commit alone if it reads as noise.
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.
A banned service used to just tint red. The payload carries the seconds
remaining (rl_remaining), so the bar now locks into a cooldown state: the live
VU dims, a red column drains away as the ban ticks down (largest remaining
seen is latched as the denominator — only remaining is sent), and an m:ss
timer counts to recovery. The moment the ban expires the track flashes green
('recovered') and the VU takes the stage back. 'Back in 4:00', not
'something's red'.
A thin accent marker sticks at each bar's recent maximum, holds ~1.2s, then
falls a few percent per update until it rests on the live fill — exactly how a
hardware VU meter's peak LED behaves. A traffic burst stays readable for a few
seconds after it's over instead of vanishing with the next 1s sample. Hidden
while it sits on the fill so idle bars don't carry a stray line.
The inactive->active transition (caught at the existing 30-frame state
refresh) now jolts the orb: a random-direction velocity kick with a briefly
lifted speed cap so it actually darts, a fast shallow size wobble (~1s decay),
and three sparks. A worker waking up reads as 'it sprang into action' instead
of just getting brighter.
Zero active workers + no pulses in flight for 75s eases the whole header into
a drowse (~4s): nucleus dims to embers, logo and orbs fade ~35-45%, spokes and
links fade ~70%, drift slows to a quarter speed (velocities keep integrating
so motion stays continuous). The first sign of work wakes it in ~0.3s with
three staggered rings blooming out of the nucleus. Idle and busy finally look
DIFFERENT — the contrast is what makes activity read as alive.
Each inbound pulse draws 3 fading ghost positions behind its head. The path is
parametric (eased t), so the tail is the same easing evaluated slightly in the
past — no position history, and it naturally stretches as the pulse accelerates
into the nucleus. Makes the energy flow legible at a glance.
A telemetry pulse used to just vanish on arrival. Now it makes contact: a small
expanding ring at the rim where it hits (pulse's own color, ~0.5s fade) plus
two debris sparks splashing back along the approach direction — the nucleus
reads as absorbing the work instead of deleting it. Capped pool (24), reset
alongside sparks/inflows on page switches and collapses.
Each worker orb gets a slow z-oscillation (-1 back .. +1 front); orbs draw in
painter's order with the hub pinned at z=0, so the cluster visibly swings
behind and in front of the nucleus instead of drifting on a flat plane. Depth
scales size ±18% and dims the back arc; the physics stay 2D. Effect eases out
during the hover-expand morph so orbs land on their buttons at natural size.
On page load every orb sat at the canvas origin: orbs are created at (0,0) and
the random scatter skipped orbs that weren't visible yet — which on load is all
of them, since the header hasn't been laid out when init() runs. The whole
cluster then drifted in from the top-left corner.
scatterOrbs() -> centerOrbs(): spawn the cluster dead-center of the canvas,
positioning every orb regardless of visibility (a few px of jitter on purpose —
the separation force ignores pairs closer than 0.1px, so a perfect stack would
never split). enterOrbState() also re-centers right after resizeCanvas(), so
activations get the true center even when init ran against a hidden 0x0 header
— and returning to the dashboard replays the center-bloom intro.
"Liked Songs" isn't a real Spotify playlist — no playlist URI exists for it;
the web UI invents the virtual id 'spotify:liked-songs' and Spotify serves the
collection via the saved-tracks endpoint. The playlist DETAIL endpoint special-
cases that id, but the mirrored refresh path resolves stored ids through
get_playlist_by_id, which fed the virtual id straight into sp.playlist() ->
"http status: 400 ... Unsupported URL / URI" on every sync cycle, silently.
get_playlist_by_id now special-cases the virtual id at the client seam (every
by-id resolver benefits, not just the mirror adapter): it builds the Playlist
from the existing get_saved_tracks() pagination, with the real owner name and
track count. New LIKED_SONGS_PLAYLIST_ID constant owns the magic string.
Safety: get_saved_tracks swallows fetch errors into [] — indistinguishable
from "no likes" — and the virtual playlist is only ever offered when likes
exist. An empty result therefore resolves as a FAILED refresh (None) instead
of a valid-looking empty playlist a mirror sync might propagate by clearing
the server-side copy.
Tests: virtual id resolves from saved tracks and never touches the playlist
endpoint, real ids still do (regression), the mirrored adapter seam returns a
full PlaylistDetail, and empty saved-tracks -> None. 473 passed across the
playlist/mirror/spotify families.
On path-mapped setups (Docker mounts etc.) the scan checked a bare
os.path.isfile() on the raw DB path — false for EVERY track — while the apply
handler resolves container/host mismatches. With a cover-art mode set, the
cover_action kept the album past the "anything to do?" gate, so every album
produced a finding with an empty tracks list whose apply could only ever fail
with "No tracks to re-tag in finding".
- the scan now resolves each track path with the same resolver the apply
handler uses (resolve_library_file_path) before reachability checks and
current-tag reads; plans carry the resolved path
- a finding can never be created with zero tracks — cover-action albums with
no usable tracks are skipped, with a debug log of why (unreachable/unmatched
counts) and the counts surfaced in the finding description
- unmatched-but-reachable tracks now get an art-only plan (empty db_data) so
album cover art covers ALL the album's files, not just source-matched ones —
apply_track_plans already treats empty db_data as a pure cover embed and
counts a failed cover download as skipped, never failed (now locked by tests)
- cover-only findings are titled "(cover art, N track(s))" instead of the
puzzling "(0 track(s))"
Tests: +5 (mapped paths resolve into plans, cover-with-nothing-reachable
creates no finding, unmatched -> art-only plan, art-only plan embeds cover,
failed cover download -> skipped). 87 passed across retag/repair/tag_writer.
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.
A single enriched against the deluxe gets every source ID pointing at the deluxe,
so the organizer filed it as e.g. track 2 of a 10-track album. Root cause: the
canonical resolver only ever scored the editions already linked — the correct
single was never even a candidate, and the misfit deluxe scored so low (0.1,
below the 0.5 floor) that nothing got pinned and the priority-walk grabbed the
deluxe anyway.
Fix, in three tested layers:
- resolve_canonical_for_album gains a fetch_alternates seam: when no linked
edition clears the floor, it scores the source's OTHER editions of the same
release and re-picks by best fit (dedup, injected, pure).
- default_fetch_alternates lists the artist's editions and keeps the same-release
ones (edition-blind name match: Deluxe / - Single / [Remastered] all collapse),
returning their tracklists. Favors recall; the scorer is the precision gate.
- _resolve_source does the misfit check inline: it fit-scores the walked edition
and only on a clear misfit searches for a better edition, then persists the pin
on apply (Track Number Repair + future runs agree). Cost-neutral and behavior-
identical for well-fitting albums (no extra API calls); strict_source and the
#758 manual lock are never overridden.
Tests: +4 resolver (expand/no-expand/dedupe/back-compat), +7 alternates (name
matcher + fetcher over fake APIs + cap), +3 organizer end-to-end (misfit->single
+pin, well-fit->no-expand, strict->no-expand). 300 passed across the reorganize
+ canonical family, lint clean.
User report: "Not authenticated with Spotify" daily + workers paused. The
log was misleading — is_spotify_authenticated() returns False for five
distinct reasons (no creds / rate-limit ban / post-ban cooldown / no
cached token / probe failure), and all five API call sites logged the
same bare "Not authenticated with Spotify". So a routine rate-limit ban
read as a logout, and the real cause (logged only at DEBUG) was invisible.
New pure describe_spotify_unavailable() maps the real state to a clear
message (priority matches is_spotify_authenticated): not-configured →
rate-limited ("ban ~Nm left (not a logout)") → post-ban cooldown →
no-token ("not connected — re-authenticate") → "auth check failed (token
refresh may have failed)". A side-effect-free client method
(_auth_unavailable_reason) gathers the live state (reads the cached token,
no API probe) and the 5 sites log it.
Now a daily ban is identifiable as a ban, and a genuine logout is
identifiable as one — so reports like this are diagnosable from the log
alone. 7 tests pin the priority/messaging. Full suite clean.
User request: the re-tag diff card shows old→new metadata per track but not
the file it applies to, so a wrong match is hard to spot before applying.
The finding already carries each track's file_path (details.tracks[].file_path
from the scan) — the renderer just wasn't showing it. Now each changed track
displays its filename beneath the title, so you can eyeball that the metadata
about to be written actually belongs to that file. Skipped when the label is
already the filename (track had no title). Pure frontend display change.
#798 follow-up. The worker's 500/day budget is a REAL-API ban shield, but
when it was hit the worker paused outright — even for a Spotify-Free user
with the uncapped free source available. So "I'm on Spotify Free" still
got capped overnight. The intuition is right: if it's ever using Spotify
Free, the budget shouldn't apply.
Fix: spent budget now becomes a third "use free" trigger (alongside
no-auth and rate-limited). When the real-API budget is exhausted and the
free source is available, the worker switches to free (uncapped) for the
rest of the day instead of pausing, then reverts to real-first on the
daily reset.
- should_use_free_fallback gains a budget_exhausted arg (free activates on
no-auth OR rate-limited OR spent-budget).
- the worker sets _budget_exhausted_use_free on ITS OWN client (a separate
instance from the search client — verified, so user searches still use
real auth), and clears it when the budget resets; _free_active() honors
the flag.
- get_stats() using_free reports the budget-bridge too, and the dashboard
bubble shows "Running (Spotify Free)" instead of "Daily Limit Reached"
(budgetStuck = exhausted AND not bridging).
A no-free user still pauses on the budget (nothing to bridge to). A pure
free-only worker never increments the budget at all. New gate test pins
the budget_exhausted trigger. Full suite clean.
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 Spotify enrichment worker's daily budget and post-ban
cooldown both exist to protect the REAL authenticated API from bans. But
the worker bridges to the no-creds Spotify Free source during a ban (a
different, anonymous path), and those guards weren't free-aware:
- the budget guard slept the worker when the daily cap was hit, blocking
free work for no reason,
- free-served items still incremented the budget counter, draining the
real-API cap with calls that never touched the real API (so you'd
return from a ban with budget already spent), and
- the post-ban cooldown slept the worker even when serving via free.
Fix: compute free_serving = client._free_active() once per loop
(defensively wrapped → False on error → original behavior). When
free_serving:
- skip the daily-budget guard,
- skip the post-ban-cooldown guard,
- don't increment the daily budget.
So the budget is now strictly a cap on REAL Spotify API usage; free runs
unthrottled by it (the free client keeps its own inter-call pacing).
The decision input (_free_active) is already pinned by the gate-model
tests (free True exactly when rate-limited+Free / Free-primary, False when
authed+healthy). Full suite clean (only pre-existing soundcloud /app env
failures remain).
#798 follow-up. When the real Spotify API is banned but the worker keeps
matching via the no-creds Spotify Free source, every status surface still
read the literal rate_limited=True flag and showed "Rate Limited /
waiting Nm" — so the dashboard bubble looked paused/stuck even though the
worker (visible in Manage Workers) was actively matching.
- spotify_worker.get_stats() adds a `using_free` flag: rate_limited AND
is_spotify_metadata_available(). Computed ONLY when rate-limited, where
is_spotify_authenticated() returns False without an API probe, so the
2s status loop pays no quota cost.
- Dashboard bubble (enrichment.js): when using_free, the bubble is
'active', the tooltip says "Running (Spotify Free)" and "Now: X (via
Spotify Free)" instead of "Rate Limited / Waiting Nm". Clicking it
pauses (works) rather than hitting the resume-blocked toast.
- Manage Workers (enrichment-manager.js): status pill shows "Running
(Spotify Free)"; the warning banner is replaced with a calm "matching
via Spotify Free until the ban lifts" note.
The flag flows through both feeds (the /api/enrichment/spotify/status
poll and the WebSocket enrichment:* push) since both serialize
get_stats(). Genuinely-stuck (no-free) workers still show "Rate Limited".
#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).
A mis-grouped library track sits under a 'Various Artists' / '[Unknown
Album]' record while the file itself is correctly tagged. Write Tags
reads the DB and stamped that junk over the file — destroying the
correct tags. (Rematching never helped: a match only stores a source-ID
pointer, it never changes the local name/title the writer reads.)
Guard: never replace a real file value with a placeholder. Added at both
seams so preview and write agree:
- build_tag_diff marks such a field protected/no-change (the preview no
longer shows a wrong overwrite, and has_changes reflects reality),
- write_tags_to_file reads the file's current values and skips the
placeholder-over-real fields, preserving the file.
Field-agnostic and direction-safe: the guard fires ONLY when the DB
value is a placeholder AND the file holds a real one. A legitimate value
still writes — including a genuine 'Various Artists' album artist on a
real compilation, where the file has no conflicting real value, so the
guard doesn't fire. Every write_tags_to_file caller writes DB->file as a
correction, so blocking placeholder-over-real is correct for all of them
(the download post-process uses a different path, embed_source_ids).
23 new tests (placeholder detection, the guard fn, build_tag_diff on the
screenshot-#2 scenario, end-to-end FLAC write preserving real values and
still overwriting with real ones). 113 existing repair/retag/tag tests
pass.
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.
Per the cleaner model: the free source only runs for users who explicitly picked
'Spotify Free' — not for every connected user. _free_wanted() is now just
_free_selected() (dropped the has-credentials auto-trigger). So:
- Plain 'Spotify' user, rate-limited -> waits out the ban as before (no surprise
background scraping, no ToS exposure for people who never chose free).
- 'Spotify Free' user, no auth -> free serves.
- 'Spotify Free' user who also connects an account -> official when healthy,
free bridges only during a rate-limit, then switches back.
Rewrote the metadata-source help text as a plain per-source list with a clear
note on how Spotify Free + a connected account interact. Gate tests updated to
pin the opt-in behavior (plain-Spotify ratelimit = no bridge; Spotify-Free
ratelimit = bridge).
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.
When Spotify Free is enabled, it now also bridges an official rate-limit ban for
authenticated users instead of stalling — search already did this (the gate
opens on no-auth OR rate-limit); this extends it to the enrichment worker.
- spotify_worker: the rate-limit guard now sleeps only when free CAN'T cover
(is_spotify_metadata_available() is False). Purely additive — with Spotify
Free off, that's False during a ban and the worker sleeps exactly as before.
Verified: toggle OFF + rate-limited -> sleeps (original); toggle ON -> bridges.
- Reframed the Settings toggle so connected users know it also covers rate-limits
("Use Spotify Free when Spotify is unavailable or rate-limited").
The official auth path is untouched; free never runs while authed Spotify works
normally.
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.
Adds an opt-in no-creds Spotify metadata path: SpotifyClient serves SpotipyFree
data (real Spotify IDs) when metadata.spotify_free is enabled AND SpotipyFree is
installed AND there's no real Spotify auth. The data lands in the SAME spotify_*
columns, so the enrichment worker, search, and #775 lookups work UNCHANGED —
they just receive free-sourced data. The worker's only change is its availability
gate.
- core/spotify_free_metadata.py: SpotifyFreeMetadataClient + normalize_artist +
pure gate fns (should_use_free_fallback / should_offer_spotify_metadata /
spotify_free_installed).
- SpotifyClient: _free_enabled (opt-in, default OFF) / _free_available /
is_spotify_metadata_available / _free_active + in-client routing for
search_artists/tracks + get_album/artist/track_details/album_tracks/artist_albums.
- 3 scoped availability gates use is_spotify_metadata_available(): enrichment
worker loop, search resolve_client, watchlist. The ~40 discovery/user-library
sites stay auth-only (no sprawl, no user-data risk).
Authed users are untouched (gate closed when auth healthy). UI surfacing comes
next. Pure gates + normalizer tested; orchestrator resolve test added.
The #799 uniqueness-guard added a source_id_conflict(self.db, ...) call to the
MusicBrainz worker's artist path. Two TestWorkerAliasEnrichment fixtures build
the worker via __new__ and set only .database, not .db, so the new call raised
AttributeError and the artist was marked 'error'. Mirror the third fixture
(which already sets worker.db). Production always sets self.db in __init__ —
test-only gap exposed by the new code path.
A leftover `.sync-tab-server { flex: 1.4 !important }` from the old equal-width
pills tab strip leaked past the brand-chip restyle (its !important beat the
chip's flex:0 0 auto), so the active Server Playlists pill spanned the whole row
instead of fitting its label. Dropped just that declaration — the tab now behaves
like every other chip; its bespoke gradient + the rest of the rule are untouched.
Enumerates all 64 flag combinations and asserts should_rediscover matches the
verbatim pre-refactor inline logic for every case except the one intended fix
(manual_match beating a stale wing_it_fallback). Guarantees the auto Playlist
Pipeline behaves identically post-refactor — no regression.
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.