Commit graph

2346 commits

Author SHA1 Message Date
JohnBaumb
f32da04fd1 Add sync tab deep-linking, redirect Discover sync to sync tab, add Force Download toggle 2026-04-23 01:18:34 -07:00
JohnBaumb
ed013d79b9 Track server push status, expand push to all playlist types, rename to _push_playlist_to_server 2026-04-23 00:55:11 -07:00
JohnBaumb
5415d0fe3c feat: add SoulSync Discover sync tab with ListenBrainz, progress tracking, and Navidrome push
Adds a full Discover Sync tab to the Sync page with:
- Core UI scaffolding, playlist modal, empty-state handling
- ListenBrainz playlist integration with auto-update toggle persistence
- Sync progress tracking with matched/total counts on cards
- Navidrome playlist push on batch completion (V1 and V2 paths)
- Active download state display with polling resume on page reload
- Stuck-download detection for downloading and catch-all states
- Serialized sync queue to prevent concurrent backend contention
- Source badges, compact card layout, URL fixes
2026-04-22 11:56:30 -07:00
BoulderBadgeDad
200b68e65e
Merge pull request #356 from kettui/fix/album-track-source-propagation
Fix album track source propagation
2026-04-22 11:39:16 -07:00
Broque Thomas
d055e53610 Repoint websocket transport test to core.js after split
The websocket init block moved from script.js into core.js in the
module split (PR #352). Test was still hardcoded to the old path.
2026-04-22 11:30:39 -07:00
Antti Kettunen
5420c0de24
Fix album track source propagation
- Pass source through artist, library, wishlist, and rehydration album-track fetches
- Preserve the resolved metadata source on cached discography and artist detail state
- Prevent 404s when opening artist-page album modals from non-Spotify sources
2026-04-22 21:21:32 +03:00
BoulderBadgeDad
00f116ebee
Merge pull request #352 from JohnBaumb/refactor/split-script-js
Split monolithic script.js into 17 domain modules
2026-04-22 10:49:17 -07:00
Broque Thomas
8f85b0c251 Fix silent wrong-artist track downloads (Maduk/Tom Walker bug)
User reported searching "Maduk - Leave A Light On" on Tidal silently
downloaded Tom Walker's completely different song of the same name, then
embedded Maduk's metadata into Tom Walker's audio. Three layers of
defense all failed permissively. Two of them are fixed here; the third
(score formula weights) was left alone since these two together cover it.

Layer 1 fix — candidate artist gate (web_server.py:27782)
  Old: `if _best_artist < 0.4 and confidence < 0.85: continue`
  New: `if _best_artist < 0.5 and confidence < 0.85: continue`

  SequenceMatcher returns exactly 0.400 for "maduk" vs "tom walker"
  (5-char vs 10-char strings with coincidental char matches), which
  slipped past the strict `< 0.4` check. The word-boundary containment
  check earlier in the function already short-circuits legitimate
  formatting variations to sim=1.0, so falling to SequenceMatcher means
  strings are genuinely different. 0.5 closes the fencepost AND gives
  a small safety buffer.

Layer 3 fix — AcoustID verification (acoustid_verification.py:316)
  When title matches but artist doesn't AND expected artist isn't found
  anywhere in AcoustID's returned recordings:
    Old: always SKIP (let file through, assume cover/collab)
    New: FAIL if artist_sim < 0.3 (clear mismatch)
         SKIP if artist_sim >= 0.3 (ambiguous — cover/collab/formatting)

  The 0.3 cutoff catches hard mismatches like Maduk/Tom Walker (sim ~0.2)
  while preserving benefit-of-the-doubt for borderline artist formatting
  differences. Legitimate covers and collabs where the expected artist
  appears anywhere in AcoustID's recordings still PASS via the existing
  secondary-match loop above.

Both fixes are defense-in-depth — either alone would have caught this
bug. Together they close the pre-download AND post-download gaps.

All 292 tests pass. Version bumped to 2.39 with changelog entries.
2026-04-22 10:32:55 -07:00
Broque Thomas
0d0bbf38c9 Add query-shortening retry + qualifier guard to Tidal search
Tidal's search engine chokes on long queries with multiple qualifier
words (remix credits, edit labels, bonus-disc markers). User reported
case: "maduk transformations remixed fire away fred v remix" returns 0,
but shortening to "maduk transformations remixed fire away" works.

Behaviour change:
- On a 0-result search, retry with progressively-shortened variants
  (capped at 5 total attempts, 100ms pause between).
- Variants (in priority order):
    1. strip trailing "(...)" / "[...]"
    2. strip all parentheticals/brackets
    3-5. drop last 1 / 2 / 3 tokens
    6. keep first half of tokens (rounded up)
- Dedupes so identical variants don't re-query.

Safety — qualifier-aware filter:
- Variant keywords (Live / Remix / Acoustic / Extended / Unplugged /
  Instrumental / Karaoke / etc.) are extracted from the original query
  using word-boundary match so "edit" doesn't match "edition" and
  "mix" doesn't match "remixed".
- If the original query carries any qualifiers, fallback results MUST
  contain those qualifiers in their track names — otherwise a shortened
  query could silently downgrade "Song (Live)" to the studio "Song".
- Tracks that fail the filter are dropped. If no variant produces
  qualifier-matching tracks, returns ([], []) — the same outcome as the
  original code, so no regression.

Contract preservation:
- Never raises to caller (outer try/except catches orchestration errors).
- Returns ([], []) on any failure path, same as original.
- Original-query successes take the same code path as before — no
  behavioural change for queries that already work.
- Defensive guards for None/empty/non-string query (early return).

Logging:
- Preserves original warning/error/info messages for back-compat log
  scraping.
- Adds fallback-success INFO log ("Tidal fallback query succeeded: ...")
  so successful retries are visible in production logs.
- Adds qualifier-filter INFO/DEBUG logs with kept/total counts.
- Per-attempt exception logs at DEBUG (not ERROR) to avoid noise when
  retries succeed.
- Traceback preserved on final failure.

Tests (16 regression tests in tests/test_tidal_search_shortening.py):
- Skowl's reported query reaches his working variant within the cap.
- Paren/bracket stripping priority.
- Short queries produce no variants.
- All variants unique (dedup guard).
- Progressive token drops present for long queries.
- Qualifier extraction is word-bounded (no "edit" in "edition").
- Qualifier extraction is case-insensitive.
- Track name filter requires ALL qualifiers.
- Empty-qualifier list passes every track (original-query behaviour).

All 292 tests pass.
2026-04-22 07:42:16 -07:00
JohnBaumb
77b069acf4 Add split integrity tests (61 tests) 2026-04-22 00:04:16 -07:00
JohnBaumb
a66c4d06e1 Split monolithic script.js (78K lines) into 17 domain modules
Extracts the single 77,957-line script.js into focused modules:

  core.js            (874)   - Global state, confirm dialog, websocket, constants
  init.js            (2358)  - Initialization, personal settings, navigation
  media-player.js    (2398)  - Media player, audio, visualizer, radio
  settings.js        (3657)  - Settings page, quality profiles, API keys, auth
  search.js          (1542)  - Search functionality, page data loading
  sync-spotify.js    (2538)  - Spotify sync, YouTube backend, hero section
  downloads.js       (6398)  - Wing It, batched polling, cancel, notifications
  wishlist-tools.js  (7234)  - Wishlist, matched downloads, tools, retag
  sync-services.js   (9076)  - Tidal, Deezer, Beatport, YouTube, ListenBrainz sync
  artists.js         (4610)  - Artists page, artist downloads
  api-monitor.js     (3798)  - API rate monitor gauges
  library.js         (6652)  - Library, artist detail, enhanced management
  beatport-ui.js     (3902)  - Beatport sliders, genre browser
  discover.js        (8920)  - Discover page and all sub-sections
  enrichment.js      (3551)  - All enrichment workers, library repair
  stats-automations.js (7575) - Stats, automations, issues, import
  pages-extra.js     (2874)  - Playlist explorer, server playlists, active downloads

Load order: core.js first (globals), init.js last (DOMContentLoaded).
All other modules define functions and load in any order.
No functional changes - pure extraction along existing section boundaries.
2026-04-21 23:52:30 -07:00
BoulderBadgeDad
47ced912b9
Merge pull request #349 from kettui/fix/lazy-load-beatport-only-when-needed
Load Beatport content only when needed
2026-04-21 22:59:44 -07:00
Broque Thomas
78fa83c8ac Add $cdnum template variable for multi-disc filenames
New smart template variable that emits "CD01" / "CD02" etc. in filenames
on multi-disc albums, and expands to empty string on single-disc albums
so mixed libraries don't end up with "CD01" on every single.

Template behaviour:
- total_discs > 1 -> "CD{disc:02d}" (zero-padded, CD prefix)
- total_discs <= 1 -> empty string
- Both $cdnum and ${cdnum} bracket form supported
- Empty value collapses cleanly via existing double-dash regex plus new
  leading-dash cleanup pass

Wiring:
- _apply_path_template in web_server.py (download pipeline)
- _apply_path_template in core/repair_jobs/library_reorganize.py
  (Reorganize repair job)
- total_discs added to every album-mode template context:
  * download pipeline album branch (uses resolved total_discs even for
    single-track downloads from search)
  * per-album Reorganize preview + apply endpoints (pre-scan all track
    tags once, take max disc_number)
  * Library Reorganize repair job (already had album_total_discs map,
    just added to context dict)

Leading-dash cleanup added to _get_file_path_from_template (web_server)
and _build_path_from_template (library_reorganize) so templates like
"$cdnum - $track - $title" don't leave "- 05 - Title" on single-disc
albums.

UI:
- Template hint in Settings -> File Organization documents $cdnum
- Template validation variable list includes $cdnum
- Reorganize modal variable reference shows $cdnum with example "CD01"

Verified:
- Multi-disc disc 1 -> "CD01 - 05 - Track"
- Multi-disc disc 2 -> "CD02 - 05 - Track"
- Single-disc      -> "05 - Track" (no leading dash)
- Templates without $cdnum behave unchanged
- 276/276 tests pass
2026-04-21 22:55:37 -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
Antti Kettunen
cc8875f30b
Pause Beatport work on tab exit
- Abort Beatport content loading when leaving the Sync/Beatport tab
- Stop Beatport slider autoplay plus discovery and sync subscriptions on exit
- Make Beatport bubble hydration cancellable so hidden tabs do not keep fetching
2026-04-22 08:12:35 +03:00
Antti Kettunen
8dbc819765
Lazy-load Beatport tab data
- Defer Beatport scraper fetches until Beatport tab opens
- Skip Beatport bubble hydration during app bootstrap
- Keep rebuild sliders and top lists behind first visit
2026-04-22 07:57:05 +03:00
Broque Thomas
b79162f9e0 Render cover art on downloads page for fixed/wishlist tracks
Download-status meta enrichment only checked spotify_album.images[0].url
for the card artwork. That's the Spotify-API shape, but the context
builder for wishlist and manually-fixed tracks populates spotify_album
with image_url (singular string) and no images array. Result: those
tracks downloaded and post-processed fine (different path) but the
downloads page showed a placeholder note icon.

Enrichment now falls through three spots before giving up:
  1. spotify_album.images[0].url (Spotify-originated)
  2. spotify_album.image_url       (wishlist / fixed discovery)
  3. track_info.image_url          (some discovery flows)

Pure read-side fix — no changes to the context builder, so existing
behaviour for Spotify-primary users is unchanged.
2026-04-21 21:54:09 -07:00
Broque Thomas
03b7230ac2 Preserve cover art in discovery fix-modal cache matched_data
Companion fix to the provider-hardcode bug (6ceedc8). The cache
matched_data built by the 5 update_match / fix endpoints was dropping
image_url and album.images when album came back as a bare string —
common for Deezer and iTunes search results. Cache hits on re-discovery
then produced downloads with no artwork.

Each save site now carries image info through:
- album_obj gets image_url + images:[{url}] populated from spotify_track.image_url
- matched_data adds top-level image_url for pipeline consumers that check there
- Works for both dict-shaped album (Spotify) and string-shaped album (Deezer/iTunes)

Mirrors the handling already present in _build_fix_modal_spotify_data for
the in-memory result['spotify_data'] — explains why the UI showed art fine
during the fix modal but the cached entry lost it after restart.

save_discovery_cache_match uses INSERT OR REPLACE, so existing bad cache
entries refresh when the user re-fixes the track. No manual cache clearing
needed.

Added to 2.38 changelog (same round of discovery-fix work).
2026-04-21 21:26:36 -07:00
Broque Thomas
6ceedc8fd4 Fix manual discovery fixes lost after restart for non-Spotify users
Five update_match endpoints hardcoded the provider as 'spotify' when
saving manual fixes to the discovery cache, but the re-discovery worker
queries the cache with _get_active_discovery_source() — the user's
actual primary. If the primary was Deezer/iTunes/Discogs/Hydrabase, the
provider column never matched, so every manual fix looked like it
vanished on restart.

Replaced 'spotify' with _get_active_discovery_source() at all 5 sites:
- Tidal update_match (web_server.py:34569)
- Deezer update_match (web_server.py:36235)
- Spotify Public update_match (web_server.py:37084)
- YouTube update_match (web_server.py:38037)
- Discovery Pool fix (web_server.py:49787)

Now symmetric with how the auto-discovery workers already save. Spotify-
primary users see no change (the hardcoded value matched their source).

Version bumped to 2.38 with changelog + version-info entries.
2026-04-21 21:12:22 -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
0af98cdded Merge main into dev (brings in PR #342 Plex PIN OAuth) 2026-04-21 20:22:08 -07:00
BoulderBadgeDad
87ea38bdd9
Merge pull request #342 from elmerohueso/plex-pin-auth
Add Plex pin-based OAuth and re-work Plex configuration dialogs
2026-04-21 20:17:12 -07:00
Broque Thomas
e5d4d61c0e Fix watchlist content filters: live false positives + auto-scan bypass
Two bugs reported in issue #320:

1. Auto-watchlist scan bypassed Global Override settings.
   scan_watchlist_profile applied _apply_global_watchlist_overrides, but
   the scheduled auto-scan called scan_watchlist_artists directly —
   bypassing the override. Users who unchecked "Albums" or "Live" under
   Watchlist → Global Override still saw full albums and live tracks
   added during nightly scans (per-artist defaults, which include
   everything, won).

   Moved override application into scan_watchlist_artists itself so
   every entry point respects it. scan_watchlist_profile now forwards
   the apply_global_overrides flag through to avoid double-application.

2. is_live_version (watchlist + discography backfill) and
   live_commentary_cleaner's content patterns used bare \blive\b, which
   matched verb uses like "What We Live For" by American Authors,
   "Live Forever" by Oasis, "Live and Let Die" by Wings.

   Tightened the live patterns to require clear recording context:
   (Live) / [Live Version] / - Live / Live at|from|in|on|version|
   session|recording|performance|album|show|tour|concert|edit|cut|take
   / In Concert / On Stage / Unplugged / Concert.

   Locked in 11 regression tests covering the reported false positives
   (What We Live For, Live Forever, Living on a Prayer, Live and Let Die)
   and the reported true positives (Dimension - Live at Big Day Out,
   MTV Unplugged, etc.).

Version bumped to 2.37 with changelog entries.
2026-04-21 19:16:25 -07:00
Broque Thomas
178719e443 Fix metadata cache health bar duplicating on findings dashboard
_loadCacheHealthStats ran async from loadRepairFindingsDashboard and
appended a new .repair-cache-health div each time. If the dashboard
refreshed while earlier fetches were still in flight, each resolving
fetch appended its own section — producing 2–6 stacked copies.

Now each fetch removes any existing .repair-cache-health inside the
dashboard before appending, so at most one bar is ever visible.
2026-04-21 18:47:15 -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
39a07e4bdf Fix Discography Backfill silently skipping most releases
Two bugs kept this job from finding anything useful on a typical library.

1. Wrong Deezer column name. The artists table has a deezer_id column
   (per music_database.py:1986), but the job looked for deezer_artist_id
   in both _scan_artist (line 132) and _get_library_artists (line 345).
   For Deezer-primary users, this meant the Deezer ID never made it into
   the source_ids map, so get_artist_discography fell back to artist-
   name-only search — slower and less accurate than an ID lookup.

2. Spotify-reported EPs were silently excluded. Spotify lumps EPs and
   true singles under album_type='single'. The previous
   _should_include_release short-circuited on album_type='single' and
   returned the include_singles setting (default False), so 4-6 track
   EPs on Spotify-primary libraries never survived the filter — even
   though include_eps defaulted to True. Only 7+ track full albums
   made it through. This is the main reason users felt the job did
   nothing.

Fixes:

- Use the correct deezer_id column name in both reference sites.

- Restructure _should_include_release so only 'album', 'ep', and
  'compilation' are trusted outright. Anything else (including
  'single' and missing type) falls through to a track-count
  disambiguation matching the download pipeline's _get_album_type_display:
  1-3 tracks = true single, 4-6 = EP, 7+ = album. A Spotify-returned
  'single' with 5 tracks now correctly counts as an EP.

Full suite stays at 263 passed. Ruff clean.
2026-04-21 17:26:38 -07:00
Broque Thomas
75d0dc3d4f Fix manual discovery match producing download cards with no cover art
User reported: after clicking Fix on a Not-Found discovery track and
picking a replacement in the fix modal, the resulting download card had
no cover image, and the track seemed to still behave like a Wing-It
stub. Both suspicions were correct. Three compounding bugs:

1. /api/spotify/search_tracks returned only id/name/artists/album/
   duration_ms — no image_url — unlike the sibling /api/itunes/ and
   /api/deezer/ endpoints which include image_url. The fix modal had
   no image data to work with when users searched via Spotify.

2. Frontend selectDiscoveryFixTrack discarded any image info it did get
   and posted the same minimal shape to the backend.

3. All 7 backend discovery/update_match endpoints built
   result['spotify_data'] with 'album' as a bare string (track.album
   which is just the album name). The download pipeline expects
   spotify_album to be a dict with image_url or images[].url — a string
   yields blank cover art. Normal discovery workers already build album
   as a rich dict; the fix-modal path was the anomaly.

4. Bonus: result['wing_it_fallback'] was never cleared on manual match.
   Tracks fixed after the auto Wing-It fallback kept the flag set, so
   downstream code checking it treated them as wing-it even though the
   user picked real metadata.

Changes:

- New helper _build_fix_modal_spotify_data(spotify_track) in web_server.py
  near _build_discovery_wing_it_stub. Handles both string and dict album
  inputs, normalises to a dict with image_url and images populated when
  the payload carries one. Matches the shape produced by normal discovery
  so downstream code is happy on both paths.

- /api/spotify/search_tracks now returns image_url (parity with iTunes
  and Deezer endpoints).

- All 7 discovery/update_match endpoints (youtube, tidal, deezer,
  spotify-public, listenbrainz, beatport — 6 via the identical pattern
  plus the listenbrainz variant with its None branch) now:
    * use the helper to build spotify_data (album as dict + top-level
      image_url)
    * explicitly set result['wing_it_fallback'] = False

- selectDiscoveryFixTrack forwards track.image_url in the POST body and
  mirrors the helper's output in the local state update so the UI
  reflects the same shape immediately.

Audit: every downstream reader of spotify_data['album'] is already
dict-or-string tolerant (isinstance checks at lines 2073, 2158, 25844,
28938, 29011, etc.) so promoting album from string to dict is safe.
Normal discovery already sets it as a dict, so we're moving the fix-
modal path to match the existing majority case.

Full suite stays at 263 passed. Ruff clean.
2026-04-21 17:12:28 -07:00
Broque Thomas
e15f581b33 Fix enrichment worker pause being silently undone after downloads finish
User reported pausing the Spotify/Last.fm/Genius enrichment worker via the
dashboard bubble would silently turn back on "by itself". Real cause was
a race in the download-yield auto-pause/resume loop (_emit_enrichment_worker_stats_loop):

1. Download starts. Loop sees worker running, auto-pauses it, adds its
   name to _download_auto_paused.
2. User clicks the enrichment bubble to pause — already paused visually,
   but they want it to STAY off. Pause endpoint sets config_manager
   '_enrichment_paused' to True and calls worker.pause() — but does not
   remove the name from _download_auto_paused.
3. Download finishes. Loop sees 'not downloading and name in
   _download_auto_paused' and blindly flips w.paused = False,
   overriding the user's explicit pause. Config still says paused,
   but the worker is actually running.

Two defensive fixes:

- Auto-resume block now checks the user's persisted config intent before
  flipping the worker on. If {name}_enrichment_paused is True in config,
  the name is dropped from _download_auto_paused without touching
  w.paused — user's pause stays honored.

- Pause endpoints for spotify-enrichment, lastfm-enrichment, and
  genius-enrichment now also discard from _download_auto_paused so a
  stale marker can't trigger this race again.

Both together mean the auto-pause loop can no longer override a manual
pause regardless of ordering.

Full suite stays at 263 passed. Ruff clean.
2026-04-21 16:30:43 -07:00
Broque Thomas
fadbb286b7 Fix [object Object] in discovery live-update paths (bulletproof pass)
The previous commit only fixed the INITIAL-render transform for
Spotify/Tidal/Deezer discovery rows. User confirmed [object Object]
still appeared after discovery completed — because there are two
additional update paths that do their own row-transform:

- WebSocket live-update handler (populates rows as discovery progresses)
- Poll-based fallback (same shape, runs when socket is disconnected)

Both had the same naive `.artists.join(', ')` on potentially-object
arrays. The poll and socket handlers exist for each of Spotify Public,
Tidal, and Deezer — six occurrences total across three platforms, all
with the same bug class. Now all use the object-aware map-and-join
pattern consistent with the initial-render fix.

Also fixes two more spots in openDiscoveryFixModal that the earlier
sweep missed:
- Missing spotify_public branch in the apply-selected-match handler:
  after user picks a replacement track, state lookup failed, the local
  row wouldn't refresh even though the backend had succeeded.
- Same artist-join bug in the same handler (track.artists from the
  fix-modal search results could be array-of-objects).

Full suite stays at 263 passed. Ruff clean.
2026-04-21 16:14:36 -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
elmerohueso
f8dd846fea
Merge branch 'main' into plex-pin-auth 2026-04-21 16:05:40 -06:00
Broque Thomas
be2d425972 Document missing features in README
Adds previously-undocumented features to the Key Features section:
- Hydrabase P2P metadata network (dev-mode alternative to iTunes)
- Genre Whitelist for filtering junk tags across enrichment sources
- Multi-artist tagging options (separator, multi-value ARTISTS, feat-in-title)
- Live Log Viewer in Settings → Logs
- ReplayGain analysis during post-processing

Expands the Automation Engine section with more trigger examples,
multi-THEN actions (up to 3 per automation), Signal Chains cycle-
detection behavior, and Automation Groups.

Expands Mirrored Playlists to cover Auto Wing It metadata fallback
and the per-track Unmatch button with DB persistence.
2026-04-21 14:58:07 -07:00
Broque Thomas
8983da5b18 Document dev/nightly release channels and contributor workflow
Adds a Release Channels section to the main README explaining the three
Docker image tracks users can choose from: stable :latest (Docker Hub),
nightly :dev (GHCR, rebuilt from dev branch), and pinned version tags.
Includes a decision table for picking the right channel and switching
instructions for docker-compose users.

Notes on the Unraid section that the template points at :latest by
default, and how to switch an Unraid container to the :dev channel
by editing the Repository field.

Adds a Contributing section covering the dev → main PR workflow, how to
branch off dev, expectations around ruff + pytest passing locally, and
how to run the dev gunicorn config.

Mirrors a short release-channels blurb at the top of
Support/README-Docker.md pointing at the main README's full guide.
2026-04-21 14:40:24 -07:00
BoulderBadgeDad
9d1009fa69
Merge pull request #347 from Nezreka/feat/service-status-indicators
Add per-service config status indicators to Settings Connections
2026-04-21 14:29:36 -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
d9217237d2 Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs
PR #340 added ruff to the build-and-test.yml CI gate, which surfaced
286 pre-existing lint errors. Left unfixed, every feature branch push
fails CI. This commit resolves all of them so CI goes green and
contributors can actually land work.

Auto-fixes (248 of 286): removed unused f-string prefixes (F541),
renamed unused loop control variables with underscore prefix (B007),
removed duplicate imports (F811).

Manually fixed 10 latent bugs ruff caught (all wrapped in try/except
today, silently failing):

- music_database.py: _add_discovery_tables() called undefined
  conn.commit() — would have crashed the iTunes-support migration
  for existing databases. Now uses cursor.connection.commit().
- web_server.py settings GET: referenced undefined download_orchestrator
  when it should be soulseek_client. Feature (_source_status on the
  settings payload) was silently missing for UI auto-disable logic.
- web_server.py _process_wishlist_automatically: active_server
  undefined in track-ownership check. Auto-wishlist was falling
  through to the error handler and re-downloading owned tracks.
- web_server.py start_wishlist_missing_downloads: same active_server
  bug in the manual wishlist path.
- web_server.py _process_failed_tracks_to_wishlist_exact: emitted
  wishlist_item_added automation event with undefined artist_name
  and track. Automation event silently never fired correctly.
- web_server.py discovery metadata enrichment: referenced cache
  without calling get_metadata_cache() first. Track enrichment from
  cached API responses was silently skipped.
- web_server.py Beatport discovery worker: wing-it fallback branch
  used undefined successful_discoveries variable. Wing-it counter
  never incremented correctly. Now uses state['spotify_matches']
  consistently with the rest of the function.
- web_server.py _run_full_missing_tracks_process: stale import json
  mid-function shadowed the module-level import, making an earlier
  json.dumps() call reference an unbound local (F823).
- web_server.py discovery loop: platform loop variable shadowed
  the module-level platform import (F402).
- core/watchlist_scanner.py: 7 lambda captures of loop variables
  (B023 classic Python closure-in-loop bug) now bind at creation.

No existing tests had to change. Full suite stays at 263 passed.
2026-04-21 13:30:52 -07:00
BoulderBadgeDad
32923e366c
Merge pull request #346 from JohnBaumb/fix/ghcr-lowercase-owner
Fix GHCR tags: lowercase repository owner for Docker compatibility
2026-04-21 12:27:02 -07:00
JohnBaumb
dee0b6e3ce Fix GHCR tags: lowercase repository owner for Docker compatibility 2026-04-21 12:25:11 -07:00
BoulderBadgeDad
1ddfa727bd
Merge pull request #340 from JohnBaumb/pipeline-update
CI/CD pipeline improvements: dev nightlies, linting, caching, cleanup
2026-04-21 12:22:16 -07:00
JohnBaumb
c91e651bcd Fix duplicate nightly tag, add date+sha pinned tags, lint dev builds, preserve nightly in cleanup 2026-04-21 11:56:08 -07:00
JohnBaumb
26ba1bdc0f Add :nightly tag, dual-push to GHCR, remove pinned dev tags 2026-04-21 11:56:08 -07:00
JohnBaumb
5edd72b6cf Add dev nightly builds, ruff linting, Docker layer caching, and GHCR cleanup 2026-04-21 11:56:08 -07:00
Broque Thomas
6314827e91 Fix test-order-dependent failures in new metadata_service tests
The _DummyConfigManager stubs in test_metadata_service_musicmap.py and
test_metadata_service_artist_image.py were missing get_active_media_server(),
which the existing test_metadata_service_discography.py dummy provides.

Both files install their dummy via sys.modules["config.settings"] with an
"if not in sys.modules" guard, so whichever test file loads first wins.
When the new files load alphabetically before discography, the limited
dummy persists and later tests hit AttributeError on get_active_media_server.

Adds the same get_active_media_server method to both dummies so all three
test files are equivalent and test ordering no longer affects outcomes.
2026-04-21 11:39:56 -07:00
BoulderBadgeDad
9eaf53630c
Merge pull request #345 from kettui/fix/get_similar_artists_stream
Refactor artist image and similar-artist lookup to use source priority
2026-04-21 11:33:38 -07:00
BoulderBadgeDad
0ebb4f9d80
Merge pull request #344 from JohnBaumb/fix/ruff-lint-errors
Fix existing ruff lint errors (F541, B007)
2026-04-21 11:20:18 -07:00
JohnBaumb
a1886ed87f Fix ruff F541 and B007 lint errors 2026-04-21 11:18:40 -07:00
Antti Kettunen
6f79214439
Route artist image lookup through metadata service
- Move /api/artist/<artist_id>/image resolution into core.metadata_service.
- Resolve artist artwork through source priority, with explicit source/plugin overrides preserved.
- Keep Spotify call tracking inside the client layer to avoid double counting.
- Update similar-artist lazy loading to pass source context and add service coverage.
2026-04-21 21:14:35 +03:00
Antti Kettunen
b022a90997
Move MusicMap similar artist matching into metadata service
- Relocate the streamed MusicMap similar-artist flow out of web_server.py and into core.metadata_service.
- Match similar artists through the configured source-priority chain instead of assuming Spotify first.
- Add iTunes artwork fallback so streamed artist payloads still carry image_url when search results are sparse.
- Cover the new service behavior with tests.
2026-04-21 21:14:35 +03:00
BoulderBadgeDad
9863c947dc
Merge pull request #343 from kettui/fix/replace-print-with-logger
Tidy up logging across the app
2026-04-21 11:05:52 -07:00
Broque Thomas
cf143f70af Batch discography completion matching against pre-fetched candidates
Artist detail pages ran check_album_exists_with_editions and check_track_exists
per discography item, each firing 5+ title variations times 3 artist variations
of fuzzy LIKE searches plus fallback broad-artist queries. For a 30-album artist
that was ~450 SQL round-trips just to answer "which of these do I own."

Hoist the artist's library albums and tracks into memory once per request via
two new helpers — get_candidate_albums_for_artist and get_candidate_tracks_for_albums —
and thread them through as optional candidate_albums / candidate_tracks params on
check_album_exists_with_editions, check_album_exists_with_completeness,
check_track_exists, check_album_completion, and check_single_completion.

Batched path scores the same _calculate_album_confidence / _calculate_track_confidence
against the in-memory list, preserving Smart Edition Matching and accuracy.
Title-only cross-artist fallback still fires for collaborative-album edge cases.
None on either param preserves legacy per-item SQL behavior for unaffected callers.

Applied to both /api/library/completion-stream (library artist detail page) and
iter_artist_discography_completion_events (Artists search page). Timing logs
added to confirm the pre-fetch cost and loop elapsed time.

On a Kendrick page load, per-album resolution drops from ~8 seconds to under
the 50ms streaming sleep floor. Observed ~100x SQL reduction on the happy path.
2026-04-21 10:47:47 -07:00