Commit graph

533 commits

Author SHA1 Message Date
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
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
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
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
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
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
90174de4b2 Spotify: rename "Spotify Free" → "Spotify (no auth)", default enrichment to it
Per Boulder's calls on the new enrichment toggle:

- Naming: "Spotify Free" was misleading (it's a hybrid — pick it, connect an
  account, and sync still uses your official playlists). Relabel the user-facing
  strings to "Spotify (no auth)" — the real distinction is needs-credentials vs
  not. Internal value/key (spotify_free, _free_*) unchanged, so no migration.

- Default ON: metadata.spotify_free_enrichment now defaults True (worker + UI
  load both treat unset as on). So bulk enrichment runs on the no-auth path by
  default and the official account is reserved for interactive search/sync; turn
  the toggle off to enrich through the connected account. The toggle overrides
  auth for the worker (authed users still enrich via no-auth) — matching the
  intended model.

- Worker runs on the toggle alone: is_spotify_metadata_available() now honors
  _prefer_free (+ package installed), so the worker enriches via no-auth even
  with no account connected and no 'no-auth' source selected. Only fires on a
  client carrying the flag (the worker's own), so interactive/watchlist
  availability is unchanged.

- UI: moved the toggle from "Metadata Source" to the Spotify section next to the
  auth fields, always visible, on by default. Help notes the genre trade-off.

Tests: prefer_free makes metadata available without auth/source (and is inert
without the package); interactive availability unaffected. 218 Spotify tests pass.
2026-06-09 12:55:19 -07:00
BoulderBadgeDad
fd3ce8ba6e Settings: add "Use Spotify Free for background enrichment" toggle
User-facing opt-in for metadata.spotify_free_enrichment (the engine landed in
38461295). A checkbox in the Metadata Source frame, independent of the primary-
source dropdown, so a user with an official Spotify account connected can choose
to run the bulk enrichment worker on the no-creds Spotify Free source — sparing
their official API quota / dodging rate-limit bans for interactive search + sync.
Help text notes the trade-off (no artist genres from Free). Default off.

Wiring mirrors the existing spotify_free setting: saved in the metadata payload,
loaded into the checkbox, persisted via the generic metadata.* config loop (no
backend change). Auto-save already covers checkboxes in #settings-page.
2026-06-09 12:27:44 -07:00
BoulderBadgeDad
4eae33270b Watchlist: put Blocklist + Download Origins buttons on the LIVE page header
Both buttons were added to showWatchlistModal() in api-monitor.js — which has
no callers (dead/legacy code), so neither ever rendered. The live watchlist
is the #watchlist-page in index.html; its action row (.watchlist-page-actions,
next to Global Settings) is the real header. Added both buttons there as
static markup. Download Origins (shipped in 1f7834cc) was in the dead modal
too — this surfaces it for the first time as well.

onclick-coverage integrity test green (both handlers resolve to their
standalone modules).
2026-06-07 16:24:26 -07:00
BoulderBadgeDad
b6d78d015d Blocklist Phase 1 (backfill + API + modal): the Blocklist button on the watchlist page
Completes Phase 1 on top of the backend (43c798a7):

- Cross-source backfill: core/blocklist/backfill.py is a pure injected-resolver
  core (resolve only missing sources, never raises); core/blocklist/runtime.py
  wires the real metadata clients with a confident name-match (exact
  significant-token equality; album/track also require the parent artist when
  both expose one — no wrong IDs hung on an entry). Resolution runs
  synchronously at add time, so a ban is cross-source from the first scan;
  the artist name-fallback in matching covers any gap.
