Commit graph

4052 commits

Author SHA1 Message Date
dev
e0c55342bc feat(quality): v3 ranked-targets UI editor (drag-to-reorder)
Replace the v2 per-tier quality UI (FLAC on/off + MP3 sliders + bit-depth
buttons) with a draggable ordered target list. Each row shows its rank +
label with move/delete; an add form picks format and, for lossless, bit
depth + min sample rate, or for lossy a minimum bitrate threshold (>=) so
VBR/mono files aren't falsely rejected. Persists v3 ranked_targets via the
existing /api/quality-profile. Presets + fallback toggle retained; help
text and tooltip rewritten for the new top-down source-gating model.

Verified: v3 profile round-trips UI shape -> DB -> load_profile_targets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:41:38 +02:00
dev
71f7c4a5e1 feat(quality): quality-rank streaming results after the match filter
search_and_download_best applied confidence scoring to streaming results
but never quality-ranked them — only the Soulseek path did. Apply
rank_for_profile to the confidence-passing survivors so the best version
wins (match first, then quality). Stable ranking keeps confidence order
within an equal tier; an "or scored" fail-safe keeps a candidate to try.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:35:19 +02:00
dev
310a5fe1bd feat(quality): quality-aware source fall-through in search_with_fallback
Add core/quality/selection.py: rank_with_targets() returns (ranked,
satisfied) where satisfied = a candidate meets a real target (strict).
load_profile_targets()/rank_for_profile() are the DB-backed wrappers.

search_with_fallback now skips a source that can deliver no target-meeting
quality and escalates to the next (source priority still wins among
satisfying sources; first source's results kept as fallback unless the
profile disables it). Returns RAW tracks — the satisfied check is a coarse
source gate; match-filtering + final ranking stay in the orchestrator so
the correct track is never pruned. Ranking is fail-open: a ranking error
never drops a source's real results.

Tested: rank_with_targets satisfied/fallback matrix + engine escalation,
stop-on-first, raw-not-pruned, fallback on/off. Amazon field test updated
for the corrected format token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:33:34 +02:00
dev
12341f006b feat(quality): source mappers + populate real quality on streaming results
Add core/quality/source_map.py centralising each source's tier->AudioQuality
mapping (Tidal/HiFi tiers, Qobuz real kHz/bit-depth, Deezer codes, Amazon
codec/tier). Add TrackResult.set_quality() to merge a mapped AudioQuality
onto a result. Wire HiFi, Qobuz, Deezer, Tidal, Amazon search results to
stamp real sample_rate/bit_depth so the global ranker no longer relies on
crude kbps heuristics for streaming sources. Fixes Qobuz/Amazon display
labels ('FLAC 24-bit/192kHz', 'Lossless') breaking format derivation.

Tested: 22 passing (mappers + set_quality merge semantics).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:27:03 +02:00
dev
97242cbd8d docs: global quality system design spec + Monochrome 30s bug note
Design for source-binding + quality-aware fall-through ranking
(per-source population, source-priority-king), ranked-targets UI,
quarantine-reason surfacing, and tests. Locks the constraints that
quality quarantine reuses the trigger='quality' retry path and never
sets force_imported (reserved for AcoustID mismatches).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:15:56 +02:00
nick2000713
4cc2401332 docs: add PLAN.md for global quality system feature
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 22:24:17 +02:00
nick2000713
2c91af0062 feat: global AudioQuality model with post-download quarantine + retry
Replaces the Soulseek-only bit-depth heuristic with a source-agnostic
quality system that works across all download sources.

## core/quality/model.py (new)
- AudioQuality dataclass: format, bitrate, sample_rate, bit_depth
- QualityTarget: one ranked entry in the user's priority list
- filter_and_rank(): source-neutral candidate ranking
- rank_candidate(): scores any AudioQuality against ranked_targets
- v2_qualities_to_ranked_targets(): migration helper

