Commit graph

3379 commits

Author SHA1 Message Date
Broque Thomas
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.
2026-05-26 15:49:49 -07:00
Broque Thomas
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.
2026-05-26 15:40:57 -07:00
Broque Thomas
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.
2026-05-26 15:33:51 -07:00
Broque Thomas
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.
2026-05-26 15:24:23 -07:00
Broque Thomas
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.
2026-05-26 15:12:56 -07:00
Broque Thomas
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.
2026-05-26 14:48:45 -07:00
Broque Thomas
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.
2026-05-26 14:41:57 -07:00
Broque Thomas
55583c1db3 LB Sync tab cards: live updates parity with Tidal
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.
2026-05-26 14:32:48 -07:00
Broque Thomas
df31d42b94 Fix LB Sync tab card data shape + tone down styling
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.
2026-05-26 14:25:01 -07:00
Broque Thomas
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.
2026-05-26 14:17:44 -07:00
Broque Thomas
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.
2026-05-26 13:07:01 -07:00
Broque Thomas
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.
2026-05-26 12:52:39 -07:00
Broque Thomas
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.
2026-05-26 12:22:09 -07:00
BoulderBadgeDad
58d0657fe5
Merge pull request #594 from Skowll/telegram_thread_id
Add Telegram thread
2026-05-26 11:47:08 -07:00
Broque Thomas
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.
2026-05-26 11:32:38 -07:00
Broque Thomas
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.
2026-05-26 09:56:49 -07:00
Broque Thomas
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.
2026-05-26 07:12:49 -07:00
Broque Thomas
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 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.
2026-05-25 23:22:47 -07:00
Broque Thomas
718eb0cb10 Add iTunes / Apple Music link import tab on Sync page
New iTunes Link tab between Deezer Link and YouTube. Accepts album,
track, and playlist URLs from music.apple.com / iTunes. Pulls the
tracklist, runs it through the same discovery -> sync -> download
pipeline as the other link tabs.

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

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

spotify_public discovery worker accepts the new platform shape so
iTunes Link reuses the same matching code path as Deezer Link and
Spotify-public. UI bits live in the sync-services.js iTunes Link
tab, with platform plumbing through wishlist-tools.js for the
multi-source state map.
2026-05-25 22:32:18 -07:00
Broque Thomas
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.
2026-05-25 19:51:38 -07:00
Broque Thomas
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.
2026-05-25 18:48:45 -07:00
Broque Thomas
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.
2026-05-25 18:13:14 -07:00
Broque Thomas
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.
2026-05-25 16:09:46 -07:00
Broque Thomas
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.
2026-05-25 16:00:09 -07:00
Broque Thomas
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.
2026-05-25 15:49:38 -07:00
Broque Thomas
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.
2026-05-25 15:12:31 -07:00
Broque Thomas
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.
2026-05-25 14:54:30 -07:00
Broque Thomas
d668bf4e6d Auto-Sync manager: filter, failure indicators, history filters
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.
2026-05-25 14:22:56 -07:00
Broque Thomas
dfdc6c6277 Restyle Auto-Sync manager and fix loading regressions
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.
2026-05-25 12:24:48 -07:00
Broque Thomas
8f72cc3113 Harden auto-sync history row rendering
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.
2026-05-25 09:06:43 -07:00
Broque Thomas
4aec5584e1 Simplify auto-sync history card layout
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.
2026-05-25 08:34:37 -07:00
Broque Thomas
81ad3079b4 Fix auto-sync history card expansion
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.
2026-05-25 08:28:36 -07:00
Broque Thomas
e86e74d640 Expand auto-sync run history cards
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.
2026-05-25 08:24:47 -07:00
Broque Thomas
60b346aa33 Standardize auto-sync modal cards
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.
2026-05-25 08:19:05 -07:00
Broque Thomas
119e8f1599 Harden auto-sync history row rendering
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.
2026-05-25 07:59:27 -07:00
Broque Thomas
06c76bbcaf Make auto-sync run history readable
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.
2026-05-25 07:55:52 -07:00
Broque Thomas
9eb7a49c24 Fix auto-sync scheduled card layout
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.
2026-05-25 07:46:45 -07:00
Broque Thomas
d8d8e0bcb5 Refine auto-sync modal spacing
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.
2026-05-25 07:44:35 -07:00
Broque Thomas
efdcde1892 Add playlist auto-sync run history
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.
2026-05-25 00:55:55 -07:00
BoulderBadgeDad
5f62152acb
Merge pull request #697 from Nezreka/feat/playlist-auto-sync
Feat/playlist auto sync
2026-05-25 00:40:40 -07:00
Broque Thomas
f402badac9 Align dashboard actions with accent theme
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.
2026-05-25 00:38:00 -07:00
Broque Thomas
f67fff22b4 Redesign dashboard quick actions tile
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.
2026-05-25 00:36:11 -07:00
Broque Thomas
a65ba7e6a3 Add node:test contract for auto-sync.js helpers
Cin review: no JS tests covered the autoSync* helpers, so the
timezone fix shipped without a regression test in the layer where the
bug actually lived. New `tests/static/test_auto_sync.mjs` runs under
`node --test` (built-in runner, no extra deps) and pins:

