Commit graph

4322 commits

Author SHA1 Message Date
BoulderBadgeDad
b72febbf1c manual search: INJECT the exact pasted-link track, don't rely on text search surfacing it (#932)
reopened by diegocade1: pasting a Qobuz track link still showed unrelated results. the earlier
fix (b1f061a) only BUBBLED the linked track to the top — but a pasted link is resolved to an
"artist title" text query and searched, and for an obscure track ("foreign lavennew" by colacola)
that text search returns broad lookalikes ("Foreign Bird", "Foreign Spies", …) and never the
actual track. nothing to bubble → user sees junk.

fix: since the link is already resolved via get_track(id), fetch that exact track AS a downloadable
result and inject it at the top (Qobuz downloads by id, so the result is fully usable). the text
search still runs for alternatives.

- QobuzClient.get_track_result(id): get_track + _qobuz_to_track_result; None on any failure.
- _qobuz_to_track_result gains require_streamable (default True for bulk search). the link fetch
  passes False: track/get may OMIT the streamable flag, which would default-False and wrongly drop
  the exact track the user explicitly asked for. (this closes the one shape assumption that
  couldn't be verified against a live Qobuz API — the track is no longer gated on it.)
- track_link.inject_linked_track_first(tracks, linked_result, id): pure seam — prepend the fetched
  result + drop any search duplicate; falls back to the bubble when no result was fetched.
- manual-search endpoint fetches linked_result defensively (getattr 'get_track_result') and calls
  the seam. Tidal/HiFi (get_track returns a dict but the converter wants an object — shape
  mismatch) have no get_track_result, so they keep the existing bubble path: NO regression.

14 tests: inject puts the fetched track first when search missed it / dedups a search copy / falls
back to bubble / str-safe id / noop; get_track_result convert/none/exception; and the REAL
converter builds a valid downloadable result from a track/get dict that OMITS streamable (search
path still rejects it). 85 track-link/qobuz tests green, ruff clean.
2026-06-28 12:42:20 -07:00
BoulderBadgeDad
8aa8fcb94a test: drift guard — frontend lossless lists must match backend LOSSLESS_FORMATS (#941)
the frontend keeps its own copy of the lossless set (settings.js RT_LOSSLESS_FORMATS + the
index.html quality-profile dropdown) — runtime-fetching a yearly-changing list from the backend
isn't worth the coupling. but that duplication IS the exact root cause of #941 (a format added
to one place, not another). so instead of unifying, pin it: two tests parse the frontend lists
and assert they match the backend LOSSLESS_FORMATS. adding a new lossless format now fails CI
until it's added everywhere, instead of silently shipping a half-wired feature.

verified the guard catches drift (not a tautology): a simulated backend-only 'ape' addition
makes the equality fail. 18 lossless tests green, ruff clean.
2026-06-28 12:18:57 -07:00
BoulderBadgeDad
4af7600fd5 lossy copy: support all lossless formats, not just FLAC (#941)
radoslav-orlov: "create lossy copies of lossless tracks" only recognized FLAC, even though ALAC/
WAV/AIFF/DSD are now quality-profile formats. the FLAC knowledge was hardcoded in 3 separate
places (the import path, the Lossy Converter scan, and the fix executor) — exactly how a format
gets added in one spot but not another.

kettui-style fix — one canonical seam both sites route through, instead of 3 more string edits:

- new core/quality/lossless.py: is_lossless_format / is_lossless_audio_path (pure; injects a
  codec probe for the ambiguous .m4a/.mp4 — ALAC vs AAC — so the decision stays testable with no
  I/O), LOSSLESS_FORMATS (single source of truth, derived-consistent with model.tier_score), and
  the lossy_output_would_overwrite_source safety invariant.
- create_lossy_copy + the Lossy Converter scan + repair_worker._fix_missing_lossy_copy all route
  through it. SQL pre-filters by candidate extensions, then each file is confirmed (probing .m4a).
- SAFETY: a lossy copy must never be written over its own source — an .m4a ALAC source + AAC
  target lands on the same .m4a path, and ffmpeg runs with -y. all three sites now bail on the
  overwrite case BEFORE ffmpeg (the existing delete-original guard was too late — the source was
  already clobbered). dropped a vestigial mutagen FLAC import; updated FLAC-only UI strings.

19 tests: full seam coverage (formats, the .m4a ALAC/AAC probe branch, candidate extensions, the
overwrite guard), a tier-model consistency test that fails if the lossless set drifts, and import-
site wiring tests — WAV now converts (was rejected), and the .m4a-ALAC+AAC overwrite case proves
ffmpeg NEVER runs. 286 quality/import/repair tests green, ruff clean.
2026-06-28 12:13:05 -07:00
BoulderBadgeDad
b62d9b5b08 quality: recognize DSD (.dsf/.dff) as lossless + stop the false "truncated" flag (#939)
diegocade1: DSD files (.dsf, ~500MB DSD64) were labeled "Low Quality" and nagged to upgrade.
two independent causes, both fixed (additive — no existing format/behaviour changed):

1) DSF was an unrecognized format -> bottom 'unknown' tier -> "Low Quality":
   - source_map: map .dsf/.dff -> 'dsf' (also lights it up in AUDIO_EXTENSIONS, so Soulseek can
     match a DSF if one exists)
   - model.tier_score: 'dsf' base 102 (just above FLAC) — lands in the lossless range
   - probe_audio_quality: add a DSD branch returning format='dsf' (mutagen.dsf for .dsf detail;
     .dff classifies lossless without measured detail) instead of None
   - settings UI: DSD in RT_LOSSLESS_FORMATS + a "DSD (DSF / DFF)" option in the profile dropdown

2) the actual cause of the screenshot's findings — the truncation guard falsely called DSF
   "broken (only ~12% decodes)": ffmpeg decodes DSD to PCM at a different rate than the DSD
   container's 2.8 MHz, so astats samples ÷ container-rate massively under-counts. now
   detect_broken_audio skips the truncation check for DSD (silence detection still applies).

8 seam tests: dsf/dff -> 'dsf'; dsf tier in lossless range (with + without measured bitrate);
is_dsd_path; and a contrast pair proving the same 12%-decode numbers flag a .flac but skip a
.dsf. 230 quality/import/silence tests green, ruff + JS integrity clean.
2026-06-28 11:45:16 -07:00
BoulderBadgeDad
bcf99d76d3 import page: share ONE staging scan across files/groups/hints (#935)
ramonskie's thread finding: opening the Import page fires staging files + groups + hints
together, and each one independently os.walk'd the whole staging folder AND mutagen-read every
file's tags — 3x the directory walk and 3x the per-file tag I/O on every page open (the import
scan storm + memory spike on large staging folders).

they all need the same per-file tag data, so scan ONCE: _scan_staging_records walks staging and
reads each file's metadata a single time, returning per-file records that files/groups/hints all
derive from in-memory. a short TTL (6s) + a lock means the three near-simultaneous page-open
requests share one scan instead of each kicking off a full re-read; the lock also prevents
concurrent full scans. hints now derives from the same read_staging_file_metadata the other two
use (same underlying tags) instead of a separate read_tags pass. album/singles process drop the
cache on completion so the list updates immediately after files leave staging.

net: 1 walk + 1 tag-read-per-file on page open instead of 3. 2 tests (shared-scan: 3 endpoints =
1 read per file, not 3; hints updated to the shared reader) + autouse cache-clear fixture; 695
import/staging tests green.
2026-06-28 09:49:03 -07:00
BoulderBadgeDad
77e3673c9c stats: normalize image URLs at cache-build time, not per /api/stats/cached read (#935)
reported (radoslav-orlov): the Stats page hangs ~20s on a 16GB/HDD box, GET /api/stats/cached
?range=7d -> 200 in ~20000ms, while it's instant on Boulder's SSD. the endpoint is documented
"instant" — it reads 3 small precomputed metadata blobs — but it then ran every top
artist/album/track image through normalize_image_url ON THE READ. that fixer calls
cache_url_for, which does a SQLite INSERT/UPDATE + commit under a global lock PER image. on an
HDD each commit fsyncs and contends with the background image fetcher -> ~20s for ~50 images.
(this is exactly the "image caching when we open a page" ramonskie flagged in the thread.)

fix: do the normalization in the background ListeningStatsWorker when it builds the cache
(_enrich_stats_items), so the cache stores browser-ready /api/image-cache/... URLs and the read
does ZERO per-image work. the read-path fixer stays as a cheap no-op (normalize_image_url early-
returns on already-proxied URLs), so an old raw-URL cache self-heals on the next rebuild with no
broken art in between. the registration writes now happen once per rebuild, off the hot path.

2 tests (existing enrich test relaxed to truthy since the value is normalized now; new
deterministic test stubs the fixer to prove every section's url is fixed at build time).
136 stats/listening/image tests green.
2026-06-28 09:32:48 -07:00
BoulderBadgeDad
d62afbb2df Release 2.8.0: Discord release notes 2026-06-28 00:12:38 -07:00
BoulderBadgeDad
d72f6396f0 lint: justify 4 best-effort try/except/pass (S110) so CI ruff passes
the CI ruff gate flagged 4 S110s in code added this cycle: the /api/debug/memory/objects
endpoint (len() on an exotic object; optional psutil rss) and the GC sweeper (malloc_trim
resolution + the trim call — absent on musl/non-Linux). all are genuinely best-effort, so add
'# noqa: S110' with a one-line reason on each. ruff check . is clean.
2026-06-28 00:03:19 -07:00
BoulderBadgeDad
2b8f6f8611 Release 2.8.0: version bump + docker-publish tag + What's New / version modal + PR description
- web_server: _SOULSYNC_BASE_VERSION 2.7.9 -> 2.8.0
- docker-publish.yml: default version_tag -> 2.8.0
- pr_description.md: rewritten for 2.8.0 (preview-clip cleanup, unverified-queue self-heal #934,
  album-completeness split albums #936, clear-completed, youtube cookies, #937, discography
  speed, wishlist art, dashboard perf #935, bounded memory #802)
- helper.js: WHATS_NEW carries the 2.8.0 block + folded "Earlier versions" summary;
  VERSION_MODAL_SECTIONS leads with 2.8.0 highlights, rolls 2.7.9 into an aggregator
2026-06-27 23:58:22 -07:00
BoulderBadgeDad
b543db147a youtube: use pasted cookies.txt instead of passing 'custom' as a browser to yt-dlp
Docker report (HellRa1SeR, 2.7.8): pasting YouTube cookies threw
`ERROR: unsupported browser: "custom"`. the 'Paste cookies.txt' dropdown value is the sentinel
'custom', and youtube_client built `cookiesfrombrowser=('custom',)` from it — yt-dlp rejects
that since 'custom' isn't a browser. (server/Docker users have no local browser, so pasted
cookies.txt is the ONLY way to authenticate yt-dlp — e.g. for private 'Liked Music'.)

the correct precedence already existed in core.youtube_cookies.build_youtube_cookie_opts
('custom' -> cookiefile, browser name -> cookiesfrombrowser, falsy -> anonymous) and the web
layer used it — but youtube_client never got migrated and kept 5 inline cookiesfrombrowser
sites. route all of them through a shared _resolve_cookie_opts() that delegates to the tested
helper (reads youtube.cookies_browser + youtube.cookies_file, checks the file exists). also:
the no-cookies download retry now drops cookiefile too (not just cookiesfrombrowser), and
removed 3 now-dead config_manager imports.

3 regression tests (custom->cookiefile, browser unchanged, custom+missing-file->anonymous);
97 youtube tests green.
2026-06-27 23:47:38 -07:00
BoulderBadgeDad
d30273985f downloads: restore Clear Completed for persisted history (clears the whole completed list)
since 9a0e3b40 persisted completed downloads in the Downloads view, the Clear Completed button
was hidden for those rows and clear-completed only pruned live session tasks. after a restart
the page filled with persisted completed downloads with no way to clear them.

now Clear Completed clears BOTH:
  - live session completed/failed tasks (clear_completed_local, unchanged), AND
  - the persisted download-history tail: new clear_completed_download_history() deletes every
    library_history event_type='download' row, so the list actually empties and stays empty.

this includes unverified rows (the verification review queue) by design: on a library where
verification never confirmed the imports, ALL completed downloads are 'unverified', so preserving
them made the button a no-op. it only removes HISTORY rows — the actual files and their tracks
entries are untouched, so nothing in the library is lost, only the 'needs verification' flags.
the action confirms first (showConfirmDialog, destructive) and the button now shows whenever any
completed/failed row is present.

3 seam tests (clears all incl unverified; leaves non-download history; empty=0); reconcile +
orphan + JS integrity suites green.
2026-06-27 23:37:06 -07:00
BoulderBadgeDad
4487a9d2dc unverified-history reconcile + orphan-clean: hardening follow-up to #938
four fixes from the review (and a self-correction):

1) close the connection. reconcile_unverified_history_from_tracks opened a connection with no
   finally/close. runs once per boot so GC reclaimed it, but now it's consistent + robust.

2) scope the tracks scan to the review queue. it built lookup dicts from EVERY verified/
   human_verified track (~350k on a large library) on every boot while anything is unverified
   (the normal state). now it loads the stuck rows first and skips verified tracks whose path
   AND basename can't match any queued row, so dicts stay proportional to the queue, not the
   library. behaviour identical (all 13 PR reconcile tests still pass).

3) close the title-less basename collision. a title-less history row fell back to filename-only
   matching with no ambiguity check, so a generic name like "01 - Intro.flac" could heal a
   DIFFERENT song to verified. now a title-less basename heal only fires when that basename is
   unique among verified tracks; unique-basename rows still heal (recall preserved).

