Commit graph

3760 commits

Author SHA1 Message Date
BoulderBadgeDad
d44de75906 Changelog/PR: note the Spotify Free budget→free bridge in the 2.6.7 entry 2026-06-06 08:52:54 -07:00
BoulderBadgeDad
e5e56f3d06 Bridge the Spotify worker to Free when the daily budget is spent (don't pause)
#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.
2026-06-06 08:51:30 -07:00
BoulderBadgeDad
971f2fd4f0 Release 2.6.7: version bump + changelog
- _SOULSYNC_BASE_VERSION -> 2.6.7 (single constant drives the UI version).
- docker-publish workflow default version_tag -> 2.6.7.
- WHATS_NEW: new 2.6.7 block grouping the 69 commits since 2.6.6 into
  user-facing entries (Spotify Free #798, Import IDs from File Tags +
  auto-reconcile, Library Re-tag, paste-a-link #775, mobile v2 #793/#795,
  Reconcile sync mode #792, #758 manual-match canonical lock, #800 Write
  Tags guard, #797 AcoustID non-English artists, artist source-id fixes,
  #799/#787/#789/#785/#790/#796, no-sound fix, perf).
- VERSION_MODAL_SECTIONS: 2.6.7 spotlight sections + a Recent Fixes (2.6.7)
  aggregator at the top.
2026-06-05 23:37:53 -07:00
BoulderBadgeDad
2d2ee34df8 #758: a manual album match pins + locks the canonical version
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.
2026-06-05 23:28:19 -07:00
BoulderBadgeDad
6b5506dee4 Don't apply the real-API daily budget / cooldown while bridging via Spotify Free
#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).
2026-06-05 22:38:59 -07:00
BoulderBadgeDad
3fcfa900bd Show "Running (Spotify Free)" instead of "rate limited" while the worker bridges
#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".
2026-06-05 22:08:07 -07:00
BoulderBadgeDad
12038bde08 Let the Spotify worker resume during a rate-limit ban when Free can bridge
#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).
2026-06-05 21:50:08 -07:00
BoulderBadgeDad
4039ec3573 #800: Write Tags must not overwrite a real file value with a placeholder
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.
2026-06-05 20:07:03 -07:00
BoulderBadgeDad
79a30c055a Run auto-reconcile as a scan phase inside the running window
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).
2026-06-05 19:10:31 -07:00
BoulderBadgeDad
83c1cd92aa Auto-reconcile embedded IDs for new tracks on library scans
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).
2026-06-05 18:31:11 -07:00
BoulderBadgeDad
e6d86dea26 Add "Import IDs from File Tags" backfill — gap-fill provider IDs from embedded tags
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).
2026-06-05 17:52:29 -07:00
BoulderBadgeDad
2604704a27 #797: stop AcoustID quarantining correct non-English-artist downloads
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.
2026-06-05 16:02:01 -07:00
BoulderBadgeDad
4249856984 #798: make Spotify Free opt-in (not auto-bridge) + clearer help text
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).
2026-06-05 15:10:18 -07:00
BoulderBadgeDad
0c03803a30 #798: fix Spotify Free not selectable — WebSocket status push missing flags
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.
2026-06-05 14:35:49 -07:00
BoulderBadgeDad
217a5eda70 #798: Spotify Free as a real dropdown source + automatic rate-limit bridge
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.
2026-06-05 14:23:28 -07:00
BoulderBadgeDad
a387814deb #798: Spotify Free as a rate-limit bridge for connected users (hybrid)
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.
2026-06-05 14:00:50 -07:00
BoulderBadgeDad
2667eaec87 #798: Spotify Free UI — enable toggle + source availability
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.
2026-06-05 13:36:46 -07:00
BoulderBadgeDad
0eff9e3708 #798: Spotify Free metadata mode — backend (opt-in, shared spotify tables)
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.
2026-06-05 13:32:37 -07:00
BoulderBadgeDad
ad1bd97aac Fix MB-worker alias test fixtures missing self.db
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.
2026-06-05 13:24:21 -07:00
BoulderBadgeDad
e7a50159e8 #799: stop the active 'Server Playlists' sync tab stretching full-width
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.
2026-06-05 11:20:12 -07:00
BoulderBadgeDad
0f3c374137 #799: add equivalence proof that should_rediscover == original pre-scan
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.
2026-06-05 11:17:49 -07:00
BoulderBadgeDad
c20150e370 #799: stop mirrored discovery from reverting a manual fix to Wing It
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.
2026-06-05 11:07:32 -07:00
BoulderBadgeDad
55c9b52aee Auto-repair duplicated source ids on startup (one-time migration)
Ships the source-id cleanup to all users: a marker-gated one-time migration in
MusicDatabase init clears any source id (deezer/spotify/itunes/musicbrainz/
discogs/audiodb/qobuz/tidal) shared across differently-named artists — the
enrichment-corruption signature. Same-name cross-server duplicates are left
untouched (DISTINCT-name check). Cleared rows re-derive correct ids on the next
enrichment pass; the now name-guarded workers won't re-corrupt.

