Commit graph

728 commits

Author SHA1 Message Date
BoulderBadgeDad
dd5fe844d4 UI consistency (buttons 2/N): wishlist modal buttons -> .btn
Migrate the wishlist add-to-wishlist modal buttons onto the shared .btn
primitive: primary -> .btn--primary, secondary -> .btn--secondary, the green
download CTA -> new .btn--download modifier. Added a shared .btn.loading state
(amber pulse, reusing the existing pulse-loading keyframe) since
confirm-add-to-wishlist-btn toggles `loading` via JS (wishlist-tools.js).

Removed the dead .wishlist-modal-btn* rules and re-scoped the mobile
full-width override to `.wishlist-modal-actions .btn`.
2026-05-28 23:48:23 -07:00
BoulderBadgeDad
eebc58d3ff UI consistency (buttons 1/N): add shared .btn primitive; migrate config-modal
Start of the button-consolidation pass (kettui's #1). The app had ~236 button
classes / ~8-10 distinct looks with heavy near-duplication.

Introduce a canonical .btn design-system primitive (base + .btn--primary /
.btn--secondary / .btn--danger), modeled on the dominant existing look
(accent-gradient primary, translucent ghost, semantic danger) and built on the
accent CSS vars. New markup and the React pages should use this; existing
per-page button classes will migrate onto it family by family.

First family migrated: the config/settings modal buttons (.config-modal-btn*,
4 static uses, no JS refs) -> .btn .btn--primary / .btn--secondary. Removed the
now-dead .config-modal-btn* rules and re-scoped its mobile full-width override
to `.config-modal-actions .btn`.

Visible change is minor by design (padding 28->24px, gradient direction
normalized). Proof step for sign-off on the .btn look before rolling wider.
2026-05-28 23:40:10 -07:00
BoulderBadgeDad
45bbc99d94 UI consistency (page shell 3/N): playlist-explorer adopts .page-shell card
Convert the playlist-explorer page from a flat padded container to the
.page-shell floating card. Drop its bespoke `padding: 24px 32px`; keep the
full-height flex layout (display:flex / column / min-height:100%) since the
explorer fills the viewport.

Visible change by design. Watch: the full-height min-height:100% inside a
margin:20px card may run slightly tall — to be confirmed visually.
2026-05-28 22:57:09 -07:00
BoulderBadgeDad
def58a9907 UI consistency (page shell 2/N): automations adopts .page-shell card
First of the "flat -> card" conversions. The automations list view sat
directly on the page background (.automations-container = bare padding) while
its inner .dashboard-header is the same header dashboard uses. Adopt
.page-shell so the page becomes a floating gradient card structurally
identical to the dashboard (page-shell card > dashboard-header > content).

- Drop .automations-container's bespoke `padding: 20px 24px` (card padding now
  from .page-shell); keep the class as the mobile/JS hook.
- Add `page-shell` to the container in markup.