4) "Clean orphaned" protects force_imported rows (deliberate user decision, keep for human
   approval) without weakening the mount-down safety gate. CRUCIAL self-correction: filtering
   them out BEFORE the orphan check (my first cut) shrank the checked count below the threshold
   and would have let a few unverified orphans be deleted during a mount outage. instead,
   find_orphan_history_ids now takes a deletable predicate: protected rows still count toward
   checked / all-missing (gate stays strong) but never enter the orphan_ids delete set.

3 new regression tests (title-less collision; deletable protects from delete; protected rows
still count toward the gate). 936 verification/acoustid/history/downloads tests green. builds
on nick2000713's #938.
2026-06-27 22:35:16 -07:00
BoulderBadgeDad
d84fba14ba
Merge pull request #938 from nick2000713/fix/unverified-acoustid-934
Fix/unverified acoustid 934
2026-06-27 22:14:01 -07:00
BoulderBadgeDad
50c17ec9f7 discography 'add to wishlist': batch the per-track ownership check (fixes ~15-30s/track)
clicking Download Discography → Add all to wishlist added ~1 track every 15-30s. trace: the
endpoint's per-track library-ownership check (track_already_owned → check_track_exists) ran the
LEGACY path — firing search_tracks for every title-variation × artist-variation, per track. on
a large library and an artist you own NOTHING of, STRATEGY-1 (indexed LIKE) always missed and
fell through to the fuzzy fallback (full-table scan), ~10-15 scans/track = the 15-30s. metadata
fetch was never the bottleneck (deezer returns each album in ~1s).