## core/download_plugins/types.py
- SearchResult gains sample_rate + bit_depth fields
- audio_quality property returns unified AudioQuality
- AlbumResult gets audio_quality aggregated from tracks

## core/soulseek_client.py
- Parses slskd attributes array (type 4=sample_rate, type 5=bit_depth)
- Real values instead of kbps heuristic
- filter_results_by_quality_preference() replaced by filter_and_rank()

## database/music_database.py
- Quality profile v3 with ranked_targets list
- Auto-migration v2 → v3 on load
- Presets (audiophile/balanced/space_saver) updated to v3

## core/imports/file_ops.py
- probe_audio_quality(): reads actual downloaded file via mutagen
  returns AudioQuality with ground-truth values

## core/imports/guards.py
- check_quality_target(): replaces check_flac_bit_depth
  checks all formats/sources against ranked_targets
- check_flac_bit_depth() kept as backwards-compat wrapper

## core/imports/pipeline.py
- Uses check_quality_target() instead of check_flac_bit_depth()
- Quality mismatch triggers _requeue_quarantined_task_for_retry('quality')
  so next-best candidate is tried before failing (same as AcoustID)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 22:22:08 +02:00
BoulderBadgeDad
78f47f04d7 Merge branch 'dev' of https://github.com/Nezreka/SoulSync into dev 2026-06-13 11:16:06 -07:00
BoulderBadgeDad
ce92828290 #867 UX: open Tidal discovery modal in 'discovering' phase so the empty/loading modal isn't interactable
When the modal opens instantly (before data loads), it was rendered in the
'fresh' phase — showing clickable Start Discovery / Wing It buttons over an empty
table, even though discovery is already auto-starting. Open it in 'discovering'
instead: the footer becomes the non-interactive 'Discovering matches…' info line
and the progress text reads 'Starting discovery…' instead of 'Click Start
Discovery to begin…'. Only Close stays clickable while the table loads.
2026-06-13 11:03:40 -07:00
BoulderBadgeDad
ecc07c6811 #867 UX (real fix): render Tidal discovery modal BEFORE the blocking discovery-start POST
The prior UX commit removed a redundant frontend pre-fetch, but the modal was
still only opened at the END of openTidalDiscoveryModal — AFTER awaiting
/api/tidal/discovery/start, whose backend handler fetches the whole playlist
synchronously (Tidal sleeps 1s/page, ~10s) before responding. So the modal still
didn't appear for ~10s. Now open the modal first (with a 'Loading playlist from
Tidal…' note), then fire the discovery-start POST and begin polling; return early
so the shared open at the bottom is skipped for this path.
2026-06-13 10:58:29 -07:00
BoulderBadgeDad
77829622a7 #867 UX: open Tidal discovery modal instantly instead of blocking ~10s on a track pre-fetch
Clicking Discover on a fresh Tidal card awaited /api/tidal/playlist/<id> (which
paginates Tidal with a 1s sleep per page + rate-limit throttle, ~10s for a large
playlist) BEFORE opening the modal — and the backend discovery worker then
re-fetched the same playlist anyway. Now that the modal builds its rows from the
backend discovery results (#867), open it immediately and let discovery populate
it: no blocking pre-fetch, no redundant double-fetch of the playlist.
2026-06-13 10:41:42 -07:00
BoulderBadgeDad
846a9c75a0 #867: Tidal playlist discovery shows all tracks (was capped to ~21)
Two issues in the same path:
1. The shared discovery modal pre-renders one row per track from a
   separately-fetched frontend track list, then the poll dropped any backend
   result without a pre-rendered row (if (!row) return). When the frontend's
   track fetch came back rate-limited/partial (~21) while discovery's own fetch
   got all 59, the surplus results vanished. Now the modal CREATES a row for any
   result lacking one, so authoritative backend results drive the list (fixes
   all sources sharing the modal).
2. get_playlist hydrated a whole relationships page in one _get_tracks_batch
   call, but Tidal caps filter[id] at 20/request, silently truncating larger
   pages. Chunk to the cap like get_album_tracks already does.

Seam + regression tests (tests/test_tidal_playlist_batch_chunking.py).
2026-06-13 10:39:30 -07:00
BoulderBadgeDad
c7ca657d56 Release 2.7.2: bump version + What's New / version modal + docker-publish default tag
Single source of truth _SOULSYNC_BASE_VERSION -> 2.7.2 (drives UI, system-info,
update check, backup metadata). docker-publish workflow_dispatch default tag -> 2.7.2.
WHATS_NEW + VERSION_MODAL_SECTIONS rewritten for 2.7.2 (current release + brief
earlier summary, per convention).
2026-06-13 10:16:35 -07:00
BoulderBadgeDad
119c6e3196 Spotify (no-auth): report connected + 'Spotify (no-auth)' test result instead of a Deezer fallback
Status checks asked is_spotify_authenticated() (official OAuth only) instead of
is_spotify_metadata_available(), so a Spotify-Free primary read as disconnected.
get_primary_source_status had spotify_free awareness but it was dead code:
get_client_for_source('spotify') returns None unless officially authed, so the
free-availability probe never had a client. Fetch the client directly for that
check; add the missing free branch to the dashboard test message. Seam + regression tests.
2026-06-13 10:16:23 -07:00
BoulderBadgeDad
992fe7567d
Merge pull request #860 from nick2000713/fix/colon-title-normalization
fix: treat colon as separator in normalize_string so T:T matches T_T
2026-06-13 10:06:49 -07:00
BoulderBadgeDad
41f73f0c38 HiFi: auto-push genuinely-new default instances to existing installs once (so a newly-added working instance reaches everyone, not just Restore-Defaults clickers; removed defaults stay removed) 2026-06-13 09:31:15 -07:00
BoulderBadgeDad
fd7fd32dfa HiFi: add us-west.monochrome.tf to default instances (community-confirmed working, Sokhi) 2026-06-13 09:19:57 -07:00
BoulderBadgeDad
fb260baa48 HiFi instances: 'Restore Defaults' button (re-adds removed defaults, keeps customs) + bigger tap targets for the ✔/✖ controls (Sokhi) 2026-06-13 09:16:39 -07:00
BoulderBadgeDad
6e7fd3ff5c M3U export: resolve paths via one bulk read instead of a per-artist search loop (fixes 'Export M3U hangs forever' under active enrichment/scan DB writes) 2026-06-13 08:55:46 -07:00
BoulderBadgeDad
608efb1d85 Server playlists: M3U export now downloads the .m3u to the browser too (was only saving server-side) — matches the other Export-as-M3U buttons 2026-06-13 08:35:02 -07:00
BoulderBadgeDad
5a16d8ad53 Server playlists: 'Export M3U' button in the compare/editor toolbar — exports the server playlist's tracks via the shared M3U writer (Music Assistant etc.) 2026-06-13 08:20:18 -07:00
BoulderBadgeDad
651b904e92 Watchlist: per-artist 'auto-download' toggle (follow-only) — off = discover/surface releases but skip the wishlist add; default on 2026-06-13 08:07:20 -07:00
BoulderBadgeDad
2428df1144 #857: custom in-container completed-downloads path for Torrent/Usenet sources (settings + UI; resolver already consumed the keys) 2026-06-13 07:15:02 -07:00
BoulderBadgeDad
15067b63ca Mirrored playlists: rename (✏️) button matches sibling buttons' hover-reveal styling 2026-06-13 07:01:18 -07:00
BoulderBadgeDad
c62074d54a #865: resolve pasted SoundCloud links (incl. unlisted/private share URLs) via direct yt-dlp resolve; manual-search forces the SoundCloud source 2026-06-13 00:41:47 -07:00
BoulderBadgeDad
ba5d62946a Mirrored playlists: custom name alias (overrides display + sync name, survives upstream refresh) — card rename button like the source-ref editor 2026-06-13 00:23:56 -07:00
BoulderBadgeDad
6366f72b7e #863: YT Artist column falls back to the matched artist when YouTube gave none (both render paths) — no more 'Unknown Artist' on matched rows 2026-06-13 00:06:41 -07:00
BoulderBadgeDad
0577dc92e5 #863: add diagnostic logging to YouTube artist-recovery so we can see per-track what it returns 2026-06-13 00:03:35 -07:00
BoulderBadgeDad
c72e83bc2f #863: move YouTube artist recovery out of the (synchronous) parse into the async discovery worker — parse is fast again, no 120s timeout risk 2026-06-12 23:46:29 -07:00
BoulderBadgeDad
0093af89d2 #863: parallelize YouTube artist-recovery (5-worker pool) so the whole playlist resolves within the budget, not just the first ~dozen 2026-06-12 23:36:31 -07:00
BoulderBadgeDad
0a6325a87b #863: recover YouTube track artist via per-video uploader/channel when flat extraction returns title-only (budget-bounded) 2026-06-12 21:38:34 -07:00
BoulderBadgeDad
4f24c2af6d #863: derive YouTube track artist from music fields / -Topic channel / 'Artist - Title' instead of the playlist owner 2026-06-12 20:14:55 -07:00
BoulderBadgeDad
cb2d920a9e #862: Library Reorganize falls back to tag-mode when an album has no source ID (media-server libs now actually reorganize) 2026-06-12 20:05:50 -07:00
BoulderBadgeDad
f5787764d4 #859: DB-update stall watchdog + UI self-heal (no more wedged 'Starting...' / frozen bar) 2026-06-12 19:38:30 -07:00
BoulderBadgeDad
0384de7c8d Settings: hide Playlist Path Template field (no longer drives symlink-view naming; kept in DOM for save/load) 2026-06-12 19:03:04 -07:00
BoulderBadgeDad
08b73f0e94 Playlists: remove dead _playlist_folder_mode routing branches (retired flag, now unreachable) 2026-06-12 18:52:57 -07:00
BoulderBadgeDad
577bba30aa Playlists: all-owned trigger makes batch dict authoritative + logs when nothing rebuilt 2026-06-12 17:34:06 -07:00
BoulderBadgeDad
47889387ad Playlists: resolve synthetic mirrored batch refs (youtube_mirrored_<pk>/auto_mirror_<pk>) to PK 2026-06-12 17:27:35 -07:00
BoulderBadgeDad
c6e077fefe Playlists: batch toggle force-rebuilds its own folder (row flag = provenance only) + resolve diagnostics 2026-06-12 17:19:45 -07:00
BoulderBadgeDad
4d7267e906 Playlists: reconcile rebuilds touched playlists from the LIBRARY, not task fields
The reconcile read each completed task's final_file_path to find paths — but not
every import path sets it (the verification worker marks the task completed
without it), so tracks that imported via that path were silently dropped (user
saw 3 of 5 symlinks). Root cause: leaning on a fragile per-task field.

Now reconcile_batch_playlists identifies the organize playlists the batch touched
(its own + any reached via a completed track's source_info provenance) and
rebuilds each from CURRENT library ownership via _rebuild_one_from_db
(check_track_exists over membership). It just asks the library what's owned, so
it's robust to HOW a track imported (modal worker / slskd monitor / verification
worker) and still prunes tracks that left. Takes a db handle; all three callers
pass MusicDatabase().

Reconcile tests rewritten for the DB-rebuild form (organize batch, wishlist
provenance, non-organize skip, plain no-op). 973 downloads/imports/playlist
tests pass.
2026-06-12 17:08:27 -07:00
BoulderBadgeDad
7fb1b115f0 Playlists: also run the reconcile on the V2 batch-completion path
on_download_completed and check_batch_completion_v2 are duplicate completion
paths. Monitor-detected downloads (Deezer / slskd-monitor / verification-worker
imports) finish the batch via the V2 path, but the materialize reconcile was
only added to on_download_completed — so those batches never built playlist
folders (no '[Playlist Folder] Rebuilt' line at all). Add the same non-fatal
reconcile to the V2 path. Now all three completion points (both lifecycle paths
+ the master.py all-owned path) materialize. 550 tests pass.
2026-06-12 16:49:52 -07:00
BoulderBadgeDad
6e86bac6eb M3U: skip the auto-save no-op when export is disabled (fixes ~30s analysis jam)
The download modal auto-saves an M3U on every render (save_to_disk, no force).
When m3u_export.enabled is off it writes nothing — but only AFTER ~30s of
per-track DB search + fuzzy matching, which it then discards; fired repeatedly
during analysis it jammed the batch (0 tasks, user cancels). Bail out at the top
of generate_playlist_m3u for exactly that case (save_to_disk and not force and
not enabled). Manual 'Export as M3U' sends force=True and content-only requests
send save_to_disk=False — both unaffected.

Pre-existing bug, unrelated to the playlist-folder feature, but it was blocking
the discovery->download flow.
2026-06-12 16:35:11 -07:00
BoulderBadgeDad
d160c486ec Playlists: mirror-update trigger prunes removed tracks (the other half)
Symmetric to the post-download reconcile (which handles ADDITIONS): when a
playlist's membership is re-synced (the mirror step — scheduled refresh or the
manual mirror endpoint), rebuild its folder from current membership WITH prune
IF it's organize-by-playlist. So a track that just LEFT the playlist has its
symlink cleaned up the instant membership changes, not only on the next download.

