The mockup had a seek tooltip (timestamp tracks the cursor over the progress
bar) but it was never ported to the real player. Added it: mousemove computes
the hovered fraction -> formatTime(duration*frac), positions the tip, shows on
hover / hides on leave. Guarded when no duration. Frontend-only; JS + CSS clean.
listening_history was populated ONLY from the media server; the web player
recorded nothing. Now a play heard ~10s logs to listening_history AND bumps
tracks.play_count/last_played — so the existing 'recently played' query reflects
actual SoulSync listening, and the Phase-2 smart-radio recency signal gets real
data.
- core/playback/play_log.build_play_event(): pure, DB-agnostic normalizer from
player payload -> listening_history event shape. Caller supplies the
timestamp (stays pure). Composite/streamed ids never become the int
db_track_id; bool ids rejected; missing title -> skip. 9 unit tests.
- MusicDatabase.record_web_player_play(): inserts the history row + increments
play_count/last_played for the library track in one call.
- /api/library/log-play: thin endpoint, server-side timestamp, best-effort
(logging failure never 500s / never affects playback).
- Frontend: npMaybeLogPlay on timeupdate fires once per track at the 10s
threshold (flag reset in setTrackInfo, set-before-fetch so it can't
double-fire), fully fire-and-forget.
Pure builder is unit-tested; the DB write can't run in-sandbox (real DB throws)
so it's a thin straightforward insert+update. JS + web_server parse clean.
Spotify-style context line above the track title. npSetPlayContext(text) shows/
hides it; set to 'Radio' when radio mode turns on, '<Artist> Radio' from
playArtistRadio (specific label wins over generic), cleared on stop/clearTrack
and when radio mode is turned off. Accent-colored name, uppercase label.
Frontend-only; JS + CSS clean.
The sidebar mini-player had prev/play/next/stop/expand but not the two
set-and-forget controls you reach for without opening the full view. Added
shuffle + repeat (3-mode, with a repeat-one badge) to the mini-controls.
State stays in sync both ways: handleNpShuffle/handleNpRepeat now call a shared
syncShuffleRepeatUI() that reflects state onto BOTH the modal and mini buttons,
so toggling in either place updates the other. Mini buttons reuse the same
handlers. Accent-active styling via --accent-light-rgb.
JS clean; CSS balance consistent with HEAD.
- Added playNext(track): inserts a track right after the current one (Spotify
'Play next'), vs addToQueue which appends to the end. Falls back to
addToQueue when nothing is playing.
- Artist-detail track rows now show BOTH a Play-next (⇥) and Add-to-queue (+)
button; the delegated handler builds one shared library-track payload and
routes to playNext / addToQueue. (Add-to-queue was already wired; play-next
+ the second button are new.)
- Fixed the queue button's hardcoded 29,185,84 to var(--accent-rgb) so it
follows the settings accent (kettui UI-consistency), and styled the new
play-next button to match.
Note: deliberately NOT adding queue buttons to SEARCH results — those are
stream/download (non-library) tracks the queue's auto-advance can't reliably
play. JS syntax clean on both files.
- Keyboard: added N (next) / P (previous) track shortcuts; 'm' mute now works
whether or not the modal is open (was modal-only). Space/seek/volume/escape
unchanged.
- Volume persistence: volume now saved to localStorage on every change (slider
+ arrow keys, via npPersistVolume) and restored on load instead of always
resetting to 70%. npLoadSavedVolume validates the stored 0..100 value.
initializeMediaPlayer applies it + syncs both slider UIs.
Frontend-only; init runs from init.js after full parse so the module consts
are defined. JS syntax clean.
playArtistRadio() flipped npRadioMode=true directly but never fetched similar
tracks, so the queue stayed empty until the current song ENDED (onAudioEnded is
what triggered the radio fetch). The modal's Radio button does it right via
npSetRadioMode(true, {fetchIfNeeded:true}).
Fix: await playLibraryTrack(...) (it's async and sets currentTrack only after
resolving the canonical DB row), THEN call npSetRadioMode(true, {fetchIfNeeded})
— which seeds the current track into the queue and immediately fetches the
radio queue. Replaces the old fixed-setTimeout guess that raced the async track
load (and could fire before currentTrack.id existed -> silent no-op).
Self-audit of the revamp surface found real bugs, now fixed:
- DOUBLE-ADVANCE race: crossfade starts ~6s before track end, but when the
track actually 'ended' fired, onAudioEnded ALSO advanced — two skips.
onAudioEnded now bails when npXfadeActive (crossfade owns the advance).
- STRAY CROSSFADE on manual skip/stop: skipping or stopping mid-fade left the
interval running, firing npFinishCrossfade on top of the manual change, and
left the second <audio> playing. Added npCancelCrossfade() (clears the timer,
tears down the 2nd audio, restores main volume) called at the top of
playQueueItem and in handleStop. The fade interval also self-checks
npXfadeActive each tick. npFinishCrossfade clears all flags cleanly so the
legitimate handoff isn't treated as an abort.
- stream_start: moved 'global stream_background_task' to function top (it was
declared inside an if-block — parsed, but brittle/bad form).
web_server parses; 76 streaming+radio tests pass; JS syntax clean; CSS balance
unchanged from HEAD.
The Media Session API was partial — play/pause/stop/seek±10/prev/next handlers
+ metadata/artwork existed, but the OS lock-screen/Bluetooth/notification
control had a DEAD scrubber (no position, no drag-to-seek). Completed it:
- setPositionState (duration/position/rate) so the lock screen shows a live
progress bar, pushed throttled (~1/s) from timeupdate, reset on
loadedmetadata of a new track, and on manual seek.
- 'seekto' action handler so dragging the lock-screen/notification scrubber
actually seeks (with fastSeek when available).
Now hardware/Bluetooth keys + the lock-screen scrubber fully drive playback
with art, metadata, and live position. Feature-detected throughout.
Click any synced lyric line to jump playback to that line's timestamp (and
resume if paused). Reuses the existing _npLyricsState.lines {time,text} data.
Hover affordance: accent-tinted line + pointer cursor. Synced lyrics only
(plain lyrics have no timestamps).
- Stop button fix: my round .np-btn { width/height 46px; border-radius:50% }
override was also hitting .np-btn-stop (it carries both classes), squashing
the 'Stop' text pill into a tiny circle. Exempted .np-btn.np-btn-stop back to
an auto-width pill.
- Queue persistence: npPersistQueue() (called from renderNpQueue, the single
mutation hook) saves the queue to localStorage; npRestoreQueue() on init
repopulates the panel on reload WITHOUT auto-playing (index reset to -1).
Queue no longer vanishes on refresh.
- Crafted entrance: controls stagger-fade/rise in when the modal opens
(npRiseIn keyframe, delays cascading util->progress->controls->volume->
upnext). Art container excluded so its transform stays free for the
play-scale.
Frontend-only; Boulder verifying live.
Crossfade was a no-op toggle. Real crossfade needs two tracks audible at once,
but /stream/audio only serves the ONE current track (single global
stream_state). So:
- web_server: extracted the range-serving body of /stream/audio into
_serve_audio_file_with_range, and added /stream/library-audio?path= which
serves an arbitrary LIBRARY file through it. Security: the path is resolved
via _resolve_library_file_path (same validator /api/library/play uses) so it
only serves files inside the configured transfer/download/media-library
dirs — not arbitrary disk.
- frontend: a second hidden <audio> (#audio-player-xfade) preloads the NEXT
library track when the current one is within 6s of ending (crossfade on,
not repeat-one), ramps the two volumes in opposite directions, then hands
off to playQueueItem so all normal now-playing state is set.
Honest limits (documented in code): library→library only (streamed tracks
hard-cut as before); there's a brief silent reload at hand-off because
playQueueItem re-points the single stream_state — the perceived crossfade has
already happened by then. EXPERIMENTAL — needs Boulder's live audio
verification; I can't test audio in-sandbox.
33 streaming tests still pass (stream_audio refactor is behavior-preserving).
Two next-level player features (frontend-only):
1. Album-art ambient color — replaced the flat pixel AVERAGE (which muddied
every cover to grey-brown) with dominant-VIBRANT extraction: coarse
histogram binning weighted by saturation² × population, then a punch-up
pass (boost saturation ~1.3x, floor brightness) so the modal glow reads as
the cover's real standout color, Apple-Music style. Feeds the existing
--np-ambient-r/g/b hooks.
2. Drag-to-reorder queue — queue rows are now draggable; npReorderQueue moves
the item AND recomputes npQueueIndex so the currently-playing track stays
correctly tracked after a reorder. Accent drop-line indicator, grab cursor,
dragging opacity.
Verified live in-browser by Boulder.
Player-revamp frontend (Phase 1). Brings the Now Playing modal to the approved
mockup look + features:
- Full restyle (override block in style.css): 28px modal radius, stronger
art-driven ambient glow, 340px rounded art that scales while playing, bold
28px title, accent artist name, accent FLAC pill, dominant 70px gradient
play button, accent-gradient progress/volume/visualizer. All driven by the
existing --accent-rgb / --accent-light-rgb so it follows the settings accent.
- Click album art -> Plexamp-style visualizer takeover, fed by the REAL
music-synced Web Audio analyser (npStartVisualizerLoop), click again -> art.
- Rich queue rows: album thumbnail + title/artist + duration, equalizer
animation on the now-playing row, hover-reveal remove.
- Up-next peek below the controls (shows the next queued track).
- Sleep timer (cycles 15/30/60m, real setTimeout -> handleStop).
- Crossfade toggle present (visual state + persisted pref; the dual-audio
crossfade engine is the next step, not yet wired).
Frontend-only; verified live in-browser by Boulder. No backend/test surface.
Continue the design-system unification (kettui UI-consistency item):
migrate the five remaining compact button families onto the shared
.btn .btn--sm primitive + color modifiers, and drop their bespoke base
CSS (net -125 lines of CSS).
- ya-header-btn (Your Albums/Artists, discover.js-injected) -> .btn .btn--sm
.btn--secondary; ya-refresh/ya-settings/ya-viewall co-modifiers kept.
- explorer-action-btn (Playlist Explorer) -> .btn--secondary / .btn--primary.
- repair-bulk-btn -> .btn--secondary / .btn--primary / .btn--warning (fix-all).
- enhanced-bulk-btn (Library bulk bar, library.js-injected) -> .btn--primary/
--secondary/--danger; class kept as a hook for the mobile.css size
override + the .tag-write / .rg-analyze special colors.
- profile-create-btn (init.js-injected) -> .btn .btn--block .btn--primary;
class kept for the scoped .profile-edit-buttons flex:1 rule.
mini-nav-btn deliberately left as a distinct icon-button archetype.
Formalize the compact 'toolbar' button tier as design-system modifiers
(.btn--sm), plus a full-width (.btn--block) and amber caution
(.btn--warning) modifier, so the many smaller per-page buttons can share
the .btn primitive without being forced to the large default size.
First adopter: the Sync page header buttons (.sync-history-btn) now use
.btn .btn--sm .btn--secondary. The class is kept as a JS/onboarding
selector hook; .auto-sync-manager-btn still tints Auto-Sync accent.
The watchlist + wishlist header/overview buttons used a bespoke
.watchlist-action-btn family (different padding/radius/font and white
primary text) instead of the shared .btn design-system primitive.
Migrate all 11 of them to .btn / .btn--primary / .btn--secondary /
.btn--danger so they match the rest of the app, and drop the now-dead
CSS.
The .watchlist-batch-remove-btn / .wishlist-batch-remove-btn hook
classes are kept on the remove buttons (their !important red overrides
compose correctly over .btn--secondary). Static HTML only; no JS-injected
usages, and mobile.css overrides target .playlist-modal-btn, not these.
Reorder the sidebar nav so Downloads sits between Wishlist and
Automations. Mobile nav reuses the same .nav-button elements and the
helper/onboarding references are selector-keyed, so no other changes
are needed.
The Tools-page Database Updater dropdown only offered Incremental and
Full Refresh, even though the backend (/api/database/update with
deep_scan) and the dashboard Deep Scan button already supported a deep
scan. Wire the missing option into the Tools UI:
- Add a "Deep Scan" option to the #db-refresh-type dropdown.
- handleDbUpdateButtonClick now sends { deep_scan: true } for that
option (deep scan takes precedence server-side) and confirms first,
since deep scan removes stale entries — mirroring the dashboard flow.
Frontend-only; the progress/status handler already drives the bar from
the backend phase ("Deep scan: ...") and the help/docs copy already
described all three modes.
Search results live in an overlay dismissed by an outside-click handler
whose allow-list omitted the floating media player. Clicking the mini
player to open the now-playing modal (or clicking inside that modal)
registered as an outside click and tore the results down, forcing a
re-search.
Add the media player containers (#media-player mini bar and
#np-modal-overlay expanded modal) to the dismiss allow-lists in both the
Search page (search.js) and the global search widget (downloads.js),
which share the same outside-click pattern. Additive change: only adds
exceptions, so every existing dismiss case is unchanged.
Low-risk tidy-up for the full-bleed "exception" pages that aren't carded.
Every page already gets a 40px gutter from .page, but the exception pages were
piling on inconsistent extra padding (library +20px, active-downloads +28/32px,
discover/docs +0) — giving accidental 60 / 68-72 / 40 effective gutters.
Drop the redundant container padding on library and active-downloads so the
single .page 40px gutter is the shared, intentional outer spacing across the
full-width exception pages. discover (centered max-width) and docs (sidebar
layout) keep their functional layout; library's mobile padding override is
unaffected.
Standardize the sync page's outer spacing to match the other pages. Like
settings, its .sync-header and .sync-content-area were siblings directly under
.page (no wrapper) — wrap both in a single .page-shell div so it becomes the
floating card with consistent margin/padding. HTML-only change.
Watch: .sync-content-area uses height:95% (grid) — fine against an auto-height
card, but to be confirmed visually (library's full-height grid was the one
that didn't fit a card).
Add the canonical .tab (bordered rounded-pill filter tab) and .card ("glass"
content card) primitives as the documented design-system standard for new
markup and the React pages — modeled on the cleanest existing looks
(watchlist filter pill; dashboard service/stat card).
Deliberately NOT migrating the existing tab/card components onto them: the
current implementations are visually divergent and JS-coupled (active-state
toggled by class name, cards built in JS), so a blind consolidation risks
subtle regressions. These primitives let new/React code be consistent now;
the legacy components migrate when visually verifiable / in React.
Unused classes -> zero visual change to the current UI.
Migrate the wishlist add-to-wishlist modal buttons onto the shared .btn
primitive: primary -> .btn--primary, secondary -> .btn--secondary, the green
download CTA -> new .btn--download modifier. Added a shared .btn.loading state
(amber pulse, reusing the existing pulse-loading keyframe) since
confirm-add-to-wishlist-btn toggles `loading` via JS (wishlist-tools.js).
Removed the dead .wishlist-modal-btn* rules and re-scoped the mobile
full-width override to `.wishlist-modal-actions .btn`.
Start of the button-consolidation pass (kettui's #1). The app had ~236 button
classes / ~8-10 distinct looks with heavy near-duplication.
Introduce a canonical .btn design-system primitive (base + .btn--primary /
.btn--secondary / .btn--danger), modeled on the dominant existing look
(accent-gradient primary, translucent ghost, semantic danger) and built on the
accent CSS vars. New markup and the React pages should use this; existing
per-page button classes will migrate onto it family by family.
First family migrated: the config/settings modal buttons (.config-modal-btn*,
4 static uses, no JS refs) -> .btn .btn--primary / .btn--secondary. Removed the
now-dead .config-modal-btn* rules and re-scoped its mobile full-width override
to `.config-modal-actions .btn`.
Visible change is minor by design (padding 28->24px, gradient direction
normalized). Proof step for sign-off on the .btn look before rolling wider.
Settings was the one flat page with no single wrapper — its .dashboard-header
and .settings-content sat as siblings directly under .page. Wrap both in a
single .page-shell div so the page becomes a floating card with the header
banner at the top, matching the dashboard structure. HTML-only change (no CSS:
.settings-content keeps its minor `0 4px` inner padding).
Library is intentionally NOT converted — its full-height artist grid + A-Z
jump rail overflow a margin:20px card, so it stays flat as a documented
exception (same category as search/discover/active-downloads).
Convert the playlist-explorer page from a flat padded container to the
.page-shell floating card. Drop its bespoke `padding: 24px 32px`; keep the
full-height flex layout (display:flex / column / min-height:100%) since the
explorer fills the viewport.
Visible change by design. Watch: the full-height min-height:100% inside a
margin:20px card may run slightly tall — to be confirmed visually.
First of the "flat -> card" conversions. The automations list view sat
directly on the page background (.automations-container = bare padding) while
its inner .dashboard-header is the same header dashboard uses. Adopt
.page-shell so the page becomes a floating gradient card structurally
identical to the dashboard (page-shell card > dashboard-header > content).
- Drop .automations-container's bespoke `padding: 20px 24px` (card padding now
from .page-shell); keep the class as the mobile/JS hook.
- Add `page-shell` to the container in markup.
Visible change by design (this page was not previously a card). Mobile keeps
its existing .automations-container padding override.
First step of the page-layout-shell standardization (kettui's UI-consistency
point #1). The dashboard, tools, watchlist and wishlist pages each defined a
byte-identical "card" container (padding 28px 24px 30px, margin 20px, gradient
bg, radius 24px, border + border-top, layered shadow) under four different
class names.
Extract that into a single `.page-shell` primitive (modeled on the canonical
dashboard/stats look) and have the four pages adopt it. Each keeps its bespoke
class for page-specific extras and as a JS/mobile hook:
- dashboard-container: keeps display:flex / column / gap:25px
- watchlist/wishlist-page-container: keep position:relative
- tools-page-container: no extras (box now fully from .page-shell)
Zero visual change: computed styles are identical (declarations relocated, not
altered), and mobile.css overrides still target the retained bespoke classes.
Per-page themed headers (watchlist amber, etc.) are intentionally NOT touched.
The class name is intended for reuse by the React pages too, so the primitive
is shared across both stacks.
Next (wave 2): migrate settings / automations / playlist-explorer / library
onto .page-shell, which snaps their slightly-off spacing to canonical.
The four artist-hero buttons (Artist Radio, Watchlist, Download
Discography, Enhance Quality) had drifted apart visually — different
sizes, weights, radii, hover treatments, and ad-hoc colors. Unify them
on the Download Discography look (the nicest of the set): accent-style
gradient fill, matching border, light-tinted text, compact 7x16 / 12px /
700 sizing, and a translateY(-1px) + colored glow on hover.
To keep them distinguishable, each carries its own hue within that
shared recipe:
- Artist Radio -> violet (#8b5cf6)
- Watchlist -> theme accent (keeps its amber "watching" state)
- Download Discography -> theme accent + shimmer (the primary action)
- Enhance Quality -> cyan (#4fc3f7, its original signature color)
Also:
- Drop the shimmer from the three secondary buttons — four simultaneous
shimmers were distracting; it now marks only the primary action.
- Remove the `margin: 12px 0 4px` on `.discog-download-wrap` (now
`margin: 0; display: inline-flex`) that pushed the discography button
~4px below its siblings in the centered flex row.
- Include Artist Radio in the mobile button sizing override (was missing).
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.
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.
- _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)
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.
- _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.
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.
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.
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.
The sidebar source-group headers (Spotify / Tidal / Qobuz / Deezer /
YouTube / Last.fm Radio / ListenBrainz / iTunes Link / SoulSync
Discovery / Spotify Link) only showed the source name in caps —
the dashboard equalizer + connections panels both render the
actual brand logo, so the sidebar reading as text-only felt
disconnected.
Added a small (18px) circular brand-logo chip to the left of
each source-group title, sourced from the same URLs the
dashboard equalizer avatars use. Dark glass backdrop + accent
ring + drop-shadow on the logo so the chip stays legible
against either light or dark marks; brightness(0) invert(1)
applied to Tidal / Qobuz / iTunes-Link so their dark-foreground
marks render as white silhouettes against the disc (same
recipe the equalizer overrides use). Last.fm's square avatar
PNG clips to a circle via object-fit: cover.
Sources without a publicly available logo (Beatport, file
imports) fall through to no-chip — the <img onerror> swap
hides the broken image so the header still renders cleanly.
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
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).
Last.fm ships a square Twitter avatar; clip it to a circle so the
disc reads as a uniform chip. Tidal / Qobuz / Discogs / Amazon
ship dark-foreground marks that disappear against the dark glass
avatar backdrop — invert to a white silhouette so the logo
actually reads. The per-service accent drop-shadow still applies
so the brand color cue is preserved as a glow around the white
silhouette.
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.
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.
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.
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
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.
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).
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.