Runs once (CREATE TABLE _source_id_dedupe_v1 marker), idempotent, per-column
try/except so a missing column can't abort it. Test forces a re-run and asserts
corruption is cleared while a legit same-name dup survives.
2026-06-05 10:21:52 -07:00
BoulderBadgeDad
0af99881bf Tighten artist matching: 0.85 gate + shared uniqueness guard
Two complementary fixes to stop distinct artists ending up with the same source
id (the near-name collisions: ODESZA/odessa, Blance/Blanke, Lady A/Lady Gaga,
plus MusicBrainz's combined-score weak matches like Grant/Amy Grant):

- core/worker_utils.accept_artist_match() / source_id_conflict(): one shared,
  tested gate. Rejects artist matches below 0.85 (stricter than the 0.80 used
  for album/track titles, since short artist names false-positive easily) AND
  refuses to store a source id a DIFFERENTLY-named artist already holds. A
  same-named holder (one act across two media servers) is still allowed.

- Routed every artist-match worker through it: deezer, qobuz, tidal, discogs,
  itunes, spotify (its scorer now uses the 0.85 threshold), audiodb, and
  musicbrainz (conflict guard only — its matcher is combined-score, so the
  guard is the net that catches its weak-name matches).

Centralizing in worker_utils avoids the copy-paste that let the original
album/track overwrite bug live in four workers at once. 17 new gate tests.
2026-06-05 10:01:17 -07:00
BoulderBadgeDad
c30e8ce402 Add one-off repair for source ids shared across multiple artists
core/maintenance/dedupe_source_ids.py + scripts/dedupe_source_ids.py: find
source-id clusters held by differently-named artists (the enrichment-corruption
signature) and clear the id + match-status on those rows so the now-name-checked
workers re-derive each correctly on the next enrichment pass. Same-name
duplicates (one artist across two media servers) are left untouched.

Dry-run by default; --apply to write. 8 seam tests cover detection (corrupt vs
legit), dry-run safety, apply behaviour, and the no-op case.
2026-06-05 08:38:55 -07:00
BoulderBadgeDad
85549197e6 Apply artist-id name-guard to audiodb/qobuz/tidal workers too
The blind 'correct the parent artist's source id from an album/track match'
logic was copy-pasted into four enrichment workers; the Deezer fix only covered
one. AudioDB, Qobuz, and Tidal had the identical bug and would corrupt their own
id columns (and re-corrupt after any cleanup).

All three now gate the correction on a name match between the result's artist
and the parent artist (audiodb reads result['strArtist']; qobuz/tidal thread the
result artist name in from their callers, as Deezer does). Regression tests
cover mismatch-skips and match-corrects for each.
2026-06-05 07:47:59 -07:00
BoulderBadgeDad
8ce26d19fa Fix Deezer enrichment stamping one artist id onto multiple artists
Root cause of the duplicate deezer_id corruption: when enriching an album or
track, _verify_artist_id 'corrected' the parent artist's deezer_id to the
search result's primary-artist id whenever they differed — with NO name check.
For a collaboration/compilation track (e.g. one our library credits to Jorja
Smith that lives on Kendrick Lamar's curated 'Black Panther' album), the result
resolves to Kendrick's album, so Kendrick's id (525046) got written onto Jorja,
Vince Staples, SOB X RBE, etc. — many artists ending up with the same id.

Now the correction only fires when the result's primary-artist NAME matches the
parent artist (the album/track-artist path now mirrors _process_artist, which
already name-checks). Mismatches are logged and skipped as collab/compilation.

Note: this prevents new corruption; existing wrong ids in a library aren't
auto-repaired (per-artist enrichment preserves an existing deezer_id).
2026-06-05 07:32:51 -07:00
BoulderBadgeDad
2962267d36 Artist-detail: keep the library view when a source id is ambiguous
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.
2026-06-05 07:28:21 -07:00
BoulderBadgeDad
d2d71d8f05 Fix artist-detail showing wrong artist when a source id is duplicated
A pasted Deezer artist link (or any Deezer-source artist click) opened the
wrong artist's header: deezer_id 525046 is stamped on 4 library rows (Kendrick
+ 3 others — an enrichment-corruption bug), and the library-upgrade lookup did
WHERE deezer_id=? LIMIT 1, grabbing an arbitrary row (Jorja Smith) while the
discography loaded fresh from Deezer (Kendrick) — a Frankenstein page.

find_library_artist_for_source now detects when a source id maps to >1 library
artist and refuses to guess: it skips the id-based upgrade (still allowing the
name fallback), so the caller renders the source artist directly — landing on
the correct artist. Unique ids are unaffected (no regression).

The underlying enrichment bug that writes one source id onto multiple artists
is separate and still worth a follow-up.
2026-06-05 07:24:51 -07:00
BoulderBadgeDad
06f01e29e8 #775: add artist links (paste an artist URL -> opens the artist)
Spotify/Apple/MusicBrainz/Deezer artist links now resolve via each source's
get-by-id (get_artist / Deezer get_artist_info), shaped to the artist card and
rendered as an artist result that opens the artist detail page through the
existing flow. Album/track link handling is unchanged; bare IDs still rejected.
2026-06-05 06:55:35 -07:00
BoulderBadgeDad
e05979ea07 #775: links only — reject bare IDs (ambiguous), add not-found hint
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.
2026-06-05 06:44:01 -07:00
BoulderBadgeDad
9772d5313c Add #775: resolve a pasted metadata link/ID instead of searching
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.
2026-06-05 06:32:45 -07:00
BoulderBadgeDad
1590330171 Fix #796: Soulseek album bundle left completed files in slskd download folder
The album-bundle path COPIES slskd's completed files into private staging (then
on to the library) but never removed slskd's originals, so they piled up in the
download folder. (copy, not move, is correct for the torrent/usenet bundle paths
— those clients keep seeding — so the shared copier can't just always delete.)

Add an opt-in remove_source to copy_audio_files_atomically that deletes each
source ONLY after it copies successfully (never on a failed stage), and set it
for the Soulseek path only. Torrent/usenet keep their originals.

Tests: keeps source by default / removes when requested / keeps on failed copy.
2026-06-04 21:56:07 -07:00
BoulderBadgeDad
ef9af0cab5 Fix S110: log instead of pass in canonical search-query add 2026-06-04 21:17:37 -07:00
BoulderBadgeDad
bc2432d9f6 Fix #785 (cont.): also search canonical title in discovery worker
The scoring best-of only helps if the right candidates were returned. File/CSV
titles ('Artist - Title') made the search query carry the artist prefix; add
canonical-title search queries so the correct tracks are actually found, then
the scorer best-of matches them. Additive (extra queries only when the title
canonicalizes differently).
2026-06-04 21:16:41 -07:00
BoulderBadgeDad
6cb753e7a1 Fix #785: file/CSV playlists fail to match (raw 'Artist - Title' titles)
#768 added canonical_source_track to the live-sync matcher and the playlist
editor reconcile, but NOT to the two paths that actually run for file/CSV
mirrored playlists: the discovery worker (core/discovery/playlist.py) and the
DB-only matcher (core/discovery/sync.py). YouTube playlists are cleaned at
ingest, so they matched; file playlists fed the raw 'Arctic Monkeys - Do I
Wanna Know?' title into search+scoring and never matched the library's clean
'Do I Wanna Know?' → reported missing / shown as 'extra'.

Add a conservative canonical best-of to both: score with the raw title AND the
canonicalized one, keep the better. canonical_source_track only strips an
'<artist> - ' prefix when it equals the artist, so it can only add a candidate.

Tests: _canonical_best_score seam (file-style match / clean title scored once /
keeps original when better).
2026-06-04 21:13:23 -07:00
BoulderBadgeDad
c3ec36c989
Merge pull request #795 from Nezreka/feat/mobile-responsive-v2
Feat/mobile responsive v2
2026-06-04 21:00:12 -07:00
BoulderBadgeDad
5373f8540b Mobile: notification panel fits the screen (override inline JS positioning)
toggleNotifPanel positions the panel inline from the bell's rect (panel.style.
right/bottom). The bell isn't flush to the right edge on mobile, so that inline
right offset + near-full-width pushed the panel off-screen left. The existing
mobile rule set right:12px without !important, so it lost to the inline style.
Now anchor both sides with !important (left+right+width:auto) so it always fits.
2026-06-04 20:50:33 -07:00
BoulderBadgeDad
982653798f Mobile: only show sidebar visualizer when the drawer is open
The visualizer is fixed at the desktop sidebar's edge; on mobile it floated over
the page content whenever music played, even with the off-canvas sidebar closed.
Hide it unless .sidebar.mobile-open (sibling selector, 3-class + !important to
beat the .active/.viz-* display rules). When the drawer opens it shows again.
2026-06-04 20:41:13 -07:00
BoulderBadgeDad
604c308e5c Mobile: downloads page (.adl-*) responsive pass — had zero coverage
The downloads page is a two-column desktop layout (main list + fixed 366px batch
panel) with NO mobile rules at all. Phone-only:
- .adl-layout stacks to a column; .adl-batch-panel goes full-width, swaps its
  left border for a top border, and flows in the page (no independent scroll).