- API: GET/POST/DELETE /api/blocklist (profile-scoped) + /api/blocklist/search
  (thin wrapper over the manual-match service search on the active source, so
  the modal needn't know the source). Add resolves the other sources before
  storing.
- Modal (webui/static/blocklist.js): tabbed Artists/Albums/Tracks in the
  revamp design language (accent light-edge, pill tabs, debounced search with
  spinner + out-of-order guard, per-result Block, "currently blocked" list
  with a match-status star and per-row remove). Opened by a new "Blocklist"
  button on the watchlist page, next to Download Origins.

Tests: 5 backfill (fill-missing-only, None/exception handling, arg shape) + 4
API (search proxy, add→backfill→list→delete round trip, validation). Modal
registered in the script-split onclick-coverage test; JS syntax-checked.
2026-06-07 15:25:52 -07:00
BoulderBadgeDad
46f03827a2 Torrents: surface the stall settings as UI controls (noldevin)
Follow-up to 5187fe5f, which shipped stall handling as config-only keys.
Boulder wanted them user-accessible, so the two knobs now render in the
Torrent Client settings section:

- "Stalled torrent timeout (minutes)" — number input. Shown in MINUTES for
  friendliness, stored in SECONDS (download_source.torrent_stall_timeout_
  seconds). 0 disables. Blank/NaN falls back to the 10-min default on save.
- "When a torrent stalls" — Abandon (default) / Pause select, maps to
  download_source.torrent_stall_action.

Both live under download_source (already in the settings POST allowlist), so
no backend change — load converts seconds→minutes, save converts back.
Inputs/selects only (no onclick), so the script-split onclick-coverage test
stays green. settings.js syntax-checked via Windows node.
2026-06-07 12:52:57 -07:00
BoulderBadgeDad
79f020b3b4
Merge pull request #801 from nick2000713/feature/retry-next-candidate-on-mismatch
Downloads: complete retry overhaul,  exhaustive multi-source retry, MusicBrainz kanji fix, version-mismatch last-resort fallback
2026-06-07 00:45:21 -07:00
BoulderBadgeDad
1f7834cc7b Download Origins: see (and delete) exactly what watchlist + playlist syncs downloaded
User ask: "a modal that lists the tracks downloaded via watchlist" — extended,
as discussed, to playlists too. One modal, two tabs, opened from the Watchlist
page (watchlist tab preselected) and the Sync page (playlists tab) — same
shared-modal-different-entry-points UX as the rest of the app.

The data: library_history recorded which SERVICE a file came from but never
what TRIGGERED it. New origin/origin_context columns (migration + index) are
written once at the import chokepoint via core/downloads/origin.py, a pure
tested deriver that reads, in priority: an explicit _dl_origin stamp (set at
batch-task creation for direct playlist batches, where the playlist context
otherwise only survived in folder mode), the wishlist provenance already
riding in track_info.source_info (watchlist_artist_name / playlist_name —
watchlist_scanner has stamped these for ages), and the folder-mode playlist
thread. Manual downloads stay unclassified by design. History starts from
now — provenance can't be conjured retroactively.

API: GET /api/download-origins?origin=watchlist|playlist (paged) and POST
/api/download-origins/delete — deletes the file on disk (resolved through the
shared container/host path resolver), the matching library track row, and the
history entries; a file that refuses deletion keeps its row and reports the
error instead of lying.

UI: webui/static/origin-history.js — tabbed modal in the revamp design
language (accent light-edge, pill tabs, entry rows reusing the
library-history-entry components), per-row delete + select-all bulk delete
with honest result toasts, empty/loading states, per-tab totals.

Tests: 8 — deriver priority/shapes (incl. the exact watchlist_scanner
source_info shape and JSON-string survival), origin filtering + counts,
row fetch/delete isolation between origins, delete-track-by-path.
2026-06-07 00:15:31 -07:00
BoulderBadgeDad
318dd28748 Dashboard animations: GPU pass — same visuals, compositor-only where possible
Audit of every dashboard animation. Already good and untouched: orb canvas
(cached glow sprites, no shadowBlur, stops on tab-hide/page-switch/scroll),
shimmer scan, sidebar orbs, embers, rl-blink (all transform/opacity), and the
reduce-effects global kill-switch. The offenders were infinite animations of
paint-bound properties — each repaints its region every frame, forever:

- avatar halo: animated box-shadow on every active bar -> the bright state is
  painted once on a wrap pseudo and only its OPACITY breathes (the wrap exists
  because the avatar clips overflow)
- rate-limited warn: animated filter:brightness -> a white-wash pseudo whose
  opacity breathes
- active-fill glow: animated box-shadow -> static glow at the old midpoint,
  breathing moved to the tip's opacity
- header sweep: animated background-position across the full-width band (on
  all four headers sharing the class) -> a real child strip translated inside
  an overflow-clipped wrap; transform+opacity, zero paint
- orb canvas: renders at ~20fps while fully asleep (drift is at crawl speed —
  invisible) instead of 60fps for the hours the dashboard sits idle

Visual parity throughout; peak-flash (event-driven, 0.65s one-shot) keeps its
box-shadow since its duty cycle is negligible.
2026-06-06 16:05:05 -07:00
dev
37140dff34 Downloads: opt-in last-resort acceptance of repeated version mismatches
Some tracks don't exist on the sources in the wanted cut — every copy is, say,
the instrumental. The retry engine correctly rejects each (version mismatch) and
gives up, leaving the track missing. New opt-in fallback: once a track's AcoustID
retries are fully exhausted, if every quarantined candidate for it failed the
SAME version mismatch (same matched version, e.g. all instrumental) and there are
>= N of them, accept the best (first-tried = oldest = highest-confidence) one.

Safety rules (core/imports/version_mismatch_fallback.py):
- Version mismatches only. Audio/artist mismatches (different recording) and
  integrity/duration failures (truncated/wrong file) never participate.
- All qualifying entries must share the same matched version; a mix
  (instrumental + live) is ambiguous → no acceptance.
- Re-import bypasses ONLY the AcoustID gate; integrity/duration/bit-depth still
  run, so a truncated or genuinely wrong file is never let through here.
- Reuses the existing quarantine approve_quarantine_entry + re-verify dispatch.

Wired at the AcoustID give-up point in the verification wrapper. Two new
post_processing settings surfaced in the Retry Logic tile (default off):
accept_version_mismatch_fallback + version_mismatch_min_count.

Pure decision core + orchestration covered by tests (11). Acceptance logged at
WARNING with track + matched version.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:44:14 +02:00
dev
81291c198b WebUI: fix empty Quality Profile tile — gate whole tile, not inner group
The Quality Profile tile expanded to an empty body: settings.js
updateSourceVisibility toggled only the inner #quality-profile-section
(Soulseek-only + downloads-tab gate), leaving the new collapsible tile's
header/body visible with hidden contents. Wrap the tile in
#quality-profile-tile and gate that wrapper as a unit instead, so the whole
tile shows (Soulseek active) or hides (otherwise) — no empty shell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:44:14 +02:00
dev
63036a41ee WebUI: Retry/Quality settings as collapsible Downloads tiles; restore sidebar hover in reduce-effects
Settings reorg (Downloads page):
- Move the retry controls (retry next-best candidate, exhaustive retry,
  retries-per-query) out of the Post-Processing tile into a new collapsible
  "Retry Logic" tile on the Downloads tab (data-stg=downloads), collapsed by
  default. Also decouples them from the post-processing master toggle, which
  previously hid them when post-processing was disabled. Config keys are
  unchanged (still post_processing.*); settings.js binds by element id so the
  DOM move needs no JS change.
- Wrap the existing Quality Profile group in a matching collapsible tile,
  collapsed by default.

Sidebar (reduce-effects):
- The perf PR (#793) gated .nav-button hover/active-hover behind
  body:not(.reduce-effects), removing the highlight entirely in reduce-effects
  mode. Restore the highlight there using only cheap properties (flat
  background + border-color; the base already reserves a 1px transparent
  border so there's no layout shift) while keeping the expensive gradient /
  translateX transform / multi-layer box-shadow off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:44:14 +02:00
dev
5e0f86c5f5 Downloads: exhausted source switches to next source instead of failing
In exhaustive retry mode, a source that spent its whole per-source budget
(query_count × retries_per_query) gave up and failed the track outright —
never trying the other configured sources. For tracks where Soulseek has a
deep pool of wrong peers (e.g. an AcoustID title mismatch every copy shares),
the budget tripped long before HiFi/Tidal/… were ever reached.

Now, when a source's budget is spent, the monitor marks it exhausted on the
task and re-queues so the worker excludes it from the next hybrid search,
falling through to the next source in the chain. Each new source spends its
own fresh budget. The task only fails once no fallback source remains (or the
absolute total ceiling trips) — single-source mode still fails immediately,
since there's nothing to fall back to.

task_worker folds the exhausted-source set into both the orchestrator search
exclusion and the hybrid-fallback source list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:44:14 +02:00
dev
f3d43f385e Downloads: per-source exhaustive retry budget on mismatch (opt-in)
Adds an opt-in exhaustive mode to the quarantine-retry path. Default
behaviour is unchanged: a single global cap (MAX_QUARANTINE_RETRIES=5).

When post_processing.retry_exhaustive is on, each source gets its OWN
retry budget sized as query_count x retries_per_query. Soulseek peers
collapse to one 'soulseek' bucket; streaming plugins keep their name.
The worker now records query_count on the task; the budget scales with
the track's real query count. Loop protection is threefold: per-source
cap, used_sources exhaustion (the natural terminator), and an absolute
ceiling (MAX_TOTAL_QUARANTINE_RETRIES=100).

New settings (config + WebUI): retry_next_candidate_on_mismatch (master),
retry_exhaustive, retries_per_query (default 5).

Tests: 6 new cases covering per-source budgeting, source separation,
Soulseek-peer bucketing, query_count default, and the absolute ceiling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:44:14 +02:00
BoulderBadgeDad
e6d86dea26 Add "Import IDs from File Tags" backfill — gap-fill provider IDs from embedded tags
Files SoulSync (or MusicBrainz Picard) already tagged carry Spotify /
iTunes / MusicBrainz / Deezer / Tidal / AudioDB / Genius / Last.fm IDs in
their metadata. Enrichment workers gate their queues on
{provider}_match_status IS NULL, so reading those IDs back and gap-filling
the {provider}_id + match_status='matched' columns lets the workers skip
the API lookup entirely — big API savings on an already-tagged library.

New manual job in Tools -> Database & Scanning ("Import IDs from File
Tags"): scans every library file, reads embedded IDs, fills any that are
missing in the DB. Background job + progress card, mirroring the
write-tags-batch pattern.

core/library/embedded_id_reconcile.py (pure + tested):
- plan_reconcile(): gap-fill plan for a track + its album + artist. Only
  empty id columns are planned; a disagreeing embedded id is a conflict,
  never applied.
- apply_reconcile_plan(): one guarded UPDATE per id column —
  WHERE id=? AND (col IS NULL OR col=''). The guard makes the fill atomic:
  if an enrichment worker matched the same entity between our read and
  this write, the UPDATE affects 0 rows instead of clobbering it. Columns
  are introspected so a schema missing a provider's columns is skipped.
- reconcile_track_row(): per-track orchestration (id extraction, plan ->
  apply, keeping the in-memory parent maps fresh for sibling tracks).

Job hardening: paged track scan (bounded memory), per-page commits (don't
starve concurrent workers), per-file try/finally (one bad file can't abort
the run), counters from real rowcount.

Scope: 19 column-fills across 8 providers. MB *recording* (track) id is
left out (UFID frame the reader doesn't surface; Vorbis key ambiguous) —
MB album+artist are covered. Amazon/ASIN deliberately excluded (ASIN is a
different namespace than the worker's amazon_id). All target columns
verified against the live schema.

Purely additive: new module, two new endpoints, one new Tools card —
no existing behavior changed. 20 unit tests (incl. the concurrency guard).
Full suite clean (only pre-existing soundcloud /app env failures remain).
2026-06-05 17:52:29 -07:00
BoulderBadgeDad
4249856984 #798: make Spotify Free opt-in (not auto-bridge) + clearer help text
Per the cleaner model: the free source only runs for users who explicitly picked
'Spotify Free' — not for every connected user. _free_wanted() is now just
_free_selected() (dropped the has-credentials auto-trigger). So:
- Plain 'Spotify' user, rate-limited -> waits out the ban as before (no surprise
  background scraping, no ToS exposure for people who never chose free).
- 'Spotify Free' user, no auth -> free serves.
- 'Spotify Free' user who also connects an account -> official when healthy,
  free bridges only during a rate-limit, then switches back.

Rewrote the metadata-source help text as a plain per-source list with a clear
note on how Spotify Free + a connected account interact. Gate tests updated to
pin the opt-in behavior (plain-Spotify ratelimit = no bridge; Spotify-Free
ratelimit = bridge).
2026-06-05 15:10:18 -07:00
BoulderBadgeDad
217a5eda70 #798: Spotify Free as a real dropdown source + automatic rate-limit bridge
Consistency fix: Spotify Free is now its own entry in the metadata-source
dropdown (alongside Spotify / iTunes / Deezer / MusicBrainz) instead of a
side-toggle. Stored as fallback_source='spotify' + spotify_free=true so all
downstream 'spotify' routing and the spotify_* columns are unchanged.

Refined gate model (no toggle):
- Connected user (has credentials) -> official; bridges to free AUTOMATICALLY
  during a rate-limit ban (no opt-in needed).
- No-auth user -> must pick 'Spotify Free' in the dropdown; then free serves.
- Never opted into Spotify (no creds, didn't pick it) -> free never runs, so no
  surprise scraping. _free_wanted() = has_credentials OR picked-spotify-free is
  the guard.
- AUTHED + healthy -> official always; free never opens.

UI: dropdown gains 'Spotify Free (no credentials)' (selectable when the package
is installed — surfaced via status.free_installed, since selecting it is the
opt-in and can't depend on having selected it); load/save map the dropdown value
to the (fallback_source, spotify_free) pair; old checkbox removed.

Gate model pinned by 6 scenario tests (connected/healthy, connected/ratelimited
bridge, no-auth picked, no-auth not-opted-in, package-missing). 117 tests green.
2026-06-05 14:23:28 -07:00
BoulderBadgeDad
a387814deb #798: Spotify Free as a rate-limit bridge for connected users (hybrid)
When Spotify Free is enabled, it now also bridges an official rate-limit ban for
authenticated users instead of stalling — search already did this (the gate
opens on no-auth OR rate-limit); this extends it to the enrichment worker.

- spotify_worker: the rate-limit guard now sleeps only when free CAN'T cover
  (is_spotify_metadata_available() is False). Purely additive — with Spotify
  Free off, that's False during a ban and the worker sleeps exactly as before.
  Verified: toggle OFF + rate-limited -> sleeps (original); toggle ON -> bridges.
- Reframed the Settings toggle so connected users know it also covers rate-limits
  ("Use Spotify Free when Spotify is unavailable or rate-limited").

The official auth path is untouched; free never runs while authed Spotify works
normally.
2026-06-05 14:00:50 -07:00
BoulderBadgeDad
2667eaec87 #798: Spotify Free UI — enable toggle + source availability
Surfaces the opt-in Spotify Free source so it's usable end-to-end:
- Settings: 'Enable Spotify Free (no credentials)' toggle that saves
  metadata.spotify_free (load + save wired). Clear best-effort/limitations note.
- config-status: adds spotify.metadata_available (configured OR free-available),
  keeping the configured flag = has-credentials so the Connections indicator
  stays honest. Search source picker shows Spotify when metadata_available.
- status payload: adds spotify.metadata_available; the Settings primary-source
  selector now allows picking Spotify when authed OR free-available.

Verified gate composition: OFF by default (no surprise scraping); ON + no auth +
installed -> available & serving; AUTHED -> official always wins (free never
runs); missing package -> gracefully unavailable. JS + integrity + 111 tests green.
2026-06-05 13:36:46 -07:00
BoulderBadgeDad
e05979ea07 #775: links only — reject bare IDs (ambiguous), add not-found hint
Follow-up to the bare-ID footgun: a bare number like 525046 carries no
source and no entity type, so it resolved to whatever album happened to own
that id (a user pasting Kendrick's Deezer artist id got an unrelated album).

Now the resolver accepts provider URLs (and the explicit spotify: URI) only;
a bare/unrecognized string is rejected and the dropdown surfaces a hint to
paste a full link. URL parsing + album/track resolution are unchanged.
2026-06-05 06:44:01 -07:00
BoulderBadgeDad
9772d5313c Add #775: resolve a pasted metadata link/ID instead of searching
New 'Link / ID' input on the Search page: paste a Spotify / Apple Music /
MusicBrainz / Deezer URL (or a bare ID) and it's looked up directly on the
owning source — no fuzzy search, no scoring.

- core/search/by_id.py: source-agnostic parser (URL domain/path or bare-ID
  format -> source,kind,id; numeric IDs fan out, first hit wins) + per-source
  get-by-id dispatch + adapters projecting each provider's dict onto the
  standard album/track card shape.
- /api/enhanced-search/by-id: thin additive route over resolve_identifier.
- Frontend: dedicated input that adopts the resolved source as active and
  renders through the existing dropdown + download/import flow.

Purely additive — existing files are insertion-only; the resolver runs only
behind the new route. 29 seam tests cover parsing, shaping, fan-out, and
not-found.
2026-06-05 06:32:45 -07:00
BoulderBadgeDad
939c660498 Fix #792: 'reconcile' playlist sync mode (edit in place, keep image/description)
Replace mode (default) deletes + recreates the server playlist every sync,
which wipes its custom image, description, and identity. Add an opt-in
'reconcile' sync mode that edits the existing playlist in place — adds the
tracks now in the source, removes the ones gone — without destroying the
object, so the user's custom art/description survive.

- Pure planner plan_playlist_reconcile(current, desired) -> {add, remove}.
- Per-client reconcile_playlist: Plex addItems/removeItems on the same object;
  Navidrome Subsonic updatePlaylist delta (songIdToAdd / descending
  songIndexToRemove); Jellyfin add + remove-by-PlaylistItemId on /Playlists/{id}/Items.
- sync_service: reconcile branch with a replace FALLBACK (if a server's in-place
  edit is unavailable/fails, sync still succeeds destructively — logged loudly).
- Default stays 'replace' (no behavior change). New Settings > Playlist sync mode
  picker (replace/reconcile/append) backed by playlist_sync.mode; per-request
  sync_mode still overrides.
- Reconcile skips the post-sync source-image push so a custom poster isn't
  re-clobbered (the bug).

Tests: planner (add/remove/dedupe/order/empty) + reconcile-or-replace dispatch
(success / false-fallback / exception-fallback / no-method). Per-server in-place
API calls need dev validation against real Plex/Jellyfin/Navidrome.

NOTE: opt-in only; default behavior unchanged.
2026-06-04 15:15:49 -07:00
BoulderBadgeDad
2328e159a1 Tools page: fix section nesting + move Metadata Updater into Metadata & Cache
Two fixes:
- The retag-tool-card removal accidentally ate the </div></div> that closed the
  Metadata & Cache grid + section, so the Management section nested inside it.
  Restored the close — Management is a sibling section again. (div balance back
  to 1998/1998.)
- Moved the Metadata Updater card from 'Database & Scanning' into 'Metadata &
  Cache' where it belongs.
2026-06-04 09:51:00 -07:00
BoulderBadgeDad
d91e6a384d Remove the old Retag Tool (superseded by Library Re-tag job + Write Tags)
The old per-download Retag Tool was limited (only native-pipeline downloads,
100-group cap, manual per-group) and did the wrong thing — it moved/reorganized
files instead of just tagging. It's superseded by the new Library Re-tag job
(whole-library, in-place) + the enhanced-library 'Write Tags' button.

Removed: the post-download record_retag_download ingestion hook (stops writing
retag_groups on every download), core/library/retag.py, the web_server state +
deps + /api/retag/* endpoints + the tool:retag WebSocket emit, the dashboard
card + both modals (index.html), the core.js socket handler, and the tools-page
wiring + help entry (wishlist-tools.js). Updated the import-pipeline test.

Verified: web_server parses, app + core imports OK, 392 tests pass, no live
references to removed symbols.

Left as inert (harmless) for a careful follow-up sweep: the retag_groups/
retag_tracks tables + their DB CRUD methods (no longer written/read), and the
now-orphaned retag JS helper functions (no entry point/wiring/socket calls them;
interspersed with wishlist functions, so not blind-deleted).
2026-06-04 09:33:03 -07:00
BoulderBadgeDad
0a5cda5189 Discover: promote recommendations to a first-class section + show the 'why'
- New 'Recommended For You' carousel section on the Discover page (between the
  hero and Your Artists), so recommendations aren't buried behind a hero modal
  button. Reuses the recommended-card markup/CSS, the watchlist add handler, and
  primes the modal cache so 'View All' opens instantly in sync.
- Re-frames the now-stale copy: recommendations are library-wide (the similar-
  artists worker feeds the whole library), not watchlist-only.
- Shows the real explanation from the backend's 'because' field —
  'Because you have X & Y' (with a full-list hover tooltip) instead of just a
  count — in both the section cards, the modal cards, and the hero subtitle.
- Cards lazy-enrich their images via the same endpoint the modal uses.
2026-06-03 18:28:19 -07:00
BoulderBadgeDad
6dfe9f3d6a Worker orbs/hub: preserve logo aspect ratio + use SoulSync logo on Manage Workers button
- Nucleus logo now fits to the pulsing radius using the image's natural
  width/height, so it no longer stretches to a square.
- Manage Workers button swaps the helix emoji for the SoulSync logo
  (trans2.png) inside the existing accent badge.
2026-06-03 17:26:50 -07:00
BoulderBadgeDad
0a77542d84 Similar Artists orb: match the AudioDB/standard wiring exactly (no inline onclick)
Root cause of 'click does nothing': I flip-flopped between inline onclick and
addEventListener. A cached index.html with my inline onclick + fresh JS with
addEventListener = the click fires the toggle TWICE (pause then resume) = no net
change. Now identical to AudioDB/Deezer/etc.: NO inline onclick on the button,
single addEventListener('click', toggle) in the init. One handler, one fire.
2026-06-03 16:03:45 -07:00
BoulderBadgeDad
b79b26b7c5 Similar Artists orb: inline onclick (like Amazon) so the click reliably fires
The addEventListener wiring evidently wasn't firing the toggle (orb showed
running but clicking didn't pause). Switched the button back to an inline
onclick=toggleSimilarArtistsEnrichment() — identical to the Amazon orb, which
works — and exposed the fn on window so the inline handler always resolves.
Toggle logic unchanged (active ? pause : resume).
2026-06-03 15:59:23 -07:00
BoulderBadgeDad
5949c104c3 Similar Artists orb: reliable pause + standard addEventListener wiring
- Orb wouldn't pause when the worker had finished its library: the toggle keyed
  off classList.contains('active'), but a done worker sits in the green
  'complete'/idle state, so clicking tried to resume (no-op). Now it pauses
  unless already paused → pausable in any state.
- Switched from inline onclick to addEventListener (matches spotify/itunes/etc.,
  the majority pattern) instead of the amazon/discogs inline style.
2026-06-03 15:33:39 -07:00
BoulderBadgeDad
d70d410f6f Similar Artists worker: dashboard enrichment bubble
Adds the dashboard status bubble (the small icon row) for the Similar Artists
worker, alongside the modal entry. Mirrors the per-source bubbles: MusicMap logo,
purple accent, spinner + active/complete/paused states, hover tooltip, and a 2s
status poll against /api/enrichment/similar_artists/status. Click toggles
pause/resume. Tooltip shows matched/pending (the worker has no artist/album/track
phases). 74 JS integrity tests pass.
2026-06-03 15:19:46 -07:00
BoulderBadgeDad
0b3c3f656d Add Manage Enrichment Workers modal (v1 + polish)
Dashboard 'enrichment bubbles' could pause/hover but offered no way to
*manage* a worker. This adds a full management modal opened from a new
header button, covering all 11 enrichment sources.

Backend (testable core helper + seam tests; no live-DB dependency):
- core/enrichment/unmatched.py: pure, whitelisted SQL builders for the
  unmatched browser. service/entity validated against a support map (never
  interpolated raw); search + pagination bound as params; tracks join albums
  for artwork; limit capped at 200.
- database/music_database.py: get_enrichment_unmatched() +
  get_enrichment_breakdown() (the breakdown splits matched/not_found/pending,
  which the existing get_stats().progress lumps together).
- core/enrichment/api.py: GET /api/enrichment/<id>/{unmatched,breakdown} on
  the existing blueprint + a db_getter hook.
- web_server.py: wire db_getter=get_database.
- tests/enrichment/test_unmatched.py: 19 tests across builders, DB methods,
  and Flask routes.

Frontend (vanilla, matches app conventions):
- webui/static/enrichment-manager.js: worker rail with live status + coverage
  micro-bars, accent-themed detail panel (hero header, segmented matched/
  not_found/pending stat cards, current item, pause/resume), and a searchable
  paginated unmatched browser with inline manual match (reusing
  search-service + manual-match) and retry (clear-match re-queues).
- Polish: entrance/exit motion, scroll-lock, Escape, refresh control,
  flicker-free polling (in-place updates), skeleton loaders, relative
  timestamps, per-worker accent theming, real dashboard logos reused at
  runtime (with the same invert/circle treatment), responsive rail.
- index.html: header button + script include. style.css: full styling.

Reuses existing pause/resume, status, and manual search+assign endpoints.
Backend tests green (19 new + 11 existing enrichment tests).
2026-06-02 19:06:44 -07:00
BoulderBadgeDad
6bc2836f47 Feature: preferred album-art source selection (opt-in, ordered, with fallback)
Lets users pick which providers' cover art to use and in what priority,
generalizing the single prefer_caa_art toggle into an ordered, mix-and-match
list (Sokhi's request). Fully opt-in: default album_art_order is [], so every
existing install is byte-for-byte unchanged until the user enables sources.

How it works:
- Per album, walk the user's ordered sources top-to-bottom; the first source
  that actually has THIS album's cover wins. A miss falls through to the next;
  if all miss, the download's own art is kept (today's default). The worst case
  is always exactly the cover you'd get today -- never wrong art, never an
  error into the download.
- Connection-gated: a source is only tried when the user is connected to it
  (free sources CAA/Deezer/iTunes/AudioDB always; Spotify only when
  authenticated). Tidal/Qobuz/HiFi deferred (cover-URL construction + no clean
  core accessor -- not shipping unverified extraction).
- Album-match validated: a source's art is used only when the album it returns
  matches the requested artist+album (significant-token subset, tolerant of
  Deluxe/Remastered/articles/feat./multi-artist). A loose top search hit for a
  different record is treated as a miss -> guarantees no wrong-album art.
- The list supersedes the legacy prefer_caa_art toggle: when album_art_order is
  non-empty it is the sole authority (add 'caa' to the list to use Cover Art
  Archive), and prefer_caa_art is neutralized for both the embedded-tag art and
  cover.jpg paths. With an empty list, prefer_caa_art behaves exactly as before.

Implementation:
- core/metadata/art_sources.py: pure resolver -- effective_art_order (config +
  legacy back-compat) and resolve_cover_art (ordered walk + fallback,
  exception-safe per source). No network/config/DB; fully unit-testable.
- core/metadata/art_lookup.py: availability gating, per-source lookups against
  existing clients (Deezer/iTunes/AudioDB/Spotify search + CAA via MBID),
  album-match validation, per-album caching, and select_preferred_art_url --
  the single gate the pipeline calls (no-op unless an explicit list is set).
- core/metadata/artwork.py: wired into embed_album_art_metadata and
  download_cover_art, gated so no configured list == current behavior.
- web_server.py: GET /api/metadata/art-sources (connected sources only).
- config/settings.py: default album_art_order: [].
- webui (index.html + settings.js): reorderable list in Core Features reusing
  the hybrid-source-list pattern + real service logos (with emoji fallback);
  load/save wired through the existing metadata_enhancement settings flow.
  loadArtSourceOrder populates the saved order synchronously (filtered to known
  sources, not availability) so a save before the availability fetch resolves,
  or a temporarily-disconnected source, can never wipe the saved order.

Tests: 40 unit/seam tests (resolver ordering/fallback/back-compat, availability,
per-source extraction, album-match validation incl. wrong-album/wrong-artist
rejection, caching, exception-safety, the off-by-default gate). Full metadata
suite still green (610 passed) -- the gated integration changes nothing when no
list is configured.

Note: the settings UI (DOM-heavy, not unit-testable in the JS harness) and the
live per-source art-fetch quality are validated by manual testing.
2026-06-01 11:45:07 -07:00
BoulderBadgeDad
f50e67ac9b Batches panel: Phase A visual upgrade (summary, segmented progress, ETA, live track)
Takes the Active Downloads batch panel from flat cards to a glanceable,
information-rich view:
- Sticky aggregate summary strip: 'N batches · X downloading · Y queued · speed · ~ETA'.
- Segmented progress bar per batch — proportional done (green) / failed (red) /
  active (accent, animated shimmer) / remaining, so the state reads at a glance
  instead of one dim fill.
- Colored stat chips (✓ done · ✗ failed · ↓ active · queued) + a per-batch ETA
  from a client-side completion-rate sampler (album bundles use the downloader's
  own speed/size). No backend changes — Phase A is frontend-only.
- 'Now downloading' line showing the live track on active batches.
- Expand chevron affordance (rotates when open); subtle phase tinting.
- Polished empty state with quick-start links (Search / Sync / Wishlist).

Card actions (filter / cancel / open-modal / expand) and the fade/history
behavior are unchanged. ETA/speed for non-bundle batches and a retry-failed
action are Phases B/C (backend).
2026-05-31 22:28:43 -07:00
BoulderBadgeDad
134d306511 Track-detail modal: click any download row for a rich, status-aware view
Clicking a track row in the download modal now opens a polished detail modal
(its own template, webui/track-detail-modal.html, included into index.html;
behavior in static/track-detail.js): cover, title/artist/album, status badge,
in-app play, source, quality, AcoustID verdict, file location, and the
expected-vs-downloaded provenance — backed by /api/downloads/task/<id>/detail.

It adapts by status:
- completed  -> play (library stream) + full provenance
- quarantined-> reason + Listen (quarantine stream) + Accept & Import + Search
- failed/not_found -> reason + Search

This absorbs the standalone quarantine chooser, which is removed (its
Listen/Accept/Search live here now, with the same Windows file-handle release
before Accept and the thin-sidecar -> Recover-to-Staging fallback). Plain
failed/not-found rows still go straight to the search modal; sync-import modal
unaffected. Status cells clear their clickable/detail state each render so a row
that flips to completed isn't left with a stale handler.
2026-05-31 20:24:37 -07:00
BoulderBadgeDad
112ecbb24f Player: seek hover tooltip on the Now Playing progress bar
The mockup had a seek tooltip (timestamp tracks the cursor over the progress
bar) but it was never ported to the real player. Added it: mousemove computes
the hovered fraction -> formatTime(duration*frac), positions the tip, shows on
hover / hides on leave. Guarded when no duration. Frontend-only; JS + CSS clean.
2026-05-30 15:15:58 -07:00
BoulderBadgeDad
f9bc96bd90 Player: 'Playing from' context header (Radio / <Artist> Radio)
Spotify-style context line above the track title. npSetPlayContext(text) shows/
hides it; set to 'Radio' when radio mode turns on, '<Artist> Radio' from
playArtistRadio (specific label wins over generic), cleared on stop/clearTrack
and when radio mode is turned off. Accent-colored name, uppercase label.

Frontend-only; JS + CSS clean.
2026-05-30 15:07:31 -07:00