Commit graph

32 commits

Author SHA1 Message Date
dev
5f5bf4b24e feat(quarantine): consolidate quarantine view into downloads page filter
Removes the duplicate Quarantine tab from the Library History modal and
brings the same-song grouping feature into the ⚠ Unverified/Quarantine
filter on the Downloads page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:14:54 +02: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
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
f5787764d4 #859: DB-update stall watchdog + UI self-heal (no more wedged 'Starting...' / frozen bar) 2026-06-12 19:38:30 -07:00
nick2000713
bf5affd03c resolve merge conflict in style.css 2026-06-11 18:21:04 +02:00
BoulderBadgeDad
0afa3c9705 Fix: "Discovery state not found" when fixing a match after restart/import (#843)
The discovery FIX → Confirm flow 404'd with "Discovery state not found" whenever
the in-memory discovery state was gone — a server restart, or an imported
playlist that wasn't discovered in THIS process — even though the card is still
shown from persisted data (the reporter's log shows "Returning 0 stored ...
playlists for hydration", i.e. the in-memory states were empty).

The thing that actually makes a manual fix STICK is writing it to the discovery
cache (save_discovery_cache_match), keyed by the original track's name + artist —
which doesn't need the in-memory state at all. But the endpoint 404'd on the
missing state before reaching that write, so the fix was dead after a restart.

- update_discovery_match (core/discovery/endpoints.py) now only does the in-memory
  result update when the state exists; the durable discovery-cache write always
  runs, falling back to client-provided original_name/original_artist when there's
  no in-memory state. With neither a state nor originals it still 404s (unchanged).
- The FIX confirm (wishlist-tools.js) now sends original_name/original_artist
  (from the source track it already has) so the backend can key the cache.

Covers all sources that share the helper (tidal/deezer/qobuz/spotify-public).
Tests: no-state-but-originals saves the cache + returns success; no-state-no-
originals still 404s; existing with-state path unchanged. 73 discovery tests pass.
2026-06-10 18:49:32 -07:00
dev
37ea6604c7 Fix import artist override and verification review 2026-06-11 01:28:31 +02:00
BoulderBadgeDad
8633386f00 Wishlist: manual "add to wishlist" now skips already-owned tracks (#825)
The manual album "Add to Wishlist" modal had NO ownership check at any layer —
the album view opened the modal without ownership info, the modal added every
track, and the backend (add_album_track_to_wishlist) added each unconditionally.
So adding an album you (partially) own dumped the owned tracks straight into the
wishlist (carlosjfcasero #825) — and the auto-cleanup doesn't reliably remove
them. The bulk discography path already dedups (full missing-track analysis);
this path didn't.

Backend (the reliable seam): add_album_track_to_wishlist now skips a track that
already exists in the library, gated on the same wishlist.allow_duplicate_tracks
toggle the watchlist scan + cleanup use — OFF → skip owned (returns
{success, skipped:true}), ON → add anyway. Default is ON, so default users are
unaffected; the quality re-download flow uses a different endpoint, so it's
untouched.

Frontend: handleAddToWishlist + addModalTracksToWishlist count skipped tracks
separately so the toast is honest ("Added 3 (5 already owned)" / "All N already
in your library") instead of falsely claiming owned tracks were added.

Tests: skips owned when duplicates off, adds missing when off, adds owned when
on (and doesn't even run the check then). 205 wishlist tests pass.
2026-06-09 10:55:45 -07:00
BoulderBadgeDad
e6d86dea26 Add "Import IDs from File Tags" backfill — gap-fill provider IDs from embedded tags
Files SoulSync (or MusicBrainz Picard) already tagged carry Spotify /
iTunes / MusicBrainz / Deezer / Tidal / AudioDB / Genius / Last.fm IDs in
their metadata. Enrichment workers gate their queues on
{provider}_match_status IS NULL, so reading those IDs back and gap-filling
the {provider}_id + match_status='matched' columns lets the workers skip
the API lookup entirely — big API savings on an already-tagged library.

New manual job in Tools -> Database & Scanning ("Import IDs from File
Tags"): scans every library file, reads embedded IDs, fills any that are
missing in the DB. Background job + progress card, mirroring the
write-tags-batch pattern.

core/library/embedded_id_reconcile.py (pure + tested):
- plan_reconcile(): gap-fill plan for a track + its album + artist. Only
  empty id columns are planned; a disagreeing embedded id is a conflict,
  never applied.
- apply_reconcile_plan(): one guarded UPDATE per id column —
  WHERE id=? AND (col IS NULL OR col=''). The guard makes the fill atomic:
  if an enrichment worker matched the same entity between our read and
  this write, the UPDATE affects 0 rows instead of clobbering it. Columns
  are introspected so a schema missing a provider's columns is skipped.
- reconcile_track_row(): per-track orchestration (id extraction, plan ->
  apply, keeping the in-memory parent maps fresh for sibling tracks).

Job hardening: paged track scan (bounded memory), per-page commits (don't
starve concurrent workers), per-file try/finally (one bad file can't abort
the run), counters from real rowcount.

Scope: 19 column-fills across 8 providers. MB *recording* (track) id is
left out (UFID frame the reader doesn't surface; Vorbis key ambiguous) —
MB album+artist are covered. Amazon/ASIN deliberately excluded (ASIN is a
different namespace than the worker's amazon_id). All target columns
verified against the live schema.

Purely additive: new module, two new endpoints, one new Tools card —
no existing behavior changed. 20 unit tests (incl. the concurrency guard).
Full suite clean (only pre-existing soundcloud /app env failures remain).
2026-06-05 17:52:29 -07:00
BoulderBadgeDad
d91e6a384d Remove the old Retag Tool (superseded by Library Re-tag job + Write Tags)
The old per-download Retag Tool was limited (only native-pipeline downloads,
100-group cap, manual per-group) and did the wrong thing — it moved/reorganized
files instead of just tagging. It's superseded by the new Library Re-tag job
(whole-library, in-place) + the enhanced-library 'Write Tags' button.

Removed: the post-download record_retag_download ingestion hook (stops writing
retag_groups on every download), core/library/retag.py, the web_server state +
deps + /api/retag/* endpoints + the tool:retag WebSocket emit, the dashboard
card + both modals (index.html), the core.js socket handler, and the tools-page
wiring + help entry (wishlist-tools.js). Updated the import-pipeline test.

Verified: web_server parses, app + core imports OK, 392 tests pass, no live
references to removed symbols.

Left as inert (harmless) for a careful follow-up sweep: the retag_groups/
retag_tracks tables + their DB CRUD methods (no longer written/read), and the
now-orphaned retag JS helper functions (no entry point/wiring/socket calls them;
interspersed with wishlist functions, so not blind-deleted).
2026-06-04 09:33:03 -07:00
dev
05c15833fa perf(webui): faster navigation, smoother scroll, no spurious settings save
Navigation & sidebar feedback:
- Show legacy pages optimistically on pointerdown + CSS :active so the
  sidebar reacts instantly instead of waiting for the click/router cycle.
- Defer heavy per-page init via requestIdleCallback so a page becomes
  scrollable before its init work runs.

Scroll smoothness:
- Cache particle canvas dimensions (no forced reflow per navigation).
- Pause particle + worker-orb canvas redraws during active scroll so the
  scroll gets the full frame budget.
- content-visibility:auto on discover shelves and search/wishlist/library
  list items to skip off-screen layout.

Dashboard:
- Run the independent initial loads in parallel (Promise.all) instead of
  six sequential awaits, collapsing the reflow cascade.

Settings:
- Wire input listeners once instead of rescanning the ~960-node subtree
  on every visit.
- Suppress auto-save while the form is programmatically populated on load,
  fixing a spurious full save (4 POSTs + backend service re-init) that
  fired on every Settings visit.

Reduce Visual Effects = full performance mode:
- Also halts particles, worker orbs and all filters; hides the static
  sidebar aura circles that looked broken without their blur/animation.

Global search bar hidden on settings/help/issues/import pages.
2026-06-04 02:40:27 +02:00
BoulderBadgeDad
58d0657fe5
Merge pull request #594 from Skowll/telegram_thread_id
Add Telegram thread
2026-05-26 11:47:08 -07:00
Broque Thomas
718eb0cb10 Add iTunes / Apple Music link import tab on Sync page
New iTunes Link tab between Deezer Link and YouTube. Accepts album,
track, and playlist URLs from music.apple.com / iTunes. Pulls the
tracklist, runs it through the same discovery -> sync -> download
pipeline as the other link tabs.

Apple Music playlists go through amp-api with a Bearer JWT scraped
from the SPA. The legacy meta-tag and inline `"token":"..."` paths
are gone in the current music.apple.com SPA, so the extractor now
walks the page's `<script src>` list (prioritising index/chunk/main
bundles), fetches up to 8 JS bundles, regex-matches JWT-shaped
strings, and base64-decodes each payload to confirm it carries
Apple media-api claims (`root_https_origin`, or `iss + iat + exp`)
before trusting it. Filters out analytics / error-reporter JWTs that
also ship in the bundle.

Tokens are cached at module scope for 6h behind a threading.Lock so
the three-worker discovery executor doesn't thunder-herd Apple on
cold start, and amp-api calls go through a single helper that on
401 invalidates the cache, refetches the page, force-refreshes the
token, and retries the request once. The playlist fetcher memoises
the page HTML for the cache-miss path so we don't refetch it for
every paginated `/tracks` page.

spotify_public discovery worker accepts the new platform shape so
iTunes Link reuses the same matching code path as Deezer Link and
Spotify-public. UI bits live in the sync-services.js iTunes Link
tab, with platform plumbing through wishlist-tools.js for the
multi-source state map.
2026-05-25 22:32:18 -07:00
Skowll
d47e4ccc0d
edit telegram_id after initial pr 2026-05-24 14:13:57 +02:00
Skowll
8450645c52
Merge branch 'dev' into telegram_thread_id 2026-05-24 12:41:35 +02:00
Broque Thomas
de8e079a6d feat(media-player): playable tracks across modals + lyrics + cleanups
Three related improvements to the now-playing media player and the
"add to wishlist" / "download missing" modals.

1. Play buttons across track-list modals
   Every track row in the download-missing modals (Spotify, Tidal,
   YouTube, services, artist album, wishlist download-missing) and
   the add-to-wishlist modal now carries a play button. Click runs
   playTrackFromLibraryOrStream:
     - If the track has a local file_path → playLibraryTrack
     - Else POST /api/stats/resolve-track to find it in the library
       by title + artist → playLibraryTrack
     - Else fall back to _gsPlayTrack streaming
   Backend ownership response gains track_id / title / file_path so
   the wishlist modal's owned tracks can hand the right metadata
   to the player without an extra round trip.
   The add-to-wishlist modal previously showed the play button only
   on owned tracks; now the button is unconditional so the streaming
   fallback can take over for unowned ones (matches the standard
   pattern from the rest of the app).

2. Clean media-player display titles
   YouTube / Tidal / Qobuz / torrent / usenet plugins encode their
   source-side identifier into the filename field as
   <source_id>||<display> so download() can recover it later. The
   media player's track-title renderer never knew about this
   convention and showed strings like
   "wvgFsXoGFnQ||Sometimes I Cry When I'm Alone" verbatim in the
   now-playing UI. extractTrackTitle and setTrackInfo now strip the
   <id>|| prefix defensively so any path into the player gets a
   clean display.
   Local library playback also fetches canonical metadata from
   /api/stats/resolve-track when track.id is present so title /
   artist / album / album art come straight from the SoulSync DB
   instead of whatever the caller passed in. Falls back silently
   to caller values on any error so playback never blocks on the
   metadata fetch.

3. Lyrics panel + View Artist close
   New collapsed lyrics panel between the playback controls and
   queue panel. POST /api/lyrics/fetch (new backend endpoint)
   prefers the local .lrc / .txt sidecar files SoulSync writes
   during post-processing so downloaded tracks resolve lyrics with
   zero network hits; falls back to LRClib exact-match (when album
   + duration are available) then to LRClib search.
   Synced LRC results are parsed (handles multi-stamp lines for
   repeated choruses), and the active line highlights + smooth-
   scrolls into the middle of the viewport on every audio
   timeupdate. Plain-text results render without highlighting.
   Per-track cache prevents re-fetching when the user revisits the
   same track. Lyrics fetch is fire-and-forget — failure shows
   "No lyrics found" without ever blocking playback.
   View Artist on the expanded player now calls
   closeNowPlayingModal before navigating; the modal was previously
   sitting open over the artist page, hiding it. Handler is bound
   once and is a no-op when no artist_id is attached.

CSS additions are additive (new .modal-track-play-btn and
.np-lyrics-* rules); no existing styles touched. Backend endpoint
returns 200-with-success-false on any miss so callers can render
"no lyrics" without treating it as an error.

WHATS_NEW updated under 2.5.9 with two entries (lyrics + View
Artist close).
2026-05-22 21:19:50 -07:00
Broque Thomas
b6b83c8bf8 Polish torrent and usenet download UX
Clarifies album-bundle progress text in the download modal and active downloads panel so release-first downloads read as downloading a release, then matching tracks after staging.

Adds waiting-state copy and tooltips for rows blocked on release staging, plus source-specific library history badge styling for Torrent, Usenet, Staging, and Auto-Import.
2026-05-21 15:19:43 -07:00
Broque Thomas
daaed373e7 fix(provenance): label torrent/usenet/staging downloads correctly in history
The download history modal was tagging every torrent / usenet
album-bundle download as 'Soulseek FLAC 24bit' because:

- core/imports/side_effects.py's source_service dict didn't have
  entries for 'staging', 'torrent', or 'usenet' usernames. The
  staging matcher in core/downloads/staging.py sets
  download_tasks[task_id]['username'] = 'staging', which fell
  through to the dict's default and got recorded as 'soulseek'
  in the track download provenance row. Same fate for any
  amazon or other source that wasn't whitelisted.

- The album-bundle flow specifically wants to be labeled as
  'torrent' or 'usenet' (where the bytes actually came from),
  not 'staging' (the intermediate). The plugin already stashes
  the source on the batch state as ``album_bundle_source`` for
  the Downloads-page status card; provenance recording can
  read the same field.

Fixes:
- core/downloads/staging.py: when marking a task post_processing
  after a staging match, check the batch's album_bundle_source
  override and use that for username instead of 'staging' when
  set. Falls back to 'staging' when no override exists
  (manual file-drop case).
- core/imports/side_effects.py: source_service map gets entries
  for 'staging', 'torrent', 'usenet', and the previously-missing
  'amazon' (which was also falling through to 'soulseek').
- webui/static/library.js: the redownload modal's serviceLabels
  / serviceIcons dicts extended to cover lidarr, amazon,
  soundcloud, auto_import, staging, torrent, usenet so badges
  render the correct name instead of either the raw source_service
  string or no badge at all.
- webui/static/wishlist-tools.js: history-source-chip color
  palette extended for the new source labels (Torrent sky-blue,
  Usenet violet, Staging / Auto-Import neutral grey).

Note: existing tracks in the DB still carry the wrong 'soulseek'
label — only NEW downloads after this fix get the right label.
A future migration could rewrite historical rows but it's
cosmetic and the underlying audio + metadata are correct.
2026-05-20 19:31:47 -07:00
Broque Thomas
daf9a527d9 feat(fix-popup): include MusicBrainz in the auto-search cascade
The Fix Track Match modal's auto-search was hardcoded to query only
Spotify -> Deezer -> iTunes, ignoring MusicBrainz entirely — even for
users with MB set as their primary metadata source. MB-niche recordings
(canonical entries with diacritics, fringe / non-mainstream tracks that
the commercial catalogues don't carry) had no chance.

Wiring:

- New `MusicBrainzSearchClient.search_tracks_with_artist(track, artist,
  limit)` for surfaces that already have title + artist split. Uses MB's
  bare-query mode (strict=False) — diacritic-folded, alias/sortname
  indexed — same recall rationale as the earlier MBID-paste endpoint.

- New route `GET /api/musicbrainz/search_tracks` mirrors the existing
  /api/{spotify,itunes,deezer}/search_tracks endpoints exactly: accepts
  `track`+`artist` (or legacy `query`) + `limit`, returns
  `{tracks: [{id, name, artists, album, duration_ms, image_url, source}]}`.
  Applies the same `core.metadata.relevance.rerank_tracks` pass Deezer /
  iTunes use, which is critical because MB's free-text scoring weighs
  title-text matches heavily and would otherwise rank cover / tribute
  recordings above the canonical version.

- `_search_tracks_text` gains a `min_score` parameter. The cascade path
  passes 20 (vs the enhanced-search-tab default of 80) so MB recordings
  whose title doesn't literally contain the artist name still enter the
  candidate pool — without that, "Army of Me" + "Bjork" only surfaces
  the HIRS Collective cover (score 100) and drops Björk's canonical
  recording (score 28). The rerank pass then surfaces Björk by artist
  match. Verified against real MB API: pre-fix returned only the cover;
  post-fix top 5 are all Björk.

- Fix popup `allSources` array (wishlist-tools.js) gets MB appended.
  The existing `activeIdx` reorder logic moves MB to the front when
  it's the active primary; otherwise MB sits last (1 req/sec rate
  limit makes it the slowest source).

7 new unit tests on the adapter: bare-query mode is used, missing
artist falls back to None (drops AND-clause), empty inputs short-circuit,
low-score candidates are kept for rerank to handle, default strict +
default min_score behaviour preserved for the existing search-tab path,
client errors are swallowed so the cascade falls through to the next
source.

Discogs intentionally absent — Discogs has no track-level search API
(see core/discogs_client.py:575 — returns []). Adding a Flask endpoint
that always returns empty would be a permanent no-op.
2026-05-19 19:06:41 -07:00
Broque Thomas
036faff8b1 feat(fix-popup): paste MusicBrainz URL/MBID to match directly
Power-user escape hatch on the Discovery Fix Track Match modal — when
fuzzy auto-search ranks the wrong recording among many same-title
versions (10 remasters, live cuts, alt sessions), paste the MusicBrainz
recording URL or bare UUID into the new field and resolve straight to
that record.

Layout:

- Shape adapter `get_recording_flat(mbid)` lives in
  `core/musicbrainz_search.py` next to existing `get_track_details`.
  Returns the flat Fix-popup track shape (artists as `string[]`,
  album as string, single `image_url`) — distinct from the
  Spotify-shaped nested dict `get_track_details` returns.

- New route `GET /api/musicbrainz/recording/<mbid>` is a thin wrapper:
  validates MBID format with an anchored UUID regex, calls the adapter,
  returns 400 / 404 / 200 with no inline shape massaging.

- Frontend `parseMusicBrainzMbid()` lives in `shared-helpers.js` —
  pure URL/UUID parser, reusable from other surfaces (failed-MB cache,
  manual match) without duplication.

- Fix modal HTML gets one new input row + button; existing search row
  and result render pipeline are untouched. New `lookupDiscoveryFixByMbid()`
  fetches the endpoint and feeds the single result through the existing
  `renderDiscoveryFixResults` -> confirm-dialog -> match pipeline, so MB-
  paste matches go through the exact same selection flow as auto-search
  results.

- Enter-key bound on the MBID input via a separate handler ref so its
  lifecycle matches the search-input handlers without conflating the
  two submit targets.

7 unit tests cover the adapter: happy path, empty/None MBID, MB returns
None, recording-without-release (empty album), multi-artist credits,
includes-list contract, and client-error swallow.

Out of scope: the Fix popup's fuzzy cascade is still hardcoded to
spotify/deezer/itunes regardless of which primary source the user has
configured. Adding MB to that cascade (when MB is the active primary)
is a separate concern.
2026-05-19 17:03:19 -07:00
Broque Thomas
f25433ea57 Harden quarantine approval flows 2026-05-18 08:37:11 -07:00
Broque Thomas
52ee406a6c Fix quarantine action button escaping 2026-05-17 21:47:56 -07:00
Broque Thomas
b42cafa150 AcoustID + quarantine modal: three bug fixes (closes #607, closes #608)
Issue #607 (AfonsoG6) -- two AcoustID problems:

1. Live recordings false-quarantining as "Version mismatch: expected
   '... (Live at Venue)' (live) but file is '...' (original)" because
   MusicBrainz often stores the recording entity with a bare title --
   the venue / live annotation lives on the release entity, not the
   recording. The audio fingerprint correctly identifies the live
   recording, but the title-text comparison flagged it as wrong.

   New pure helper `core/matching/version_mismatch.py:is_acceptable_version_mismatch`
   accepts the mismatch only when:
     - One-sided AND involves 'live': exactly one side is 'live' and
       the other is bare 'original'. Two-sided mismatches stay strict.
     - Fingerprint score >= 0.85 (stricter than the existing 0.80
       minimum -- escape valve only fires when AcoustID is more
       confident than its own threshold).
     - Bare title similarity >= 0.70.
     - Artist similarity >= 0.60.

   Other version markers (instrumental, remix, acoustic, demo, etc)
   stay strict -- those have distinct fingerprints AND MB always
   annotates them in the recording title. The existing
   test_acoustid_version_mismatch.py suite passes unchanged.

2. Audio-mismatch failure message reported "identified as '' by ''
   (artist=100%)" when AcoustID returned multiple recordings -- prior
   code mixed `recordings[0]`'s strings (which can be empty) with
   `best_rec`'s scores. Now uses `matched_title` / `matched_artist`
   consistently in both the high-confidence-skip path and the final
   fail message.

Issue #608 (AfonsoG6) -- quarantine modal:

3. Approve / Delete buttons silently no-op'd when the filename
   contained an apostrophe -- the unescaped quote broke the inline JS
   in the onclick handler. Now wraps the id via
   `escapeHtml(JSON.stringify(id))`, which round-trips quotes /
   backslashes / unicode / newlines safely through the HTML attribute
   to JS string boundary.

4. Bonus UX: quarantine entry expanded view now shows source uploader
   (username) and original soulseek filename when the sidecar carries
   that context -- helps trace which uploader the bad file came from.
   Backend exposes `source_username` + `source_filename` fields from
   `sidecar.context.original_search_result`. Degrades to '' on legacy
   thin sidecars.

Tests:
- 23 new boundary tests in tests/matching/test_version_mismatch.py
  pin every shape: equal versions trivial, one-sided live both
  directions, threshold floors (each just below default -> reject),
  two-sided strict, non-live one-sided strict (covers exact
  test_instrumental_returned_for_vocal_request_fails scenario),
  custom-threshold overrides.
- 4 existing test_acoustid_version_mismatch.py tests pass unchanged.
- 507 AcoustID / matching / imports tests pass.
2026-05-15 08:55:06 -07:00
Skowll
5cbdcbbfe5
add thread_id for telegram notification 2026-05-14 18:46:54 +02:00
Broque Thomas
d0d65946c8 Polish quarantine UI — fold into Library History modal as third tab
Standalone Quarantine button + modal felt out of place — duplicated
the chrome of the existing Library History modal but with worse
styling and behavior. Folded the quarantine list into the existing
modal as a third tab next to Downloads + Server Imports.

UI changes:
- Removed the standalone Quarantine button on the Downloads page
  header and the standalone modal HTML
- Added third tab to library-history-tabs with a count badge
- loadLibraryHistory dispatches to loadQuarantineList when the
  quarantine tab is active
- Quarantine entries render as library-history-entry cards using
  the exact same class chrome as Downloads + Imports (thumb
  placeholder, title + meta, badge, relative time via
  formatHistoryTime, expandable details panel)
- Per-row actions styled as lh-audit-btn to match the existing
  Audit button look
- Approve / Recover / Delete now use the themed showConfirmDialog
  + showToast — no more native browser alert / confirm

Backend endpoints + pure helpers + tests unchanged from f4cff78f.
WHATS_NEW entry rewritten to reflect the actual final UX.
2026-05-14 08:50:24 -07:00
Broque Thomas
f4cff78f13 Quarantine management — list, approve, delete, recover
Closes #584. Quarantined files used to sit in ss_quarantine/ with a
thin sidecar — no UI, no recovery, no way to see what got dropped.
This adds the management surface the user needs without going to the
filesystem.

UI: new "Quarantine" button on the downloads page header opens a
modal with every quarantined file (filename, expected track/artist,
reason, when, size). Three actions per row:

- Approve (one-click): restores the file, re-runs the post-process
  pipeline with ONLY the failing check skipped, lands in the library
  with full tags + lyrics + scan
- Recover (legacy fallback): moves to Staging for thin-sidecar
  entries that lack the embedded context Approve needs
- Delete: permanent removal of file + sidecar

Per-check bypass: context['_skip_quarantine_check'] = 'integrity' /
'acoustid' / 'bit_depth'. Skips ONLY the named check — other quality
gates stay live. No blanket bypass-all flag.

Sidecar expansion: move_to_quarantine now persists the full
json-serializable context via serialize_quarantine_context (drops
non-JSON-safe values, walks nested dicts/lists/sets, str-coerces
unknown objects) plus the trigger name. Existing thin sidecars are
detected and routed to Recover instead of Approve.

Pure helpers in core/imports/quarantine.py: list_quarantine_entries
/ delete_quarantine_entry / approve_quarantine_entry /
recover_to_staging / serialize_quarantine_context. 27 tests pin
every shape: orphan files / orphan sidecars / corrupt sidecars /
collision-safe filename restoration / full-context vs thin-sidecar
dispatch / json round-trip safety.

Four new endpoints in web_server.py — thin glue around the helpers:
GET /api/quarantine/list, DELETE /api/quarantine/<id>,
POST /api/quarantine/<id>/approve, POST /api/quarantine/<id>/recover.

Download modal status differentiates "🛡️ Quarantined" from
" Failed" so recoverable files are visible at a glance — checked
against the error_message text, no schema change needed.

Pipeline changes are three minimal per-check conditionals at the
existing quarantine sites in core/imports/pipeline.py. Each
move_to_quarantine call now passes its trigger name so the sidecar
records which check fired.

Full suite: 2992 passed.
2026-05-14 08:06:19 -07:00
Broque Thomas
6ce185491d Add per-download Audit Trail modal to Library History
- new "Audit" button on each download row in the library history
  modal opens a second modal visualizing the download lifecycle as
  an interactive horizontal stepper (request → source → match →
  verify → process → place) with click-to-expand detail cards
- hero header with album art + track title + meta line + status
  pills (source / quality / acoustid result)
- three tabs: Lifecycle / Tags / Lyrics
- Tags tab reads the audio file live via mutagen at audit-open
  time via new GET /api/library/history/<id>/file-tags endpoint;
  file is the single source of truth so background enrichment
  writes (audiodb / lastfm / genius / replaygain / lyrics fetch)
  show up too. flat key/value rows stacked vertically (label-above-
  value) so long MBIDs / URLs / joined genre lists wrap cleanly.
  source IDs grouped per-service into 2-col sub-card grid.
- Lyrics tab renders the full transcript with dimmed timecodes.
- post-processing step infers observable changes from source-vs-
  final state (format conversion, file rename via tag template,
  folder template).
- "Download History" button also added to the Downloads page batch
  panel header so it's reachable outside the dashboard.
- mobile responsive: tabs + stepper scroll horizontally, modal
  goes full-screen, hero stacks below 480px.

19 helper tests pin the mutagen reader: id3 (TIT2/TPE1/TALB + TXXX
+ USLT + APIC), vorbis (FLAC dict + _id/_url passthrough), file
metadata (format / bitrate / duration), defensive paths (empty /
missing file / mutagen returns None / mutagen raises), stringify
edge cases (list / tuple / int / frame-with-text / whitespace).
2026-05-13 09:50:24 -07:00
Antti Kettunen
36267618a3
Rename status cache to metadata_source
Expose the primary metadata provider status under a generic cache key and update the websocket fixture plus frontend readers to match.
2026-05-02 22:02:00 +03:00
Antti Kettunen
7a9f074a70
Normalize wishlist UI copy
- Replace Spotify-only labels in the wishlist and matching surface with metadata/provider-neutral wording
- Keep the existing matching behavior intact while removing the most visible Spotify-first text
2026-04-30 07:54:17 +03:00
Antti Kettunen
5420c0de24
Fix album track source propagation
- Pass source through artist, library, wishlist, and rehydration album-track fetches
- Preserve the resolved metadata source on cached discography and artist detail state
- Prevent 404s when opening artist-page album modals from non-Spotify sources
2026-04-22 21:21:32 +03:00
JohnBaumb
a66c4d06e1 Split monolithic script.js (78K lines) into 17 domain modules
Extracts the single 77,957-line script.js into focused modules:

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

Load order: core.js first (globals), init.js last (DOMContentLoaded).
All other modules define functions and load in any order.
No functional changes - pure extraction along existing section boundaries.
2026-04-21 23:52:30 -07:00