Factored a shared _rebuild_one_from_db (used by the manual 'Rebuild' button and
the mirror hook) + rebuild_mirrored_playlist_if_organized. Gated to organized
playlists, non-fatal at both mirror call sites.

Now the invariant 'folder = the playlist's current owned members' holds on every
change: additions caught at download, removals caught at mirror. 2 new tests
(removed track pruned; non-organized skipped). 985 + 277 tests pass.
2026-06-12 15:49:13 -07:00
BoulderBadgeDad
997e76b6b4 Playlists: one path-independent reconcile after every batch (closes wishlist gap)
Replaces the two organize-only triggers with a single reconcile_batch_playlists
called at both batch-completion points. It groups the batch's newly-resolved
tracks by their per-track playlist provenance:
  - the batch's OWN organize playlist → full (re)build with prune, and
  - a track that completed for a DIFFERENT playlist (e.g. a WISHLIST fulfilling a
    track that belongs to an organize playlist) → ADDED to that folder, no prune.

So a late wishlist arrival now lands in its playlist folder immediately, instead
of only on the next sync/manual rebuild — the folder = the playlist's owned
members, kept true on every ownership change regardless of download path. Uses
the paths the batch already captured (no DB re-match, no waiting on the server
scan/sync). Non-fatal.

3 new reconcile tests (organize full-rebuild, wishlist add-without-prune, plain
batch no-op). 983 downloads/imports/playlist tests pass.
2026-06-12 15:30:47 -07:00
BoulderBadgeDad
87621b7191 Playlists: Settings UI (path + symlink/copy + rebuild button) + rebuild endpoint
- Settings: 'Playlists Folder' path field (Unlock pattern, separate-root help
  text), a Symlinks/Copies selector, and a 'Rebuild playlist folders now' button
  (standard test-button style). Wired through PATH_INPUT_IDS / load / save, plus
  'playlists' added to the settings save allowlist so it persists.
