mirror of https://github.com/Nezreka/SoulSync.git
dev
main
fix/usenet-album-poll-sab-handoff
fix/quarantine-source-dedup
release/2.5.3
fix/disable-beatport-features
johnbaumb-discover-redesign
1.0
1.1
1.2
1.3
1.4
1.5
1.6
1.7
1.8
1.9
2.0
2.1
2.2
2.3
2.4.0
2.4.1
2.4.2
2.5.0
2.5.1
2.5.2
2.5.3
2.5.4
2.5.5
2.5.6
2.5.7
2.5.9
2.6.0
2.6.1
2.6.2
2.6.3
2.6.4
2.6.5
2.6.6
2.6.7
2.6.8
2.6.9
2.7.0
v0.65
${ noResults }
3413 Commits (2.6.2)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
b470eae0b1
|
Merge pull request #709 from Nezreka/dev
Dev |
2 weeks ago |
|
|
36614e1a4d
|
Merge pull request #708 from Nezreka/fix/community-feedback-mbrainz-mirrored-enhanced
Fix/community feedback mbrainz mirrored enhanced |
2 weeks ago |
|
|
65d7756da2 |
Resolve pre-existing ruff lint errors blocking CI
Five pre-existing lint errors on dev baseline (all introduced May 25-26 before this branch was cut) were blocking CI on this PR. Cleared as courtesy fixes so the merge isn't gated on unrelated tech debt: - web_server.py:22613 — F811 duplicate `urlparse` import inside `_parse_itunes_link_url` (already imported at module top, line 20). Removed from the inline `from urllib.parse import parse_qs, urlparse`; kept `parse_qs` since that one is only used here. - core/listenbrainz_manager.py:746 — S110 silenced with `# noqa: S110 — best-effort lookup, delete proceeds either way`. Matches the existing project convention used in web_server.py:1693, core/watchlist/auto_scan.py:463, core/library_reorganize.py:548. - core/playlists/sources/listenbrainz.py:236 — B905 `zip()` without explicit `strict=`. Added `strict=False` — preserves existing behaviour where `matched` can legitimately be shorter than `match_indices` on partial discover failure. - core/playlists/sources/listenbrainz.py:273 — S110 silenced with `# noqa: S110 — caller falls back to last cached playlist on refresh failure`. - core/playlists/sources/soulsync_discovery.py:105 — S110 silenced with `# noqa: S110 — manager persists last_generation_error on failure; surface existing snapshot`. The existing multi-line comment that already explained the swallow was rolled into the noqa justification so the rule + reason live on one line. Ruff `python -m ruff check .` now passes; 664 discovery + metadata tests still pass. |
2 weeks ago |
|
|
6125ef8834 |
MB rerank: prefer_known_duration is now a score boost, not a tiebreaker
Live smoke against `/api/musicbrainz/search_tracks?track=Coffee+Break&artist=Zeds+Dead` exposed the edge case the tiebreaker implementation couldn't reach: The canonical Zeds Dead "Coffee Break" recording (mbid 6e2d4a70, length 184000ms) lives on the Coffee Break Single release — album_type='single', which carries a 0.85 album_type_weight in `score_track`. A sibling length-less recording (mbid 3b89bf3c) lives on an Album release — album_type='album', weight 1.0. After multiplying by EXACT_ARTIST_BOOST the canonical sat at 1.275 while the length-less sibling sat at 1.5. The previous tiebreaker only kicked in on equal scores, so the length-less album edition wins and the user sees 0:00 first instead of the actionable 3:04 row. Bug reproduced: ordering came out length-less / canonical / Omar-LinX-collab. Switched `prefer_known_duration` to a 1.25x score boost on recordings with non-zero duration_ms. The multiplier is sized above the album-vs-single weight spread (0.176) so length-known recordings can overcome an album-type penalty when scores would otherwise tie on title + artist match, but stays small enough that cover/karaoke penalty (0.05) and variant-tag penalty (0.85) still dominate — a length-known tribute still loses to a length-less canonical. Post-fix live response: 6e2d4a70 (canonical, 184000ms) sits first, 8ec2ce3f (Zeds Dead + Omar LinX collab, 153000ms) second, 3b89bf3c (length-less album edition) third. Verified Björk diacritic fallback path unaffected — `Bjork` + `Army of Me` still cascades strict-empty → bare and returns all 10 Björk recordings. 122 metadata tests pass — the three `prefer_known_duration` cases were designed to pin behaviour, not the specific multiplier value, so they all still pass under the boost implementation: ties promote length-known, relevance still beats length-pref, default-off behaviour unchanged. |
2 weeks ago |
|
|
8dbbf13c61 |
Branch cleanup: lift manual-match helpers, fix length-pref ordering, profile-scope view toggle
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.
|
2 weeks ago |
|
|
b67d13164a |
Library: persist Enhanced / Standard view toggle in localStorage
User feedback: the Enhanced view toggle on the artist detail page reset to Standard on every artist click, so admins who prefer Enhanced had to re-flip the toggle every single time. Persist the choice in localStorage and reapply on every artist navigation + page reload. - `toggleEnhancedView()` writes `soulsync-library-view-mode` to localStorage on every change. - `navigateToArtistDetail()` reads the saved value after the standard reset block runs; if `enhanced` AND `isEnhancedAdmin()` it calls `toggleEnhancedView(true)` after `loadArtistDetailData` kicks off. The brief Standard render is hidden as soon as the toggle flips. - Gated on `isEnhancedAdmin()` so non-admin profiles (which never see the toggle) can't end up with a stale Enhanced preference being applied silently. - Wrapped in try/catch since localStorage is unavailable in some private-browsing modes. No backend change; no DB migration needed. |
2 weeks ago |
|
|
39f582a690 |
Mirrored playlist: stop Playlist Pipeline from reverting manual Fix-popup matches
User reported that manually mapping a mirrored-playlist track via the
Fix popup (either by search or by pasting an MBID) worked end-to-end
once — match saved, library track downloaded — but the next Playlist
Pipeline run flipped the track back to "Provider Changed" and forced
them to re-do the manual map every cycle.
Three independent issues were combining to cause this:
1. Hardcoded `provider: 'spotify'` on manual-fix save
`update_youtube_discovery_match` (the endpoint the Fix popup posts
to, also used by mirrored playlists since the frontend routes
`platform === 'mirrored'` through the YouTube endpoint) always
stamped the cached match as Spotify-provided. The Fix-popup cascade
actually queries the user's primary metadata source first and falls
back to Spotify / Deezer / iTunes / MusicBrainz — so a user on
MusicBrainz primary picking an MB result still had it saved as
`provider: 'spotify'`. The next prepare-discovery call (which
compares cached_provider to the active source) then immediately
classified the match as drifted and pending re-discovery. Fixed by
deriving `match_source` from `spotify_track.get('source')` (every
*_search_tracks endpoint stamps `source` on results) with a fallback
to `_get_active_discovery_source()` for the MBID-paste path (which
uses the lean flat shape that doesn't carry source). `matched_data['source']`
and the mirrored `extra_data['provider']` both now use the derived
value. `match_source` is also recomputed in the cache-save except
handler so the downstream mirrored-DB save still has it.
2. Discovery worker re-queueing manual matches as "incomplete"
`run_playlist_discovery_worker` in `core/discovery/playlist.py`
re-adds any track to `undiscovered_tracks` when its `matched_data`
lacks `track_number` or `album.id` / `album.release_date`. The
check was designed as a legacy-fix backfill for old discoveries
that lost those fields to a Track-dataclass stripping bug. But
manual fixes from the popup are *intentionally* lean — search-
result rows don't include `track_number` (none of the search
endpoints return it), and the MBID-lookup flat shape doesn't
carry `album.id` / `release_date` (the recording lookup returns
only `album.name`). So every manual match looked "incomplete" and
got re-discovered every pipeline run, overwriting the user's pick
with whatever the auto-search ranked first. Manual matches now
short-circuit ahead of the incomplete-data branch.
3. `prepare_mirrored_discovery` ignored the `manual_match` flag
Independent of the provider-stamping fix above, the prepare-
discovery endpoint that powers the mirrored-playlist UI did its
own `cached_provider != current_provider` check and didn't honour
manual_match either. Defence in depth — even if a future code
path stamps the wrong provider on a manual match, the flag now
anchors it as cached. `has_cached` also extended so manual
matches with off-provider stamps still count toward the cached
tally for phase classification.
Tests:
- new `test_manual_match_skipped_even_when_matched_data_incomplete`
in `tests/discovery/test_discovery_playlist.py` pins the worker
short-circuit using a realistic MB-shape matched_data (album dict
without id / release_date, no top-level track_number). 16 existing
tests still green; 848 across discovery / metadata / automation
suites pass.
|
2 weeks ago |
|
|
acc5eb77ea |
Fix popup: anchor artist field in MB search to stop title-collision covers
`/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
|
2 weeks ago |
|
|
4555ff7eb9 |
Wishlist modal: surface most-advanced live phase, not least-complete
The sibling-merge aggregator from
|
2 weeks ago |
|
|
7f751202d2 |
Wishlist modal: merge sibling sub-batches into one status response
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.
|
2 weeks ago |
|
|
c002014f10 |
Wishlist: reify run id + gate cycle toggle on last-sibling completion
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. |
2 weeks ago |
|
|
7832acba31 |
Manual wishlist run: also split into per-album sub-batches
The Phase-1 fix (commit
|
2 weeks ago |
|
|
c3b88e6963 |
Wishlist albums cycle: split into per-album bundle batches
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. |
2 weeks ago |
|
|
85426a210c |
Fix album-bundle downloads landing every track as track 1
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.
|
2 weeks ago |
|
|
f758ae9330 |
Drop `[LB Rolling]` diagnostic logs back to debug
The bulk rolling-mirror ensure path was instrumented with INFO
lines + a WARNING on SELECT failure (commit
|
2 weeks ago |
|
|
80a88a62ac |
Auto-Sync sidebar: improve playlist card readability
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. |
2 weeks ago |
|
|
a8e6432e86 |
SoulSync Discovery tab: open mirror detail modal after refresh
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).
|
2 weeks ago |
|
|
bd91c94f92 |
Add SoulSync Discovery tab to Sync page (Phase 1c.3)
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. |
2 weeks ago |
|
|
5378b726ee |
Debug logging on LB rolling-mirror bulk ensure
Temporary instrumentation — bulk ensure path silently created only one rolling mirror despite multiple known series members existing in the LB cache. Promotes the bulk-ensure summary + per-title match notes to INFO level so the next refresh surfaces in the server log: - ``[LB Rolling] Bulk ensure walking N cached titles for profile X`` - ``[LB Rolling] Title matched series: <title> -> <series_id>`` - ``[LB Rolling] Bulk ensure done — M/N titles matched a series`` Plus the outer ``except`` is bumped from debug to warning so a genuine SELECT failure stops being invisible. Once the root cause is identified the noise can drop back to debug. |
2 weeks ago |
|
|
4dc70b3611 |
Rolling LB mirrors: also fire on skipped + bulk catch-all in cleanup
Two paths were leaving rolling mirror placeholders uncreated: 1. ``_update_playlist`` short-circuits with status "skipped" when the cached track count matches the API result (the smart- comparison fast path). The Phase 1c.2.1 ``_ensure_rolling_series_mirror`` call sat after the short-circuit, so any user whose LB cache was already up-to-date got zero rolling placeholders inserted — their Auto-Sync sidebar showed no ListenBrainz group after refresh. 2. First-time install of the rolling-mirror code on top of an existing LB cache: every per-playlist call goes "skipped" because nothing has changed, so even with fix #1 the user needs a per-playlist trigger to populate. No good. Fix: - ``_update_playlist`` now runs ``_ensure_rolling_series_mirror`` on the skip path too (with an explicit ``conn.commit()`` since the insert needs to land before the connection closes). - ``_cleanup_old_playlists`` gains ``_ensure_rolling_mirrors_from_cache`` — a one-shot bulk pass that walks every cached LB title and ensures the matching rolling mirror exists. Cheap (single SELECT + idempotent INSERT OR IGNORE per row) and catches the first-run + skipped-everything cases. |
2 weeks ago |
|
|
1eadd9a65e |
Pre-create rolling LB series mirrors when LB cache updates
Make the rolling Weekly Jams / Weekly Exploration / Top Discoveries / Top Missed Recordings mirror entries appear in Auto-Sync's sidebar the moment ListenBrainz first publishes any member of the series — without requiring the user to manually discover a per- period card first. Previously the rolling mirror was only created on discovery completion, so users with cached LB playlists but no discovery history saw an empty ListenBrainz group in the Auto-Sync manager and couldn't schedule the rolling entries. - ``_ensure_rolling_series_mirror(cursor, title)`` new helper on ``ListenBrainzManager``: detect_series + ``INSERT OR IGNORE`` the matching ``mirrored_playlists`` row with the synthetic source_playlist_id, the canonical name, and zero tracks. Idempotent — no-op when the rolling mirror already exists or when the title doesn't belong to a series. - ``_update_playlist`` now calls the helper after the cache row is inserted/updated, so every LB refresh that lands a per- period series member guarantees a rolling mirror exists. First Auto-Sync schedule fired against an empty rolling mirror populates tracks through the existing LB adapter + ``_maybe_discover`` hook — synthetic id resolves to the latest cache row, tracks come back with needs_discovery=True, matching engine runs, mirror gets tracks. No extra wiring needed. 236 tests still green. |
2 weeks ago |
|
|
d8cc2f5f01 |
Last.fm radio cache cap: 5 → 10
User-visible behavior: at most 10 mirrored Last.fm Radio rows exist at any time. When the cache prunes the 11th-newest + older lastfm_radio rows, the existing cascade-delete hook (``_cascade_delete_mirrored_for_mbids``) removes their matching ``source='lastfm'`` mirror rows in the same transaction. 5 was too aggressive — users seeding multiple radios in a row were losing earlier downloads' provenance before they had time to act on the tracks. 10 gives a few weeks of breathing room without letting the Mirrored tab balloon. |
2 weeks ago |
|
|
862cedde9d |
Auto-Sync manager: exclude Last.fm Radio mirrors from the schedule board
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. |
2 weeks ago |
|
|
cf5da04439 |
Roll LB Weekly / Top series into single rolling mirrors (Phase 1c.2.1)
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.
|
2 weeks ago |
|
|
e8ee8576a0 |
Fix Last.fm radios mirrored under wrong source
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. |
2 weeks ago |
|
|
bbc950d325 |
Auto-Sync manager: add LB / Last.fm / SoulSync Discovery / iTunes labels
``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. |
2 weeks ago |
|
|
38e35930a9 |
Add Last.fm Radio tab to Sync page (Phase 1c.2)
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.
|
2 weeks ago |
|
|
6198fc37d8 |
LB manager: cascade-delete mirrored rows when LB cache prunes
ListenBrainz auto-rotates the user's "For You" playlists weekly: "Weekly Jams for X, week of 2026-05-25 Mon" gets a fresh MBID every Monday, and the prior week's playlist gets dropped from ListenBrainz's API after ~25 weeks. The LB manager already mirrors that retention policy in ``_cleanup_old_playlists`` (keeps the 25 most-recent per category). The Sync-tab auto-mirror flow, though, created a ``mirrored_playlists`` row for each unique MBID — so the user's Mirrored tab would accumulate 100+ dead Weekly Jams / Weekly Exploration rows per year, each pointing at an LB playlist the cache had already pruned. Fix: when LB manager removes a cached LB playlist (either via the periodic ``_cleanup_old_playlists`` rotation or an explicit ``delete_cached_playlist`` call), also delete the matching ``mirrored_playlists`` row + its tracks. Downloaded tracks stay in the library — only the mirror row + track refs go. - New ``_cascade_delete_mirrored_for_mbids(cursor, mbids, source)`` helper runs in the same transaction as the LB cache delete so the two stay consistent. - ``_cleanup_old_playlists`` now selects ``playlist_mbid`` alongside ``id`` from the stale rows + passes the mbids through the cascade helper before committing. - ``delete_cached_playlist`` looks up the playlist's type first (so it knows whether to target ``source='listenbrainz'`` or ``source='lastfm'`` mirrored rows), then cascades. Cleanup is best-effort: any cascade error logs a warning but doesn't roll back the LB cache delete itself. Losing the cache→mirror link in a rare edge case is preferable to crashing the LB update loop. |
2 weeks ago |
|
|
f521be7720 |
LB Sync tab: fix track counts + auto-mirror on discovery complete
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. |
2 weeks ago |
|
|
969d5ffc1b |
Fix LB Sync tab card styling — dead CSS + ID collision
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.
|
2 weeks ago |
|
|
55583c1db3 |
LB Sync tab cards: live updates parity with Tidal
The initial LB Sync tab ( |
2 weeks ago |
|
|
df31d42b94 |
Fix LB Sync tab card data shape + tone down styling
Two bugs from the initial LB tab commit (
|
2 weeks ago |
|
|
a7053a6061 |
Add ListenBrainz tab to Sync page (Phase 1c.1)
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. |
2 weeks ago |
|
|
246503066b |
Fold provider-matching into PlaylistSource contract (Phase 1b)
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. |
2 weeks ago |
|
|
8c41b05fe8 |
Refactor refresh_mirrored to use unified PlaylistSource registry
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. |
2 weeks ago |
|
|
c5898c3b9b |
Add unified PlaylistSource adapter layer (Phase 0)
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. |
2 weeks ago |
|
|
58d0657fe5
|
Merge pull request #594 from Skowll/telegram_thread_id
Add Telegram thread |
2 weeks ago |
|
|
980576f3a8 |
Sync page: dedicated iTunes Link icon + reorder Qobuz 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. |
2 weeks ago |
|
|
b5755d6307 |
Trust user manual picks past AcoustID verification (#701)
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. |
2 weeks ago |
|
|
85ba93f16f |
Fix album-bundle staging match + wishlist provenance (#700, #698)
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. |
2 weeks ago |
|
|
87516e5b7b |
Restore lost redownloadLibraryAlbum function (#699)
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
|
2 weeks ago |
|
|
718eb0cb10 |
Add iTunes / Apple Music link import tab on Sync page
New iTunes Link tab between Deezer Link and YouTube. Accepts album, track, and playlist URLs from music.apple.com / iTunes. Pulls the tracklist, runs it through the same discovery -> sync -> download pipeline as the other link tabs. Apple Music playlists go through amp-api with a Bearer JWT scraped from the SPA. The legacy meta-tag and inline `"token":"..."` paths are gone in the current music.apple.com SPA, so the extractor now walks the page's `<script src>` list (prioritising index/chunk/main bundles), fetches up to 8 JS bundles, regex-matches JWT-shaped strings, and base64-decodes each payload to confirm it carries Apple media-api claims (`root_https_origin`, or `iss + iat + exp`) before trusting it. Filters out analytics / error-reporter JWTs that also ship in the bundle. Tokens are cached at module scope for 6h behind a threading.Lock so the three-worker discovery executor doesn't thunder-herd Apple on cold start, and amp-api calls go through a single helper that on 401 invalidates the cache, refetches the page, force-refreshes the token, and retries the request once. The playlist fetcher memoises the page HTML for the cache-miss path so we don't refetch it for every paginated `/tracks` page. spotify_public discovery worker accepts the new platform shape so iTunes Link reuses the same matching code path as Deezer Link and Spotify-public. UI bits live in the sync-services.js iTunes Link tab, with platform plumbing through wishlist-tools.js for the multi-source state map. |
2 weeks ago |
|
|
96e6ba0ed7 |
Preserve Navidrome album cover art
Expose Navidrome album coverArt as a Subsonic getCoverArt thumbnail so library refreshes keep a real album-art URL. Preserve existing album thumb_url when an incoming server album has no thumbnail, preventing manual or server-corrected covers from being cleared and later replaced by loose missing-cover searches. Add regression tests for Navidrome album thumbnails and DB thumb preservation. |
2 weeks ago |
|
|
dad1b5109e |
Add _build_library_tag_db_data helper
Extract repeated DB tag payload construction into a new _build_library_tag_db_data(track_data, album_genres) helper and replace in multiple endpoints. The helper builds the metadata dict (title, artist_name, track_artist, album_title, year, genres, track/disc numbers, bpm, track_count, thumb_url) and populates artists_list by splitting track_artist on ';'. Added tests (tests/test_library_tag_payload.py) to verify artists_list creation, genre propagation, thumb_url selection, and fallback behavior when track_artist is missing. This reduces duplication and ensures consistent tag payloads across tag-preview, batch preview, and tag-writing flows. |
2 weeks ago |
|
|
26eeb1e9a1 |
Bump base version to 2.6.2
Update version references for the 2.6.2 release: change the workflow dispatch input description and default to 2.6.2 in .github/workflows/docker-publish.yml, and update the _SOULSYNC_BASE_VERSION constant in web_server.py to 2.6.2 so release metadata and build/version strings reflect the new patch release. |
2 weeks ago |
|
|
a5c23f898e |
Quick Actions: soften animations + smooth flow-line reset
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. |
2 weeks ago |
|
|
47498b88d3 |
Quick Actions: fix Automations flow visibility + add hero playhead
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. |
2 weeks ago |
|
|
82717dec03 |
Redesign Quick Actions as asymmetric bento with signature animations
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. |
2 weeks ago |
|
|
f98c1a5997 |
Fix Auto-Sync modal 'total is not defined' regression
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. |
2 weeks ago |
|
|
d18f45dfec |
Auto-Sync: bulk schedule per source + custom interval columns
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. |
2 weeks ago |