Commit graph

1824 commits

Author SHA1 Message Date
BoulderBadgeDad
cbab4234ef Export: combine watchlist + library into one button with a scope selector
Per feedback — instead of two export buttons (one on the watchlist filter bar, one
in the library header), there's now a single "Export" button. The modal gains a
Watchlist | Library scope toggle at the top; switching scope re-fetches and shows/
hides the "library counts" option (library-only). One place, both rosters.

Also relaxed the two export endpoint wiring tests — they asserted an empty DB,
which is false in a shared test run (the artists table may already hold rows); now
they assert a valid JSON array + headers/columns instead. The endpoints are
unchanged and verified against real data.
2026-06-11 23:10:54 -07:00
BoulderBadgeDad
a789fb71c0 Library export: export the whole library roster too (corruption's request)
Extends the watchlist export to the full library. The exporter is now general
(core/exports/artist_export.py, renamed from watchlist_export) — adds tidal/qobuz
links and an extra_fields passthrough, so the library export also carries
lastfm/genius URLs + soul_id, and an optional "library counts" toggle adds owned
album/track counts per artist.

- GET /api/library/artists/export?format=&links=&contents= — pulls every artists
  row, normalizes onto the canonical *_artist_id keys, optionally GROUP-BY counts
  for album/track totals.
- The export modal is now openArtistExportModal(scope): "Export Library" button in
  the library header + the existing "Export" on the watchlist bar (a thin wrapper).
  Library mode shows the extra "library counts" toggle.

Tests (11): builder across formats + the new tidal/qobuz links + extra_fields
columns; watchlist + library endpoint wiring. 64 integrity green; ruff clean.
2026-06-11 22:59:21 -07:00
BoulderBadgeDad
f8652c106b Watchlist: export the roster to JSON / CSV / text (corruption's request)
An "Export" button on the watchlist filter bar opens a modal (same aesthetic as the
artist DB-record inspector) to export your whole watchlist roster — each artist's
name + source IDs (spotify / musicbrainz / deezer / discogs / itunes / amazon),
with an optional "external links" toggle that adds the discography URLs built from
those IDs. Live preview, copy, and download in the chosen format.

- core/exports/watchlist_export.py: pure builder (json/csv/txt + links, present-IDs
  only, deterministic columns) — the single source of truth, fully unit-tested.
- GET /api/watchlist/export?format=&links= shapes the roster + returns it (with
  X-Export-Count / X-Export-Ext headers for the modal).
- Frontend reuses the DB-record helpers (_jsonSyntaxHighlight / _arecCopy).

Tests (8): builder across json/csv/txt, links on/off, present-ids-only, empty +
bad-format fallback, mime/ext, and endpoint wiring. ruff clean; 64 integrity green.

Scoped to the watchlist for v1; library-wide export + a "library contents"
(owned albums/tracks) option are natural follow-ups.
2026-06-11 22:48:58 -07:00
BoulderBadgeDad
4d2772765c Add Aria2 to the torrent client list (Shdjfgatdif's request)
New Aria2 JSON-RPC adapter, alongside qBittorrent / Transmission / Deluge. Aria2's
RPC (default :6800/jsonrpc) maps cleanly onto the uniform adapter contract:
- the --rpc-secret token leads every call as "token:<secret>" (no username — the
  secret uses the existing password field),
- addUri returns a GID (our torrent id); tellStatus → TorrentStatus with state
  mapping (active→downloading, or seeding once the payload is complete; waiting→
  queued; etc.),
- remove picks forceRemove vs removeDownloadResult by status, and (since aria2
  doesn't delete files on remove) unlinks the file paths itself for delete_files,
- bare-host URLs get /jsonrpc appended.

Wired into adapter_for_type + the Settings dropdown (with a help note: port 6800,
secret in the Password field). All adapter methods go through the same interface,
so the stall/orphan handling and downloads pipeline work unchanged.

Tests (9): registry wiring, state mapping (incl. active→seeding), token-prefixed
params, /jsonrpc fixup, status parse (+ name fallback, no div-by-zero). 126 torrent
tests green; ruff clean.
2026-06-11 22:29:40 -07:00
BoulderBadgeDad
8118a2c6bd Add Empty Folder Cleaner library-maintenance job (corruption's request)
A maintenance job to keep the music library tidy — finds empty folders left behind
by imports/relocations/deletions (empty artist/album dirs, or dirs holding only OS
junk like .DS_Store/Thumbs.db) and removes them.

Safety is the focus (deleting directories is destructive):
- only TRULY empty folders are flagged — a folder with a cover image or any audio
  is never touched; only OS-junk files count as "no real content" (a setting),
- the library root + symlinked dirs are never removed,
- walks bottom-up so a parent left empty by its removable children cascades,
- the apply handler RE-CHECKS emptiness at delete time, so a folder that gained a
  file between scan and apply is left alone.

dir_is_removable + remove_empty_folder are pure/injectable seams. Wired through the
job registry, repair_worker apply handler (_fix_empty_folder), fixable-types, and
the findings UI. Opt-in (default off), weekly interval.

Tests (10): removable decision (empty / real-file / surviving-subdir / junk-only /
strict mode) + apply re-check (removes empty + junk, refuses content/root/symlink).
Repair + integrity suites green; ruff clean.
2026-06-11 22:11:26 -07:00
BoulderBadgeDad
d9cda0c31c Manage Profiles: make the login-password state visible (clarity)
The per-member login password was easy to miss — the lock button had no dedicated
styling and nothing showed who was actually stranded. Now, when login mode is on:

- a banner explains every member needs a login password (+ to use the lock button)
- each member row shows a status pill: "⚠ No login password" (red) or "🔒 Login
  ready" (green) — so you can see at a glance who can't sign in yet
- the lock button is properly styled (it had none) and pulses red when that member
  has no password, so the action you need is obvious

When login mode is off, none of this shows (no noise). Pure UI/clarity — no
behavior change.
2026-06-11 21:10:02 -07:00
BoulderBadgeDad
5b52d579c5 Login mode: enforce "every profile has a password" at every write-point (no gaps)
Invariant: while security.require_login is on, every profile must have a login
password or it's locked out. Previously only the admin's own anti-lockout existed,
so members could be stranded (created without a password, or login flipped on while
passwordless members existed). Closed all the write-points:

core/security/login_provisioning.py (pure policy, single source of truth):
- members_without_password(profiles) — non-admin profiles that can't sign in
- create_needs_password(require_login) / removing_password_strands(require_login)

Wired into web_server:
- create_profile: while login is on, a new member must be given a password (400
  otherwise) and it's set on creation.
- enable-login (settings save): refuses to turn login on while any member lacks a
  password — lists them — same shape as the existing admin anti-lockout.
- set-password: refuses to CLEAR a password while login is on (would strand them).

UI: Create Profile form gains a login-password field (alongside the optional PIN);
the Manage Profiles per-member password button (prior commit) covers existing
members + changes.

Tests: pure policy seam + endpoint enforcement (create blocked w/o password when
on, allowed w/ password, no friction when off, clear blocked when on). 442
profile/settings/auth tests green; ruff clean.
2026-06-11 19:48:50 -07:00
BoulderBadgeDad
e046a2add4 Login mode: let the admin set a member's login password (Manage Profiles)
Closes the gap where "Require login" was effectively admin-only: a member with no
password can't sign in and can't bootstrap one themselves (can't log in to reach
the setting). The set-password endpoint already allowed admin→anyone — this adds
the missing UI.

Each non-admin row in Manage Profiles gets a lock-icon button that opens an inline
form to set / change / remove that member's LOGIN password (separate from the
quick-switch PIN), with a confirm field + a hint explaining when it's used. Admin
rows don't get it (admin manages their own in Settings → Security, which keeps its
anti-lockout). textContent-only rendering, so a profile name can't inject markup.

Test: admin sets a member's password → the member can then authenticate
(verify_profile_password) and a wrong password fails; admin can clear it back to
no-login. 64 script-split integrity tests green.
2026-06-11 19:24:31 -07:00
BoulderBadgeDad
29c8f11403 #437: add ReplayGain Filler library-maintenance job
Post-processing applies ReplayGain only to slskd/WebUI downloads — content added
via Lidarr, the REST API, or by hand never got it, and there was no way to (re)apply
RG to existing tracks or fix ones where analysis failed (raised in #437 + comments).

New ReplayGain Filler repair job (sibling of Lyrics/Cover Art fillers): scans for
tracks with no ReplayGain track-gain tag and creates a finding per track; the scan
only READS tags (cheap) and no-ops when ffmpeg is absent. Applying a finding runs
the same ffmpeg ebur128 analysis the import pipeline uses (gain = ref - LUFS) and
writes the RG tags in place — no moves, no re-matching. Opt-in (default off),
schedulable like the other maintenance jobs.

Wired: job registry, repair_worker apply handler (_fix_missing_replaygain) +
fixable-types, and the findings UI (label / fix-button / detail rows).

Tests: pure needs_replaygain decision (missing/blank/present/+0.00-is-tagged) +
the apply handler's analyze→compute→write seam with the pipeline gain formula,
ffmpeg-absent + missing-file guards, and registration. 93 repair tests green.
2026-06-11 18:41:12 -07:00
BoulderBadgeDad
68acf89b83 #852: hide the whole app behind the lock screen — bypass reveals a blank page
Beckid's ask: bypassing the login/PIN overlay shouldn't show the app pages at all,
not even the (data-less) chrome. The overlay was cosmetic-on-top; the static shell
sat behind it, so "Hide Distracting Items" exposed the empty UI.

Now the lock screens add body.app-locked, and a CSS rule hides every body child
except the two lock overlays themselves (display:none !important). Safari's
hide-element trick can only ADD hiding — it can't undo this rule — so removing the
overlay leaves a blank page. initApp() drops the class once authenticated (first
line, before component layout init). Defense-in-depth on top of the server-side
HTTP + WebSocket gating, which already blocks any actual data.

Targeted + safe: the app shows by default (no blank-screen risk); only an active
lock hides it. Profile picker (not a security lock) is unaffected.
2026-06-11 18:08:02 -07:00
BoulderBadgeDad
123eb6139f Artist detail: "DB Record" inspector — everything the DB knows about an artist
A small glowing button at the bottom-right of the artist hero (library artists
only) opens a programmer-style modal showing the COMPLETE artists DB row — every
source id + match status, cached bios / tags / similar / urls, soul_id, timestamps,
the lot (62 columns) — plus owned album/track counts.

- Backend: GET /api/artist/<id>/record returns the full row with JSON-text columns
  (genres, aliases, lastfm_tags/similar, discogs_urls, …) decoded into real
  arrays/objects, + album/track counts. 404 for non-library artists.
- Frontend: editor-themed modal (Tokyo-night tokens) with a Fields tab (copyable,
  filterable key/value rows) and a syntax-highlighted JSON tab. Copy-all-as-JSON,
  per-value copy (HTTP/Docker clipboard fallback), and Save .json. Esc / click-out
  to close. Helpers namespaced (_arecEsc) so they can't clobber the shared globals.

Tests: endpoint returns the full row with decoded JSON + counts; 404 for a missing
artist. 64 script-split integrity tests still green; ruff clean.
2026-06-11 16:57:48 -07:00
BoulderBadgeDad
26b27eb441 What's New + version modal: 2.7.1 content
Per the release convention: WHATS_NEW + VERSION_MODAL_SECTIONS carry only the
current release, with older cycles folded into the "Earlier" summary.

- WHATS_NEW '2.7.1': download verification & review (badge, persistence, review
  queue), the #852 websocket login-bypass fix, the acoustid Relocate action (#704),
  faster artist pages (#853), the LB-weekly un-wedge (#702), the torrent metaDL
  stall + orphan fix, and the smaller fixes (#851/#840/search auto-select) +
  contributor PRs (#845/#848/#850). 2.7.0 rolled into "Earlier versions".
- VERSION_MODAL_SECTIONS: verification & review leads, security fix section,
  fixes list, and an "Earlier in 2.7.0" aggregator replacing the 2.6.x one.
- Fixed the "Go to page" links: the downloads page id is 'active-downloads', not
  'downloads' — the old entries' links silently did nothing.
2026-06-11 15:50:20 -07:00
BoulderBadgeDad
9bf7881f7a #704: add "Relocate" fix for AcoustID mismatches — retag + restage for re-import
The 'retag' fix corrects a mismatched file's tags/DB but leaves it in the WRONG
artist/album folder, so the library shows the right title while the file sits under
the previous track. AcoustID yields only title+artist (no reliable album), so an
in-place move has no safe target.

New 'relocate' action: retag the file, move it into Staging, drop the stale tracks
row, and clean up the emptied folder. The auto-import worker (which watches Staging)
re-identifies it with full metadata and files it correctly — reusing the import
pipeline instead of guessing a destination.

- core/repair_jobs/relocate.py: pure, injectable orchestration (retag -> move ->
  drop row) + collision-safe staging_destination. Row is dropped only AFTER a
  successful move, so a failed move never orphans the library entry.
- _fix_acoustid_mismatch gains the 'relocate' branch (thin wrapper: resolve path,
  staging dir, drop-row closure, empty-parent cleanup).
- UI: "Relocate" button on the AcoustID-mismatch fix modal.

Tests (8): staging-dest collision suffixing; relocate happy path; tag-write failure
still relocates; FAILED move does NOT drop the row; no-tags skips write; a real
file move through safe_move_file; and a handler integration test (file moved to
staging + tracks row deleted end-to-end). Repair + integrity suites green.
2026-06-11 12:01:05 -07:00
BoulderBadgeDad
525222b04c #840: offer '&' as an artist tag separator (MusicBrainz/Picard style)
The artist-separator setting + backend join already supported any delimiter; just
add ' & ' to the dropdown. Safe by construction — artists are joined from the
source's artist LIST, never split, so a name containing '&' (Florence & The
Machine) is one entry and can't be mis-parsed. Closes the join half of #840.
2026-06-11 11:23:21 -07:00
BoulderBadgeDad
eb35ba86fb
Merge pull request #845 from nick2000713/fix/import-folder-artist-override-optin
feat: import folder-artist override opt-in + verification pipeline review queue
2026-06-11 10:39:59 -07:00
BoulderBadgeDad
ce6ce4d8d6 Search: auto-select Spotify when "Spotify (no auth)" is the active source
On the Search page and the global search widget (both share createSearchController),
the source picker stayed empty when the active metadata source was Spotify-no-auth,
until you clicked Spotify manually.

Root cause: get_primary_source_status reports the no-auth composite as source
'spotify_free' (for display labelling). The controller's initActiveSource set
activeSource = 'spotify_free' (it's a valid SOURCE_LABELS entry), but the icon row
renders from SOURCE_ORDER, which only has 'spotify' — so no icon matched the active
source and nothing highlighted.

Fix: normalize 'spotify_free' -> 'spotify' when deriving the initial active source
(they're the same searchable source; the picker only has a Spotify icon). Now
no-auth auto-selects Spotify like plain Spotify does. One spot, fixes both surfaces.
2026-06-11 09:39:40 -07:00
nick2000713
bf5affd03c resolve merge conflict in style.css 2026-06-11 18:21:04 +02:00
rollingbase
01ed54f4f2 SW: stop cover-art burst from hard-failing on first load
A discography page fires 70+ cover-art requests at once. Routed through
the service worker one-for-one, that burst overruns the browser's
per-host connection pool (~6); the overflow fetches reject, and the
cache-first strategy mapped each rejection to Response.error() — which
Firefox surfaces as NS_ERROR_INTERCEPTION_FAILED, a hard, *uncached*
image failure for that load. The page renders artless cards on first
visit and only "heals" on reload (cached images shrink the burst).

Fix the image path three ways:
- Cap concurrent image fetches (semaphore, 6) so the burst queues
  instead of saturating the connection pool — the actual first-load fix.
- Retry a rejected fetch once with a short backoff; most failures are
  transient connection-cap rejections that clear as the burst drains.
- On final failure return a benign 504 instead of Response.error(), so a
  dead image degrades to a normal broken image (recoverable next nav)
  rather than NS_ERROR_INTERCEPTION_FAILED.

Cache hits bypass the throttle entirely. Static-asset strategy is
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:03:41 +02:00
BoulderBadgeDad
8968d87cc4 What's New + version modal: 2.7.0-only, summarize the rest
Going forward these only carry the current release plus one brief "earlier versions"
summary — no accumulating per-version backlog.

- WHATS_NEW: replaced the full 2.6.x→2.5.x backlog with a single '2.7.0' block
  (per-profile accounts, login/recovery/reverse-proxy, the fixes, artist-sync) + an
  "Earlier versions" one-liner. The "Older Versions" button auto-hides with one
  version, so the nav still works.
- VERSION_MODAL_SECTIONS: five curated 2.7.0 sections + a brief "Earlier in 2.6.x".
- Content drawn from the 2.7.0 pr_description. Added a convention comment at the top
  of WHATS_NEW. JS validated (string-aware brace/quote check clean); 64 integrity
  tests pass.
2026-06-10 23:05:01 -07:00
BoulderBadgeDad
fece771dd0 Security UI: show saved login password / recovery question state
After saving a password or recovery question, a refresh made the section look
unset (passwords are never echoed back to the browser), so it seemed like you had
to redo it. Now the saved state is reflected:

- "✓ A login password is set" appears when the admin has a password; the field
  becomes "Enter a new password to change it".
- "✓ Recovery question saved: <question>" appears, the saved question is pre-
  selected (preset or custom), and the answer field becomes "Enter a new answer to
  change it".
- Shown both on load (applyLoginSavedState from /api/profiles, which now includes
  recovery_question — not secret, already shown on the sign-in screen) and
  immediately after saving.

64 integrity tests pass.
2026-06-10 22:46:15 -07:00
BoulderBadgeDad
09d4bbc530 Security UI: confirm-password field for admin password + recovery reset
Mirror the PIN setup's confirm step so a typo can't silently set a password you
can't reproduce. Both the Step 1 admin password (Settings) and the forgot-password
reset (login screen) now require entering it twice and reject a mismatch before
saving. 64 integrity tests pass.
2026-06-10 22:41:23 -07:00
BoulderBadgeDad
2bb9bc1357 Settings: reorganize Security into clear groups with visible prerequisites
The security section had grown into a flat pile of toggles with hidden
dependencies. Regrouped into three labelled cards so it reads top-to-bottom:

- 🔑 Lock with a PIN — set PIN (Step 1) → Require PIN
- 👤 User accounts (login) — Step 1 admin password → Step 2 recovery question →
  Step 3 Require login. The Step 3 toggle is now visually LOCKED (greyed +
  disabled + "set the admin password first" hint) until an admin password exists,
  so the anti-lockout rule is obvious instead of surfacing as a 400 on save. It
  unlocks the moment the password is saved.
- 🌐 Reverse proxy & remote access — the proxy toggle, with the auth-proxy header
  nested under it (indented), plus WebSocket origins.

- get_all_profiles/get_profile now expose has_password + has_recovery so the UI
  can reflect setup state; updateRequireLoginGate() drives the lock.
- New .security-subgroup/.security-subhead/.security-nested/.security-locked CSS.

All IDs + handlers preserved. Inert unless used; default install unaffected.
64 script-split integrity tests pass.
2026-06-10 22:38:10 -07:00
BoulderBadgeDad
5c80ee1010 Login recovery (UI): Settings setup + "Forgot password?" on the login screen
- Settings → Security: a recovery-question picker (5 presets + Custom) + answer
  field + Save, posting to /api/profiles/1/set-recovery. handleRecoveryQuestionChange
  reveals the custom box.
- Login screen: a "Forgot password?" link opens a recovery view — enter username →
  fetch your question → answer + new password → reset → reload signed in. Reuses the
  launch-PIN overlay styling/structure (entry + recovery views).

All inert unless login mode is on, so a default/LAN install never sees any of it.
64 script-split integrity tests pass (every new handler resolves).
2026-06-10 22:28:07 -07:00
BoulderBadgeDad
21dfbb39b0 Native login (increment 3/3): login screen, set-password, Settings toggle, logout
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).
2026-06-10 22:10:33 -07:00
BoulderBadgeDad
fb8c8a71c6 Security UI: Settings toggles for reverse-proxy mode + auth-proxy header
Config is DB-backed (metadata.app_config) — there is no config.json — so the
reverse-proxy settings I added earlier had NO way to be set by a user and were
effectively dead. Added them to Settings → Security, next to the launch-PIN toggle:

- "Behind a reverse proxy" checkbox (security.trust_reverse_proxy) — help text notes
  it's for nginx/Caddy/Traefik+TLS, to leave OFF for direct/LAN http://, and that it
  needs a restart (applied at app init).
- "Auth proxy user header" field (security.auth_proxy_header) — e.g. Remote-User,
  with the must-strip-client-headers warning; blank = off.

Wired into the existing settings load + save; the save loop already persists every
key in the security object via config_manager.set, so no backend change needed.
Fixed Support/REVERSE-PROXY.md to point at Settings → Security instead of a
nonexistent config.json. Off by default → zero impact for direct users.

64 script-split integrity tests pass.
2026-06-10 21:17:26 -07:00
BoulderBadgeDad
d82d02b921 Artist Sync: unify with deep scan — server-diff stale removal, scoped to one artist
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.
2026-06-10 19:43:25 -07:00
BoulderBadgeDad
4d1b9a5639 Artist Sync: guard stale-removal against an unreachable mount + gate it admin-only
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.
2026-06-10 19:33:44 -07:00
BoulderBadgeDad
0afa3c9705 Fix: "Discovery state not found" when fixing a match after restart/import (#843)
The discovery FIX → Confirm flow 404'd with "Discovery state not found" whenever
the in-memory discovery state was gone — a server restart, or an imported
playlist that wasn't discovered in THIS process — even though the card is still
shown from persisted data (the reporter's log shows "Returning 0 stored ...
playlists for hydration", i.e. the in-memory states were empty).

The thing that actually makes a manual fix STICK is writing it to the discovery
cache (save_discovery_cache_match), keyed by the original track's name + artist —
which doesn't need the in-memory state at all. But the endpoint 404'd on the
missing state before reaching that write, so the fix was dead after a restart.

- update_discovery_match (core/discovery/endpoints.py) now only does the in-memory
  result update when the state exists; the durable discovery-cache write always
  runs, falling back to client-provided original_name/original_artist when there's
  no in-memory state. With neither a state nor originals it still 404s (unchanged).
- The FIX confirm (wishlist-tools.js) now sends original_name/original_artist
  (from the source track it already has) so the backend can key the cache.

Covers all sources that share the helper (tidal/deezer/qobuz/spotify-public).
Tests: no-state-but-originals saves the cache + returns success; no-state-no-
originals still 404s; existing with-state path unchanged. 73 discovery tests pass.
2026-06-10 18:49:32 -07:00
BoulderBadgeDad
27d738e7b1 Fix: Find & Add library search buried exact matches (case-sensitive ordering)
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.
2026-06-10 17:23:13 -07:00
dev
e7bc77cb04 Use app confirm modal for verification review actions 2026-06-11 01:53:05 +02:00
BoulderBadgeDad
b36392c62b Fix: a '/' in a song title was treated as a path separator (#835)
YouTube/Tidal/Qobuz results encode the name as ``id||title``. When the title
itself contains a '/' (e.g. the Sawano AoT track "YouSeeBIGGIRL/T:T"), two places
wrongly basename-split it on the slash and kept only the last segment ("T:T"):

- core/downloads/file_finder.py — the completed-download finder truncated its
  search target to "T:T", so the real on-disk file (slash sanitised by the
  writer) never matched → "not found after processing" → the download got
  QUARANTINED. Now an encoded ``id||title`` keeps the whole title as the target
  and contributes no remote-directory components; real Soulseek PATHS still get
  basename + dir extraction unchanged.
- webui/static/downloads.js — the manual-search FILE column showed only "T:T".
  Added a ``||``-aware short-label helper (mirrors the correct handling already
  used elsewhere in the file); real file paths still show their basename.

Tests: the finder locates "YouSeeBIGGIRL∕T: T.mp3" from the encoded title
"…||YouSeeBIGGIRL/T:T" (the screenshot case), doesn't match an unrelated file,
and a genuine Soulseek path still resolves to its last segment. 21 finder tests
+ 64 script-split integrity tests pass.
2026-06-10 16:29:09 -07:00
dev
37ea6604c7 Fix import artist override and verification review 2026-06-11 01:28:31 +02:00
dev
5896f2dcc6 fix: eager config load + check acoustid.enabled for verification pill
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>
2026-06-11 01:28:31 +02:00
dev
97b40cbd43 feat(verification): review queue — listen/compare/approve/delete unverified downloads
- ⚠ 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>
2026-06-11 01:28:31 +02:00
dev
8dcad2be4e feat(downloads): Unverified review filter + visible retry progress
- '⚠ Unverified' filter pill on the Downloads page lists completed downloads
  whose verification status is unverified/force_imported (review queue)
- the quarantine-retry engine's attempt counter (already tracked internally)
  is now surfaced: task.retry_info ('2/5') shows next to Searching/Downloading
  in the modal and as 🔁 on the Downloads page rows, with the trigger in the
  tooltip

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 01:28:31 +02:00
dev
2a11dc961a feat(verification): persist status into library_history, badge on Downloads completed list
The persistent Completed list is built from library_history (not live tasks),
so the badge never showed after a session ended. Column added (additive),
written at import, passed through _build_history_download_item, rendered by
_adlVerifBadge next to the status label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 01:28:31 +02:00
dev
9d1d09a571 feat(verification): persist status (db+tag), surface on Downloads, scan-aware force-imports
- import pipeline writes SOULSYNC_VERIFICATION tag + context status
  (verified / unverified / force_imported via version-mismatch fallback)
- downloads payload + UI badge (tooltip explains each state)
- AcoustID scan reads the tag: refreshes tracks.verification_status,
  reports force-imported mismatches as informational (clearly marked),
  optional skip via job setting skip_force_imported
- evaluate(): empty expected artist = title-only comparison (old scanner
  behaviour); thresholds single-sourced in the core

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 01:28:31 +02:00
BoulderBadgeDad
cc18ec266e Profiles: visual revamp of the Manage Profiles modal
Functionally unchanged — just brought it up to the polish of the rest of the app
(My Accounts / Manage Workers style). Same markup hooks + JS bindings, so no
behaviour change.

- Glassy gradient panel with blur backdrop, rise+fade entrance, soft shadow.
- Sticky header with a gradient people-icon badge + subtitle; close button
  rotates on hover.
- Profile rows are cards now: hover lift, and the profile you're signed in as is
  highlighted (accent ring + a "You" pill).
- Role/status shown as pills (Admin / No Downloads / N pages) instead of a
  dot-joined string.
- Edit/Delete are clean SVG icon buttons (was ✏️/🗑️ emoji) with accent/red hover.
- Inputs get a focus glow; colour swatches are larger with a check on the
  selected one.

64 script-split integrity tests pass; all JS-referenced classNames verified present.
2026-06-10 16:17:04 -07:00
BoulderBadgeDad
e5b30d6e63 Profiles: restore hand cursor on the clickable Service Status section
The cursor:pointer + hover rules were collateral damage when the dead
credential-set CSS block was removed. Re-added them (admin = pointer on the
section + its rows; non-admin stays default).
2026-06-10 14:10:58 -07:00
BoulderBadgeDad
186cbf0fcc Profiles: Tidal logo on a light disc (dark logo) in My Accounts 2026-06-10 13:49:25 -07:00
BoulderBadgeDad
af1a35385c Profiles: ListenBrainz in My Accounts; Personal Settings now just server library
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.
2026-06-10 13:32:29 -07:00
BoulderBadgeDad
60b9fe10e9 Profiles: per-profile Tidal self-auth (playlists) — with a safe token-save redirect
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.
2026-06-10 13:11:59 -07:00
BoulderBadgeDad
e8bd9c8018 Profiles: per-profile Spotify self-auth (shared app) + My Accounts modal + read wiring
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.
2026-06-10 12:21:17 -07:00
BoulderBadgeDad
c154aa5442 Profiles: remove the admin Connected Accounts manager (pivot to pure self-auth)
The model shifted from "admin creates shared credential sets, users pick" to
"each profile self-auths its own playlist accounts". Removed the admin-facing
Connected Accounts manager: the Settings section, credential-sets.js, its CSS,
the script tag + integrity-registry entry, and the loadSettingsData hook.

The credential-sets backend (service_credentials tables + /api/credentials and
/api/profiles/me/services endpoints) is left in place but dormant — additive,
tested, harmless — rather than churn migrations that already ran on installs.
Per-profile self-auth reuses the existing per-profile columns + the
get_*_for_profile client pattern instead. The Service Status modal (admin-only)
is unaffected.
2026-06-10 11:48:31 -07:00
BoulderBadgeDad
91eaaa4828 Profiles: revert Service Status modal to admin-only
Active metadata source / media server / download source are app-wide
infrastructure, so the quick-switch modal is admin-only again:
openServiceSwitchModal() no-ops (with a toast) for non-admins, and the sidebar
Service Status loses its clickable affordance for them. Per-profile playlist
account selection will live on its own user-accessible surface, not here.
2026-06-10 11:43:22 -07:00
BoulderBadgeDad
355b39e3b5 Fix: launch PIN re-triggered the first-run setup wizard every visit (#842)
Regression from the #832 server-side launch gate (Beckid). On a PIN-required,
unverified session the gate 401'd /api/setup/status — which the frontend checks
BEFORE the PIN screen. The 401 left setup_complete undefined, so `!undefined`
relaunched the full setup wizard on every visit (cancel → PIN screen worked,
which was the tell).

Two-layer fix:
- Allowlist /api/setup/status in the launch gate (it's a harmless boolean, no
  secrets) so the frontend gets the real answer even while locked.
- Make the frontend fail-safe: only launch the wizard on a definitive
  setup_complete === false from an OK response — never on a 401/locked/ambiguous
  one.

Test: locked session still 401s data endpoints but /api/setup/status returns
{setup_complete:true}; added a gate-allowlist assertion. 21 gate tests pass.
2026-06-10 11:40:47 -07:00
BoulderBadgeDad
7856433c6f Profiles: dark disc for white-wordmark logos (Plex/SoulSync) in the modal
The Plex logo is a white wordmark, so it vanished on the modal's white logo
disc (it only shows on Settings because those toggles sit on dark). Added a
per-logo `dark` flag (Plex + SoulSync) that renders their disc dark (#1f2329)
across the hero, rail, and option cards, so the white logo is visible. Other
logos keep the white disc.
2026-06-10 11:00:56 -07:00
BoulderBadgeDad
d018c19cb7 Profiles: fix server logos in the quick-switch modal (+ Settings Jellyfin)
The modal's server logos were the odd ones out — Jellyfin used the wide
jellyfin.org wordmark and Navidrome a stretched navidrome.org image. Switched
the modal's _SS_SERVER_INFO (drives both the rail and the option grid) to clean
square icons: Plex's plex-logo.svg, Navidrome's tweakers.net icon, and the
homarr-labs Jellyfin PNG. Also swapped the Settings page Jellyfin toggle from
the jellyfin.org wordmark to the same homarr-labs icon so they match.
2026-06-10 10:55:29 -07:00
BoulderBadgeDad
22947794e0 Profiles: label the no-auth Spotify composite everywhere it's shown
Follow-up to the modal fix: the sidebar Service Status + dashboard service card
also mislabeled "Spotify (no auth)" as plain "Spotify". They read the status
`source`, which came straight from metadata.fallback_source ('spotify') with no
awareness of the metadata.spotify_free flag.

- get_primary_source_status now reports a DISPLAY source of 'spotify_free' when
  fallback_source='spotify' + metadata.spotify_free is set (the raw 'spotify' is
  still used for the auth/connected checks), and treats the free path's
  availability as "connected" so the dot isn't falsely red on a no-auth setup.
- getMetadataSourceLabel maps 'spotify_free' → "Spotify (no auth)"; the status
  presentation treats spotify_free as part of the Spotify family (session /
  rate-limit / cooldown display still work). Added a SOURCE_LABELS entry.
- testDashboardConnection normalizes spotify_free → spotify (the only logic
  consumer of the source value — the dashboard Test button).

Routing is unchanged (the real source stays 'spotify' + free flag); this is
purely the display layer. Settings was always correct. 64 integrity tests pass;
the 2 failing soundcloud tests are pre-existing (confirmed identical on a clean
tree).
2026-06-10 10:41:09 -07:00
BoulderBadgeDad
6c05ec3670 Profiles: fix modal showing wrong active source + hero header / panel depth
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.
2026-06-10 10:26:29 -07:00