fix: pre-fetch the artist's owned tracks ONCE (get_candidate_albums_for_artist →
get_candidate_tracks_for_albums) and pass candidate_tracks to check_track_exists's batched
in-memory path — the same path the discography backfill job + completion-stream already use.
pass an EMPTY list (not None) when nothing is owned so the owns-nothing case still takes the
fast path → instant. per-track cost drops from ~20s to ~1ms.

safe: track_already_owned's only real caller is this endpoint; the new param is optional
(None = unchanged legacy behaviour for any other caller). normal ownership still detected (same
artist-variation breadth); the one divergence is a track owned ONLY via a compilation → a
harmless redundant wishlist add, which is the endpoint's explicitly-accepted failure mode and
already how the backfill job behaves. 4 new tests; 1134 discog/metadata/wishlist tests green.
2026-06-27 19:51:28 -07:00
BoulderBadgeDad
88b68b8073 worker orbs: drive the render loop with setInterval on firefox (permanent 1fps fix)
the dashboard orb canvas kept falling back to ~1fps on firefox after the page settled. root
cause: firefox throttles requestAnimationFrame to ~1fps for a canvas it heuristically deems
occluded. the WAA keepalive only delayed the heuristic; it re-fired over time.

real fix: on firefox, drive tick() with a setInterval(~60fps) instead of self-scheduling rAF —
setInterval is not subject to the canvas-occlusion rAF throttle, so the orbs stay at full
framerate indefinitely. chrome is untouched (keeps vsync-aligned rAF). same render workload
(idle still drops to 20fps via the existing sleep skip); background tabs still parked by the
visibilitychange handler → stopLoop clears the interval. kept the keepalive (harmless).
2026-06-27 18:27:31 -07:00
BoulderBadgeDad
80d84c6de3 album completeness: fix excluded-sibling missing list + O(N) grouping (follow-up to #936)
#936 added fragmented-row grouping. two follow-ups found in review:

1) BUG: an excluded canonical sibling (a row pinned to the same canonical edition whose tracks
   fail the strict fragment match) was emitted with canonical_items but no owned set, so
   _build_missing_tracks flagged the ENTIRE tracklist as missing — including tracks the row
   already owns (e.g. a 3-track fragment of a 12-track edition reported 12 missing, not 9).
   now compute that sibling's own owned slots from its local tracks (shared
   _owned_reference_for_tracks helper, same logic as the anchor) so it reports only what it's
   actually missing and the count stays internally consistent.

