Commit graph

1879 commits

Author SHA1 Message Date
BoulderBadgeDad
d647fc8ad1 Release 2.7.7: version bump + What's New / version modal + PR description + docker-publish default tag
- _SOULSYNC_BASE_VERSION -> 2.7.7; docker-publish default version_tag -> 2.7.7
- pr_description.md rewritten for 2.7.7 (the #915 primary-source parity headline, #913 listening-recs
  foundation, jellyfin atomic-write, and the #905/#908/#909/#910/#911/#912/#914/#916/#917/#918 batch)
- WHATS_NEW: replaced the 2.7.6 block with 2.7.7 (current release + a brief 'earlier versions' summary)
- VERSION_MODAL_SECTIONS: promoted the 2.7.7 highlights, rolled 2.7.6 into the 'Earlier' aggregator
2026-06-23 23:24:39 -07:00
BoulderBadgeDad
2934903874 #916: align missing-track title match with the Reorganize matcher
Reporter's image 3 shows Reorganize maps all 62 multi-disc tracks correctly ('62 unchanged') —
it matches by title, proving the titles DO align on this album. My first normalizer DELETED
bracket content, so 'X - Main Theme' (file) vs 'X (Main Theme)' (canonical) would mismatch.
Reorganize treats brackets as separators (keeps the words); now _normTitleForMatch does the
same — drop only the (feat. Y) credit, turn every other separator into whitespace.

Verified: dash<->bracket, curly<->straight apostrophe, special<->regular hyphen, and feat all
normalize equal; distinct titles stay distinct.
2026-06-23 15:58:36 -07:00
BoulderBadgeDad
16cb29c9ee #916: enhanced view stops flagging multi-disc tracks as missing
Multi-disc albums store disc_number=1 for EVERY track in the library (verified across the live
DB — even a 175-track OST shows disc[1..1]; the scanner doesn't split discs). The enhanced view
matched owned<->canonical tracks strictly by disc:track_number slot, so every canonical disc-2+
track (slot 2:N) found no owned counterpart and was flagged missing ('62/72 · 36 missing').

_deriveEnhancedMissingTracks now matches each canonical track by slot first, then falls back to
title against any UNUSED owned track (consuming each owned track once, so genuine missings and
duplicate titles still count right). Display-only — no scanner/data change.

Verified by simulation (62 owned all disc-1, 72 canonical across 2 discs): old logic flags 36
missing, new flags 10 (the genuinely-absent tracks). Frontend-only; repo has no JS test runner.
2026-06-23 15:32:26 -07:00
BoulderBadgeDad
65f73fae92 #911: redownload via the album's CANONICAL source (covers the 67% with multiple ids)
The first pass only checked spotify then itunes. But ~67% of a real library (46k/69k albums)
carry BOTH a spotify and itunes id, and the canonical priority is spotify>deezer>itunes>mb>…,
so a spotify-first guess diverges from what the Enhanced view actually tags/displays the album
as (e.g. a deezer-canonical album with an itunes id too).

Now redownload reuses _getEnhancedAlbumCanonicalSource (the view's single source of truth) and
fetches via the same /api/album/<id>/tracks?source= endpoint the view uses for its canonical
tracklist — so a redownload is always the exact edition on screen, across every source. The
stored spotify/iTunes id + a last-resort search remain as fallbacks. Frontend-only; the album
endpoints and canonical resolver are unchanged.
2026-06-23 11:20:03 -07:00
BoulderBadgeDad
69cb51cc13 #911: album Redownload uses the stored match id, not a fresh search
The Enhanced-view album Redownload only honoured album.spotify_album_id. For an iTunes-matched
album (no spotify id) it fell through to a fresh /api/enhanced-search and grabbed the FIRST hit
— which can be a different edition than the one you have (issue: matched the 66-track 'Original
Soundtrack Collection', got the 19-track 'Volume 1').

Now it prefers the album row's stored source id (spotify, then iTunes — the iTunes endpoint
already returns a Spotify-shaped payload) and only searches when neither exists. Also fixed the
search fallback to fetch from the MATCHING source endpoint instead of always hitting Spotify
(latent bug for iTunes search hits).

Frontend-only orchestration fix; no JS test runner in the repo, the album endpoints are unchanged.
2026-06-23 11:12:29 -07:00
BoulderBadgeDad
cefddd73b7 Release 2.7.6: bump version + What's New / version modal + PR description + docker-publish default tag
- _SOULSYNC_BASE_VERSION 2.7.5 -> 2.7.6
- docker-publish.yml default version_tag 2.7.5 -> 2.7.6
- pr_description.md rewritten for 2.7.6 (ListenBrainz export #903, YouTube Liked Music #902,
  Deep Scan data-loss guard #904, dashboard performance, #901/multi-disc/track-number fixes)
- helper.js WHATS_NEW: new 2.7.6 block + earlier-versions summary
- helper.js VERSION_MODAL_SECTIONS: 2.7.6 highlights lead; 2.7.5 rolled down

ruff check . clean app-wide; export/#904/cookie suites green (54).
2026-06-22 22:48:21 -07:00
BoulderBadgeDad
5f1ec9ed7e #903: fix mirrored-card layout break from export status div
The live export status was a separate flex child with flex-basis:100%, which became a
greedy item in the card's flex row and squished the info column to min-content (text
wrapping vertically). Inject the status into the card's existing .card-meta line instead
(same approach as the pipeline phase indicator) so it sits inline and leaves the row intact.
Removes the offending div + CSS.
2026-06-22 22:28:07 -07:00
BoulderBadgeDad
c06ef6bb34 #903: mirrored-playlist card export button + live status UI
Phase 6 (UI). Adds an export button to the mirrored-playlist card's hover action row (next
to rename/link/delete). Click -> a small on-brand modal to pick a destination (Sync to
ListenBrainz directly, or Download .jspf). Starts the background export, then polls status
and shows live progress on the card ('Matching 340/1000 · 312 matched' -> 'Synced · 947/1000
matched · view'). Reuses the tested backend job/endpoints; additive (new button + CSS + JS
functions, existing card render untouched apart from the inserted button).
2026-06-22 20:40:03 -07:00
BoulderBadgeDad
01c51a3c0e #904: guard standalone Deep Scan against relocating a desynced library
Standalone _run_soulsync_deep_scan did a path-only diff (untracked = transfer files
not in the soulsync DB) and shutil.move'd EVERY untracked file to Staging — no guard.
When the DB is empty/out of sync with disk (volume swap, DB reset, external Picard
tag edits) but Transfer holds the real library, that flags the whole library as
untracked and relocates all of it; Phase 5 then deletes the rows, and with Staging
cleanup on the files are gone for good. Reporter lost ~1,500 tracks into Staging.

The stale_guard the orphan detector + media-server deep scan already use (#828, #908)
was never wired into this path. Fix:

- core/library/standalone_scan.py (pure, tested): plan_standalone_deep_scan() diffs
  untracked (separator-normalized) and decides whether the move is safe. Blocks when
  the untracked share is implausibly large (>20 files AND >50% of Transfer — the
  desync signature, via is_implausible_orphan_flood) or when the user marked Transfer
  permanent. A normal batch of new arrivals still moves.
- web_server: consult the planner before Phase 4; on block, move NOTHING, leave files
  in place, and surface a loud warning + activity item. Guard Phase 5 deletes too
  (skip on desync-block or implausible stale share).
- 'Transfer is my permanent library — never move files out' toggle
  (import.transfer_is_permanent) in Settings.
- tests/library/test_standalone_scan.py: seam coverage + the #904 regression
  (empty DB + 1,500 files -> blocked, nothing moved).

No behavior change for in-sync libraries; the guard only trips on the desync pattern.
2026-06-22 17:53:49 -07:00
BoulderBadgeDad
df6815c2cc perf(dashboard): remove invisible card blur + redundant shadow layers
Profiling the actually-painted dashboard found two pure-waste GPU costs (no visual
payoff), reclaimable with zero degradation:

- backdrop-filter on cards whose backgrounds are already 90-99% opaque, so the blur
  is invisible: service-card (x3), stat-card-dashboard (x3), activity-feed-container.
  Dropped the filter, nudged opacity to ~0.97 so the unblurred sliver is imperceptible.
- redundant/near-invisible box-shadow layers on the two biggest elements: page-shell
  (near-fullscreen — collapsed two stacked outer shadows to one) and sidebar (dropped
  a duplicate layer + a 0 0 60px accent glow at 6% opacity that's barely visible but a
  costly 60px-blur pass).

Targets the DURING-USE cost, not idle. sidebar-header keeps its blur (genuinely
translucent), and the cursor blob is untouched (that one's a real visual tradeoff).
2026-06-22 17:10:40 -07:00
BoulderBadgeDad
b73389adea perf(dashboard): cache particle glow sprites instead of per-frame gradients
The dashboard particle preset built a fresh createRadialGradient + arc-fill for all
50 glows every frame. But each glow's gradient is a fixed size/colour for the
particle's life — only the pulse ALPHA changes per frame, and canvas multiplies
image alpha by ctx.globalAlpha. So bake the gradient once into a per-particle
offscreen canvas (full alpha) and drawImage it each frame with globalAlpha = pulse
(times the incoming globalAlpha, so transition fades stay identical). Rebuilt only
when the accent colour changes.

Pixel-identical output: same colour, same linear falloff, same source-over; sprite
rendered at ceil(glowSize) then downscaled to exact glowSize. Drops 50 gradient
allocations + arc-fills per frame to 50 cached blits. Scoped to the dashboard
preset only (smallest blast radius); other presets untouched.
2026-06-22 15:23:16 -07:00
BoulderBadgeDad
4b58d8079e perf(dashboard): auto-enable performance mode on weak hardware (device-scoped)
People report SoulSync working their machine hard at idle. On likely-weak devices
(<=2 cores, or <=2GB, or low on both: <=4 cores AND <=4GB) auto-enable reduce-effects
once and toast why ('lower-power device — turn effects back on in Settings').

Device-scoped via localStorage on purpose: a weak laptop must not flip the server
setting for the user's other machines. Acts only when this device has no stored
preference (null), so it runs at most once and never overrides an explicit choice.
Conservative thresholds avoid flagging capable boxes (a 4-core/8GB laptop isn't
touched; Firefox/Safari, which don't expose deviceMemory, only trip on <=2 cores).

Settings-load now prefers the device-level localStorage value over the server
default, so opening Settings no longer clobbers the per-device (auto or manual) choice.
2026-06-22 15:12:25 -07:00
BoulderBadgeDad
b5b71df3fa perf(dashboard): trim blur radii (orbs 40->28px, header backdrop 28->18px)
GPU fill/blur cost scales with radius. The sidebar aura orbs already fade to
transparent at 70% of their gradient, so dropping their blur 40->28px shrinks the
composited bounding box with no perceptible softness loss. The frosted header's
backdrop-filter is re-blurred whenever the orbs drift behind it; 28->18px cuts
that per-frame work ~a third while keeping the frosted look. Relief is biggest on
weak GPUs (the machines people complain about).
2026-06-22 15:02:19 -07:00
BoulderBadgeDad
9454970a83 perf(dashboard): sidebar header sweep animates transform, not left
The .sidebar-header::after ambient sweep animated `left` (-100% -> 140%) on an
8s infinite loop — forcing a layout recalc every frame it's on screen, on the
sidebar that's present on every page. Convert to transform: translateX() with a
pixel-identical travel path (element is 60% of header width, so translateX(400%)
== the old 240%-of-header sweep) + will-change. Compositor-only now; no per-frame
layout. Zero visual change.
2026-06-22 15:01:49 -07:00
BoulderBadgeDad
49592f898c #902: YouTube Liked Music sync — paste a cookies.txt (server/Docker auth)
Private YT Music playlists (a user's Liked Music, list=LM) need auth, but the
only cookie option was cookiesfrombrowser — a browser on the same machine as
SoulSync, useless on a headless/Docker box (and locked to whatever account that
browser happens to be signed into). Add a 'Paste cookies.txt' mode so users can
supply the exact session they want from any machine.

- core/youtube_cookies.py: pure seam — build_youtube_cookie_opts (cookiefile vs
  cookiesfrombrowser precedence, mutually exclusive, fail-safe on a missing file),
  looks_like_cookiefile (needs a real cookie row; rejects junk/header-only),
  write_pasted_cookiefile (validate + 0600 write; blank/junk never clobbers a saved file).
- _youtube_cookie_opts() delegates to the seam, so every yt-dlp call site gets it.
- /api/settings pops cookies_paste before the generic persist, validates (400 on
  junk), writes config/youtube_cookies.txt, stores only the path (blob never hits config.json).
- Settings dropdown gains 'Paste cookies.txt'; selecting it reveals a textarea.
- tests/test_youtube_cookies.py: precedence, validation, fail-safe write (11 tests).
2026-06-22 14:41:04 -07:00
BoulderBadgeDad
89018bb6b3 CSS: .sidebar-header z-index 2 -> 1
Drop the header to the same stacking level as the nav instead of forcing it above.
2026-06-22 12:06:57 -07:00
BoulderBadgeDad
ad0eda3575 Release 2.7.5: bump version + What's New / version modal + PR description + docker-publish default tag
Bumps _SOULSYNC_BASE_VERSION to 2.7.5, defaults the docker-publish workflow's
version_tag to 2.7.5, and refreshes the release notes for the fixes/features
since 2.7.4: deezer real track numbers, special-edition cover art (release-scope),
the leading-'The' dedup, HiFi preview rejection (#895), M3U/M3U8 import (#893),
organize-by-playlist file naming, durable Find & Add match, ignore-list management
+ manual-add unblock (#897), and the Unraid template fixes (#899). WHATS_NEW +
VERSION_MODAL_SECTIONS rolled to 2.7.5 with 2.7.4 folded into the earlier-versions recap.
2026-06-21 22:57:14 -07:00
BoulderBadgeDad
9aaafaf341 Organize-by-playlist: optional custom file naming for the playlist folder
Files inside an Organize-by-Playlist folder were stuck with the library filename
(materializer hardcoded os.path.basename) — users wanted control over the naming,
e.g. a playlist-order prefix so a DAP plays them in order.

Add an opt-in FILENAME template "Playlist File Naming" (file_organization.templates
.playlist_item), tokens $position/$artist/$album/$track/$title. It is a filename,
not a path: validated to forbid "/" or "\" and to require $title, both in the
Settings UI (blocks save with a reason) and in core/playlists/item_naming.py, which
also fails safe at apply time — a bad/empty template falls back to the library
filename, so it can never produce a broken name. Default empty = current behavior.

Works for symlink AND copy modes (a symlink name is independent of its target).
Applied in _rebuild_one_from_db (the live reconcile/rebuild path), which has the
per-track metadata + playlist order; the pure FS materializer just gained an
optional dest_names override and is otherwise untouched. $position is zero-padded
to playlist width for correct sorting.

Tests: pure validate/render (slash + missing-title rejected, fallback, sanitize,
no-separator guarantee), FS-layer dest_names + collision disambiguation + back-
compat, and end-to-end through the DB rebuild (07->01 rename + empty-template
keeps library filename).
2026-06-21 21:57:20 -07:00
BoulderBadgeDad
15ea87a154 #897: surface the ignore-list on the wishlist page + stop blocking manual re-adds
Two issues behind #897:

1) Discoverability — the "Ignored" management modal (view/un-ignore/clear-all,
   shipped with #874) was only reachable from the wishlist *overview modal*
   footer, which most users never open. Add the same button to the wishlist
   page toolbar next to Cleanup / Clear All, wired to openWishlistIgnoreModal().

2) Manual re-add silently blocked (carlosjfcasero) — the album-modal "add to
   wishlist" endpoint passes source_type=album, but the ignore gate only
   bypasses+clears for source_type=manual, so re-adding a previously-cancelled
   track failed. We cannot just send manual: source_type drives Albums/Singles
   categorisation and repair_worker legitimately uses album too. Thread an
   explicit user_initiated flag (db.add_to_wishlist -> service -> album route)
   that bypasses+clears the ignore while preserving the real source_type.

Regression test pins both: an automatic source_type=album add stays blocked,
the user_initiated add goes through, clears the ignore, and keeps source_type=album.
2026-06-21 20:21:44 -07:00
BoulderBadgeDad
1c93a5640d #893: add the M3U/M3U8 hint row to the import-file dropzone
The format was listed as supported but had no hint box like CSV/TSV and TXT do.
2026-06-21 19:57:48 -07:00
BoulderBadgeDad
3aaf19a357 #893: support M3U/M3U8 in playlist import-from-file
The import-from-file tool only took CSV/TSV/TXT. Add M3U/M3U8 — the most common
file-playlist format, and the one SoulSync itself exports. Parses extended M3U
(#EXTINF artist/title/duration, #PLAYLIST name) and simple M3U (derives
artist/title from the file name), and keeps "# MISSING:" entries from our own
export (those are exactly the tracks a user imports to go match/download).
Frontend-only: the parser runs client-side and feeds the existing import path.
2026-06-21 19:43:59 -07:00
BoulderBadgeDad
cc7d48b736 Release 2.7.4: bump version + What's New / version modal + PR description
Bumps _SOULSYNC_BASE_VERSION to 2.7.4 and refreshes the What's New panel +
version-modal highlight reel to the 2.7.4 set (re-identify #889 headline; #890
title-strip; #891 residual-folder cleanup; #886 AAC tier; #887 Spotify Free
status; #884 NZBGet; #885 tz; Sokhi import-cleanup batch), with 2.7.3 rolled into
the brief 'earlier versions' summary per the current-release-only convention.
2026-06-18 20:44:53 -07:00
BoulderBadgeDad
1ccc7b5e15 #889: fix re-identify modal header — clip the blurred bg in its own layer
The hero's overflow:hidden was clipping the header content, but removing it let the
blurred background + overlay cover (and steal clicks from) the source tabs below.
Move the decoration into .reid-hero-decor — an absolutely-positioned clip layer
that contains the blur and is pointer-events:none — so the header content (a sibling,
never clipped) shows in full AND the tabs stay clickable.
2026-06-18 16:57:24 -07:00
BoulderBadgeDad
c4c112d17e #889 Phase 5: wire the Re-identify button into the Enhanced library view
Adds a per-track ⇄ action (admin-only, alongside source-info/redownload/delete)
that opens the Re-identify modal seeded with the track's title/artist/album/art.
The loop is now live: click ⇄ → pick a release → file stages + hint writes →
auto-import re-files it under the chosen single/EP/album (and replaces the old
entry on success when 'replace' is ticked).

Double-gated: the button only renders for admins, and /api/reidentify/apply
re-checks is_admin server-side.
2026-06-18 15:39:41 -07:00
BoulderBadgeDad
f4c16ecc22 #889 Phase 4: the Re-identify modal + apply backend
The showpiece: a focused 'which release does this track belong to?' chooser.
Source tabs (default active), pre-seeded search, the same song surfaced across
single/EP/album with color-coded type badges, ISRC-ranked, replace-original
toggle (on by default). Glassy panel, blurred hero art, shimmer/spinner states,
hover-lift result cards — matched to the app's modal language.

Backend:
- core/imports/rematch_apply.py: pure staged_destination + build_reidentify_hint,
  injectable stage_file_for_reidentify (COPIES the file, never moves — original
  safe until re-import succeeds). 6 tests.
- POST /api/reidentify/apply (admin-only): resolve_hint_fields → stage file →
  create_hint → nudge the worker. Replace deletes the old row only on success.

Frontend: modal markup (index.html), full stylesheet (style.css), and the
openReidentifyModal/search/select/confirm flow (library.js). Not yet reachable
from a button — Phase 5 wires it.
2026-06-18 15:37:56 -07:00
BoulderBadgeDad
70ea7eabf6 Update style.css 2026-06-18 15:05:14 -07:00
BoulderBadgeDad
400b35d655 #886: AAC as an opt-in Soulseek quality tier (purely additive, off by default)
radoslav-orlov: add AAC as a download quality option. AAC is more efficient than
MP3, so it's useful for Soulseek/torrents (streaming sources pick their own
codec; Amazon — the AAC-heavy one — is down).

Additive by construction: every quality tier already defaults enabled=false and
the waterfall is built only from enabled tiers, so AAC ships OFF and the bucketer
routes a not-enabled AAC file to the 'other' bucket EXACTLY as today (where it was
silently dropped). Only a user who turns AAC on makes it a first-class tier,
ranked above MP3 / below FLAC (priority 1.5, min-kbps gate so junk AAC can't beat
a good MP3).

- music_database: aac tier (disabled) in the default profile + all 3 presets.
- soulseek_client: map .m4a -> 'aac' in both result parsers (was 'unknown' ->
  dropped); add the 'aac' bucket + a gated branch + a fallback size limit.
- settings UI: an 'AAC' tier toggle (unchecked) between FLAC and MP3; save
  defaults its priority to 1.5 so upgraded profiles rank it right on first save.

7 seam tests pinning the additive guarantee (aac absent/disabled -> dropped as
before; FLAC/MP3 selection unchanged; aac on -> selectable, below FLAC, above
MP3); 81 quality/soulseek tests pass, ruff clean. quality_upgrade left untouched
(its AAC handling is unchanged).
2026-06-18 13:45:52 -07:00
BoulderBadgeDad
56da2e105c #887: Spotify enrichment shows 'Running (Spotify Free)' for no-auth users, not 'Not Authenticated'
radoslav-orlov: with no Spotify auth, enrichment runs on the no-creds Spotify Free
source (prefer-free is on by default) and IS working — pending drains, the modal
shows RUNNING — but the dashboard header tooltip said 'Not Authenticated /
Connect Spotify in Settings'. Two causes:

- get_stats() only set using_free for the rate-limit / spent-budget bridges, not
  the plain no-auth-default-free case. The loop already computes the right signal
  (free_serving = _free_active(), True here) but it was a local var. Cache it on
  self each iteration and report it as using_free (no auth API call in the 2s
  status loop).
- The dashboard's Spotify updater checked notAuthenticated BEFORE bridgingFree, so
  even with using_free it showed Not Authenticated. notAuthenticated now excludes
  the bridging-free case; the LastFM/Genius/Tidal/Qobuz updaters (no free path)
  are unchanged.

5 seam tests for get_stats free/auth reporting; 67 enrichment/free tests pass.
2026-06-18 13:00:33 -07:00
BoulderBadgeDad
820ff20139 Settings UI: 'Match singles to their parent album' toggle (Library > Post-Processing)
Surfaces metadata_enhancement.single_to_album as a checkbox in the Post-Processing
> Core Features section, next to the cover-art settings (it's about getting the
right album cover). Default OFF, wired like the replaygain toggle (load '=== true',
save raw .checked) since the generic data-config binding defaults a missing key to
ON. Registered the default in settings.py DEFAULT_CONFIG + config.example.json.
2026-06-18 09:44:17 -07:00
BoulderBadgeDad
6c0d79a84a Release 2.7.3: bump version + What's New / version modal + docker-publish default tag
- web_server.py: _SOULSYNC_BASE_VERSION 2.7.2 -> 2.7.3
- helper.js: WHATS_NEW + VERSION_MODAL_SECTIONS rewritten for 2.7.3
  (current release + rolled-down 2.7.2/2.7.1/2.7.0 summary)
- docker-publish.yml: workflow_dispatch default tag -> 2.7.3

2.7.3 = Quality Upgrade Finder + #867 Tidal discovery + #880/#879/#877/
#876/#874/#870/#868 fixes + the 'Track 01' track-number recovery.
2026-06-16 00:03:57 -07:00
BoulderBadgeDad
48e86a1a58 #874: wishlist ignore-list — stop auto-retrying removed/cancelled tracks
A user who removes a wishlist track, or cancels an in-flight wishlist
download, would have it re-added on the next auto cycle (watchlist scan,
failed-track capture, or the cancel handler's own re-add), so the same
release downloaded -> failed/cancelled -> re-queued forever.

Adds a TTL'd skip-gate (30 days), softer than the blocklist: it expires
so the track is reconsidered later, and never blocks a manual
force-download — only the automatic re-queue.

- core/wishlist/ignore.py: pure TTL/normalization/display logic + a
  best-effort orchestrator (no DB handle, caller passes now).
- database/music_database.py: migration-safe wishlist_ignore table +
  add/check/remove/list(+purge)/clear methods, and the gate in
  add_to_wishlist beside the blocklist guard. Fail-open throughout — an
  ignore error can never block a legitimate add; a manual add bypasses
  the gate AND clears the ignore.
- routes.py: user remove (single/album/batch) records an ignore. Hooked
  at the route layer, NOT the DB remove, so success-cleanup never
  ignores (regression-tested).
- web_server.py: cancel now ignores + removes from the wishlist instead
  of re-adding for endless retry; three /api/wishlist/ignore-list*
  endpoints.
- downloads.js: 'Ignored' modal (view / un-ignore / clear all).
- 13 tests: pure logic, DB seam, gate (block/bypass/fail-open),
  route wiring, and the success-cleanup-does-not-ignore regression.
2026-06-15 22:50:39 -07:00
BoulderBadgeDad
46be97b195 #876: group quarantine alternatives by target track-id + auto-clear siblings on approve
Multiple failed source attempts at one song each land in quarantine as
separate entries. Group them by the *intended* target (sidecar context
track_info isrc -> id -> uri, falling back to normalized artist|title for
legacy thin sidecars) — an exact relationship across siblings, since the
bad files' own tags differ but the target track is constant.

- core: quarantine_group_key() + find_quarantine_siblings() seams; list
  entries now carry group_key.
- approve endpoint: remove_siblings flag auto-deletes the other attempts
  once one is accepted (captured BEFORE approve restores the file out of
  quarantine, or the id lookup would resolve nothing). Scoped to the
  quarantine manager; download-modal chooser + version-mismatch fallback
  pass no flag and are unaffected.
- UI: multi-member groups render as a collapsible parent row (album art +
  'N alternatives'); singletons unchanged. Toast reports removed count.
- 11 tests incl. ordering regression for capture-before-approve.
2026-06-15 22:12:06 -07:00
BoulderBadgeDad
93af95d865 #876: show the real Quarantine tab count on open (not a stale 0)
The Quarantine tab badge was only populated by loadQuarantineList(), which runs
when the tab is clicked — so opening Library History showed a stale 0 until then.
Refresh the count on modal open via the existing /api/quarantine/list endpoint.
2026-06-15 21:37:42 -07:00
BoulderBadgeDad
e7814e0acf #877: Download Discography filters mirror Artist Detail (fix dead EPs + add Live/Comp/Featured)
The Download Discography modal exposed only Albums/EPs/Singles, its EPs toggle did
nothing, and Live/Compilations/Featured were missing — so you couldn't fine-filter
a bulk download the way Artist Detail lets you browse.

Root cause: the modal's endpoint (/api/artist/<id>/discography) used the base
get_artist_discography, which lumps EPs into singles, and the modal only read
{albums, singles} — so the EPs bucket was always empty (dead toggle). It also had
no content-type (Live/Compilation/Featured) classification at all.

- Backend: the endpoint now uses get_artist_detail_discography — the SAME split
  Artist Detail uses — and returns a separate `eps` list.
- Frontend: read `eps`; tag each card with data-is-live/compilation/featured via a
  new shared _classifyReleaseContent() (also adopted by the Artist Detail cards so
  the two can't drift); add Live/Compilations/Featured filter buttons; combined
  category+content filtering. The download payload is built from VISIBLE checked
  cards, so every toggle now actually changes what downloads.
- Regression test: get_artist_detail_discography splits an EP into the eps bucket.
2026-06-15 21:30:55 -07:00
BoulderBadgeDad
f2f0f5d849 Sidebar UI: frosted-glass header blur, centered nav badges, admin cleanup
- .sidebar-header: real frosted-glass blur of content scrolling behind it —
  made the background translucent (was an opaque base layer), added
  backdrop-filter blur, and raised the header above the nav (z-index) so nav
  items actually sit in its backdrop.
- .dl-nav-badge: vertically centered on the right (top:50% + translateY) instead
  of pinned to the top-right corner.
- Removed border-top-right-radius from .sidebar and .sidebar-header (square top).
- Hide the "My Accounts" + "My Settings" header buttons for admin profiles —
  both are inert for admin (every service is "Managed in Settings", and My
  Settings is an empty pointer note); kept for non-admins who get real UI.
2026-06-15 21:16:18 -07:00
BoulderBadgeDad
02d6af29ed #879: a failed settings load must never overwrite the saved config
Reported by @Lysticity: opening Settings reset the whole config to defaults. The
chain: GET /api/settings 500s (their env: ConfigManager missing redacted_config)
-> loadSettingsData() called response.json() WITHOUT checking response.ok, so the
error body {"error": ...} was treated as settings -> every field populated as
`settings.x?.y || ''` blanked to defaults -> autosave then wrote those defaults
over the real config.

Fix (settings.js): bail BEFORE touching any field when the response isn't ok / is
an error body, set window._settingsLoadFailed, and guard BOTH save paths
(debouncedAutoSaveSettings + saveSettings) on it. The flag clears on the next
successful load. So any load failure (500, lock, network) now leaves the saved
config untouched instead of wiping it.

The redacted_config method exists in all 2.7.x source + on dev (their 500 looks
like a stale/mismatched build), but the UI must not destroy config on ANY failed
load. Regression test pins redacted_config stays a callable method on the class
(its removal is exactly what 500s the endpoint).
2026-06-15 20:09:53 -07:00
BoulderBadgeDad
09b97c5f63 #870: Deezer ARL 'resets itself' — test the SAVED token, not the redaction mask
The Deezer ARL field round-trips a redaction sentinel for a saved-but-untouched
secret (shown as dots). The save path already guards against the sentinel
overwriting the real token (ConfigManager.set), so the ARL was never actually
lost — but the connection TEST read the field value and sent the sentinel as the
token, so Deezer returned USER_ID=0 ('Invalid ARL token') after navigating away
and back. That false failure made it look like the ARL kept resetting.

Fix:
- ConfigManager.resolve_secret(key, posted): empty/sentinel posted value -> the
  stored value; a real string -> a genuine new secret. Reusable for any secret
  connection-test (single source of truth).
- /api/deezer-download/test now resolves the effective ARL via resolve_secret, so
  an untouched field tests the stored token.
- testDeezerDownloadConnection() strips the sentinel before sending (untouched ->
  empty -> backend uses the saved token).

Seam/regression tests for resolve_secret (sentinel/empty/none -> stored, real ->
passthrough, nothing stored -> empty). JS integrity 64 green.
2026-06-13 15:37:29 -07:00
BoulderBadgeDad
b393866782 Remove old auto-acting Quality Scanner tool (replaced by Quality Upgrade Finder job)
Phase 2 of the redesign. The tool that judged quality by extension and auto-dumped
matches into the wishlist is gone; quality scanning is now the reviewed
quality_upgrade repair job.

Removed:
- Frontend: Tools-page Quality Scanner card, its JS handlers/poller/socket listener,
  help tooltip + tour entry (webui index.html, core.js, helper.js, wishlist-tools.js).
- Backend: /api/quality-scanner/{start,status,stop} endpoints, the in-memory state +
  executor + 1s socket broadcast, the QualityScannerDeps/run_quality_scanner shim.
- core/discovery/quality_scanner.py: the auto-acting worker + deps class (the shared
  match/normalize helpers stay — the new job imports them).

Rewired:
- Automation 'start_quality_scan' action now triggers the quality_upgrade repair job
  via repair_worker.run_job_now() (AutomationDeps gains run_repair_job_now, drops the
  4 scanner fields). Action block's vestigial scope field removed (scope lives in the
  job's settings now). NOTE: the 'quality_scan_completed' trigger no longer fires (the
  repair job doesn't emit it).
- Updated all automation test _build_deps helpers + conftest tool-progress harness;
  deleted the obsolete worker test. 528 affected tests pass; 6123 collect cleanly.

QUALITY_TIERS / _get_quality_tier_from_extension kept (used elsewhere).
2026-06-13 12:14:45 -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
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
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
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