Visible change by design (this page was not previously a card). Mobile keeps
its existing .automations-container padding override.
2026-05-28 22:54:31 -07:00
BoulderBadgeDad
d2a730a6aa UI consistency (page shell 1/2): extract shared .page-shell primitive
First step of the page-layout-shell standardization (kettui's UI-consistency
point #1). The dashboard, tools, watchlist and wishlist pages each defined a
byte-identical "card" container (padding 28px 24px 30px, margin 20px, gradient
bg, radius 24px, border + border-top, layered shadow) under four different
class names.

Extract that into a single `.page-shell` primitive (modeled on the canonical
dashboard/stats look) and have the four pages adopt it. Each keeps its bespoke
class for page-specific extras and as a JS/mobile hook:
- dashboard-container: keeps display:flex / column / gap:25px
- watchlist/wishlist-page-container: keep position:relative
- tools-page-container: no extras (box now fully from .page-shell)

Zero visual change: computed styles are identical (declarations relocated, not
altered), and mobile.css overrides still target the retained bespoke classes.
Per-page themed headers (watchlist amber, etc.) are intentionally NOT touched.

The class name is intended for reuse by the React pages too, so the primitive
is shared across both stacks.

Next (wave 2): migrate settings / automations / playlist-explorer / library
onto .page-shell, which snaps their slightly-off spacing to canonical.
2026-05-28 22:38:55 -07:00
BoulderBadgeDad
ff974c0b5c Standardize artist-detail hero action buttons
The four artist-hero buttons (Artist Radio, Watchlist, Download
Discography, Enhance Quality) had drifted apart visually — different
sizes, weights, radii, hover treatments, and ad-hoc colors. Unify them
on the Download Discography look (the nicest of the set): accent-style
gradient fill, matching border, light-tinted text, compact 7x16 / 12px /
700 sizing, and a translateY(-1px) + colored glow on hover.

To keep them distinguishable, each carries its own hue within that
shared recipe:
- Artist Radio       -> violet (#8b5cf6)
- Watchlist          -> theme accent (keeps its amber "watching" state)
- Download Discography -> theme accent + shimmer (the primary action)
- Enhance Quality    -> cyan (#4fc3f7, its original signature color)

Also:
- Drop the shimmer from the three secondary buttons — four simultaneous
  shimmers were distracting; it now marks only the primary action.
- Remove the `margin: 12px 0 4px` on `.discog-download-wrap` (now
  `margin: 0; display: inline-flex`) that pushed the discography button
  ~4px below its siblings in the centered flex row.
- Include Artist Radio in the mobile button sizing override (was missing).
2026-05-28 12:35:02 -07:00
Broque Thomas
7145368d42 Basic search: visual overhaul + per-source picker in hybrid mode
Two things in this commit. Functional download / matched-download
behaviour is untouched — same JS handlers, same routes for the
download actions, same album-expand interaction.

VISUAL REDESIGN
- Glass search-bar card with accent radial wash + focus ring + pill
  primary search button
- Source chip row above the search bar (see below)
- Always-visible compact filter pill row (Type / Format / Sort) —
  pills carry both ``bs-filter-pill`` (new visual) and ``filter-btn``
  (legacy class for ``resetFilters`` + ``applyFiltersAndSort`` in
  wishlist-tools.js to keep working)
- Accent-tinted status pill matching the dashboard / auto-sync look
- Album result cards: glass card with accent left-edge stripe,
  52px brand-tinted cover icon, chevron expand indicator, pill
  action buttons (Download / Matched Album), accent glow on hover
- Track result cards: glass row with accent stripe, 44px icon,
  pill action buttons (Stream / Download / Matched Download)
- Multi-disc separators inside expanded album track lists styled
  with the accent treatment
- Responsive: action button columns stack vertically below 900px

New CSS lives in a self-contained ``webui/static/basic-search-v2.css``
sheet linked from index.html. Selectors are scoped to
``#basic-search-section`` for any class that already exists in
style.css (``.album-result-card``, ``.album-icon``, ``.track-*``,
etc.); the new ``bs-*`` prefixed classes for the search bar /
filters / source row / status are unscoped because they only exist
in the new markup. ``!important`` is used on the card-level rules
to defeat the original unscoped ``.album-result-card`` etc. rules
in style.css that would otherwise leak heavyweight padding /
box-shadow / 56px icon styles into the new design.

Also removed ``overflow: hidden`` from the original
``.album-result-card`` and ``.track-result-card`` rules in style.css
— those two classes only render in ``downloads.js`` basic search
results (verified via grep, two render sites only), so the
removal can't impact any other UI.

SOURCE PICKER (hybrid mode)
- New ``GET /api/search/sources`` endpoint returns the list of
  active sources from the orchestrator's chain (or the single
  active source in single-source mode).
- Frontend renders a chip row above the search bar. Click a chip
  to target that source for the next search; the chip's brand
  accent fills.
- In single-source mode the lone chip is rendered as a dashed-
  border label so the user always knows what they're searching
  but can't accidentally try to switch to sources that aren't
  configured.
- ``/api/search`` accepts an optional ``source`` body param. When
  set, ``core/search/basic.py:run_basic_search`` resolves the
  client directly via ``orchestrator.client(source)`` and calls
  its ``.search()`` instead of going through the hybrid chain.
- Backwards compatible: omitting ``source`` falls through to the
  original ``orchestrator.search()`` call exactly as before.
  Unknown source names also fall back to the default — typo
  protection.

TESTS (5 new + 6 pre-existing = 11 total in test_search_basic.py)
- source param routes to specific client, NOT orchestrator chain
- no source param preserves original orchestrator-default behaviour
- unknown source name falls back to orchestrator default
- ``run_basic_soulseek_search`` backwards-compat alias preserved
- source-targeted path serialises albums + tracks correctly

101 search-suite tests pass.
2026-05-28 10:22:07 -07:00
Broque Thomas
4ae5aee528 Sync page: collapse tabs to brand-logo chips with active label pill
The sync-tabs row had 14 sources jostling for horizontal space —
labels wrapped to 2 lines, the active pill ate disproportionate
room, the whole strip felt cramped and would only get worse as
more sources get added.

Restyled the strip as circular brand-logo chips. Inactive tabs
are 40px discs that show only the source's icon; the currently-
active tab swells into a pill that reveals its label inline.
Hover surfaces the source name as a native tooltip via the
title attr. Each chip carries its source's brand color as a
hover ring + active fill (Spotify green, Tidal orange, Qobuz
blue, Deezer purple, iTunes coral, YouTube red, Beatport green,
LB orange, Last.fm red, SSD teal).

Three sources share a logo with another source (Spotify Link
/ Spotify, Deezer Link / Deezer, iTunes Link / no native iTunes
but same logo family). Each "Link" variant carries a small
chain-link badge bottom-right so the chip disambiguates without
forcing the label to always be visible.

CSS-only swap — same JS handlers, same .active class, same
data-tab routing. HTML edit wraps each tab's label in a
``<span class="sync-tab-label">`` and adds ``data-link="true"``
to the Link variants so the CSS can target them.

Responsive: chips collapse to 36px on laptop / tablet and 32px
on mobile; the divider hides on mobile and gap tightens.
2026-05-27 22:04:28 -07:00
Broque Thomas
b19c1ae8cc Auto-Sync sidebar: brand logo on each source-group header
The sidebar source-group headers (Spotify / Tidal / Qobuz / Deezer /
YouTube / Last.fm Radio / ListenBrainz / iTunes Link / SoulSync
Discovery / Spotify Link) only showed the source name in caps —
the dashboard equalizer + connections panels both render the
actual brand logo, so the sidebar reading as text-only felt
disconnected.

Added a small (18px) circular brand-logo chip to the left of
each source-group title, sourced from the same URLs the
dashboard equalizer avatars use. Dark glass backdrop + accent
ring + drop-shadow on the logo so the chip stays legible
against either light or dark marks; brightness(0) invert(1)
applied to Tidal / Qobuz / iTunes-Link so their dark-foreground
marks render as white silhouettes against the disc (same
recipe the equalizer overrides use). Last.fm's square avatar
PNG clips to a circle via object-fit: cover.

Sources without a publicly available logo (Beatport, file
imports) fall through to no-chip — the <img onerror> swap
hides the broken image so the header still renders cleanly.
2026-05-27 20:23:12 -07:00
Broque Thomas
e296fbfadd Auto-Sync manager: full visual overhaul to match the dashboard vibe
The Auto-Sync manager modal had been carrying its original visual
treatment forward unchanged while the rest of the app moved
toward the glassy / accent-radial / gradient-border aesthetic
the dashboard now sets. Restyled every surface inside the modal
to match.

Strategy: selector-based override layer appended at the end of
``webui/static/style.css`` — every selector in the new block
already exists in the original CSS above; the new block wins on
cascade order. Zero HTML / JS changes; functionality untouched.
Delete the v2 block to revert.

Surfaces restyled
- Modal shell: glass + thin accent border + corner radial wash
  + inner top-edge highlight, matching the dashboard ``.dash-card``
  architecture
- Header: gradient-clipped title, accent-tinted eyebrow, hairline
  accent separator below, spinning-X close button
- KPI summary tiles: dashboard-style gradient tiles, accent
  top-edge glow on hover, gradient stat numbers
- Live monitor strip: accent-tinted glass card, status-colored
  borders (running = green, error = red)
- Refresh / intro buttons: pill primary with accent fill + glow
  on hover (replaces the bare ghost button)
- Tabs: underline-style with accent fill + soft radial glow on
  the active tab (replaces the pill-tab look)
- Sidebar: glass panel, source groups as collapsible-feel cards,
  accent border on scheduled playlist tiles, accent ring on the
  filter input focus
- Board: subtle accent radial spotlight backdrop; columns are
  glass cards with gradient headers + accent drag-over glow
- Drop zones: animated dashed pill with accent radial wash;
  accent-tinted text on drag-over
- Scheduled cards: accent left-edge stripe, gradient pill timing
  badges, pill "Run now" primary + rotating ghost X — health
  variants (failing / warning) re-tint the left-edge stripe
- Run history rows: dashboard recent-activity aesthetic, accent
  hover lift, pill status badges with colored borders
- Bulk schedule popover: glass card with accent border, pill
  buttons, red ghost for unschedule
- Weekly editor: glass modal matching version-modal vibe,
  day-toggle pills (accent fill when active), pill save button
- Empty / monitor-empty states: dashed glass card with subtle
  vibe
- Soft accent-tinted scrollbars throughout the modal
2026-05-27 20:15:56 -07:00
Broque Thomas
a315192e9a Dashboard: round Last.fm avatar, invert dark-mark logos (Tidal/Qobuz/Discogs/Amazon)
Last.fm ships a square Twitter avatar; clip it to a circle so the
disc reads as a uniform chip. Tidal / Qobuz / Discogs / Amazon
ship dark-foreground marks that disappear against the dark glass
avatar backdrop — invert to a white silhouette so the logo
actually reads. The per-service accent drop-shadow still applies
so the brand color cue is preserved as a glow around the white
silhouette.
2026-05-27 19:32:32 -07:00
Broque Thomas
79465580ab Dashboard: equalizer bars now show real brand logos in the avatar disc
Initial-letter glyphs (SP / AM / DZ / ...) read as placeholder
once the brand-logo equalizer disc was visualised — each chip
should carry the service's actual mark. Wired the same logo
URLs the header-action worker orbs already load (Spotify press
asset, iTunes Wikimedia SVG, Deezer brandfetch symbol, Last.fm
avatar, Genius logo, MusicBrainz Wikimedia SVG, AudioDB local
PNG, Tidal / Qobuz / Discogs SVGRepo marks, Amazon local SVG)
into a new _RATE_GAUGE_LOGOS map and rendered as an ``<img>``
inside the avatar disc.

Visual details
- Disc backdrop switched from a solid accent-gradient fill to a
  dark glass radial + accent-tinted ring + accent drop-shadow
  on the logo. The service color still anchors the chip without
  competing with the logo for contrast.
- Logo sized at 75% of the disc for breathing room. Drop-shadow
  pops dark / multi-tone marks against the dark backdrop.
- Avatar bumped to 34px / 28px / 26px across desktop / tablet /
  mobile so logos read clearly at every breakpoint.

Resilience
- ``<img onerror>`` swaps in an initial-letter glyph span on
  load failure (CDN drop, network blip). The ``.rate-eq-avatar
  --fallback`` variant restores the original accent-gradient
  disc look so the fallback chip still reads as branded.

Asset
- AudioDB ships no public logo URL — saved the existing header-
  action base64 PNG (~30 KB) to ``webui/static/audiodb.png`` so
  the equalizer can reference it as ``/static/audiodb.png`` like
  Amazon already does.
2026-05-27 19:22:28 -07:00
Broque Thomas
c845fa933f Dashboard: next-level polish on enrichment equalizer bars
Four upgrades that take the equalizer row from clean to vibey.
All tied together by the same --eq-accent / --eq-glow CSS
variables so future tweaks stay coherent across the four
animation layers.

1. Brand-color avatar disc above each bar. Circular chip with a
   2-3 letter glyph (SP / AM / DZ / LF / GN / MB / ADB / TD /
   QB / DC / AZ) and a radial gradient using the service's
   accent. Inner highlight + drop-shadow for depth; slow halo
   pulse when the worker is running. Anchors each capsule to
   its identity so the row reads as "these are your services"
   not "these are 11 anonymous bars."

2. Peak-flash detector. When ``cpm`` actually steps upward
   between socket updates (above a small jitter floor so
   near-zero noise doesn't trigger), the peak tip briefly
   flares white-hot, the fill flashes brighter, and the
   reflection puddle ripples — all on a 650ms one-shot the JS
   removes after fire. Mimics a hardware VU meter's peak-
   detect LED. Sells the "alive" feeling by tying bar
   movement to real call activity, not just continuous
   animation.

3. Rolling-counter number animation. The live count under
   the bar digit-animates from old→new with easeOutCubic
   over 520ms instead of snapping. Per-element animation
   handles tracked in a WeakMap so a fast second update
   cancels the prior RAF loop instead of fighting it.
   Premium-counter feel.

4. Glass-surface reflection puddle. Soft accent-colored
   blurred ellipse under each bar; opacity scales with the
   real (unclamped) rate via the --eq-glow variable so idle
   bars don't pollute the row with permanent ground-light.
   Rate-limited bars get a red puddle. Peak-flash briefly
   intensifies the puddle so the surface "ripples" with the
   call burst. Mounted on the host button (not the track) so
   it escapes the track's overflow clipping.

Responsive: avatar disc shrinks to 26px at laptop/tablet,
24px at mobile.
2026-05-27 19:08:00 -07:00
Broque Thomas
74dcafd6e9 Dashboard: equalizer-bar redesign for Enrichment Services panel
The rate monitor on the dashboard used a 10-column grid of circular
SVG speedometers. With 11 services configured (Amazon was the
straw), the grid produced 10-in-row-1 + 1-orphan-in-row-2, breaking
the dashboard's tile symmetry. Speedometers also wasted ~80% of
their pixels on empty arc — most services sit at 0 cpm most of
the time, so the row visually read as a wall of empty gauges.

Replaced with a VU-meter / equalizer row: one vertical capsule
per service, brand-color gradient filling from the bottom, bar
height tracks ``calls/min ÷ limit``. Music-app native aesthetic,
fits the existing accent-heavy glassy vibe, and symmetric by
design at any service count — services slot into the flex row.

Visual details
- 4% sliver floor on idle bars so the row reads as "everything
  alive" instead of "8 dead gauges" — vibe over literal zero
- Continuous shimmer scan when worker is running (vertical wash)
- Slow breathing pulse on idle bars
- Red gradient + faster pulse when rate-limited
- White-hot peak tip glows in the service's accent color
- Status pill below each bar (Running pulses green, Paused amber)
- Big count number floats top-center of the track

Behavior
- Click any bar opens the same detail modal the speedometer used —
  no data-flow changes, no API changes, drop-in visual swap.
- Renderer auto-detects the dashboard context (data-card="enrichment")
  and routes through the equalizer path; legacy speedometer code
  still ships for any non-dashboard mount.
- Responsive: tightens at laptop/tablet breakpoints, wraps to
  5-per-row on phones.
2026-05-27 19:02:50 -07:00
Broque Thomas
17607c2d83 Update style.css 2026-05-27 18:34:07 -07:00
Broque Thomas
698c21c3ce Auto-Sync Weekly Board: weekday schedules in the UI (PR 3/4)
PR 3 of the schedule-types feature — see
``memory/project_auto_sync_schedule_types.md``. Backend
``next_run_at`` + ``weekly_time`` trigger handler landed in PRs 1-2.
This PR exposes them in the Auto-Sync manager so users can finally
schedule playlists by day-of-week + time instead of only hourly
intervals.

**UI layout:**

The Auto-Sync modal grows a ``Weekly Board`` tab between
``Hourly Board`` (renamed from ``Schedule Board``) and
``Automation Pipelines``. Same sidebar (mirrored playlists grouped
by source, with filter). Main panel is 7 day columns Mon-Sun
instead of 10 hour buckets. Drag a playlist onto a day column →
creates a single-day weekly schedule at the default time
(09:00 in the browser's IANA tz from
``Intl.DateTimeFormat().resolvedOptions().timeZone``). Click any
scheduled card → opens an editor popover for time, multi-day
toggles, tz override, and unschedule.

Multi-day schedules render under every matching column (Mon-Wed-Fri
schedule appears as three cards, one per column) — matches how
users think about "this playlist runs on Mon AND Wed AND Fri".

**Mutual exclusion:** one schedule per playlist. The save path on
either tab deletes any existing schedule of the OTHER kind before
installing the new one. Backend can technically run both as two
separate automation rows, but two cards under the same playlist
would surprise users and the engine has no merge semantic for
"daily-and-hourly".

**Pure-function helpers** (testable via node:test, matching the
existing ``tests/static/test_auto_sync.mjs`` pattern):

- ``detectBrowserTimezone()`` — Intl tz with UTC fallback for
  browsers where Intl is absent.
- ``autoSyncWeeklyTrigger({time, days, tz})`` — defensive payload
  builder: garbage time → 09:00, unrecognised days dropped,
  missing tz → browser tz.
- ``autoSyncWeeklyFromTrigger(config)`` — inverse parser with
  the same defensive shape. Empty days expands to every weekday
  (matches ``next_run_at`` engine semantic). Returns null for
  non-object configs so ``buildAutoSyncScheduleState`` can route
  broken rows to automationPipelines instead of silently
  bucketing them as every-day weekly.
- ``autoSyncWeeklyLabel(parsed)`` — sorted "Mon, Wed, Fri @
  09:00" / collapses to "Daily @ HH:MM" for full-week / "Unscheduled"
  for null. Canonical Mon-Sun ordering regardless of input order.

**Tests:** 26 new node:test cases across ``detectBrowserTimezone``
x1, ``autoSyncWeeklyTrigger`` x6, ``autoSyncWeeklyFromTrigger`` x6,
``autoSyncWeeklyLabel`` x5, and ``buildAutoSyncScheduleState``
weekly bucketing x5 (covering owned weekly_time → weeklySchedules,
hourly stays in playlistSchedules, non-owned falls through to
automationPipelines, legacy-named auto-sync rows still recognised,
garbage trigger_config falls through). All 62 node:test cases pass;
261 across the automation pytest suite still green (zero regression
on PRs 1-2's plumbing). Python wrapper at
``tests/test_auto_sync_js.py`` shells out cleanly.

**CSS** (themed to the existing Auto-Sync gradient + accent
variables):
- 7-column grid for the weekly board, narrower than the 10
  hour-bucket layout.
- Editor popover with backdrop-blur, accent-tinted save / delete
  buttons, hover states that pick up the user's accent color.
- ``scheduled-elsewhere`` state for playlists with an hourly
  schedule visible on the weekly board (dashed border + opacity)
  so the user knows a drop will replace, not stack.

**WHATS_NEW entry** under 2.6.3 unreleased — first user-visible
slice of the schedule-types feature.

PR 4 (Monthly UI tab) deferred until weekly proves wanted.
2026-05-27 12:39:56 -07:00
Broque Thomas
80a88a62ac Auto-Sync sidebar: improve playlist card readability
The mirrored-playlist cards in the Auto-Sync schedule modal's
sidebar were truncating long names with ellipsis on a single line
+ rendering meta info at 10px, which made entries like
"Top Missed Recordings of 2024 for Nezreka" or "ListenBrainz
Weekly Exploration" unreadable.

- Name wraps to multiple lines instead of ellipsis-truncating
  (sidebar is narrow; truncation hid critical disambiguating
  text like the year / week / username).
- Bumped name 12px → 13px, meta 10px → 11px with brighter color
  (0.4 → 0.55 alpha).
- Bumped card padding 10px/12px → 12px/14px + spacing 6px → 8px
  so multi-line entries have breathing room.
- Pinned the leading status dot to the first text line via
  ``margin-top`` so multi-line names flow underneath rather than
  push the dot off-center.
2026-05-26 20:12:15 -07:00
Broque Thomas
bd91c94f92 Add SoulSync Discovery tab to Sync page (Phase 1c.3)
Last of the three unified-tab phases. Surfaces the user's
persisted personalized playlists (decade mixes, hidden gems,
popular picks, daily mixes, discovery shuffle, etc.) on the
Sync page so they participate in the mirrored-playlist +
Auto-Sync pipeline like every other source.

Different shape from the LB / Last.fm tabs:

- Tracks already carry Spotify / iTunes / Deezer IDs (matched
  at generation time from the discovery pool), so there is NO
  MB-style "needs discovery" hop. The mirror is created with
  fully-populated ``matched_data`` JSON inline, downstream
  consumers (sync, wishlist) see canonical extra_data
  immediately.
- Click on a card runs the kind's generator
  (``POST /api/personalized/playlist/<kind>/<variant>/refresh``)
  + grabs the fresh track snapshot + mirrors under a synthetic
  id of the form ``ssd_<kind>_<variant>`` (e.g. ``ssd_decade_1980s``,
  ``ssd_hidden_gems``). Re-clicks UPSERT the same row, so the
  Auto-Sync schedule survives every refresh.
- Sub-tabs / archive concept don't apply here — each personalized
  playlist is already a singleton per (profile, kind, variant);
  the manager handles its own rotation.

New file: ``webui/static/sync-soulsync-discovery.js`` (~210 lines).
``initializeSyncPage`` learns a new tab branch. CSS adds
``soulsync-discovery-icon`` (star SVG, teal ``#14b8a6``) +
``.soulsync-discovery-playlist-card`` joins the unified card
selector group with a matching teal accent.

WHATS_NEW entry added under 2.6.3.

236 tests still green; no Python paths touched.
2026-05-26 19:46:03 -07:00
Broque Thomas
38e35930a9 Add Last.fm Radio tab to Sync page (Phase 1c.2)
Sibling to the ListenBrainz Sync tab from Phase 1c.1. Last.fm Radio
playlists already live in the same ``listenbrainz_playlists`` table
as LB ones (``playlist_type='lastfm_radio'``) and run through the
same MB-track discovery worker, so this tab is intentionally thin
— list + render + delegate. Card click hands straight off to the
LB Sync-tab click handler since the downstream modal + state
machine are identical.

- ``webui/index.html``: new ``<button data-tab="lastfm-sync">``
  + tab content container between the LB tab and the existing
  Import / Mirrored tabs. Plus a ``<script>`` tag for the new
  module.
- ``webui/static/sync-lastfm.js`` (new): ``loadLastfmSyncPlaylists``
  hits the existing ``/api/discover/listenbrainz/lastfm-radio``
  endpoint, ``renderLastfmSyncPlaylists`` mirrors the LB card
  shape with a ``📻`` icon + a ``.lastfm-playlist-card`` brand
  class, click handler forwards to
  ``handleListenBrainzSyncCardClick``.
- ``webui/static/sync-listenbrainz.js``: the shared 500ms refresh
  loop now iterates LB + Last.fm cards in one pass and treats
  either tab as "active" for liveness. No second loop needed.
- ``webui/static/sync-services.js``: new tab-activation branch in
  ``initializeSyncPage`` mirrors the LB pattern.
- ``webui/static/style.css``: ``.lastfm-icon`` SVG (Last.fm "as"
  logo, red), and ``.lastfm-playlist-card`` joins the unified
  card selector group with the Last.fm-red accent
  (``rgba(213, 16, 7, ...)``).
- ``web_server.py``: the lastfm-radio endpoint now includes
  ``track_count`` in its JSPF payload (same fix as the LB
  endpoints last commit).
- WHATS_NEW entry added under 2.6.3.

Mirrors created from Last.fm radios participate in the same auto-
trim Phase 1c.1's cascade-delete hook does — when the LB manager
rotates a stale ``lastfm_radio`` row out of its 5-most-recent
window, the matching ``source='lastfm'`` mirror row is removed
along with it. Library files stay on disk.

225 tests across adapter + automation suites still green; this
commit adds no Python paths to test.
2026-05-26 15:24:23 -07:00
Broque Thomas
969d5ffc1b Fix LB Sync tab card styling — dead CSS + ID collision
Two interacting bugs that left LB Sync-tab cards rendering with a
solid orange gradient background instead of the dark glass style
every other Sync-page card uses:

1. **Duplicate element id** ``listenbrainz-tab-content``: the new
   Sync-tab content div reused the same id the Discover page's
   pre-existing LB section already owned. Two elements with the
   same id is invalid HTML, and ``getElementById`` in the refresh
   loop was hitting the Sync version first while ``initialize
   SyncPage``'s ``${tabId}-tab-content`` lookup could race against
   it. Renamed the Sync-page tab id + ``data-tab`` attribute to
   ``listenbrainz-sync`` (matches the existing ``${tabId}-tab-
   content`` convention so the lookup becomes
   ``listenbrainz-sync-tab-content``). Discover-page LB tab
   keeps its original id untouched.

2. **Dead ``.listenbrainz-playlist-card`` rule** at style.css
   L36155 painting a solid ``linear-gradient(#eb743b → #d26230)``
   over the card. That class was orphaned — no JS or HTML
   instantiated it before Phase 1c.1 — but it sat at higher
   source order than my unified ``.youtube-playlist-card,
   .tidal-playlist-card, ...`` rule, so the bare-class selector
   won the cascade and overwrote the dark glass background.
   Also removed the matching dead ``.listenbrainz-icon { font-
   size: 48px }`` rule and its local ``@keyframes pulse`` copy
   (the keyframes are defined in four other live blocks).

3. **Missing LB selectors in unified inner-element rules**:
   ``.listenbrainz-playlist-card`` was only added to the OUTER
   card selector group in the first pass — the inner
   ``.playlist-card-icon`` / ``.playlist-card-content`` /
   ``.playlist-card-name`` / ``.playlist-card-info`` /
   ``.playlist-card-action-btn`` (+ ::before, :hover, :disabled)
   selector groups were left out, so the inner elements lost all
   their styling. Bulk-added LB to every group so the card
   inherits the full glass shell the other sources get, with a
   brand-orange ``rgba(235, 116, 59, ...)`` accent matching the
   Tidal / Deezer / Spotify-public pattern.
2026-05-26 14:41:57 -07:00
Broque Thomas
df31d42b94 Fix LB Sync tab card data shape + tone down styling
Two bugs from the initial LB tab commit (a7053a60):

1. **All cards showed identical "ListenBrainz Playlist / 0 tracks"
   defaults.** The /api/discover/listenbrainz/* endpoints wrap each
   entry in JSPF shape — ``{playlist: {identifier, title, creator,
   annotation, track}}`` — but renderListenBrainzSyncPlaylists was
   reading ``p.title`` / ``p.creator`` / ``p.track_count`` directly,
   so every field hit its fallback. Now unwraps the inner playlist
   object, extracts the MBID from the identifier URL via
   ``.split('/').pop()`` (matches buildListenBrainzPlaylistsHtml on
   the Discover page), and reads track_count from
   ``annotation.track_count`` with a fallback to ``track.length``.

2. **The tab looked too orange.** The initial commit gave the
   sub-tabs a saturated orange surface that clashed with the rest
   of the app, and the new ``.listenbrainz-playlist-card`` class
   wasn't in the unified ``.youtube-playlist-card,
   .tidal-playlist-card, ...`` selector group — so the card lost
   its dark glass base and inherited only my override CSS. Two
   fixes:

   - Added ``.listenbrainz-playlist-card`` to the unified card
     selector group (base + ::before + hover + hover::before + icon)
     so it picks up the dark glass background. The brand accent
     stripe + hover glow use ``rgba(235, 116, 59, ...)`` matching
     the other source cards' subtle accent pattern.
   - Sub-tabs reverted to a neutral dark surface (``rgba(255,
     255, 255, 0.04)``) with the orange used only as a thin
     accent on the active state's border + inset shadow.
   - Dropped the ``.refresh-button.listenbrainz`` override so the
     refresh button falls back to the user's chosen accent like
     the Spotify / Qobuz refresh buttons do.
2026-05-26 14:25:01 -07:00
Broque Thomas
a7053a6061 Add ListenBrainz tab to Sync page (Phase 1c.1)
First user-facing slice of the Discover-to-Sync unification. Adds a
ListenBrainz tab on the Sync page alongside Tidal / Qobuz /
Spotify Public / Beatport / etc. so users can mirror + auto-sync
ListenBrainz playlists from the same surface as every other source,
without detouring through the Discover page.

The Discover-page LB flow already owns all the heavy lifting
(state machine, discovery polling, sync → mirror creation). This
commit adds the Sync-page entry point only — list cached LB
playlists, render cards, pre-fetch tracks on click, hand off to
``openDownloadModalForListenBrainzPlaylist``. Zero backend changes.

- ``webui/index.html``: new ``<button data-tab="listenbrainz">`` +
  tab content container with "For You / My Playlists /
  Collaborative" sub-tabs and a refresh button.
- ``webui/static/sync-listenbrainz.js`` (new): ``loadListenBrainz
  SyncPlaylists`` fetches all three LB cache categories in parallel,
  ``renderListenBrainzSyncPlaylists`` renders cards in the standard
  ``.youtube-playlist-card`` shell with the existing phase-state
  helpers (so card colors / button text stay consistent with Tidal
  / Qobuz / etc.). Click handler populates the
  ``listenbrainzTracksCache`` from
  ``/api/discover/listenbrainz/playlist/<mbid>`` if not already
  primed, then defers to the shared modal opener.
- ``webui/static/sync-services.js``: one new branch in
  ``initializeSyncPage`` to lazy-load the tab on first activation.
- ``webui/static/style.css``: ``.listenbrainz-icon`` SVG (orange
  play-button in circle for inactive, white for active),
  ``.listenbrainz-sub-tab-btn`` styling for the sub-tabs,
  ``.refresh-button.listenbrainz`` accent.
- ``webui/static/helper.js``: WHATS_NEW entry under 2.6.3.

Auth-not-connected case is surfaced as a friendly placeholder
pointing the user at Settings → Connections instead of an empty
list.
2026-05-26 14:17:44 -07:00
Broque Thomas
980576f3a8 Sync page: dedicated iTunes Link icon + reorder Qobuz tab
The iTunes Link tab was reusing the generic `import-file-icon` (a
blue document glyph), which read as "import a file" rather than
"iTunes / Apple Music link". Added a dedicated `.itunes-icon`
inline-SVG matching the iTunes 11+ / Apple Music aesthetic —
pink-red circle with a white double-stem note glyph — and switched
the tab button to use it. Stays consistent with the rest of the
tab icons in the file (all inline data URIs, no external fetches).

Also moved the Qobuz tab from between Deezer and Deezer Link to
between Tidal and Deezer, so the Deezer / Deezer Link pair sits
adjacent and the lossless-streaming services (Tidal / Qobuz) group
naturally. Updated the Qobuz Playlist Sync modal-section feature
line to drop the now-stale "between Deezer and Deezer Link"
position claim.
2026-05-26 11:32:38 -07:00
Broque Thomas
718eb0cb10 Add iTunes / Apple Music link import tab on Sync page
New iTunes Link tab between Deezer Link and YouTube. Accepts album,
track, and playlist URLs from music.apple.com / iTunes. Pulls the
tracklist, runs it through the same discovery -> sync -> download
pipeline as the other link tabs.

Apple Music playlists go through amp-api with a Bearer JWT scraped
from the SPA. The legacy meta-tag and inline `"token":"..."` paths
are gone in the current music.apple.com SPA, so the extractor now
walks the page's `<script src>` list (prioritising index/chunk/main
bundles), fetches up to 8 JS bundles, regex-matches JWT-shaped
strings, and base64-decodes each payload to confirm it carries
Apple media-api claims (`root_https_origin`, or `iss + iat + exp`)
before trusting it. Filters out analytics / error-reporter JWTs that
also ship in the bundle.

Tokens are cached at module scope for 6h behind a threading.Lock so
the three-worker discovery executor doesn't thunder-herd Apple on
cold start, and amp-api calls go through a single helper that on
401 invalidates the cache, refetches the page, force-refreshes the
token, and retries the request once. The playlist fetcher memoises
the page HTML for the cache-miss path so we don't refetch it for
every paginated `/tracks` page.

spotify_public discovery worker accepts the new platform shape so
iTunes Link reuses the same matching code path as Deezer Link and
Spotify-public. UI bits live in the sync-services.js iTunes Link
tab, with platform plumbing through wishlist-tools.js for the
multi-source state map.
2026-05-25 22:32:18 -07:00
Broque Thomas
a5c23f898e Quick Actions: soften animations + smooth flow-line reset
Auto-Sync: equalizer cycle slowed 1.6s -> 3.2s, amplitude swing
tightened (0.4-1.0x of base height -> 0.55-0.85x) so the bars
breathe instead of slamming. Playhead duration slowed 5.5s -> 9s
and the line was thinned + given a softer accent color (rgba 0.7
instead of full light) and a smaller drop-shadow. Playhead now
fades in over the first 10% and fades out over the last 15% so it
glides on and off rather than appearing at the edge.

Automations: the flow line was using a background-position sweep
that snapped from end to start each loop — visible as a reset jump
every cycle. Rewrote the sweep as a pseudo-element with its own
translateX + opacity animation: fades in at 15%, runs across, fades
out before snapping back. Node pulse + line sweep both run on the
same 3.2s cycle now so the three nodes and two lines stay in
phase. Node animation delays adjusted to evenly stagger across the
new cycle length.
2026-05-25 16:09:46 -07:00
Broque Thomas
47498b88d3 Quick Actions: fix Automations flow visibility + add hero playhead
Two tweaks based on usage feedback.

Automations flow was anchored at \`right: -8%\` which pushed the
trigger->action->notify chain off the right edge of the minor tile.
Repositioned to fill the bottom of the tile with left/right inset
matching the tile padding, and bumped the base opacity from 0.25 to
0.45 so the chips are actually visible without hovering. Connecting
lines now have a 60%-wide bright accent sweep that travels
left-to-right along each segment in sync with the node pulses, so
the flow reads as a signal propagating through the chain rather
than three nodes blinking in place.

Auto-Sync hero gets a vertical accent playhead that scrolls
left-to-right across the equalizer bars on a 5.5s loop — a
now-playing scrubber overlay that adds horizontal motion to the
existing vertical bar pulse. Drop-shadow filter gives it a soft
glow as it passes over each bar. prefers-reduced-motion disables
both the playhead and the new line sweep.
2026-05-25 16:00:09 -07:00
Broque Thomas
82717dec03 Redesign Quick Actions as asymmetric bento with signature animations
Auto-Sync hero on the left (spans both rows), Tools + Automations
stacked on the right. Each tile gets a CSS-only ambient animation
that visually represents what that section does — no more three
identical rectangles.

Auto-Sync (hero, 2 rows tall): 20-bar live equalizer animates along
the bottom edge with per-bar offsets so it reads as a real audio
waveform. Foreground has a live status pulse dot + accent kicker,
big 56px icon, large title, description, and a CTA bar separated
by a hairline rule.

Tools (top-right): an oversized gear icon rotates slowly off the
right edge as a watermark. Hover speeds it up (28s -> 12s) and
brightens the tint.

Automations (bottom-right): three nodes connected by gradient lines
pulse in sequence, mimicking trigger -> action -> notify flow. Each
node glows + halos on its phase.

Card recipe (gradient body, top accent stripe, accent border on
hover, multi-layer shadow) is the same library-status-card vocab
the rest of the dashboard already uses. Container query
(container-type: inline-size) drives every dimension via
clamp(min, Ncqw + base, max) so padding, text, icon, and animation
sizes scale with the actual card width — no overflow on narrow
dashboards. Single-column stack at <=560px.

prefers-reduced-motion disables all three signature animations.
2026-05-25 15:49:38 -07:00
Broque Thomas
d18f45dfec Auto-Sync: bulk schedule per source + custom interval columns
Two upgrades to the schedule board:

Bulk schedule. Each source group in the sidebar gets a small "Bulk"
button next to the title. Clicking it opens a popover with the same
ten standard buckets plus "Custom interval…" (prompts for hours) and
"Unschedule all". Picking a bucket POSTs/PUTs the schedule for every
schedulable playlist in that source. Result toast aggregates ok/fail
counts. Big quality-of-life for "I want every Spotify playlist
weekly" without 30 individual drags.

Custom interval columns. The board's column set is no longer the
hardcoded `AUTO_SYNC_BUCKETS` list — it's the union of those plus any
hour values currently in use by playlist_schedules. A 6h or 36h
schedule (created via the bulk custom prompt, or hand-edited in the
Automations page) now renders as its own dashed-border column instead
of silently disappearing from the board because it didn't match a
standard bucket. Standard columns still render solid; custom ones get
a "custom" eyebrow + dashed border so they're visually distinct.
2026-05-25 14:54:30 -07:00
Broque Thomas
d668bf4e6d Auto-Sync manager: filter, failure indicators, history filters
Six small UX additions on the Playlist Auto-Sync manager:

- Sidebar gets a "Filter playlists…" search input. Re-renders only
  the schedule panel on input so focus is preserved while typing.
- Scheduled cards show a red `!` badge + red border tint when the
  last three pipeline runs failed (yellow `⚠` if at least one of the
  last few failed). Surfaces chronically broken schedules visually
  instead of leaving them indistinguishable from healthy ones.
- Run History tab title shows a red error count badge when there are
  failed runs in the loaded window.
- Run History tab body gains All / Errors / Completed filter pills
  with per-bucket counts.
- Load-more button at the bottom of the history tab pulls another
  50 entries (capped at 500).
- "Run pipeline again" button in the expanded detail of each history
  card re-triggers that playlist's pipeline directly.

Also dropped the "Discovered: completed" result pill — `tracks_discovered`
in the result payload is a status string, not a count, and the same
data is already in the before/after stats grid above.
2026-05-25 14:22:56 -07:00
Broque Thomas
dfdc6c6277 Restyle Auto-Sync manager and fix loading regressions
Three problems wrapped into one pass on the Playlist Auto-Sync surface:

1. Visual: the manager modal had its own vibe (radial gradient, pill
   tabs, sky-blue chrome) that didn't line up with the rest of the
   app. Reworked the modal shell, KPI summary, live pipeline monitor,
   tab bar, schedule board sidebar, and column cards to use the
   standard SoulSync patterns — gradient `#1a1a1a → #121212`,
   accent-tinted 1px border, 20px radius, underline tabs, dense dark
   card pattern that Automations + Library pages already use. Modal
   now uses near-full screen so there's room for the schedule board
   without horizontal scroll pain. Run history cards followed the
   same path: slim horizontal row mirroring `.automation-card` plus
   an expanded detail that mirrors the Automations run-history modal
   (stats-grid + facts row + result pills + log section).

2. Hang: the previous SQL fix for the run-history "in library" count
   added `COLLATE NOCASE` on the join columns of `tracks` and
   `artists`. SQLite can't use `idx_artists_name` or `idx_tracks_title`
   when the comparison collation doesn't match the column collation,
   so the join did a full table scan per mirrored playlist track.
   ~18s per playlist × 30 playlists = `/api/mirrored-playlists` hung
   indefinitely and the modal stayed at "Loading schedule…" forever.
   Switched the join back to case-sensitive equality (~6ms per
   playlist, 3000× faster). Spotify names canonicalize to the same
   form as library imports so the recall loss is in the rounding
   error of pure case-only mismatches.

3. Slowness: even after the hang fix, each modal open spent ~1.5s
   gathering per-playlist status counts. The endpoint looped
   `get_mirrored_playlist_status_counts(playlist_id)` per row, which
   opened a fresh SQLite connection + PRAGMA setup each time. Added
   `get_all_mirrored_playlist_status_counts(profile_id)` which
   returns counts for every mirrored playlist owned by the active
   profile in 4 batched `GROUP BY` queries over a single connection.
   Modal load dropped to ~280ms.

Also fixed: `tracks.artist` reference in `get_mirrored_playlist_status_counts`
that never worked since the schema went relational — the query threw
"no such column", got swallowed by the try/except, and the in-library
count silently defaulted to 0 on every playlist. Rewired to join
through `artists`.

`get_mirrored_playlist_status_counts` (single-playlist) kept for
callers that still want it, but the modal endpoint uses the batched
version.
2026-05-25 12:24:48 -07:00
Broque Thomas
4aec5584e1 Simplify auto-sync history card layout
Flatten playlist pipeline history cards into a stable header, flow, preview, and detail structure so the run history does not collapse into broken line rows. Keep explicit expand bindings and preserve the richer detail payload rendering.
2026-05-25 08:34:37 -07:00
Broque Thomas
81ad3079b4 Fix auto-sync history card expansion
Bind run history card expand interactions after the modal renders instead of relying on inline handlers, and reshape the run cards into a clearer two-row layout with controlled metadata, preview chips, and roomier expanded detail padding.
2026-05-25 08:28:36 -07:00
Broque Thomas
e86e74d640 Expand auto-sync run history cards
Make playlist pipeline run history cards clickable and keyboard-accessible, with expanded detail sections for summary stats, timeline, before/after snapshots, result payload fields, and future run logs. Refresh the card styling so the expanded state remains responsive inside the Auto-Sync modal.
2026-05-25 08:24:47 -07:00
Broque Thomas
60b346aa33 Standardize auto-sync modal cards
Bring Auto-Sync automation and run-history cards closer to the Automations page pattern with status dots, flow chips, compact metadata, and denser run preview details.
2026-05-25 08:19:05 -07:00
Broque Thomas
119e8f1599 Harden auto-sync history row rendering
Normalize sparse playlist pipeline history rows before rendering and add a visible fallback for empty entries so the Run History tab cannot collapse into unreadable divider lines.
2026-05-25 07:59:27 -07:00
Broque Thomas
06c76bbcaf Make auto-sync run history readable
Render playlist pipeline history as visible run cards with fallback summaries, preview chips, metadata, Details controls, and an explicit empty result message for sparse payloads.
2026-05-25 07:55:52 -07:00
Broque Thomas
9eb7a49c24 Fix auto-sync scheduled card layout
Give placed playlist cards a dedicated content wrapper, full-width action row, wider board columns, and defensive wrapping so titles, timing badges, and Run now controls stay inside card bounds.
2026-05-25 07:46:45 -07:00
Broque Thomas
d8d8e0bcb5 Refine auto-sync modal spacing
Compact the inactive pipeline monitor, widen schedule columns, increase board gutters, and rework scheduled playlist cards so Run now and remove actions no longer crowd playlist text.
2026-05-25 07:44:35 -07:00
Broque Thomas
efdcde1892 Add playlist auto-sync run history
Persist per-playlist pipeline run snapshots from the shared playlist pipeline, expose a history API, and upgrade the Auto-Sync modal with live pipeline monitoring, Run now controls, and a runs-style history tab.
2026-05-25 00:55:55 -07:00
Broque Thomas
f402badac9 Align dashboard actions with accent theme
Update the dashboard Quick Actions tile to use the shared accent color variables for lane glow, icon chips, borders, hover states, and keyboard focus while keeping the three-destination launcher responsive.
2026-05-25 00:38:00 -07:00
Broque Thomas
f67fff22b4 Redesign dashboard quick actions tile
Replace the old Tools CTA with a unified three-lane dashboard launcher for Tools, Auto-Sync, and Automations, using restrained glass/accent styling and responsive stacked behavior.
2026-05-25 00:36:11 -07:00
Broque Thomas
dc4d157944 Fix Auto-Sync next-run countdown and theme its modal
The Playlist Auto-Sync schedule board was showing "next in 8h" on every
card regardless of the configured interval. Root cause: backend stores
next_run as a naive UTC string ("2026-05-25 05:00:00") and the new
auto-sync renderer was parsing it with plain `new Date(...)`, which
treats unmarked timestamps as local time. On Pacific time that offsets
the displayed countdown by ~8 hours. Auto-Sync now routes through the
existing `_autoParseUTC` helper that the rest of the Automations page
already uses, so countdowns line up with the wall clock.

A separate correctness fix in the automation update API: when a PUT
changes `trigger_type` or `trigger_config`, the stored `next_run` is
now blanked before the engine reschedules. Previously the scheduler's
restart-survival path would preserve a stale future timestamp from the
prior interval, so dragging a playlist from the 8h column to the 1h
column kept firing at the old 8h mark. Boot-time restart behavior is
unchanged — only user-driven schedule changes reset the clock.

Modal restyle: the Auto-Sync manager's hardcoded sky-blue palette is
replaced with `var(--accent-rgb)` everywhere so the modal honors the
user's chosen accent color. Tinted glow on the modal border, tabbed
header active state, scheduled-playlist chips, scrollbars, and a new
drag-over highlight on columns all follow the accent theme. The
column drag-over state is wired through new ondragleave handling so
the highlight clears reliably when leaving a column.
2026-05-24 22:01:21 -07:00
Broque Thomas
9a8e7d02a7 Make auto-sync schedule modal responsive
Constrain Auto-Sync columns inside the modal with per-column vertical scrolling, add responsive layouts for narrower and shorter viewports, and separate schedule interval labels from next-run timing.

Also prevents unsupported mirrored sources from being scheduled into the playlist pipeline while still showing them as unavailable in the sidebar.
2026-05-24 21:01:46 -07:00
Broque Thomas
5421f3800e Polish auto-sync manager modal
Upgrade the Auto-Sync modal into a tabbed manager with a richer schedule board and a separate read-only Automation Pipelines tab for existing playlist_pipeline automations.
2026-05-24 20:39:48 -07:00
Broque Thomas
854141f903 Add playlist auto-sync schedule board
Add a Sync-page Auto-Sync manager with source-grouped mirrored playlists, interval columns, and drag/drop scheduling backed by playlist_pipeline automations.

Schedules created by the board are editable there, while existing custom pipeline automations are shown as locked automation-managed entries.
2026-05-24 20:31:03 -07:00
Broque Thomas
46a0999ca2 Clarify mirrored playlist auto-sync action
Rename the manual pipeline button to Auto-Sync and make non-JSON endpoint failures show an actionable restart message instead of a raw JSON parse error.
2026-05-24 20:23:37 -07:00
Broque Thomas
a1409576f4 Move mirrored pipeline action into card controls
Place the Run Pipeline action alongside the existing mirrored playlist card controls so users can find it next to clear, edit source, and delete.
2026-05-24 20:09:06 -07:00
Broque Thomas
f83c671570 Add direct mirrored playlist pipeline runs
Expose playlist-native run and status endpoints that reuse the shared mirrored playlist pipeline engine while routing progress into playlist UI state.

Add a Run Pipeline action to mirrored playlist cards and modals with live status polling, and make the shared pipeline lock atomic for manual and scheduled callers.
2026-05-24 19:54:04 -07:00
Broque Thomas
73bd2db547 Harden playlist pipeline source refresh
Centralize mirrored playlist source reference normalization so edited links and IDs are stored consistently. Preserve URL-backed refresh refs, surface missing-source refresh failures, count background sync failures in pipeline summaries, and retry guarded automation skips after a short delay instead of losing a scheduled run. Add focused coverage for source refs, mirrored playlist source updates, refresh failures, and guarded retry behavior.
2026-05-24 19:31:00 -07:00
Antti Kettunen
7853d0cfb8
chore(webui): remove legacy import feature js / css code 2026-05-24 21:17:19 +03:00
Broque Thomas
a3ba79a9ce Improve radio mode UI and behavior
Refactor and enhance the player radio feature: add npSetRadioMode, npQueueHasNext, and npEnsureCurrentTrackInQueue helpers to centralize radio-state changes and conditional radio fetch logic; replace direct npRadioMode toggles with npSetRadioMode in the expanded player and artist-radio flow (now awaits playLibraryTrack and triggers fetchIfNeeded). Add accessibility (aria-pressed) and label/pulse elements to the radio button, and update CSS for improved visuals and active-state animation. Also adjust toasts/messages and ensure the current library track is seeded into the queue when needed.
2026-05-24 11:02:19 -07:00
Broque Thomas
ccbe918808 Unify artist detail action buttons
Move the artist watchlist and discography actions into the main artist hero action row so they sit with Artist Radio and Enhance Quality. Apply a shared compact pill treatment for the hero actions while preserving the existing button IDs and click behavior.
2026-05-24 10:46:21 -07:00
Broque Thomas
4ca3f70bf3 Show MusicBrainz release variants in import
Expand matched MusicBrainz release groups into concrete releases for specific album searches so import users can choose the correct edition by track count, format, country, and disambiguation. Preserve distinct MusicBrainz release IDs instead of deduping same-title variants, carry release metadata through import matching, and surface those details on album result cards. Add coverage for variant preservation and release-group expansion.
2026-05-24 09:33:19 -07:00
Broque Thomas
a34eae1445 Add Qobuz playlist sync to Sync page (#677)
Qobuz joins Tidal and Deezer as a first-class playlist sync source.
New Qobuz tab on the Sync page lists user playlists + a virtual
Favorite Tracks entry, and clicks route through the same discovery →
sync → download pipeline the other services already use.

Backend:
* core/qobuz_client.py — new get_user_playlists, get_playlist,
  get_user_favorite_tracks, get_user_favorite_tracks_count. Returns
  normalized dicts (matches Deezer client shape, not Tidal's
  dataclasses) so the discovery worker can iterate directly without
  duck-typing. Virtual `qobuz-favorites` ID dispatches to favorites
  fetcher inside get_playlist — same trick Tidal uses with
  COLLECTION_PLAYLIST_ID. Both list endpoints paginate against
  Qobuz's 500-cap limit.
* core/discovery/qobuz.py — new worker module. Mirrors
  core/discovery/deezer.py: pause enrichment, iterate tracks,
  hit discovery cache, fall back to _search_spotify_for_tidal_track,
  build wing-it stub on miss, sync results to mirrored playlist.
* web_server.py — adds /api/qobuz/playlists, /playlist/<id>,
  /discovery/start/<id>, /discovery/status/<id>, /discovery/update_match,
  /playlists/states, /state/<id>, /reset/<id>, /delete/<id>,
  /update_phase/<id>, /sync/start/<id>, /sync/status/<id>,
  /sync/cancel/<id>. One-for-one with the Tidal + Deezer endpoint
  sets. Qobuz discovery executor registered for clean shutdown.

Frontend:
* webui/static/sync-services.js — full handler set (loadQobuzPlaylists,
  createQobuzCard, openQobuzDiscoveryModal, startQobuzDiscoveryPolling,
  startQobuzPlaylistSync, startQobuzSyncPolling, cancelQobuzSync,
  startQobuzDownloadMissing, rehydrateQobuzDownloadModal, etc.).
  Reuses the shared YouTube discovery modal via fake `qobuz_<id>`
  urlHash and is_qobuz_playlist flag. Shared switch statements in
  getModalActionButtons / generateTableRowsFromState / Wing It helpers
  in downloads.js gain new isQobuz branches alongside the existing
  per-service ones.
* webui/index.html — new Qobuz tab button + content div, slotted
  between Deezer and Deezer Link.
* webui/static/style.css — new .qobuz-icon for the tab icon.
* webui/static/core.js — qobuzPlaylists / qobuzPlaylistStates /
  qobuzPlaylistsLoaded globals.

Followed the existing per-service pattern verbatim rather than
refactoring the duplicated transformers across Tidal / Deezer /
Spotify-public / YouTube / Mirrored — that refactor is its own follow-up
PR per the "don't break Tidal/Deezer" scope discipline. Adding the 6th
copy of a proven pattern is lower risk than collapsing 5 working
services behind a new abstraction.

Tests:
* tests/test_qobuz_playlists.py — 12 tests covering pagination,
  normalization, favorites virtual-ID routing, artist-name fallback
  chain (performer → album.artist → 'Unknown Artist'), and
  unauthenticated short-circuits.
2026-05-23 23:27:36 -07:00
Broque Thomas
eba7f61e04 Surface metadata source on Import album results (#681)
Import album search silently fell through to the next source in
METADATA_SOURCE_PRIORITY when the configured primary returned zero
matches — intentional behavior shared with the auto-import worker
(see core/auto_import_worker.py:1316). With MusicBrainz selected and
a query MB couldn't resolve, users saw Deezer cards with no indication
their primary was bypassed.

Backend now echoes `primary_source` on /api/import/search/albums,
/api/import/search/tracks, and /api/import/staging/suggestions.
Frontend renders a per-card 'via {source}' badge when the served
source differs from the primary, plus a banner above the grid when
every card came from a fallback source. Fallback semantics unchanged.

Also collapses an inline duplicate of _renderSuggestionCard inside
importPageSearchAlbum into a single shared renderer.

Regression test pins the contract: response carries primary_source +
per-album source when the chain falls back.
2026-05-23 16:22:17 -07:00
Broque Thomas
94129d3099 Clarify hybrid source album behavior
Add dynamic level badges to the hybrid source order settings. The first enabled source shows Album-level only when it supports album-bundle downloads; every other source shows Track-level to make fallback behavior visible.

Update the helper copy and badge styling so users can understand why putting Soulseek, Torrent, or Usenet first changes album-download behavior.
2026-05-23 15:15:28 -07:00
Antti Kettunen
5b82e6c1ba
refactor(webui): remove legacy stats page assets
- delete the old stats page HTML, JS, and CSS now that the React route owns the experience
- preserve helper/tour selectors by exposing the legacy stats ids from the React page
- move shared track playback fallback into library code
2026-05-23 21:22:45 +03:00
Antti Kettunen
cadd78603c
fix(webui): make sidebar nav SPA links
- convert the sidebar nav to real links with URL-driven state
- intercept left-clicks so internal navigation stays in-app while preserving native browser link behavior
- keep artist-detail transitions param-aware and update route tests
2026-05-23 12:47:23 +03:00
Broque Thomas
de8e079a6d feat(media-player): playable tracks across modals + lyrics + cleanups
Three related improvements to the now-playing media player and the
"add to wishlist" / "download missing" modals.

1. Play buttons across track-list modals
   Every track row in the download-missing modals (Spotify, Tidal,
   YouTube, services, artist album, wishlist download-missing) and
   the add-to-wishlist modal now carries a play button. Click runs
   playTrackFromLibraryOrStream:
     - If the track has a local file_path → playLibraryTrack
     - Else POST /api/stats/resolve-track to find it in the library
       by title + artist → playLibraryTrack
     - Else fall back to _gsPlayTrack streaming
   Backend ownership response gains track_id / title / file_path so
   the wishlist modal's owned tracks can hand the right metadata
   to the player without an extra round trip.
   The add-to-wishlist modal previously showed the play button only
   on owned tracks; now the button is unconditional so the streaming
   fallback can take over for unowned ones (matches the standard
   pattern from the rest of the app).

2. Clean media-player display titles
   YouTube / Tidal / Qobuz / torrent / usenet plugins encode their
   source-side identifier into the filename field as
   <source_id>||<display> so download() can recover it later. The
   media player's track-title renderer never knew about this
   convention and showed strings like
   "wvgFsXoGFnQ||Sometimes I Cry When I'm Alone" verbatim in the
   now-playing UI. extractTrackTitle and setTrackInfo now strip the
   <id>|| prefix defensively so any path into the player gets a
   clean display.
   Local library playback also fetches canonical metadata from
   /api/stats/resolve-track when track.id is present so title /
   artist / album / album art come straight from the SoulSync DB
   instead of whatever the caller passed in. Falls back silently
   to caller values on any error so playback never blocks on the
   metadata fetch.

3. Lyrics panel + View Artist close
   New collapsed lyrics panel between the playback controls and
   queue panel. POST /api/lyrics/fetch (new backend endpoint)
   prefers the local .lrc / .txt sidecar files SoulSync writes
   during post-processing so downloaded tracks resolve lyrics with
   zero network hits; falls back to LRClib exact-match (when album
   + duration are available) then to LRClib search.
   Synced LRC results are parsed (handles multi-stamp lines for
   repeated choruses), and the active line highlights + smooth-
   scrolls into the middle of the viewport on every audio
   timeupdate. Plain-text results render without highlighting.
   Per-track cache prevents re-fetching when the user revisits the
   same track. Lyrics fetch is fire-and-forget — failure shows
   "No lyrics found" without ever blocking playback.
   View Artist on the expanded player now calls
   closeNowPlayingModal before navigating; the modal was previously
   sitting open over the artist page, hiding it. Handler is bound
   once and is a no-op when no artist_id is attached.

CSS additions are additive (new .modal-track-play-btn and
.np-lyrics-* rules); no existing styles touched. Backend endpoint
returns 200-with-success-false on any miss so callers can render
"no lyrics" without treating it as an error.

WHATS_NEW updated under 2.5.9 with two entries (lyrics + View
Artist close).
2026-05-22 21:19:50 -07:00
Broque Thomas
b6b83c8bf8 Polish torrent and usenet download UX
Clarifies album-bundle progress text in the download modal and active downloads panel so release-first downloads read as downloading a release, then matching tracks after staging.

Adds waiting-state copy and tooltips for rows blocked on release staging, plus source-specific library history badge styling for Torrent, Usenet, Staging, and Auto-Import.
2026-05-21 15:19:43 -07:00
Broque Thomas
8b0de9eb76 fix(downloads): harden album bundle staging
Route torrent and Usenet album bundles through private per-batch staging so Auto-Import cannot race public staging or duplicate imports.

Expose album-bundle progress in batch status and render it on the Downloads page while the external client is still downloading.

Tighten release handoff safety by rejecting archive path traversal, ignoring torrent candidates without a usable URL, and skipping Soulseek source reuse for torrent/Usenet batches.

Tests: .venv/bin/python -m pytest tests/downloads/test_downloads_status.py tests/test_album_bundle_dispatch.py tests/downloads/test_downloads_staging.py tests/test_torrent_usenet_plugins.py
2026-05-20 21:39:06 -07:00
Broque Thomas
0468816367 docs(downloads): docker mount heads-up for torrent / usenet sources
Torrent and usenet clients each download to their own folders
(not Soulseek's). SoulSync needs read access to those paths to
import the resulting files. Bare-metal setups work without
configuration; Docker setups need volume mounts; remote
downloader hosts need a network mount.

- webui/index.html: orange warning card on the Indexers &
  Downloaders hero, listing the three deployment shapes
  (bare-metal / Docker / remote) and what each needs.
- webui/static/style.css: ind-hero-warning rule set —
  warning-tone palette (amber on dark glass) so the card
  reads as advisory, not destructive. Inline ul + code
  styling for the bullet list inside.
- docker-compose.yml: commented placeholder mounts under the
  existing IMPORTANT block for /downloads/torrents and
  /downloads/usenet. Same uncomment-and-edit pattern as the
  existing slskd helper block. Documents the in-container path
  must match what the torrent / usenet client reports as its
  save_path.
2026-05-20 17:49:39 -07:00
Broque Thomas
9b36d421ee ui(settings): collapsible sections + Lidarr-style polish for Indexers tab
Restructure the Indexers & Downloaders tab to mirror the
Paths & Organization / Post-Processing / Library Preferences
pattern on the Library page — each subsystem (Indexers / Torrent
Client / Usenet Client) gets its own collapsible section header
with a status dot, hint, and animated arrow.

Visual cues borrowed from Lidarr but rendered in SoulSync's
existing dark-glass theme:
- Intro hero card at the top of the tab with a 1-2-3 flow:
  Indexers find releases → Downloader fetches → SoulSync imports.
  Accent-color stepper pills + sub-copy summarising what's
  optional vs required.
- Status dot in each section header — grey 'unknown' before
  testing, green after Test Connection succeeds, red on failure.
  Driven by _setIndStatusDot() helper called from each test
  handler. Soft glow on the active states.
- Per-service service-title color accents matching existing
  spotify-title / tidal-title pattern: prowlarr-title (orange,
  Prowlarr brand), torrent-title (sky blue, qBit family),
  usenet-title (violet).
- Indexer list cards replace the inline-emoji list — proper
  protocol badges (Torrent vs Usenet pill), monospace id chip,
  privacy tag, dimmed appearance when the indexer is disabled
  in Prowlarr.
- Indexers section starts open; Torrent + Usenet start collapsed
  since most users only configure one protocol.

No behavior changes — same fields, same endpoints, same save
flow. Pure visual restructure of the panels added in the previous
three commits.
2026-05-20 16:07:35 -07:00
Antti Kettunen
0d683d87c0
refactor(webui): link artist detail navigation
- replace click-driven artist-detail hops with semantic links
- keep SPA transitions via shell bridge interception for /artist-detail/:source/:id
- drop legacy page helper wrappers and dead bridge plumbing
2026-05-19 10:22:59 +03:00
Broque Thomas
19307630d1 Fix missing album art for non-Spotify sources + animate Downloads nav icon
- watchlist_scanner: fall back to album.image_url when album object has no
  images list (affects MusicBrainz CAA URLs, iTunes, Deezer — all use
  image_url on the Album dataclass, not the Spotify-style images array)
- Pulse Downloads nav icon while active downloads are in progress, same
  pattern as watchlist scan animation
2026-05-18 20:24:13 -07:00
Broque Thomas
8dd39dee65 Pulse watchlist nav icon during active watchlist scan 2026-05-18 20:13:50 -07:00
Broque Thomas
f3ad65de34 Complete MusicBrainz watchlist source parity
Add MusicBrainz watchlist artist ID storage, badges, linked-provider editing, and per-artist preferred source support.

Backfill watchlist MusicBrainz matches from already-enriched library artists so existing MusicBrainz worker matches appear in watchlist cards and settings.

Extend bulk watchlist add, liked artist matching, artist map source picking, and service status labels to recognize MusicBrainz, with regression tests for watchlist ID persistence and backfill.
2026-05-18 19:19:25 -07:00
Broque Thomas
f25433ea57 Harden quarantine approval flows 2026-05-18 08:37:11 -07:00
Broque Thomas
94f6c950cb Polish manual library match tool card 2026-05-17 20:32:18 -07:00
Broque Thomas
42f4aa5eac Add manual library track matching 2026-05-17 20:27:05 -07:00
Broque Thomas
3b62bcab0c Add missing-track import from existing library files
Show actionable missing album tracks in the enhanced library from canonical metadata, with a practical Manage flow for Add to Library or I Have This.

Implement I Have This as a non-destructive copy/import path: copy the chosen existing file, run normal post-processing with the missing track context, insert the real library row, and inherit album identity tags from target siblings so Navidrome does not split albums.

Improve the modal with selectable search results, visible import progress, disabled controls during import, and missing-track row styling.
2026-05-17 13:58:11 -07:00
Broque Thomas
42a833fcb2 Amazon Music: UI badges, enrichment match chips, watchlist linking, metadata cache
- Artist cards, hero section, and enhanced view now show Amazon Music badges
  when amazon_id is populated (AMAZON_LOGO_URL constant, orange #FF9900 brand)
- Enhanced view artist and album match status rows include amazon_match_status
  chip with click-to-rematch via openManualMatchModal
- getServiceUrl: added amazon (album/track ASIN → music.amazon.com) and fixed
  missing discogs entries; serviceLabels adds tidal/qobuz/amazon
- Enhanced view enhanced-artist-id-badges includes amazon_id entry
- DB SELECTs for library artists list and artist detail now return amazon_id;
  both response dicts include the field
- watchlist_artists migration adds amazon_artist_id column
- Watchlist config GET: amazon_artist_id in SELECT/WHERE/response (index 18)
- Watchlist artists list response includes amazon_artist_id
- link-provider endpoint: amazon added to valid_providers and col_map
- _populateLinkedProviderSection: amazonId param + Amazon Music source row
- Watchlist card source badges render Amazon pill (watchlist-source-amazon CSS)
- _openSourceSearch labels map includes amazon
- service_search: amazon_worker injected via init(); _search_service amazon branch
  uses search_artists/albums/tracks, same {id,name,image,extra} return shape
- _SERVICE_ID_COLUMNS: amazon → amazon_id for artist/album/track
- _init_service_search call passes amazon_worker_obj
- amazon_client._fetch_album_metas: 5-minute TTL cache per ASIN — cached hits
  skip _rate_limit() and HTTP call entirely; fixes ~10s artist detail load
- registry.py: removed amazon from METADATA_SOURCE_PRIORITY and
  METADATA_SOURCE_LABELS — T2Tunes has no discography API, cannot serve as a
  primary metadata source; Amazon remains a download source + ASIN enricher
- Settings metadata source dropdown and help text updated accordingly
2026-05-16 22:52:27 -07:00
Broque Thomas
5450f4ac5e Wire Amazon Music enrichment worker into dashboard UI
Adds full parity with Deezer/Qobuz/Tidal/Discogs in every dashboard
UI layer — orb button, live tooltip, WebSocket push, rate speedometer.

- webui/index.html: Amazon enrichment orb button after Discogs
- webui/static/amazon.svg: local icon (a + smile, same pattern as
  hydrabase.png — avoids external URL dependency)
- webui/static/style.css: Amazon button/spinner/tooltip CSS with
  FF9900 brand color; added to mobile tooltip suppress list
- webui/static/worker-orbs.js: Amazon orb in WORKER_DEFS [255,153,0]
- webui/static/api-monitor.js: Amazon in rate gauge services list,
  label, and color map
- webui/static/enrichment.js: updateAmazonEnrichmentStatusFromData,
  toggleAmazonEnrichment, DOMContentLoaded init + 2s poll
- webui/static/core.js: socket.on enrichment:amazon-enrichment listener
- web_server.py: amazon-enrichment added to _emit_enrichment_status_loop
  workers dict so WebSocket pushes fire every 2s
2026-05-16 17:43:38 -07:00
Broque Thomas
d39679951b Wire Amazon Music into enhanced search and global search source picker
- Add 'amazon' to VALID_SOURCES (and transitively VALID_STREAM_SOURCES)
  in core/search/orchestrator.py so the backend accepts it as a
  requested source without returning 400
- Add resolve_client('amazon') case — mirrors musicbrainz pattern,
  gets the cached AmazonClient from the metadata registry
- Add 'amazon' to _alternate_sources() so it appears as a tab when
  another source is primary (always available, no credentials)
- Add SERVICE_CONFIG_REGISTRY entry 'amazon': {'always': True} so
  /api/settings/config-status reports it as configured
- Add SOURCE_LABELS['amazon'] and SOURCE_ORDER entry in
  shared-helpers.js so both enhanced search and global search show
  the Amazon Music tab
- Add 'amazon' to _ALWAYS_CONFIGURED_SOURCES so the picker never
  dims the tab (no credentials required)
- Add .enh-tab-amazon.active CSS (Amazon orange #FF9900)
- 3530 tests pass
2026-05-16 14:18:18 -07:00
Broque Thomas
f52e056827 Update style.css 2026-05-14 20:24:51 -07:00
Broque Thomas
2ccada088d Dashboard: cursor-following accent blob + darker cards
Two-layer accent glow that follows the cursor across the bento grid:

- Soft halo (1280px, blur 48) lerps toward target with a delay; bright
  inner core (540px, blur 18, screen-blended) lerps faster.
- Both layers gently pulse on different rhythms so the blob feels alive
  even when stationary.
- Target = cursor position when hovering any .dash-card; otherwise the
  grid center (idle resting position). On leaving cards/gap, blob waits
  1.5s before drifting back to center -- a small dwell that lets it
  feel intentional rather than skittish.
- Card backgrounds darkened to near-black with stronger borders for
  contrast against the accent glow.

Performance:
- requestAnimationFrame loop runs only while the blob is moving and
  idles when settled at the target.
- Two-pass per frame: read all getBoundingClientRect() first, then
  write CSS vars in a second pass -- one layout flush per frame
  instead of one per card.
- IntersectionObserver snaps to grid center the first time the
  dashboard becomes visible (handles the case where home page is
  hidden at attach time).

Honors the existing reduce-effects setting:
- CSS hides both blob layers via body.reduce-effects.
- JS MutationObserver on body class kills the rAF loop when toggled
  on; re-snaps to center and restarts when toggled off.
- prefers-reduced-motion media query disables the pulse animations.
2026-05-14 20:19:58 -07:00
Broque Thomas
acce083675 Dashboard bento grid redesign + responsive breakpoints
Replaces the old stacked dashboard with a bento grid: services, stats,
library, syncs, tools, activity, enrichment each live in their own card.

- 3-col on desktop (>=1500px), 2-col on laptop, 2-col tighter on tablet,
  1-col stack on mobile (<700px). Sub-grids inside each card adapt at
  every breakpoint (service tiles 3-2-1, stat cards 3-2, gauge tiles
  10-5-4-3-2).
- Cards use the user's accent color for glow + hover border + CTA icons
  (was hardcoded per-card hues).
- Mount fade-up with per-card stagger; subtle bloom drift; reduced-motion
  honored.
- Enrichment row collapses the per-service gauge tile (hides the 3-stat
  row, scales the gauge SVG to fill the tile width) so all 10 services
  fit on one row at desktop.
- Recent syncs stacks vertically inside its bento card instead of
  overflowing horizontally.
- Every existing id, button, and JS hook preserved -- no behavior change,
  pure visual + responsive overhaul.
2026-05-14 19:16:46 -07:00
Broque Thomas
831ddc97d8 Polish Download Missing modal tracklist
Pure CSS tune-up scoped to .download-missing-modal-content. Column
layout, table semantics, and every JS hook (#match-*, #download-*,
.track-*, .download-tracks-tbody-*) untouched.

Adds:
- Hairline row dividers + cleaner cell padding
- Hover gets accent gradient sweep + 3px inset edge bar
- Monospace track numbers (glow accent on row hover) + monospace
  tabular duration
- Status cells in both columns get uppercase micro-caps with a
  leading colored dot + soft glow halo (green/amber/blue/orange/red),
  pulses while checking + downloading
- Artist column centered
- Soft scrollbar
2026-05-13 18:38:02 -07:00
Broque Thomas
fcad5d4b18 Drop duplicate Download History button from Downloads batch panel header
Audit-trail PR added two buttons to the Downloads page — one always
visible next to the 'Batches' panel title, one inside the collapsible
'Recent History' header. User wants only the Recent History one.

Removes the panel-header button + the unused
.adl-batch-panel-header-actions style. Recent History button +
the original Dashboard button remain.
2026-05-13 15:11:07 -07:00
BoulderBadgeDad
c77aa61fdf
Merge pull request #530 from dlynas/feat/explicit-badges
feat: add explicit badges to discography modal and artist-detail cards
2026-05-13 15:04:14 -07:00
BoulderBadgeDad
c6cc8a9923
Merge pull request #528 from dlynas/feat/wrap-discog-modal-text
feat: wrap discog modal card titles instead of truncating
2026-05-13 15:01:47 -07:00
Antti Kettunen
48aec3f6f3
Remove legacy issues shell code
- Delete the static issues page renderer and detail modal helpers
- Keep the React issues route as the only implementation
- Drop the dead mobile CSS and troubleshooter hook that only targeted the removed shell
2026-05-13 22:26:22 +03:00
Antti Kettunen
d98dcd8606
Initial Vite app scaffolding & issues page impl
- File-based routing with tanstack router
  - Persist top-level navigation state in url, even for most legacy pages
  - Striving for an intuitive and simple folder structure where
    route-related code is colocated, but the amount of files is still
    kept to a minimum
- Replace native fetch with `ky`
  - Familiar api, but more polished
2026-05-13 22:24:46 +03:00
Broque Thomas
89246a7304 Write artist.jpg to artist folder so Navidrome shows real photos
Closes #572 (rhwc).

Navidrome has no API for setting an artist image — it reads
`artist.jpg` (or `folder.jpg`) from the artist folder during
library scans. SoulSync's `update_artist_poster` for Navidrome
was a no-op, so users only ever saw album-art-derived thumbnails
as the artist photo.

- new "Write Artist Image" button on artist detail page
- POST /api/artist/<id>/write-image-to-disk derives the artist
  folder from any track's resolved file_path (reuses
  _resolve_library_file_path so docker mount translation +
  library.music_paths probes from #558 apply), fetches the photo
  from the configured metadata source priority chain, downloads
  with content-type validation, writes atomically via
  `<filename>.tmp + os.replace`
- when active server is Navidrome, triggers a library scan
  immediately so the file is picked up
- respects existing artist.jpg (frontend prompts before
  overwriting) so user-supplied photos aren't clobbered
- works for plex / jellyfin too as a fallback layer — both
  servers also read artist.jpg from disk

26 tests pin the pure helpers in core/library/artist_image.py:
folder derivation (trailing sep / empty / non-string), URL
picking (missing attr / whitespace / non-string), download
(non-image content-type / 404 / timeout / empty body), atomic
write (replace / temp-cleanup-on-failure / overwrite guard /
missing folder).
2026-05-13 11:48:09 -07:00
Broque Thomas
6ce185491d Add per-download Audit Trail modal to Library History
- new "Audit" button on each download row in the library history
  modal opens a second modal visualizing the download lifecycle as
  an interactive horizontal stepper (request → source → match →
  verify → process → place) with click-to-expand detail cards
- hero header with album art + track title + meta line + status
  pills (source / quality / acoustid result)
- three tabs: Lifecycle / Tags / Lyrics
- Tags tab reads the audio file live via mutagen at audit-open
  time via new GET /api/library/history/<id>/file-tags endpoint;
  file is the single source of truth so background enrichment
  writes (audiodb / lastfm / genius / replaygain / lyrics fetch)
  show up too. flat key/value rows stacked vertically (label-above-
  value) so long MBIDs / URLs / joined genre lists wrap cleanly.
  source IDs grouped per-service into 2-col sub-card grid.
- Lyrics tab renders the full transcript with dimmed timecodes.
- post-processing step infers observable changes from source-vs-
  final state (format conversion, file rename via tag template,
  folder template).
- "Download History" button also added to the Downloads page batch
  panel header so it's reachable outside the dashboard.
- mobile responsive: tabs + stepper scroll horizontally, modal
  goes full-screen, hero stacks below 480px.

19 helper tests pin the mutagen reader: id3 (TIT2/TPE1/TALB + TXXX
+ USLT + APIC), vorbis (FLAC dict + _id/_url passthrough), file
metadata (format / bitrate / duration), defensive paths (empty /
missing file / mutagen returns None / mutagen raises), stringify
edge cases (list / tuple / int / frame-with-text / whitespace).
2026-05-13 09:50:24 -07:00
dlynas
42bee21c9f feat: add explicit badges to discography modal and artist-detail cards
Adds an explicit field to the Album dataclass in core/metadata/types.py
and the client-level Album dataclasses in deezer_client.py,
itunes_client.py, and hydrabase_client.py (the legacy discography path
reads from client objects, not typed dicts).

Deezer extracts explicit_lyrics (int→bool), iTunes extracts
collectionExplicitness ('explicit' string), Hydrabase forwards the
explicit field from the server response. Spotify, Discogs, MusicBrainz,
Qobuz, and Tidal have no explicit signal and stay None.

The flag threads through both builder functions in discography.py and
renders as a small "E" badge next to explicit titles in the discography
download modal and artist-detail page cards.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:35:08 -04:00
dlynas
503f07b9fc feat: wrap discog modal card titles instead of truncating
Card titles in the discography modal now display their full text
across multiple lines rather than being cut off with an ellipsis.
Artwork and the selection checkbox are pinned to the top of the card
so they align with the first line of text when titles wrap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:35:08 -04:00
Broque Thomas
6fe85f2f37 Server playlist sync: append mode (preserve user-added tracks)
Discord report (CJFC, 2026-04-26): syncing a Spotify playlist to the
server overwrote anything manually added to the server-side playlist.
The fix adds a per-sync mode picker next to the Sync button on the
playlist details modal — Replace (default, current delete-recreate
behavior) or Append only (preserves existing tracks, only adds new
ones). Useful when the source platform caps playlist size and the
user is manually building beyond it on the server.

Implementation:

* New `append_to_playlist(name, tracks)` method on Plex / Jellyfin /
  Navidrome clients. Each uses the server's NATIVE append API:
    - Plex: `existing_playlist.addItems(new_tracks)`
    - Jellyfin: `POST /Playlists/<id>/Items?Ids=...&UserId=...`
    - Navidrome: Subsonic `updatePlaylist?songIdToAdd=...`
  Falls back to `create_playlist` when the playlist doesn't exist
  yet (first sync). No delete-recreate, no backup playlist created
  (preserves playlist creation date + metadata + non-soulsync-managed
  tracks).
* Dedup-by-server-native-id (ratingKey for Plex, GUID for Jellyfin,
  song-id for Navidrome) — never re-adds a track already on the
  playlist. Server-native identity, not fuzzy title+artist match,
  so it can't false-collide.
* `sync_service.sync_playlist` accepts `sync_mode='replace'|'append'`
  kwarg. Single if/else branch dispatches to `append_to_playlist` or
  `update_playlist`. Threaded through `core/discovery/sync.run_sync_task`
  and the `/api/sync/start` HTTP handler. Validation on the API rejects
  unknown mode strings (defaults to 'replace').
* Frontend: per-playlist `<select id="sync-mode-${id}">` rendered next
  to the Sync button in both modal renderers (sync-spotify.js for
  Spotify playlists, sync-services.js for Deezer ARL playlists).
  `startPlaylistSync` reads the select at click time; missing select
  (other callers like discover.js) defaults to 'replace' so backward
  compat preserved without per-call-site updates.
* SoulSync standalone has no playlist methods at all and the modal
  hides the Sync button entirely on it via `_isSoulsyncStandalone` —
  dispatch never reaches that path, no defensive fallback needed.

15 new tests pin per-server append behavior:
  - missing playlist → create_playlist delegation
  - dedup filtering (existing IDs skipped, only new tracks added)
  - empty new-track set short-circuits without API call
  - failure paths return False without raising
  - contract listing (KNOWN_PER_SERVER_METHODS includes
    'append_to_playlist'; Plex / Jellyfin / Navidrome all implement)

Plus tests/discovery/test_discovery_sync.py fake `sync_playlist`
fixture got `sync_mode='replace'` default to match the new signature
(was breaking after the kwarg add; now passing).

WHATS_NEW entry under new '2.6.0' block (hidden by
`_getLatestWhatsNewVersion` until next release bump).

Closes CJFC discord request.
2026-05-10 22:52:11 -07:00
Broque Thomas
e20994e1c7 Manual picks: stream results, don't auto-retry, fix stuck-at-0%
Three follow-on fixes to the manual-search candidates modal once people
started actually using it:

1. NDJSON streaming. Manual search waited for every source to return
   before showing anything. Now streams one event per source as each
   completes — header line, source_results per source, done terminator.
   Frontend appends rows incrementally via response.body.getReader().

2. Manual picks no longer auto-retry on failure. New _user_manual_pick
   flag set on the task in /download-candidate. Both monitor retry
   paths (not-in-live-transfers stuck + Errored state) bail on the
   flag. Surfaces the failure to the user instead of silently picking
   a different candidate via fresh search.

3. Non-Soulseek manual picks (youtube/tidal/qobuz/hifi/deezer/
   soundcloud/lidarr) no longer stuck at "downloading 0%" forever. The
   live_transfers IF branch now marks manual-pick tasks failed
   directly when the engine reports Errored, instead of deferring to
   the monitor (which bails on manual picks). Engine fallback in else
   branch covers the rare race where the orchestrator's pre-populated
   transfer lookup is missing the entry.

Plus a deadlock fix discovered along the way: the new failure path
synchronously called on_download_completed while holding tasks_lock,
which itself re-acquires the same Lock — non-reentrant
threading.Lock self-deadlocked the polling thread. While wedged, every
other endpoint that needed the lock (including /candidates → other
failed rows couldn't open modals) hung waiting. Moved completion
callbacks onto a daemon thread so the lock releases first.

Plus failed/not_found/cancelled rows are now ALWAYS clickable (not
just when the auto-search cached candidates) — the modal carries the
manual search bar, which is the user's recourse for empty results.

Plus manual download worker now runs on a dedicated thread instead of
competing with the batch's 3-worker missing_download_executor pool —
saturated batches no longer queue manual picks indefinitely.

All scoped to manual picks via the _user_manual_pick flag — auto
attempt flow byte-identical to before. Engine fallback gated on the
flag too so auto attempts in the else branch keep the original
do-nothing behavior (safety valve handles the stuck-forever case).

Also dropped _handle_failed_download from web_server.py — defined
but had no callers (dead code).

17 new unit tests pin the gate behavior:
- engine fallback: Errored/Cancelled/Succeeded/InProgress transitions,
  manual-pick gate, terminal-state skip, soulseek skip, missing
  download_id skip, engine returning None, orchestrator exception
- monitor: manual-pick skips not-in-live-transfers retry + Errored
  retry
- IF-branch end-to-end: Errored marks failed, "Completed, Errored"
  hits failure branch, auto attempts defer to monitor

Manual-search endpoint tests rewritten for NDJSON: 11 cases (validation,
single-source dispatch, parallel "all" dispatch, one-event-per-source
streaming shape, unconfigured-source skip + reject, header metadata,
per-source exception isolation).

Full suite 2259 passed, 1 skipped.
2026-05-08 15:12:58 -07:00
Broque Thomas
996575fab3 Add manual search to the failed-track candidates modal
When an auto-download fails or returns "not found" with leftover
candidates, the user can already click the status cell to open a
modal showing those candidates and pick a different one. This adds
a manual search bar to that modal — type any query, hit search,
get a fresh round of results without having to bail out and start
over from the main search page.

Solves the case where the auto-query was bad (featured artist not
in title, parentheticals like "(Remastered 2019)" tripping the
matcher, slight artist-name variants, transliteration) but the
file genuinely exists on the source.

Frontend (downloads.js)

- Added a manual-search section above the existing auto-candidates
  table inside the candidates modal.
- Source picker is smart per download mode:
  - Single-source mode (soulseek-only / youtube-only / etc) shows
    a "Searching X" label, no dropdown.
  - Hybrid mode shows a dropdown with "All sources" default + every
    configured source. Picking "All" runs parallel searches across
    them and tags each result row with its source badge.
  - Only configured sources show up; unconfigured are hidden.
- Validation: button disabled until query length >= 2, "Type at
  least 2 characters" hint until threshold crosses.
- Loading state on search button while the request is in flight.
- Manual results render in a separate table above the existing
  auto-candidates table, using the same row template (file /
  quality / size / duration / user / ⬇ button) so the renderer
  helper is shared.
- Click ⬇ reuses the existing `downloadCandidate(taskId, candidate,
  trackName)` flow — same retry path, same AcoustID verification
  when the file lands, no shortcut around the safety net.
- Re-running the search with a different query replaces the
  previous manual results.

Backend (web_server.py)

- Extended `GET /api/downloads/task/<id>/candidates` response with:
  - `download_mode` (e.g. 'hybrid', 'soulseek')
  - `available_sources` (list of configured source IDs + labels)
  - `source` field on each candidate (purely additive — frontend
    auto-renderer ignores it on legacy code paths, manual-search
    renderer uses it for the badge)
- Added `POST /api/downloads/task/<id>/manual-search`:
  - Body: `{ query, source: 'all' | <source_id> }`
  - Validates query length (>=2 trimmed) → 400
  - Validates source against the configured-sources gate → 400
    (rejects unconfigured sources even when explicitly named)
  - For 'all': parallel `ThreadPoolExecutor` dispatch across every
    configured download source, merged results
  - For specific source: just that source
  - Returns same shape as `/candidates` so the frontend renderer
    is reused
- New module-level helpers: `_STREAMING_SOURCE_NAMES`,
  `_infer_candidate_source`, `_serialize_candidate`,
  `_list_available_download_sources`. The existing `/candidates`
  endpoint also goes through `_serialize_candidate` so the source
  badge is consistent across both flows.

Behavior preserved

- Existing modal layout / candidates table / ⬇ button are
  byte-identical when the user doesn't use manual search.
- `downloadCandidate()` JS function untouched.
- `/candidates` and `/download-candidate` endpoints
  backwards-compatible — only NEW fields added, nothing changed
  or removed.

Tests

`tests/test_manual_search_endpoint.py` — 10 tests:

- `test_manual_search_validates_query_length`
- `test_manual_search_validates_source` (whitelist gate)
- `test_manual_search_handles_task_not_found` (404)
- `test_manual_search_dispatches_to_configured_source_only`
- `test_manual_search_all_dispatches_parallel`
- `test_manual_search_skips_unconfigured_sources`
- `test_manual_search_rejects_unconfigured_source_explicitly`
- `test_manual_search_returns_same_shape_as_candidates`
- `test_manual_search_single_source_mode_lists_source` (verifies
  `available_sources` reflects the active mode)
- `test_manual_search_isolates_per_source_exceptions` (one source
  throwing doesn't kill the merged result)

2242/2242 full suite green (was 2232 + 10 new). Ruff clean.
JS parses clean.
2026-05-08 09:50:17 -07:00
Broque Thomas
1a2da016e4 Add download buttons + bulk action to artist top-tracks sidebar
Closes #513 (s66jones).

The artist detail page already showed a "Popular on Last.fm" sidebar —
list of an artist's top tracks by playcount, with a play button per row
but no download action. Issue #513 wanted a way to grab those tracks
the same way zotify let users grab "top X songs" without pulling the
full discography.

Pulls from the configured primary metadata source (Spotify
`artist_top_tracks`, Deezer `/artist/{id}/top`) when available, falls
back to the existing Last.fm display-only mode for sources that don't
expose popularity ranking (iTunes / Discogs / MusicBrainz). Source
label in the section title shifts to match.

Each row gets a hover-revealed download button that wishlists the
single track via the existing /api/add-album-to-wishlist endpoint
(preserves the track's real album metadata, so the wishlist worker
later places the file in its proper album folder).

A "Download All" footer button opens the standard download modal in
PLAYLIST context, not album context — the virtual playlist_id is
`top_tracks_<source>_<artistId>` which doesn't match any of the
album-prefix checks in `startMissingTracksProcess` (downloads.js).
That keeps `is_album_download=false`, so the master worker doesn't
inject a wrapper context as `_explicit_album_context`. Each track
downloads using its own real album metadata, files land in proper
per-album folders on disk (not a fake "Top Tracks" folder).

Backend additions:

- `SpotifyClient.get_artist_top_tracks(artist_id, country, limit)` —
  wraps `spotipy.artist_top_tracks`, returns up to 10 tracks for the
  market (Spotify's API cap). UI-side limit trim only.
- `DeezerClient.get_artist_top_tracks(artist_id, limit)` — wraps
  `/artist/{id}/top?limit=N`, converts Deezer's raw shape to the same
  Spotify-compatible dict layout (id, name, artists, album with
  album_type / total_tracks / images, duration_ms, track_number,
  disc_number) so downstream code doesn't branch on source.
- `GET /api/artist/<id>/top-tracks` — dispatches to whichever client
  matches the primary source. Resolves per-source artist IDs from the
  DB row first (matching what /discography already does) so a Spotify
  ID in the URL still works when Deezer is primary, and vice versa.
  Returns `{success, source, tracks, resolved_artist_id}` on hit;
  `{success: False, reason: 'unsupported_source' | 'spotify_not_authenticated'
  | 'deezer_unavailable' | 'no_tracks_found'}` on miss so the frontend
  can decide whether to fall through to Last.fm.

Frontend:

- `_loadArtistTopTracks` tries the metadata source first, falls
  through to the legacy `/api/artist/0/lastfm-top-tracks` call if the
  source can't deliver. Section title and per-row UI shift based on
  which source answered.
- New per-row `.hero-top-track-download` button (hover-revealed).
- New `.hero-top-tracks-download-all` footer button — only visible
  when metadata-source mode rendered the list (Last.fm fallback hides
  it since rows have no track IDs to download).

Tests: 10 new tests pin the client methods —
- Spotify: returns track list, honors UI limit cap, returns empty when
  unauthed / artist_id missing / API throws.
- Deezer: shape conversion to Spotify-compatible dict, empty when no
  data / artist_id missing, limit clamping at upper bound, default
  fallback when limit=0, malformed entries skipped.

The Flask endpoint dispatcher itself isn't covered by the new test
file because importing web_server at test-collection time spins up
worker threads that race with caplog-using tests elsewhere in the
suite (specifically test_library_reorganize_orchestrator). Endpoint
verified manually; the underlying client methods (the load-bearing
logic) are covered.

2204/2204 full suite green (was 2194 + 10 new).
2026-05-07 15:44:47 -07:00
Broque Thomas
dd48dc8c6e Update style.css 2026-05-07 14:03:14 -07:00
Broque Thomas
4c11375930 Repair job card badge — show pending count, not last-scan count
Discord report: Duplicate Detector card said "372 findings" and Cover
Art Filler said "60 findings", but clicking the Findings tab's Pending
filter showed 0. User read it as "findings aren't being created" —
looked like a detector bug.

Actual cause: the badge sourced ``last_run.findings_created``
(historical "found in last scan") without considering current state.
After the user (or bulk-fix automation) resolved or dismissed those
findings, they no longer appeared on the Pending tab — but the badge
kept showing the last-scan number in red urgent styling.

Backend was correct end-to-end: detectors create pending rows,
bulk-fix moves them to resolved, Findings tab filters by status.
Only the badge display lied about current state.

Fix:

- ``RepairWorker._get_pending_count_by_job()`` — single SQL aggregation
  returning ``{job_id: pending_count}`` for every job with pending
  findings. O(1) lookup per job instead of N round trips.
- ``get_all_job_info()`` calls it once per request and adds
  ``pending_findings_count`` to each job's API response.
- ``enrichment.js`` job card now branches on the count:
  - ``> 0`` → red ``"X pending"`` badge (urgent, action needed)
  - ``= 0`` AND last scan found something → muted grey ``"X found in
    last scan"`` (historical context, no action needed)
- New CSS class ``.repair-flow-badge.findings-historical`` for the
  muted slate color so the two states are visually distinct.

User-visible result with the screenshotted state (372 dup / 60 cover-
art findings, all resolved):
- Before: red "372 findings" / "60 findings" — implied 432 things to
  do, but Findings tab showed 0 pending
- After: grey "372 found in last scan" / "60 found in last scan" —
  the badge text tells the user the count is historical, no surprise
  when Pending is empty

Tests: 3 new tests in ``tests/test_create_finding_dedup_counter.py``
pin the per-job pending count helper:
- returns ``{job_id: count}`` based on status='pending' rows only;
  resolved + dismissed rows excluded
- empty dict when no pending findings exist
- gracefully returns ``{}`` on DB error (badge falls back to
  historical count via the existing JS ``or 0`` safety)

2188/2188 full suite green. Pure UI/state-display fix — no detector
logic, no backend behavior change.
2026-05-07 08:28:17 -07:00
Broque Thomas
2ab460f5c4 Add Library Disk Usage card to System Statistics
Discord request (Samuel [KC]): show how much disk space the library
takes on the Stats page. Implementation piggybacks on the existing
deep scan — Plex/Jellyfin/Navidrome all return file size in their
track API responses, so we read it during the deep scan and store
it on the tracks row. Aggregation is then a single SQL query — no
filesystem walk, no extra I/O during the scan, no separate stat
job. SoulSync standalone gets size from os.path.getsize at insert
time (different code path; the file is local when we write the row).

Schema (`database/music_database.py`):
- New `file_size INTEGER` column on `tracks`. Migration uses the
  established `try SELECT, except ALTER TABLE ADD COLUMN` pattern.
  Idempotent; safe on existing installs. NULL on legacy rows so
  they don't contribute to totals until next deep scan refreshes.
- Added the column to the canonical CREATE TABLE so fresh installs
  get it without going through the migration path.

Track-object plumbing:
- `core/jellyfin_client.py` — JellyfinTrack reads MediaSources[0].Size
  alongside existing Bitrate read. None when 0 / missing.
- `core/navidrome_client.py` — NavidromeTrack reads `size` from
  the Subsonic song object (int coercion + None on parse fail).
- `core/soulsync_client.py` — SoulSyncTrack does os.path.getsize
  (only "server" where size has to come from disk).
- Plex needs no client-side change: track.media[0].parts[0].size
  is read directly inside insert_or_update_media_track.

Persistence — TWO separate insert paths:

(a) `database/music_database.py:insert_or_update_media_track` —
    Plex/Jellyfin/Navidrome flows. Reads file_size from Plex's
    MediaPart OR `track_obj.file_size` wrapper attribute (defensive
    Plex-attr-not-present check + > 0 type guard).
    INSERT writes the new column.
    UPDATE uses COALESCE(?, file_size) so a None from the server
    on a re-sync (rare Jellyfin Size omission) doesn't blank an
    existing value. Pinned via test.

(b) `core/imports/side_effects.py:record_soulsync_library_entry` —
    SoulSync standalone flow. Completely separate code path: the
    standalone deep scan moves files to staging for auto-import
    rather than calling insert_or_update_media_track. After the
    auto-import processes them, side_effects writes the tracks row
    directly. Reads file_size via os.path.getsize(final_path) at
    insert time (file is local) and includes it in the INSERT
    column list. SoulSync only does INSERT-if-not-exists (no
    UPDATE path), so no COALESCE concern.

Aggregator (`database/music_database.py:get_library_disk_usage`):
- SELECT COALESCE(SUM(file_size), 0), COUNT(file_size),
  COUNT(*) - COUNT(file_size) for the totals.
- Per-format breakdown done in Python via os.path.splitext over
  (file_path, file_size) rows — sidesteps SQLite's first-vs-last-dot
  ambiguity for paths like /music/Kendrick/M.A.A.D City/01.flac.
- Defensive: skips empty paths, paths without extension, and
  implausibly long extensions (>6 chars). Returns the full
  empty-shape dict (NOT a partial / undefined) when the column
  doesn't exist or queries fail, so the UI's `if (!data.has_data)`
  branch handles fresh installs cleanly.

API + UI:
- `core/stats/queries.py` — thin pass-through get_library_disk_usage
  matching the existing query-helper convention.
- `web_server.py` — new /api/stats/library-disk-usage endpoint
  mirroring the /api/stats/db-storage pattern.
- `webui/index.html` — new card in System Statistics above the
  Database Storage card.
- `webui/static/stats-automations.js` — _loadLibraryDiskUsage +
  _renderLibraryDiskUsage. Empty state: "Run a Deep Scan to
  populate (X tracks pending)". Partial: "X measured (+Y pending)".
  Full: total + format bars proportional to the largest format.
- `webui/static/style.css` — .stats-disk-* styled to match the
  Database Storage card.

Backward compatibility:
- Migration is additive; existing rows get NULL file_size; the
  empty-shape return from the aggregator means the UI renders
  cleanly without errors before any deep scan runs.
- Old installs upgrading will see "Run a Deep Scan to populate
  (N tracks pending)". Running their next deep scan fills sizes —
  the existing scan flow doesn't need any changes, just consumes
  the new track-wrapper attribute.

Tests:
- `tests/test_library_disk_usage.py` — 13 cases covering schema
  migration, NULL defaults on legacy inserts, fresh-install empty
  shape, summing with mixed NULL/known sizes, per-format breakdown,
  mixed-case extensions, paths with album-name dots, missing
  extensions, empty file_path, implausibly long extensions,
  JellyfinTrack.file_size persistence via insert_or_update_media_track,
  COALESCE preservation on null re-sync.
- `tests/imports/test_import_side_effects.py` — extended the
  existing record_soulsync_library_entry test to assert
  track_row['file_size'] == os.path.getsize(final_path), pinning
  the SoulSync-standalone path. Test fixture's tracks schema also
  updated to include the file_size column.

Verified: full suite 1813 pass (13 new, 1 existing-test extension),
ruff clean, smoke test populating + reading the column round-trips
correctly.

WHATS_NEW entry under '2.4.2' dev cycle.
2026-05-03 20:17:06 -07:00
Broque Thomas
cdd408b6f3 Auto-import: live card updates + multi-disc + featured-artist tag fixes
The 'Live Per-Track Progress' work shipped a backend in-progress row + top-of-tab
progress text but the history cards themselves stayed visually stale during
processing — lowercase "processing" badge, neutral styling, no per-track hint.
Smoke-testing also surfaced two latent identification bugs that prevented
multi-disc rips with features (Kendrick GKMC Deluxe) from importing at all.

Card-level live progress (`webui/static/stats-automations.js`):
- Cache `/api/auto-import/status` response in `_autoImportLastStatus`; poller
  awaits status before re-rendering results so the card has the live data.
- Add 'processing' entries to statusLabels / statusIcons / statusClass.
- When card folder_name matches `current_folder`, swap the meta line to
  `track N/M: <track name>` and tag the matching row in the expanded list
  as `auto-import-track-row-active`; prior rows tag as `-row-done`.

Card styling (`webui/static/style.css`):
- `.auto-import-processing` blue left border, `.auto-import-badge-processing`
  pulse animation, active/done track-row classes.

Multi-disc enumeration (`core/auto_import_worker.py:_scan_directory`):
- Old code skipped disc folders during recursion AND only attached them to a
  parent that had its own loose audio. A folder containing only `Disc 1/`,
  `Disc 2/` was invisible. Now: when a directory has only disc subdirs and no
  loose audio, treat that directory itself as the album candidate. Disc folders
  still skipped when standing alone.
- Add `FolderCandidate.is_staging_root` flag (set when the staging dir itself
  becomes the candidate via this path) so identification can refuse to use the
  meaningless folder name.

Tag identification (`core/auto_import_worker.py:_identify_from_tags`):
- Per-track `artist` tag fragmented consensus on albums with features
  ("Kendrick Lamar" / "Kendrick Lamar, Drake" / "Kendrick Lamar, Dr. Dre"
  produced 3 separate `(album, artist)` keys for one album). Now group by
  album first, then pick the most-common artist within that album group.
- `_read_file_tags` now prefers `albumartist` over `artist` for album-level
  identity; falls back to `artist` for files without albumartist.
- Add INFO-level log when tag identification rejects, showing top albums and
  their counts so the user can diagnose multi-disc / tagging issues.

Folder-name false-match guard (`core/auto_import_worker.py:_identify_folder`):
- When `is_staging_root` is set, skip the folder-name strategy entirely. Logs
  the skip and falls through to AcoustID. Without this, dropping disc folders
  directly into staging caused the scanner to search the metadata source for
  the literal name "Staging", which false-matched against random albums (e.g.
  "Stamina, Dinos" — a French rap album — at 13% confidence).

What's New entries added under 2.4.2 dev cycle.
2026-05-02 23:15:52 -07:00
Antti Kettunen
2693640c62
Hide dashboard status placeholders until ready
- Keep the sidebar and dashboard service cards neutral until the first /status payload arrives
- Prevent placeholder source names and card text from flashing on dashboard load
- Reveal the real service status only after the live snapshot populates the UI
2026-05-02 22:02:01 +03:00
Broque Thomas
0c4fad033d Show artist breadcrumb on sidebar Library button when on artist-detail page
Artist-detail is a "pseudo-page" reachable from Library, the unified
Search page, and the global search popover. It has no [data-page]
match in the sidebar, so navigateToPage's bulk-active-removal left
every nav button unhighlighted while the user was viewing an artist —
the sidebar offered no visual anchor for where they were.

Now:
- navigateToPage('artist-detail') falls back to highlighting the
  Library button when no [data-page] match exists, anchoring the
  sidebar to the canonical home for artist detail views.
- A new _updateSidebarLibraryBreadcrumb() helper rewrites the Library
  button label between plain "Library" and a "Library / Artist Name"
  breadcrumb based on currentPage + artistDetailPageState. Long names
  (>14 chars) truncate with an ellipsis; the full name shows on hover
  via the title attribute.
- Called from navigateToPage (entering / leaving the page) and from
  loadArtistDetailData (covers same-page artist switches in the
  similar-artist chain where currentPage stays 'artist-detail').

CSS adds .nav-text-root / .nav-text-sep / .nav-text-context selectors
so the "Library" anchor word stays visually dominant while the
separator and artist name dim to a secondary tier — readable but not
competing for attention.

Pure visual change. No backend touched. No new tests (DOM-only).
2026-05-01 17:49:34 -07:00
elmerohueso
788b7011d0 fix hifi instance reorder and enable/disable 2026-04-28 20:19:00 -06:00
Broque Thomas
d6094a3587 Library reorganize: FIFO queue with live status panel
Replaces the single-slot "one reorganize at a time, return 409 on collision"
model with a per-user FIFO queue. Buttons stay clickable, "Reorganize All"
is one backend call instead of an N-call JS loop, and a status panel mounted
at the top of the artist actions bar shows live progress (active item,
queued count, recent completions) with per-item cancel buttons.

Backend
- core/reorganize_queue.py: singleton queue + worker thread, dedupe-on-
  enqueue, cancel rules (queued cancellable, running not), enqueue_many
  for bulk operations, progress fan-out via update_active_progress
- core/reorganize_runner.py: factory builds the worker's runner closure
  with injected dependencies. Reads config per-call so changing the
  download path in Settings takes effect on the next reorganize without
  a server restart
- database/music_database.py: get_album_display_meta and
  get_artist_albums_for_reorganize — moves the SQL out of route handlers
- web_server.py: thin enqueue/snapshot/cancel/clear endpoints, runner
  registration at module load. Old _reorganize_state globals + status
  endpoint deleted. Static-asset cache buster (?v=<server-start>)
  added so JS/CSS updates ship live without users clearing cache

Frontend
- webui/static/library.js: status panel mount, polling (1.5s when
  active, 8s when idle), expand/collapse, per-item cancel, debounced
  enhanced-view reload (one reload per artist batch instead of N).
  Per-album reorganize button paints with queued/running indicator
  and short-circuits to a toast when the album is already in queue
- webui/static/style.css: panel + button styling matching the existing
  glass-UI accents
- webui/static/helper.js + version modal: WHATS_NEW entry

Tests (22 new)
- tests/test_reorganize_queue.py (19 tests): FIFO order, dedupe,
  per-item source, cancel rules, continue-on-failure, snapshot
  shape, progress propagation, bulk enqueue
- tests/test_reorganize_runner.py (4 tests): per-call config reads,
  setup-failure summary, dependency injection, progress fan-out
- tests/test_reorganize_db_methods.py (7 tests): SQL JOIN behavior,
  ordering, fallback for blank strings, artist isolation

Full suite 549 passed in 27s.
2026-04-25 18:01:32 -07:00
Broque Thomas
2b15260b88 Reorganize: route library files through the post-processing pipeline
Reported on Discord by winecountrygames. The library "Reorganize" tool
had several layered bugs that all traced to the same root cause: the
endpoint reinvented every wheel post-processing already turns — its own
template engine, its own disc-number resolution from file tags, its own
sidecar sweep, its own collision detection — and each had drifted from
the canonical path used by fresh downloads. Reported symptoms:

  - 3-disc Aerosmith deluxe collapsed to a flat single-disc layout
  - Half the tracks on other albums silently skipped, no error / no count
  - Re-runs left empty leftover album folders cluttering the artist dir

Architecture: stop reinventing wheels. Route reorganize through exactly
the same pipeline downloads use. Per-album:

  1. Fetch the canonical tracklist from a metadata source (Spotify /
     iTunes / Deezer / Discogs / Hydrabase) using the album's stored
     source IDs. New `core/library_reorganize.py::plan_album_reorganize`
     does this — primary-source-first, fall through priority chain
     unless the user picked a specific source in the modal (strict mode).
  2. For each local track, find the matching API entry via a scored
     candidate matcher. Score components: exact-title (100),
     substring-with-length-ratio (40-90), track-number agreement (20).
     Hard reject when the two titles have different version
     differentiators (Remix vs no-remix means different recordings,
     not annotation drift). Below threshold = unmatched, surfaced as
     "not in source's tracklist, left in place" rather than silently
     mis-routing.
  3. Copy the file to a per-album staging directory, build the same
     context dict the import flow builds (`spotify_album` /
     `track_info` / etc. with `is_album_download=True` so the path
     builder enters ALBUM mode, not SINGLE mode), call
     `_post_process_matched_download(...)` — same function fresh
     downloads use. Post-process handles tagging, multi-disc subfolder
     decisions, sidecar regeneration, AcoustID verification.
  4. Read `context['_final_processed_path']` to learn where it landed.
     Update `tracks.file_path` in the DB BEFORE removing the original
     (DB-update failure leaves the file at both locations, recoverable
     via library scan; the reverse would orphan the row). Delete
     per-track sidecars (post-process recreates them at the new
     destination).

3 concurrent workers per album via ThreadPoolExecutor, matching the
download path's per-batch worker count. State mutations all guarded by
a single lock; staging filenames carry a UUID prefix so concurrent
copies of identically-named source files don't overwrite each other.

Source picker in the modal lets the user choose which source to read
the tracklist from. Two endpoints feed it:
  - `/api/library/album/<id>/reorganize/sources` — sources for THIS
    album that are both authed AND have a stored ID. For the per-
    album modal.
  - `/api/library/reorganize/sources` — all authed sources globally.
    For the bulk "Reorganize All" modal where per-album ID coverage
    varies.
When the user picks a specific source, the orchestrator runs in
`strict_source=True` mode (no fallback chain) — picking Spotify means
"use Spotify or fail", not "use Spotify and silently fall back."

Preview endpoint shares the same planning logic as apply via
`preview_album_reorganize` — the destination path comes from the same
`_build_final_path_for_track` post-process uses, so what you see in
the preview is exactly what you get on apply.

Empty destination folders (from earlier failed runs OR from the
current run when post-process creates a dir then fails AcoustID)
get cleaned up after each successful run: walk up to the artist
folder from any successful destination, prune empty album-sibling
folders one level deep. Bounded scope = won't touch unrelated user
dirs.

Web_server.py shrinks by ~450 net lines. The endpoint handler is now
a thin wrapper that builds injected callables (path resolver, post-
process function, DB updater, empty-dir cleaner), spawns a thread
that calls `reorganize_album()`, and returns. All actual logic lives
in `core/library_reorganize.py` where it's unit-testable without
spinning up Flask.

Frontend cleanup: the per-call template input in both reorganize
modals (per-album and bulk) was redundant — the backend always uses
the configured global download template. Removed the input and the
variables-grid reference UI it was for.

39 new unit tests pin every contract:
  - source resolution (no_source_id when album has none, fallthrough
    chain when primary returns nothing, strict mode bypasses fallback)
  - matcher scoring (exact / substring / multi-disc disambiguation /
    smart-quote tolerance / dash-vs-parens / bonus-track substring /
    Remix-vs-original differentiator rejection / "Real" doesn't false-
    match "Real Real Real" / track-number-only no longer fires)
  - file safety (DB-update failure leaves original in place, post-
    process failure leaves original in place, post-process exception
    caught and original preserved, success removes original AND
    updates DB in the right order)
  - sidecar handling (per-track .lrc/.nfo deleted on success, kept on
    failure; album-level cover.jpg/folder.jpg cleaned only when
    directory has no remaining audio)
  - staging cleanup (recreated between tracks because post-process
    nukes it, dir cleaned up on success AND on failure)
  - destination-dir prune (empty siblings removed, real album with
    files preserved, no recursive sweep)
  - source picker (only authed-with-stored-ID sources for per-album,
    all authed sources for bulk; strict mode doesn't fall back)
  - concurrency (3 workers in flight, state stays consistent under
    races, stop_check cuts off pending tasks)
  - preview parity (preview produces same destination as apply for
    multi-disc; ALBUM mode not SINGLE mode; unmatched/no-path tracks
    surfaced with reasons)

Limitations (deliberate punts, NOT in this PR):
  - Renamed local titles on multi-disc albums where track_number
    also disagrees: matcher returns nothing (track is "not in
    source"). Fixable by using duration_ms as a tertiary signal.
  - Per-track in-modal source switching with per-album track-count
    hints (would need a second API call before opening the modal).
  - UI status panel on the artist page during a run — currently
    just toasts. Documented as a follow-up PR.

Files:
  - core/library_reorganize.py — new module: plan_album_reorganize,
    preview_album_reorganize, reorganize_album, available_sources_for_album,
    authed_sources, _score_candidate, helpers for staging/post-
    processing/finalizing, sidecar + dest-dir cleanup
  - core/metadata_service.py — no changes; reused get_album_for_source,
    get_album_tracks_for_source, get_source_priority,
    get_client_for_source
  - web_server.py — three endpoints (preview / apply / sources GETs)
    are thin wrappers; -450 net lines
  - tests/test_library_reorganize_orchestrator.py — 39 tests covering
    every contract above
  - webui/static/library.js — source picker UI in both modals; dead
    template input + variables-grid removed
  - webui/static/style.css — dropdown option styling fix (white-on-
    white was unreadable)

Reported on Discord by winecountrygames — his bug report named the
trigger button (Enhanced view → Reorganize All) and both symptoms
(multi-disc collapse, half-album skip), which let the diagnosis go
straight to the architectural problem.
2026-04-24 23:00:22 -07:00
Broque Thomas
2b7d6c8c7c Fix global search popover not scrolling when results overflow
The source-picker refactor introduced a new stable DOM structure inside
`#gsearch-results`:

    <div id="gsearch-results">            <!-- max-height: 60vh, flex-col -->
      <div id="gsearch-source-row" />     <!-- icon row, controller-rendered -->
      <div id="gsearch-fallback-banner" />
      <div id="gsearch-body" />           <!-- surface renders results here -->
    </div>

But the companion CSS never landed. `#gsearch-body` had default block
layout, so when results exceeded the 60vh cap, they clipped silently
instead of scrolling. The old structure had `.gsearch-results-body`
with `overflow-y: auto; flex: 1` directly inside the panel; that rule
still exists but its selector now matches a nested div with no flex
parent, so `flex: 1` is a no-op and overflow doesn't trigger.

Fix: give the three stable children the right flex behaviour so the
body fills remaining space and scrolls.

- `#gsearch-source-row` and `#gsearch-fallback-banner` stay at natural
  height (flex-shrink: 0).
- `#gsearch-body` grows (flex: 1 1 auto), can shrink below content
  height (min-height: 0 — this is the critical bit, otherwise flex
  items won't shrink below their intrinsic size and overflow never
  triggers), and scrolls vertically.

Styled scrollbar matches the rest of the panel (4px, translucent thumb).
2026-04-24 08:34:03 -07:00
Broque Thomas
258644fd9f Drop Show/Hide Results button + auto-restore cached results on navigate-back
Cin flagged two related UX issues during PR review:

1. The "Show Results / Hide Results" toggle next to the search bar served
   no real purpose — there was nothing else on the Search page worth seeing
   instead of results, so toggling visibility was always pointless overhead.

2. Navigating away from /search via a sidebar link dismissed the dropdown
   (the click was caught by the outside-click handler). Coming back left
   the input populated but the results hidden, requiring a Show Results
   click or a fresh search. The cached state was intact in the controller
   the whole time — just not rendered.

Both fixed by the same direction: dropdown visibility becomes a pure
function of query state, never user-toggleable. The closure now exposes
`_searchPageRestoreOnEnter` so subsequent calls to `initializeSearchModeToggle`
re-render from the controller's cached state instead of early-returning.

Removes the button HTML, click handler, `updateToggleButtonState` function,
the desktop + responsive CSS for `.enhanced-search-btn`, and the orphaned
`.btn-icon` rule. Net -94 lines.
2026-04-23 22:11:49 -07:00
Broque Thomas
30ab21c0e5 Global search bar: ambient accent-glow aura under the pill
Adds a subtle radial glow at the bottom of the viewport that emanates
from the floating search bar, fades outward toward both window corners,
and shrinks vertically as it moves away from the bar. Makes the bar
easier to spot at a glance without a heavy full-width bar or a chrome
strip.

- New `.gsearch-aura` fixed element, 260px tall, full width, pointer
  events off. Radial-gradient with the accent color centered at the
  bottom middle; colour stops taper 620x230px by default, ramping to
  820x280px and brighter when the bar is focused/active.
- `_gsUpdateVisibility` hides the aura on /search alongside the bar
  via a simple `.hidden` class.
- Focus handler adds `.active` to the aura in step with the bar;
  `_gsDeactivate` removes it. z-index 99990 (below the bar at 99998,
  above most page content).
2026-04-23 17:28:16 -07:00
Broque Thomas
dd20298df4 Library page empty state: offer to search metadata sources for the query
When a user types an artist name into the library search and gets no
hits, the old empty state just said "No artists found — try adjusting
your search or filters." Dead end for the common case of "I searched
for someone I don't own yet."

The empty state now detects when libraryPageState.currentSearch is
non-empty and swaps in a CTA that hands the query off to /search:

  "kendrick" isn't in your library
  They might be available on a connected metadata source.
  [🔍 Search online for "kendrick" →]

Clicking the button navigates to /search, pre-fills the enhanced search
input, and dispatches an input event so the existing debounced search
fires automatically. Uses the same hand-off pattern _gsNavigateToSearchPage
already uses for Soulseek, so the Search page's source-picker flow
picks up naturally from there.

No change to the generic empty state (no query active) or to any other
library page behaviour.
2026-04-23 17:28:16 -07:00
Broque Thomas
c605904a5c Source picker: dim unconfigured sources, redirect to Settings on click
The picker used to render every source whether or not the user had
credentials for it. Clicking Discogs with no token, Hydrabase with no
URL, or Spotify with nothing saved would fire a doomed fetch — at best
a silent empty state, at worst a confusing fallback to another source.

Now the picker reads /api/settings/config-status (the same endpoint the
Settings → Connections page already uses for the green/yellow status
dot) on init and dims icons whose service isn't set up. Clicking a
dimmed icon navigates to Settings → Connections and scrolls to the
relevant service card with a brief accent-coloured pulse to orient
the user.

Sources the backend's SERVICE_CONFIG_REGISTRY doesn't cover
(musicbrainz, youtube_videos, soulseek) are permanently treated as
configured — they need no user credentials, so dimming them would
mislead.

Extra guard: if the user's configured primary metadata source is
itself unconfigured (Spotify saved as primary but no client_id yet),
`_initDefaultSource` falls forward to the first configured source so
the default active icon is never a "set up" chip.

Shared helpers:
- fetchSourceConfiguredMap() centralizes the config-status lookup for
  both surfaces. Falls back permissively if the endpoint fails so the
  picker never stops working over a network hiccup.
- openSettingsForSource(src) navigates to Settings → Connections and
  scrolls to `[data-service=src]`, pulsing a 2.2s accent flash
  (.stg-service-flash) so the user doesn't lose their place.

CSS:
- .unconfigured: 42% opacity, 0.7 grayscale filter, subdued hover
  state with no transform/glow (feels "look but don't touch"),
  defensive override to kill brand glow if somehow active.
- @keyframes stg-service-flash-anim for the scroll-to highlight.
2026-04-23 17:28:16 -07:00
Broque Thomas
ec0c425e71 Global widget: drop the double-panel look on the source row
The global search popover already draws its own frosted-glass panel
(via .gsearch-results), so putting another bordered/gradient container
around the source icons inside it read as "panel inside a panel" —
visually noisy and left a dark empty strip on the right when the row
didn't fill the popover width.

Strip the source row's own background/border, center-align the chips
(justify-content: center) so they stay grouped instead of drifting to
the left, and keep a subtle bottom divider so the icons still read as
a distinct control group above the results.
2026-04-23 17:28:15 -07:00
Broque Thomas
f6f8a3e960 Source picker visual boost: glassy row, brand-glow active, pulsing cache dot
Dresses up the bare chip row so the picker reads as a deliberate piece of
UI rather than a utility bar. Both the Search page (.enh-source-*) and
the global widget (.gsearch-source-*) get the same treatment.

- The row itself is now a frosted-glass panel (subtle white gradient,
  inner highlight, rounded 14px / 12px corners, outer shadow) so the
  picker feels unified instead of a loose strip of buttons.
- Chips bumped: min-width 90px (row) / 72px (widget), bigger padding,
  12px rounded corners, subtle linear gradient top-to-bottom, 30px icons
  (up from 22px) with a drop-shadow for depth.
- Hover lifts the chip by 1px with a darker drop-shadow and brighter
  border — cheap but effective microinteraction.
- Active state is brand-themed per source: the chip's background
  becomes a top-weighted gradient in the service's colour, the border
  matches, and an outer brand-coloured glow (6-22px blur) surrounds it.
  scale(1.03) pops it above neighbours. Label bumps to 700 weight when
  active. Same treatment for Spotify / Apple Music / Deezer / Discogs /
  Hydrabase / MusicBrainz / Music Videos / Soulseek.
- Cache dot gets a brand-coloured glow and a subtle 2.4s pulse so the
  "already fetched this query" hint is visible without being loud.
- Fallback-warning icons get an amber tint on both border and outer
  ring to match the existing fallback banner colour.
2026-04-23 17:28:15 -07:00
Broque Thomas
86e6d8df49 Fix source-picker review items: real logos, cached click close, Soulseek clip
Three follow-up fixes after browser testing:

1. Clicking a source whose results are already cached was closing the
   results dropdown. The outside-click handler treated the icon click
   as "outside" because the icon row lives above the input wrapper, not
   inside it. The icon click handler now calls stopPropagation so the
   document handler never runs. Also added an `#enh-source-row`
   whitelist to the search-page outside-click handler as a second
   layer of defense.

2. The icon chips used generic emojis (🎵, 🍎, 🎶, etc.) which don't
   convey brand identity. SOURCE_LABELS now carries a `logo` URL per
   source (mirroring the existing constants in core.js): the real
   Spotify / Apple Music / Deezer / Discogs / MusicBrainz / Hydrabase /
   Soulseek brand logos render as <img> inside the chip. Music Videos
   stays on emoji since the codebase has no YouTube-specific logo
   constant. renderSourceRow (Search page) and _gsSourceRowHtml (global
   widget) both honor the new field; loading state still overrides
   with an hourglass.

3. When Soulseek was selected, the icon row appeared clipped at the
   top of the page. Caused by the flex parent (.downloads-main-panel)
   compressing the row when .search-section.active competes for space
   with flex-grow:1. Added `flex-shrink: 0` + explicit `overflow-y: visible`
   on both .enh-source-row and .gsearch-source-row so the row keeps
   its natural height even under layout pressure. Logo <img> elements
   got explicit 22x22 / 18x18 containers so they render at chip scale
   without the inline font-size hack.
2026-04-23 17:28:15 -07:00
Broque Thomas
9ddfcf254f Global search widget: same source-picker icon row + per-source cache
Matches the Search page redesign so both surfaces behave identically.
The sidebar popover previously always fan-out-fetched all sources on
every keystroke (via _gsFetchSourceStream streaming NDJSON for every
alternate) and exposed a post-search tab bar to switch views.

Now:

- The popover renders an always-visible source icon row at the top, one
  icon per source (Spotify, iTunes, Deezer, Discogs, Hydrabase,
  MusicBrainz, Music Videos, Soulseek).
- Typing fetches only the currently-selected source. No fan-out.
- Clicking a different icon: cache hit -> instant re-render; cache miss
  -> single-source fetch + render.
- Per-query cache cleared on query change; cache dots on icons show
  which sources already have results for the current query.
- Default active source read from /api/settings (metadata.fallback_source)
  on first focus; falls back to Spotify.
- Fallback banner shown when the backend served a different source than
  the one clicked (rate-limit auto-fallback).
- Soulseek icon click navigates to /search with the query pre-filled,
  since the raw file list doesn't fit the popover. The Search page
  takes over rendering from there.

Gone: _gsFetchSourceStream (fan-out), _gsRenderTabs, _gsSwitchSource,
_gsState.altAbortCtrl, per-section _loading sets.
Added: _gsInitDefaultSource, _gsFetchSource, _gsFetchYouTubeVideos,
_gsSourceRowHtml, _gsFallbackBannerHtml, _gsSetActiveSource,
_gsNavigateToSearchPage.
2026-04-23 17:28:15 -07:00
Broque Thomas
a72810ce22 Search page: replace fan-out with source-picker icon row + per-source cache
The Search page previously fired a primary /api/enhanced-search request
plus a fan-out loop (_queueAlternateSourceFetches / _fetchAlternateSource)
that streamed NDJSON from /api/enhanced-search/source/<src> for every
other configured source. One search = 7 API calls across Spotify, iTunes,
Deezer, Discogs, Hydrabase, MusicBrainz, and YouTube Music Videos. The
post-search tab bar then let users switch views between the results that
had already been fetched.

This changes the default to explicit per-source selection:

- The old <select id="search-source-select"> dropdown and the
  <div id="enh-source-tabs"> post-search tab bar are replaced by a
  single always-visible icon row (#enh-source-row) above the search
  bar. One button per source, horizontal-scroll on narrow screens.
- Typing fetches only the currently-selected source. No fan-out.
- Clicking a different icon switches to that source and fetches it
  on demand, unless results for this query are already cached.
- Per-query cache (Map keyed by source) is cleared whenever the query
  changes; cached icons show a small dot, loading icons show a spinner.
- Soulseek is a first-class icon in the row — selecting it routes to
  the existing raw-file basic search, no change to that renderer.
- YouTube Music Videos is its own icon, still uses the NDJSON stream
  endpoint for incremental rendering.
- Default active icon reads metadata.fallback_source from /api/settings
  on init; falls back to Spotify.
- Rate-limit fallback (backend serves Deezer when Spotify is banned)
  surfaces as an amber banner above results plus an amber border on the
  clicked icon, so users understand why the returned results don't
  match the source they picked.

SOURCE_LABELS in shared-helpers.js gains an 'icon' field per source and
a new SOURCE_ORDER constant for the canonical picker order. The fan-out
functions (_queueAlternateSourceFetches, _fetchAlternateSource,
renderSourceTabs, window._switchEnhSourceTab) are gone.

Backend untouched — POST /api/enhanced-search already supported a
`source` param for single-source mode; we were just never using it by
default. Global widget redesign to match is the next commit.
2026-04-23 17:28:15 -07:00
Broque Thomas
1b3751598d Stop sidebar nav items bleeding through the sticky header
The sticky .sidebar-header had two layered issues that let nav items
show through it while the user scrolled the sidebar:

  - its background was a single linear-gradient of rgba() stops,
    starting at ~14% accent on transparent — the upper portion of the
    header was effectively translucent
  - .sidebar > * sets z-index 1 on every sidebar child, so the header
    and the nav buttons share a stacking level. Sticky alone doesn't
    lift the header; with equal z-index the nav wins on DOM order

Layer the existing accent gradient over a solid rgb(18, 18, 18) base
(visual unchanged, fully opaque), and bump the header to z-index 2 so
it paints above the nav buttons as they scroll under it.
2026-04-22 22:49:34 -07:00
Broque Thomas
20dfcf10a5 Bump artist-detail card size 25% (180→225px min, 150→190px on small screens) 2026-04-22 20:02:49 -07:00
Broque Thomas
59298b5b17 Restore big-photo album cards on artist-detail page
The standalone /artist-detail page rendered releases via createReleaseCard
in a stacked layout: square image on top, then title, then year, then a
completion bar — all inside a 300px-tall card with internal padding. The
inline Artists page (now retired) used a richer treatment: full-bleed
artwork with a dark gradient overlay and the title + year pinned at the
bottom. This commit brings that look to the standalone page.

Card markup (still .release-card so all the existing JS filter +
state hooks work, plus .album-card for the visual):

  <div class="release-card album-card" ...>
    <div class="album-card-image" data-bg-src="..."></div>
    <div class="completion-overlay [state]">
        <span class="completion-status">...</span>
    </div>
    <div class="album-card-content">
        <div class="album-card-name">title</div>
        <div class="album-card-year">year</div>
    </div>
    [optional .mb-card-icon]
  </div>

Image loads lazily via the existing observeLazyBackgrounds /
data-bg-src plumbing in core.js — call moved into populateRelease-
Section so each batch of new cards gets observed.

Completion overlay (top-right floating badge):
  - Library artists: 'Checking…' / '✓ Owned' / 'N/M' / 'X%' / 'Missing'
    based on release.owned + track_completion shape (existing logic
    preserved, just rendered as a badge instead of a bar).
  - Source artists (no library data): omitted entirely. The card just
    shows artwork + title + year, which is what the user asked for.

CSS: scoped overrides under #artist-detail-page .release-card.album-card
neutralize the old release-card background gradient, internal padding,
fixed 300px height, and flex column layout. Cards become aspect-ratio:1
square with overflow:hidden so the image fills and the gradient + text
sit on top.

Filter state (data-is-live / data-is-compilation / data-is-featured)
still tagged on each card so the Include filter group keeps working.

Smoke: library Kendrick Lamar should now look like the inline Artists
page used to — square cards, big artwork, name + year on the bottom.
Source-clicked artist (Schoolboy Q from Deezer) shows the same
visual without the completion overlay.
2026-04-22 19:53:56 -07:00
Broque Thomas
9106617538 Show collection bars + Top Tracks for source artists, upgrade source clicks to library when possible
Three issues from screenshots:

1. .collection-overview was hidden for source artists (CSS rule too
   aggressive). It actually renders fine — just shows 0/N "missing"
   for each release type, which is useful info. Removed from the hide
   rules.

2. #artist-hero-sidebar (Top Tracks "Popular on Last.fm") was also
   hidden. The renderer (_loadArtistTopTracks in library.js) already
   fetches by artist name via /api/artist/0/lastfm-top-tracks, so it
   works for source artists too. Removed from the hide rules.

3. Clicking a source-artist result for someone you ALREADY have in
   the library was loading the bare source view instead of the
   library view (bug). Backend now does a "library upgrade" lookup
   in get_artist_detail: when the direct ID lookup misses but a
   source param is provided, search the artists table by the source-
   specific ID column (deezer_id / spotify_artist_id / etc.). If a
   match exists, use that library PK and the rest of the library
   path runs normally — owned releases, enrichment, completion
   bars, all the goodies you'd see if you'd clicked from Library.
   Falls back to a name match within the active server, then to the
   source-only response if nothing matches.

The remaining library-only items (artist-enrichment-coverage, Radio
button, Enhance Quality button) stay hidden for source artists since
they all require owned tracks.
2026-04-22 19:19:48 -07:00
Broque Thomas
93f1941829 Unify artist detail: route source artists to standalone page, retire inline Artists page
Completes the artist-detail unification. Source artists now land on
the same /artist-detail page as library artists (with the source-aware
backend endpoint from earlier this session handling the data fetch).
The inline Artists page is gone — artists.js deleted, #artists-page
HTML block removed, /artists URL aliases to /search.

  Source-artist callsites re-migrated from selectArtistForDetail to
  navigateToArtistDetail (search results, global widget, download
  modal, Discover hero / Your Artists cards / artmap context / genre
  deep-dive, watchlist artist detail).

  Visual upgrade to standalone hero: added .artist-detail-hero-bg +
  .artist-detail-hero-overlay (blurred image bg, dark gradient — same
  treatment as the inline page). library.js sets the bg image when
  loading an artist.

  Library-only UI hidden via CSS for source artists (existing rules
  from the previous commit cover Enhanced toggle, Status filter,
  completion bars, enrichment coverage, Top Tracks sidebar, Radio /
  Enhance buttons).

  Final 2 helpers (lazyLoadArtistImages used by wishlist-tools,
  showCompletionError used by completion checker) moved from
  artists.js into shared-helpers.js. The inline-page candidate set
  was dropped from _resolveSimilarArtistsTargets.

  init.js: 'artists' alias added at top of navigateToPage (same
  pattern as the existing 'downloads' alias). 'case artists:' handler
  removed from loadPageData. _getPageFromPath now maps artist-detail
  to library as its parent (matches the existing nav highlight at
  init.js:2161).

  tests/test_script_split_integrity.py: artists.js removed from
  SPLIT_MODULES; KNOWN_CROSS_FILE_DUPES updated to point escapeHtml
  at shared-helpers.js instead of artists.js. 354/354 tests pass.

  Net delta: -1700 lines.

Stays at 2.39. Once you've verified end-to-end (library artist ->
hero looks like inline visual; source artist from Search -> same
page, similar artists works, no 404s; /artists URL -> /search), a
follow-up commit bumps to 2.40 with the full WHATS_NEW entry that's
already prepped.
2026-04-22 17:00:52 -07:00
Broque Thomas
6a76405444 Add Similar Artists to standalone /artist-detail page, hide library-only UI for source artists
First increment of the artist-detail unification redesign. Delivers
the two most-visible missing pieces for source artists without touching
the hero layout — that's a later commit.

Changes:
  - HTML: new #ad-similar-artists-section inside #artist-detail-main
    (scoped IDs with 'ad-' prefix so they don't collide with the inline
    Artists page, which has the same section using base IDs).
  - shared-helpers.js: similar-artists helpers (loadSimilarArtists +
    display/progressive/createBubble + lazy image loader) moved out of
    artists.js. New _resolveSimilarArtistsTargets() resolver picks
    whichever candidate set has a `.page.active` ancestor, so the same
    function works on both the inline Artists page and the standalone
    artist-detail page without caller changes.
  - library.js populateArtistDetailPage: sets
    document.body.dataset.artistSource = 'library' | 'source' before
    rendering, and fires loadSimilarArtists(artist.name) after
    populating the rest of the page.
  - style.css: body[data-artist-source='source'] rules hide
    library-only UI on the artist-detail page — Enhanced view toggle,
    Status (owned/missing) filter, completion bars, enrichment
    coverage, Top Tracks sidebar, Radio / Enhance Quality buttons,
    "X owned / Y missing" section-stats counts. CSS-only, additive,
    library artists completely unaffected.

Impact today:
  - Library artists: Similar Artists section now appears at the
    bottom of their detail page (previously only the inline Artists
    page had it). All other UI unchanged.
  - Source artists: still route to the inline Artists page (Part B
    reverted earlier this session). The standalone page is now
    source-ready infrastructure-wise, but source artists don't reach
    it yet. A later commit will re-migrate source callers to the
    standalone page once the hero rendering is also source-ready.

artists.js shrinks from 1903 -> 1584 lines (similar-artists block
extracted). shared-helpers.js grows correspondingly. 357/357 tests
still pass. No version bump — this is still 2.39 pending.
2026-04-22 16:19:25 -07:00
Broque Thomas
f203b3e46d Remove embedded Download Manager from Search page, bump to 2.44
Phase 3c of the Search/Artists unification. The Search page carried
a second copy of the Download Manager (active + finished queues,
clear/cancel-all buttons) that was hidden by default and duplicated
the dedicated Downloads page. That duplicate is now gone.

Removed:
  - Side-panel HTML block and the toggle button that showed/hid it
  - ~290 lines of polling + render infra in downloads.js: loadDownloads-
    Data, startDownloadPolling/stopDownloadPolling, updateDownload-
    Queues, renderQueue, updateTabCounts/updateDownloadStats,
    initializeDownloadTabs/switchDownloadTab, cancelDownloadItem,
    clearFinishedDownloads, cancelAllDownloads, and the
    activeDownloads/finishedDownloads globals
  - initializeDownloadManagerToggle and its call from init.js
  - Stopped hitting /api/downloads/status every second on the Search
    page (the dedicated Downloads page already polls its own view)

CSS grid for the Search page collapsed from '1fr 370px' to '1fr' now
that the right panel is gone. Unused .controls-panel__* / .download-
manager__* / .downloads-side-panel CSS rules kept in place — harmless,
can be pruned later.
2026-04-22 13:31:42 -07:00
Broque Thomas
68d46c5aba Replace Enhanced/Basic toggle with source picker, bump to 2.42
Phase 3 of the Search/Artists unification. The Search page's two-mode
toggle is replaced by a single 'Search from' dropdown: All sources
(Auto), Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz,
or Soulseek (raw files). Auto keeps today's fan-out behavior for
backwards compatibility; picking a specific source hits only that
provider. 'Soulseek' routes to the raw-file basic section, so one
picker covers both old modes. Loading text and the enhanced fetch
now respect the selected source. Zero API changes — uses the source
param added in 2.40 and the shared fetch helper from 2.41.
2026-04-22 13:06:13 -07:00
Broque Thomas
b9a7ed97be Add per-row cancel + Cancel All to downloads page, fix streaming cancel
Three closely-related changes bundled together. The UI work exposed the
backend bug when I tried to cancel a Deezer download and saw it marked
cancelled in the DB but continuing in the background.

Backend — cancel_task_v2 orchestrator dispatch fix:
  The slskd-specific cancel block was written back when soulseek_client
  was a raw SoulseekClient. It was later swapped to DownloadOrchestrator
  (which doesn't expose .base_url / ._make_request), so the first
  diagnostic log line crashed with AttributeError. The outer try/except
  swallowed it, leaving streaming downloads (YouTube / Tidal / Qobuz /
  HiFi / Deezer / Lidarr) running in the background after the user
  clicked cancel.

  Replaced the ~80-line block with a single
  soulseek_client.cancel_download(download_id, username, remove=True)
  call — the orchestrator's dispatch picks the right client by username,
  same path /api/downloads/cancel already uses successfully.

Per-row cancel button (fancy):
  Circular X button on .adl-row for rows in active or queued state.
  Hidden by default (opacity 0, translateX + scale), fades in + settles
  on .adl-row:hover with a cubic-bezier overshoot. Own :hover gives a
  1.12x scale pop and brighter red glow. Touch devices (@media
  (hover: none)) keep it visible.

  Backend: surfaced playlist_id in /api/downloads/all items so the
  frontend can hit cancel_task_v2 without a second lookup. Frontend:
  adlCancelRow(btnEl, playlistId, trackIndex) with double-click guard
  via data-cancelling + adl-row-cancel-pending class.

Cancel All header button:
  Red-themed button next to "Clear Completed". Only visible when any
  task is in downloading / searching / post_processing / queued state —
  auto-hides the moment the last one finishes. Confirm dialog shows
  "Cancel N tasks across M batches?". Iterates _adlBatches, calls
  /api/playlists/<batch_id>/cancel_batch sequentially (same endpoint
  each modal's "Cancel All" and the per-batch-card cancel use). Disables
  during the loop, mixed/success/error toast based on result.

All 276 tests pass.
2026-04-21 22:35:30 -07:00
Broque Thomas
d0ee9c73b3 Reveal download cancel button on hover instead of always showing
Cancel button on active download items was always visible, cluttering
the card. Now hidden by default and fades in when you hover the card
(or focus anything inside it, for keyboard a11y).

- opacity + pointer-events approach so layout doesn't jump on reveal
- 4px slide-in on reveal for a subtle entrance
- Touch devices (hover: none) keep the button always visible — no hover
  means no way to discover it otherwise
2026-04-21 21:12:06 -07:00
Broque Thomas
457763cbab Rebuild Discography Backfill: auto-wishlist, Fix All, section UI
Root-cause fix for "scanning 50 artists" then silence: when the master
repair worker was paused, force-run still kicked off _run_job but the
job's first wait_if_paused() blocked forever because is_paused was tied
to the master-enabled state. Force-run now bypasses master-pause —
scheduled runs still respect it.

Also fixes Fix All on discography findings doing nothing: the backend
bulk_fix_findings query had a fixable_types allowlist that excluded
missing_discography_track (and acoustid_mismatch). Added both.

Backfill job rebuild:
- auto_add_to_wishlist opt-in setting — creates findings AND pushes to
  wishlist during the scan
- 3-option fix dialog (Add to Wishlist / Just Clear / Cancel) on single
  Fix, Bulk Fix selection, and Fix All (page-level)
- Fix All "Just Clear" path uses the clear endpoint with job_id filter
  instead of the generic "may delete files" bulk-fix warning
- Batched in-memory matching using get_candidate_albums_for_artist +
  get_candidate_tracks_for_albums (same fast path the Library pages use)
- Rich album context per finding (id, name, album_type, release_date,
  images, artists, total_tracks) — flows through the wishlist pipeline
  so auto-processor classifies each track into the right cycle
  (albums vs singles) and post-processing gets correct folder/tags/art
- Per-artist progress logs [N/50] Scanning ArtistName
- Default interval 24h (was 168h); all release types default on; settings
  reordered with _section_* group headers (Core / Release Types /
  Content Filters)

Repair settings UI:
- Generic _section_<name> key convention renders as an uppercase group
  divider in the settings panel — any job can opt in
- .repair-setting-row gets a dashed bottom border so label↔toggle pairing
  is visually clear
- _prettifyRepairSettingKey fixes acronym capitalization (EPs, not Eps)

Version bumped to 2.36 with changelog entries.
2026-04-21 18:44:43 -07:00
Broque Thomas
891b18540b Fix Spotify Playlist Discovery: Fix button, header readability, [object Object]
Three separate issues reported on the Spotify Playlist Discovery modal:

1. Fix button fails with "Track data not found"
   openDiscoveryFixModal() branched on platform name to locate the discovery
   state but had no case for 'spotify_public'. Row rendering passes that
   platform value when the source is a Spotify public playlist, so state
   lookup failed and the toast fired. Added the spotify_public branch —
   state lives in youtubePlaylistStates alongside the other reused platforms.

2. Table header too transparent to read
   .youtube-discovery-modal .discovery-table th used rgba(255,255,255,0.1)
   as background (10% white) with white text, which lost contrast when the
   orange progress bar or varied row content scrolled underneath. Switched
   to near-solid dark rgba(17,17,20,0.96) with a brighter border-bottom
   and z-index:5 so the sticky header stacks cleanly above table content.

3. "[object Object]" in the matched-artist column for Wing-It tracks
   The Spotify Public Playlist row-transform joined result.spotify_data.artists
   directly with .join(', '). Wing-It stub metadata (built server-side by
   _build_discovery_wing_it_stub) returns artists as [{name: "..."}] —
   array of objects. .join() stringified each object to "[object Object]".
   Same pattern existed in three places in script.js; all now map objects
   to .name and filter empties before joining. Graceful fallback to "-"
   if the result is empty.

No existing tests touched. Full suite stays at 263 passed. Ruff clean.
2026-04-21 15:38:50 -07:00
Broque Thomas
0b4647ddd4 Add per-service config status indicators to Settings Connections tab
Adds green/yellow header gradient on each service card showing whether the
user has filled in credentials, plus an expand-triggered verification layer
that surfaces working-or-not status inline.

Backend (web_server.py):
- SERVICE_CONFIG_REGISTRY mapping each of the 11 services in Connections to
  its config requirements. Supports required-keys, always-green, any-of,
  and custom-check semantics (Tidal uses token-file check, Qobuz accepts
  either email/password OR cached auth token).
- _is_service_configured(service) — cheap config presence check, no APIs hit.
- GET /api/settings/config-status — returns {service: {configured}} for all
  services in one call. Drives the page-load gradient.
- POST /api/settings/verify — takes {services: [...]}, runs
  run_service_test per service, caches results 5 min in-memory, parallelizes
  with ThreadPoolExecutor(max_workers=3) to avoid self-rate-limiting. Query
  param ?force=true busts cache.
- Added verify branches for iTunes, Deezer, Discogs, Qobuz, Hydrabase in
  run_service_test (previously missing — these services couldn't be tested).

HTML (webui/index.html):
- data-service="..." on all 11 .stg-service containers so JS can map card
  to backend service name.

CSS (webui/static/style.css):
- .status-configured gradient (subtle green, left-to-transparent fade)
- .status-missing gradient (yellow, same shape)
- Spinner badge in header for .status-checking state
- "Testing connection…" status line style inside panel body
- Red warning bar style for verify failures at top of expanded panel
- Brand dot now glows always (was only glowing when expanded); hover and
  expand states intensify the glow progressively.

JS (webui/static/script.js):
- applyServiceStatusGradients() fetches config-status and applies
  green/yellow class per card. Called on Connections tab activate + after
  any settings save.
- _stgVerifyServices(services, {force}) — batch verify POST, tracks
  in-flight state, renders spinners/status lines/warnings per service.
- toggleStgService() fires single-service verify when a card is expanded
  (not on collapse). Skipped if a verify is already in flight for that
  service.
- toggleAllServiceAccordions() fires one batched verify for all 11 services
  when "Expand All" is clicked; skipped on "Collapse All".
- _stgRefreshAfterSave() — after settings save, refreshes gradient (cheap)
  and re-verifies only the cards the user currently has expanded (so
  freshly-edited credentials show their new verify result immediately,
  without re-pinging every service).

Failure UI: top-of-panel red warning bar with the error message (e.g.
"Discogs token rejected (HTTP 401)", "Hydrabase not connected…"). Removed
automatically on next successful verify.

No existing tests changed. Full suite stays at 263 passed. Ruff clean.
2026-04-21 14:24:41 -07:00
Broque Thomas
9c15d00128 Enrich downloads page cards with artwork, artist, album, and source badges
The downloads page previously showed only title and source per download.
Now shows album artwork thumbnail, artist name, album name, source badge,
and quality badge (after post-processing). All metadata comes from the
existing matched_downloads_context — no extra API calls needed.

Falls back gracefully to title-only display when context metadata is
not available (e.g. orphaned Soulseek transfers with no task mapping).
2026-04-20 22:18:20 -07:00
Broque Thomas
a23bce5966 Auto Wing It fallback for failed playlist discovery tracks
When playlist discovery fails to match a track on any metadata API,
instead of marking it "Not Found" and excluding it from downloads,
automatically build stub metadata from the raw source title/artist
and include it in the download queue. Soulseek searches with the
raw data, post-processing enhances whatever it can find.

All 7 discovery workers updated: YouTube, ListenBrainz, Tidal,
Deezer, Spotify Public, Beatport, and automated mirrored playlists.
Amber "Wing It" badge distinguishes stubs from real API matches.
Fix button still available so users can manually find a proper match.
Wing It stubs persist in DB for mirrored playlists and are
re-attempted on future discovery runs. Failed wing-it downloads
skip wishlist per-track (checked by wing_it_ ID prefix) so real
matched failures in the same batch still go to wishlist normally.
2026-04-19 16:05:50 -07:00
Broque Thomas
63230ae39c Add bottom padding to library pagination for scroll breathing room 2026-04-18 23:35:28 -07:00
Broque Thomas
3404812a1e Improve live log viewer — fix level filters, faster updates, add search
- Fix level filter showing nothing: now uses heuristic classification
  for print() output (error/traceback/failed→ERROR, warn→WARNING, etc.)
  in addition to exact logger format matching
- Speed up WebSocket updates from 2s to 0.5s polling
- Add search box with 300ms debounce — filters both initial load and live
- Use DocumentFragment for batch DOM appends (performance)
- Increase line cap from 1000 to 2000
- Backend search parameter support in /api/logs/tail
2026-04-18 23:13:51 -07:00
Broque Thomas
8b0e619fa1 Add live log viewer on Settings → Logs tab
Terminal-style real-time log viewer with:
- Log file selector (app, post-processing, acoustid, source reuse)
- Color-coded log levels (DEBUG gray, INFO blue, WARNING yellow, ERROR red)
- Level filter buttons (All/Debug/Info/Warn/Error)
- Auto-scroll with toggle, copy and clear buttons
- Live updates via WebSocket (2s polling, pushes new lines)
- Initial load fetches last 200 lines via REST API
- 1000-line display cap with oldest lines trimmed

Also fixes Advanced tab settings (Discovery Pool, Security, etc.) being
hidden inside collapsed Library Preferences section body — misplaced
closing div caused them to be invisible.
2026-04-18 22:57:15 -07:00
Broque Thomas
288776a7f3 Add genre whitelist for filtering junk tags during enrichment
New core/genre_filter.py with ~180 curated default genres. When strict
mode is enabled in Settings → Library Preferences → Genre Whitelist,
only whitelisted genres pass through during enrichment. Junk tags from
Last.fm (artist names, radio shows, playlist names) are silently dropped.

Applied at all 10 genre write points: Spotify, Last.fm, AudioDB, Deezer,
Discogs, iTunes, Qobuz enrichment workers + post-processing genre merge
+ initial download artist/album creation.

Strict mode is OFF by default — zero behavior change for existing users.
First enable auto-populates the whitelist with defaults. Users can add,
remove, search, and reset genres via the Settings UI.
2026-04-18 20:23:53 -07:00
Broque Thomas
b17a6e2dd7 Add per-artist metadata source override for watchlist scans
Users can now override which metadata provider (Spotify, Deezer, Apple Music,
Discogs) is used when scanning a specific watchlist artist for new releases.
The selector appears in the artist config modal and only shows sources the
artist has enrichment IDs for. Default behavior is unchanged — all artists
use the global metadata source unless explicitly overridden.
2026-04-18 16:05:05 -07:00
Broque Thomas
f4aaab8a66 Reorganize Settings Library tab with collapsible sections
Three collapsible categories, collapsed by default:
- Paths & Organization (file templates + music library paths)
- Post-Processing (metadata, tags, conversion, lyrics)
- Library Preferences (import, content filter, stats, playlists, M3U)

Section headers have data-stg=library so they only appear on the
Library tab. Bolder headers with accent-colored arrows and subtle
border. Collapse state preserved when switching settings tabs.
2026-04-18 08:28:12 -07:00
Broque Thomas
4f5025d526 Add MusicBrainz search tab, wider global search, bump to v2.32
New MusicBrainz tab in Enhanced and Global search — finds tracks and
albums on MusicBrainz's community database with Cover Art Archive
images. Covers obscure tracks that Spotify/Deezer/iTunes miss.

- core/musicbrainz_search.py: search adapter with Track/Artist/Album
  dataclasses, Cover Art Archive integration, smart query parsing
- Albums deduplicated (keeps best version with date and art)
- No artist results shown (MusicBrainz has no artist images)
- Album detail with full tracklist for download modal
- Smart word-boundary splitting for queries without separators
- Global search results container widened from 620px to 920px
- UI version bumped to 2.32
2026-04-18 02:06:27 -07:00
Broque Thomas
c009acdbb6 Add SoulSync Standalone toggle to Settings page
Fourth server option on the Connections tab with SoulSync logo and
'Standalone' label. Config panel shows Transfer folder path and
Verify Folder button. Test connection counts audio files in the
Transfer folder. Settings save/load properly detects soulsync toggle.
2026-04-17 20:56:05 -07:00
Broque Thomas
bbf5af1ce1 Fix auto-import rescan race condition, coverage penalty, and UI
Race condition: scanner re-scanned folders while post-processing was
still moving files, causing partial matches and ghost failures. Now
tracks in-progress paths and skips them on subsequent scans.

Coverage penalty fix: individual tracks that match at 80%+ confidence
now auto-import even when overall album coverage is low (e.g. 2 of 18
tracks present). Previously low coverage killed the entire import.

Import page: stats bar, filter pills, Scan Now, Approve All, Clear
History (clears imported + failed), live scan progress.
2026-04-17 19:37:42 -07:00
Broque Thomas
d2c6979ce4 Recursive staging scan, singles support, and improved import UI
Auto-import now scans the staging folder recursively — any folder
structure depth works (Artist/Album/tracks, Album/tracks, etc.).
Loose audio files are treated as singles with tag/filename/AcoustID
identification.

Import results UI redesigned:
- Click cards to expand per-track match details with confidence scores
- Shows identification method badge (Tags, Folder Name, AcoustID)
- Per-track grid: track name, matched filename, confidence percentage
- Time ago labels, folder path, better status badges
- Approve/Dismiss buttons use event.stopPropagation for clean UX
2026-04-17 17:48:00 -07:00
Broque Thomas
c20529dbb5 Fix auto-import toggle visual and import page refresh
Toggle appeared off when running because CSS :checked rules were
scoped to .repair-master-toggle. Added auto-import-toggle-label
selectors. Refresh now re-renders whichever tab is active.
2026-04-17 17:07:19 -07:00
Broque Thomas
168b4c21dd Fix batch panel collapse button not clickable when collapsed
Title text was pushing the toggle button out of the 44px collapsed
panel. Now hides the title and centers the button when collapsed.
2026-04-17 12:58:29 -07:00
Broque Thomas
9898bd1190 Add batch context panel to Downloads page
Split Downloads page into main list (left) and batch panel (right).
Each active batch gets a color-coded card with artwork thumbnail,
progress bar, per-track status with download percentages, and
expandable track list. Download rows get matching color indicators.

- Click batch name to open its download/wishlist modal
- Filter icon narrows main list to one batch with clear banner
- Collapsible panel toggle for full-width list view
- Completed batches fade out after 15 seconds
- 7-day batch history with source type color dots
- Artwork fallback shows colored initial when no art available
- Per-track progress: download %, spinner for searching, proc label
- source_page column on sync_history for UI origin tracking
- /api/downloads/all includes batch summaries and per-track progress
- /api/downloads/batch-history endpoint for history queries
- Responsive layout, overflow-x hidden to prevent scroll flicker
2026-04-17 12:18:00 -07:00
Broque Thomas
308773ea7c Add Auto-Import — background staging folder watcher with smart matching
Full auto-import pipeline: background worker watches the staging folder,
identifies music using embedded tags → folder name parsing → AcoustID
fingerprinting, matches files to metadata source tracklists, and
processes high-confidence matches through the existing post-processing
pipeline automatically.

Worker: AutoImportWorker with start/stop/pause/resume, configurable
scan interval (default 60s), confidence threshold (default 90%), and
auto-process toggle. Processes one folder per cycle, alphabetical
order. Disc folder detection, stability checking, content hash dedup.

Confidence gate: 90%+ auto-processes silently, 70-90% queued as
pending review with approve/dismiss actions, <70% flagged for manual
identification. Track matching uses weighted algorithm (title 45%,
artist 15%, track number 30%, album tag 10%).

Database: auto_import_history table tracks every scan result with
folder hash, match data JSON, confidence, status, timestamps.

API: 7 endpoints — status, toggle, settings (GET/POST), results
(filtered/paginated), approve, reject.

UI: Auto tab on Import page with enable toggle, confidence slider,
scan interval selector. Live result cards with album art, confidence
bar (green/yellow/red), status badges, match stats. 5-second polling.
2026-04-17 06:51:08 -07:00
Broque Thomas
922b350983 Show all server playlists with synced/unsynced visual separation
Server Playlists tab now displays ALL playlists from the media server,
split into two sections: Synced Playlists (with mirrored/history match,
full opacity, "Synced" badge, "Open Editor" action) and Other Server
Playlists (everything else, dimmed at 70%, "View Tracks" action). Both
sections have header with icon, title, and count. Unsynced cards fade
to full opacity on hover.
2026-04-16 22:19:20 -07:00
Broque Thomas
a867bba18f Bidirectional artist sync, repair jobs grid, deezer column fix
Artist Sync button on enhanced library page now does true bidirectional
sync: Phase 1 pulls new albums/tracks from the media server using the
DatabaseUpdateWorker in deep scan mode (preserves enrichment), Phase 2
removes stale DB entries for files no longer on disk. Works for Plex,
Jellyfin, and Navidrome. Toast shows +albums, +tracks, -stale counts.

Repair jobs tab redesigned: 2-column grid layout with glass gradient
cards, accent top line on hover, hover lift effect, job description
text below name, running state with pulsing accent bar. Responsive
to single column under 900px.

Fixed deezer_artist_id → deezer_id column name on artists table lookup.
2026-04-16 19:13:03 -07:00
Broque Thomas
f59c564382 Wishlist Nebula — expanded view redesign, live processing, download flow
Expanded albums: click album tile to reveal track list with per-track
remove buttons. Art shrinks to banner, title/count move to static
position, tracks scroll at 200px max-height. Handles many albums with
flex-wrap and align-items: flex-start.

Expanded singles: visible labels below 44px art circles, remove button
on hover. No longer tooltip-only.

Live processing: polls wishlist stats every 5s, detects auto-processing
and manual download batches. Orbs pulse with accent glow during active
processing. Nebula auto-refreshes when tracks complete (count decreases).
Polling stops on page navigation.

Download flow: single "Download Wishlist" button opens category choice
dialog (Albums/Singles with counts). If processing is already active,
shows toast or reopens existing download modal. Toned down processing
pulse animation (3s cycle, scale 1.03, brightness 1.1).
2026-04-16 17:09:25 -07:00
Broque Thomas
16ad6184ed Wishlist Nebula enhancements — artist photos, art ring, animations
Eight visual upgrades to the wishlist nebula:

1. Watchlist artist photos used for orb images (cross-referenced via API)
2. Hover tooltip with artist name and track count
3. Pulse animation on orbs when their category is next in auto-processing
4. Album art ring — tiny covers orbit each orb in a slow spinning ring
5. Staggered entry animation — orbs fade in and float up on page load
6. Click artist name to navigate to Artists page with search pre-filled
7. Improved label styling with accent color hover + underline
8. Responsive art ring sizing per orb size class
2026-04-16 16:33:16 -07:00
Broque Thomas
dd26437125 Wishlist Nebula — artist orb visualization replacing category cards
Bespoke wishlist page design: each artist is a glowing orb sized by
track count (sm/md/lg), with a spinning conic gradient ring colored by
artist name hash. Click to expand — albums appear as satellite rows
with cover art, singles as compact pills. Remove buttons on hover at
every level (album, single track).

Search bar filters orbs in real-time. Download Albums/Singles buttons
trigger the existing category download flow. Orbs sorted by track count
(biggest artists first). Responsive layout with mobile breakpoints.
2026-04-16 16:24:56 -07:00
Broque Thomas
baed8ed8b6 Fix worker orb tooltips rendering behind dashboard content
Added z-index: 10 to .dashboard-header to elevate its stacking context
above the dashboard content sections. Bumped all 13 worker orb tooltip
z-index values from 1000 to 5000. The tooltips now render above section
headers when they drop downward from the header into the content area.
2026-04-16 14:23:38 -07:00
Broque Thomas
85b470809e Add automation group management — rename, delete, bulk toggle, drag-drop
Full automation page upgrade with group management and drag-and-drop:

Backend: batch_update_group() and bulk_set_enabled() DB methods, new
PUT /api/automations/group and POST /api/automations/bulk-toggle endpoints.

Group headers: rename (inline edit), delete (choice dialog — keep
automations or delete all), bulk toggle (enable/disable all in group).
Actions appear on hover, styled as small icon buttons.

Drag and drop: non-system cards are draggable between group sections.
Drop zones show dashed accent border feedback. Collapsed sections
auto-expand on 500ms drag-hover. System/Hub sections dimmed during drag.
dragenter counter pattern handles child element bubbling.

Delete group dialog: glass card modal with three options — keep
automations (move to My Automations), delete everything, or cancel.
2026-04-16 14:19:13 -07:00
Broque Thomas
4c9bab356d Fix dashboard header layout — center worker orbs, reposition quick-nav
Worker orbs now centered across the full header width instead of
right-aligned. Watchlist/Wishlist buttons moved to absolute top-right
corner in their own container, preventing tooltip overflow from pushing
them to a second line. Import button removed from header (accessible
via sidebar).

Responsive: at 900px quick-nav drops to static full-width row, worker
orb tooltips hidden on mobile (status visible via orb color/spinner,
details on Tools page). At 768px everything stacks vertically.
2026-04-16 12:36:12 -07:00
Broque Thomas
d9b4e5b853 Add smart Library Status card to Dashboard with deep scan support
Adaptive card on the Dashboard showing library state with four modes:
- No server: gold accent, directs to Settings
- Disconnected: gold warning with troubleshooting guidance
- Empty library: blue accent with prominent Scan Now button
- Healthy: green accent with stats grid (artists/albums/tracks/DB size),
  Refresh button (incremental) and Deep Scan button (full re-check)

Stats displayed as mini cards with individual icons. Animated glow orb,
gradient accent top line, shimmer progress bar during scans. Deep scan
added to /api/database/update endpoint (deep_scan flag) — re-checks
every track, adds new ones, removes stale, preserves enrichment data.
Confirmation dialog explains what deep scan does before starting.
2026-04-16 11:45:29 -07:00
Broque Thomas
60d737f7ab Add Tools sidebar page with grouped layout and Library Maintenance hero
Dashboard Tools & Operations section replaced with a compact link card.
All 10 tool cards moved to a dedicated Tools page in the sidebar, grouped
into three sections: Database & Scanning, Metadata & Cache, Management.

Library Maintenance promoted to hero position at the top of the page with
accent top bar, logo, enable toggle, and tabbed content (Jobs, Findings,
History) — no longer buried in a modal. openRepairModal() now navigates
to the Tools page. Repair modal HTML removed.

Tool initialization extracted from loadDashboardData() into a dedicated
initializeToolsPage() with idempotent event listener wiring. Container
sizing updated to use margin: 20px (matching Dashboard/Stats) instead of
max-width: 1400px for consistent full-width appearance across all pages.
2026-04-16 08:11:18 -07:00
Broque Thomas
cf18590794 Promote Watchlist and Wishlist from modals to full sidebar pages
Watchlist and Wishlist are now proper sidebar pages with full design
treatment matching the app's established visual language — glass
containers, gradient headers, accent lines, card hover effects.

Watchlist page: artist grid with sort (name/scan date/date added),
search filter, last scan summary strip, live scan activity, batch
selection, all existing sub-modals (artist config, global settings,
artist detail slideout) preserved and working.

Wishlist page: stats strip (album count, singles count, next cycle),
category cards with mosaic backgrounds, track list with inline search
filter, batch operations, download integration. Auto-processing
detection on header button shows download progress modal when active.

Header buttons rewired to navigate to pages. All refresh points updated
to reinitialize pages instead of reopening modals. Timer/polling cleanup
on page navigation. Artist detail overlay converted to fixed positioning.
2026-04-15 23:00:17 -07:00
Broque Thomas
251c27e006 Add Last.fm Track Radio to Discover page
Adds a new Last.fm Radio section to the Discover page that lets users
search a track on Last.fm, generate a similar-tracks playlist, and run
it through the existing discovery/download/sync pipeline. Also generates
playlists automatically from top listening history during watchlist scans
(max once per week).

- core/lastfm_client.py: Add get_similar_tracks() using track.getsimilar
- core/listenbrainz_manager.py: Add save_lastfm_radio_playlist() with
  deterministic MBID (MD5 seed), cleanup limit of 5 for lastfm_radio type
- web_server.py: Add /api/lastfm/configured, /api/lastfm/search/tracks,
  /api/lastfm/radio/generate, /api/discover/listenbrainz/lastfm-radio;
  fix playlist['name'] KeyError in discovery worker that was resetting
  phase back to 'fresh' after completion
- core/watchlist_scanner.py: Add _generate_lastfm_radio_playlists() with
  weekly throttle, called at end of scan_all_watchlist_artists()
- webui/index.html: Add #lastfm-radio-section above ListenBrainz section,
  hidden unless Last.fm API key is configured
- webui/static/script.js: Search/generation/card-load functions; fix
  discovery modal labels (Last.fm Radio vs ListenBrainz), description
  update on completion, belt-and-suspenders completion handling inside
  updateYouTubeDiscoveryModal; fix album/duration display for tracks
  without metadata; music note SVG placeholder for missing art
- webui/static/style.css: Styles for search bar, dropdown, result rows
2026-04-14 23:41:36 -07:00
Broque Thomas
1475e3882c Reposition mini player to sit left of bell/help buttons at same baseline 2026-04-14 20:08:20 -07:00
Broque Thomas
76ff98261b Move media player from sidebar to floating bottom-right mini player
The sidebar player was a poor use of vertical real estate and created the
collapsed-state layout issues. The mini player is now a fixed 360px widget
at bottom-right (above the bell/help buttons), matching the convention of
most streaming apps.

Changes:
- Removed media player from sidebar; sidebar spacer now pushes support/version
  section to bottom as before
- New .mini-player-body horizontal layout: album art | track info | controls
- Added prev/next skip buttons (mini-nav-btn) with same skip logic as the
  Now Playing modal; updateNpPrevNextButtons() now syncs both sets
- .media-player.idle now display:none (widget hides entirely when no track)
- Progress bar is a flush full-width line at the top of the widget
- Volume slider kept as hidden DOM element for JS compatibility; volume is
  set via the Now Playing modal
- Toast container moved up to bottom:174px to stay above the mini player
- expand-hint button updated to four-corner expand icon, opens NP modal
- All volumeSlider and click-exclusion references updated for null-safety
2026-04-14 19:50:18 -07:00
Broque Thomas
ce129010e1 Add ReplayGain analysis and tagging support
New core/replaygain.py module uses FFmpeg's ebur128 filter (already a
project dependency) to analyze integrated loudness and true peak, then
writes ReplayGain 2.0 tags (-18 LUFS reference) to MP3 (TXXX frames),
FLAC/OGG/Opus (Vorbis comments), and M4A/MP4 (freeform atoms).

Three analysis modes in the enhanced library view:
- Per-track RG button: synchronous single-track analysis (~1-3 s)
- Album "ReplayGain" button: background job writing both track gain
  and album gain (mean LUFS across all album tracks) to every file
- Bulk bar "ReplayGain" button: batch track-gain for selected tracks

read_file_tags() in tag_writer.py extended with four new optional keys
(replaygain_track_gain/_peak, replaygain_album_gain/_peak) so existing
RG values surface in the tag-preview diff view. Purely additive — no
existing endpoints or DB schema changed.
2026-04-14 19:09:25 -07:00
Broque Thomas
ff5684bded Add configurable sources for Your Artists section on Discover page
Gear button next to View All opens a sources modal letting users pick
which connected services (Spotify, Tidal, Last.fm, Deezer) contribute
artists to the Your Artists carousel. Setting saved via standard
/api/settings endpoint under discover.your_artists_sources.

- GET /api/discover/your-artists/sources returns enabled config + which
  services are currently connected
- _fetch_and_match_liked_artists skips sources not in the enabled list
- Disconnected services shown dimmed and non-interactive in modal
- Saving with nothing selected blocked with error toast
- Remove z-index from .sidebar-header (fixes artist map overlap)
- Add padding-bottom to #automations-list-view (search bar overlap fix)
2026-04-13 13:39:36 -07:00
Broque Thomas
d210c2311f Add bottom padding to automations list view
Add a 10vh bottom padding rule for #automations-list-view in webui/static/style.css to provide extra spacing at the bottom of the automations list and prevent content from being obscured by fixed UI elements (e.g., footer).
2026-04-13 10:14:58 -07:00
Broque Thomas
c77cf25912 Fix Settings page content clipped by global search bar
Add padding-bottom: 10vh to #settings-page .settings-content so the
bottom section is not obscured by the floating search bar overlay.

Closes #292 (item 3)
2026-04-13 10:11:43 -07:00
Broque Thomas
82a621ff05 Use anchors for artist badges and update styles
Replace div badges with data-url/onclick handlers by semantic <a> elements (with href, target="_blank" and rel="noopener noreferrer") for clickable artist badges, keeping non-clickable badges as divs. Update CSS to target .artist-hero-badge and unify hover/image rules instead of relying on data-url attribute, preserving visual behavior and removing pointer cursor for non-clickable divs. Also remove rendering of the server_source badge from the artist meta panel. These changes improve accessibility, security, and maintainability of badge markup and styling.
2026-04-13 10:10:41 -07:00
Broque Thomas
26b2ca60fc Bump to v2.3, sticky sidebar header, compact idle player, Plex fixes
Version bump to 2.3 with rewritten What's New modal covering all
changes since v2.2. Docker publish workflow default updated.

Sidebar improvements:
- Header stays pinned at top while nav and player scroll beneath it
- Media player collapses to compact single-line when no track is
  playing, expands to full size when playback starts

Fixes:
- Server playlists endpoint Plex Tag object crash (getattr fix)
- Server playlists tab auto-refreshes after download completion
- Fixed dead code syntax error in archived version notes
2026-04-12 14:43:18 -07:00
Broque Thomas
48eff57ed0 Fix sync tab content clipping — enable vertical scrolling
sync-tab-content had overflow:hidden which clipped long content like
the file import preview table and server playlist editor. Changed to
overflow-y:auto so all sync tabs scroll when content exceeds the
container height.
2026-04-12 12:06:29 -07:00
Broque Thomas
7798f56885 Add centralized Downloads page with live status across all sources
New sidebar page showing every download task across the app in a unified
live-updating list. Tracks from Sync, Discover, Artists, Search, and
Wishlist all appear in one place.

Features:
- Filter pills: All / Active / Queued / Completed / Failed
- Section headers grouping by status category
- Track position (3 of 19) for album/playlist batches
- Album art, artist/album metadata, batch context, error messages
- Status dots with accent glow for active, green for complete, red fail
- Clear Completed button removes terminal items from tracker
- Nav badge shows active download count from any page via WebSocket

Fixes artist [object Object] display — handles all format variations
(list of dicts, list of strings, dict, string) for artist and album
fields in the API response.
2026-04-11 23:58:22 -07:00
Broque Thomas
d39db04ac0 Standardize Discogs worker hover tooltip to match other enrichment buttons 2026-04-11 17:40:41 -07:00
Broque Thomas
1dcdccb282 Fix music video download in global search and improve progress visibility
- Moved _downloadMusicVideo to top-level scope so global search can use
  it (was inside enhanced search conditional that only runs on downloads page)
- Global search video cards use base64 data attributes to avoid JSON
  escaping issues in onclick handlers
- Darkened thumbnail overlay during download for better progress visibility
- Larger progress ring (52px) with accent-colored glow shadow
2026-04-10 23:23:35 -07:00
Broque Thomas
54b7a0f0e8 Add music video download with progress and metadata matching
Click any video card in Music Videos tab to download. Flow:
1. Search primary metadata source for clean artist/title
2. Fall back to YouTube title parsing if no match
3. Download video via yt-dlp (best quality MP4)
4. Save to configured Music Videos folder as Artist/Title-video.mp4

UI shows circular progress ring on the thumbnail during download,
green checkmark on completion, red X on error (clickable to retry).
Cards are non-interactive while downloading.

Backend: /api/music-video/download and /api/music-video/status endpoints
YouTube client: download_music_video() method keeps video format
2026-04-10 23:14:20 -07:00
Broque Thomas
b44bb34b44 Add Music Videos search tab to enhanced and global search
New "Music Videos" pill tab alongside Spotify/Deezer/iTunes/Discogs
in both enhanced search and global search. Searches YouTube via yt-dlp
and displays results in a video card grid with 16:9 thumbnails, play
overlay, duration badge, channel name, and view count.

- Backend: /api/enhanced-search/source/youtube_videos endpoint with
  search_videos() method on YouTubeClient returning YouTubeSearchResult
- Frontend: Video grid layout with responsive cards, YouTube red tab
  color, proper section hiding when switching between metadata and
  video tabs
- Global search: Full parity with enhanced search video rendering
- No download functionality yet — display only
2026-04-10 23:07:14 -07:00
Broque Thomas
e83ee23990 Add Reduce Visual Effects toggle for low-end devices
New toggle in Settings > Appearance disables backdrop blur (220
instances), animations (238), transitions (961), and box shadows
(804) across the entire UI via a single body class. Significantly
reduces GPU/CPU usage on low-end devices. Default off — no change
for existing users. Applied from localStorage on load to prevent
flash.
2026-04-10 06:34:21 -07:00
Broque Thomas
e65b6bab67 Add metadata source filter to library and fix Discogs enrichment
Library page: new dropdown filter to show artists matched or unmatched
to any metadata source (Spotify, MusicBrainz, Deezer, Discogs, etc).
Select "No Discogs" to find artists needing manual Discogs matching.
Filter applied as WHERE clause on the source ID columns.

Discogs enrichment: added to valid_services whitelist, _enrichment_locks,
and _run_single_enrichment handler. The Enrich button was returning an
error when Discogs was selected from the dropdown.
2026-04-07 09:29:48 -07:00
Broque Thomas
410bddd102 Redesign download history with collapsible entries and full provenance
Entries are now compact cards that expand on click to reveal source
details. Shows expected vs downloaded title/artist with red mismatch
highlighting. Source artist column added to DB. Streaming track IDs
extracted from the id||name filename pattern. File and ID always on
their own line to avoid edge-case misplacement.
2026-04-06 19:33:02 -07:00
Broque Thomas
f8fbcb507c Add source provenance and AcoustID result to download history
Track original source filename, track ID, and AcoustID verification
result for every download. Helps debug wrong-file downloads from
streaming sources like Tidal. Each column migrated independently
for crash safety. Frontend shows source detail line and color-coded
AcoustID badge per entry. Button renamed to "Download History".
2026-04-06 16:18:58 -07:00
Broque Thomas
afd5125262 Add collapsible accordion UI to Settings Connections tab
Visual overhaul of the API Configuration section: each service frame
is now a collapsible accordion with brand-colored dots, chevron
indicators, and smooth expand/collapse animations. Includes an
Expand All / Collapse All toggle. No functional changes — all element
IDs, save/load logic, and tab switching preserved.
2026-04-06 12:39:05 -07:00
Broque Thomas
a2b9e32d04 Add download source tracking to library history modal
New download_source column on library_history table records which source
(Soulseek, Tidal, Qobuz, HiFi, YouTube, Deezer) each track was downloaded
from. Extracted from context username during post-processing.

Frontend shows source badge alongside quality badge on each download entry.
Source breakdown bar below tabs shows per-source totals with color-coded
chips (e.g., "Soulseek: 847 | Tidal: 203"). Includes DB migration for
existing installs. Existing entries show quality only (source is NULL).
2026-04-05 18:03:46 -07:00
Broque Thomas
7a24431e46 Redesign watchlist linked artist section with per-source match controls
Replaced single "Change" button with per-source rows showing match
status for each provider (Spotify, Apple Music, Deezer, Discogs).
Each row has Fix/Match button that searches that specific source API,
plus clear button to remove individual matches.

- Per-source search uses _search_service (same as enrichment modal)
- Backend: added Discogs to valid providers, empty ID clears match
- Fixed provider validation to accept 'discogs' alongside others
- Clear sets DB column to NULL instead of rejecting empty string
2026-04-05 14:20:35 -07:00
Broque Thomas
8ba1585646 Paginate wishlist modal to prevent browser crash on large wishlists (5K+)
Initial load fetches 200 tracks instead of all. "Load More" button at
bottom shows (200 of 5,000) and loads next batch on click. Backend now
returns total count alongside limited results for both album and singles
categories. Rendering logic unchanged — just operates on smaller sets.
2026-04-05 13:15:24 -07:00
Broque Thomas
9f7fe27e7c Add Playlist Pipeline automation + wishlist badges + Discogs enrichment modal + hub cleanup
Playlist Pipeline — single automation that runs the full playlist lifecycle:
refresh → discover → sync → download missing. Replaces 4-automation signal
chains. Phase-aware progress display (Phase 1/4, 2/4, etc.), guard function
prevents concurrent runs, fire-and-forget wishlist at end. Re-sync loop
catches newly downloaded tracks on next scheduled run.

- New action type 'playlist_pipeline' with handler, blocks endpoint config,
  builder UI (playlist select, process all, skip wishlist checkboxes),
  help modal, result display map, and Hub template
- Removed 3 redundant Hub templates (Release Radar, Discovery Weekly,
  Playlist Auto-Sync) — all replaced by the pipeline
- Fixed sync completion polling (status is 'finished' not 'complete')
- Fixed refresh handler progress hijack (null out _automation_id)
- Fixed matched_tracks field access from sync_states result

Also in this commit:
- Wishlist badges on enhanced search and global search tracks (amber)
- Discogs added to manual enrichment modal search + artist/album dropdowns
- Profile PIN forgot recovery on profile selection dialog
2026-04-05 11:34:50 -07:00
Broque Thomas
58ee8d8a8a Fix cover.jpg not using Cover Art Archive during wishlist processing
cover.jpg was always written from Spotify/iTunes URL (640x640) when the
first track in an album reached _download_cover_art before MusicBrainz
lookup completed. Later tracks with MBID skipped because file existed.

Fix: when cover.jpg exists but is small (<200KB) and we now have a CAA
MBID, attempt to upgrade it with the high-res CAA version. If CAA fetch
fails, keep existing cover — no pointless overwrites.

Also adds Forgot PIN recovery to the profile selection PIN dialog,
reusing the same credential verification flow as the launch lock screen.
Backend reset endpoint now accepts profile_id parameter.
2026-04-04 21:40:21 -07:00
Broque Thomas
48a9de8861 Artist Map: image proxy, caching, on-the-fly explorer, genre uncap, dated changelogs
- Image proxy endpoint (/api/image-proxy) for canvas CORS — allowlisted CDNs,
  browser-like UA for Deezer, 24h cache headers. Direct CORS first, proxy fallback.
- Server-side 5-min cache on all artist map endpoints with auto-invalidation
  on watchlist add/remove, scan complete, and new MusicMap discoveries.
- Explorer fetches similar artists from MusicMap on-the-fly when none stored,
  saves to DB for instant future visits. Validates artist names against
  Spotify/iTunes API before loading map — rejects gibberish with 404.
- Genre map per-genre cap removed (was 300 backend, 400 frontend).
- Center node in Explorer uses type 'center' not 'watchlist' — no longer
  misidentified as a watchlist artist.
- Error overlay auto-dismisses after 2.5s and returns to Discover page.
- Helper What's New restructured with dated sections (April 4/3/2/1, March),
  trimmed from ~80 to ~38 entries, date headers styled as purple dividers.
- Version modal updated with Artist Map section and recent fixes.
2026-04-04 21:18:08 -07:00
Broque Thomas
f348b6a7cb Add Artist Map Hub section below hero slider
Full-width section with three mode cards: Watchlist (existing), Genres
(placeholder), and Explorer (placeholder). Dark gradient background
with purple/blue ambient glow, animated dot grid overlay, and polished
card hover effects. Replaces the old small hero button.

Responsive: 3-column desktop, 1-column mobile.
2026-04-04 12:25:19 -07:00
Broque Thomas
36e83e64ee Redesign Artist Map toolbar + shortcuts key + fixes
Toolbar redesigned: three-section layout (back+brand / centered search
with icon / tool buttons). Brand icon in accent purple. Zoom buttons in
compact pill group. Tool labels visible on desktop, icon-only on mobile.

Added keyboard shortcuts modal (keyboard icon button): lists all 10
shortcuts with styled kbd elements.

Fixes:
- Mouse wheel zoom min matched to button zoom min (0.02)
- Right-click no longer triggers left-click info modal (button filter)
- Removed external "Open on Spotify" link from context menu
- Search results dropdown centered under search bar
2026-04-04 11:56:37 -07:00
Broque Thomas
52894d3c65 Artist Map polish: keyboard shortcuts, context menu, filter toggle
- Keyboard: Escape close, +/- zoom, F fit, S search, H toggle similar
- Right-click context menu: Artist Info, View Discography, Watchlist,
  Open on Spotify — glass-style with auto-close
- Filter toggle button + H key: hide/show similar artists for clean
  watchlist-only overview
- Removed node dragging (caused visual desync with offscreen buffer)
- Better loading progress text
- Cleanup: keyboard handler removed on close, context menu hidden
2026-04-04 11:41:23 -07:00
Broque Thomas
d349754a93 Artist Map: cache backfill, constellation effects, related artists, polish
- Metadata cache backfill: batch-lookup all node names across all sources
  to fill missing IDs, images, and genres
- Source-aware navigation: View Discography passes correct source to
  artist page so non-active-source artists load correctly
- Constellation hover effect: 800ms delay, fade in/out animation, dim
  overlay with glowing connection lines to related artists
- Click ripple animation on node selection
- Related artists list in info modal with clickable navigation
- Rich tooltip with artist photo, name, genres on hover
- Removed node dragging (caused visual desync with offscreen buffer)
- Performance: cached constellation lookups, lighter cache query
  (no raw_json), canvas.width for proper DPR overlay coverage
2026-04-04 11:31:25 -07:00
Broque Thomas
cfac226eed Add Artist Map — force-directed constellation of watchlist + similar artists
Visual canvas map on Discover page showing watchlist artists as large
anchor bubbles surrounded by their similar artists, sized by relevance.

Layout: golden angle spiral for watchlist nodes with push-apart guarantee,
spiral packing with spatial grid collision detection for similar artists.
Offscreen buffer rendering for smooth pan/zoom (single drawImage blit).

Features:
- 320px watchlist bubbles, similar sized 25-55% by rank/occurrence
- Search bar with instant filter + animated zoom-to-node
- Tooltip with artist photo, name, genre tags on hover
- Touch support: single finger pan, pinch zoom, tap to click
- Zoom +/- buttons and fit-to-screen with smooth 250ms animation
- Click opens artist info modal (same as Your Artists)
- Loading overlay with image count progress
- Async image loading via createImageBitmap (non-blocking)
2026-04-04 10:22:15 -07:00
Broque Thomas
58d8e830c6 Your Artists on Discover + Deezer OAuth + MB Lookups Manager + Explorer improvements + bug fixes
YOUR ARTISTS (major feature):
- Aggregates liked/followed artists from Spotify, Tidal, Last.fm, Deezer
- Matches to ALL metadata sources (Spotify, iTunes, Deezer, Discogs)
- DB-first matching: library → watchlist → cache → API search (capped)
- Image backfill from Spotify API for artists missing artwork
- Carousel on Discover page with 20 random matched artists
- View All modal with search, source filters, sort, pagination
- Artist info modal: hero image, matched source badges, genres, bio,
  listeners/plays from Last.fm, watchlist toggle, view discography
- Auto-refresh with loading state on first load, polls until ready
- Deduplication by normalized name across all services

DEEZER OAUTH:
- Full OAuth flow: /auth/deezer + /deezer/callback
- Settings UI on Connections tab (App ID, Secret, Redirect URI)
- Token stored encrypted, auto-included in API calls
- get_user_favorite_artists() for liked artists pool

SERVICE CLIENTS:
- Spotify: added user-follow-read scope + get_followed_artists()
- Tidal: get_favorite_artists() with V2/V1 fallback
- Last.fm: get_authenticated_username() + get_user_top_artists()

FAILED MB LOOKUPS MANAGER:
- Manage button on Cache Health modal
- Browse/filter/search all failed MusicBrainz lookups
- Search MusicBrainz directly and manually match entries
- Optimized cache health queries (11 → 4 consolidated)
- Dashboard cache stats now poll every 15s

EXPLORER IMPROVEMENTS:
- Discover button on undiscovered playlist cards
- Status badges: explored/wishlisted/downloaded/ready
- Auto-refresh during discovery via polling
- Redesigned controls: prominent Explore button, icons

BUG FIXES:
- Fix album artist splitting on collab albums (collab mode fed
  album-level artists instead of per-track)
- Fix cover.jpg not moving during library reorganize (post-pass sweep)
- Fix cover.jpg missing when album detection takes fallback path
- Fix wishlist auto-processing toast spam (was firing every 2s)
- Fix media player collapsing on short viewports
- Fix watchlist rate limiting (~90% fewer API calls)
- Configurable spotify.min_api_interval setting
- Better Retry-After header extraction
- Encrypt Last.fm and Discogs credentials at rest
- Add $discnum template variable (unpadded disc number)
2026-04-03 22:39:05 -07:00
Broque Thomas
96f80753fa Fix media player collapsing in sidebar on short viewports and mobile
Added min-height and flex-shrink: 0 to prevent the sidebar flex layout
from compressing the player to zero height. Desktop 120px, tablet 100px,
phone 90px. The sidebar-spacer absorbs compression instead.
2026-04-03 13:51:18 -07:00
Broque Thomas
95b1665e19 Redesign Explorer controls — prominent Explore button, icons, polish
Explore button moved to its own row below playlist cards with gradient,
search icon, and "Explore Selected Playlist" label. Impossible to miss.

Mode toggle redesigned as pill segmented control with grid/list icons.
Action bar buttons get inline SVG icons (checkmark, square, heart).
Primary buttons use gradient + glow shadow treatment.

Build hint shows "Select a playlist above, then explore" → updates to
"Ready: [name]" when playlist selected. Discovery poller refreshes
cards every 5s while active. Button re-enables as "Open" after modal
launch so user can reopen closed discovery modal.
2026-04-03 13:11:23 -07:00
Broque Thomas
8b58434c17 Explorer improvements: discover from Explorer, status badges, auto-refresh
1. Discover button on undiscovered playlist cards — triggers discovery
   directly from Explorer instead of redirecting to Sync page. Button
   changes to "Open" to reopen modal after closing.

2. Status badges on playlist cards: checkmark (in library), heart
   (wishlisted), star (fully discovered), percentage (needs discovery).
   Meta line shows "N in library · M wishlisted" counts.

3. Auto-refresh: polls every 5s during active discovery to update cards.
   WebSocket listener for discovery:progress events. Cards refresh when
   discovery completes.

4. Explored tracking: playlists get green checkmark badge after tree is
   built (session-only, resets on reload).

Backend: new get_mirrored_playlist_status_counts with fail-safe design —
core discovery counts use simple reliable queries, library/wishlist
counts are best-effort extras that won't break discovery detection.

Card layout redesigned: badges inline with playlist name, discover
button below meta text, no more absolute positioning overlaps.
2026-04-03 13:00:04 -07:00
Broque Thomas
93fb082172 Add Failed MB Lookups manager + optimize cache performance
New feature: Failed MusicBrainz Lookups management modal accessible
from Cache Health. Browse all failed lookups with type filter tabs,
search bar, pagination. Click any entry to search MusicBrainz and
manually match — saves MBID at 100% confidence. Clear individual
entries or bulk clear all.

Backend: 4 new endpoints — failed-mb-lookups list, mb-entry delete,
musicbrainz/search (artist/release/recording), mb-match save.

Performance: Cache health stats consolidated from 11 queries to 4
using CASE expressions. Added partial index on musicbrainz_cache for
failed lookups. Dashboard cache stats now poll every 15s instead of
single fire-and-forget fetch. Failed MB type counts cached on frontend,
only re-fetched after mutations.

Also includes: library reorganize now moves cover.jpg via post-pass
sidecar sweep, and changelog updates.
2026-04-03 11:38:50 -07:00
Broque Thomas
f51a8a9ee9 Add Discogs to watchlist UI — badges, provider section, config endpoint
- Watchlist artist list: discogs_artist_id in API response
- Watchlist source badges: Discogs badge on artist cards
- Watchlist config modal: discogs_artist_id in SQL query, WHERE clause,
  response, and linked provider section with badge
- CSS for watchlist-source-discogs and watchlist-provider-badge.discogs
2026-04-02 21:09:11 -07:00
Broque Thomas
240dd87727 Fix Discogs cache — add field extractor, wire worker caching, browser UI
- Add _extract_discogs_fields to metadata cache — handles Discogs field
  names (title vs name, images array, Artist - Title format)
- Worker uses _fetch_and_cache_artist/_fetch_and_cache_album helpers
  that cache raw data while returning it for enrichment
- All search/lookup methods cache results for repeat queries
- Cache browser: Discogs stat pill, source filter, clear button, badge
- Fixes albums showing as 'Unknown' and artists missing images in cache
2026-04-02 19:47:36 -07:00
Broque Thomas
cc95cfcdf2 Wire Discogs as fully featured fallback metadata source
- SpotifyClient: add _discogs lazy-load property, route _fallback to
  DiscogsClient when configured (requires token, falls back to iTunes)
- web_server: _get_metadata_fallback_client returns DiscogsClient when
  selected and token present
- Enhanced search: Discogs added as source tab with NDJSON streaming,
  only available when token configured
- Alternate sources list includes Discogs when token is set
- Frontend: source labels, tab styling, fetch list all include Discogs
- Consistent with iTunes/Deezer pattern — same interfaces, same routing
2026-04-02 17:32:28 -07:00
Broque Thomas
b53c042721 Add Discogs to worker orbs animation and fix button styling
- Add Discogs to WORKER_DEFS in worker-orbs.js so it participates
  in the floating orb animation like all other enrichment workers
- Use SVG logo image instead of text
- Fix spinner and state CSS to match exact pattern of other workers
2026-04-02 17:18:31 -07:00
Broque Thomas
72e720dd88 Add Discogs enrichment button to dashboard header
- Circular button with "dc" logo text, matching exact pattern of
  AudioDB/Deezer/Spotify/iTunes/Last.fm/Genius/Tidal/Qobuz buttons
- Spinner animation when active, dimmed when paused, green when complete
- Hover tooltip showing status, current item, and progress stats
- Click to toggle pause/resume with config persistence
- WebSocket status handler updates button state in real-time
2026-04-02 17:06:49 -07:00
Broque Thomas
d5bb0dfaa1 Combine enrichment pills into unified rate monitor cards, debounce idle
- Merge enrichment worker status into rate monitor WebSocket payload
- Hide old enrichment pills — rate monitor cards now show: service name,
  worker status badge, arc gauge, calls/min, 1h/24h counts, budget bar
- Debounce idle detection with 5s grace period — prevents status
  flickering between Running and Idle on every worker cycle
- Responsive grid layout with richer card design
2026-04-02 11:17:04 -07:00
Broque Thomas
559b89353f Add API Rate Monitor dashboard with real-time speedometer gauges
- New core/api_call_tracker.py — centralized tracker with rolling 60s
  timestamps (speedometer) and 24h minute-bucketed history (charts)
- Instrument all 9 service client rate_limited decorators to record
  actual API calls with per-endpoint tracking for Spotify
- 1-second WebSocket push loop for real-time gauge updates
- Modern radial arc gauges with service brand colors, glowing active
  arc, endpoint dot, 0/max scale labels, smooth CSS transitions
- Click any gauge to open detail modal with 24h call history chart
  (Canvas 2D, HiDPI, gradient fill, grid lines, danger zone band)
- Spotify modal shows per-endpoint history lines with color legend
  and live per-endpoint breakdown bars
- Rate limited state indicator — blinking red badge with countdown
  timer appears on gauge card when Spotify ban is active
- REST endpoint GET /api/rate-monitor/history/<service> for chart data
- Responsive grid layout (5 cols desktop, 3 tablet, 2 phone)
2026-04-02 10:46:43 -07:00
Broque Thomas
b8870e7310 Add loading indicators for streaming search and lazy-load artist images
- Per-section loading spinners (artists/albums/tracks) shown until each
  NDJSON chunk arrives, auto-replaced with real content on receipt
- Active tab content auto-re-renders as streaming data arrives for both
  enhanced search and global search
- Global search lazy-loads artist images for iTunes/Deezer via
  /api/artist/{id}/image fallback (album art), matching enhanced search
2026-04-02 09:06:06 -07:00
Broque Thomas
68b4aace68 Fix track source-info/redownload 404 and add clear-match (#237, #236)
- Change <int:track_id> to <track_id> on 5 library track endpoints —
  Jellyfin uses GUID strings, int converter rejected them with 404 (#237)
- Add PUT /api/library/clear-match endpoint — sets service ID to NULL
  and match status to not_found, allowing users to undo wrong matches (#236)
- Add "Clear Match" button in the manual match modal for all services
- Add bottom padding to .page to prevent floating buttons (bell, help)
  from overlapping track action buttons at page bottom (#237)
2026-04-02 07:56:42 -07:00
Broque Thomas
b194e1e15b Add discovery artist blacklist — block artists from all discovery playlists
- New discovery_artist_blacklist table with NOCASE name matching
- Filter blacklisted artists from all 6 discovery pool queries, hero
  endpoint, and recent releases via SQL subquery and Python set check
- Name-based filtering means one block covers all sources (Spotify/iTunes/Deezer)
- Hover any discovery track row → ✕ button to quick-block that artist
- 🚫 button on Discover hero opens management modal with search-to-add
  (powered by enhanced search) and list of blocked artists with unblock
- CRUD API: GET/POST/DELETE /api/discover/artist-blacklist
- Updated changelogs
2026-04-01 23:35:56 -07:00
Broque Thomas
7acf7a7d80 Expose MusicBrainz cache in UI — browse, clear, and unified health display
- Add MusicBrainz to Cache Browser: stats pill, source filter, dedicated
  browse endpoint, cards with matched/failed status indicators
- Add Clear MusicBrainz and Clear Failed MB Only to cache clear dropdown
- Move MusicBrainz into Cache Health "By Source" bar chart alongside
  Spotify/iTunes/Deezer instead of isolated metric row
- Rename ambiguous "Failed Lookups" to "Failed MB Lookups" in summary cards
- Add browse-musicbrainz and clear-musicbrainz API endpoints
- Add musicbrainz_total/musicbrainz_failed to cache stats response
- Add Global Search Bar and MusicBrainz cache to changelogs
2026-04-01 23:12:16 -07:00
Broque Thomas
13629720e8 Add global search bar with full enhanced search parity
Persistent Spotlight-style search bar at bottom-center, accessible
from any page via click, /, or Ctrl+K. Hidden on Downloads page
where enhanced search already exists.

Features matching enhanced search:
- Clear button when input has text
- Source tabs with live switching
- Source badges, library check, play buttons
- Album click opens download modal directly
- Artist click navigates to detail page
- Tab switching stays open (timestamp guard)
- Mobile responsive
2026-04-01 22:31:12 -07:00
Broque Thomas
0e3a217591 Polish Wing It UX — dropdown on LB cards, position detection, better toasts
1. LB Discover page cards now use the dropdown (Download/Sync) instead
   of the old single button with full choice dialog
2. Dropdown position auto-flips downward when button is near viewport top
3. Dropdown centered on button instead of right-aligned
4. Sync completion toast now includes playlist name and  indicator
5. Download modal already shows  prefix on playlist name as indicator

All changes purely additive — existing flows unaffected.
2026-04-01 20:30:34 -07:00
Broque Thomas
a9dd93b176 Fix Wing It sync live status and button persistence across all modals
Live status: updateYouTubeModalSyncProgress was hardcoded to youtube-*
element IDs but each source uses its own prefix (listenbrainz-*, tidal-*,
deezer-*, etc). Now tries all prefixes to find the correct elements.
Fixes Wing It sync progress AND a pre-existing bug where normal LB/Tidal
sync from the modal wouldn't show live progress.

Wing It button added to sync_complete phase so it persists after sync.
Fixed tracks lookup with state.playlist?.tracks fallback. Increased
button size in modal to match other action buttons.
2026-04-01 20:10:30 -07:00