Commit graph

465 commits

Author SHA1 Message Date
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
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
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
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
449a26e56b Extract Auto-Sync into webui/static/auto-sync.js
Cin review: stats-automations.js had ~600 lines of new Auto-Sync code
piled into an already-large shared file. Moved into its own module:

- New webui/static/auto-sync.js holds:
  - Schedule board state (`AUTO_SYNC_BUCKETS`, `_autoSyncScheduleState`,
    `_autoSyncActiveTab`, `mirroredPipelinePollers`)
  - All `autoSync*` functions (trigger conversion, render panels,
    drag/drop, save/unschedule, schedule modal lifecycle)
  - Mirrored-playlist pipeline helpers (`runMirroredPlaylistPipeline`,
    `pollMirroredPipelineStatus`, `applyMirroredPipelineState`,
    `parseMirroredPipelineResponse`, `editMirroredSourceRef`,
    `getMirroredSourceRef`)
- index.html loads auto-sync.js immediately after stats-automations.js
  so the older `renderMirroredCard` path can keep reaching these
  globals through the window namespace.
- stats-automations.js drops 567 lines and gains a one-line breadcrumb
  pointing at the new file.

No behavior changes — every function moved verbatim. Globals stay in
the same window namespace, so the still-resident `renderMirroredCard`
keeps calling `runMirroredPlaylistPipeline` / `editMirroredSourceRef`
/ `mirroredPipelinePollers` exactly as before.

Both files pass `node --check`. Full Python suite still green.
2026-05-24 23:46:08 -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
Antti Kettunen
27fbc80e7a
feat(webui): migrate import route to React
- Move import page, tabs, workflow state, and route tests into React-owned route slices
- Preserve shell gating, staging queries, album matching, singles matching, auto-import, and queue behavior
- Add migration plan snapshot so cleanup/refinement can build on a stable baseline
2026-05-24 21:11:40 +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
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
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
b24152c74b
feat(webui): migrate stats page to react
- move the stats route onto the React shell with Recharts-based visualizations
- remove the global Chart.js include and add a local stats seed script for easier testing
- keep parity coverage with route, API, and helper tests while preserving the legacy page layout
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
43f3121abd docs(downloads): recommend single shared download folder
Refines the filesystem-access guidance after realising the
simplest setup is to skip the per-protocol folder split entirely
— point Soulseek + qBit + SAB / NZBGet at the same download
folder and SoulSync reads one place.

- webui/index.html: warning card tone shifted from 'this is a
  caveat' to 'here's the easiest fix' — leads with the single-
  folder recommendation, demotes the per-protocol mount option
  to a fallback. Icon swapped from ⚠️ to 💡 to match the
  shifted framing.
- docker-compose.yml: comment block restructured. EASIEST SETUP
  now leads (reuse the existing ./downloads mount, point every
  client there). SEPARATE FOLDERS demoted to a second option
  with the same commented placeholders for users who want them.
2026-05-20 17:54:03 -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
080b1aa1b4 feat(downloads): wire torrent + usenet as live download sources
The payoff for the previous five commits. Two new download
sources slot into the existing DownloadSourcePlugin contract,
backed by Prowlarr (search) + the torrent or usenet client
adapter (transfer) + archive_pipeline (post-extract walk). They
appear in the Download Source dropdown next to Soulseek / Tidal /
Lidarr / etc. and also participate in hybrid mode.

Pipeline (both plugins, mirror shape):
1. search(query) → ProwlarrClient.search filtered to the right
   protocol, projected into TrackResult / AlbumResult shapes the
   existing search UI already speaks. Filename field encodes the
   indexer's download URL (or magnet URI for torrents) so
   download() can recover it later.
2. download() → decodes URL, hands it to the active adapter
   (qBittorrent / Transmission / Deluge for torrent; SABnzbd /
   NZBGet for usenet), spawns a background poll thread that
   tracks progress + reports the adapter-reported save_path.
3. On 'seeding' / 'completed' → archive_pipeline walks the save
   directory, extracts any archives the downloader didn't
   already unpack, picks the first audio file as the canonical
   file_path. Matches the Lidarr client's single-track-pick
   contract — picking which specific track to import happens in
   post-processing.

- core/download_plugins/torrent.py: TorrentDownloadPlugin +
  module-level helpers (_decode_filename, _guess_quality_from_title,
  _parse_indexer_id_filter, _adapter_state_to_display, _row_to_status).
  Uses get_active_torrent_adapter() so a settings change to the
  client type takes effect without restart.
