Commit graph

1814 commits

Author SHA1 Message Date
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
BoulderBadgeDad
cd59d75531 Profiles: richer Service Status modal + surface configured-vs-effective source
Visual rework toward the Manage Workers feel:
- Cards are now circular brand-logo discs on white, with each service's brand
  color (Spotify green, Deezer purple, Plex gold, …) driving the logo ring +
  active glow/gradient + hover lift. Replaces the flat emoji tiles.
- The left rail is alive: each tab shows its category + the CURRENT active
  choice's logo and label (e.g. "Metadata · Deezer"), with the active tab in a
  brand-tinted gradient + accent bar — mirroring the worker rows.

Correctness fix (answers "modal says spotify, settings says spotify (no auth)"):
the modal read the RAW configured source, but the rest of the app shows the
EFFECTIVE one. get_primary_source() silently downgrades a configured 'spotify'
to the default (deezer) when Spotify isn't authenticated — so configured and
effective diverge. The endpoint now returns `effective` alongside `active`, and
the Metadata panel shows a note ("Configured source isn't connected — actually
using Deezer right now") whenever they differ. Settings was never broken; the
modal just wasn't showing the resolved source.

78 tests pass (integrity + endpoints); smoke confirms configured spotify →
effective deezer surfaces, spotify_free stays itself.
2026-06-10 09:58:47 -07:00
BoulderBadgeDad
22202104ef Profiles: redesign the Service Status modal — active source/server/download switcher
Replaces the basic credential-pill quick-switch with a Manage-Workers-styled
modal (topbar + left rail + panel, entrance animation, brand-logo cards).

- Sidebar Service Status: whole panel opens the modal; clicking the Metadata /
  Media Server / Download rows deep-links straight to that tab. Removed the
  "switch ▸" hover text.
- Three tabs: Metadata (source logo cards, unavailable ones dimmed), Server
  (Plex/Jellyfin/Navidrome/SoulSync logos), Download (Single⇄Hybrid segmented
  toggle; Hybrid shows a draggable priority list). Logos reuse SOURCE_LABELS +
  HYBRID_SOURCES; active card gets an accent ring + check.
- Admin writes the GLOBAL active source/server/download (reuses the same setters
  + client reloads as the Settings save, so changes take effect immediately).
  Non-admins see it read-only (editable=false) — the per-profile override is the
  next layer.

Backend: GET /api/profiles/me/active-sources (any profile; reports editable),
POST /api/profiles/active-sources (@admin_only; validates against the allowed
metadata/server/download lists, applies + reloads). New service-switch.js
(registered + in the integrity registry); old modal removed from
credential-sets.js (admin Connected Accounts manager stays).

Tests: 14 endpoint tests — read shape, admin sets metadata/hybrid+order
(reflected), bad-value 400s, non-admin read-only + 403 on write. 64 integrity
tests pass; real-app smoke confirms render + deep-links + the full set/reflect
cycle.
2026-06-10 09:45:32 -07:00
BoulderBadgeDad
156c890de7 Profiles Phase 1+2 (UI): admin Connected Accounts manager + quick-switch modal
Frontend for the credential-set feature, matching the blocklist/house modal
style. Functional end to end against the existing endpoints; visuals are a
clean first pass to refine.

Admin manager (Settings → Connected Accounts, admin-only — empty for non-admins):
per service, the saved accounts render as pills with a delete ✕, and "+ Add
account" reveals an inline form built from each service's required fields.
Create POSTs /api/credentials; secrets are entered but never read back (the API
only returns id/label). Loads via loadCredentialSets() at the end of
loadSettingsData().

Quick-switch modal (sidebar Service Status is now clickable for ALL profiles):
shows, per service the admin set up, a "Default" pill + one pill per account,
highlighting the profile's current choice; clicking persists via
/api/profiles/me/services/select and re-renders. Empty-state message when the
admin hasn't configured any alternates.

webui/static/credential-sets.js (new, registered in index.html), house-style
CSS appended, sidebar made clickable, settings hook added. Registered the new
module in the script-split integrity test (onclick coverage). 64 integrity
tests pass; real-app smoke confirms index renders, the asset serves, and
admin-create → per-profile-list round-trips.

Note: selections are stored but not yet consumed by the live clients (the
resolver remains dormant) — wiring playlist-pull/enrichment to use a profile's
selected account is the next step.
2026-06-10 08:58:43 -07:00
BoulderBadgeDad
bb0f68a8bf Profiles Phase 2 (drag): make the hybrid-source reorder actually draggable
The hybrid download-source list set item.draggable=true and the help text said
"drag to reorder", but no drag handlers were wired — only the arrow buttons
worked (and _syncHybridOrderFromDOM was dead code). Wired real
dragstart/dragover/drop on each item, reordering _hybridVisualOrder (the same
model moveHybridSource uses) then rebuilding + autosaving. Added grab/grabbing
cursors + a dragging state. The arrow buttons still work unchanged.
2026-06-10 00:43:04 -07:00
BoulderBadgeDad
931167f197 Release 2.6.9: version bump + docker-publish default + What's New changelog
Bumps _SOULSYNC_BASE_VERSION 2.6.8 → 2.6.9, the docker-publish workflow's
default version tag, and adds the 2.6.9 What's New entry (15 items, security
fixes first: #832 launch-PIN enforcement and the settings-secret leak, then
#833/#831/#830/#829/#828/#827/#825/#824/#823/#740, Spotify (no auth), multi-
artist tags, decimal-volume dedup).
2026-06-09 23:03:49 -07:00
BoulderBadgeDad
8983299130 Security: stop GET /api/settings from shipping decrypted secrets to the browser
Found during the #832 audit: GET /api/settings returned dict(config_data) — and
config_data is DECRYPTED in memory — so every API key, OAuth secret, Plex/
Jellyfin token, and service password went to the browser in cleartext. Fernet
"encrypted at rest" protects a leaked DB file; it does nothing once the API
hands the plaintext to the client (devtools, HAR captures, an XSS, a screen
share, or a non-PIN'd LAN viewer).

Fix (centralized in ConfigManager):
- redacted_config() deep-copies config and replaces every _SENSITIVE_PATHS value
  that's actually set with REDACTED_SENTINEL; unset secrets stay empty so the UI
  still shows "not configured". Dict-valued secrets (tidal/qobuz OAuth sessions)
  collapse to the sentinel too. GET /api/settings now serves this copy.
- set() ignores a write of REDACTED_SENTINEL to a sensitive path, so the masked
  placeholder round-tripped by an unchanged settings form can never overwrite
  the real secret. A real value still saves; an empty value still clears.

Frontend: secret inputs are type=password, so the sentinel renders as dots
(looks like a saved secret). _wireRedactedSecrets() clears the mask on focus so
editing types fresh rather than onto the sentinel, and re-masks on blur if left
untouched — so an unchanged secret round-trips the sentinel (kept), an edited
one saves the new value, and a deliberately emptied one clears.

Tests: every sensitive path masks; unset stays empty; dict secrets mask; live
config not mutated; sentinel round-trip keeps the real secret; real value
overwrites; empty clears; sentinel on a non-secret path writes normally.
9 new tests; 518 config-touching tests pass (1 pre-existing soundcloud mock
failure, unrelated — fails identically on a clean tree).
2026-06-09 22:50:18 -07:00
BoulderBadgeDad
111af5150e Watchlist page: hued action chips, meta chips, Global Settings reskin (#831 round 3)
Boulder: the cards are good but everything around them was basic — six
identical grey pill buttons, a plain header, and a dated Global Settings modal.

Action chips (artist-detail button language — tinted gradient + hover lift +
icon scale): Scan is the primary CTA with the accent gradient and a shimmer
sweep; the rest get per-hue identity (similar-artists blue, settings slate,
origins green, history amber, blocklist/cancel red). One .wl-chip base class
with a --chip-rgb variable per hue. Header count/timer become pill meta chips
(timer accent-tinted).

Chip-safe labels: the scan/update handlers set button.textContent, which would
wipe the new svg + shimmer children on first use — added _wlSetChipLabel()
(preserves icon/shimmer, swaps the text node) and converted all 11 writes.

Global Settings modal: emoji + inline-styled header replaced with the
origins/blocklist house-style head (title/sub/✕); option cards now show live
checked-state feedback (:has(:checked) accent ring + grayscale-dimmed icons
when off — also upgrades the per-artist config modal, same components); the
master-override toggle gets a CSS .enabled treatment instead of the hard-coded
green inline border the JS used to write.

All element ids/onclicks unchanged; JS syntax-checked; 131 watchlist tests pass.
2026-06-09 21:22:02 -07:00
BoulderBadgeDad
34e0503fad Watchlist scan deck v2: portrait-anchored hero, zero layout shift (#831 polish)
Boulder's screenshots: the v1 deck shifted around depending on what data had
arrived (the album row vanished entirely without art, leaving floating
"Processing…/Processing…" text), the images were small, and the feed header
floated in empty space. Redesigned in the artist-detail-page language:

- Big 148px square portrait (rounded, shadowed) anchors the left side, with
  the current album stamped as a 62px overlay badge in its corner — when art
  is missing, both keep their slot and show a glyph placeholder instead of
  collapsing, so the deck NEVER changes shape mid-scan.
- 24px artist name + uppercase accent phase line + a fixed-height
  "now checking" block (accent left rule) for album + track, with stable
  placeholders ("Looking for new releases…" / "—") instead of doubled
  "Processing…" text.
- The additions feed is an inset fixed-height panel (artist-page sidebar
  style): same size whether 0 or 10 tracks, empty state centered.
- JS: hide the artist photo when the CURRENT artist has none (previously the
  prior artist's photo lingered), cleaner placeholder copy.
2026-06-09 20:57:27 -07:00
BoulderBadgeDad
9f12bdfef6 Watchlist: bespoke live scan deck + persistent per-run Scan History (#831 round 2)
Boulder: the live display was a cramped ~600px box showing a fraction of the
data the scan already tracks, with no animation and no history.

Live scan deck (replaces the three-column box, full width):
- Header: pulsing live dot, "x / y artists" progress text, and two live
  counter chips (found / added) that pop when they change.
- Animated progress bar (artist index / total) with a shimmer sweep.
- Stage: artist avatar with accent glow + name + readable phase line
  ("Checking album 2 of 5"), album art + album + current track.
- "Added to wishlist this run" feed: taller, bigger art, slide-in animation
  that plays once per new track (feed re-renders only when it changes).
- All data was already in scan_state (current_artist_index, total_artists,
  tracks_found/added_this_scan, current_phase) — just never displayed. The
  legacy fullscreen-modal markup shares element ids and lacks the new ones,
  so it keeps working untouched.

Scan History (persistent):
- New watchlist_scan_runs table — one row per run (status, timestamps,
  artists/found/added counts) + the full track ledger JSON. Saved at scan
  completion AND cancellation; idempotent on run_id; pruned to the last 100
  runs. Wishlist rows erode as tracks download, so this is the durable record.
- GET /api/watchlist/scan/history (runs) + /history/<run_id>/tracks (ledger).
- New History button on the Watchlist page → modal in the origins/blocklist
  house style: run cards (date, cancelled chip, artists/found/added stats)
  expanding into the Added / Skipped track lists with art and badges.

Tests: save+fetch with ledger, idempotent re-save, prune keeps newest,
unknown-run empty, cancelled runs recorded. 398 watchlist/wishlist/history
tests pass; JS syntax-checked; all rendered strings escaped.
2026-06-09 20:35:16 -07:00
BoulderBadgeDad
e8cde40d22 Watchlist: show WHICH tracks a scan found/added + group Download Origins (#831)
Tacobell444 (#707 follow-up): the scan summary said "New tracks: 19 • Added to
wishlist: 10" with no way to see which tracks those were — you had to scan your
wishlist and guess what was new.

Scan ledger: the scanner now records a per-run scan_track_events list (track,
artist, album, thumb, status added|skipped — skipped = found-new but declined
by add_to_wishlist: already queued or blocklisted; capped at 500). The status
endpoint already serializes scan_state, so the payload flows free. The
completed (and cancelled) scan summary on the Watchlist page gets a
"Show tracks" toggle expanding a styled list — Added section + Skipped section
with badges, reusing the live-feed row styling.

Download Origins grouping: the modal now groups entries by what triggered them
(watchlist artist / playlist name) with collapsible headers + counts instead of
a flat list with a per-row badge. Entries arrive newest-first so groups order
themselves by their newest download. Same row markup, checkboxes/delete intact.

Provenance: watchlist adds now stamp scan_run_id into wishlist source_info, so
per-run grouping is queryable later (future "what did run X add" views).

Tests: per-run ledger seam test (added + skipped statuses, album/artist fields,
FIFO unchanged). 316 watchlist/wishlist tests pass; JS syntax-checked.
2026-06-09 20:14:02 -07:00