Self-review pass on the prior three commits — kettui-style cleanup
that should have landed first time.
**Length-preference sort ordering (real bug):**
The `search_tracks_with_artist` stable sort that promoted length-known
recordings ran in `core/musicbrainz_search.py`, but the MB endpoint in
`web_server.py:search_musicbrainz_tracks` runs `rerank_tracks` after
it — which re-sorts by relevance score and dropped the length-pref
ordering down to tiebreaker-only. For canonical-same-song MB duplicates
that all score identically the tiebreaker survived, but the
order-of-operations was wrong.
Moved into `rerank_tracks` itself via a new `prefer_known_duration`
flag. Sort key sits between relevance score and the stable-order
tiebreaker so relevance still wins (length only decides ties, never
overrides a higher-relevance match). The MB endpoint opts in via
`prefer_known_duration=True`; Spotify / iTunes / Deezer callers stay
on the default-off path since their search results always include
length. Pinned with three new `TestRerankTracks` cases:
ties-promote-length, relevance-still-wins, default-off-unchanged.
**Route logic lifted to `core/discovery/manual_match.py`:**
Two pieces lived as inline route logic in `web_server.py` — the
`derive_manual_match_provider` fallback chain (payload.source →
active source → 'spotify') used by `update_youtube_discovery_match`,
and the `is_drifted_for_redo` predicate (cached provider differs from
active AND not manual_match) used by `prepare_mirrored_discovery`.
Per kettui's "extract logic from web_server.py, don't AST-parse it"
standard, both helpers now live in `core/discovery/manual_match.py`
with 12 dedicated unit tests covering fallback resolution order,
non-dict payload defenses, manual_match exemption from drift,
absent-provider legacy default, and edge cases.
Side benefits from the lift:
- `match_source` now derived once before the cache-save try block
instead of being duplicated in try + except (the except block existed
only because the original used `match_source` later — pre-computing
killed the duplication).
- `prepare_mirrored_discovery`'s `has_cached` check now reuses
`is_drifted_for_redo` with inverted polarity instead of restating
the field whitelist inline, so a future schema change only has to
land in one place.
- The mirrored-DB persist block now gates on `matched_data is not None`
to avoid a pre-existing latent NameError if the cache-save block
raised before matched_data construction.
**Enhanced toggle localStorage key now profile-scoped:**
`soulsync-library-view-mode` was global — two admin profiles would
share one preference. Wrapped in `_libraryViewModeKey()` which appends
`:${currentProfile.id}` when a profile is loaded, falls back to the
unsuffixed key otherwise (preserves pre-multi-profile saved values).
Tests:
- 12 new in `tests/discovery/test_manual_match.py` pinning both helpers.
- 3 new in `tests/metadata/test_relevance.py` pinning the
`prefer_known_duration` semantics.
- `test_search_tracks_with_artist_prefers_results_with_known_length`
renamed to `_does_not_resort_by_length` since the sort moved out of
this method. 664 tests pass across discovery + metadata suites.
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.
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.
`/api/musicbrainz/search_tracks` powers the Fix popup's auto-search
cascade for users on MusicBrainz as primary. When both track + artist
fields were filled, `search_tracks_with_artist` always took the bare
keyword path (`<track> <artist>` joined as one query string). MB's
recording-search scorer weights title matches far above artist matches,
so for "Coffee Break" + "Zeds Dead" the top results were Emapea / The
Vidalias / West One Orchestra's "Coffee Break" — three unrelated cover-
title collisions ahead of the canonical Zeds Dead recording. The
endpoint's `rerank_tracks` pass can't fix this when the right answer
is below the API's 50-result cutoff.
Both-fields mode now uses a strict field-scoped Lucene query first
(`recording:"<t>" AND artist:"<a>"`) which anchors the artist and
prunes title-collision covers at the source. `min_score=0` because the
field-scoped query is itself precise; rerank still does final ordering.
Bare query stays as the fallback when strict returns nothing — covers
the diacritic / alias cases the original `strict=False` path was added
for ("Bjork" query vs canonical "Björk" artist where Lucene phrase
match never hits the recording).
Single-field mode (track-only or artist-only) is unchanged: still bare-
query directly, since there's no artist value to anchor.
Also stable-sort results to prefer entries with non-zero `duration_ms`.
MB has multiple recordings per song (single release, album release,
remasters, compilations) and not every recording carries length data.
Without the preference sort, the user sees a 0:00 row first while a
sibling recording with the real 3:04 sits two rows below — matches the
report where MBID-paste lookup of the canonical recording (length 3:04)
contradicted the search-result's 0:00 row for the same song.
Tests:
- new `test_search_tracks_with_artist_strict_first_when_both_fields`
pins the strict=True call when both fields present
- new `test_search_tracks_with_artist_falls_back_to_bare_when_strict_empty`
pins the Björk-style fall-through path
- new `test_search_tracks_with_artist_prefers_results_with_known_length`
pins the length-preference sort
- existing `..._keeps_low_score_for_rerank` updated to side_effect so
the bare-fallback path is exercised; behaviour pinned identically
- existing `..._uses_bare_query_mode` renamed + repurposed for strict-
first; old name's behaviour no longer accurate
The sibling-merge aggregator from 7f751202 used "least-complete
phase wins", which made the modal appear frozen during parallel
album bundle downloads. The task table is phase-gated to
downloading/complete/error in downloads.js — so whenever any
sibling was still in album_downloading, the merged phase stayed
there and tasks for the sibling that had advanced past its bundle
never rendered. User reported: both albums downloading on slskd,
modal blank until one completes fully.
Flip the rule: surface the most-advanced live phase so the modal
renders task progress as soon as any sibling reaches it. The
all-siblings-in-album_downloading case still surfaces
album_downloading (bundle progress UI is correct there); error
stays sticky.
Updated WHATS_NEW under 2.6.3 to describe the corrected behavior.
Two new tests pin the regression:
- downloading + album_downloading → downloading
- album_downloading + album_downloading → album_downloading
Phase 1c.2.1 splits each wishlist run across multiple
``download_batches`` rows (per-album bundle dispatch). The
download-missing modal opens against the original batch_id
allocated by ``start_manual_wishlist_download_batch`` /
``process_wishlist_automatically``. Pre-fix that batch_id was
just one sibling among N, so the modal went stale as soon as the
primary sub-batch finished — subsequent albums downloaded fine
but no live status reached the UI.
Fix: backend merges every sibling sub-batch's tasks +
analysis_results into the response keyed under the originally-
requested batch_id. Modal sees one unified view of the whole run
without knowing about the split. Frontend untouched.
Architecture (Kettui standards):
- ``core/downloads/wishlist_aggregator.py`` — pure
``merge_wishlist_run_status(primary, siblings)`` helper.
No IO, no runtime state, no globals. Lifted out of
``status.py`` so the merge contract can be pinned via unit
tests without standing up the live ``download_batches`` /
``download_tasks`` state.
- ``core/downloads/status.py``'s ``build_batched_status`` now
pre-indexes ``download_batches`` by ``wishlist_run_id`` inside
the existing ``tasks_lock`` snapshot, then runs the merge
helper whenever a requested batch has a sibling.
Merge rules pinned by 12 tests:
- ``track_index`` re-indexed globally 0..N-1 across the merged
``analysis_results`` so the modal's ``data-track-index`` DOM
keys don't collide between siblings. Tasks' ``track_index``
follows the same remap so the analysis-results ↔ tasks
cross-reference stays intact.
- ``task_id`` is uuid per task — no collision concern.
- Phase: error is sticky; otherwise the LEAST-complete
pre-terminal phase wins (analysis < album_downloading <
downloading). All-complete returns ``complete``; mixed
complete + active returns ``downloading`` so the modal stays
alive until every sibling lands.
- ``album_bundle``: picks whichever sibling currently has an
active bundle download (state in
``{searching, downloading, downloading_release, staging}``).
Falls back to the first non-empty bundle so a completed run
still shows a progress bar.
- ``analysis_progress`` summed across siblings.
- ``active_count`` summed; ``max_concurrent`` keeps primary's
value as the representative.
- ``playlist_id`` + ``playlist_name`` preserved from the primary
(the row the modal originally opened against).
Legacy single-batch wishlist runs (no ``wishlist_run_id`` on the
batch) skip the merge entirely — passthrough. Back-compat by
absence.
1108 tests across downloads + wishlist + automation + imports +
playlist-sources + lb-series suites green. 12 new aggregator
tests pin the merge contract.
Closes the open UX gap from the Phase 1c.2.1 ship — modal now
tracks every sibling sub-batch's progress for the full duration
of the wishlist run.
Phase 1c.2.1 splits each wishlist invocation into per-album sub-
batches so the album-bundle dispatch can engage once per album.
Side effect: the completion handler ``finalize_auto_wishlist_completion``
ran end-of-run logic (cycle toggle + state reset + automation
event emit) once per BATCH, so a 2-album run fired the cycle
toggle twice + emitted two ``wishlist_processing_completed``
events. The cycle landed at the right value either way but the
state machine had become per-batch instead of per-run.
Fix: reify "wishlist run" as a first-class concept via a shared
``wishlist_run_id`` UUID. Generated once per wishlist invocation
in both the auto- and manual-wishlist paths, stamped on every
sub-batch row in ``download_batches``.
``finalize_auto_wishlist_completion`` now reads the completing
batch's ``wishlist_run_id`` and, when present, scans
``download_batches`` for siblings still in pre-terminal phases.
If any sibling is still active, the per-batch summary records
but the cycle toggle + state reset + automation emit are
deferred. Only the last completing sibling fires the run-level
finalization. Legacy single-batch runs (no run_id field) keep
their toggle-immediately behavior — back-compat by absence.
The run_id also lays groundwork for frontend grouping (one
logical row in the Downloads view per wishlist run instead of N
sibling rows), but that UX work is deferred.
3 new tests in ``test_processing.py`` pin: defer-when-siblings-
active, toggle-when-last-sibling-done, back-compat-without-run_id.
1 new assertion in ``test_automation.py`` confirms all sub-batches
of one auto-wishlist invocation share the same run_id. 309 tests
across wishlist + automation suites green.
Notes: dispatch concurrency unchanged — sub-batches still run via
the shared download worker pool. Slskd serializes per-uploader at
its own layer (same uploader = automatic queue, different
uploaders = legit parallel), so SoulSync-side serial enforcement
would duplicate work the right layer already handles.
Auto-wishlist's "albums" cycle used to dump every missing album
track into one batch and run per-track Soulseek / Prowlarr searches
for each (~50 searches for a typical scan). The album-bundle
dispatch (introduced in 2.5.9 for explicit album downloads) was
gated on ``is_album_download=True`` + populated
``album_context``/``artist_context``, none of which the wishlist
batch ever set — so wishlist runs always took the per-track flow
even when 12 missing tracks all belonged to the same album.
Fix: split wishlist albums-cycle tracks into per-album sub-batches
at submission time. Each sub-batch carries its own album context,
trips the existing dispatch gate, and engages one slskd / torrent
/ usenet album-bundle search per album. Tracks the helper can't
group (no album metadata, no artist) fall through to a residual
per-track batch.
- New ``core/wishlist/album_grouping.py``:
``group_wishlist_tracks_by_album(tracks)`` returns
``WishlistGroupingResult(album_groups, residual_tracks)``.
Pure function — extracts album_id (or name-normalized fallback)
+ primary artist + album context from each track's nested
spotify_data, buckets, and threshold-promotes. Independent of
runtime state so it can be unit-tested without the wishlist
executor.
- ``core/wishlist/processing.py``: when ``current_cycle ==
'albums'``, run the grouping helper, submit one batch per album
with ``is_album_download=True`` + the group's album/artist
context, then a single residual batch for orphans. Singles
cycle path unchanged.
- 9 new tests in ``test_album_grouping.py`` pin the bucketing
contract (empty / single album / multi album / orphan / threshold
/ nested payloads / no-id fallback / no artist).
- 2 new tests in ``test_automation.py`` exercise the per-album
split end-to-end through ``process_wishlist_automatically``:
multi-album batch → two sub-batches each with album context;
mixed orphan + real album → one bundle batch + one residual.
1099 tests across wishlist + imports + downloads + automation +
playlist-sources + staging-provenance + track-number-repair
suites green. WHATS_NEW entry added under 2.6.3.
Now when an auto-wishlist scan finds 12 missing tracks from
Ryoto's "Cha-La Head-Cha-La", it runs ONE slskd / Prowlarr
album-bundle search for the release instead of 12 per-track
searches.
Soulseek album-bundle (and any other release-staging path) was
importing every file with ``track_number=1`` because the staging
metadata reader used the auto-import-flavor filename extractor:
``extract_track_number_from_filename`` returns 1 when the basename
has no ``NN -`` prefix. That's the right default for the loose
auto-import flow (single file in, no upstream metadata to lean
on), but completely wrong for staging-cache reads:
- For an album-bundle download the user has authoritative track
numbers in the Spotify track list flowing through to
``track_info`` for each task.
- ``try_staging_match`` in ``core/downloads/staging.py`` was
meant to use those numbers when the staged file's own metadata
doesn't have them.
- But the staging cache populated ``track_number=1`` for every
untagged bare-title file (e.g. ``Cha-La Head-Cha-La.flac``), the
album-bundle resolution branch reads file-side first, sees 1,
and short-circuits the rest of the chain.
Fix:
- New ``extract_explicit_track_number`` in
``core/imports/filename.py`` — strict variant that returns
``0`` when no numeric prefix is visible. Docstring explicitly
contrasts with the legacy 1-defaulting helper so future
callers pick the right one.
- ``read_staging_file_metadata`` in ``core/imports/staging.py``
now uses the strict extractor, so the staging file dict
carries ``track_number=0`` ("unknown") instead of ``1`` for
untagged bare-title files.
- The legacy ``extract_track_number_from_filename`` keeps its
1-default behavior so auto-import callers + the post-process
template fallbacks are unchanged; it's now implemented in
terms of the strict variant.
- Tag-side parsing also tightened to require ``> 0`` before
overriding the filename-derived value.
3 new tests pin the contracts:
- ``test_extract_explicit_track_number_returns_zero_when_no_prefix``
- ``test_read_staging_file_metadata_returns_zero_track_when_unknown``
- existing ``test_extract_track_number_from_filename_handles_common_patterns``
now explicitly comments why bare filenames keep returning 1.
758 tests across imports + downloads + repair + staging-provenance
suites green. WHATS_NEW entry added under 2.6.3.
Reported against an album-bundle download of Ryoto's
"Cha-La Head-Cha-La" where slskd staged 15 untagged FLAC files
named after the song titles only.
The mirrored-playlist cards in the Auto-Sync schedule modal's
sidebar were truncating long names with ellipsis on a single line
+ rendering meta info at 10px, which made entries like
"Top Missed Recordings of 2024 for Nezreka" or "ListenBrainz
Weekly Exploration" unreadable.
- Name wraps to multiple lines instead of ellipsis-truncating
(sidebar is narrow; truncation hid critical disambiguating
text like the year / week / username).
- Bumped name 12px → 13px, meta 10px → 11px with brighter color
(0.4 → 0.55 alpha).
- Bumped card padding 10px/12px → 12px/14px + spacing 6px → 8px
so multi-line entries have breathing room.
- Pinned the leading status dot to the first text line via
``margin-top`` so multi-line names flow underneath rather than
push the dot off-center.
Phase 1c.3 left the click flow at "card shows 'mirrored' + toast",
which felt incomplete — Tidal / LB / Last.fm all open a follow-up
modal after their discovery flow so the user can act on the
results (sync to server playlist, queue downloads, etc.). SoulSync
Discovery skips the discovery phase (tracks pre-matched), so the
natural analog is the mirrored-playlist detail modal — same one
the Mirrored tab opens when you click a row.
- Inline ``fetch('/api/mirror-playlist', ...)`` in place of the
fire-and-forget ``mirrorPlaylist`` helper so we can capture
the returned ``playlist_id`` from the response.
- After successful mirror creation, call
``openMirroredPlaylistModal(playlist_id)`` (exposed by
stats-automations.js) to surface the tracks view.
The card itself keeps the ``♪ N / ✓ N / mirrored`` progress text
so a quick second click can re-refresh without re-opening the
modal each time (just re-runs the generator + re-upserts the
mirror).
Last of the three unified-tab phases. Surfaces the user's
persisted personalized playlists (decade mixes, hidden gems,
popular picks, daily mixes, discovery shuffle, etc.) on the
Sync page so they participate in the mirrored-playlist +
Auto-Sync pipeline like every other source.
Different shape from the LB / Last.fm tabs:
- Tracks already carry Spotify / iTunes / Deezer IDs (matched
at generation time from the discovery pool), so there is NO
MB-style "needs discovery" hop. The mirror is created with
fully-populated ``matched_data`` JSON inline, downstream
consumers (sync, wishlist) see canonical extra_data
immediately.
- Click on a card runs the kind's generator
(``POST /api/personalized/playlist/<kind>/<variant>/refresh``)
+ grabs the fresh track snapshot + mirrors under a synthetic
id of the form ``ssd_<kind>_<variant>`` (e.g. ``ssd_decade_1980s``,
``ssd_hidden_gems``). Re-clicks UPSERT the same row, so the
Auto-Sync schedule survives every refresh.
- Sub-tabs / archive concept don't apply here — each personalized
playlist is already a singleton per (profile, kind, variant);
the manager handles its own rotation.
New file: ``webui/static/sync-soulsync-discovery.js`` (~210 lines).
``initializeSyncPage`` learns a new tab branch. CSS adds
``soulsync-discovery-icon`` (star SVG, teal ``#14b8a6``) +
``.soulsync-discovery-playlist-card`` joins the unified card
selector group with a matching teal accent.
WHATS_NEW entry added under 2.6.3.
236 tests still green; no Python paths touched.
Last.fm Radio playlists are seed-track-specific similar-tracks
snapshots — they don't update on the Last.fm side once generated,
so scheduling one for auto-refresh would just re-discover the
same 25 tracks every interval. The mirror still exists (visible
in the Mirrored tab) so the user can pull the downloads, but it
doesn't belong on the schedule board.
``autoSyncCanSchedulePlaylist`` now rejects ``source='lastfm'``
alongside the existing ``file`` + ``beatport`` exclusions.
Cosmetic-only on the frontend; backend mirror creation +
Mirrored tab listing are unchanged.
ListenBrainz publishes "Weekly Jams for X" / "Weekly Exploration
for X" with a fresh MBID every week, and "Top Discoveries of YYYY
for X" / "Top Missed Recordings of YYYY for X" with a fresh MBID
every year. Auto-mirroring those per-period yielded one mirrored-
playlist row per week/year — useless for Auto-Sync schedules
because the underlying LB playlist never updates, only a brand new
playlist replaces it. The user accumulates 100+ dead Weekly Jams
rows per year if they discover regularly.
This commit collapses each family into a single ROLLING mirror
keyed by a synthetic ``source_playlist_id`` (e.g.
``lb_weekly_jams_Nezreka``). Each new period UPSERTs into the same
row, so the user gets one stable Auto-Sync schedule per series
that automatically picks up the latest period's tracks on every
refresh. Non-series LB playlists (user-created, collaborative,
Last.fm radios for a specific seed) continue to mirror under
their per-playlist MBID as before. Per-period LB playlists are
still visible + usable on the LB Sync tab — only the mirror layer
collapses.
- ``core/playlists/lb_series.py`` (new) — series-detect helper
with regex patterns + canonical-name + LIKE-pattern template
for each known LB family. Exposes
``detect_series(title)``, ``is_series_synthetic_id(id)``, and
``list_series_synthetic_ids()`` so both the JS auto-mirror hook
and the LB adapter can speak the same language.
- ``GET /api/listenbrainz/series-detect?title=...`` — thin HTTP
shim around ``detect_series`` so the auto-mirror JS doesn't
duplicate the regex.
- ``ListenBrainzPlaylistSource.get_playlist`` now recognizes
synthetic series ids — it queries the LB cache for the newest
cache row whose title matches the series' LIKE pattern and
resolves to that row's MBID before fetching tracks. The mirror's
meta keeps the synthetic id so refreshes always re-resolve to
the latest period.
- ``_mirrorListenBrainzAfterDiscovery`` (sync-services.js) calls
the new detect endpoint when discovery completes — if a match
comes back it swaps the per-period MBID for the synthetic id +
the canonical name. Existing Last.fm radio routing logic stays
intact (Last.fm radios aren't a series).
- ``ListenBrainzManager._cleanup_per_period_series_mirrors`` —
one-shot consolidation sweeper runs in ``_cleanup_old_playlists``
+ deletes any legacy per-period mirror rows so the consolidated
rolling mirror is the only one left. Idempotent — only matches
per-period titles ("Weekly Jams for ..., week of ...") and never
the canonical rolling-mirror titles ("ListenBrainz Weekly
Jams").
- 11 new tests pin the detector + synthetic-id helpers; 236 total
across adapter + automation + lb-series suites green.
Two-part fix for Last.fm Radio playlists showing up in the
ListenBrainz group of the Auto-Sync manager + Mirrored tab
instead of their own Last.fm group:
1. **Mirror-creation hook** (sync-services.js): the
``_mirrorListenBrainzAfterDiscovery`` helper hardcoded
``source='listenbrainz'`` on every auto-mirror call, even for
Last.fm Radio playlists (which share the same MB-track shape +
discovery worker but should land under ``source='lastfm'``).
``save_lastfm_radio_playlist`` always prefixes the playlist name
with "Last.fm Radio: <seed>", so the helper now keys on that
prefix to pick the right mirror source + owner fallback. Going
forward, new Last.fm radios mirror correctly the moment
discovery completes.
2. **Backfill** (listenbrainz_manager.py): legacy mirror rows
created before the fix above are stuck under
``source='listenbrainz'``. Added
``_retag_misrouted_lastfm_radio_mirrors`` to ``_cleanup_old_playlists``
so the next LB refresh re-tags any row whose name starts with
"Last.fm Radio:" but is still on ``source='listenbrainz'``.
Idempotent — UPDATE only matches misrouted rows.
``autoSyncSourceLabel`` was missing entries for the post-Phase-0
sources, so any mirrored playlists with ``source='listenbrainz'``
or ``'lastfm'`` rendered their raw lowercase identifier in the
sidebar's group heading instead of a friendly brand label. Added
the four newer sources. Also added ``itunes_link`` which the iTunes
link tab has been able to create for a few releases now.
Cosmetic only — the existing ``autoSyncCanSchedulePlaylist`` gate
already accepts everything except ``file`` and ``beatport``, so
these sources were always schedulable; the group heading just had
no human label.
Sibling to the ListenBrainz Sync tab from Phase 1c.1. Last.fm Radio
playlists already live in the same ``listenbrainz_playlists`` table
as LB ones (``playlist_type='lastfm_radio'``) and run through the
same MB-track discovery worker, so this tab is intentionally thin
— list + render + delegate. Card click hands straight off to the
LB Sync-tab click handler since the downstream modal + state
machine are identical.
- ``webui/index.html``: new ``<button data-tab="lastfm-sync">``
+ tab content container between the LB tab and the existing
Import / Mirrored tabs. Plus a ``<script>`` tag for the new
module.
- ``webui/static/sync-lastfm.js`` (new): ``loadLastfmSyncPlaylists``
hits the existing ``/api/discover/listenbrainz/lastfm-radio``
endpoint, ``renderLastfmSyncPlaylists`` mirrors the LB card
shape with a ``📻`` icon + a ``.lastfm-playlist-card`` brand
class, click handler forwards to
``handleListenBrainzSyncCardClick``.
- ``webui/static/sync-listenbrainz.js``: the shared 500ms refresh
loop now iterates LB + Last.fm cards in one pass and treats
either tab as "active" for liveness. No second loop needed.
- ``webui/static/sync-services.js``: new tab-activation branch in
``initializeSyncPage`` mirrors the LB pattern.
- ``webui/static/style.css``: ``.lastfm-icon`` SVG (Last.fm "as"
logo, red), and ``.lastfm-playlist-card`` joins the unified
card selector group with the Last.fm-red accent
(``rgba(213, 16, 7, ...)``).
- ``web_server.py``: the lastfm-radio endpoint now includes
``track_count`` in its JSPF payload (same fix as the LB
endpoints last commit).
- WHATS_NEW entry added under 2.6.3.
Mirrors created from Last.fm radios participate in the same auto-
trim Phase 1c.1's cascade-delete hook does — when the LB manager
rotates a stale ``lastfm_radio`` row out of its 5-most-recent
window, the matching ``source='lastfm'`` mirror row is removed
along with it. Library files stay on disk.
225 tests across adapter + automation suites still green; this
commit adds no Python paths to test.
Two follow-ups to the LB Sync tab work:
1. **Track counts all showed 0.** The
``/api/discover/listenbrainz/*`` endpoints assemble a JSPF-shaped
payload but drop the cached ``track_count`` field from the
underlying ``listenbrainz_playlists`` row — the JSON the frontend
sees only carries ``title`` / ``creator`` / ``annotation`` / an
empty ``track`` array. The Discover-page renderer worked around
it by hard-coding a fallback of 50; the Sync-page renderer had
no such fallback, so every card displayed "0 tracks". Backend
now includes ``track_count`` directly in each playlist payload
(it's already in the cached row) so any frontend can render an
accurate count without resorting to a default. JS still falls
back to ``annotation.track_count`` and then ``track.length`` for
older callers.
2. **LB playlists never landed in Mirrored Playlists.** The
existing ``/api/listenbrainz/sync/start/<mbid>`` endpoint runs
the converted Spotify tracks through ``_run_sync_task`` — i.e.
it pushes them to the user's media server (Plex / Jellyfin /
Navidrome / SoulSync) as a server-side playlist. It does NOT
call ``database.mirror_playlist``. So no ``mirrored_playlists``
row gets created and the playlist can't be picked up by the
Auto-Sync scheduler, can't show up under the Mirrored tab,
doesn't participate in pipeline automations — the whole point
of the Sync-tab unification.
Tidal works because Tidal mirrors on tab load with raw tracks
then enriches via discovery. LB tracks only have provider IDs
*after* discovery, so the equivalent moment for LB is "discovery
complete". Added ``_mirrorListenBrainzAfterDiscovery(mbid)``
that pulls the matched ``spotify_data`` out of
``discovery_results`` and posts to ``/api/mirror-playlist`` via
the existing ``mirrorPlaylist`` helper. Hooked into both the
WebSocket and HTTP-poll completion handlers of
``startListenBrainzDiscoveryPolling``. UPSERT-keyed on (source,
source_playlist_id, profile_id), so re-running discovery is a
safe no-op refresh.
Result: any LB playlist the user discovers (from either the
Discover page or the new Sync tab) now lands in
``mirrored_playlists`` with ``source='listenbrainz'`` + matched
tracks carrying canonical ``extra_data`` JSON, ready for the
Auto-Sync refresh + sync pipeline wired up in Phase 1a + 1b.
Two interacting bugs that left LB Sync-tab cards rendering with a
solid orange gradient background instead of the dark glass style
every other Sync-page card uses:
1. **Duplicate element id** ``listenbrainz-tab-content``: the new
Sync-tab content div reused the same id the Discover page's
pre-existing LB section already owned. Two elements with the
same id is invalid HTML, and ``getElementById`` in the refresh
loop was hitting the Sync version first while ``initialize
SyncPage``'s ``${tabId}-tab-content`` lookup could race against
it. Renamed the Sync-page tab id + ``data-tab`` attribute to
``listenbrainz-sync`` (matches the existing ``${tabId}-tab-
content`` convention so the lookup becomes
``listenbrainz-sync-tab-content``). Discover-page LB tab
keeps its original id untouched.
2. **Dead ``.listenbrainz-playlist-card`` rule** at style.css
L36155 painting a solid ``linear-gradient(#eb743b → #d26230)``
over the card. That class was orphaned — no JS or HTML
instantiated it before Phase 1c.1 — but it sat at higher
source order than my unified ``.youtube-playlist-card,
.tidal-playlist-card, ...`` rule, so the bare-class selector
won the cascade and overwrote the dark glass background.
Also removed the matching dead ``.listenbrainz-icon { font-
size: 48px }`` rule and its local ``@keyframes pulse`` copy
(the keyframes are defined in four other live blocks).
3. **Missing LB selectors in unified inner-element rules**:
``.listenbrainz-playlist-card`` was only added to the OUTER
card selector group in the first pass — the inner
``.playlist-card-icon`` / ``.playlist-card-content`` /
``.playlist-card-name`` / ``.playlist-card-info`` /
``.playlist-card-action-btn`` (+ ::before, :hover, :disabled)
selector groups were left out, so the inner elements lost all
their styling. Bulk-added LB to every group so the card
inherits the full glass shell the other sources get, with a
brand-orange ``rgba(235, 116, 59, ...)`` accent matching the
Tidal / Deezer / Spotify-public pattern.
The initial LB Sync tab (a7053a60 + df31d42b) rendered cards once
and never updated them as the discovery / sync flow progressed —
phase text stayed "Ready to discover", action button kept saying
"Discover", no progress counts. Tidal's cards by contrast update
phase + button + progress live throughout the entire flow because
Tidal's polling code calls ``updateTidalCardPhase`` /
``updateTidalCardProgress`` at every state transition.
Rather than patch the existing LB polling in sync-services.js with
parallel update hooks at every transition (4–6 injection points
across discovery + sync paths), this commit takes the lighter
route: a single 500ms refresh loop that reads the canonical
``listenbrainzPlaylistStates`` dict the polling code already
owns and updates the on-screen cards from it. The loop only ticks
while the LB tab is the active Sync tab — auto-stops the moment
the user switches away.
- ``_refreshOneLbSyncCard(card)`` — updates phase text + color
(via the shared ``getPhaseText`` / ``getPhaseColor`` helpers),
action button label (via ``getActionButtonText``), and the
per-card progress text in the same shape Tidal uses:
``♪ <total> / ✓ <matched> / ✗ <failed> / <percent>%``. Switches
to the sync-progress payload during syncing / sync_complete.
- ``_startLbSyncCardRefreshLoop`` — idempotent; kicked on tab
activation (in ``initializeSyncPage``) and right after the initial
``renderListenBrainzSyncPlaylists`` render if the tab is already
visible.
- Added the ``.playlist-card-progress`` slot to the LB card
template; hidden initially when phase=fresh, populated by the
refresh loop once discovery/sync begins.
Two bugs from the initial LB tab commit (a7053a60):
1. **All cards showed identical "ListenBrainz Playlist / 0 tracks"
defaults.** The /api/discover/listenbrainz/* endpoints wrap each
entry in JSPF shape — ``{playlist: {identifier, title, creator,
annotation, track}}`` — but renderListenBrainzSyncPlaylists was
reading ``p.title`` / ``p.creator`` / ``p.track_count`` directly,
so every field hit its fallback. Now unwraps the inner playlist
object, extracts the MBID from the identifier URL via
``.split('/').pop()`` (matches buildListenBrainzPlaylistsHtml on
the Discover page), and reads track_count from
``annotation.track_count`` with a fallback to ``track.length``.
2. **The tab looked too orange.** The initial commit gave the
sub-tabs a saturated orange surface that clashed with the rest
of the app, and the new ``.listenbrainz-playlist-card`` class
wasn't in the unified ``.youtube-playlist-card,
.tidal-playlist-card, ...`` selector group — so the card lost
its dark glass base and inherited only my override CSS. Two
fixes:
- Added ``.listenbrainz-playlist-card`` to the unified card
selector group (base + ::before + hover + hover::before + icon)
so it picks up the dark glass background. The brand accent
stripe + hover glow use ``rgba(235, 116, 59, ...)`` matching
the other source cards' subtle accent pattern.
- Sub-tabs reverted to a neutral dark surface (``rgba(255,
255, 255, 0.04)``) with the orange used only as a thin
accent on the active state's border + inset shadow.
- Dropped the ``.refresh-button.listenbrainz`` override so the
refresh button falls back to the user's chosen accent like
the Spotify / Qobuz refresh buttons do.
First user-facing slice of the Discover-to-Sync unification. Adds a
ListenBrainz tab on the Sync page alongside Tidal / Qobuz /
Spotify Public / Beatport / etc. so users can mirror + auto-sync
ListenBrainz playlists from the same surface as every other source,
without detouring through the Discover page.
The Discover-page LB flow already owns all the heavy lifting
(state machine, discovery polling, sync → mirror creation). This
commit adds the Sync-page entry point only — list cached LB
playlists, render cards, pre-fetch tracks on click, hand off to
``openDownloadModalForListenBrainzPlaylist``. Zero backend changes.
- ``webui/index.html``: new ``<button data-tab="listenbrainz">`` +
tab content container with "For You / My Playlists /
Collaborative" sub-tabs and a refresh button.
- ``webui/static/sync-listenbrainz.js`` (new): ``loadListenBrainz
SyncPlaylists`` fetches all three LB cache categories in parallel,
``renderListenBrainzSyncPlaylists`` renders cards in the standard
``.youtube-playlist-card`` shell with the existing phase-state
helpers (so card colors / button text stay consistent with Tidal
/ Qobuz / etc.). Click handler populates the
``listenbrainzTracksCache`` from
``/api/discover/listenbrainz/playlist/<mbid>`` if not already
primed, then defers to the shared modal opener.
- ``webui/static/sync-services.js``: one new branch in
``initializeSyncPage`` to lazy-load the tab on first activation.
- ``webui/static/style.css``: ``.listenbrainz-icon`` SVG (orange
play-button in circle for inactive, white for active),
``.listenbrainz-sub-tab-btn`` styling for the sub-tabs,
``.refresh-button.listenbrainz`` accent.
- ``webui/static/helper.js``: WHATS_NEW entry under 2.6.3.
Auth-not-connected case is surfaced as a friendly placeholder
pointing the user at Settings → Connections instead of an empty
list.
Adds ``discover_tracks(tracks) -> List[NormalizedTrack]`` to the
PlaylistSource interface. Sources whose tracks already carry
provider IDs (Spotify, Tidal, Qobuz, YouTube, Deezer, Spotify
public, iTunes link, SoulSync Discovery) inherit a no-op default;
ListenBrainz + Last.fm override to run the matching engine.
This closes the last gap before LB / Last.fm / SoulSync Discovery
can land as Sync-page mirror sources: the refresh handler now
calls ``source.discover_tracks(...)`` whenever a source returns
tracks with ``needs_discovery=True``, so mirrored LB rows arrive
already discovered + ready for the sync pipeline. Previously, LB
playlists ran through a separate state-machine worker tied to the
Discover-page UI, with results stored in ``discovery_cache``
instead of ``mirrored_playlist_tracks.extra_data``.
Changes:
- ``core/playlists/sources/base.py`` — PlaylistSource switches from
Protocol to ABC so a concrete default for ``discover_tracks``
can live on the base class. The four real-work methods stay
``@abstractmethod``; instantiating an adapter that forgets one
fails loudly at construction.
- ``core/discovery/matching.py`` (new) — pure ``match_mb_tracks``
helper that runs Strategy-1-only matching-engine queries against
Spotify (primary) or iTunes (fallback). No state machine, no
discovery-cache writes, no wing-it stub — that richer flow stays
in ``core/discovery/listenbrainz.py`` for the Discover-page UI.
- ``ListenBrainzPlaylistSource`` + ``LastFMPlaylistSource`` take
an optional ``discover_callable`` constructor arg. Last.fm reuses
the LB implementation since the track shape is identical.
- ``bootstrap.build_playlist_source_registry`` accepts a
``discover_callable`` kwarg and wires it into LB + Last.fm
adapters.
- ``web_server.py`` boot constructs the discovery callable from the
existing matching engine + ``_discovery_score_candidates`` +
Spotify / iTunes clients, passes through to the registry.
- ``refresh_mirrored.py`` adds a small ``_maybe_discover`` helper
that calls ``source.discover_tracks(...)`` between fetch and
``to_mirror_track_dict`` projection — only fires when at least
one track has ``needs_discovery=True``, so the normal Spotify /
Tidal / etc. refresh path stays a zero-cost pass-through.
Tests:
- 5 new adapter tests: default no-op pass-through, LB discovery
with mixed matches/misses, LB no-callable fallback, Last.fm
shares the LB implementation, mirror-dict spotify_hint emit.
- 1 new automation test: end-to-end LB refresh with a stub
discover_callable proves the matched_data lands in
``mirror_playlist_tracks.extra_data`` after the registry
refresh + discover hop.
225 tests across adapter + automation suites green.
Phase 1a of the Discover-to-Sync unification. The mirrored-playlist
refresh handler used to branch per-source through a ~190-line
if/elif chain (Spotify, Spotify public, Deezer, Tidal, YouTube).
Each branch hand-built its own ``extra_data`` JSON for the matched-
data block. With every new source we considered for Sync-page mirror
support (ListenBrainz, Last.fm radio, SoulSync Discovery, iTunes
link), that chain would have grown a new elif.
This commit lifts the per-source logic into the existing adapter
layer and collapses the dispatch to a registry lookup:
- ``core/playlists/sources/deezer.py`` — new adapter so the registry
covers every source the refresh handler previously branched on.
- ``core/playlists/sources/bootstrap.py`` — single helper that builds
a populated registry from injected getter callables. Both
``web_server.py`` boot and the automation test fixtures call it,
so the two construction paths can't drift.
- ``core/playlists/sources/base.py`` — ``to_mirror_track_dict``
projection helper centralises the NormalizedTrack → DB-row
conversion (including the discovered/matched_data and
spotify_hint extra_data shapes the downstream sync + wishlist
consumers already expect).
- Spotify adapter now populates ``extra['discovered']`` + an
``extra['matched_data']`` block when fetching via the authed API,
so Spotify mirrors keep landing pre-discovered (matches the
pre-refactor contract pinned by
``test_spotify_refresh_writes_to_db``).
- Spotify-public adapter populates ``extra['spotify_hint']`` so the
discovery worker can skip its search step and jump straight to
enrichment for the known track ID.
- All artist-name fields now project to first-artist-only across
every adapter — matches the pre-refactor mirror_playlist DB shape
(``t.artists[0]``).
``refresh_mirrored.py`` shrinks ~190 → ~80 lines and keeps:
- the file/beatport unrefreshable-source filter,
- URL extraction from ``description`` via ``require_refresh_url``
for spotify_public + youtube,
- the Spotify-public → authed-Spotify fallback when the user is
signed in (handler-level branch, not in any adapter),
- the Tidal-not-authenticated soft-skip log (skip, not error),
- existing-extra_data preservation across refreshes,
- the ``playlist_changed`` automation event emit on track-set delta.
Test scaffolding:
- ``_build_deps`` in ``tests/automation/test_handlers_playlist.py``
now builds a default registry from the passed clients via
``build_playlist_source_registry``, so existing refresh tests
exercise the same path without per-test changes. New tests cover
Tidal-not-authed soft-skip, Deezer refresh writes plain tracks,
YouTube refresh reads URL from description, and Spotify-public
uses authed Spotify when signed in.
- 4 new adapter tests for Deezer projection +
``to_mirror_track_dict`` (minimal track, Spotify matched_data,
Spotify-public spotify_hint).
- ``playlist_source_registry`` field on ``AutomationDeps`` defaults
to ``None`` so the other 5 automation test files (which don't
exercise refresh_mirrored) keep working unchanged.
220 tests across automation + adapter suites green.
Groundwork for unifying Discover-page playlists (ListenBrainz, Last.fm
radio, SoulSync Discovery) with Sync-page playlists (Spotify, Tidal,
Qobuz, YouTube, Spotify public, iTunes link). All nine sources now
expose the same `PlaylistSource` Protocol so callers stop having to
branch per-source.
This commit only adds the abstraction — no dispatch sites collapse to
the registry yet, no DB or UI changes. Adapters wrap existing clients
via injected getter callables to avoid eager imports of web_server.py
globals.
- core/playlists/sources/base.py — PlaylistMeta, NormalizedTrack,
PlaylistDetail dataclasses + PlaylistSource Protocol with
supports_listing / supports_refresh / requires_auth capability
flags. needs_discovery flag on NormalizedTrack marks tracks that
carry raw MB metadata (LB, Last.fm) vs tracks already matched to a
provider ID (everything else).
- core/playlists/sources/registry.py — thread-safe lazy-factory
registry with instance caching + re-register invalidation.
- nine adapters in core/playlists/sources/ wrapping SpotifyClient,
TidalClient, QobuzClient, spotify_public_scraper, the YouTube +
iTunes-link parsers (via injected callables), ListenBrainzManager,
Last.fm radio rows in the ListenBrainz cache, and
PersonalizedPlaylistManager.
- tests/test_playlist_sources_adapters.py — 18 tests covering each
adapter's field projection with fake backing clients, plus
registry lazy-construct + cache + re-register invalidation.
Phase 1 will collapse refresh_mirrored.py's per-source if/elif chain
to a registry lookup and surface ListenBrainz as a Sync-page tab.
The iTunes Link tab was reusing the generic `import-file-icon` (a
blue document glyph), which read as "import a file" rather than
"iTunes / Apple Music link". Added a dedicated `.itunes-icon`
inline-SVG matching the iTunes 11+ / Apple Music aesthetic —
pink-red circle with a white double-stem note glyph — and switched
the tab button to use it. Stays consistent with the rest of the
tab icons in the file (all inline data URIs, no external fetches).
Also moved the Qobuz tab from between Deezer and Deezer Link to
between Tidal and Deezer, so the Deezer / Deezer Link pair sits
adjacent and the lossless-streaming services (Tidal / Qobuz) group
naturally. Updated the Qobuz Playlist Sync modal-section feature
line to drop the now-stale "between Deezer and Deezer Link"
position claim.
When a task failed AcoustID verification and got quarantined, opening
the candidates modal and manually picking a different file would just
re-quarantine it. The manual-pick path through
`_attempt_download_with_candidates` ran full post-processing with no
quarantine bypass — so if the alternate file disagreed with AcoustID's
stored metadata too (common for live versions, remasters, regional
title differences, fingerprint coverage gaps) the file landed right
back in quarantine. User got stuck in the loop.
The Approve button on quarantined rows already handles the "I want
this exact file" case via `_skip_quarantine_check='all'`. The
candidates modal handles the "I want a different file" case — same
user intent, opposite direction, but the bypass plumbing didn't carry
through.
`/api/downloads/task/<id>/download-candidate` already sets
`task['_user_manual_pick'] = True`. `attempt_download_with_candidates`
now reads that flag under tasks_lock alongside `used_sources` and,
when set, injects `_skip_quarantine_check='acoustid'` plus
`_user_manual_pick=True` into the stored `matched_downloads_context`
entry. The acoustid-only scope is deliberate: integrity + bit-depth
gates still run because those check the new file's actual condition
(corruption, sample rate) rather than its identity — only the
metadata-mismatch gate is the user-override case.
Auto-search picks (the normal task-worker path) leave the flag unset
and continue to run full AcoustID verification, preserving the
existing safety net for non-user-initiated downloads.
Tests:
- positive: manual-pick task → stored context has
`_skip_quarantine_check='acoustid'` and `_user_manual_pick=True`
- negative: auto-search task → stored context has neither key,
AcoustID still runs as before
Full suite 3976 pass.
Root cause (#700): the Soulseek album-bundle path downloads whole
releases into a private staging dir, then per-track workers claim
those files via the staging-match shortcut. When slskd files arrived
without ID3 tags (common for FLAC rips), the staging cache fell back
to the filename stem as the title — and stems shaped like
"Artist - Album - 03 - Title" could not clear the 0.80 title-
similarity threshold against the clean Spotify track name. Every
track in the album went not_found, the batch ended "failed" in the
Downloads UI with an empty queue, and the bundle-downloaded files
just sat unused in staging.
Fix: in _staging_title_variants, add a trailing-title variant by
extracting the segments after a bare track-number block (e.g. "03")
between " - " delimiters. Conservative — only fires when a clear
digit segment is present, so real song titles with dashes like
"Hold Me - Live" are left intact. Generated as an additional variant
alongside the existing raw/compacted/feat-stripped/bonus-stripped
forms, so behavior on already-matching files is unchanged.
Downstream (#698): the album-bundle staging miss pushed every failed
track to the wishlist labelled as a playlist track, and a couple of
fallback paths in ensure_wishlist_track_format and the slskd-result
reconstruction hardcoded album_type='single' / total_tracks=1 on the
stored album dict. On wishlist requeue the path builder saw
album_type='single' and routed the download through single_path,
dumping the file in the Singles tree even though it belonged to an
album. (Running Reorganize would fix it because the DB album linkage
was still correct, but the file landed in the wrong place first.)
Fixes:
- new resolve_wishlist_source_type_for_batch() returns 'album' for
is_album_download batches; wishlist_failed.py now calls it instead
of hardcoding 'playlist'
- build_wishlist_source_context() threads album_context /
artist_context / is_album_download from the batch into the wishlist
row so future requeue logic has authoritative routing data
- the non-dict-album fallback in ensure_wishlist_track_format and
the slskd-result reconstruction default album_type='album' (and
total_tracks=0 = unknown) instead of lying with 'single'/1; the
existing setdefault chain handles dict-shaped album data unchanged
Tests:
- 2 staging-match tests pin the new tail-extraction behavior against
a realistic untagged slskd stem, plus a negative test that confirms
a dash-in-title without a digit segment still does NOT extract a
variant
- 2 payload tests pin the album_type='album' default for both
fallback paths
- 4 processing tests pin resolve_wishlist_source_type_for_batch()
and the album-context threading in build_wishlist_source_context()
3974 pass; no behavioural change on already-working flows.
The Redownload button on the enhanced artist-view album row was
calling redownloadLibraryAlbum(album, artistName, btn), but the
function body was dropped from the source tree when commit a66c4d06
split the 78K-line script.js into 17 domain modules. The onclick
threw ReferenceError silently — no toast, no log, no popup, no
visible failure for the user.
Function restored verbatim from a66c4d06~1:webui/static/script.js
into library.js next to deleteLibraryAlbum, since it depends on
artistDetailPageState and the existing
openDownloadMissingModalForArtistAlbum / registerArtistDownload
helpers in shared-helpers.js.
New iTunes Link tab between Deezer Link and YouTube. Accepts album,
track, and playlist URLs from music.apple.com / iTunes. Pulls the
tracklist, runs it through the same discovery -> sync -> download
pipeline as the other link tabs.
Apple Music playlists go through amp-api with a Bearer JWT scraped
from the SPA. The legacy meta-tag and inline `"token":"..."` paths
are gone in the current music.apple.com SPA, so the extractor now
walks the page's `<script src>` list (prioritising index/chunk/main
bundles), fetches up to 8 JS bundles, regex-matches JWT-shaped
strings, and base64-decodes each payload to confirm it carries
Apple media-api claims (`root_https_origin`, or `iss + iat + exp`)
before trusting it. Filters out analytics / error-reporter JWTs that
also ship in the bundle.
Tokens are cached at module scope for 6h behind a threading.Lock so
the three-worker discovery executor doesn't thunder-herd Apple on
cold start, and amp-api calls go through a single helper that on
401 invalidates the cache, refetches the page, force-refreshes the
token, and retries the request once. The playlist fetcher memoises
the page HTML for the cache-miss path so we don't refetch it for
every paginated `/tracks` page.
spotify_public discovery worker accepts the new platform shape so
iTunes Link reuses the same matching code path as Deezer Link and
Spotify-public. UI bits live in the sync-services.js iTunes Link
tab, with platform plumbing through wishlist-tools.js for the
multi-source state map.
Auto-Sync: equalizer cycle slowed 1.6s -> 3.2s, amplitude swing
tightened (0.4-1.0x of base height -> 0.55-0.85x) so the bars
breathe instead of slamming. Playhead duration slowed 5.5s -> 9s
and the line was thinned + given a softer accent color (rgba 0.7
instead of full light) and a smaller drop-shadow. Playhead now
fades in over the first 10% and fades out over the last 15% so it
glides on and off rather than appearing at the edge.
Automations: the flow line was using a background-position sweep
that snapped from end to start each loop — visible as a reset jump
every cycle. Rewrote the sweep as a pseudo-element with its own
translateX + opacity animation: fades in at 15%, runs across, fades
out before snapping back. Node pulse + line sweep both run on the
same 3.2s cycle now so the three nodes and two lines stay in
phase. Node animation delays adjusted to evenly stagger across the
new cycle length.
Two tweaks based on usage feedback.
Automations flow was anchored at \`right: -8%\` which pushed the
trigger->action->notify chain off the right edge of the minor tile.
Repositioned to fill the bottom of the tile with left/right inset
matching the tile padding, and bumped the base opacity from 0.25 to
0.45 so the chips are actually visible without hovering. Connecting
lines now have a 60%-wide bright accent sweep that travels
left-to-right along each segment in sync with the node pulses, so
the flow reads as a signal propagating through the chain rather
than three nodes blinking in place.
Auto-Sync hero gets a vertical accent playhead that scrolls
left-to-right across the equalizer bars on a 5.5s loop — a
now-playing scrubber overlay that adds horizontal motion to the
existing vertical bar pulse. Drop-shadow filter gives it a soft
glow as it passes over each bar. prefers-reduced-motion disables
both the playhead and the new line sweep.
Auto-Sync hero on the left (spans both rows), Tools + Automations
stacked on the right. Each tile gets a CSS-only ambient animation
that visually represents what that section does — no more three
identical rectangles.
Auto-Sync (hero, 2 rows tall): 20-bar live equalizer animates along
the bottom edge with per-bar offsets so it reads as a real audio
waveform. Foreground has a live status pulse dot + accent kicker,
big 56px icon, large title, description, and a CTA bar separated
by a hairline rule.
Tools (top-right): an oversized gear icon rotates slowly off the
right edge as a watermark. Hover speeds it up (28s -> 12s) and
brightens the tint.
Automations (bottom-right): three nodes connected by gradient lines
pulse in sequence, mimicking trigger -> action -> notify flow. Each
node glows + halos on its phase.
Card recipe (gradient body, top accent stripe, accent border on
hover, multi-layer shadow) is the same library-status-card vocab
the rest of the dashboard already uses. Container query
(container-type: inline-size) drives every dimension via
clamp(min, Ncqw + base, max) so padding, text, icon, and animation
sizes scale with the actual card width — no overflow on narrow
dashboards. Single-column stack at <=560px.
prefers-reduced-motion disables all three signature animations.
Refactor introduced when adding the history filter dropped the
`const total = _autoSyncScheduleState.runHistoryTotal || 0;` line at
the top of populateAutoSyncHistoryList, but line 705's load-more
footer still referenced `total`. ReferenceError bubbled to the
refresh-modal catch and the modal rendered the generic 'Could not
load schedule data' error state instead of the schedule board.
Two upgrades to the schedule board:
Bulk schedule. Each source group in the sidebar gets a small "Bulk"
button next to the title. Clicking it opens a popover with the same
ten standard buckets plus "Custom interval…" (prompts for hours) and
"Unschedule all". Picking a bucket POSTs/PUTs the schedule for every
schedulable playlist in that source. Result toast aggregates ok/fail
counts. Big quality-of-life for "I want every Spotify playlist
weekly" without 30 individual drags.
Custom interval columns. The board's column set is no longer the
hardcoded `AUTO_SYNC_BUCKETS` list — it's the union of those plus any
hour values currently in use by playlist_schedules. A 6h or 36h
schedule (created via the bulk custom prompt, or hand-edited in the
Automations page) now renders as its own dashed-border column instead
of silently disappearing from the board because it didn't match a
standard bucket. Standard columns still render solid; custom ones get
a "custom" eyebrow + dashed border so they're visually distinct.
Six small UX additions on the Playlist Auto-Sync manager:
- Sidebar gets a "Filter playlists…" search input. Re-renders only
the schedule panel on input so focus is preserved while typing.
- Scheduled cards show a red `!` badge + red border tint when the
last three pipeline runs failed (yellow `⚠` if at least one of the
last few failed). Surfaces chronically broken schedules visually
instead of leaving them indistinguishable from healthy ones.
- Run History tab title shows a red error count badge when there are
failed runs in the loaded window.
- Run History tab body gains All / Errors / Completed filter pills
with per-bucket counts.
- Load-more button at the bottom of the history tab pulls another
50 entries (capped at 500).
- "Run pipeline again" button in the expanded detail of each history
card re-triggers that playlist's pipeline directly.
Also dropped the "Discovered: completed" result pill — `tracks_discovered`
in the result payload is a status string, not a count, and the same
data is already in the before/after stats grid above.
Three problems wrapped into one pass on the Playlist Auto-Sync surface:
1. Visual: the manager modal had its own vibe (radial gradient, pill
tabs, sky-blue chrome) that didn't line up with the rest of the
app. Reworked the modal shell, KPI summary, live pipeline monitor,
tab bar, schedule board sidebar, and column cards to use the
standard SoulSync patterns — gradient `#1a1a1a → #121212`,
accent-tinted 1px border, 20px radius, underline tabs, dense dark
card pattern that Automations + Library pages already use. Modal
now uses near-full screen so there's room for the schedule board
without horizontal scroll pain. Run history cards followed the
same path: slim horizontal row mirroring `.automation-card` plus
an expanded detail that mirrors the Automations run-history modal
(stats-grid + facts row + result pills + log section).
2. Hang: the previous SQL fix for the run-history "in library" count
added `COLLATE NOCASE` on the join columns of `tracks` and
`artists`. SQLite can't use `idx_artists_name` or `idx_tracks_title`
when the comparison collation doesn't match the column collation,
so the join did a full table scan per mirrored playlist track.
~18s per playlist × 30 playlists = `/api/mirrored-playlists` hung
indefinitely and the modal stayed at "Loading schedule…" forever.
Switched the join back to case-sensitive equality (~6ms per
playlist, 3000× faster). Spotify names canonicalize to the same
form as library imports so the recall loss is in the rounding
error of pure case-only mismatches.
3. Slowness: even after the hang fix, each modal open spent ~1.5s
gathering per-playlist status counts. The endpoint looped
`get_mirrored_playlist_status_counts(playlist_id)` per row, which
opened a fresh SQLite connection + PRAGMA setup each time. Added
`get_all_mirrored_playlist_status_counts(profile_id)` which
returns counts for every mirrored playlist owned by the active
profile in 4 batched `GROUP BY` queries over a single connection.
Modal load dropped to ~280ms.
Also fixed: `tracks.artist` reference in `get_mirrored_playlist_status_counts`
that never worked since the schema went relational — the query threw
"no such column", got swallowed by the try/except, and the in-library
count silently defaulted to 0 on every playlist. Rewired to join
through `artists`.
`get_mirrored_playlist_status_counts` (single-playlist) kept for
callers that still want it, but the modal endpoint uses the batched
version.
Render playlist pipeline history rows with DOM construction instead of per-card HTML strings so malformed payload values cannot collapse the row into an empty bordered shell. Add a visible per-row render fallback for bad history records while preserving the expandable detail panel.
Flatten playlist pipeline history cards into a stable header, flow, preview, and detail structure so the run history does not collapse into broken line rows. Keep explicit expand bindings and preserve the richer detail payload rendering.
Bind run history card expand interactions after the modal renders instead of relying on inline handlers, and reshape the run cards into a clearer two-row layout with controlled metadata, preview chips, and roomier expanded detail padding.
Make playlist pipeline run history cards clickable and keyboard-accessible, with expanded detail sections for summary stats, timeline, before/after snapshots, result payload fields, and future run logs. Refresh the card styling so the expanded state remains responsive inside the Auto-Sync modal.
Bring Auto-Sync automation and run-history cards closer to the Automations page pattern with status dots, flow chips, compact metadata, and denser run preview details.
Normalize sparse playlist pipeline history rows before rendering and add a visible fallback for empty entries so the Run History tab cannot collapse into unreadable divider lines.
Render playlist pipeline history as visible run cards with fallback summaries, preview chips, metadata, Details controls, and an explicit empty result message for sparse payloads.
Give placed playlist cards a dedicated content wrapper, full-width action row, wider board columns, and defensive wrapping so titles, timing badges, and Run now controls stay inside card bounds.
Compact the inactive pipeline monitor, widen schedule columns, increase board gutters, and rework scheduled playlist cards so Run now and remove actions no longer crowd playlist text.
Persist per-playlist pipeline run snapshots from the shared playlist pipeline, expose a history API, and upgrade the Auto-Sync modal with live pipeline monitoring, Run now controls, and a runs-style history tab.
Update the dashboard Quick Actions tile to use the shared accent color variables for lane glow, icon chips, borders, hover states, and keyboard focus while keeping the three-destination launcher responsive.
Replace the old Tools CTA with a unified three-lane dashboard launcher for Tools, Auto-Sync, and Automations, using restrained glass/accent styling and responsive stacked behavior.