2) PERF: _build_candidate_groups rescanned all albums for every canonical group — O(G*N), which
   degrades badly once many editions are pinned (fine today at G=1, latent at scale). invert to
   a single O(N) pass that assigns each row to its unique group; identical 'exactly one match'
   semantics. also added a Stop/Pause check in _prepare_work_items, which now front-loads the
   canonical lookups + matching.

3 new tests (excluded-sibling reports only its missing tracks; ambiguous candidate stays
   independent; unambiguous candidate joins) — 17 completeness + 123 repair tests green.
2026-06-27 18:18:50 -07:00
BoulderBadgeDad
60b0022122
Merge pull request #936 from ragnarlotus/fix/album-completeness-fragmented-rows
Fix fragmented album rows in Album Completeness
2026-06-27 18:10:57 -07:00
BoulderBadgeDad
b683c7fb50 wishlist: render library-sourced art (album + artist) — fix blank wishlist images
root cause: the library stores album/artist art as media-server RELATIVE paths (Plex
/library/metadata/.., Jellyfin /Items/.., Navidrome /rest/..), which don't render in a browser
<img>. normal wishlist items carry Spotify CDN urls so they show fine, but LIBRARY-sourced
items — dead-file re-downloads and preview-clip re-fetches — carry the raw relative path, so
their album art came up blank. and the nebula only had artist photos for WATCHLISTED artists,
so non-watchlist orbs showed initials.

fix on READ in the wishlist tracks endpoint (so it also repairs items already in the wishlist,
no re-run needed), using the library data we already have:
  - normalize each track's album.images url that needs it — relative/internal only, via the
    canonical normalize_image_url; CDN urls are left untouched so already-rendering items can't
    regress.
  - build an artist-name -> normalized library-photo map and return it; the nebula seeds its
    artist-image map from it (every wishlist artist), with curated watchlist photos overriding.

8 tests (predicate: relative/internal fixed, CDN untouched; album normalize in-place; artist
map build/skip-empty/idempotent/graceful). 237 wishlist+repair+JS tests green, ruff clean.
2026-06-27 17:35:28 -07:00
BoulderBadgeDad
4c53a8f3a2 preview-clip job: enable select-all/fix-all + give the re-wishlist proper album art
two field reports:
1) FIX-ALL skipped these findings — bulk_fix_findings() has a hardcoded fixable_types
   allowlist that didn't include 'short_preview_track'. added it, so select-all/fix-all and
   the per-page bulk-fix now cover this tool.
2) RE-WISHLISTED ITEM WAS ART-LESS — the payload pulled album art from the library thumb,
   which is empty for un-enriched HiFi previews, so the wishlist orb showed initials (no album
   OR artist image, since the orb falls back to album art). now the duration lookup also
   captures the metadata source's CDN art from raw_data (spotify album.images / itunes
   artworkUrl, upscaled) and stores it on the finding; the fix prefers that over the empty thumb.

