Commit graph

844 commits

Author SHA1 Message Date
Broque Thomas
9556fc9b5c Add Wing It mode — download or sync without metadata discovery
Wing It bypasses Spotify/iTunes/Deezer matching and uses raw track
names directly. User chooses Download or Sync from a choice dialog.

Download: opens Download Missing modal with force-download-all
pre-checked. wing_it flag skips wishlist for failed tracks.

Sync: new POST /api/wing-it/sync endpoint runs _run_sync_task with
raw track dicts. Live inline sync status display on the LB card
using the same progress elements as normal sync. Unmatched tracks
skip wishlist via _skip_wishlist flag on sync_service.

Button in three places:
- Next to "Start Discovery" in all discovery modals (fresh phase)
- Next to "Download Missing"/"Sync" after discovery (discovered phase)
- Next to "Download" on ListenBrainz cards (Discover page)

Fixed force-download toggle ID, sync progress field names
(total_tracks/matched_tracks not total/matched). All changes
purely additive — normal flows unaffected.
2026-04-01 19:16:51 -07:00
Broque Thomas
119840fa5d Redesign toast notification system with bell button and history panel
Complete replacement of the old bottom-center stacking toast system:

Compact Toasts: Single toast at a time, bottom-right above buttons.
Pill shape with type-colored left border stripe, icon, message, and
optional "Learn more" link. Slides in, fades out after 3.5s. Click
to dismiss. New toasts replace the current one smoothly.

Notification Bell: 44px circle button next to the helper (?), with
red badge counter for unread notifications. Click opens panel.

Notification Panel: Glass popover above bell button showing history
of last 50 notifications. Each entry has type icon, message, relative
timestamp, and optional help link. Unread dot indicator. Clear All
button. Marks all as read when panel opens.

