Commit graph

405 commits

Author SHA1 Message Date
BoulderBadgeDad
68d52a1b3f Release 2.8.2: version bump + release notes
Bumps base version 2.8.1 → 2.8.2 and the docker-publish default tag. A stability + performance
release: Spotify reliability (Docker boot-hang #949, the token-cache re-auth fix, on-demand
Sync-to-Spotify), the "slow after update" password-manager fix + Max Performance mode (#948), and
large-library imports that no longer time out the import page (#947).

Updates the five release touch-points: web_server version, docker-publish default, pr_description.md,
helper.js WHATS_NEW + VERSION_MODAL_SECTIONS (current release + Earlier-in-2.8.1 summary), and the
new RELEASE_2.8.2_discord.md (truncated for Discord, 1351 chars).
2026-06-29 11:10:50 -07:00
BoulderBadgeDad
f3672c7ab4 spotify export: on-demand write-auth (restores Sync to Spotify safely, #945)
Brings back Spotify playlist export WITHOUT the regression that forced every user to re-auth.
The safety property: the global login scope (SPOTIFY_OAUTH_SCOPE) is NEVER changed, so no
existing token is invalidated. The write permission is requested only when a user actually
exports to Spotify.

- SPOTIFY_EXPORT_SCOPE = the global read scope + playlist-modify, used ONLY by the new
  /auth/spotify/export route. Spotify returns a superset token; the normal /callback exchanges
  and stores it unchanged (read ⊆ read+write keeps the standard auth check valid) — no callback
  changes needed.
- SpotifyClient.has_write_scope() checks the cached token for playlist-modify.
- start_playlist_export_service returns {needs_auth, auth_url} for Spotify when the token lacks
  write, instead of starting a doomed job. The modal opens the consent in a new tab and tells the
  user to retry once approved; the "Sync to Spotify" button is back, gated on connection as before.
- Release notes (pr_description / What's New / version modal / discord) restored to Spotify &
  Deezer with the one-time-permission note; discord back under 2000 chars (1983).

Tests: export scope is a strict superset of the (still read-only) global scope; has_write_scope
true/false for write/readonly/missing tokens and no-client. 275 spotify/oauth tests green, ruff
clean, 64 script-integrity green.
2026-06-29 08:32:19 -07:00
BoulderBadgeDad
6f451a34e1 playlist export: hide Spotify until on-demand write-auth; release notes → Deezer-now
Follow-up to the auth hotfix (633aa82b). The Spotify playlist-write scope was reverted out of the
global OAuth scope (it was force-invalidating every user's token on upgrade), so "Sync to Spotify"
can't get write access yet — clicking it would dead-end on a misleading "reconnect Spotify". So:

- removed the "Sync to Spotify" button from the export modal (Deezer stays); the backend write
  client + endpoint are left in place, dormant, for when on-demand write-auth lands
- modal copy is now Deezer-only ("Match missing tracks (Deezer)", "stored Deezer ID")
- release notes (pr_description, helper.js WHATS_NEW + version modal, RELEASE_2.8.1_discord.md)
  reworded from "Spotify & Deezer" to "Deezer", with a "Spotify export coming in a follow-up" note

64 script-integrity tests green; discord file back under the 2000-char limit (1952); no stale
Sync-to-Spotify mentions remain. Deezer export (live-verified) is unaffected.
2026-06-29 08:18:40 -07:00
BoulderBadgeDad
60e7193539 Release 2.8.1: version bump + release notes
Bumps base version 2.8.0 → 2.8.1 and the docker-publish default tag. Headline is the
Spotify/Deezer playlist export (#945); also the Library Reorganize rename-only mode (#875),
broader lossless handling (#941/#939), download + search fixes, the refined reduce-visual-effects
pass, and merged contributor PRs (#942/#943/#944).

Updates the five release touch-points: web_server version, docker-publish default, pr_description.md,
helper.js WHATS_NEW + VERSION_MODAL_SECTIONS (current release + Earlier-in-2.8.0 summary), and the
new RELEASE_2.8.1_discord.md.
2026-06-28 23:31:32 -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
30ff0bde49 Release 2.7.9: version bump + What's New / version modal + PR description + docker-publish default tag
- _SOULSYNC_BASE_VERSION -> 2.7.9; docker-publish default version_tag -> 2.7.9
- pr_description.md rewritten for 2.7.9 (best-quality downloads + quality profile #896, Discover listening recs + Listening Mix #913, Wing It Pool, Auto-Sync lane redesign, multi-disc #927, sync labels #925, post-processing race #928)
- WHATS_NEW: 2.7.8 block -> 2.7.9 (+ brief earlier-versions); VERSION_MODAL_SECTIONS: 2.7.9 highlights promoted, 2.7.8 rolled into an Earlier aggregator
- RELEASE_2.7.9_discord.md: mini Discord post
2026-06-25 16:11:51 -07:00
BoulderBadgeDad
ed0a2079cf
Merge pull request #896 from nick2000713/feature/best-quality-search-mode
Global quality system: real-audio verification, best-quality search & quality profiles (please try...not ready to merge)
2026-06-24 20:27:38 -07:00
BoulderBadgeDad
f010fbc487 Release 2.7.8: version bump + What's New / version modal + PR description + docker-publish default tag
- _SOULSYNC_BASE_VERSION -> 2.7.8; docker-publish default version_tag -> 2.7.8
- pr_description.md rewritten for 2.7.8 (align playlists + re-add-to-wishlist-from-sync
  features, the #922 Spotify-Free label fix, and the #918 iTunes-cache self-heal follow-up)
- WHATS_NEW: replaced the 2.7.7 block with 2.7.8 (current release + brief 'earlier versions')
- VERSION_MODAL_SECTIONS: promoted the 2.7.8 highlights, rolled 2.7.7 into an 'Earlier' aggregator
2026-06-24 19:02:44 -07:00
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
nick2000713
63374b32f1 Merge remote-tracking branch 'nezreka/dev' into feature/best-quality-search-mode
# Conflicts:
#	core/hifi_client.py
2026-06-23 11:33:50 +02: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
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
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
dev
b761229a00 merge: pull upstream/main (2.7.4) into feature/best-quality-search-mode
- Keep our v3 ranked-targets quality system (filter_and_rank, QualityTarget)
  in soulseek_client.py, settings.js, database presets, and index.html
- Take upstream removal of standalone quality-scanner code:
  QualityScannerDeps + run_quality_scanner moved to repair job
  (core/repair_jobs/quality_upgrade_scanner.py)
- Take upstream AAC-tier addition in database/music_database.py default profile
- Take upstream removal of /api/quality-scanner/* routes from web_server.py
- Remove test_discovery_quality_scanner.py (deleted upstream)
- 47 upstream commits absorbed (2.7.3 + 2.7.4 including re-identify flow,
  dead-folder cleanup, track-number prefix strip, and more)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 16:54:51 +02: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
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
dev
e0c55342bc feat(quality): v3 ranked-targets UI editor (drag-to-reorder)
Replace the v2 per-tier quality UI (FLAC on/off + MP3 sliders + bit-depth
buttons) with a draggable ordered target list. Each row shows its rank +
label with move/delete; an add form picks format and, for lossless, bit
depth + min sample rate, or for lossy a minimum bitrate threshold (>=) so
VBR/mono files aren't falsely rejected. Persists v3 ranked_targets via the
existing /api/quality-profile. Presets + fallback toggle retained; help
text and tooltip rewritten for the new top-down source-gating model.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:41:38 +02:00
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
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
26b27eb441 What's New + version modal: 2.7.1 content
Per the release convention: WHATS_NEW + VERSION_MODAL_SECTIONS carry only the
current release, with older cycles folded into the "Earlier" summary.

- WHATS_NEW '2.7.1': download verification & review (badge, persistence, review
  queue), the #852 websocket login-bypass fix, the acoustid Relocate action (#704),
  faster artist pages (#853), the LB-weekly un-wedge (#702), the torrent metaDL
  stall + orphan fix, and the smaller fixes (#851/#840/search auto-select) +
  contributor PRs (#845/#848/#850). 2.7.0 rolled into "Earlier versions".
- VERSION_MODAL_SECTIONS: verification & review leads, security fix section,
  fixes list, and an "Earlier in 2.7.0" aggregator replacing the 2.6.x one.
- Fixed the "Go to page" links: the downloads page id is 'active-downloads', not
  'downloads' — the old entries' links silently did nothing.
2026-06-11 15:50:20 -07:00
BoulderBadgeDad
8968d87cc4 What's New + version modal: 2.7.0-only, summarize the rest
Going forward these only carry the current release plus one brief "earlier versions"
summary — no accumulating per-version backlog.

- WHATS_NEW: replaced the full 2.6.x→2.5.x backlog with a single '2.7.0' block
  (per-profile accounts, login/recovery/reverse-proxy, the fixes, artist-sync) + an
  "Earlier versions" one-liner. The "Older Versions" button auto-hides with one
  version, so the nav still works.
- VERSION_MODAL_SECTIONS: five curated 2.7.0 sections + a brief "Earlier in 2.6.x".
- Content drawn from the 2.7.0 pr_description. Added a convention comment at the top
  of WHATS_NEW. JS validated (string-aware brace/quote check clean); 64 integrity
  tests pass.
2026-06-10 23:05:01 -07:00
BoulderBadgeDad
931167f197 Release 2.6.9: version bump + docker-publish default + What's New changelog
Bumps _SOULSYNC_BASE_VERSION 2.6.8 → 2.6.9, the docker-publish workflow's
default version tag, and adds the 2.6.9 What's New entry (15 items, security
fixes first: #832 launch-PIN enforcement and the settings-secret leak, then
#833/#831/#830/#829/#828/#827/#825/#824/#823/#740, Spotify (no auth), multi-
artist tags, decimal-volume dedup).
2026-06-09 23:03:49 -07:00
BoulderBadgeDad
1fba950284 Release 2.6.8: version bump + docker-publish default + What's New changelog
- _SOULSYNC_BASE_VERSION 2.6.7 → 2.6.8 (drives the UI version + release notes).
- docker-publish.yml workflow_dispatch default version_tag → 2.6.8 (the manual
  tagged-release publish; run the workflow to push :2.6.8 + announce).
- helper.js WHATS_NEW: new 2.6.8 block (18 entries) covering everything since
  2.6.7 — Blocklist, the #801 retry overhaul, Download Origins, Expired
  Download Cleaner, Lyrics Filler + re-tag lyrics, Spotify-token-in-DB deauth
  fix, #705 release-date gate, YouTube-OOTB, Navidrome #809, the dashboard/
  modal visual pass, #767 reorganize edition, #806 cover art, cover-art
  read-only handling, #804 import fixes, artist-page discography + #808,
  iTunes-id repair, torrent stall handling, paste-MBID match, and smaller fixes.
  Surfaces automatically in What's New + version release notes now that the
  build version is 2.6.8.
2026-06-07 23:18:21 -07:00
BoulderBadgeDad
d44de75906 Changelog/PR: note the Spotify Free budget→free bridge in the 2.6.7 entry 2026-06-06 08:52:54 -07:00
BoulderBadgeDad
971f2fd4f0 Release 2.6.7: version bump + changelog
- _SOULSYNC_BASE_VERSION -> 2.6.7 (single constant drives the UI version).
- docker-publish workflow default version_tag -> 2.6.7.
- WHATS_NEW: new 2.6.7 block grouping the 69 commits since 2.6.6 into
  user-facing entries (Spotify Free #798, Import IDs from File Tags +
  auto-reconcile, Library Re-tag, paste-a-link #775, mobile v2 #793/#795,
  Reconcile sync mode #792, #758 manual-match canonical lock, #800 Write
  Tags guard, #797 AcoustID non-English artists, artist source-id fixes,
  #799/#787/#789/#785/#790/#796, no-sound fix, perf).
- VERSION_MODAL_SECTIONS: 2.6.7 spotlight sections + a Recent Fixes (2.6.7)
  aggregator at the top.
2026-06-05 23:37:53 -07:00
BoulderBadgeDad
83e3f6b660 2.6.6: add curated highlights to the version modal (VERSION_MODAL_SECTIONS)
The version-button modal renders from VERSION_MODAL_SECTIONS (the curated
highlight reel), separate from the WHATS_NEW detailed log. Its top entries were
stale (2.5/2.6.0 era), so promote the 2.6.6 highlights to the top per the file's
release process: Artist Map v2, self-explaining recommendations, the cover-art
filler file-embedding, and a Recent Fixes & Performance roundup (qBittorrent
5.2.0, organize-by-playlist #780, nav/scroll perf #783, dashboard mobile).
2026-06-03 22:33:07 -07:00
BoulderBadgeDad
febefc8324 Release 2.6.6: version bump + What's New
- Bump _SOULSYNC_BASE_VERSION 2.6.5 -> 2.6.6 (the single source of truth that
  propagates to the UI, backups, and the update check).
- Add the 2.6.6 What's New block (qBittorrent 5.2.0 login fix, Cover Art Filler
  on-disk detection + file embedding + stricter matching, recommendations
  explainability + Discover section, organize-by-playlist #780, nav/scroll perf
  #783, dashboard mobile polish).
- Finalize the 2.6.5 block: it shipped in tag 2.6.5 but was left flagged
  unreleased (so its notes never displayed) — stripped the flag + dated it per
  the file's own release convention.
2026-06-03 22:28:06 -07:00
Broque Thomas
7145368d42 Basic search: visual overhaul + per-source picker in hybrid mode
Two things in this commit. Functional download / matched-download
behaviour is untouched — same JS handlers, same routes for the
download actions, same album-expand interaction.

VISUAL REDESIGN
- Glass search-bar card with accent radial wash + focus ring + pill
  primary search button
- Source chip row above the search bar (see below)
- Always-visible compact filter pill row (Type / Format / Sort) —
  pills carry both ``bs-filter-pill`` (new visual) and ``filter-btn``
  (legacy class for ``resetFilters`` + ``applyFiltersAndSort`` in
  wishlist-tools.js to keep working)
- Accent-tinted status pill matching the dashboard / auto-sync look
- Album result cards: glass card with accent left-edge stripe,
  52px brand-tinted cover icon, chevron expand indicator, pill
  action buttons (Download / Matched Album), accent glow on hover
- Track result cards: glass row with accent stripe, 44px icon,
  pill action buttons (Stream / Download / Matched Download)
- Multi-disc separators inside expanded album track lists styled
  with the accent treatment
- Responsive: action button columns stack vertically below 900px

New CSS lives in a self-contained ``webui/static/basic-search-v2.css``
sheet linked from index.html. Selectors are scoped to
``#basic-search-section`` for any class that already exists in
style.css (``.album-result-card``, ``.album-icon``, ``.track-*``,
etc.); the new ``bs-*`` prefixed classes for the search bar /
filters / source row / status are unscoped because they only exist
in the new markup. ``!important`` is used on the card-level rules
to defeat the original unscoped ``.album-result-card`` etc. rules
in style.css that would otherwise leak heavyweight padding /
box-shadow / 56px icon styles into the new design.

Also removed ``overflow: hidden`` from the original
``.album-result-card`` and ``.track-result-card`` rules in style.css
— those two classes only render in ``downloads.js`` basic search
results (verified via grep, two render sites only), so the
removal can't impact any other UI.

SOURCE PICKER (hybrid mode)
- New ``GET /api/search/sources`` endpoint returns the list of
  active sources from the orchestrator's chain (or the single
  active source in single-source mode).
- Frontend renders a chip row above the search bar. Click a chip
  to target that source for the next search; the chip's brand
  accent fills.
- In single-source mode the lone chip is rendered as a dashed-
  border label so the user always knows what they're searching
  but can't accidentally try to switch to sources that aren't
  configured.
- ``/api/search`` accepts an optional ``source`` body param. When
  set, ``core/search/basic.py:run_basic_search`` resolves the
  client directly via ``orchestrator.client(source)`` and calls
  its ``.search()`` instead of going through the hybrid chain.
- Backwards compatible: omitting ``source`` falls through to the
  original ``orchestrator.search()`` call exactly as before.
  Unknown source names also fall back to the default — typo
  protection.

TESTS (5 new + 6 pre-existing = 11 total in test_search_basic.py)
- source param routes to specific client, NOT orchestrator chain
- no source param preserves original orchestrator-default behaviour
- unknown source name falls back to orchestrator default
- ``run_basic_soulseek_search`` backwards-compat alias preserved
- source-targeted path serialises albums + tracks correctly

101 search-suite tests pass.
2026-05-28 10:22:07 -07:00
Broque Thomas
258905ff5c Fix: duplicate tracks in albums with Japanese / CJK titles (#722)
Reporter @Sokhii: downloading the Mushoku Tensei Original
Soundtrack II via Apple Music metadata + Tidal download
produced duplicate library entries — same audio file landed
under multiple track positions in the album view.

Root cause (verified by direct probe + isolated repro):
``MusicMatchingEngine.normalize_string`` correctly skipped
unidecode for CJK text (kanji→pinyin would have produced
gibberish — see the inline comment at line 74-76), but then
ran ``re.sub(r'[^a-z0-9\s$]', '', text)`` which stripped EVERY
CJK character. Every Japanese title normalised to ``''``.
``similarity_score`` has an early-out guard
    if not str1 or not str2: return 0.0
so EVERY CJK-vs-CJK title comparison returned 0.000.

Downstream effect: the matcher fell back to duration+artist
alone. For an OST album with 24 tracks all by the same artist
with similar durations, multiple iTunes track queries landed
on the SAME Tidal candidate. SoulSync wrote each download to
a different output filename (per the iTunes track position),
so on disk there were N copies of the same audio under
different track numbers. The user's library showed 34 entries
for an album with 24 actual tracks.

Probed iTunes album 1753240110 directly — 24 distinct tracks,
zero (disc, track_number) collisions, both US + JP storefronts.
So the duplicate origin was definitely downstream of metadata
fetch.

Fix: when CJK is detected upstream, the alphanumeric-strip step
also preserves CJK Unified Ideographs + radicals
(⺀-鿿), Hiragana + Katakana (぀-ヿ), Halfwidth
/ Fullwidth forms (＀-￯), and Hangul syllables
(가-힯). CJK titles now produce a comparable normalised
form instead of an empty string. ``similarity_score`` works as
intended:

  '命の灯火' vs '命の灯火' → 1.000  (was 0.000)
  '命の灯火' vs '無職転生' → 0.000  (was 0.000, but now from
                                       actual char comparison
                                       not from the empty-string
                                       guard)

Latin-only normalisation is completely unchanged. ``has_cjk``
is False for Latin input, so both the CJK-lowercase branch AND
the new CJK-preserve strip branch are skipped — Latin titles
go through the original unidecode + lowercase + strip path
verbatim. Tested via 4 regression tests that pin the Latin
baseline (simple, unidecode target, $-preservation, identical
+ different similarity scores).

16 new unit tests in ``tests/test_matching_engine_cjk.py``:
- Kanji / Hiragana / Katakana / Hangul / Chinese all survive
- CJK-only strip still removes Latin punctuation in the
  CJK branch
- Mixed Latin + CJK lowercases the Latin half
- Identical CJK titles → 1.0
- Disjoint CJK titles → near 0
- Partially overlapping CJK titles → midrange
- CJK doesn't falsely match unrelated Latin
- 4 Latin-baseline regression pins
- Real-world Mushoku Tensei OST scenario

371 text + imports + new CJK tests pass after the fix.
2026-05-28 08:53:43 -07:00
Broque Thomas
6d54203710 Bump version to 2.6.4
- _SOULSYNC_BASE_VERSION → 2.6.4
- helper.js: '2.6.4' unreleased → 'May 28, 2026 — 2.6.4 release'
- .github/workflows/docker-publish.yml default version_tag → 2.6.4
- pr_description.md: rewrite for 2.6.4 with #721 as the headline patch,
  2.6.3 fixes carried forward unchanged (2.6.3 was bumped on dev but
  never reached main / docker, so 2.6.4 is the first release to ship
  this batch)
2026-05-28 08:17:43 -07:00
Broque Thomas
df675c7c9f Fix: Usenet bundle stuck on "downloading release" when SAB History flips before storage lands (#721)
Follow-up to the 2.6.3 queue→history handoff fix (#706). User
@IamGroot60 reported in #721 that on 2.6.3 the bundle still gets
stuck mid-flight: SoulSync UI sits on "Usenet downloading release
61%" forever, SAB History shows the job as Completed 2+ minutes
ago, files are physically present in the slskd downloads folder
but never copied into ``storage/album_bundle_staging/<batch>/``.

Root cause: a second-stage gap in the SAB pipeline. SAB flips a
job's ``status`` to ``Completed`` in History as soon as par2 +
unrar finish, but its post-processing pipeline writes the final
``storage`` field a few seconds LATER (the move-to-final step).
``poll_album_download`` saw the first ``Completed`` read with
``save_path=None`` and bailed:

  if status.state in complete_states:
      return last_save_path  # ← None at this point

``download_album_to_staging`` got ``save_path=None``, set
``result['error']`` and returned. The bundle was marked failed but
the LAST progress emit before the failure was ``downloading
progress=0.61``, so the UI froze on "61%" — the terminal ``failed``
emit never registered on the user's screen because the renderer
holds the last-known progress.

Fix
- ``poll_album_download`` now tracks a separate transient counter
  for "complete state seen, save_path not yet set." Up to
  ``transient_miss_threshold`` (default 5) consecutive reads in
  that state are tolerated before the poll bails. SAB writes the
  ``storage`` field within 2-10 seconds of the History flip in
  practice — the default 5 × 2s = 10s window covers it.
- When save_path eventually lands, return it normally.
- When the threshold is exhausted with save_path still empty,
  emit terminal ``failed`` with an explicit message pointing at
  the missing save_path field — no more 6-hour silent spin.
- Earlier ``downloading`` reads with a non-empty ``save_path``
  (qBit / Transmission set this from the start of the download)
  remain "sticky" — if the eventual ``completed`` read has empty
  save_path, the cached one applies. So torrent flows aren't
  affected by the retry path.

SAB adapter (``_parse_history_slot``)
- Widened the save_path field fallback chain:
    storage → path → download_path → dirname → incomplete_path
  Covers SAB version differences (older builds populated ``path``)
  and forks that expose ``download_path`` or ``dirname``.
  ``incomplete_path`` is the last resort — SAB's in-progress dir
  before the final move — so the bundle plugin at least has a
  path to scan when nothing else lands.
- Whitespace-only values are skipped.
- Loud debug log when none of the known fields land — users on
  SAB versions / forks with novel field names need to see this in
  logs so we can grow ``_HISTORY_SAVE_PATH_KEYS``.

Tests
- ``test_album_bundle.py`` (3 new):
  - tolerates_completed_with_late_save_path_arrival — the #721
    scenario; first Completed read has no save_path, third has
    it; poll returns the path normally
  - gives_up_when_completed_with_no_save_path_persists — past
    the threshold the poll fails loudly instead of silent-spinning
  - uses_save_path_from_earlier_downloading_emit_if_completed_lacks_one
    — sticky save_path keeps torrent flows working
- ``test_usenet_client_adapters.py`` (6 new):
  - falls back to ``path`` when ``storage`` empty
  - falls back to ``download_path``
  - prefers ``storage`` when multiple fields present
  - returns ``None`` when all fields empty (the #721 gap window)
  - ignores whitespace-only values
  - uses ``incomplete_path`` as last resort

132 album-bundle + usenet tests pass.

Branch is on dev parented at 2.6.3 — user @IamGroot60 offered
to test on dev, so this is a candidate cherry-pick for either
a 2.6.4 hotfix or merge straight into dev for the next release.
2026-05-28 08:01:52 -07:00
Broque Thomas
a9608e1bcb Bump version to 2.6.3
- _SOULSYNC_BASE_VERSION → 2.6.3
- helper.js WHATS_NEW unreleased flag → 'May 27, 2026 — 2.6.3 release'

Note: .github/workflows/docker-publish.yml default version_tag was
also bumped to 2.6.3 locally, but .github is gitignored in this
repo — workflow updates need to land via the GitHub UI separately.
The workflow_dispatch input is overrideable at trigger time
regardless of the default, so this isn't blocking.
2026-05-27 22:26:16 -07:00
Broque Thomas
5771c5ba77 Album-bundle staging: clean Soulseek copies + sweep orphans at startup
Two related leaks in ``storage/album_bundle_staging/<batch_id>/``:

1. **Soulseek bundle cleanup was excluded.** The per-batch cleanup
   at the end of a bundle download gated on:
       (album_bundle_source or '').lower() in ('torrent', 'usenet')
   The comment justified it as "slskd keeps its own completed
   folders" — but the Soulseek bundle path ALSO copies completed
   files into the private staging dir (``soulseek_client.py:1599``,
   ``copy_audio_files_atomically(completed, Path(staging_dir))``)
   for the per-track workers to claim. Those copies persisted
   forever; long-running installs accumulated stale GB. Extended
   the cleanup gate's allow-list to include ``soulseek`` so the
   per-batch dir is removed on bundle completion — same code path
   that already worked for torrent / usenet.

2. **No sweep for orphan dirs.** Any leftover ``<batch_id>``
   subdir from a previous-session crash, an errored batch, or a
   pre-fix Soulseek bundle stayed on disk forever. Added
   ``sweep_orphan_album_bundle_staging(staging_root, active_batch_ids)``
   that runs ONCE at server startup, before any batch can register
   a staging dir. Removes every ``<batch_id>``-shaped subdir
   whose id isn't in the active set. Safe by construction:
     - Only touches subdirs of the configured staging root.
     - Name-shape check (``entry.name == _safe_batch_dirname(entry.name)``)
       rejects hand-placed dirs like ``.git`` or stray docs.
     - ``shutil.rmtree`` errors log + continue — sweep must not
       crash app startup over a permission glitch.
     - active_batch_ids normalised through ``_safe_batch_dirname``
       so colon-bearing batch_ids match their on-disk form.
   Wired into the web_server startup right after the stuck-flags
   diagnostic so it fires before anything else touches batches.

Tests
- ``test_downloads_lifecycle.py`` gained one regression test
  pinning that Soulseek bundles now have their staging dir
  cleaned (sibling to the existing torrent test).
- ``test_album_bundle_staging_sweep.py`` (NEW, 11 tests)
  covers: orphan removal with no actives, active dirs preserved,
  special-char batch_id normalisation, no-op on missing /empty
  /empty-string staging root, non-dir entries skipped, unsafe-
  name dirs preserved (.git etc.), partial rmtree failure doesn't
  abort the rest, listdir failure returns 0 cleanly, default
  None active set, defensive against empty / None entries in
  the active set.

488 downloads tests pass.

For users with an existing "clean up old files" automation pointed
at this dir: stop pointing it there if you want — the auto-cleanup
+ startup sweep cover it now. Or leave it as belt-and-suspenders
with a relaxed (1h+) mtime threshold so it can't race a mid-batch
download.
2026-05-27 22:18:42 -07:00
Broque Thomas
4ae5aee528 Sync page: collapse tabs to brand-logo chips with active label pill
The sync-tabs row had 14 sources jostling for horizontal space —
labels wrapped to 2 lines, the active pill ate disproportionate
room, the whole strip felt cramped and would only get worse as
more sources get added.

Restyled the strip as circular brand-logo chips. Inactive tabs
are 40px discs that show only the source's icon; the currently-
active tab swells into a pill that reveals its label inline.
Hover surfaces the source name as a native tooltip via the
title attr. Each chip carries its source's brand color as a
hover ring + active fill (Spotify green, Tidal orange, Qobuz
blue, Deezer purple, iTunes coral, YouTube red, Beatport green,
LB orange, Last.fm red, SSD teal).

Three sources share a logo with another source (Spotify Link
/ Spotify, Deezer Link / Deezer, iTunes Link / no native iTunes
but same logo family). Each "Link" variant carries a small
chain-link badge bottom-right so the chip disambiguates without
forcing the label to always be visible.

CSS-only swap — same JS handlers, same .active class, same
data-tab routing. HTML edit wraps each tab's label in a
``<span class="sync-tab-label">`` and adds ``data-link="true"``
to the Link variants so the CSS can target them.

Responsive: chips collapse to 36px on laptop / tablet and 32px
on mobile; the divider hides on mobile and gap tightens.
2026-05-27 22:04:28 -07:00
Broque Thomas
f976a6da53 Fix: Soulseek album-bundle downloads stuck on "failed" after slskd
finished the release (#715)

Symptom (user @pavelcreates / @IamGroot60 on 2.6.2):
- Click Download on an album in the search modal
- slskd starts + completes every track of the release
- 22+ minutes after the last completed download, batch flips
  to "failed" with no clear log line explaining why
- Per-track Soulseek downloads on the same machine were fine

Root cause: ``core/soulseek_client._resolve_downloaded_album_file``
probed three hard-coded candidate paths to locate each downloaded
file in the slskd download dir:

  candidates = [
      download_path / remote_filename,
      download_path / basename,
      download_path / *normalized_path_parts,
  ]

On the common slskd config ``directories.downloads.username = true``
slskd writes files at ``<download_dir>/<username>/<filename>`` —
none of the three candidates carry a username segment, so the
resolver returned None for every file even though the file was
physically present in a subdir one level deeper. ``_poll_album
_bundle_downloads`` saw 0 completed_paths, kept spinning, and
hit the master deadline (~30 min) before bailing the batch.

Why per-track worked: ``web_server._find_completed_file_robust``
already does a recursive walk-by-basename + path-confirm against
the remote directory components, so any layout slskd writes ends
up resolved. The bundle path didn't go through it.

Fix
- Lifted the robust finder into ``core/downloads/file_finder.py``
  as a pure function ``find_completed_audio_file(download_dir,
  api_filename, transfer_dir=None) -> (path, location)``. Zero
  globals; recursive walk; handles slskd dedup suffix
  ``_<10+digit-timestamp>``, YouTube / Tidal ``id||title`` encoded
  filenames, the AcoustID-quarantine subdir skip, basename
  collisions disambiguated by remote-path components, and a
  fuzzy-basename fallback above 0.85.
- ``_resolve_downloaded_album_file`` keeps the three-candidate
  fast path (cheap probe for the slskd-flat default) but now
  delegates to the new helper when none hit, instead of giving up.
- ``_poll_album_bundle_downloads`` tracks "slskd reports
  Completed but local resolver returns None" per key. When every
  remaining key has been in that state past a 45-second grace
  window, the poll exits early with an explicit error pointing at
  the likely ``soulseek.download_path`` mismatch instead of
  silently spinning until the master deadline.
- ``web_server._find_completed_file_robust`` becomes a thin
  delegate so both callers share one finder. Legacy inline impl
  kept as ``_find_completed_file_robust_legacy`` for reference;
  to be removed next release.
- Fixed misleading ``"(0 tracks, quality=)"`` log on the preflight-
  reuse path — was reading attrs off a None ``picked`` object.

Tests (17 new in tests/downloads/test_file_finder.py)
- Flat slskd layout
- Username-prefixed (the #715 case)
- Full remote tree preserved
- Deeply nested username + tree
- File genuinely missing returns None
- Basename collision disambiguated by remote dirs
- Single basename match wins regardless of dirs
- slskd dedup suffix match
- Short ``_<digits>`` (year) not treated as dedup
- AcoustID quarantine subdir skipped
- YouTube / Tidal ``id||title`` encoded filenames
- transfer_dir fallback
- Both dirs miss → (None, None)
- Non-audio files ignored
- Empty api_filename
- Fuzzy match on punctuation variant
- Fuzzy rejects below threshold

475 downloads tests pass after the lift.
2026-05-27 21:20:37 -07:00
Broque Thomas
e296fbfadd Auto-Sync manager: full visual overhaul to match the dashboard vibe
The Auto-Sync manager modal had been carrying its original visual
treatment forward unchanged while the rest of the app moved
toward the glassy / accent-radial / gradient-border aesthetic
the dashboard now sets. Restyled every surface inside the modal
to match.

Strategy: selector-based override layer appended at the end of
``webui/static/style.css`` — every selector in the new block
already exists in the original CSS above; the new block wins on
cascade order. Zero HTML / JS changes; functionality untouched.
Delete the v2 block to revert.

Surfaces restyled
- Modal shell: glass + thin accent border + corner radial wash
  + inner top-edge highlight, matching the dashboard ``.dash-card``
  architecture
- Header: gradient-clipped title, accent-tinted eyebrow, hairline
  accent separator below, spinning-X close button
- KPI summary tiles: dashboard-style gradient tiles, accent
  top-edge glow on hover, gradient stat numbers
- Live monitor strip: accent-tinted glass card, status-colored
  borders (running = green, error = red)
- Refresh / intro buttons: pill primary with accent fill + glow
  on hover (replaces the bare ghost button)
- Tabs: underline-style with accent fill + soft radial glow on
  the active tab (replaces the pill-tab look)
- Sidebar: glass panel, source groups as collapsible-feel cards,
  accent border on scheduled playlist tiles, accent ring on the
  filter input focus
- Board: subtle accent radial spotlight backdrop; columns are
  glass cards with gradient headers + accent drag-over glow
- Drop zones: animated dashed pill with accent radial wash;
  accent-tinted text on drag-over
- Scheduled cards: accent left-edge stripe, gradient pill timing
  badges, pill "Run now" primary + rotating ghost X — health
  variants (failing / warning) re-tint the left-edge stripe
- Run history rows: dashboard recent-activity aesthetic, accent
  hover lift, pill status badges with colored borders
- Bulk schedule popover: glass card with accent border, pill
  buttons, red ghost for unschedule
- Weekly editor: glass modal matching version-modal vibe,
  day-toggle pills (accent fill when active), pill save button
- Empty / monitor-empty states: dashed glass card with subtle
  vibe
- Soft accent-tinted scrollbars throughout the modal
2026-05-27 20:15:56 -07:00
Broque Thomas
6a619254df Auto-Sync: weekly board cards now match the hourly board
When the Weekly Board shipped, its scheduled-card visual diverged
from the hourly board's: weekly cards showed only the playlist
name + weekly label + timezone, while hourly cards already
carried a full action row (Run-now button, unschedule X,
next-run countdown, health badge). Two boards looking like
different apps.

Standardised the weekly card on the hourly shape so a day-column
drop produces the exact same affordances as an interval-bucket
drop:

  - Health badge (warning ⚠ / failing !) when recent runs errored
  - Source + track-count meta line under the name
  - Timing line: weekly label + tz pill + next-run countdown
  - Run-now button (disabled while pipeline running, same gating
    logic the hourly card already had)
  - Unschedule X — calls the weekly-specific helper, leaving
    hourly schedules untouched

Click anywhere outside the buttons still opens the weekly editor
for changing days / time / tz. Weekly cards also become
draggable between day columns now — drop on a new column appends
the day to the schedule (matches the multi-day editor flow).
2026-05-27 19:38:43 -07:00
Broque Thomas
79465580ab Dashboard: equalizer bars now show real brand logos in the avatar disc
Initial-letter glyphs (SP / AM / DZ / ...) read as placeholder
once the brand-logo equalizer disc was visualised — each chip
should carry the service's actual mark. Wired the same logo
URLs the header-action worker orbs already load (Spotify press
asset, iTunes Wikimedia SVG, Deezer brandfetch symbol, Last.fm
avatar, Genius logo, MusicBrainz Wikimedia SVG, AudioDB local
PNG, Tidal / Qobuz / Discogs SVGRepo marks, Amazon local SVG)
into a new _RATE_GAUGE_LOGOS map and rendered as an ``<img>``
inside the avatar disc.

Visual details
- Disc backdrop switched from a solid accent-gradient fill to a
  dark glass radial + accent-tinted ring + accent drop-shadow
  on the logo. The service color still anchors the chip without
  competing with the logo for contrast.
- Logo sized at 75% of the disc for breathing room. Drop-shadow
  pops dark / multi-tone marks against the dark backdrop.
- Avatar bumped to 34px / 28px / 26px across desktop / tablet /
  mobile so logos read clearly at every breakpoint.

Resilience
- ``<img onerror>`` swaps in an initial-letter glyph span on
  load failure (CDN drop, network blip). The ``.rate-eq-avatar
  --fallback`` variant restores the original accent-gradient
  disc look so the fallback chip still reads as branded.

Asset
- AudioDB ships no public logo URL — saved the existing header-
  action base64 PNG (~30 KB) to ``webui/static/audiodb.png`` so
  the equalizer can reference it as ``/static/audiodb.png`` like
  Amazon already does.
2026-05-27 19:22:28 -07:00
Broque Thomas
c845fa933f Dashboard: next-level polish on enrichment equalizer bars
Four upgrades that take the equalizer row from clean to vibey.
All tied together by the same --eq-accent / --eq-glow CSS
variables so future tweaks stay coherent across the four
animation layers.

1. Brand-color avatar disc above each bar. Circular chip with a
   2-3 letter glyph (SP / AM / DZ / LF / GN / MB / ADB / TD /
   QB / DC / AZ) and a radial gradient using the service's
   accent. Inner highlight + drop-shadow for depth; slow halo
   pulse when the worker is running. Anchors each capsule to
   its identity so the row reads as "these are your services"
   not "these are 11 anonymous bars."

2. Peak-flash detector. When ``cpm`` actually steps upward
   between socket updates (above a small jitter floor so
   near-zero noise doesn't trigger), the peak tip briefly
   flares white-hot, the fill flashes brighter, and the
   reflection puddle ripples — all on a 650ms one-shot the JS
   removes after fire. Mimics a hardware VU meter's peak-
   detect LED. Sells the "alive" feeling by tying bar
   movement to real call activity, not just continuous
   animation.

3. Rolling-counter number animation. The live count under
   the bar digit-animates from old→new with easeOutCubic
   over 520ms instead of snapping. Per-element animation
   handles tracked in a WeakMap so a fast second update
   cancels the prior RAF loop instead of fighting it.
   Premium-counter feel.

4. Glass-surface reflection puddle. Soft accent-colored
   blurred ellipse under each bar; opacity scales with the
   real (unclamped) rate via the --eq-glow variable so idle
   bars don't pollute the row with permanent ground-light.
   Rate-limited bars get a red puddle. Peak-flash briefly
   intensifies the puddle so the surface "ripples" with the
   call burst. Mounted on the host button (not the track) so
   it escapes the track's overflow clipping.

Responsive: avatar disc shrinks to 26px at laptop/tablet,
24px at mobile.
2026-05-27 19:08:00 -07:00
Broque Thomas
74dcafd6e9 Dashboard: equalizer-bar redesign for Enrichment Services panel
The rate monitor on the dashboard used a 10-column grid of circular
SVG speedometers. With 11 services configured (Amazon was the
straw), the grid produced 10-in-row-1 + 1-orphan-in-row-2, breaking
the dashboard's tile symmetry. Speedometers also wasted ~80% of
their pixels on empty arc — most services sit at 0 cpm most of
the time, so the row visually read as a wall of empty gauges.

Replaced with a VU-meter / equalizer row: one vertical capsule
per service, brand-color gradient filling from the bottom, bar
height tracks ``calls/min ÷ limit``. Music-app native aesthetic,
fits the existing accent-heavy glassy vibe, and symmetric by
design at any service count — services slot into the flex row.

Visual details
- 4% sliver floor on idle bars so the row reads as "everything
  alive" instead of "8 dead gauges" — vibe over literal zero
- Continuous shimmer scan when worker is running (vertical wash)
- Slow breathing pulse on idle bars
- Red gradient + faster pulse when rate-limited
- White-hot peak tip glows in the service's accent color
- Status pill below each bar (Running pulses green, Paused amber)
- Big count number floats top-center of the track

Behavior
- Click any bar opens the same detail modal the speedometer used —
  no data-flow changes, no API changes, drop-in visual swap.
- Renderer auto-detects the dashboard context (data-card="enrichment")
  and routes through the equalizer path; legacy speedometer code
  still ships for any non-dashboard mount.
- Responsive: tightens at laptop/tablet breakpoints, wraps to
  5-per-row on phones.
2026-05-27 19:02:50 -07:00
Broque Thomas
01a867e589 Auto-Sync: fix LB pipelines stuck on "Refreshing:" for 5+ minutes
Pipeline-driven Auto-Sync runs against any ListenBrainz playlist
(Weekly Jams, Weekly Exploration, Top Discoveries, etc.) would sit
on ``Refreshing: "<name>"`` with no UI updates for 5-7 minutes
before the pipeline progressed. Two real bugs stacked:

1. **Double discovery.** The refresh handler called
   ``_maybe_discover`` (matching engine, per-track Spotify/iTunes/
   Deezer matches) inline for any source returning
   ``needs_discovery=True`` tracks. Phase 2 of the pipeline then
   ran the SAME matching engine via ``run_playlist_discovery_worker``
   on the same tracks. The refresh-side run blocked the loop with
   zero progress emission; Phase 2's already has the timed
   progress-poll pattern. So LB tracks discovered twice, the first
   time silently.

   Pipeline now sets ``skip_discovery=True`` on its refresh config.
   The handler honors the flag and lets Phase 2 handle discovery
   end-to-end. Standalone callers (Sync-page tab, registration
   action) leave the flag unset so they still get matched_data
   on refresh.

2. **No targeted LB refresh.** The LB adapter's ``refresh_playlist``
   called ``manager.update_all_playlists()`` — the only refresh
   entry-point the manager exposed — which re-pulls every cached
   LB playlist's details from the API (~12+ round-trips) even
   when only one playlist needed refreshing. Wasteful;
   tax-on-everyone for one-playlist work.

   Added ``LBManager.refresh_playlist(mbid)`` — reads the cached
   playlist_type, fetches just that playlist's details, runs the
   normal ``_update_playlist`` upsert path. Defaults type to
   ``user`` for un-cached mbids so new-playlist discovery still
   works. Skips ``_cleanup_old_playlists`` and
   ``_ensure_rolling_mirrors_from_cache`` (wasted work for a
   single-playlist refresh).

Also: killed a silent ``except Exception: pass`` in the LB
adapter's old refresh wrapper that was masking every LB API
failure as a stale-cache hit. Refresh errors now log with full
traceback at warning level and propagate ``None`` so the outer
handler at ``refresh_mirrored.py:104`` counts the error and
surfaces it to the run-history error tally.

Pinned with 12 new unit tests across:
  - ``tests/test_listenbrainz_manager.py`` (8): targeted refresh
    happy path, unauthenticated guard, empty-mbid guard, upstream
    ``None`` return, default playlist_type for unknown mbid,
    exception propagation, cost guard skipping cleanup, skipped-
    when-unchanged signal
  - ``tests/test_playlist_sources_adapters.py`` (3): adapter uses
    targeted call (not legacy), adapter returns ``None`` on manager
    error (not silent swallow), adapter resolves synthetic series
    ids before calling the manager
  - ``tests/automation/test_handlers_playlist.py`` (1):
    skip_discovery flag bypasses ``_maybe_discover`` end-to-end
2026-05-27 18:04:55 -07:00
Broque Thomas
45ecf2730d Wishlist: harden Spotify backfill — poisoned tn=1 can't mask lean album
Residual per-track wishlist downloads (single tracks from different
albums, below the album-bundle threshold) were producing folders
without a year subfolder whenever the wishlist row carried a stale
``track_number=1`` from an older payload default.

Why: ``core/downloads/candidates.py`` had a single API-fetch branch
that served two concerns — resolving the track position AND
hydrating the lean ``spotify_album_context`` (release_date /
total_tracks / cover image) — gated entirely on track_number being
unresolved. When the wishlist row's ``track_number`` happened to
be 1 (a poisoned default rather than a real value), the gate
short-circuited and the album hydration the same call would have
done was skipped. Deezer-sourced discovery matches don't ship
release_date in their search-result album shape, so without the
backfill the folder lost its year.

The two concerns split:
  - track_number resolution keeps its track_info → track object →
    API precedence chain. track_info defaults still win.
  - album hydration runs whenever release_date or total_tracks are
    missing, independent of where (or whether) track_number was
    resolved.

The single API round-trip still serves both — the cost contract
is preserved. The side-effect coupling is gone.

Lifted into ``core/downloads/track_metadata_backfill.py``
(``hydrate_download_metadata``) so the precedence chain is pinned
in isolation. 24 unit tests cover the precedence chain, the
poisoned-tn=1 regression case, defensive non-dict/None inputs,
the cost guard (API called at most once per invocation), and
disc_number resolution.

Also lands the upstream piece: ``core/wishlist/routes.py:_build_track_data``
no longer defaults ``track_number=1`` / ``disc_number=1`` /
``total_tracks=1`` / ``release_date=''`` when the library-modal add
payload omits them. Missing values now flow through as ``None`` so
the downstream pipeline can detect-and-recover instead of locking
to a fake position.
2026-05-27 16:47:26 -07:00
Broque Thomas
997732ee63 Wishlist: fix three regressions causing all imports to land as track 01 with no year
Real-world regression triggered by the album-bundle work earlier in
2.6.3. Tracks with full Spotify metadata were importing as
``01 - <title>`` under ``Artist - Album/`` (no year), even when the
source filename carried the correct track number and Spotify's
release_date was available.

Investigation via DB inspection of stored wishlist rows:

```
"Never Gonna Give You Up" → track_number=None,  release_date=""
"idfc"                    → track_number=1,    release_date=""
"No Sleep Till Brooklyn"  → track_number=1,    release_date=""
```

Source-of-truth Spotify metadata had release_date AND real track
positions, but the wishlist row was poisoned. Three regressions
compounded the loss:

**Fix A — ``track_object_to_dict`` (``core/wishlist/payloads.py:295``)
preserved only album.name during Track→dict conversion.**

Pre-fix:
```python
album_name = "Unknown Album"
if hasattr(track_object, "album") and track_object.album:
    if hasattr(track_object.album, "name"):
        album_name = track_object.album.name
    else:
        album_name = str(track_object.album)

result = {
    ...
    "album": {"name": album_name},   # ← release_date / images / etc. all dropped
    ...
}
```

When a wishlist payload arrived as a Track dataclass instead of a
raw spotify_data dict, the Track→dict conversion stripped
release_date, images, album_type, total_tracks, id, and album-level
artists. Every wishlist row added through this path landed in the
DB with ``album={'name': X}`` only.

Post-fix: three branches handle the three album shapes
- ``album_attr`` is a dict → ``dict(album_attr)`` preserves every key
- ``album_attr`` is a sub-object → pull all common Album-dataclass
  attrs (id, release_date, album_type, total_tracks, images, ...)
- ``album_attr`` is a bare string → build a dict from the track
  object's adjacent attrs (release_date, album_id, album_type, ...)
  and surface ``image_url`` as ``album.images``

**Fix B — ``core/discovery/playlist.py:309`` only added
``track_number`` / ``disc_number`` keys when truthy.**

Pre-fix:
```python
matched_data = { 'id': ..., 'name': ..., ... }   # no track_number / disc_number
if track_number:
    matched_data['track_number'] = track_number
if disc_number:
    matched_data['disc_number'] = disc_number
```

Deezer-sourced matches always hit this branch with ``track_number=None``
because the cache enrichment at line 304 reads ``_raw.get('track_number')``
literally, but Deezer's raw shape uses ``track_position``. So the key
was omitted from ``matched_data``, downstream consumers couldn't
distinguish "missing key" from "value is 1", and the chain silently
filled 1.

Post-fix: keys are ALWAYS present (None when unknown). Also adds a
``best_match.track_number`` fallback so the Track-dataclass-mapped
value (which DOES include ``track_position``→``track_number``
mapping) gets used when the cache lookup misses.

**Fix C — Pipeline only consulted ``album_info.track_number`` before
falling to the filename (``core/imports/pipeline.py:645``).**

VA-collection source files like ``417 Fountains of Wayne - Stacys
Mom.flac`` have a leading playlist-position number that isn't the
album track number. The previous chain (album_info → filename →
floor-1) couldn't recover the real position because the filename
extractor either returned 417 (wrong) or None (caught by the floor).
But the wishlist payload's ``track_info.spotify_data.track_number``
HAD the right answer all along — Spotify says Stacy's Mom is track
3 on Welcome Interstate Managers.

Post-fix: resolution chain extracted into ``core/imports/track_number.py:resolve_track_number``
as a pure function:
1. ``album_info.track_number`` (album-bundle dispatch authoritative)
2. ``track_info.track_number`` (per-track flow payload)
3. ``track_info.spotify_data.track_number`` (nested fallback)
4. ``extract_explicit_track_number(file_path)`` (filename, returns
   0 when no numeric prefix — vs the default helper that returns 1)
5. Caller (pipeline) applies the final >=1 floor

Each step coerces to a positive int or falls through to the next.
Pure function = unit-testable in isolation = single place to fix
the rule.

**Test coverage (37 new tests):**

- ``tests/wishlist/test_payloads.py`` (+4) — Track→dict conversion
  preserves full album dict (dict / object / string album shapes) +
  None-track-number stays None.
- ``tests/discovery/test_discovery_playlist.py`` (+2) — matched_data
  always includes track_number/disc_number keys (None when unknown)
  + falls back to best_match attrs when cache misses.
- ``tests/imports/test_track_number_resolver.py`` (+16) — every
  resolution-chain branch pinned: album_info-wins, track_info
  fallback, spotify_data nested, JSON-string parsing, garbage-string
  fall-through, zero / negative / non-numeric / string-numeric
  coercion, filename fallback, explicit extractor vs default
  extractor semantics, defensive None inputs, VA-collection
  filename behaviour, all-sources-missing → None.

1571 wider-suite tests pass (wishlist + imports + discovery +
downloads + metadata). Ruff clean.

**Migration note:** existing wishlist rows that were saved under
the OLD ``track_object_to_dict`` (with stripped album metadata) still
have ``release_date=''`` in the DB blob. Those won't self-heal — the
next attempt loads from the poisoned blob. Users can remove + re-add
those tracks to refresh, or wait for the next sync run that
re-discovers them with full metadata. No automatic migration shipped
in this PR (scope creep — the forward path is fixed, backfill is a
separate concern).
2026-05-27 15:39:22 -07:00
Broque Thomas
6841128dc2 Wishlist: distinguish Queued from Analyzing for executor-pending batches
PR 4 of 4 in the wishlist-album-bundle issue series. UI fix only —
zero behavior change.

User's 26-track wishlist run rendered all 26 sub-batches as
"Analyzing..." simultaneously. Pre-fix the rows were created with
``phase='analysis'`` BEFORE being submitted to ``missing_download_executor``
(max_workers=3 by default), so 23 batches sat in the executor queue
visually identical to the 3 actually running. Misled users into
thinking SoulSync was processing 26 in parallel; really only 3 ever
ran at once with the rest waiting their turn.

Fix:
- Wishlist auto-flow submission sites now create batch rows with
  ``phase='queued'``.
- The master worker (``core/downloads/master.py:328``) already flipped
  phase to ``'analysis'`` as its first action on entry — that
  transition becomes the real signal that the executor picked the
  batch up.
- ``core/downloads/status.py`` surfaces ``analysis_progress`` for
  the ``queued`` phase too so the UI has the track count to render
  "Queued — N tracks" instead of an empty card.
- Frontend (``webui/static/pages-extra.js``, ``downloads.js``) renders
  "Queued " for ``phase='queued'`` distinct from the spinner-laden
  "Analyzing..." for ``phase='analysis'``.

Scope choices:
- Only the auto-wishlist submission sites flipped this PR
  (``core/wishlist/processing.py:860`` album sub-batches +
  ``core/wishlist/processing.py:907`` residual). The manual-wishlist
  sites at ``:451`` and ``:627`` use the same executor + worker, but
  those create a caller-allocated batch_id that the frontend polls
  immediately — wanted to verify the manual-poll path handles
  ``queued`` cleanly before flipping those. Trivial follow-up.
- Other submission sites in album_bundle_dispatch / web_server.py /
  task_worker.py left untouched — they don't go through the
  executor-queue pattern that causes this UI confusion.

Tests:
- Updated ``test_process_wishlist_automatically_creates_batch_for_matching_tracks``
  to assert ``phase='queued'`` on creation (was ``'analysis'``); explanatory
  comment names the executor-pool reason.
- New ``test_queued_phase_surfaces_analysis_progress_for_ui_count`` in
  ``tests/downloads/test_downloads_status.py`` pinning the new
  ``queued ⊂ analysis_progress`` rendering contract.
- 884 tests pass across wishlist + downloads + imports suites.
- Ruff clean on changed Python files; JS syntax OK on changed
  webui files.

PR 3 (sibling-completion gate) was investigated and dropped — the
"1/26 finalized" symptom turns out to be downstream of the
staging-match bug (PR 2's instrumentation will catch it on the
user's next reproduction run), not an independent sibling-gate bug.
The gate logic itself is correct.
2026-05-27 14:52:02 -07:00
Broque Thomas
dd32e3bbe1 Wishlist: only engage album-bundle when multiple tracks from same album (PR 1/4)
Real-world wishlist case the original c3b88e69 design missed: user with
26 missing tracks from 26 different albums. Each item used to promote
to its own album-bundle sub-batch (``min_tracks_per_album=1``), which
downloaded the ENTIRE album (5-42 files) to claim one track. Confirmed
in app.log:

- "Licensed To Ill" downloaded 3 times across cycles (3-4 files each)
- "The Understanding" 17 files for 1 wishlist track
- "Alright, Still" 42 files for 1 wishlist track
- ~85% wasted bandwidth, slskd hammered with 26 concurrent searches

PR 1 of a 4-PR fix series — see commit body footer for the other PRs.

Default ``min_tracks_per_album`` 1 → 2. Single-track wishlist items
fall to ``residual_tracks`` → classic per-track batch (already works,
already efficient). Album-bundle kept for the case it was designed
for: user has 2+ tracks missing from the same album.

Override via the new ``wishlist.album_bundle_min_tracks`` config key:
- 1 = previous behaviour (bundle every item)
- 2 = new default
- 3+ = stricter, for users who want bundle only on bigger gaps

Helper ``_resolve_album_bundle_threshold`` lives in
``core/wishlist/processing.py``. Defensive shape mirrors the existing
config-driven knobs (``get_poll_interval`` / ``get_transient_miss_threshold``):
non-numeric, non-positive, or config-manager-raise all fall back to
the safe default. Three test cases pin the fallback chain.

Both wishlist entry points wired through the same helper:
- ``process_wishlist_automatically`` (auto cycle, line 812)
- ``start_manual_wishlist_download_batch`` (manual run, line 539)

Tests:
- ``tests/wishlist/test_album_grouping.py`` — old ``test_default_threshold_promotes_solo_albums`` flipped to ``test_default_threshold_demotes_solo_albums`` with explanatory docstring naming the real-world cause. New ``test_default_threshold_promotes_multi_track_albums`` pins the 2+ promotion. New ``test_explicit_threshold_one_restores_solo_promotion`` pins that the kwarg still works for opt-back-in.
- ``tests/wishlist/test_processing.py`` — 3 new tests for ``_resolve_album_bundle_threshold``: default-when-config-missing, honors-config-override, falls-back-on-garbage.
- ``tests/wishlist/test_automation.py`` — ``test_wishlist_albums_cycle_splits_into_per_album_batches`` updated to use 2+ tracks per album (5 tracks across 2 albums instead of 3 across 2 with 1 solo). ``test_wishlist_albums_cycle_residual_for_orphan_tracks`` updated to include 2 tracks from Album One so it still promotes.
- ``tests/wishlist/test_manual_download.py`` — same shape update for the manual path test.
- ``tests/wishlist/test_album_grouping.py:test_multiple_albums_emit_separate_groups`` updated to reflect new default (alb1 with 2 tracks promotes, alb2 with 1 track goes residual).
- ``tests/wishlist/test_album_grouping.py:test_nested_track_data_payloads_normalized`` pinned with explicit ``min_tracks_per_album=1`` so the test stays focused on payload-shape parsing, not the threshold rule.

114 wishlist tests pass; 866 across wishlist + automation + downloads +
album_bundle + album_bundle_dispatch suites still green. Ruff clean.

Sibling PRs queued in TaskCreate:
- PR 2 — investigate post-process staging-match miss (the second-order
  bug that causes the same album to redownload every cycle when the
  staging step doesn't claim the requested track).
- PR 3 — fix sibling-completion gate that fires on first sibling
  instead of last (log evidence: run a4945c88 finalized 1/26 batches).
- PR 4 — UI distinguish Queued from Analyzing for batches waiting
  on the executor (23/26 batches sit at "Analyzing..." while really
  queued at max_workers=3).
2026-05-27 13:42:04 -07:00
Broque Thomas
698c21c3ce Auto-Sync Weekly Board: weekday schedules in the UI (PR 3/4)
PR 3 of the schedule-types feature — see
``memory/project_auto_sync_schedule_types.md``. Backend
``next_run_at`` + ``weekly_time`` trigger handler landed in PRs 1-2.
This PR exposes them in the Auto-Sync manager so users can finally
schedule playlists by day-of-week + time instead of only hourly
intervals.

**UI layout:**

The Auto-Sync modal grows a ``Weekly Board`` tab between
``Hourly Board`` (renamed from ``Schedule Board``) and
``Automation Pipelines``. Same sidebar (mirrored playlists grouped
by source, with filter). Main panel is 7 day columns Mon-Sun
instead of 10 hour buckets. Drag a playlist onto a day column →
creates a single-day weekly schedule at the default time
(09:00 in the browser's IANA tz from
``Intl.DateTimeFormat().resolvedOptions().timeZone``). Click any
scheduled card → opens an editor popover for time, multi-day
toggles, tz override, and unschedule.

Multi-day schedules render under every matching column (Mon-Wed-Fri
schedule appears as three cards, one per column) — matches how
users think about "this playlist runs on Mon AND Wed AND Fri".

**Mutual exclusion:** one schedule per playlist. The save path on
either tab deletes any existing schedule of the OTHER kind before
installing the new one. Backend can technically run both as two
separate automation rows, but two cards under the same playlist
would surprise users and the engine has no merge semantic for
"daily-and-hourly".

**Pure-function helpers** (testable via node:test, matching the
existing ``tests/static/test_auto_sync.mjs`` pattern):

- ``detectBrowserTimezone()`` — Intl tz with UTC fallback for
  browsers where Intl is absent.
- ``autoSyncWeeklyTrigger({time, days, tz})`` — defensive payload
  builder: garbage time → 09:00, unrecognised days dropped,
  missing tz → browser tz.
- ``autoSyncWeeklyFromTrigger(config)`` — inverse parser with
  the same defensive shape. Empty days expands to every weekday
  (matches ``next_run_at`` engine semantic). Returns null for
  non-object configs so ``buildAutoSyncScheduleState`` can route
  broken rows to automationPipelines instead of silently
  bucketing them as every-day weekly.
- ``autoSyncWeeklyLabel(parsed)`` — sorted "Mon, Wed, Fri @
  09:00" / collapses to "Daily @ HH:MM" for full-week / "Unscheduled"
  for null. Canonical Mon-Sun ordering regardless of input order.

**Tests:** 26 new node:test cases across ``detectBrowserTimezone``
x1, ``autoSyncWeeklyTrigger`` x6, ``autoSyncWeeklyFromTrigger`` x6,
``autoSyncWeeklyLabel`` x5, and ``buildAutoSyncScheduleState``
weekly bucketing x5 (covering owned weekly_time → weeklySchedules,
hourly stays in playlistSchedules, non-owned falls through to
automationPipelines, legacy-named auto-sync rows still recognised,
garbage trigger_config falls through). All 62 node:test cases pass;
261 across the automation pytest suite still green (zero regression
on PRs 1-2's plumbing). Python wrapper at
``tests/test_auto_sync_js.py`` shells out cleanly.

**CSS** (themed to the existing Auto-Sync gradient + accent
variables):
- 7-column grid for the weekly board, narrower than the 10
  hour-bucket layout.
- Editor popover with backdrop-blur, accent-tinted save / delete
  buttons, hover states that pick up the user's accent color.
- ``scheduled-elsewhere`` state for playlists with an hourly
  schedule visible on the weekly board (dashed border + opacity)
  so the user knows a drop will replace, not stack.

**WHATS_NEW entry** under 2.6.3 unreleased — first user-visible
slice of the schedule-types feature.

PR 4 (Monthly UI tab) deferred until weekly proves wanted.
2026-05-27 12:39:56 -07:00
Broque Thomas
f13d339584 Usenet album poll: tolerate SAB queue→history handoff, emit terminal failure (#706)
User reported usenet album downloads getting stuck on "downloading
release" while SABnzbd reported the job as complete. Container restart
did not help; reproducible on every usenet album download.

Three independent issues all causing the same symptom — the download
modal freezes mid-flow with no error surfaced to the user:

1. SAB queue → history transition window
   SAB removes a slot from its queue BEFORE adding it to the history,
   and on a busy server (par2 verify, unrar, multi-file move) that
   window can span several poll iterations. The poll treated a single
   None status as terminal failure ("disappeared from client") and
   gave up. Now the poll tolerates up to ~10s of consecutive misses
   (5 polls at the default 2s interval) before declaring the job gone.

2. SAB queue states like `Pp` were unmapped
   `_SAB_QUEUE_STATE_MAP` didn't cover SAB's `Pp` (post-processing
   summary), `Unpacking`, `Trying`, `Deleted`, or the `Prop_paused`
   / `Prop_failed` variants. Unmapped states fell through to the
   default-'error' fallback, and the poll loop only treated explicit
   'failed' / 'completed' as terminal — 'error' was neither, so the
   loop spun until the 6-hour timeout. Map now covers every Status
   value from SAB's `sabnzbd/api.py`, and the poll treats the default-
   'error' fallback as a transient miss (warn-logged, retry within
   the same tolerance window) so a brand-new unmapped state can't
   infinite-loop the way `Pp` did here.

3. No terminal failure emit
   The poll only logged on failure / timeout / disappeared — never
   called the progress callback with 'failed', so the download modal
   stayed at the last 'downloading' emit forever. Plumb a 'failed'
   emit through every failure exit path so the UI flips out of the
   downloading state when the poll gives up.

Plus:

4. SAB direct nzo_ids lookup instead of paging all-history
   `_get_status_sync` was fetching the latest 50 history entries on
   every poll and iterating to find the target nzo_id. On busy
   servers (many recent downloads), the target job could roll past
   the 50-entry window and look like a "disappeared" job. Replaced
   with a targeted `mode=queue&nzo_ids=<id>` → `mode=history&nzo_ids=<id>`
   chain. Falls back to the bulk path for SAB versions that pre-date
   the nzo_ids filter — the transient-miss tolerance covers any
   short-lived gap there too.

Implementation:

Lifted the album-bundle poll loop out of `usenet.py` and `torrent.py`
into `core/download_plugins/album_bundle.py:poll_album_download` —
near-duplicate implementations are now a single function with deps
injected so it's testable in isolation (kettui's extract-don't-AST-parse
standard; can't unit-test a `time.sleep` loop inside a plugin method).
The lifted helper takes:
- `get_status` callable bound to job_id, so the same loop works for
  usenet UsenetStatus and torrent TorrentStatus shapes
- `complete_states` set so torrent's `{'seeding', 'completed'}` and
  usenet's `{'completed'}` both Just Work
- `failed_states` set so torrent's `{'error'}` is terminal while
  usenet's default-'error' fallback is transient
- `transient_miss_threshold` (default 5 ≈ 10s at 2s poll)
- `sleep` / `monotonic` injectables for deterministic tests

Per-track flows in both plugins gained the same transient-miss
tolerance inline — they don't use the emit pattern (update an
`active_downloads[id]` row dict via lock instead), so reusing the
helper would have required threading a no-op emit through. Inline
fix is small enough.

Tests:
- 11 new tests in `tests/test_album_bundle.py:poll_album_download`
  cover the happy path, transient-miss tolerance with recovery,
  hard-failure threshold, explicit-failed surface, timeout-emit,
  default-'error' transient treatment, shutdown clean exit,
  torrent's `seeding`-counts-as-complete, save_path captured across
  iterations, and adapter-exception treated as transient miss.
- 521 download-suite tests pass (33 in test_album_bundle, others
  pin existing torrent + usenet contracts).
- Ruff clean.

Closes #706.
2026-05-27 09:42:51 -07:00
Broque Thomas
1d6ced286b Discogs: strip artist disambiguation suffixes at every name surface (#634)
Discogs uses two disambiguation conventions for duplicate artist names:
- legacy `(N)` numeric suffix: "Bullet (2)", "Madonna (3)"
- newer `*` asterisk suffix: "John Smith*", "Foo*"

Both were leaking through to the UI on artist search and album search,
and worse — through the import path into folder names on disk
(reported: importing yielded folders literally named `Foo*`).

The pre-existing cleanup only handled `(N)` and only at ONE site —
`get_user_collection` (line 469) and one path inside
`extract_track_from_release` (line 448 — `re.sub(r'\s*\(\d+\)$', '',
artist_name)`). Every other surface (artist search, album search,
album-track lookups, get_artist_albums feature matching) returned the
raw Discogs string.

Centralized into `_clean_discogs_artist_name(name)` at module top,
with regex covering both suffixes including repeated forms (`Baz**`,
`Foo (3)*`). Applied at six sites:

- `Artist.from_discogs_artist` (artist search)
- `Album.from_discogs_release` (album search — three fallbacks: array,
  string, title-split)
- `Track.from_discogs_track` (track lookup — track-level + release-level
  fallback)
- `extract_track_from_release` (replaces the inline `(N)`-only re.sub)
- `get_user_collection` (existing site, now also strips `*`)
- `get_artist_albums` (artist_name used for primary-vs-feature matching;
  cleaning prevents `Beyoncé*` from failing equality vs `Beyoncé`)
- `get_album` (artists_list + per-track artists in the tracklist projection)

Tests:
- New `test_clean_discogs_artist_name` parametrized over 14 cases
  covering `(N)`, `*`, repeated `**`, combined `(N) *`, whitespace
  handling, empty/None defensive returns.
- New `test_get_user_collection_strips_discogs_asterisk_disambiguation`
  pinning the asterisk path end-to-end through the collection import
  flow (sibling to the existing `(N)` test).
- Existing 37 discogs tests still pass.

Out of scope (separate issue): the same #634 report flagged track-count
and year fields rendering as 0 / empty in Discogs album search. Both
are inherent to Discogs `/database/search` response shape — search
results don't carry `tracklist` (only release detail does) and `year`
is often `0` in search payloads. Fixing requires lazy-fetching release
detail per row, which hits the 25 req/min unauth limit hard. Not
bundled here.
2026-05-27 09:05:47 -07:00
Broque Thomas
b67d13164a Library: persist Enhanced / Standard view toggle in localStorage
User feedback: the Enhanced view toggle on the artist detail page reset
to Standard on every artist click, so admins who prefer Enhanced had to
re-flip the toggle every single time. Persist the choice in
localStorage and reapply on every artist navigation + page reload.

- `toggleEnhancedView()` writes `soulsync-library-view-mode` to
  localStorage on every change.
- `navigateToArtistDetail()` reads the saved value after the standard
  reset block runs; if `enhanced` AND `isEnhancedAdmin()` it calls
  `toggleEnhancedView(true)` after `loadArtistDetailData` kicks off.
  The brief Standard render is hidden as soon as the toggle flips.
- Gated on `isEnhancedAdmin()` so non-admin profiles (which never see
  the toggle) can't end up with a stale Enhanced preference being
  applied silently.
- Wrapped in try/catch since localStorage is unavailable in some
  private-browsing modes.

No backend change; no DB migration needed.
2026-05-27 07:08:21 -07:00
Broque Thomas
39f582a690 Mirrored playlist: stop Playlist Pipeline from reverting manual Fix-popup matches
User reported that manually mapping a mirrored-playlist track via the
Fix popup (either by search or by pasting an MBID) worked end-to-end
once — match saved, library track downloaded — but the next Playlist
Pipeline run flipped the track back to "Provider Changed" and forced
them to re-do the manual map every cycle.

Three independent issues were combining to cause this:

1. Hardcoded `provider: 'spotify'` on manual-fix save
   `update_youtube_discovery_match` (the endpoint the Fix popup posts
   to, also used by mirrored playlists since the frontend routes
   `platform === 'mirrored'` through the YouTube endpoint) always
   stamped the cached match as Spotify-provided. The Fix-popup cascade
   actually queries the user's primary metadata source first and falls
   back to Spotify / Deezer / iTunes / MusicBrainz — so a user on
   MusicBrainz primary picking an MB result still had it saved as
   `provider: 'spotify'`. The next prepare-discovery call (which
   compares cached_provider to the active source) then immediately
   classified the match as drifted and pending re-discovery. Fixed by
   deriving `match_source` from `spotify_track.get('source')` (every
   *_search_tracks endpoint stamps `source` on results) with a fallback
   to `_get_active_discovery_source()` for the MBID-paste path (which
   uses the lean flat shape that doesn't carry source). `matched_data['source']`
   and the mirrored `extra_data['provider']` both now use the derived
   value. `match_source` is also recomputed in the cache-save except
   handler so the downstream mirrored-DB save still has it.

2. Discovery worker re-queueing manual matches as "incomplete"
   `run_playlist_discovery_worker` in `core/discovery/playlist.py`
   re-adds any track to `undiscovered_tracks` when its `matched_data`
   lacks `track_number` or `album.id` / `album.release_date`. The
   check was designed as a legacy-fix backfill for old discoveries
   that lost those fields to a Track-dataclass stripping bug. But
   manual fixes from the popup are *intentionally* lean — search-
   result rows don't include `track_number` (none of the search
   endpoints return it), and the MBID-lookup flat shape doesn't
   carry `album.id` / `release_date` (the recording lookup returns
   only `album.name`). So every manual match looked "incomplete" and
   got re-discovered every pipeline run, overwriting the user's pick
   with whatever the auto-search ranked first. Manual matches now
   short-circuit ahead of the incomplete-data branch.

3. `prepare_mirrored_discovery` ignored the `manual_match` flag
   Independent of the provider-stamping fix above, the prepare-
   discovery endpoint that powers the mirrored-playlist UI did its
   own `cached_provider != current_provider` check and didn't honour
   manual_match either. Defence in depth — even if a future code
   path stamps the wrong provider on a manual match, the flag now
   anchors it as cached. `has_cached` also extended so manual
   matches with off-provider stamps still count toward the cached
   tally for phase classification.

Tests:
- new `test_manual_match_skipped_even_when_matched_data_incomplete`
  in `tests/discovery/test_discovery_playlist.py` pins the worker
  short-circuit using a realistic MB-shape matched_data (album dict
  without id / release_date, no top-level track_number). 16 existing
  tests still green; 848 across discovery / metadata / automation
  suites pass.
2026-05-27 06:59:58 -07:00