3 new tests (art capture from spotify raw_data, itunes artwork upscale, fix uses finding art);
8 job tests + repair suite green.
2026-06-27 17:10:07 -07:00
dev
0be1952222 downloads(#934): opt-in "Clean orphaned" action for dead review-queue rows
The reconcile heals rows whose file is still in the library; it deliberately
leaves ORPHANS — history rows whose file is gone (deleted / replaced /
re-downloaded elsewhere). Those can never be healed (no file left to confirm)
and linger in the Unverified list forever. This adds an explicit, user-initiated
cleanup for them.

- core/downloads/orphan_history.py: pure, tested rule. A row is an orphan when
  its file resolves nowhere; flags `suspicious` when EVERY reviewed file is
  unreachable (the mount-down signature) so the caller refuses rather than
  mass-delete a healthy log during an outage.
- POST /api/verification/clean-orphans (admin-only): runs it against
  _resolve_history_audio_path (raw path -> prefix-swap resolver -> tracks-table
  title fallback), refuses on the suspicious signature, and deletes only history
  ROWS — never a file (the files are already gone).
- UI: "🧹 Clean orphaned" button in the Unverified bulk-actions row, with a
  confirm dialog spelling out that it removes log rows only and refuses if the
  library looks offline.

NEVER automatic / never at boot — a filesystem check during a mount outage would
otherwise wipe good history. 5 pure-rule tests + safety-gate coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWJk7EuM7YktQeNyqQwTZY
2026-06-28 02:04:08 +02:00
dev
27ea2ee735 downloads(#934): title-guard the reconcile basename match (fix self-introduced false-heal)
Second-pass audit of the startup reconcile found a real correctness bug in it:
the basename fallback had NO title guard, so a shared track-number filename
("01 - Intro.flac" in different albums) would heal the WRONG song — marking an
actually-unverified file 'verified' and silently dropping it from the review
queue. This is the exact collision the scan-time matcher (history_match.py)
guards against; the reconcile now mirrors it.

- basename match now requires the history row's title and the candidate track's
  title to agree (alphanumeric-lowercase), only when BOTH are present (legacy
  rows without a title still fall back to filename-only, like the matcher).
- exact-path matches stay unguarded (same path = same file, unambiguous).
- cheap early-out: skip the tracks scan entirely when no 'unverified' rows exist
  (keeps the every-boot cost ~nil on healthy libraries).

3 new tests (collision must-not-heal, titles-agree heals, missing-title falls
back). 8 reconcile tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWJk7EuM7YktQeNyqQwTZY
2026-06-28 01:49:41 +02:00
BoulderBadgeDad
034a19b24f preview-clip findings: add Play button + length comparison to verify before approving
each preview-clip finding now renders a dedicated detail card: File Length vs Real Length
(e.g. 28s vs 200s) and a Play button so the user can listen to the clip and confirm it's a
busted ~30s preview before approving the delete + re-download. reuses the existing
_renderPlayButton/playFindingTrack path that dead_file/orphan findings already use (the
finding carries file_path + title/artist/album, everything it needs).
2026-06-27 16:27:27 -07:00
BoulderBadgeDad
bd3c5860f6 preview-clip job: fix scan hang + report live per-track progress
two field-reported issues: clicking the job sat at 'Starting…' with Scanned: 0 forever and
never showed the current track.

1) HANG — spotify get_track_details() defaults to allow_fallback=True, which scrapes the
   configured metadata source when the official API isn't authed (HiFi users). that scrape is
   slow and blocked the scan loop on the first track. now pass allow_fallback=False (official
   only — fast, returns None cleanly) and fall through to iTunes/MusicBrainz.
2) NO LIVE UPDATE — progress was only pushed every 5 tracks via update_progress, never the
   current item. now report_progress() every track (phase + 'artist — title' + scanned/total)
   plus a start phase, so the UI moves and shows what it's checking.

also made the test track ids INTEGER to match production (tracks.id is INTEGER PRIMARY KEY),
exercising the real str(id) finding -> WHERE id=? round-trip. 5 tests green.
2026-06-27 16:24:31 -07:00
BoulderBadgeDad
116edeb477 tools: add Preview Clip Cleanup repair job (detect ~30s previews, re-fetch full track)
HiFi (and occasionally other) downloads sometimes deliver a ~30s preview clip instead of the
full song; it lands in the library looking real. new repair job scans short tracks (duration
<= 30s, configurable), looks up the EXPECTED length from the track's metadata source
(spotify/itunes/mb get_track_details), and flags any whose real length is much longer than the
file (default: >= 30s longer) as a preview clip.

approving the finding (repair_worker._fix_short_preview_track) deletes the preview file (path
resolved via _resolve_file_path like the other delete tools), drops the DB row so the track
goes missing, and re-adds it to the wishlist with the full payload (mirrors _fix_dead_file)
so the real version downloads. scan ONLY creates findings — nothing destructive without user
approval, like every other tool.