- `autoSyncTriggerForHours` / `autoSyncHoursFromTrigger` round-trip
  for 1h, 4h, 12h, 24h, 48h, 168h. Catches off-by-one in the day vs
  hour conversion that backs the schedule board's drag-drop.
- `autoSyncBucketLabel` / `autoSyncIntervalLabel` formatting +
  pluralization.
- `autoSyncSourceLabel` known + unknown + falsy.
- Predicates (`autoSyncCanSchedulePlaylist`,
  `autoSyncIsPipelineAutomation`, `autoSyncPlaylistIdFromAutomation`,
  `autoSyncIsScheduleOwned`) including the `owned_by`-flag /
  legacy-name-prefix split from the previous commit.
- `buildAutoSyncScheduleState` partitions board-owned schedules from
  custom pipelines correctly.
- `autoSyncNextRunLabel` parses the naive UTC timestamp as UTC, not
  local — exactly the regression that took an hour to diagnose this
  session. Includes a past-time check ("due now") and a multi-day
  case.
- `getMirroredSourceRef` source_ref/description-URL/source_playlist_id
  resolution order.

Cross-realm note: vm-sandbox return values fail `deepStrictEqual`
against host-realm objects even when shape matches, so a small
`deepShapeEqual` helper round-trips through JSON for structural
comparison. The `_autoParseUTC` stub mirrors the real implementation
in stats-automations.js so the timezone test exercises both files end
to end.

`tests/test_auto_sync_js.py` is the pytest shim — shells out to
`node --test` and surfaces failures inline. Skips cleanly when node
isn't on PATH or is older than 22, matching the existing
discover-section-controller test pattern.

Also updated SPLIT_MODULES in tests/test_script_split_integrity.py to
include the new auto-sync.js — the onclick-coverage check was failing
because `openAutoSyncScheduleModal` (referenced from index.html via
the Sync page button) now lives in a module the integrity scanner
wasn't searching.

39 new JS test cases, all green via `node --test` and via the pytest
wrapper.
2026-05-25 00:01:34 -07:00
Broque Thomas
449a26e56b Extract Auto-Sync into webui/static/auto-sync.js
Cin review: stats-automations.js had ~600 lines of new Auto-Sync code
piled into an already-large shared file. Moved into its own module:

- New webui/static/auto-sync.js holds:
  - Schedule board state (`AUTO_SYNC_BUCKETS`, `_autoSyncScheduleState`,
    `_autoSyncActiveTab`, `mirroredPipelinePollers`)
  - All `autoSync*` functions (trigger conversion, render panels,
    drag/drop, save/unschedule, schedule modal lifecycle)
  - Mirrored-playlist pipeline helpers (`runMirroredPlaylistPipeline`,
    `pollMirroredPipelineStatus`, `applyMirroredPipelineState`,
    `parseMirroredPipelineResponse`, `editMirroredSourceRef`,
    `getMirroredSourceRef`)
- index.html loads auto-sync.js immediately after stats-automations.js
  so the older `renderMirroredCard` path can keep reaching these
  globals through the window namespace.
- stats-automations.js drops 567 lines and gains a one-line breadcrumb
  pointing at the new file.

No behavior changes — every function moved verbatim. Globals stay in
the same window namespace, so the still-resident `renderMirroredCard`
keeps calling `runMirroredPlaylistPipeline` / `editMirroredSourceRef`
/ `mirroredPipelinePollers` exactly as before.