- .adl-header + .adl-controls stack so the filter pills get full width.
- .adl-filter-pills wrap instead of overflowing; cancel/clear buttons flex to fit.
2026-06-04 20:27:26 -07:00
BoulderBadgeDad
2a509e74c3 Mobile: discover carousels — center wrap, 2-up recommended + discover cards
- .discover-carousel / #genre-tabs: add justify-content:center to the wrap.
- .recommended-card--carousel: 45% (2-up) on mobile, overriding the flex-basis.
- .discover-card: 160px -> 45% (2-up) on mobile.
2026-06-04 20:24:47 -07:00
BoulderBadgeDad
273c4e5fa3 Mobile: hide mini-player when Now Playing modal is open + uncap artist image
- Hide the floating mini-player while the expanded Now Playing modal is open
  (it has z-index 99998 vs the overlay's 10001, so it floated over the modal).
  General fix (desktop too), via sibling selector on the overlay's open state.
- Artist hero image: drop the max-width:40vw cap on mobile (overrides the base
  rule) so the image isn't artificially shrunk.
2026-06-04 19:04:39 -07:00
BoulderBadgeDad
5d8536a5bf Mobile: responsive pass on the Now Playing (music player) modal
Existing mobile rules made it full-screen + stacked the body, but left the
desktop layout inside untouched. Phone-only (max-width:768px):
- album art scales (min(220px, 66vw)) instead of fixed 220px
- left/right columns full width; track info, action + util rows centered
- controls row gap tightened to fit a phone
- queue + lyrics panels: drop the 40px desktop side padding that crushed content,
  give them a touch more vertical room
2026-06-04 18:58:44 -07:00
BoulderBadgeDad
a1ad6a1225 Mobile v2: artist page, enhanced track table, media player, sync buttons, hero padding
All phone-only (max-width:768px), all in mobile.css — desktop untouched.

- artist hero: drop the 100px image-container cap; artist name -> 1.6em centered
  block; bio max-height:fit-content; center hero action buttons + match-status
  chips (moved here from base rules so desktop stays as-is).
- #6 enhanced-view track table: a 6+ col table clipped to one visible column on
  a phone. Drop table layout -> each row is a flex line (play . title . duration
  . actions); secondary columns fold into the existing mobile actions sheet.
- #7 mini media player: was pinned at desktop coords (right:132px; width:340px)
  and overflowed. Full-width bar sitting just above the bottom global search.
- #8 page heroes (tools-maintenance / watchlist / discover): trim desktop-sized
  padding + margins that wasted space on mobile.
- #9 sync header: Auto-Sync / Library Match / Sync History didn't fit; stack the
  header + wrap the buttons.
2026-06-04 18:34:40 -07:00
BoulderBadgeDad
8700a171fb
Merge pull request #793 from nick2000713/perf/scroll-render-and-pm-compat
perf(webui): fix scroll-container raster jank + make the UI usable with password-manager extensions
2026-06-04 16:58:27 -07:00
BoulderBadgeDad
2d443986e7
Merge pull request #794 from Nezreka/feat/playlist-reconcile-sync
Feat/playlist reconcile sync
2026-06-04 16:27:27 -07:00
BoulderBadgeDad
a893f618c7 Fix #792: per-source discovery syncs ignored the sync-mode setting
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.
2026-06-04 16:18:54 -07:00
BoulderBadgeDad
65c9948c78 Fix #792: reconcile sync mode was clamped back to 'replace' in the backend
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.
2026-06-04 15:57:40 -07:00
BoulderBadgeDad
b105372d70 Fix #792: sync UI shadowed the new sync-mode setting (forced 'replace')
The reconcile setting never took effect: startPlaylistSync always sent
sync_mode (defaulting to 'replace' from the per-playlist <select>) AND clamped
any non-replace/append value back to 'replace' — so 'reconcile' could never be
sent and the global Settings value was always overridden. The per-server Plex
reconcile code was never even reached; replace ran and re-pushed the poster.

- Per-playlist select now defaults to 'Sync mode: default' (empty) which defers
  to Settings > Playlist sync mode, and gains a 'Reconcile' option for an
  explicit per-sync override.
- startPlaylistSync sends '' (not 'replace') when no explicit choice, so the
  backend uses the configured default; clamp now allows reconcile.
  (Other callers already sent no sync_mode, so they pick up the setting too.)
2026-06-04 15:27:00 -07:00
BoulderBadgeDad
939c660498 Fix #792: 'reconcile' playlist sync mode (edit in place, keep image/description)
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.
2026-06-04 15:15:49 -07:00