conservative: genuine short tracks (source agrees they're short) and tracks whose length can't
be verified are skipped, never flagged. registered the job + finding-type label/fix-button in
the UI. 5 tests (scan flag/skip/scope + fix delete+remove+wishlist); 89 repair tests green.
2026-06-27 16:07:41 -07:00
BoulderBadgeDad
319b6483a2 integrity: don't quarantine longer masters/versions as 'truncated' (#937)
the duration-agreement check used abs() drift, so a file running LONGER than the metadata
(a remaster with a longer outro, an extended cut) was rejected the same as a truncated one —
e.g. A-Ha 'Take on Me' remaster at 228.5s vs 225.0s expected, quarantined for +3.5s.

but a longer file is the OPPOSITE of truncated. make the auto tolerance asymmetric: keep the
tight 3s/5s bound for SHORTER files (the truncation case the check exists for), allow up to
15s in the LONGER direction for version/master differences. a wrong song still trips it (off
by far more than 15s), and a user-pinned tolerance is honoured symmetrically. direction-aware
rejection message too. 4 new tests; 274 integrity/import tests green.
2026-06-27 15:25:13 -07:00
BoulderBadgeDad
b85e9b40d9 perf: malloc_trim after GC so RSS actually drops (caps the peak) (#802)
the growth-triggered collects were firing but RSS still climbed to 2.2GB before snapping to
1.2GB — because gc.collect() freed the python objects but glibc hoarded the memory rather than
returning it to the OS, so RSS stayed at the high-water mark. add malloc_trim(0) after each
collect to hand freed arenas back to the OS, so incremental collects genuinely lower RSS and
the sawtooth caps near floor+200MB instead of overshooting. best-effort (skipped on musl/non-
linux). also tightened the growth trigger 250->200MB.
2026-06-27 14:34:59 -07:00
BoulderBadgeDad
b4583f23be perf: growth-triggered GC instead of fixed timer — caps the peak (#802)
the 60s timer overshot: browsing piled plexapi cyclic garbage faster than once-a-minute caught
it, so RSS hit ~2.2GB before a sweep (then dropped to 1.2GB). switch to polling RSS cheaply
(every 8s) and collecting as soon as it grows +250MB since the last sweep — so it fires DURING
a heavy browse and caps the peak near floor+250MB instead of running to 2GB+. keeps a 120s
backstop for slow idle accumulation.
2026-06-27 14:24:05 -07:00
BoulderBadgeDad
f383a62c4f perf: periodic full GC to bound RSS (plexapi cyclic Element trees) (#802)
measured the 'resource hungry' / lockup issue: browsing every page grows RSS ~300MB -> 1.8GB
and it stays. it's not a leak — it's deferred cyclic collection. plexapi parses Plex responses
into XML Element trees whose nodes reference each other in cycles; Python's generational GC
leaves them in gen2 and sweeps it rarely, so ~227k Element objects pile up. forcing gc.collect()
reclaimed ~700MB instantly (1.8GB -> 1.1GB live), confirming.

add a daemon that runs a full gc.collect() every 60s so the cyclic garbage is reclaimed on a
cadence instead of climbing into lock-up. full collect is ~tens of ms; once a minute is
negligible. this is the root of the reporter's 2GB + ramonskie's spike too.
2026-06-27 14:17:58 -07:00
BoulderBadgeDad
de9e78cf95 debug: add lightweight one-shot /api/debug/memory/objects (gc, no tracemalloc)
tracemalloc's continuous tracing locks up a loaded app, so add a one-shot gc-based memory
breakdown: top object types by total size AND by count, plus the biggest individual containers
(>1MB). a runaway 'count' points at an unbounded cache; a big bytes/str total points at blob
retention. lets us pinpoint the RSS growth (300MB -> 1.8GB after browsing) without tracing.
2026-06-27 14:02:46 -07:00
BoulderBadgeDad
5a54ffe14a dashboard: show SoulSync's own RAM next to system memory %
the Memory Usage stat showed only global system memory (psutil.virtual_memory().percent).
add the process's own resident set size (RSS) — the real 'how much RAM SoulSync uses' number —
formatted MB under 1GB, GB above. headline stays the system %, subtitle now reads 'SoulSync ·
612 MB' instead of the generic 'Current usage'. graceful fallback if psutil errors / older
backend. useful context after the recent RAM-footprint discussions.
2026-06-27 13:42:14 -07:00
BoulderBadgeDad
6802399805 dashboard/shell: drop frosted-glass blur on Firefox only (#935)
Firefox re-rasterizes blur()/backdrop-filter every composite where Chrome caches it, so the
always-visible shell glass (sidebar header + aura orbs, hero/header buttons) was ~half of
Firefox's idle GPU. gate behind @supports(-moz-appearance:none) so it's Firefox-only: hide the
two blur(28px) sidebar orbs + the dash-card blobs, and drop backdrop-filter on the sidebar
header and hero/header buttons (each keeps its tint, just unfrosted). measured ~20-25% -> ~10-13%
on Firefox, every page (sidebar is always visible). chrome is untouched — the block doesn't
exist there, full frost intact.
2026-06-27 13:36:15 -07:00
BoulderBadgeDad
240dce0c1b worker-orbs: Firefox compositor keep-alive fixes post-hover 1fps slowdown (#935)
removing the always-on dash-card blob animation (for the Chrome GPU win) incidentally let
Firefox start throttling the worker-orb canvas's compositing to ~1fps after a header hover
re-layerizes the dashboard — Chrome never throttles it. re-add the 'keep the compositor warm'
effect cheaply: a 2px, ~invisible element running an infinite transform-only animation (zero
paint). gated behind CSS.supports('-moz-appearance') so it's Firefox-only; Chrome never gets
it. confirmed fix in Firefox/Zen.
2026-06-27 13:07:03 -07:00
BoulderBadgeDad
c6caaaa599 dashboard: hide cursor-glow blobs on Firefox only (#935)
the .dash-card cursor blobs are 16 large blur(48px)/blur(18px) layers. chrome caches them
once; firefox re-rasterizes blur on every composite, so they're a big chunk of idle dashboard
GPU on firefox. they're purely decorative and reduce-effects already hides them. gate behind
@supports(-moz-appearance:none) so it's firefox-only — chrome keeps the full cursor glow,
this block doesn't exist there.
2026-06-27 12:53:10 -07:00
BoulderBadgeDad
9faaf5c50c sidebar orbs: drop scale() from the drift so the blur stops re-rasterizing (#935)
same antipattern as the dash-card blobs: .sidebar::before/::after are blur(28px) and the
orb keyframes animated transform: scale() infinitely → the GPU re-blurred them every frame,
on every page (the sidebar is always visible). keep the translate drift + opacity (both
compositor-only, the blur layer just moves), remove the scale. same look, no per-frame reblur.
2026-06-27 11:34:37 -07:00
BoulderBadgeDad
bce6a91aa2 dashboard: stop the cursor-blob pseudo-elements re-blurring every frame (#935)
each .dash-card renders two accent-blob pseudo-elements — ::before is 1280x1280 blur(48px),
::after 540x540 blur(18px) + mix-blend-mode:screen — and both ran an INFINITE scale-pulse
animation. scaling a blurred element re-rasterizes the blur every frame; with 8 cards × 2
blobs that's 16 huge blurred layers re-blurring at 60fps whether or not the user touches
anything. that's the dashboard's whole-screen repaint / ~36% idle GPU.

remove the infinite pulse (the dashBlob*Pulse animations). the blob still follows the cursor
via --blob-x/y; it just no longer 'breathes' at idle, so when nothing's moving there's nothing
to repaint. trimmed will-change to the props that actually change (left/top).
2026-06-27 11:29:39 -07:00
BoulderBadgeDad
28a539a840 ui: default Background Particles OFF (#935)
the full-page particle canvas runs a continuous requestAnimationFrame loop behind every
page — real GPU cost, and multiple users hit GPU strain until they found the toggle. flip
the default to off; the eye candy is opt-in now.

- init.js: runtime flag defaults false unless localStorage is explicitly 'true'
- settings.js: config read is now '=== true' (default off) instead of '!== false'
- index.html: checkbox no longer 'checked' by default; hint reworded

existing users who explicitly enabled it (localStorage/config 'true') keep it on; the
existing '!== false' runtime guards still work since the flag is now always set explicitly.
2026-06-27 10:48:07 -07:00
dev
027cf74d47 ui(downloads): Unverified review rows get the Quarantine-style card design
The Quarantine sub-view was reworked into rich cards (artwork, source line,
row-click to expand an inline detail panel, consistent action cluster) but the
Unverified sub-view was left on the generic download-row layout that opened a
modal on click. Bring it to parity:

- dedicated _verifUnverifiedRowHtml / _verifUnverifiedRows renderer, used via a
  sibling branch in _adlRender (mirrors the quarantine sub-view branch).
- row click toggles an inline details panel (why flagged, download source,
  quality, file, downloaded-at), open-state keyed by stable id so it survives
  the 2 s poll re-render — same pattern as verifQuarInspect.
- reuses the existing verif-quar-* / verif-actions / adl-row CSS (no new styles)
  and the existing play/compare/audit/approve/delete handlers.
- NO grouping: each unverified import is its own track (grouping only makes
  sense for the quarantine alternates), per design intent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWJk7EuM7YktQeNyqQwTZY
2026-06-27 19:27:28 +02:00
dev
a42cf3b865 downloads(#934): instant startup reconcile of stuck-'unverified' history from tracks truth
Complements the AcoustID-scan-time heal: re-links library_history rows still
showing 'unverified' to the verified/human_verified status their file already
carries in the tracks table — matching exact path AND basename, so a file that
moved (media-server import / reorganize) heals even though the stored history
path is frozen. Upgrade-only and non-destructive (no deletes, no bulk
migration).

Why this is needed on top of the scan-time fix:
- It clears the EXISTING backlog (e.g. 5551 rows) on the next restart with NO
  re-fingerprinting and no AcoustID API calls — the file's status is already in
  tracks from the prior scan; this just propagates it to the frozen history row.
- It covers human_verified files, which the AcoustID scan skips entirely
  (file_verif_status == 'human_verified' returns early), so their stale history
  rows would otherwise never heal.

Runs once on DB init (cheap, idempotent). 5 real-sqlite tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWJk7EuM7YktQeNyqQwTZY
2026-06-27 19:22:45 +02:00
BoulderBadgeDad
3a92571c71 downloads: stop AcoustID scan duplicating history rows / leaving verified tracks 'unverified' (#934)
the AcoustID scanner matched library_history rows by EXACT file_path, but that path is
frozen at import time while the file moves afterward (media-server import / reorganize) —
so tracks.file_path (what the scan reads) no longer equals it. two failures resulted, both
introduced in 37ea6604: verified status never reached the history row (verified tracks kept
showing 'unverified'), and a fresh acoustid_scan row was INSERTed every run (5551 rows for
3675 songs).

- new pure, tested matcher (core/downloads/history_match.py): exact path → filename guarded
  by title; prefers a real download row over a synthetic scan row.
- _persist_status now HEALS the matched row's path + status (so future scans match cleanly),
  DELETES synthetic acoustid_scan duplicates by exact path (collision-free, never a real row),
  and inserts only when the file genuinely has no row.
- a full AcoustID job now self-cleans existing duplicates — no destructive bulk migration.

8 matcher + 4 real-DB heal/dedup/insert tests; existing scanner tests updated to the new
seam (heal vs insert). 1076 acoustid/verification/download tests green.
2026-06-27 09:52:39 -07:00
ragnarlotus
866c8a6400 Merge remote-tracking branch 'upstream/dev' into fix/album-completeness-fragmented-rows 2026-06-27 13:38:10 +02:00
BoulderBadgeDad
eddaea2f93 watchlist history: record automatic scans too (#933)
save_watchlist_scan_run had a single caller — the manual scan endpoint. the automatic/
scheduled path (process_watchlist_scan_automatically) ran the full scan but never wrote a
history row, so nightly scans never showed up in the History modal — only manual ones.

- new shared helper persist_scan_run(database, state, ...) extracts the run from the
  finished watchlist_scan_state and writes one history row
- the automatic path now stamps scan_run_id/scan_track_events and calls it
- the manual path is refactored onto the same helper so the two can't drift apart again
- history is global (no profile filter), so the all-profiles nightly scan records one
  aggregate row (profile_id None → 1, never NULL)

tests: 4 new persist_scan_run seam tests (real DB) + 2 new auto-scan integration tests
proving the auto path actually records (completed + cancelled, exactly once). 420
watchlist/automation tests green.
2026-06-27 00:42:58 -07:00
BoulderBadgeDad
b1f061a2a8 manual search: float the pasted Qobuz/Tidal track to the top (#932)
a pasted track link IS resolved + searched, but the 'bubble the exact track to the top'
step read getattr(t,'id') — and TrackResult has no top-level id (the source id lives in
_source_metadata['track_id']). so the bubble was a silent no-op: the linked track sat buried
among fuzzy text-search lookalikes and the user saw unrelated tracks. qobuz made it worse —
_qobuz_to_track_result never stamped _source_metadata at all, so the track had no id to match.

- stamp _source_metadata={'source':'qobuz','track_id':...} on qobuz TrackResults (mirrors tidal)
- extract the bubble into pure, tested helpers (linked_track_id / bubble_linked_track_first)
  that read _source_metadata['track_id'] — fixes it for tidal too, str/int-safe, stable no-op
19 track-link tests (+6 new) + 87 qobuz/download tests green.
2026-06-26 21:45:41 -07:00
BoulderBadgeDad
7e2d2db08d watchlist: don't fuse different editions as the same album (Sokhi: Expedition 33)
_normalize_album_for_match stripped ANY trailing '- clause', so a real distinguishing
subtitle ('Clair Obscur: Expedition 33 - Nos vies en Lumière (Bonus Edition)') collapsed to
the same name as the OST → _albums_likely_match treated them as one album → the watchlist
marked unowned tracks of one edition as owned via the other and under-wishlisted.

- strip a trailing '- ...' clause ONLY when every token in it is an edition/format
  qualifier (+ connectors / year-ordinal): '- Single', '- Acoustic Version', '- 2011
  Remaster' still collapse, but real subtitles ('- Nos vies en Lumière', '- Volume 2',
  '- Live in Berlin') are kept. Avoids the inverse regression (a same-album pair splitting
  into a redownload loop), which a naive narrow strip would have caused.
- drop the loose substring shortcut + raise the fuzzy floor 0.6→0.85; genuine drift already
  collapses to an EXACT match, so the looseness only ever produced false fuses.

blast radius: _albums_likely_match has exactly one caller (the allow-duplicates skip).
48 album-match tests pass (qualifier-suffix merges + edition-subtitle splits) + 219 watchlist.
2026-06-26 20:38:10 -07:00
BoulderBadgeDad
e96d62432f test(album-completeness): stop polluting sys.modules (fix flaky suite failure)
the file faked spotipy + config.settings in sys.modules at import time with no teardown.
the fake config.settings had no ConfigManager, so depending on collection order it leaked
into tests/test_config_save_retry and intermittently failed the full suite. the real
modules import fine in the test env (spotipy is installed, config.settings has both
ConfigManager + config_manager), so the stubs were pure liability — removed them. album
tests still pass (10), the album+config combo that errored now passes (17), 573 repair/
config/canonical tests green.
2026-06-26 19:56:11 -07:00
BoulderBadgeDad
3a95fc45a4
Merge pull request #931 from ragnarlotus/fix/album-completeness-canonical-source
Fix album completeness canonical edition matching
2026-06-26 19:50:12 -07:00
BoulderBadgeDad
31637e0096 canonical: recognize musicbrainz as a readable album source (PR #929 follow-up)
#929 added 'musicbrainz' to library_reorganize._ALBUM_ID_COLUMNS but not to the
canonical layer, breaking the equality invariant test (canonical reads exactly what
reorganize reads). add musicbrainz to CANONICAL_ALBUM_SOURCES, and move it from the
'can't pin' param group to the 'pins' group in the manual-lock tests — now consistent,
and forward-compatible with pinning a deliberately-matched MB edition. inert at runtime
today (mb isn't in the manual source selector, so should_pin is never called with it).
540 canonical/reorganize tests green.
2026-06-26 19:04:24 -07:00
BoulderBadgeDad
18f390b879
Merge pull request #929 from ragnarlotus/fix/reorganize-musicbrainz-release-id
Fix MusicBrainz release resolution in Library Reorganize
2026-06-26 18:58:03 -07:00
ragnarlotus
85d5904846 fix album completeness fragmented rows 2026-06-27 00:04:06 +02:00
ragnarlotus
a6364ac283 fix album completeness canonical edition matching 2026-06-26 23:47:10 +02:00
ragnarlotus
8a6b02b3fd test(reorganize): verify MusicBrainz integration 2026-06-26 03:10:07 +02:00