- core/download_plugins/usenet.py: UsenetDownloadPlugin —
  parallel shape, reuses the torrent module's helpers. Different
  enough states (no seeding, no magnet) to warrant its own class
  but cheap to keep in lockstep.
- core/download_plugins/registry.py: register 'torrent' and
  'usenet' plugins. Per the registry docstring this is the only
  wiring point needed — the orchestrator picks them up
  automatically via the iteration helpers.
- webui/index.html: 'Torrent Only (via Prowlarr)' + 'Usenet Only
  (via Prowlarr)' added to the Download Source dropdown. New
  redirect card (#prowlarr-source-redirect) explains that the
  actual config lives on the Indexers & Downloaders tab —
  shown whenever torrent or usenet is in the active source set.
- webui/static/settings.js: HYBRID_SOURCES gets two new entries
  so hybrid mode can pick them up. updateDownloadSourceUI now
  toggles the redirect card based on active sources.
- tests/test_torrent_usenet_plugins.py: 23 tests covering pure
  helpers (filename encode/decode round-trip incl. magnet URIs,
  quality guesser, state mapping), search projection logic
  (protocol filter, drops without URLs, magnet-preferred-over-URL,
  filename encoding, neutralised soulseek-specific score fields),
  is_configured (both prowlarr + adapter required), finalize
  (picks first audio file, errors on empty dir / missing save_path),
  clear/get_all lifecycle, DownloadSourcePlugin protocol
  conformance, and registry membership.
2026-05-20 17:22:19 -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
Broque Thomas
7a3ce50f71 feat(usenet): add adapter layer for SABnzbd and NZBGet
Third commit in the torrent + usenet rollout. SoulSync now also
speaks the two big usenet downloaders through a sibling adapter
contract that mirrors the torrent adapter set. All three layers are
now stood up — Prowlarr finds releases, the torrent adapter and the
usenet adapter each know how to ship work to the underlying client.
A later commit wires Prowlarr search results through the adapters
and through the archive-extract-match pipeline.

- core/usenet_clients/base.py: UsenetClientAdapter Protocol +
  UsenetStatus dataclass. Uniform state set covers usenet-specific
  phases (queued / downloading / extracting / verifying / repairing /
  completed / failed / paused).
- core/usenet_clients/__init__.py: adapter_for_type factory +
  get_active_adapter that reads usenet_client.type each call.
- core/usenet_clients/sabnzbd.py: REST adapter. ?apikey=... auth,
  mode=addurl and mode=addfile (multipart) for add_nzb. Reads both
  the active queue and the recent history so completed / failed
  jobs surface in get_all. Parses SAB's HH:MM:SS ``timeleft`` into
  seconds.
- core/usenet_clients/nzbget.py: JSON-RPC adapter. HTTP Basic auth,
  ``append`` method for add_nzb (auto-detects URL vs base64 NZB),
  ``editqueue`` with GroupPause/GroupResume/GroupDelete/GroupFinalDelete
  for state changes. Reads NZBGet's 64-bit split size fields
  (FileSizeHi + FileSizeLo) preferentially over the legacy
  FileSizeMB aggregate.
- core/connection_test.py: 'usenet_client' branch picks the right
  adapter, runs check_connection, surfaces per-client error
  messages (different credentials needed).
- config/settings.py: usenet_client.{type, url, api_key, username,
  password, category} defaults + both api_key and password marked
  encrypted-at-rest.
- web_server.py: 'usenet_client' added to the /api/settings POST
  allow-list.
- webui/index.html: new Usenet Client panel on the Indexers &
  Downloaders tab. Type picker swaps the credential fields between
  API-key (SABnzbd) and username+password (NZBGet).
- webui/static/settings.js: load/save wiring, updateUsenetClientUI
  for the credential field swap, testUsenetClientConnection.
- webui/static/helper.js: WHATS_NEW + VERSION_MODAL_SECTIONS entry.
2026-05-20 15:17:22 -07:00
Broque Thomas
de2faf290b feat(torrent): add adapter layer for qBittorrent, Transmission, Deluge
Second commit in the torrent + usenet rollout. SoulSync now speaks
three different BitTorrent client APIs through one uniform adapter
contract — picks the active client by config and dispatches the same
verbs to whichever backend the user uses. Each adapter handles its
own auth quirk (qBit cookie + CSRF Referer, Transmission session-id
renegotiation, Deluge JSON-RPC session) and maps native state
strings onto a shared 7-value set so the rest of the app stays
client-agnostic.

- core/torrent_clients/base.py: TorrentClientAdapter Protocol +
  TorrentStatus dataclass. Eight verbs: is_configured, check_connection,
  add_torrent (URL/magnet), add_torrent_file (raw bytes), get_status,
  get_all, remove, pause, resume.
- core/torrent_clients/__init__.py: adapter_for_type factory +
  get_active_adapter that reads torrent_client.type each call so
  settings changes take effect without restart.
- core/torrent_clients/qbittorrent.py: WebUI v2 adapter. Cookie auth
  via /api/v2/auth/login, transparent 403 re-login, Referer header
  to satisfy qBit's CSRF guard. add_torrent returns the just-added
  hash via /torrents/info sort=added_on (qBit's add endpoint doesn't
  echo the hash).
- core/torrent_clients/transmission.py: RPC adapter. Auto-resolves
  bare host URLs to /transmission/rpc, handles the 409 + new
  X-Transmission-Session-Id renegotiation transparently, accepts
  HTTP basic auth. add_torrent_file base64-encodes payload per spec.
- core/torrent_clients/deluge.py: Deluge 2.x JSON-RPC adapter.
  Password-only auth, distinguishes magnet vs HTTP URL at the RPC
  method layer, applies category via Label plugin (best-effort —
  label plugin is optional).
- core/connection_test.py: 'torrent_client' branch picks the right
  adapter, runs check_connection, surfaces a per-client error
  message.
- config/settings.py: torrent_client.{type, url, username, password,
  category, save_path} defaults + torrent_client.password in the
  encrypted-at-rest secrets list.
- web_server.py: 'torrent_client' added to the /api/settings POST
  allow-list so saved config persists.
- webui/index.html: new Torrent Client panel on the Indexers &
  Downloaders tab — client-type dropdown, URL, username, password,
  category, optional save path, Test Connection.
- webui/static/settings.js: load/save wiring + testTorrentClientConnection.
- webui/static/helper.js: WHATS_NEW + VERSION_MODAL_SECTIONS entry.
2026-05-20 15:10:30 -07:00
Broque Thomas
579eff8807 feat(settings): add Prowlarr integration as indexer aggregator
First commit toward torrent and usenet download sources. Prowlarr is
the indexer manager component of the *arr stack — it exposes Usenet
and torrent indexers behind a single Newznab-style API so SoulSync
doesn't have to integrate each indexer individually. This commit
wires up Prowlarr as a search-only source; the torrent and usenet
download client adapters land in the next commits and plug into
this search surface.

- core/prowlarr_client.py: sync-backed async client. is_configured,
  check_connection, get_indexers, search by Newznab category. Music
  category constants (3000 all / 3010 MP3 / 3040 lossless / etc.).
- core/connection_test.py: 'prowlarr' branch hits /api/v1/system/status
  for the Test Connection button.
- web_server.py: GET /api/prowlarr/indexers returns the live indexer
  list (id, name, protocol, enabled, privacy). Settings POST allow-list
  now accepts 'prowlarr' so saved config persists.
- config/settings.py: prowlarr.{url, api_key, indexer_ids} defaults
  plus prowlarr.api_key in the encrypted-at-rest secrets list.
- webui/index.html: new "Indexers & Downloaders" tab on Settings with
  the Prowlarr panel (URL, API key, Test, Refresh Indexer List,
  optional indexer-ID allowlist).
- webui/static/settings.js: load/save wiring, testProwlarrConnection,
  loadProwlarrIndexers (HTML-escapes user-supplied indexer names).
- webui/static/helper.js: WHATS_NEW 2.6.0 unreleased block plus a
  curated VERSION_MODAL_SECTIONS entry.
2026-05-20 14:41:54 -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
Antti Kettunen
5e39f1ee09
refactor(webui): centralize artist-detail handoff
- add a canonical TanStack route for artist-detail and keep the legacy page as the renderer target
- expose page-level artist-detail navigation on the shell bridge for legacy callers
- remove artist-detail-specific routing, origin stack, and back-label logic from the shared shell helpers
2026-05-19 09:26:10 +03:00
Broque Thomas
5bc5fbb662 Add MusicBrainz as a metadata source
Register MusicBrainz as a first-class metadata source alongside Deezer, iTunes, Spotify, Discogs, and Hydrabase. Expose the shared client through metadata services, add the settings option, and expand the MusicBrainz search adapter with source-compatible artist, album, track, and detail methods.

Carry MusicBrainz IDs through similar-artist discovery, recommended artists, artist map serialization, and personalized playlist selection. Update DB migrations and lookup filters so similar_artist_musicbrainz_id is preserved on older schemas and used for source requirements and library exclusion.

Normalize MusicBrainz album adapter output for import context and add regression coverage for registry mapping, typed album conversion, and similar-artist filtering. Verified by user with 120 focused tests passing.
2026-05-18 18:47:13 -07:00
Broque Thomas
04adbf01e2 Update index.html 2026-05-18 09:56:39 -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
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
1f579cede8 Add Amazon Music as a primary metadata source
Wires AmazonClient into the metadata source registry following the
exact same pattern as DeezerClient. No existing source paths touched.

- Add get_album_metadata / get_artist_info / get_artist_albums_list
  aliases to AmazonClient (mirrors DeezerClient interface aliases)
- Register amazon in METADATA_SOURCE_PRIORITY and METADATA_SOURCE_LABELS
- Add _get_amazon_factory() + get_amazon_client() to registry.py
- Add amazon branch to get_client_for_source(); thread amazon_client_factory
  kwarg through get_primary_client() and get_primary_source_status()
- Re-export get_amazon_client from the core.metadata_service shim
- Add Amazon Music option to Settings metadata source dropdown
- 3530 tests pass
2026-05-16 13:52:26 -07:00
Broque Thomas
dcbe09c7aa Add Amazon Music to download source mode dropdown 2026-05-16 10:04:14 -07:00
Broque Thomas
fa73c41ef6 Wire Amazon Music as a first-class download source
Follows the exact same standard as Tidal, Qobuz, HiFi, and Deezer.

registry.py — import + register AmazonDownloadClient as 'amazon'.

amazon_download_client.py — read amazon_download.quality / allow_fallback
from config on init; pass quality as preferred_codec to AmazonClient;
_download_sync codec waterfall respects allow_fallback flag.

download_orchestrator.py — reload_settings() updates preferred_codec +
allow_fallback on the live client after a settings save. 'amazon' added
to _streaming_sources so search_and_download_best routes it correctly.

api_call_tracker.py — 'amazon' registered in RATE_LIMITS (120/min),
SERVICE_LABELS, and SERVICE_ORDER so API call monitoring shows Amazon.

web_server.py — 'amazon_download' added to the settings service loop.
'amazon' added to serverless_sources (no slskd probe needed). Streaming
file-finder extended to handle amazon username + ||asin||title encoding
(extension-less fuzzy match, same as Tidal/Qobuz/HiFi). New endpoint:
GET /api/amazon/test-connection → checks T2Tunes proxy status.

webui/index.html — amazon-download-settings-container: quality dropdown
(flac/opus/eac3), allow-fallback checkbox, test-connection button.

webui/static/settings.js — 'Amazon Music' added to HYBRID_SOURCES,
_hybridSourceEnabled, allSources mode list, loadSettings(), saveSettings()
payload, updateDownloadSourceUI() show/hide + auto-test. New
testAmazonConnection() function.
2026-05-16 09:40:50 -07:00
Broque Thomas
81af852f61 reenable beatport 2026-05-15 13:48:24 -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
52a89f25df Remove Write Artist Image button from artist detail page
Backend endpoint + helper + tests left in place — only the UI surface
removed. Re-add later if the workflow proves useful.
2026-05-14 12:36:03 -07:00
Broque Thomas
d0d65946c8 Polish quarantine UI — fold into Library History modal as third tab
Standalone Quarantine button + modal felt out of place — duplicated
the chrome of the existing Library History modal but with worse
styling and behavior. Folded the quarantine list into the existing
modal as a third tab next to Downloads + Server Imports.

UI changes:
- Removed the standalone Quarantine button on the Downloads page
  header and the standalone modal HTML
- Added third tab to library-history-tabs with a count badge
- loadLibraryHistory dispatches to loadQuarantineList when the
  quarantine tab is active
- Quarantine entries render as library-history-entry cards using
  the exact same class chrome as Downloads + Imports (thumb
  placeholder, title + meta, badge, relative time via
  formatHistoryTime, expandable details panel)
- Per-row actions styled as lh-audit-btn to match the existing
  Audit button look
- Approve / Recover / Delete now use the themed showConfirmDialog
  + showToast — no more native browser alert / confirm

Backend endpoints + pure helpers + tests unchanged from f4cff78f.
WHATS_NEW entry rewritten to reflect the actual final UX.
2026-05-14 08:50:24 -07:00
Broque Thomas
f4cff78f13 Quarantine management — list, approve, delete, recover
Closes #584. Quarantined files used to sit in ss_quarantine/ with a
thin sidecar — no UI, no recovery, no way to see what got dropped.
This adds the management surface the user needs without going to the
filesystem.

UI: new "Quarantine" button on the downloads page header opens a
modal with every quarantined file (filename, expected track/artist,
reason, when, size). Three actions per row:

- Approve (one-click): restores the file, re-runs the post-process
  pipeline with ONLY the failing check skipped, lands in the library
  with full tags + lyrics + scan
- Recover (legacy fallback): moves to Staging for thin-sidecar
  entries that lack the embedded context Approve needs
- Delete: permanent removal of file + sidecar

Per-check bypass: context['_skip_quarantine_check'] = 'integrity' /
'acoustid' / 'bit_depth'. Skips ONLY the named check — other quality
gates stay live. No blanket bypass-all flag.

Sidecar expansion: move_to_quarantine now persists the full
json-serializable context via serialize_quarantine_context (drops
non-JSON-safe values, walks nested dicts/lists/sets, str-coerces
unknown objects) plus the trigger name. Existing thin sidecars are
detected and routed to Recover instead of Approve.

Pure helpers in core/imports/quarantine.py: list_quarantine_entries
/ delete_quarantine_entry / approve_quarantine_entry /
recover_to_staging / serialize_quarantine_context. 27 tests pin
every shape: orphan files / orphan sidecars / corrupt sidecars /
collision-safe filename restoration / full-context vs thin-sidecar
dispatch / json round-trip safety.

Four new endpoints in web_server.py — thin glue around the helpers:
GET /api/quarantine/list, DELETE /api/quarantine/<id>,
POST /api/quarantine/<id>/approve, POST /api/quarantine/<id>/recover.

Download modal status differentiates "🛡️ Quarantined" from
" Failed" so recoverable files are visible at a glance — checked
against the error_message text, no schema change needed.

Pipeline changes are three minimal per-check conditionals at the
existing quarantine sites in core/imports/pipeline.py. Each
move_to_quarantine call now passes its trigger name so the sidecar
records which check fired.

Full suite: 2992 passed.
2026-05-14 08:06:19 -07:00
Broque Thomas
177bd85355 Configurable duration tolerance for downloaded-file integrity check
Previously hardcoded at 3s (5s for tracks >10min) — files drifting
past that got quarantined with no user override. Live recordings,
alternate masterings, and some legitimate uploads routinely drift
further.

New setting `post_processing.duration_tolerance_seconds`. Default 0
means "use auto-scaled defaults" (unchanged behavior for users who
don't touch it). Positive value overrides the per-track defaults.
Capped at 60s — past that the check is effectively off.

Logic lifted to pure helper `resolve_duration_tolerance` in
file_integrity.py. Coerces every plausible input (None / empty /
zero / negative / unparseable / above-cap / numeric string / float)
to either a float override or None for auto. 12 tests pin every
shape.

Wired into `core/imports/pipeline.py` at the integrity-check call
site — runs for ALL matched downloads (Soulseek / Tidal / Qobuz /
HiFi / YouTube / Deezer-direct) since they all share that pipeline.
Settings UI input under Settings → Metadata → Post-Processing.
2026-05-14 06:53:36 -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
Antti Kettunen
9258e89f56
Post-rebase cleanup 2026-05-13 22:30:13 +03:00
Antti Kettunen
59eb8b75b0
Move shared shell chrome into bridge
Keep the page chrome sync helpers in shell-bridge.js so React and legacy routing share one implementation.

This preserves the sidebar breadcrumb and discover download bar behavior without shadowing the legacy shell helpers in init.js.
2026-05-13 22:26:26 +03:00
Antti Kettunen
a9976c54ae
Centralize shell bridge glue 2026-05-13 22:26:23 +03:00
Antti Kettunen
147a09035c
Remove stale initial page rendering hooks
- Drop unused _resolve_webui_initial_* helpers from web_server.py.
- Remove template-side initial_nav_page and initial_client_page conditionals.
- Keep Vite asset injection and runtime page activation in the client.
2026-05-13 22:26:22 +03:00
Antti Kettunen
686bfcc749
Drop server-rendered webui page state
Remove the Flask route-to-page helpers and stop passing initial active-page flags into the shell template.

The web UI now renders static page and nav markup, while the client-side shell remains responsible for establishing active page state after load. This keeps the hybrid Flask + Vite asset setup intact while reducing duplicated route/page ownership logic in the backend template layer.

Also added a previously missing /stream path to the spa exclusions
2026-05-13 22:26:21 +03:00
Antti Kettunen
736f243d5c
Simplify webui Vite asset injection 2026-05-13 22:26:21 +03:00