Both files pass `node --check`. Full Python suite still green.
2026-05-24 23:46:08 -07:00
Broque Thomas
9b086c5a65 Add owned_by column for Auto-Sync schedule ownership
The Auto-Sync schedule board was detecting its own automations by
checking `group_name === 'Playlist Auto-Sync' || name.startsWith('Auto-Sync:')`.
That's fragile — renaming the row from the Automations page silently
hands ownership back to the read-only Automation Pipelines tab and the
board stops managing it.

This commit replaces the string convention with an explicit
`automations.owned_by` TEXT column:

- Migration `_add_automation_owned_by_column` adds the column and
  backfills `'auto_sync'` for existing rows that match the legacy
  `group_name`/`name`-prefix pattern, so users running the migration
  don't lose their schedules.
- `database.create_automation` and `database.update_automation` accept
  `owned_by` (the latter via its `allowed` kwarg set).
- `core/automation/api.py` forwards `owned_by` on both POST and PUT.
  Missing field is left as None, preserving today's behavior for every
  caller that doesn't opt in.
- The Auto-Sync schedule board posts `owned_by: 'auto_sync'` and the
  detection helper now prefers that signal, falling back to the legacy
  name/group convention so any hand-rolled rows still show up.

Tests: three new cases in `tests/automation/test_automation_api.py`
covering create-with-owned-by, create-without (defaults to None), and
update set/clear. The fake DB grew the matching kwarg.
2026-05-24 23:40:22 -07:00
Broque Thomas
feb6778af4 Address Cin review: extract helpers, indexed pool fetch, tidy nits
Three changes folded into one perf+cleanup pass:

1. Indexed fast path for the per-artist pool fetch. The previous
   `search_tracks(artist=name)` call hit
   `unidecode_lower(artists.name) LIKE ?`, a function-in-WHERE that
   can't use `idx_artists_name`. New `MusicDatabase.get_artist_tracks_indexed`
   does a two-step lookup: exact-name match (indexed) plus a
   case-insensitive fallback, then `tracks WHERE artist_id IN (...)`
   via `idx_tracks_artist_id`. Drops per-artist fetch from seconds to
   milliseconds for the common case. The sync helper falls back to
   the old LIKE-based `search_tracks` only when the indexed lookup
   finds nothing, preserving diacritic recall and `tracks.track_artist`
   feature-artist matches with zero regression.

2. Public text-normalization helper. Lifted the body of
   `MusicDatabase._normalize_for_comparison` into
   `core/text/normalize.py:normalize_for_comparison` so callers outside
   the database layer (matching engine, sync pool, future import-side
   comparisons) don't reach across the module boundary into a
   leading-underscore "private" method. The DB method now delegates,
   so existing internal call sites stay untouched. Sync's lazy pool
   now imports the public helper.

3. Artist-name walker extracted. `_artist_name` at module level in
   `services/sync_service.py` replaces two near-identical inline
   str-or-dict-or-fallback walkers (one in `sync_playlist`, one in
   `_find_track_in_media_server`). Returns `''` for None instead of
   the literal string `'None'`.

Plus three small tidies from the same review:

- `_POOL_FETCH_LIMIT = 10000` constant in place of the literal at the
  pool-fetch call site.
- Trimmed the verbose docstring + comment block on the pool helper.
- Set-intersection predicate for the trigger-shape reset in
  `core/automation/api.py` instead of a two-line `or` chain.

Also removed the duplicate `_get_active_media_client()` call at
sync_service.py:212/214 — pre-existing wart that was sitting in the
same block I was editing.

Tests: 21 new tests across `tests/database/`, `tests/sync/`, and
`tests/text/`, plus updates to the existing pool tests to cover the
new fast/fallback split. Full suite stays green (3953 passing).
2026-05-24 23:33:55 -07:00
Broque Thomas
687bb0ca2c Add tests for next_run reset and lazy candidate pool
`tests/automation/test_automation_api.py` gains three update_automation
tests covering the schedule-shape reset:
- trigger_config change blanks next_run
- trigger_type change blanks next_run
- non-trigger field (name) leaves next_run alone