Same showToast(message, type, helpSection) signature — all 842
callers unchanged. Deduplication preserved. Updated version modal
and helper What's New.
2026-04-01 16:41:41 -07:00
Broque Thomas
ce9ba42a91 Fix hybrid download mode not trying fallback sources (#235)
The download orchestrator's hybrid search stops at the first source
that returns ANY results, even if all those results fail quality
filtering. This meant Soulseek returning 100 low-quality results
would prevent HiFi/Tidal/YouTube from ever being tried.

Added hybrid fallback in the download worker: when all queries exhaust
with no valid candidates and hybrid mode is active, remaining sources
in the hybrid order are searched directly, bypassing the orchestrator's
stop-at-first-hit logic. Each fallback source gets the first 2 queries
with full quality filtering and candidate validation.

Fixed guard condition from convoluted diagnostic string parsing to a
simple mode check. Uses getattr for safe attribute access.
2026-04-01 15:49:51 -07:00
Broque Thomas
8105f589ce Update version modal and helper What's New with all recent features
Added: Track Redownload with source info and provenance tracking,
Spotify API rate limit improvements (caching, auto-pause workers),
YouTube Topic fix, CAA art toggle, Genius search improvements,
Genius rate limit interval increase. Updated existing redownload
entry to reflect final implementation (removed old blacklist-from-delete
reference, added streaming source search and provenance tracking).
2026-04-01 15:39:23 -07:00
Broque Thomas
b8006d93c3 Refine track number filename extraction — separator required
_extract_track_number_from_filename now requires a separator after
digits (dash, dot, bracket) to prevent parsing artist names like
"50 Cent" as track number 50. Also handles disc-track format "1-03".

This is a last-resort fallback only — standard downloads get track
numbers from Spotify/iTunes/Deezer metadata. Only affects files with
no metadata where the filename starts with digits.
2026-04-01 15:24:03 -07:00
Broque Thomas
0493f566df Fix redownload pipeline — full parity, stuck batch, button timing
Pipeline parity: redownload/start now fetches full track details from
the selected metadata source (Spotify/iTunes/Deezer) for real
track_number, disc_number, and album context. Sets explicit album
context flags so post-processing uses the standard album download path.

Stuck batch fix: active_count was 0, decremented to -1 on completion,
so batch never detected as complete. Now initializes active_count=1
and queue_index=1 since we submit the worker directly.

Button timing: Download Selected handler wired up immediately before
streaming starts, reads from window._redownloadCandidates which
updates live as results arrive. No longer blocked by slow Soulseek.

Track number: _extract_track_number_from_filename requires separator
after digits so "50 Cent" is not parsed as track 50.

Progress: real download stats from /api/downloads/status. Handles
streaming sources showing "Processing..." when no transfer found.
2026-04-01 14:59:30 -07:00
Broque Thomas
af98cb54c4 Fix redownload pipeline — Track object, confidence, lock safety
Three bugs that would crash when clicking Download Selected:

1. Passed raw dict to _attempt_download_with_candidates which accesses
   track.artists/track.album as attributes — now constructs Track object
2. TrackResult missing confidence attr — sort would crash — now set
   from candidate data
3. Redownload hook (old file delete + DB update) was inside tasks_lock
   blocking all task state changes — moved outside lock with proper
   None initialization
2026-04-01 14:14:50 -07:00
Broque Thomas
0fd6be3239 Stream download source results progressively via NDJSON
Step 2 of the redownload modal now streams results as each download
source responds instead of waiting for all sources to finish. Tidal/
YouTube/Qobuz columns appear instantly while Soulseek searches.

Backend: search-sources endpoint uses ThreadPoolExecutor + NDJSON
streaming — one JSON line per source as it completes.

Frontend: reads the NDJSON stream, appends columns with fade-in
animation as each source responds. Download button enables as soon
as any results arrive.

Each source gets its own column with results grouped and sorted by
confidence. Visual confidence bars, format badges, and source-specific
metadata (Soulseek username/slots). Best overall match auto-selected.
2026-04-01 13:53:57 -07:00
Broque Thomas
40f3621c48 Redesign redownload modal — columns, glass blur, Deezer fix, cover art
Major redesign:
- All metadata sources shown as side-by-side columns (not tabs)
- Frosted glass modal background with blur(40px) saturate(1.4)
- Album cover art in header from DB thumb_url (resolved for Plex)
- 1100px width, all elements scaled up, white text on accent buttons

Bug fixes:
- Deezer: use global singleton client, title-only fallback search,
  strip version suffixes from query
- Track.__init__: added missing popularity=0 parameter
- Overlay: dedicated .redownload-overlay class avoids CSS conflicts
2026-04-01 12:33:14 -07:00
Broque Thomas
04f01d36e1 Add track download provenance tracking and source info UI (#234)
New track_downloads table records every download with full source data:
service type (soulseek/youtube/tidal/etc), username, remote filename,
file size, and audio quality. Recorded at all 3 post-processing
completion points.

Source Info button (ℹ) on each track in the enhanced library view shows
a popover with download provenance: service, username, original filename,
size, quality, download date. Includes "Blacklist This Source" button
that stores the real username+filename (not guessed local filenames).

Removed broken "Delete & Blacklist" option from Smart Delete since it
had no access to real source data. Blacklisting now done exclusively
from the Source Info popover where actual provenance data exists.

Added blacklist CRUD API endpoints (GET/POST/DELETE /api/library/blacklist).
2026-04-01 11:37:21 -07:00
Broque Thomas
f8f87e0e38 Update version modal and helper What's New with redownload feature 2026-04-01 10:40:07 -07:00
Broque Thomas
35dd0546d1 Add Track Redownload modal with manual source selection (#234)
Three-step redownload flow in the enhanced library view:
1. Metadata Source — searches Spotify/iTunes/Deezer simultaneously,
   shows results with match scores, flags current match
2. Download Source — searches all active download sources (Soulseek,
   YouTube, Tidal, etc.), shows candidates with format/bitrate/size/
   confidence, flags blacklisted sources
3. Download — starts download, polls for progress, deletes old file
   on success, updates DB path

Also integrates the download blacklist into the download pipeline —
_attempt_download_with_candidates now skips blacklisted sources
automatically during all downloads (wishlist, playlist sync, etc.).

New redownload button (↻) on each track row in enhanced library view.
Post-processing hook deletes old file and updates DB track path after
successful redownload.
2026-04-01 10:36:51 -07:00
Broque Thomas
8d6486bee3 Add Smart Delete with file removal and download blacklist (#234)
Track delete in the enhanced library now shows three options:
- Remove from Library: DB record only (existing behavior)
- Delete File Too: DB + os.remove() the file from disk
- Delete & Blacklist: DB + file removal + add source to blacklist

New download_blacklist table stores rejected sources (username + filename)
with CRUD methods. Blacklist will be checked by the download pipeline
and the upcoming track redownload modal.

Smart delete modal styled with the same glass/dark theme as other
SoulSync modals, with color-coded destructive options.
2026-04-01 10:29:07 -07:00
Broque Thomas
660221d86a Show 'Yielding for downloads' on auto-paused enrichment workers
Dashboard enrichment chips show 'Yielding' instead of 'Paused' when
workers are auto-paused during downloads. Tooltips show 'Yielding for
downloads' for full context. Distinguishes user-paused from auto-paused.

Also handles edge case where user manually resumes a worker during
downloads — adds to override set so the loop doesn't re-pause it.
Override resets when downloads finish so next download session re-pauses.
2026-04-01 09:09:33 -07:00
Broque Thomas
c73df05fd9 Auto-pause rate-limited enrichment workers during downloads
Spotify, Last.fm, and Genius enrichment workers are now automatically
paused while any download batch is active. This prevents enrichment
API calls from competing with post-processing metadata lookups for
rate limit headroom, especially during heavy download scenarios
(3 playlist workers + wishlist downloads simultaneously).

Workers resume automatically when all downloads finish. Only workers
that were auto-paused are resumed — manually paused workers stay paused.
Piggybacks on the existing 2-second enrichment status loop with no new
threads or timers.
2026-04-01 08:55:38 -07:00
Broque Thomas
778e68a844 Improve Genius artist search for manual matching (#233)
The manual match modal for Genius only returned 0 or 1 artist result
because search_artist() searched songs (per_page=5), extracted the
primary artist, and returned the first match or None.

Added search_artists() that returns multiple unique artists extracted
from song results with broader search (per_page=20). The manual match
endpoint now shows up to 8 artist candidates and multiple track results
instead of one-or-nothing. Also shows the Genius URL as extra info.
2026-04-01 08:31:17 -07:00
Broque Thomas
982ca77501 Make Cover Art Archive album art opt-in instead of default (#232)
CAA art can be higher resolution (1200x1200+) but quality is
inconsistent — some releases have cellophane-wrapped photos or
low-quality scans. Spotify/iTunes/Deezer art is lower res (640x640)
but consistently clean and official.

New toggle: Settings → Post-Processing → "Use MusicBrainz Cover Art
Archive for album art" (off by default). Applies to both embedded
art and cover.jpg downloads.
2026-04-01 07:59:40 -07:00
Broque Thomas
89ef5f931f Route all Spotify search calls through cached methods
Five places in web_server.py called spotify_client.sp.search() directly,
bypassing the cached search_tracks()/search_artists() methods. Each
discovery worker (Tidal, YouTube, ListenBrainz, Beatport) was also
doubling API calls — sp.search() for raw data then search_tracks() for
Track objects.

Now all use cached methods only. Raw track data for album art is
retrieved from the metadata cache by track ID after matching. Also fixed
a pre-existing bug where Tidal discovery could pair stale Spotify raw
data with a newer iTunes match.

Bumped is_spotify_authenticated() probe cache TTL from 5 to 15 minutes
to reduce /v1/me calls (~288/day → ~96/day). Manual disconnect still
takes effect immediately via _invalidate_auth_cache().
2026-03-31 22:18:02 -07:00
Broque Thomas
54f927320d Fix skip_cache crash when watchlist scan uses non-Spotify client
iTunes and Deezer clients don't accept skip_cache param — only pass
it when the active client is the Spotify client (detected via sp attr).
2026-03-31 22:01:20 -07:00
Broque Thomas
a7ebde8c01 Add skip_cache param to get_artist_albums for watchlist scans
The watchlist auto-scan needs fresh data from Spotify to detect new
releases, so it bypasses the cache added in the previous commit.
All other callers (UI browsing, completion badges, discography views)
continue to benefit from cached results.
2026-03-31 21:59:46 -07:00
Broque Thomas
87f17a1318 Fix cover.jpg not using Cover Art Archive high-res source
The MusicBrainz release ID found by _embed_source_ids was stored in the
metadata dict but never propagated to album_info. The old code tried to
write album_info['musicbrainz_release_id'] inside _embed_source_ids, but
album_info wasn't in that function's scope — causing a silent NameError.

Fix: copy the MBID from metadata to album_info in _enhance_file_metadata
(where both are in scope) right after _embed_source_ids returns. This
makes _download_cover_art see the MBID and use Cover Art Archive for
cover.jpg instead of falling back to the smaller Spotify/iTunes thumbnail.
2026-03-31 21:40:19 -07:00
Broque Thomas
f275a9831e Strip '- Topic' suffix from YouTube auto-generated channel names (#231)
YouTube's auto-generated artist channels use the format "Artist - Topic"
as the channel name. This suffix was not being stripped during playlist
parsing, causing metadata discovery to fail (e.g., searching for
"Koven - Topic" instead of "Koven" on iTunes/Deezer).

Fixed in all three places where YouTube artist names are cleaned:
- web_server.py clean_youtube_artist() — playlist parsing
- ui/pages/sync.py clean_youtube_artist() — UI-side parsing
- core/youtube_client.py — search result fallback artist extraction
2026-03-31 21:06:05 -07:00
Broque Thomas
34c8b1bb50 Add Server Playlist Manager with dual-column compare editor
New "Server Playlists" tab (default on Sync page) lets users compare
mirrored playlists against their media server and fix match issues.

- Dual-column comparison: source tracks (left) vs server tracks (right)
- Smart matching: exact title first, then fuzzy artist+title (≥75%)
- Find & Add: search library to fill missing slots at correct position
- Swap: replace matched tracks with different versions
- Remove: delete tracks from server playlists with confirmation
- Title similarity percentage badge on each match
- Disambiguation modal when multiple mirrored playlists share a name
- Album art on source tracks, server tracks, and search results
- Cross-column click-to-scroll highlighting
- Filter buttons (All/Matched/Missing/Extra) with live counts
- Escape key and backdrop click to close modals
- Mobile responsive (stacked columns under 768px)
- Works with Plex, Jellyfin, and Navidrome
2026-03-31 20:58:27 -07:00
Broque Thomas
de3fba3f37 Add Sync History dashboard with per-track match caching and detail modal
New dashboard section shows recent syncs as scrolling cards with
playlist art, source badge, match percentage bar, and health color.
Click any card to open a detail modal showing every track's match
status, confidence score, album art, and download/wishlist status.

Per-track data is now cached in sync_history.track_results for all
sync paths: server-sync (playlist→media server), download missing
tracks, and wishlist processing. SyncResult carries match_details
from the sync service. Both image URLs and matched track info are
preserved for review.

Features:
- Staggered card entrance animation, delete button on hover
- Filter bar: All/Matched/Unmatched/Downloaded
- Color-coded confidence badges (green/amber/red)
- Unmatched tracks show "→ Wishlist" status
- 32px album art thumbnails per track row
- Auto-refreshes every 30 seconds on dashboard
- Falls back gracefully for old syncs without track_results
2026-03-31 15:26:46 -07:00
Broque Thomas
ad262822a4 Cache per-track results in sync history + fix config_manager import
Sync operations now store per-track data (name, artist, match status,
confidence, download status) in a new track_results column on
sync_history. Also fixed missing config_manager import in
add_to_wishlist that crashed the duplicate tracks toggle.
2026-03-31 14:21:37 -07:00
Broque Thomas
cfe2ab7dec Add toggle to disable auto-clearing slskd search history
Users who keep manual searches in slskd as reminders were losing
them when SoulSync auto-cleaned at 200+ entries. New toggle in
Settings → Downloads → Soulseek: "Auto-clear slskd search history"
(on by default, preserving current behavior). When disabled, both
the hourly cleanup automation and the full cleanup step skip the
search history maintenance.
2026-03-31 13:03:37 -07:00
Broque Thomas
f4407490c1 Skip slskd connection check when Soulseek is not an active source
The dashboard status poll and hybrid connection check were pinging
slskd every 2 minutes regardless of download source, flooding logs
with connection errors when slskd wasn't running. Now only checks
slskd when the download mode is 'soulseek' or when 'soulseek' is
in the hybrid order. Hybrid mode also only checks sources in the
configured priority order instead of all six.
2026-03-31 12:37:14 -07:00
Broque Thomas
2f4ff8213f Fix artist sync endpoint returning 404 for non-integer IDs (#230)
Route used <int:artist_id> which rejected Spotify/iTunes/Deezer
artist IDs. Changed to accept any string — tries as DB integer ID
first (verified against DB), then falls back to checking
spotify_artist_id, itunes_artist_id, deezer_artist_id columns.
Handles numeric iTunes/Deezer IDs correctly by verifying the DB
row exists before assuming it's a DB ID.
2026-03-31 11:33:46 -07:00
Broque Thomas
c50eee1563 Fix discovery sync running outside Flask context + pass profile ID
_sync_discovery_results_to_mirrored was calling get_current_profile_id()
which requires Flask's g context, but runs in a background thread.
Now captures the profile ID in the Flask endpoint (where context exists)
and passes it through the discovery state to the background worker.
Also added debug logging to trace mirrored playlist matching.
2026-03-31 10:54:11 -07:00
Broque Thomas
06f0e53a6f Sync discovery results back to mirrored playlist extra_data
Tidal, Deezer, and Beatport discovery workers found metadata matches
but only stored them in memory and the discovery cache — never wrote
them back to the mirrored playlist tracks. This meant playlists had
to be re-discovered every time. Now writes {discovered, provider,
confidence, matched_data} to each mirrored track's extra_data after
discovery completes, matching the pattern YouTube and automation
pipeline discovery already used. Matches tracks by source_track_id
first, falls back to position index. Purely additive — discovery
logic is untouched.
2026-03-31 10:27:44 -07:00
Broque Thomas
3154d16cf3 Fix slow Tidal playlist loading — metadata only, no track fetching
get_user_playlists_metadata_only() was fetching full track lists for
every playlist sequentially (1+ API calls per playlist with 1s sleep
between pagination pages). For 20+ playlists this took 30-60 seconds.

Now returns only metadata (name, ID, track count, image) from a
single V2 API call. Track count comes from the numberOfTracks
attribute. Tracks are fetched on-demand when the user selects a
specific playlist to sync/mirror via the existing get_playlist()
endpoint.
2026-03-31 09:27:15 -07:00
Broque Thomas
03298afac1 Change fresh install defaults: hybrid download + Deezer metadata
Fresh installs now default to hybrid download mode (HiFi → YouTube →
Soulseek) instead of Soulseek-only, and Deezer as the metadata
fallback source instead of iTunes. Existing users with saved settings
are unaffected — defaults only apply when config keys don't exist.
2026-03-31 09:04:35 -07:00
Broque Thomas
1fb9b45d59 Fix wishlist download status tooltip showing wrong track name (#227)
The tooltip on failed/not_found tracks was offset by 3-6 items because
the backend cleanup step removed owned tracks from the wishlist between
when the frontend rendered the table and when the backend assigned
track indices. Surviving tracks got new enumeration indices (0,1,2...)
that didn't match their original table row positions (0,1,3,4...).

Fix: stamp each track with its position in the frontend's track_ids
array as _original_index, so the track_index always matches the modal
table row regardless of how many tracks were cleaned during processing.
2026-03-31 08:41:01 -07:00
Broque Thomas
6c4d8d9348 Fix race condition: paused enrichment workers making API calls on startup
Worker threads were started before paused flag was set, allowing one
loop iteration (and API call) before pause took effect. Now sets
paused=True BEFORE start() so the thread sees it immediately. Fixes
the Spotify rate limit re-trigger on every container restart for users
with paused enrichment. Also increased max-retries rate limit ban
from 1 hour to 4 hours to prevent endless retry cycles.
2026-03-31 08:14:25 -07:00
Broque Thomas
a52edde733 Fix discovery toast always showing iTunes instead of active source
YouTube and ListenBrainz discovery toasts had source_label hardcoded
to 'iTunes' when not using Spotify. Now uses discovery_source.upper()
like the other discovery functions, so it correctly shows DEEZER when
Deezer is the active metadata source.
2026-03-30 22:20:44 -07:00
Broque Thomas
13ea2fc1fe Guard album_info None check in cover art MBID storage
album_info can be None for single track downloads — the dict
assignment would crash with TypeError. Added None check.
2026-03-30 22:02:45 -07:00
Broque Thomas
f3047c46cf Use Cover Art Archive for cover.jpg downloads (high-res)
cover.jpg was always using the Spotify/iTunes thumbnail (640x640).
Now tries Cover Art Archive first (1200x1200+) when MusicBrainz
release ID is available. One line stores the MBID on album_info
during _enhance_file_metadata so _download_cover_art can use it.
Falls back to the source thumbnail if CAA has no art. Works for
all metadata sources since MusicBrainz enrichment is source-agnostic.
2026-03-30 19:49:17 -07:00
Broque Thomas
d944d4a7d2 Fix Japanese/CJK text mangled in Soulseek search queries
normalize_string() was running unidecode on all text, converting
Japanese kanji to Chinese pinyin gibberish (命の灯火 → "tvanimedei").
Now detects CJK characters (kanji, hiragana, katakana, hangul,
fullwidth forms) and skips unidecode for text containing them —
just lowercases instead. Non-CJK text (Latin accents, Cyrillic)
still goes through unidecode normally.
2026-03-30 18:28:25 -07:00
Broque Thomas
1646c3d9e1 Fix partial name matching false positives (#225)
"Believe" was falsely matching "Believe In Me" because SequenceMatcher
gives high scores when the search string is fully contained in the
match. Added a length ratio penalty: when cleaned titles differ in
length by more than 30%, the similarity score is multiplied by the
ratio (min/max length). This crushes prefix/suffix false positives
while leaving exact matches and cleaned variants (remastered, deluxe)
unaffected.
2026-03-30 17:58:59 -07:00
Broque Thomas
f788361b08 Fix pipeline stopping when metadata discovery fails (#224)
Playlist auto-sync was dropping tracks that failed iTunes/Apple Music
discovery — they never reached the wishlist or download pipeline. Now
undiscovered tracks continue through using available metadata: first
from the spotify_hint (embed scraper data with real Spotify track ID,
name, artists), then from raw playlist fields if a source track ID
exists. Album cover art from the mirrored playlist is included. Only
tracks with no usable ID or name are skipped.
2026-03-30 17:50:30 -07:00
Broque Thomas
69346ec313 Add Playlist Explorer — visual discovery tree for expanding playlists
New dedicated Explorer page with interactive node graph visualization.
Users select a mirrored playlist, choose Albums or Discographies mode,
and the app builds a branching tree: playlist root → artist nodes →
album nodes → track nodes. Supports all metadata sources (Spotify,
iTunes, Deezer) with source-aware discovery cache integration.

Features:
- Streaming NDJSON builds tree progressively as artist data arrives
- Circular artist nodes with photos, rounded album nodes with art
- SVG bezier connections that draw in on completion, fade on hover
- Click artist to expand albums, double-click album for track listing
- Single-click albums to select, Select All/Deselect for bulk ops
- Wishlist confirmation modal with per-album progress (NDJSON streaming)
- Artist nodes glow when any of their albums are selected
- Playlist picker with source tabs, discovery % gate (50% minimum)
- Zoom (scroll/pinch/buttons), pan (right/middle-drag), fit-to-view
- Metadata cache for discographies and album track listings
- Owned album detection from library database
- Fallback track-name matching when album names are missing
2026-03-30 17:13:30 -07:00
Broque Thomas
fde1a7d77e Fix .lrc files written without timestamps for plain lyrics
Plain (unsynced) lyrics were being saved with .lrc extension despite
having no timestamps, making them invalid for Plex and other players
that expect LRC format. Synced lyrics now write as .lrc, plain lyrics
write as .txt. Both types still get embedded in audio file tags.
Updated all file move/rename operations to handle .txt sidecars
alongside .lrc.
2026-03-30 13:28:41 -07:00
Broque Thomas
c5652b0d4b Add API call counts and Spotify budget to dashboard service chips
Enrichment chips now show live activity: 24h call count for all
services and daily budget usage (used/3,000) with gradient progress
bar for Spotify. Tracking is centralized in _get_enrichment_status
using cumulative stat diffs over a rolling deque — no worker files
modified. Added section header, "Configure →" label for unconfigured
services, and full 1h/24h breakdown in tooltips.
2026-03-30 13:20:22 -07:00
Broque Thomas
525a09c840 Fix collaborative album artist not applied to single downloads (#215)
Single path template was missing _artists_list and _itunes_artist_id
context keys, so the collab mode first-artist extraction in
_apply_path_template had nothing to work with — $albumartist resolved
to the full multi-artist string. Added both keys matching the exact
pattern used by album and playlist modes, including the iTunes
spotify_album.external_urls fallback. Updated settings UI hints to
show $albumartist as available for single and playlist templates.
2026-03-30 11:37:05 -07:00
Broque Thomas
f2e24a36df Fix enrichment overwriting manual match status (#221)
When a user manually matched an artist to a service ID then triggered
enrichment, the worker re-searched by name, failed to find a match,
and overwrote the status back to not_found — despite the ID being
valid. Now both Genius and AudioDB workers check for existing service
IDs before searching by name. If an ID exists (from manual match),
the worker uses it for a direct API lookup to enrich metadata while
preserving the matched status. Added AudioDB lookup-by-ID client
methods for artist, album, and track.
2026-03-30 11:07:48 -07:00
Broque Thomas
edaa55ae82 Harden Spotify OAuth callback for Docker/SSH tunnel setups (#220)
Top-level try/except in do_GET ensures an HTTP response is always sent
— previously, unhandled exceptions caused BaseHTTPRequestHandler to
silently close the connection (ERR_EMPTY_RESPONSE). All callback
logging now uses the app logger instead of print() so output appears
in app.log rather than only Docker stdout. Added health check at / to
verify the callback server is running, and startup now logs the actual
bind address to help diagnose port conflicts.
2026-03-30 10:19:32 -07:00
Broque Thomas
32adc66fe3 Show all services on dashboard with click-to-configure (#219)
Dashboard now displays all enrichment services as live-status chips
below the core service cards. Each chip shows Running, Idle, Paused,
Stopped, or Not Configured state with color-coded left border accents.
Unconfigured services appear dimmed with dashed borders — clicking any
configurable chip navigates to Settings → Connections and scrolls to
the relevant service section.

Also fixes the Spotify card always being labeled "Apple Music" when
using iTunes fallback — card now always says "Spotify" with an amber
"Using iTunes/Deezer" indicator when fallback is active.
2026-03-30 10:04:13 -07:00
Broque Thomas
ab8e44dafd Add Qobuz credentials to Settings Connections tab (#218)
Qobuz login was only available on the Downloads tab when Qobuz was
selected as download source. But Qobuz credentials are also needed
for the enrichment worker which runs independently. Users saw
"Connect Qobuz in Settings" on the dashboard but couldn't find it.

Adds a Qobuz section to Settings → Connections (same pattern as
Tidal's existing Connections section). checkQobuzAuthStatus() now
syncs both the Connections and Downloads tab sections. Login from
either tab updates both. No backend changes — same API endpoints.
2026-03-30 08:45:30 -07:00
Broque Thomas
7133595e0d Fix enrichment widget showing Running when rate limited (#217)
The tooltip only checked paused/authenticated/idle/running states.
When Spotify was rate limited or daily budget exhausted, the worker
thread was still alive (sleeping in guards) so it showed "Running"
with no current item and stale 0% progress.

Now checks rate_limited and daily_budget.exhausted before running:
- Rate limited: "Rate Limited — Waiting Xm for rate limit to clear"
- Budget exhausted: "Daily Limit Reached — Resets in Xh Xm"
- No current item: "Waiting for next item..." instead of blank

Also adds rate_limit info object to get_stats() response for the
countdown display.
2026-03-30 08:12:56 -07:00
Broque Thomas
59587162cd Add metadata cache maintenance and health monitoring
Cache maintenance:
- Input validation rejects junk entities (Unknown Artist, empty names)
  from being cached, with exemptions for synthetic entries (_features,
  _tracks suffixes)
- CacheEvictorJob expanded to 4 phases: TTL eviction, junk cleanup,
  orphaned search cleanup, MusicBrainz failed lookup cleanup
- MusicBrainz null results now expire after 30 days (was 90) so failed
  lookups get retried sooner

Cache health UI:
- Polished modal accessible from Dashboard "Cache Health" button and
  repair dashboard health bar
- Shows health status banner (healthy/fair/poor), stat cards, source
  breakdown with colored progress bars, type pills, and metrics table
- Repair dashboard shows compact bar with health dot indicator
2026-03-30 07:40:20 -07:00
Broque Thomas
4e6b424bc7 Fix wishlist Download Selection ignoring checkbox selections
downloadSelectedCategory() was passing only the category name to the
download function, which fetched ALL tracks in that category. Now
collects checked track IDs from checkboxes BEFORE closing the modal
(DOM is destroyed on close), then filters the fetched tracks to only
the selected ones.

If nothing is checked, downloads the full category (same as before).
Other callers of openDownloadMissingWishlistModal are unaffected —
the new selectedTrackIds parameter defaults to null.
2026-03-29 21:43:48 -07:00
Broque Thomas
1b532503cb Fix Tidal OAuth redirect URI ignoring user config in Docker
The auth_tidal() endpoint was overriding the user's configured
redirect_uri with one built from request.host. In Docker, request.host
is the container hostname (e.g. "soulsync-webui"), not the external
URL the user configured in settings.

Now checks config_manager for the user's configured redirect_uri first.
Only falls back to request.host dynamic detection if no redirect URI
is configured.
2026-03-29 21:32:42 -07:00
Broque Thomas
a803e8399c Add Cover Art Archive as high-resolution album art source
Album art embedding now tries Cover Art Archive first (using the
MusicBrainz release ID from source ID embedding) before falling back
to Spotify/iTunes/Deezer URLs. CAA provides original-quality artwork,
often 1200x1200 or higher vs Spotify's 640x640 max.

Reordered _embed_source_ids to run before _embed_album_art_metadata
so the MusicBrainz release ID is available for the CAA lookup. Also
fixed hardcoded 640x640 FLAC picture dimensions — now detects actual
size from image bytes. Falls back to existing behavior if CAA fails
or no release ID exists.
2026-03-29 20:40:27 -07:00
Broque Thomas
7cff379aa7 Embed lyrics directly in audio file tags
Lyrics from LRClib are now embedded in audio file tags (USLT for MP3,
lyrics for FLAC/OGG, ©lyr for M4A) in addition to the .lrc sidecar
file. Navidrome, Jellyfin, and Plex can read embedded lyrics but not
all support .lrc files.

Embedding is best-effort — if it fails, the .lrc file still exists.
Only adds to existing tags, never creates tags from scratch.
2026-03-29 20:15:26 -07:00
Broque Thomas
f68cae64a7 Skip AcoustID verification for high-confidence cross-language matches
When the fingerprint score is >=0.95 but title/artist don't match
(e.g. English expected vs Japanese returned), SKIP instead of FAIL.
A 95%+ fingerprint means the audio IS the correct recording — the
metadata mismatch is just a language/script difference, not a wrong
file. Prevents Japanese, Chinese, Korean, and other non-Latin tracks
from being falsely quarantined.

Happy path unchanged — matching title/artist still returns PASS at
the earlier check before this code is reached.
2026-03-29 20:04:41 -07:00
Broque Thomas
c0bb1a4f34 Save cleared tags to disk before metadata enhancement
Previously, tags.clear() only cleared in memory — if any later step
threw (metadata extraction, API calls, album art download), the file
was moved with its original Soulseek source tags intact. This caused
album fragmentation in media servers when some tracks had MusicBrainz
IDs and others didn't.

Now the cleared tags are saved to disk immediately after wiping. If
enhancement succeeds, the file is saved again with full metadata
(identical to before). If it fails, the file has clean empty tags
instead of inconsistent junk — media servers group by folder structure
which is always correct.
2026-03-29 19:45:27 -07:00
Broque Thomas
3c47281ce5 Redesign Watch All Unwatched as polished preview modal
Replaces the fire-and-forget button with a premium modal that shows
exactly which artists will be added before confirming. Features:

- Glassmorphic modal with stat cards, two-column artist grid, search
  filter, collapsible ineligible section, and loading spinner
- Source-aware filtering: only shows artists with the active source's
  ID (Spotify/iTunes/Deezer) as eligible
- Frontend and backend both paginate at 400 to avoid SQLite variable
  limit (SQLITE_MAX_VARIABLE_NUMBER=999) that silently broke queries
  above ~500 artists
- Backend source detection aligned with frontend — uses only the
  active source's ID, falls back to configured metadata source
2026-03-29 18:12:42 -07:00
Broque Thomas
56305615a4 Fix Watch All Unwatched ignoring Deezer artist IDs
The bulk watchlist add had no Deezer ID support — artists with only a
deezer_id were silently skipped (artist_id stayed None). Also fixed the
source detection to use the actual ID field picked instead of a numeric
heuristic that could assign Deezer IDs to the wrong service column.

Fallback chain is now: active source first, then Spotify → iTunes → Deezer.
2026-03-29 16:41:27 -07:00
Broque Thomas
98463e5d43 Fix library maintenance path fixes failing silently (#207)
fix_finding() was using a potentially stale transfer_folder that was
only refreshed during scheduled job runs, not on manual fix attempts.
Now re-reads the transfer path from config before each fix, matching
the same logic used by _run_next_job().

Also surfaces fix failure reasons to the user — bulk fix now logs each
failure with finding ID and error, and the frontend toast shows the
actual error message instead of just "X failed".
2026-03-29 16:25:36 -07:00
Broque Thomas
213736e168 Fix manual match storing iTunes/Deezer IDs in Spotify ID columns (#213)
When Spotify falls back to iTunes/Deezer (auth failure, rate limit, API
error), the manual match modals were storing numeric fallback IDs in
spotify_*_id columns, breaking Spotify links and metadata fetching.

Fix detects the actual provider by inspecting result IDs (alphanumeric =
Spotify, numeric = fallback) rather than checking auth status, which can
be misleading during rate limit bans. The frontend now passes the real
provider to the storage endpoint so IDs land in the correct column.
2026-03-29 16:04:22 -07:00
Broque Thomas
3f866ebf5e Add daily budget to Spotify enrichment worker to prevent rate limit bans
The background enrichment worker now caps itself at 3,000 processed items
per calendar day. Counter resets at midnight automatically. When exhausted,
the worker sleeps and checks every 5 minutes for a new day.

This is scoped entirely to the enrichment worker — user-initiated Spotify
API calls (searches, playlist ops, album lookups, etc.) are completely
unaffected. Budget status is exposed in the worker's get_stats() response
for the dashboard widget.
2026-03-29 15:34:41 -07:00
Broque Thomas
be86ed8799 Fix OPUS files losing all metadata during import/post-processing
OggOpus (Mutagen) is a separate class from OggVorbis but uses the
same VorbisComment tag API. All isinstance checks for (FLAC, OggVorbis)
missed OggOpus, so tags were cleared but never rewritten — resulting
in empty metadata and "Unknown Artist" on the media server.

Added _is_ogg_opus() helper and applied to all 15 format checks in
web_server.py and 2 in core/tag_writer.py. No change to FLAC/OGG/
MP3/M4A handling (short-circuit evaluation skips the new check).
2026-03-28 09:37:12 -07:00
Broque Thomas
b5b03a2b86 Add Download Discography feature on artist detail page
New "Download Discography" button in artist hero section opens a modal
showing the full catalog — albums, EPs, and singles — with filter
toggles, select/deselect all, and per-album owned/missing indicators.

Modal features:
- Glassmorphic design with artist image blurred background header
- Filter pills for Albums/EPs/Singles with instant grid filtering
- Album cards with cover art, year, track count, and checkbox
- Owned albums dimmed and unchecked by default, missing pre-selected
- Live NDJSON streaming: each album updates in real-time as processed
- "Process Wishlist Now" button after completion
- Albums sorted by track count (Deluxe first) to prevent duplicate
  folder contexts from standard/deluxe edition ordering

Backend: NDJSON streaming endpoint POST /api/artist/<id>/download-discography
- Fetches tracks per album via active metadata client
- Adds to wishlist with dedup (no slow fuzzy matching)
- Streams one JSON line per album as it completes
- Works on both Artists search page and Library artist detail page
2026-03-27 22:15:05 -07:00
Broque Thomas
2909d29614 Update version modal and helper What's New with all recent features
Added 7 new sections to version modal: Stream Source, YouTube Fix,
Completion Badges, Collab Album Handling, Per-Artist Sync, Stability
fixes. Updated helper What's New with 5 new entries.
2026-03-27 15:46:25 -07:00
Broque Thomas
326bb548ce Add per-artist Sync button on enhanced library view
New "Sync" button in the enhanced view header validates an artist's
library entries against files on disk. Removes stale tracks (missing
files), cleans empty albums, and updates track counts.

- POST /api/library/artist/<id>/sync endpoint
- Checks each track's file_path via _resolve_library_file_path
- Empty album cleanup checks ALL tracks (not just this artist's)
  to avoid deleting albums shared with other artists
- Toast shows results: stale removed, albums cleaned, or "All files
  verified" if everything checks out
- Auto-refreshes enhanced view when changes are made
2026-03-27 15:36:08 -07:00
Broque Thomas
f94f043dc6 Fix album delete returning HTML instead of JSON for non-integer IDs
The DELETE /api/library/album/<id> route used <int:album_id> which
rejected non-integer IDs (e.g., Navidrome string IDs). Flask returned
its default 404 HTML page, causing "unexpected token <" JSON parse
error on the frontend. Changed to <album_id> to match all other
album routes which already accept any ID type.
2026-03-27 15:00:41 -07:00
Broque Thomas
b49806a83a Add collaborative album artist handling with per-source resolution
New setting in Settings → Library → File Organization: "Collaborative
Album Artist" — choose between first listed artist (default) or all
artists combined for $albumartist in folder paths and album_artist tag.

Per-source resolution:
- Spotify: artists array has separate objects — picks first directly
- Deezer: API already returns first artist only — no change needed
- iTunes: combined string ("Larry June, Curren$y & The Alchemist") —
  resolves via artistId API lookup to get primary name ("Larry June").
  Safe for "Tyler, the Creator" and "Simon & Garfunkel" because their
  IDs resolve to the same combined name (no change).

Applied to both folder path ($albumartist template) and album_artist
metadata tag for consistency. Track artist tag always keeps all artists.
iTunes lookup only fires when source is iTunes (numeric ID + not Deezer).
2026-03-27 13:39:27 -07:00
Broque Thomas
72fab16bad Add POST /api/wishlist/process endpoint for external API access
Allows external apps to trigger wishlist processing without needing
to look up automation IDs. Returns 409 if already running. Uses the
same _process_wishlist_automatically function as the automation engine.
2026-03-27 11:58:22 -07:00
Broque Thomas
7f9755a26e Add album-aware track matching for multi-artist albums
When artist-specific track search fails, falls back to album-aware
matching: finds the album by title (any artist), then checks if the
track exists on it. Fixes daily re-downloads of collaborative albums
filed under a different artist (e.g., "Spiral Staircases" tagged
under "The Alchemist" but scanned from "Larry June's" watchlist).

- check_track_exists: new album parameter, album-aware fallback with
  0.8 album title threshold + 0.7 track title threshold
- Watchlist scanner: passes album_data.get('name') to track checks
- Download modal: passes batch_album_context to fallback track search
- Wishlist callers (4 spots): extract and pass track album name
- Backwards compatible: album=None default, no change for callers
  without album context (singles, playlists)
2026-03-27 11:44:01 -07:00
Broque Thomas
4baf5e53d4 Fix track numbering and disc assignment for non-Spotify metadata sources
The download context builder always called spotify_client.get_track_details()
first for track_number/disc_number, which fails or returns wrong data when
using Deezer/iTunes as metadata source. All tracks got track_number: 0,
defaulting to 01, and disc_number: 1.

Fix: check track_info (from frontend album data) first, then the track
object, then API call as last resort. The album tracks response already
has correct track_position and disk_number from Deezer — no API call needed.
2026-03-27 11:33:51 -07:00
Broque Thomas
cab12b61a6 Fix missing year and track numbers when Deezer metadata cache is stale
Root cause: search_albums cached album data without release_date or
track_position. get_album_metadata accepted this incomplete cache hit
(only checked for title), never called the full API. Result: year
empty, all tracks numbered 01.

Fix: get_album_metadata now requires release_date in cache to use it.
Search results without release_date trigger a fresh /album/{id} API
call which returns complete data. Also improved track_number fallback
in download context builder when Spotify isn't configured.
2026-03-27 11:24:36 -07:00
Broque Thomas
a0b2fa9441 Fix false album completion badges, add multi-artist album matching
Completion accuracy:
- Exact match only: "Complete" requires owned_tracks >= expected_tracks,
  no more 90% rounding that hid missing tracks
- Deduplicate track counting: DISTINCT (title, track_number) prevents
  duplicate album entries from inflating owned count (e.g., 3 "GNX"
  entries with 12+1+2 rows counted as 12 unique tracks, not 15)
- MAX(track_count) instead of SUM for stored count — uses largest
  album entry rather than summing duplicates
- file_path IS NOT NULL filter ensures only real files are counted
- Frontend uses real numbers instead of overriding missing=0 when
  backend says "completed"

Multi-artist albums:
- Title-only fallback search when artist-specific search fails
- Finds "Anger Management" filed under "The Alchemist" when checking
  from Rico Nasty's page
- Same confidence scoring prevents false matches
2026-03-27 09:25:08 -07:00
Broque Thomas
a33f891fa6 Add per-artist watchlist lookback period override
New "Scan Lookback" dropdown in the watchlist artist config modal.
Each artist can override the global lookback period (7d to entire
discography). Default is "Use Global Setting" (NULL in DB).

- Database: lookback_days INTEGER DEFAULT NULL on watchlist_artists,
  auto-migrated on startup
- Scanner: checks per-artist lookback_days first, falls back to
  global discovery_lookback_period if NULL
- Backend: GET/POST /api/watchlist/artist/<id>/config includes
  lookback_days. Changing lookback clears last_scan_timestamp to
  force a rescan with the new window
- Frontend: dropdown with 8 options in artist config modal
- Fully backwards compatible — existing artists unchanged
2026-03-27 08:17:02 -07:00
Broque Thomas
db104ec155 Auto-reconnect Hydrabase WebSocket when server restarts
Background monitor checks connection every 30s. If auto_connect is
enabled and socket is dead, reconnects using saved credentials.
Exponential backoff (30s→60s→120s→max 300s) on repeated failures.
Log suppression after 3 consecutive failures to prevent spam.

Fixes: Hydrabase server restart required manual reconnect from UI.
Now recovers automatically within 30s of server becoming available.
2026-03-26 20:21:14 -07:00
Broque Thomas
9fcbd323a5 Add stream source setting, auto-update yt-dlp on container start
Stream source:
- New setting in Settings → Downloads: "Stream / Preview Source"
- Options: YouTube (instant, default) or Active Download Source
- YouTube streams require no auth and are instant
- If active source is Soulseek, automatically falls back to YouTube
- Uses direct client search (bypasses orchestrator's download mode)
- Config key: download_source.stream_source

Docker:
- entrypoint.sh now runs pip install -U yt-dlp on every container
  start, so Docker users always have the latest yt-dlp without
  rebuilding the image
2026-03-26 19:27:35 -07:00
Broque Thomas
de5e3f6b3f Skip slskd API calls when known disconnected to prevent timeout spam
When slskd is unreachable, the download status emitter was making two
blocking API calls every 2 seconds (0.75s cache TTL), each timing out
after 5-10s. With 9 enrichment workers + WebSocket emitters competing
for threads, this created enough thread pressure to trigger Werkzeug's
"write() before start_response" race condition and crash the process.

Fix: check _status_cache (refreshed every 2 min by /status endpoint)
before calling slskd. If soulseek is known disconnected, skip both
transfers/downloads calls and return empty data. Normal behavior
resumes within 2 minutes of slskd recovering.
2026-03-26 14:49:25 -07:00
Broque Thomas
a98108b3ed Persist enrichment worker pause state across restarts
Bug: pausing workers via UI only called .pause() in memory — never
saved to config. On process restart (crash, OOM, Docker healthcheck),
all workers came back active, silently discarding user's changes.

Fix:
- All 18 pause/resume endpoints (9 workers × 2) now write to
  config_manager: e.g. config_manager.set('spotify_enrichment_paused', True)
- All 9 worker startup blocks check config and immediately pause
  if the saved state says paused
- Status endpoint already reads the same config keys (line 4217),
  so dashboard display is consistent

Workers: MusicBrainz, AudioDB, Deezer, Spotify, iTunes, Last.fm,
Genius, Tidal, Qobuz
2026-03-26 13:41:51 -07:00
Broque Thomas
f19db4ecce Add launch PIN lock screen with credential-based recovery
Security:
- Toggle in Settings → Advanced: "Require PIN to access SoulSync"
- Full-screen lock overlay on every page load when enabled
- PIN validated server-side against admin profile (bcrypt hash)
- Inline PIN creation if admin has no PIN set, change PIN button if set
- One-time session flag: verify-launch-pin sets it, /profiles/current
  consumes it — every page load re-requires PIN

Recovery:
- "Forgot PIN?" on lock screen switches to credential verification
- User pastes any configured API key/token/secret (Spotify, Tidal,
  Plex, Jellyfin, Navidrome, ListenBrainz, AcoustID, Last.fm, Genius)
- Server checks against all 9 stored values — any match clears PIN
  and disables lock, with toast guiding to Settings to set a new one

Profile switch integration:
- Entering PIN during profile switch also sets launch_pin_verified
  flag, preventing double-PIN prompt on the subsequent page reload

Updated version modal and helper What's New with this feature.
2026-03-26 13:15:36 -07:00
Broque Thomas
5305481187 Update version modal and helper What's New with latest features
- Added 6 new sections to /api/version-info: Interactive Help System,
  Rich Artist Profiles, Enhanced Library Manager, In Library Badges,
  FLAC Bit Depth, Enrichment Worker Improvements
- Consolidated helper WHATS_NEW under v2.1 (no version bump) with
  12 entries covering all recent features with Show me navigation
- Removed stale v2.2 key from WHATS_NEW that referenced unbumped version
2026-03-26 12:25:53 -07:00
Broque Thomas
f6709c7cc3 Add library ownership badges to enhanced search results
Search results now show "In Library" badges on albums and tracks
that already exist in the user's library. Badges appear with a
staggered fade-in animation after results render (non-blocking).

- Backend: /api/enhanced-search/library-check endpoint builds
  owned album/track sets in 2 queries, O(1) lookups per result
- Frontend: async call after render, 30ms stagger per badge
- Tracks in library get play button rewired for direct playback
  from media server instead of searching download sources
- Fixed enhanced search album card text not visible (info div
  now absolute-positioned with gradient overlay)
- Download manager panel hidden by default for more search space
2026-03-25 19:39:19 -07:00
Broque Thomas
6c3b9ddfc2 Optimize watchlist card CSS, backfill missing album covers
Watchlist cards: removed spring-bounce transitions, staggered
animations, and multi-layer hover shadows. Added CSS containment
and will-change for smoother scrolling.

Recent releases: backfill missing album cover art on page load
via metadata source lookup. Persists found covers to database.
2026-03-25 18:36:45 -07:00
Broque Thomas
46308d8d31 Fix watchlist artist image not saving for Deezer source
Two bugs preventing Deezer artist images in the watchlist:

1. web_server.py: The image fetch called get_artist() which doesn't
   exist on DeezerClient. Replaced with direct Deezer API call for
   picture_xl — simple and reliable.

2. music_database.py: update_watchlist_artist_image() only matched
   spotify_artist_id and itunes_artist_id in the WHERE clause.
   Deezer artists use deezer_artist_id, so the UPDATE matched zero
   rows and the image was never saved. Added deezer_artist_id to
   the WHERE clause.
2026-03-25 17:49:33 -07:00
Broque Thomas
de8f758596 Fix watchlist artist image not loading for Deezer source
The watchlist add code called fallback.get_artist() which doesn't
exist on the Deezer client (only get_artist_info). The AttributeError
was silently caught, leaving image_url as None. Now uses
get_artist_info() and falls back to a direct Deezer API call for
picture_xl when the Spotify-compatible format doesn't have images.
2026-03-25 17:37:59 -07:00
Broque Thomas
d248c36da1 Redesign Artists page: rich hero section, full-bleed cards, multi-source genres
Artists page hero section:
- Large portrait artist photo (400x480px, rounded rectangle)
- Blurred saturated background from artist image
- 2.6em bold name with text shadow
- Real service logo badges (Spotify, MusicBrainz, Deezer, iTunes,
  Last.fm, Genius, Tidal, Qobuz) — matching library page
- Genre pills merged from metadata cache + Last.fm tags
- Last.fm bio with read more/show less toggle
- Last.fm listener count + playcount stats (large bold numbers)
- Backend enriches discography response with artist_info from
  metadata cache + library (all service IDs, Last.fm data, genres)

Album/Single/EP cards:
- Full-bleed cover art filling entire card with gradient overlay
- Album name + year overlaid at bottom over dark gradient
- Image zoom on hover, accent glow for dynamic-glow cards
- Responsive grid (220px desktop, 170px tablet, 140px mobile)

Similar artist cards:
- Full-bleed image cards matching library artist card style
- Gradient overlay with name at bottom, aspect-ratio 0.8
- Grid-controlled sizing via existing responsive breakpoints

Genre explorer (multi-source):
- Queries all allowed sources (iTunes+Deezer always, Spotify when
  authed) via _get_genre_allowed_sources() helper
- Deezer genre support: genre_id mapping from search results,
  one-time backfill from stored raw_json, album-to-artist propagation
- Genre deep dive deduplicates artists across sources
- Source dots on artists/tracks in deep dive modal
- Artist clicks route through source-specific client
- Album endpoint falls back across sources when IDs don't match
- Genre explorer cached 24hr in-memory, positioned at top of
  Discover page below hero slider

All changes mobile responsive with proper breakpoints.
2026-03-25 11:49:51 -07:00
Broque Thomas
e08462a002 Multi-source genre explorer with Deezer genre support and cross-source routing
Genre explorer and deep dive modal now combine data from all available
metadata sources (iTunes + Deezer always, Spotify when authenticated).
Artists are deduplicated by name across sources, preferring entries
with images. Source dots (green/red/purple) indicate data origin.

Deezer genre support:
- Extract genre_id from Deezer album search responses via ID-to-name
  mapping table (26 Deezer genre categories)
- Extract full genre names from Deezer get_album responses
- One-time backfill updates existing cached albums from stored raw_json
- Propagate album genres to Deezer artist entities

Cross-source album routing:
- /api/discover/album endpoint uses source-specific client (iTunes or
  Deezer) based on the item's source, not just the active fallback
- Spotify path falls back to active fallback when album not found
- Track clicks use album_id directly instead of name-based resolution
- resolve-cache-album adds partial match and live search fallback

Other fixes:
- Genre explorer positioned at top of Discover page (below hero)
- Genre explorer results cached 24hr in-memory for fast reload
- Related genres computed from all albums by matched artists
- Artist clicks open Artists page with discography (not library detail)
- Discovery pool genre queries restored to source-filtered (Browse by
  Genre tabs stay source-isolated as designed)
2026-03-24 19:36:11 -07:00
Broque Thomas
6101832ee1 Add search filter and rematch to discovery pool modal
Discovery pool lists (matched and failed) now have a search input
that filters tracks client-side by name, artist, or playlist.

Matched tracks get a "Rematch" button that opens the fix modal in
cache-only mode — deletes the old cache entry and saves the new
match directly to the discovery cache via /api/discovery-pool/rematch.
This works regardless of whether a mirrored playlist track exists.

Failed tracks retain the existing "Fix Match" flow unchanged.
2026-03-24 13:07:04 -07:00
Broque Thomas
ee9f3b7386 Fix $albumtype template not working for singles in album context search
The album-aware search (_detect_album_info_album_context) was forcing
is_album=True for every track found in any release, including singles.
This routed singles through the album_path template instead of
single_path, ignoring the user's $albumtype folder structure.

Now applies the same classification logic as _detect_album_info_web:
checks album_type, total_tracks, and name comparisons to correctly
distinguish albums from singles/EPs. Works for all metadata sources
(Spotify, iTunes, Deezer) since all album objects have album_type.
2026-03-24 12:02:48 -07:00
Broque Thomas
434ac417fd Add artist/title verification for streaming source download candidates
Streaming sources (Tidal, Qobuz, Deezer, HiFi, YouTube) were blindly
trusted with confidence 1.0 — all search results passed through without
any matching. When searching common track titles like "Die for You",
the most popular version (The Weeknd) could be downloaded instead of
the intended artist (Grabbitz/Valorant).

Now verifies each streaming result's artist and title against the
expected track before accepting it. Artist must substring-match or
have >=0.6 similarity; title needs >=0.5 similarity. Results sorted
by title confidence. If nothing passes, falls through to the standard
matching engine instead of silently downloading the wrong track.
2026-03-24 11:03:43 -07:00
Broque Thomas
01b0b70515 Fix enhanced view reorganize not moving sidecars, add template debug logging
Enhanced album reorganize now moves LRC, cover.jpg, folder.jpg and other
sidecar files alongside audio files. Added debug log to library reorganize
repair job showing which template is loaded from config vs default.
2026-03-24 07:12:35 -07:00
Broque Thomas
21837be84e Fix 403 on followed Spotify playlists: fallback to public embed scraper
When fetching tracks from a followed playlist returns 403 (owner made it
private or API scope insufficient), fall back to the public embed scraper
which doesn't need API auth. Tracks still load with basic metadata.
2026-03-23 19:57:55 -07:00
Broque Thomas
571ba6b532 Version 2.1: Deezer downloads, cache discovery, stats, glass UI, album consistency
Major version bump with 40+ commits of features and fixes:
- Deezer as 6th download source (ARL auth, FLAC/MP3, Blowfish decrypt)
- Cache-powered discovery: 5 sections + Genre Deep Dive modal
- Listening Stats page with charts and play buttons
- Picard-style MB release preflight for consistent album tagging
- Album Tag Consistency repair job
- Unified glass UI across dashboard, sync, and modals
- Mobile responsive overhaul for all pages
- Enrichment fixes: retry loops, rate limits, worker pause during scans
- AcoustID skip for trusted API sources
2026-03-23 16:45:23 -07:00
Broque Thomas
deadfa1e82 Skip AcoustID verification for trusted API download sources
Tidal, Qobuz, Deezer, and HiFi download by exact track ID from official
APIs — files are guaranteed correct. AcoustID fingerprinting only runs
for Soulseek (P2P) and YouTube (extracted audio) where mislabeling is
possible. Users with AcoustID disabled see no change.
2026-03-23 16:08:27 -07:00
Broque Thomas
7eed88c72c Fix Deezer download integration: add deezer_dl to all streaming source checks
- Added deezer_dl to 7 hardcoded streaming source username tuples in web_server
- Streaming sources now bypass Soulseek filename-matching engine in get_valid_candidates
  (API search results are already properly matched, no need for P2P filename scoring)
- Fixes download progress tracking, completion detection, and candidate filtering for Deezer
2026-03-23 15:57:53 -07:00
Broque Thomas
2ae5050ef1 Add Deezer download source: client, settings UI, ARL authentication
- New core/deezer_download_client.py: full download client with ARL auth,
  Blowfish decryption, quality fallback (FLAC/MP3 320/MP3 128), search,
  thread-safe download tracking. Not yet integrated into orchestrator.
- Settings UI: Deezer download quality selector, ARL token input, test
  connection button. Appears in download source dropdown and hybrid list.
- Test endpoint: /api/deezer-download/test verifies ARL and returns tier.
- Added deezer_download to settings save whitelist and sensitive paths.
- Fixed Spotify enrichment worker default to unpaused (like other workers).
2026-03-23 15:06:40 -07:00
Broque Thomas
5d6ccee066 Add Picard-style MB release preflight for album downloads
Before album tracks start post-processing, pre-populate the MusicBrainz
release cache with ONE verified release. All tracks then hit the cache
during per-track processing and get the same release MBID — no second pass,
no file lock contention, tags correct on first write.

Handles edition name mismatches (Spotify says 'Super Deluxe', MB says
just the base name) by stripping qualifiers and searching again.
Validates track count to pick the right edition. Three download paths
covered: download missing modal, enhanced album, and matched album.
2026-03-23 12:42:29 -07:00
Broque Thomas
68f06d663b Pause enrichment workers during database scans to reduce lock contention
All 11 background workers (9 enrichment + repair + SoulID) pause when a DB
scan starts and resume when it finishes. Tracks which workers were running
so manually-paused workers stay paused after scan. Covers incremental,
full refresh, deep scan, automation-triggered, and startup scans.
2026-03-23 10:33:10 -07:00
Broque Thomas
efe8164501 Fix missing album art on wishlist items from mirrored playlists
Embed scraper was pre-marking spotify_public tracks as discovered with
playlist-level images instead of per-track album art. Discover step then
skipped them entirely. Now stores spotify_hint (track ID) without marking
discovered, so Discover runs proper API lookups for real album art.
2026-03-23 09:22:49 -07:00
Broque Thomas
df390feece Fix automation signal chain: forward event data (playlist_id) to action handlers
Event-triggered automations now receive playlist_id from the triggering event
when the action config doesn't have one set. Fixes silent 'No playlist specified'
failures in Discover/Sync chains. Added debug logging to trace event matching.
2026-03-23 08:51:52 -07:00
Broque Thomas
17e06aaae5 Update What's New modal with cache discovery, genre deep dive, DB storage, performance, mobile, and album split fix 2026-03-22 21:38:24 -07:00
Broque Thomas
e3d70da55a Add DB storage visualization + cache-powered discovery sections + Genre Deep Dive
- Stats page: database storage donut chart with per-table breakdown and total size
- Discover page: 5 new sections mined from metadata cache (zero API calls):
  Undiscovered Albums, New In Your Genres, From Your Labels, Deep Cuts, Genre Explorer
- Genre Deep Dive modal: artists (clickable → artist page), popular tracks,
  albums with download flow, related genre pills, in-library badges
- All cache queries filtered by active metadata source (Spotify/iTunes/Deezer)
- Stale cache entries (404) gracefully fall back to name+artist resolution
- Album cards show "In Library" badge, artist avatars scaled by prominence
2026-03-22 21:35:18 -07:00
Broque Thomas
40a9858f28 Fix album split in Navidrome: normalize MusicBrainz release cache keys across edition variants 2026-03-22 16:51:03 -07:00
Broque Thomas
93005298ee Cap Opus bitrate at 256kbps, fix lossy copy flavor text, redesign artist action buttons 2026-03-22 16:34:36 -07:00
Broque Thomas
8c84189121 Add per-artist enrichment coverage rings to artist hero section 2026-03-22 15:56:12 -07:00
Broque Thomas
b59a0eaf95 Add play buttons to stats page with cover art support 2026-03-22 15:24:38 -07:00
Broque Thomas
9e75731f6c Add scrobbling to Last.fm/ListenBrainz + update What's New
Scrobbling:
- Last.fm: API signing, web auth flow, batch scrobble (50/request)
- ListenBrainz: submit_listens (batch 1000, listen_type=import)
- Worker hook: scrobbles unscrobbled events after each poll
- DB: scrobbled_lastfm/scrobbled_listenbrainz tracking columns
- Settings: API secret input, authorize button, scrobble toggles
- Config: lastfm.api_secret, lastfm.session_key, *.scrobble_enabled

What's New: added all features from this session (scrobbling,
personalized discovery, stats page, SoulID, lossy codecs, import,
hero redesign, Hydrabase, orphan fixes, year collection).
2026-03-22 14:38:53 -07:00
Broque Thomas
232481fd13 Personalize discovery playlists using listening stats
Integrates play history data into the discovery algorithm:

- Listening profile: _get_listening_profile() builds user's top artists,
  genres, play counts, and listening velocity from the last 30 days
- Artist genre cache: pre-built from local DB for O(1) genre lookups
- Release Radar: +10 genre affinity, +15 artist familiarity, -10 overplay
  penalty. Weights rebalanced to 45% recency + 25% popularity + bonuses
- Discovery Weekly: serendipity scoring within tiers — boosts unheard
  artists in preferred genres, penalizes overplayed artists
- Recent Albums: adaptive time window (21-60 days) based on listening
  velocity — heavy listeners get fresher content, casual listeners more
- New "Because You Listen To" sections: personalized carousels based on
  user's top 3 played artists via similar artists + genre fallback
- New endpoint: /api/discover/because-you-listen-to with artist images
- Frontend: BYLT sections with artist photo headers on discover page
- All changes gracefully fall back when no listening data exists
2026-03-22 13:54:37 -07:00
Broque Thomas
cfb0e85564 Add Listening Stats page with media server play data integration
Full stats dashboard that polls Plex/Jellyfin/Navidrome for play
history and presents it with Chart.js visualizations:

Backend:
- ListeningStatsWorker polls active server every 30 min
- listening_history DB table with dedup, play_count/last_played on tracks
- get_play_history() and get_track_play_counts() for all 3 servers
- Pre-computed cache for all time ranges (7d/30d/12m/all) rebuilt each sync
- Single cached endpoint serves all stats data instantly
- Stats query methods: top artists/albums/tracks, timeline, genres, health

Frontend:
- New Stats nav page with glassmorphic container matching dashboard style
- Overview cards (plays, time, artists, albums, tracks) with accent hover
- Listening timeline bar chart (Chart.js)
- Genre breakdown doughnut chart with legend
- Top artists visual bubbles with profile pictures + ranked list
- Top albums and tracks ranked lists with album art
- Library health: format breakdown bar, unplayed count, enrichment coverage
- Recently played timeline with relative timestamps
- Time range pills with instant switching via cache
- Sync Now button with spinner, last synced timestamp
- Clickable artist names navigate to library artist detail
- Last.fm global listeners shown alongside personal play counts
- SoulID badges on matched artists
- Empty state when no data synced yet
- Mobile responsive layout

DB migrations: listening_history table, play_count/last_played columns,
all with idempotent CREATE IF NOT EXISTS / PRAGMA checks.
2026-03-22 13:18:14 -07:00
Broque Thomas
aca8a8e996 Fix Opus cover art and quality: map audio only, embed art via Mutagen
Opus ffmpeg command now uses -map 0:a to extract only the audio stream,
matching the user's working command and preventing picture stream
interference with the encoder. After conversion, cover art is embedded
from the source FLAC using Mutagen: Opus gets METADATA_BLOCK_PICTURE
(base64-encoded per OGG spec), AAC gets MP4Cover. Applied to both
the post-download lossy copy and the repair job fix handler.
2026-03-22 08:32:04 -07:00
Broque Thomas
491b89a1d2 Redesign library artist hero with Last.fm integration
- Add get_artist_top_tracks to Last.fm client (up to 100 tracks)
- Include lastfm_listeners, lastfm_playcount, lastfm_tags, lastfm_bio,
  and soul_id in artist detail API response
- New endpoint: /api/artist/<id>/lastfm-top-tracks for lazy loading
- Hero layout: image (160px) | center (name, badges, genres, bio,
  listener/play stats, progress bars) | right card (scrollable top
  100 tracks from Last.fm)
- Badges 36px with hover lift, bio in subtle card with Read More
  toggle, Last.fm tags merged with existing genres
- Numbers formatted: 1234567 → 1.2M
- Graceful degradation: sections hidden when Last.fm data unavailable
2026-03-21 23:04:34 -07:00
Broque Thomas
f9fc95c9f5 Add Opus and AAC codec options to lossy copy (Blasphemy Mode)
Lossy copy now supports MP3, Opus, and AAC (M4A) codecs with a
configurable dropdown in settings. Each codec uses the appropriate
ffmpeg encoder (libmp3lame/libopus/aac) and Mutagen tag writer
(ID3/Vorbis/MP4). Quality tag, filename substitution, and Blasphemy
Mode file cleanup all work per-codec. Backward compatible — existing
configs default to MP3.
2026-03-21 20:19:36 -07:00
Broque Thomas
a3592d2a14 Fix playlist sync crash: 'Working outside of application context'
get_current_profile_id() uses Flask's g object which doesn't exist
in background threads. Now profile_id is captured at request time
and passed as a parameter to _run_sync_task. All 8 call sites
updated. Automation path defaults to profile_id=1.
2026-03-21 16:09:24 -07:00
Broque Thomas
4dba3757be Fix orphan detector false positives and add staging/delete choice
Orphan detector: add normalized tag matching that strips parentheticals
and brackets (feat. X, [FLAC 16bit], etc.) and tries first-artist-only
for comma-separated artists. Prevents false orphan flags for tracks
like "The Mountain (feat. Dennis Hopper...)" that exist in DB as
"The Mountain". All lookups remain O(1) set operations.

Orphan fix: replace auto-delete with user choice prompt. Single Fix
and Fix All both show modal asking "Move to Staging" or "Delete".
Move to Staging relocates file to import staging folder for proper
re-import with metadata matching. Fix action flows through API
endpoint → repair_worker.fix_finding → _fix_orphan_file handler.
Staging path uses docker_resolve_path for container compatibility.
2026-03-21 11:27:51 -07:00
Broque Thomas
2d511d0a16 Add SoulID worker with API-based debut year disambiguation
SoulID worker generates deterministic soul IDs for all library entities:
  - Artists: hash(name + debut_year) — searches iTunes + Deezer APIs,
    verifies correct artist by matching discography against local DB
    albums via MusicMatchingEngine, pools years from both sources and
    picks the earliest. Falls back to hash(name) if no match found.
  - Albums: hash(artist + album)
  - Tracks: song ID hash(artist + track) + album ID hash(artist + album + track)

Dashboard button with trans2.png logo, rainbow spinner, hover tooltip.
Worker orb with rainbow effect. SoulSync badge on library artist cards.
DB migration adds soul_id columns with indexes to artists/albums/tracks.
Migration version flag auto-resets artist soul IDs when algorithm changes.
2026-03-21 10:21:06 -07:00
Broque Thomas
a7bef972e0 Smarter staging import: tag-first matching and auto-grouping
1. Fix filename parser pattern order — "01 - Title" now matched
   before "Artist - Title", preventing track numbers being treated
   as artist names (e.g., "08" no longer becomes the artist)

2. Tag priority over filename parsing — shared _read_staging_file_metadata()
   helper reads title, artist, albumartist, album, track_number, disc_number
   from Mutagen tags. Only falls back to filename parsing when BOTH title
   AND artist tags are empty. Applied to all 3 staging scan sites.

3. Improved match scoring — rebalanced from title(0.5)+tracknum(0.5) to
   title(0.45)+artist(0.15)+tracknum(0.30)+album_bonus(0.10). Files
   whose album tag matches the selected album get boosted.

4. Auto-group detection — new /api/import/staging/groups endpoint groups
   staging files by album+artist tags. Frontend shows "Auto-Detected
   Albums" section with one-click search. Match endpoint accepts
   optional file_paths filter to scope matching to a specific group.
2026-03-20 22:34:20 -07:00
Broque Thomas
ee3500242e Fix Hydrabase search types, ID routing, and plugin passthrough
- Use correct server request types: 'tracks', 'albums', 'artists',
  'artist.albums', 'album.tracks' (were singular, caused timeouts)
- Normalize artists to strings (server may send dicts)
- Use native plugin IDs (iTunes/Spotify) instead of soul_id for
  album/artist/track IDs so downstream endpoints can resolve them
- Carry soul_id and plugin_id in external_urls for routing
- Pass plugin param from frontend to server for correct client routing
  (iTunes vs Deezer vs Spotify) with isdigit() fallback
- Route source=hydrabase to iTunes client for artist images
- Include external_urls in enhanced search API response
- Reduce WebSocket timeout from 15s to 8s
2026-03-20 21:04:56 -07:00
Broque Thomas
a4f0745547 Fix Hydrabase not appearing as enhanced search source tab
- Remove stale hydrabase.enabled check, use is_connected() directly
- Add hydrabase to frontend alternate source fetch list
- Normalize Hydrabase artists to strings (server may send dicts),
  fixing silent crashes that prevented albums/tracks from appearing
2026-03-20 18:24:20 -07:00
Broque Thomas
c69040886e Collect release year from all metadata sources during post-processing
Post-processing now extracts release year from MusicBrainz, Deezer,
Tidal, Qobuz, and Spotify context (first source wins). Writes
ORIGINALDATE and DATE tags to file, and backfills the album year
in the DB if currently missing. Fixes Library Reorganize showing
blank years for Tidal-only downloads.

Also raises Library Reorganize API year lookup cap from 50 to 200.
2026-03-20 17:31:00 -07:00
Broque Thomas
ce89154952 Fix hybrid source toggle/reorder not saving and skip unconfigured sources
- Add debouncedAutoSaveSettings() to moveHybridSource and toggleHybridSource
- Skip unconfigured sources at search time with is_configured() check
- Add get_source_status() to orchestrator, include in settings API response
- Auto-disable unconfigured sources in UI on settings load
2026-03-20 16:22:48 -07:00
Broque Thomas
485a2d2792 Add fallback queries to album pre-flight Soulseek search
Album pre-flight now tries 3 query variations instead of one:
full artist+album, cleaned artist+album (no feat./parentheticals),
and album-only. Stops at first success. Fixes cases where a banned
keyword in the artist name caused zero results, falling back to
slower track-by-track search unnecessarily.
2026-03-20 16:01:50 -07:00
Broque Thomas
d7b9b3ba26 Refactor post-processing metadata lookups + fix Hydrabase as fallback source
Post-processing: Extract 7 inline source lookup blocks into standalone
_pp_lookup_* functions called in configurable order via
metadata_enhancement.post_process_order config. Default order matches
original hardcoded sequence — zero behavioral change.

Hydrabase: Fix connected Hydrabase incorrectly becoming primary source.
_is_hydrabase_active() now only returns True in dev_mode (legacy).
Auto-connect no longer forces dev_mode. Hydrabase as fallback works
through normal _get_metadata_fallback_client path like iTunes/Deezer.
Settings button min-width fix.
2026-03-20 15:48:48 -07:00
Broque Thomas
10361bb837 Complete Hydrabase as selectable fallback metadata source
- Remove redundant enable checkbox — fallback dropdown is the enable
- Hydrabase option only appears in dropdown when connected
- Connect/disconnect dynamically adds/removes dropdown option
- _is_hydrabase_active checks fallback_source == hydrabase (not config toggle)
- Fallback client returns hydrabase_client when selected, iTunes if disconnected
- Auto-reconnect respects fallback selection for dev_mode handling
- hydrabase added to settings save service list for persistence
- Status shows green Connected on page load when auto-connected
2026-03-20 14:18:10 -07:00
Broque Thomas
214985482d Add Hydrabase as a searchable tab in multi-source enhanced search
Hydrabase appears as a switchable source tab alongside Spotify/iTunes/
Deezer when connected and enabled. Added to alternate_sources list and
enhanced-search/source endpoint. Tab only shows when Hydrabase is
available — zero change when disconnected.
2026-03-20 13:51:21 -07:00
Broque Thomas
2f9491c71b Expose Hydrabase as a configurable metadata source (no dev mode needed)
Add Hydrabase section to Settings → Connections with enable toggle,
WebSocket URL, API key, auto-connect, and connect/disconnect button.
_is_hydrabase_active() now checks hydrabase.enabled config in addition
to dev_mode — either path activates it. Default disabled, zero change
for existing users. Dev admin page stays behind dev mode password.
2026-03-20 13:41:02 -07:00
Broque Thomas
0edf6197ec Update What's New modal with latest features and fixes
Add 7 new sections: Multi-Source Search Tabs, Per-Profile Service
Credentials, Modern Settings Redesign, Hybrid N-Source Priority,
Automation Hub Pipelines, Staging Folder Check, Library Safety Fixes.
2026-03-20 12:33:33 -07:00
Broque Thomas
53477768cb Complete per-profile service credentials feature
Adds Tidal per-profile OAuth with token storage on profile row.
Auth initiation stores profile_id in PKCE state, callback detects
it and stores encrypted tokens per-profile instead of globally.

Personal settings modal now shows Spotify, Tidal, Server Library,
and ListenBrainz sections for non-admin profiles. Admin sees only
ListenBrainz (unchanged). Server library selection wired into
playlist sync via _apply_profile_library.

Full per-profile support: Spotify (credentials + OAuth + playlists),
Tidal (OAuth + token storage), server library (Plex/Jellyfin/Navidrome),
ListenBrainz (existing). All backwards compatible — upgrading users
see zero change.
2026-03-20 12:08:20 -07:00
Broque Thomas
be27c36da2 Wire per-profile server library into playlist sync
PlaylistSyncService now accepts profile_id and applies per-profile
library selection before syncing. _apply_profile_library sets Plex
library name or Jellyfin user/library on the client based on the
profile's saved preferences. All existing _get_active_media_client
calls automatically pick up the active profile via instance state.

Admin and profiles without custom library settings: zero change.
2026-03-20 12:00:11 -07:00
Broque Thomas
e7fe083099 Add per-profile Spotify credentials and server library selection
Complete end-to-end per-profile Spotify support:
- DB migration: 11 columns on profiles table (Spotify creds, Tidal
  tokens, server library IDs) with encryption
- API endpoints: save/load/delete Spotify creds + server library
- Per-profile Spotify client cache with separate OAuth token storage
- Profile-aware OAuth flow (auth initiation + callback via state param)
- Playlist listing endpoint uses per-profile client
- Frontend: Spotify + Server Library + ListenBrainz sections in
  personal settings modal (non-admin only)
- Admin users see zero change — fully backwards compatible
- Tidal per-profile deferred (needs device auth flow)
2026-03-20 11:49:30 -07:00
Broque Thomas
06caf43f2f Add per-profile Spotify client with cache and playlist endpoint wiring
get_spotify_client_for_profile() returns the right SpotifyClient for
the active profile. Admin and profiles without custom creds get the
global client (zero behavioral change). Profiles with custom creds
get a dedicated cached instance with separate OAuth token storage.

Playlist listing endpoint now uses per-profile client so non-admin
users with their own Spotify credentials see their own playlists.
2026-03-20 10:47:58 -07:00
Broque Thomas
efe4561bcb Add per-profile service credentials foundation (DB + API)
DB migration adds 11 columns to profiles table for per-profile Spotify
credentials, Tidal tokens, and media server library selection. All
encrypted, all default NULL (fall back to global config).

API endpoints follow existing ListenBrainz per-profile pattern:
- GET/POST/DELETE /api/profiles/me/spotify
- GET/POST /api/profiles/me/server-library

Foundation only — no frontend UI or client initialization changes yet.
2026-03-20 10:30:56 -07:00
Broque Thomas
e0533215da Fix enrichment workers looping on tracks with NULL IDs
Workers would endlessly match the same track because UPDATE WHERE id =
NULL matches 0 rows in SQL. Added AND id IS NOT NULL to all enrichment
queries (individual, batch EXISTS, and batch fetch) across all 9
workers. Also added process-level guard for belt-and-suspenders safety.

Fix Deezer get_track → get_track_details method name mismatch.
2026-03-20 09:36:25 -07:00
Broque Thomas
81852caa59 Fix wishlist download UI showing status on wrong track rows
Stamp _original_index on tracks before submitting to download processor
so task indices match frontend modal row order. Sanitization and
filtering could reorder tracks, causing the backend to assign indices
that didn't match the displayed rows. Visual-only fix — actual
downloads were always correct.
2026-03-20 08:42:37 -07:00
Broque Thomas
46f82027cb Allow re-sync from download_complete state and add Rediscover button
All 6 playlist sync endpoints now accept download_complete phase so
users can re-sync after downloading. Added Rediscover button to
discovered and download_complete states (YouTube + Beatport). Added
full button set (Sync, Download Missing, Rediscover) for
download_complete which previously showed no buttons at all.
2026-03-19 22:17:59 -07:00
Broque Thomas
c53583716a Fix batch_complete firing with 0 downloads and album ID int cast error
- Only emit batch_complete automation event when successful_downloads > 0
- Remove int() cast on album_id in _fix_incomplete_album — Plex/Jellyfin
  use hash/text IDs that SQLite stores as-is in INTEGER columns
- Use string comparison for album_id equality check
2026-03-19 19:48:06 -07:00
Broque Thomas
fc4e16337a Redesign hybrid mode with N-source priority ordering
Replace fixed primary/secondary hybrid dropdowns with an ordered list
of all 5 download sources. Users enable/disable each source and reorder
with up/down arrows to set download priority. Sources are tried in
order until one returns results.

- New hybrid_order config field (backward compat with legacy primary/secondary)
- Download orchestrator loops ordered list with per-source error handling
- Sortable source list UI with icons, toggle switches, priority numbers
- Source-specific settings shown for all enabled hybrid sources
- Seamless migration from legacy 2-source to N-source format
2026-03-19 18:51:52 -07:00
Broque Thomas
3293a476fc Check staging folder for existing files before downloading
Before searching Soulseek/YouTube, checks the staging folder for a
matching file using tag-based or filename-based fuzzy matching. On match,
copies the file to the transfer folder and runs normal post-processing
(tagging, AcoustID, path organization). Staging scan is cached per batch
to avoid re-walking for every track.
2026-03-19 16:51:10 -07:00
Broque Thomas
e715ceef3e Add multi-source search tabs for enhanced search (Spotify/iTunes/Deezer)
Search results now show switchable tabs for alternate metadata sources.
Primary source renders immediately, alternate sources load in parallel
and tabs appear progressively as each completes.

- New /api/enhanced-search/source/<name> endpoint for per-source queries
- Source-aware routing via ?source= param on discography, album tracks,
  album detail, and artist image endpoints (prevents numeric ID
  misrouting between iTunes and Deezer)
- Source override stored on artistsPageState for consistent navigation
- Tabs styled with source brand colors, show result counts
- All additive — users who ignore tabs see zero behavioral change
2026-03-19 13:23:43 -07:00
Broque Thomas
b9c83a50fa Add Soulseek peer queue filtering and configurable download timeout
- Add max_peer_queue setting to skip peers with long queues (soft filter
  with fallback to unfiltered if all results removed)
- Add download_timeout setting replacing hardcoded 10-minute limit
- Include quality_score (peer health: upload speed, free slots, queue
  length) in result ranking — was calculated but never used in sort key
- New UI controls in Soulseek settings section
2026-03-19 11:47:37 -07:00
Broque Thomas
11b1222c90 Fix YouTube playlist parsing capped at ~100 tracks
Force full playlist resolution with lazy_playlist=False and materialize
entries list to prevent yt-dlp from stopping at the first page of results.
2026-03-19 09:47:12 -07:00
Broque Thomas
99481a0232 Fix Track Match search ignoring Track/Artist fields and low result limit
- Frontend was concatenating Track and Artist inputs into a single
  query string, causing Spotify to return mixed results matching
  either word in any field. Now sends track and artist as separate
  params; backend builds field-filtered query (track:X artist:Y).
- Result limit was silently capped at 10 in spotify_client.search_tracks
  via min(limit, 10). Raised to respect requested limit up to
  Spotify's API max of 50.
- iTunes fallback endpoint updated with same field-specific params.
- Legacy ?query= param still supported for backward compatibility.

Fixes #194
2026-03-19 08:23:19 -07:00
Broque Thomas
0a3cca4f1f Fix Spotify invalid_client and Tidal redirect URI mismatch on auth
- Duplicate `spotify` key in saveSettings() object literal caused
  second definition (embed_tags/tags) to silently overwrite the first
  (client_id/client_secret/redirect_uri), destroying credentials on
  every save. Merged into single key.
- authenticateSpotify() and authenticateTidal() now await saveSettings()
  before opening auth window, ensuring credentials are persisted.
- Tidal auth now dynamically sets redirect_uri from request host for
  LAN/Docker users and stores it in tidal_oauth_state so the callback
  token exchange uses the same URI.

Fixes #191
2026-03-19 08:02:12 -07:00
Broque Thomas
6b5e37aded Add preflight track-hash comparison and fix duplicate sync history entries
Preflight: hash track IDs before syncing and compare against last sync.
Skip only if exact same tracks were already synced and all matched.
Replaces the old count-based smart-skip which could miss track swaps.

Sync history: update existing entry for same playlist_id instead of
creating duplicates. Re-syncing the same playlist now refreshes the
existing row with new timestamps and stats.
2026-03-18 18:17:50 -07:00
Broque Thomas
0428a4565d Deduplicate variant album releases in artist discography view
Metadata sources (Deezer/iTunes/Spotify) return separate entries for
Explicit/Clean/Remastered variants of the same album. Normalize titles
by stripping variant suffixes and group by name+year, keeping the
version with the most tracks or preferring explicit.
2026-03-18 17:22:39 -07:00
Broque Thomas
e4579b4f2a Fix wishlist items from playlist sync missing album name and cover art
Playlist refresh was storing matched_data.album as a bare string with
no images dict. When unmatched tracks were added to the wishlist, they
showed as 'Unknown Album' with no cover art. Now all three playlist
import paths (authenticated API, public scraper, spotify_public) store
album as a proper dict with name and images array, matching the format
used by the discovery worker.
2026-03-18 17:11:26 -07:00
Broque Thomas
7b3bfd8db4 Fix artist discography miscategorizing albums as singles/EPs
Track count heuristics were overriding the album_type field from
Spotify/iTunes/Deezer, forcing any release with <=3 tracks into
singles and 4-6 tracks into EPs regardless of actual album type.
Now trusts the source's album_type directly.
2026-03-18 16:52:27 -07:00
Broque Thomas
b9732156cd Add per-tag granular toggle settings for all 47 embedded tags
Replace category-based tag settings (10 toggles) with per-tag controls
grouped by source service in an accordion UI. Each of the 11 service
groups (Spotify, iTunes, MusicBrainz, Deezer, AudioDB, Tidal, Qobuz,
Last.fm, Genius, General) has a master toggle that disables all child
tags, with individual toggles for fine-grained control. ISRC and
copyright fallback chains are now per-source toggleable. Genre merge
contributions from each source are independently controllable. All
tags default to enabled for backward compatibility.
2026-03-18 16:17:20 -07:00
Broque Thomas
d3bff90fd6 Add Picard-compatible MusicBrainz tags and per-category tag settings
Post-processing now writes all 18 MusicBrainz tags that Picard writes:
Release Group ID, Album Artist ID, Release Track ID, Release Type,
Status, Country, Original Date, Media, Barcode, Catalog #, ASIN,
Script, Total Discs (plus the 5 already supported). One cached API
call per album via get_release with recordings include.

New "Tags to Embed" settings section with 10 category toggles (all
enabled by default): MusicBrainz IDs, Release Info, Source IDs, ISRC,
BPM, Mood & Style, Copyright & Label, Genre Merging, URLs, Quality.
Each shows inline description of what it includes.
2026-03-18 15:49:36 -07:00
Broque Thomas
5adfdad9a3 Speed up dashboard polling intervals for more responsive UI
Service status 10s→5s, activity feed 5s→2s, watchlist/wishlist/db stats 30s→10s.
2026-03-18 15:02:00 -07:00
Broque Thomas
2c2c006f83 Fix wishlisted tracks showing 'Unknown Album' with no cover art
Multiple code paths built wishlist album data as a bare string instead
of a dict with images/album_type/total_tracks, causing wishlist entries
to lose album name and artwork. Fixed in: Beatport fallback conversion,
sync normalization, _ensure_spotify_track_format, and wishlist service
track reconstruction. Now uses track name as album fallback and always
includes required album dict keys.
2026-03-18 14:23:53 -07:00
Broque Thomas
333b8577ca Fix NoneType crash on Soulseek test when download orchestrator fails to init 2026-03-18 08:56:36 -07:00
Broque Thomas
2656927f79 Beatport enrichment progress, metadata cache fixes, per-source cache clearing
- Fix enrichment progress never updating: remove `continue` that skipped
  progress_callback for successful tracks in enrich_chart_tracks
- Split chart/extract into two-step flow: extract raw tracks, then enrich
  via polling endpoint with live progress overlay updates
- Move Beatport enrichment cache to persistent metadata cache system
- Fix metadata cache detail modal for Beatport (URL entity_ids with slashes)
- Add per-source Clear dropdown (Spotify/iTunes/Deezer/Beatport) to cache browser
- Remove debug logging from enrichment progress tracking
2026-03-18 08:39:02 -07:00