- POST /api/playlists/materialize/rebuild → rebuild_organized_playlists_from_db:
  rebuilds every organize-by-playlist folder from CURRENT ownership, re-matching
  each track with check_track_exists (name, not IDs) so it self-heals after a
  reorganize / membership change. +1 test.

70 materialize tests + JS integrity pass; settings round-trip wiring verified.
2026-06-12 14:04:34 -07:00
BoulderBadgeDad
aa5d747327 Playlists: wire materialize triggers + retire per-track routing flag
- Routing (step 5): organize-by-playlist tracks no longer set the per-track
  _playlist_folder_mode flag, so they import NORMALLY into Artist/Album — exactly
  what a normal download does. _playlist_name provenance is kept (origin.py).
- Triggers (step 4): build the playlist folder from the batch's own payload at
  both end-of-flow points — the all-owned path in master.py (no downloads, so the
  lifecycle never runs) and the batch-complete hook in lifecycle.py (after
  downloads). Both gated on playlist_folder_mode, both non-fatal.

Works for the all-owned case (the smack test that did nothing before) and for
mixed owned/downloaded, with no source-ID or mirrored-playlist dependency. The
materialized folder uses the default ./Playlists root + symlink mode until the
Settings UI is added.

Updated the master test to assert the new contract (provenance kept, routing
flag gone). 979 tests pass.
2026-06-12 13:37:59 -07:00
BoulderBadgeDad
bef73d855d Playlists: stitch a batch's owned+downloaded paths into the materializer
materialize_playlist_from_batch(batch, download_tasks, config) collects the real
on-disk path of every resolved track from the batch's OWN payload — owned via
analysis_results.matched_file_path, downloaded via tasks.final_file_path — runs
each through the playback path resolver (Docker-correct), de-dupes, and hands the
list to rebuild_playlist_folder. Gated on playlist_folder_mode.