`tests/sync/test_sync_candidate_pool.py` is new — nine tests for the
lazy artist track pool in PlaylistSyncService:
- candidate_pool=None disables pooling and skips the DB call
- first lookup for an artist fetches and caches
- second lookup for the same artist reuses the cache (zero DB calls)
- empty result still cached so the next call short-circuits without SQL
- defensive None return coerced to []
- search_tracks exception returns None and does NOT poison the cache
- pool key is normalized so casing variants share a single fetch
- different artists get separate pool entries
- server_source plumbing survives the trip to search_tracks

All assertions go through fakes / MagicMock — no real DB, no
web_server.py import, no AST-parsing.
2026-05-24 22:58:48 -07:00
Broque Thomas
871feb3997 Speed up playlist sync with a lazy per-artist track pool
Syncing a playlist where most tracks weren't in the library was burning
~30 SQL queries per missed track. `services/sync_service.py` walked
each Spotify track through `check_track_exists` with no
`candidate_tracks`, hitting the legacy title-variation × artist-variation
grid in `database/music_database.py:6041-6069` for every miss. The
`sync_match_cache` only covered matches, so misses re-paid the full
lookup cost every sync. A 30-track playlist with a 30% match rate
(Discover Weekly was 9/30 in the test run) was taking ~4m14s, almost
entirely in the matching phase.

`check_track_exists` already accepts a `candidate_tracks` kwarg that
skips the SQL widening and scores against an in-memory list (the
batched path at `music_database.py:6031`, originally added for artist
discography iteration). The sync service just wasn't using it.

This commit wires that path in via a lazy per-artist pool:

- `sync_playlist` creates an empty `candidate_pool` dict and passes it
  to each `_find_track_in_media_server` call.
- `_get_or_fetch_artist_candidates` runs SQL for an artist only on the
  first track that needs them — playlists where every track is already
  in `sync_match_cache` pay zero pool cost (no upfront delay).
- Subsequent misses for the same artist hit the memoized list and skip
  the per-variation SQL grid.
- Artists with no library tracks still get a cached empty list, which
  triggers the batched path's instant short-circuit instead of falling
  into the SQL widening.
- Any pool fetch failure returns None so the caller falls through to
  the original per-track SQL loop, so the worst case is the old
  behavior, never a regression.

On a 30-track / 25-unique-artist playlist with a cold cache the SQL
fan-out drops from ~900 queries to ~25; with a warm cache it drops to
zero (no pool fetches at all). Applies to every entry point that goes
through `sync_playlist`: manual sync, auto-sync schedules, the
`playlist_pipeline` automation action, and the Sync All button.
2026-05-24 22:43:32 -07:00
Broque Thomas
dc4d157944 Fix Auto-Sync next-run countdown and theme its modal
The Playlist Auto-Sync schedule board was showing "next in 8h" on every
card regardless of the configured interval. Root cause: backend stores
next_run as a naive UTC string ("2026-05-25 05:00:00") and the new
auto-sync renderer was parsing it with plain `new Date(...)`, which
treats unmarked timestamps as local time. On Pacific time that offsets
the displayed countdown by ~8 hours. Auto-Sync now routes through the
existing `_autoParseUTC` helper that the rest of the Automations page
already uses, so countdowns line up with the wall clock.

A separate correctness fix in the automation update API: when a PUT
changes `trigger_type` or `trigger_config`, the stored `next_run` is
now blanked before the engine reschedules. Previously the scheduler's
restart-survival path would preserve a stale future timestamp from the
prior interval, so dragging a playlist from the 8h column to the 1h
column kept firing at the old 8h mark. Boot-time restart behavior is
unchanged — only user-driven schedule changes reset the clock.

Modal restyle: the Auto-Sync manager's hardcoded sky-blue palette is
replaced with `var(--accent-rgb)` everywhere so the modal honors the
user's chosen accent color. Tinted glow on the modal border, tabbed
header active state, scheduled-playlist chips, scrollbars, and a new
drag-over highlight on columns all follow the accent theme. The
column drag-over state is wired through new ondragleave handling so
the highlight clears reliably when leaving a column.
2026-05-24 22:01:21 -07:00
Broque Thomas
9a8e7d02a7 Make auto-sync schedule modal responsive
Constrain Auto-Sync columns inside the modal with per-column vertical scrolling, add responsive layouts for narrower and shorter viewports, and separate schedule interval labels from next-run timing.

Also prevents unsupported mirrored sources from being scheduled into the playlist pipeline while still showing them as unavailable in the sidebar.
2026-05-24 21:01:46 -07:00