No re-matching, no source IDs, no mirrored-playlist lookup — works for any
organize-by-playlist download including the all-owned case. 5 tests. Still
isolated; the triggers wire it in next.
2026-06-12 13:32:53 -07:00
BoulderBadgeDad
3a6cb8cda5 Playlists: config (separate root + symlink/copy) + pure materializer seam
- settings: playlists.materialize_path (separate root, mapped apart from the
  music library so the media server never double-scans it) + materialize_mode
  (symlink|copy).
- core/playlists/materialize.py: pure filesystem engine that (re)builds a
  playlist folder of relative symlinks (or copies) into the real library —
  idempotent, prunes stale entries, disambiguates filename collisions, never
  escapes the root, and auto-falls-back to copy when the FS can't symlink.
  No DB, no app state; ops injectable. 13 unit tests.

Isolated + additive — nothing live calls this yet (stitcher/trigger/routing
come next).
2026-06-12 13:30:48 -07:00
BoulderBadgeDad
37431ea82b Downloads: additively surface each track's real file path (analysis + completed)
The download analysis already matches every track to a library row via
check_track_exists / manual match, then discarded the result. Keep it: each
analysis_results entry now carries matched_file_path + matched_track_id (the
owned file's real location, or None). Symmetrically, a completed download task
now records final_file_path (where the import landed).

Purely additive, no behavior change, no new matching, zero perf cost — just
stops throwing away what the pipeline already computed. This is the foundation
for playlist materialization: owned + downloaded tracks both report where their
real file is, so the folder can be built by name match, not source IDs.
2026-06-12 13:28:26 -07:00
BoulderBadgeDad
550fca0fe5 webui: sync organize-by-playlist toggles + stop dashboard poller 401-spam while locked
- The download-modal 'Organize by Playlist' toggle had no onchange, so flipping
  it never saved or synced the saved per-playlist preference. Add the handler
  (source auto-derived from the ref) so both controls read/write the one
  organize_by_playlist value — manual action persists, the other reflects it.
- loadDashboardSyncHistory polled /api/sync/history every 30s even while the
  launch-PIN/login gate was active, 401-spamming the log. Skip when locked, and
  on a 401 (stale session after a restart) surface the unlock screen so it
  self-heals instead of spamming.
2026-06-12 13:28:24 -07:00