Commit graph

3416 commits

Author SHA1 Message Date
Broque Thomas
66d7029276 Wishlist payloads: preserve real track_number + release_date end-to-end
Two confirmed-from-code-reading bugs in the wishlist retry chain.
Both cause downstream post-process to render every retried file as
``01 - <title>`` without year in the folder path, even when the
source slskd file had the correct track number embedded and Spotify
had the album release date.

**Bug A — track_number defaults to 1 at every link in the chain.**

Pre-fix: ``.get('track_number', 1)`` defaulted at four sites:
- ``core/wishlist/payloads.py:121`` ``ensure_wishlist_track_format``
- ``core/wishlist/payloads.py:282`` Track-object conversion
- ``core/imports/context.py:421`` legacy album-info builder
- ``core/imports/pipeline.py:645`` final processing read

Each step "filled in" 1 when the upstream had dropped the key. The
downstream filename-extract fallback at ``pipeline.py:652`` ONLY
runs when the value is None — pre-filled 1 never matched, so the
fallback never fired, so the source filename's track number (e.g.
``08. No Sleep Till Brooklyn.flac``) was discarded in favour of the
default-1.

Fix: change every default from ``1`` to ``None`` along the chain.
The pipeline already has the right detect-and-recover logic — it
just needs the chain to stop poisoning it. Final ``< 1`` floor at
``pipeline.py:660`` still defaults to 1 as last resort, so callers
that genuinely have nothing still produce a valid number.

**Bug B — release_date dropped from cancelled-task wishlist payload.**

Pre-fix: ``build_cancelled_task_wishlist_payload`` only ``setdefault``ed
``name`` / ``album_type`` / ``images`` on the album dict. The
release_date field copy was load-bearing (when input was a dict, the
``dict(album_raw)`` copy preserved it), but when input was a bare
string the constructed dict had only name + album_type — no
release_date / total_tracks / etc.

Fix:
- Explicit comment on the dict-shape branch that release_date survives
  via the unconditional ``dict(album_raw)`` copy + setdefault
  semantics — so a future refactor that switches to a stricter copy
  doesn't silently strip the field.
- String-shape branch now pulls release_date from
  ``track_info.album_release_date`` or ``track_info.release_date``
  when present so the round-trip preserves the year for the path
  template.
- track_data shape itself now carries ``track_number`` / ``disc_number``
  at the top level (Bug A intersect — was dropping it entirely).

**Tests:** 4 new in tests/wishlist/test_payloads.py:
- ``test_ensure_wishlist_track_format_preserves_real_track_number``
- ``test_ensure_wishlist_track_format_keeps_missing_track_number_as_none``
- ``test_build_cancelled_task_wishlist_payload_preserves_track_number``
- ``test_build_cancelled_task_wishlist_payload_string_album_pulls_release_date_from_track_info``

14 payload tests pass; 879 across wishlist + imports + downloads
suites still green; 1410 wider suite all pass. Ruff clean.

Commits 2 + 3 of 3 in PR 2/4 of the wishlist-album-bundle issue fix
series. Commit 1 (94ba1d73) instrumented staging-match so the next
wishlist run produces the evidence we need to diagnose bug C
(staging-match silently drops album-bundle wishlist tracks); that
fix lands in a follow-up PR after the user's next reproduction run.
2026-05-27 14:25:03 -07:00
Broque Thomas
94ba1d733d Staging match: log rejection reason on every silent-False exit
Pre-fix: ``try_staging_match`` silently returned False on three exit
points (empty cache, no track title, low best-score). Could not
diagnose the "track gets staged via album-bundle but never claimed
→ re-added to wishlist → infinite loop" bug from app.log because the
match-attempt + rejection was invisible.

Now every False exit logs at INFO with enough context to debug from
a single grep:
- ``[Staging] No match attempted for <track> — staging cache empty for batch <id>``
- ``[Staging] No match attempted for task <id> — track has empty title``
- ``[Staging] No match for <track> in batch <id> — best candidate <file> (title_sim=X, artist_sim=Y, combined=Z) below 0.75 threshold``
- ``[Staging] No match for <track> in batch <id> — N staging files but none had usable title variants``

Per-candidate skips (no title variants / title_sim < 0.80) log at
DEBUG so the noise stays out of INFO unless explicitly enabled.

Logs the near-miss candidate score on rejection so a 0.74 (one point
below threshold) surfaces as a different kind of bug than a 0.10
(completely wrong file in staging). Same shape SAB's adapter logs
now use for transient-vs-terminal status calls (PR #717).

Zero behavior change — pure logging. Enables the follow-up commit
that actually fixes the staging-match drop, by giving us real evidence
of WHERE the wishlist tracks are being rejected during the user's
next album-bundle run.

24 staging tests still pass; behavior unchanged.

Commit 1 of 3 in PR 2/4 of the wishlist-album-bundle issue fix
series. See ``memory/feedback_always_build_kettui_grade.md`` for
the instrument-before-blind-fix rule that drove this ordering.
2026-05-27 14:12:55 -07:00
BoulderBadgeDad
cac057b6eb
Merge pull request #716 from Nezreka/fix/wishlist-album-bundle-threshold
Wishlist: only engage album-bundle when multiple tracks from same alb…
2026-05-27 13:56:21 -07:00
Broque Thomas
dd32e3bbe1 Wishlist: only engage album-bundle when multiple tracks from same album (PR 1/4)
Real-world wishlist case the original c3b88e69 design missed: user with
26 missing tracks from 26 different albums. Each item used to promote
to its own album-bundle sub-batch (``min_tracks_per_album=1``), which
downloaded the ENTIRE album (5-42 files) to claim one track. Confirmed
in app.log:

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

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

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

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

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

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

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

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

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

**UI layout:**

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

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

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

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

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

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

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

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

PR 4 (Monthly UI tab) deferred until weekly proves wanted.
2026-05-27 12:39:56 -07:00
BoulderBadgeDad
f23761cf59
Merge pull request #713 from Nezreka/feat/auto-sync-engine-monthly-trigger
Wire automation engine through next_run_at + register monthly_time (P…
2026-05-27 12:10:49 -07:00
Broque Thomas
62ef39c4b7 Wire automation engine through next_run_at + register monthly_time (PR 2/4)
PR 1 (commit 6ad85e27) shipped the ``next_run_at`` pure function as
foundation plumbing. PR 2 wires the engine through it and adds
``monthly_time`` as a real registered trigger type. After this PR
``core/automation_engine.py`` no longer has its own datetime
arithmetic for daily / weekly schedules — every next-run computation
flows through one function with one set of defensive fallbacks.

Net user-visible change: zero (no UI surface for monthly_time yet —
that's PR 3). New ``monthly_time`` trigger is reachable only via
direct API for now.

**Engine refactor:**

- ``_finish_run`` — collapsed three inline branches (daily_time
  arithmetic, weekly_time arithmetic, fallback schedule arithmetic)
  into a single ``next_run_at(...)`` call with ``_dt_to_db_str``
  normalising the aware-UTC result to the engine's naive-UTC string
  convention. Retry-delay short-circuit preserved. Exception
  swallowing preserved (logged at debug, writes None next_run).

- ``_setup_daily_time_trigger`` + ``_setup_weekly_time_trigger`` +
  new ``_setup_monthly_time_trigger`` — three near-identical methods
  collapsed into one ``_setup_timed_trigger`` skeleton. Each public
  method is now a one-line dispatch passing trigger_type to the
  shared helper with a human-readable label for the debug log.

- Existing ``_next_weekly_occurrence`` deleted — its logic now lives
  in ``core/automation/schedule.py:_next_weekly`` (lifted in PR 1).

- New ``_dt_to_db_str(dt)`` module-level helper normalises aware-UTC
  → naive-UTC string. Centralised so a tz mistake here surfaces in
  one place. Aware non-UTC datetimes converted to UTC first
  (defensive against a future bug that passes the wrong tz).

- New ``_resolve_system_default_tz()`` reads the server's local IANA
  tz via ``tzlocal``. Cached at module import (the host's tz doesn't
  change while the process runs). Falls back to UTC when ``tzlocal``
  is missing — defensive for minimal Docker images.

- New ``self._default_tz`` engine attribute reads from
  ``automation.default_timezone`` config first, falls back to the
  system-detected IANA name. Override path lets users on weird
  setups pin a specific tz without touching env vars.

**Convergence fix (intentional behaviour change):**

Old ``_setup_daily_time_trigger`` / ``_setup_weekly_time_trigger``
didn't check the DB for an existing future ``next_run`` — they'd
recompute from scratch on every engine startup, overwriting manual
edits or pending retries. The interval path (``_setup_schedule_trigger``)
already had this check. The new shared ``_setup_timed_trigger``
brings daily / weekly in line: existing-future next_run wins over
freshly-computed delay. Treat this as a correctness fix, not a
breaking change — the old behaviour was an inconsistency, not a
deliberate choice.

**Backward-compat:**

- Existing ``schedule`` / ``daily_time`` / ``weekly_time`` rows
  continue to work unchanged. The ``_trigger_handlers`` registry
  keeps every historic key.

- Existing rows without an explicit ``tz`` field use
  ``self._default_tz`` (server-local IANA via ``tzlocal``) —
  preserves "every Monday 09:00 server-local" behaviour on
  non-UTC servers. Pre-fix the engine used naive
  ``datetime.now()`` which is also server-local; net effect is
  identical wall-clock time, just routed through a tz-aware
  pipeline that handles DST correctly (the May 2026 "next in 8h"
  bug fix class).

- Engine boots even when ``tzlocal`` is missing — the resolver
  falls back to UTC silently. Existing tests would catch a hard
  dependency on tzlocal here.

**``tzlocal>=5.0`` added to requirements.txt** alongside
``tzdata>=2024.1`` from PR 1. Both libraries are small and stable;
``tzlocal`` returns a clean IANA name across Windows / Linux /
Docker, sidestepping the platform-specific tz detection mess.

**Tests:** 20 new in ``tests/automation/test_engine_schedule_integration.py``:
- ``_dt_to_db_str`` x3 (aware UTC, aware non-UTC converted to UTC,
  naive assumed UTC)
- ``_resolve_system_default_tz`` x2 (returns IANA string, falls back
  to UTC without tzlocal)
- ``_finish_run`` dispatch through next_run_at for each trigger type
  (schedule, daily_time, weekly_time, monthly_time)
- Retry-delay short-circuits next_run_at
- next_run_at returns None → DB next_run cleared
- next_run_at raises → engine swallows + writes None
- Event triggers skipped (no scheduled next-run)
- ``self._default_tz`` passed through to next_run_at
- monthly_time registered in _trigger_handlers
- All historic trigger types kept registered
- ``_setup_monthly_time_trigger`` arms timer + writes DB
- ``_setup_timed_trigger`` honours existing future DB next_run
- Skip-with-log when next_run_at returns None
- End-to-end no-mock smoke for monthly_time

260 automation suite tests pass; the 240 from PR 1's branch plus 20
new integration tests. Ruff clean.

No WHATS_NEW entry — UI doesn't expose monthly_time yet (PR 3),
and the backward-compat path preserves existing daily/weekly
schedule timing.
2026-05-27 12:03:41 -07:00
BoulderBadgeDad
6ad85e2733
Merge pull request #712 from Nezreka/feat/auto-sync-schedule-types
Feat/auto sync schedule types
2026-05-27 11:40:41 -07:00
Broque Thomas
3e61105a1d Close three review gaps before PR 1 ships
Self-review pass on ec4a55c1 — applying the standing kettui-grade
rule (see memory/feedback_always_build_kettui_grade.md). Three issues
that would have surfaced on review:

1. Silent tz fallback to UTC
   ``_resolve_tz`` returned UTC when the IANA name was unknown — no
   log, no warning. User on a host without ``tzdata`` who configures
   ``America/Los_Angeles`` got schedules running silently at UTC
   offset with no way to debug. Now logs WARNING once per unknown
   name (deduped via ``_UNKNOWN_TZ_WARNED`` set so a misconfigured
   row doesn't spam every poll cycle) and the log line names BOTH
   real causes — typo or missing tzdata — so the user can fix from
   a single grep.

2. ``weeks`` unit drift from engine
   I added ``'weeks': 86400*7`` to ``_INTERVAL_MULTIPLIERS`` but the
   engine's existing ``_calc_delay_seconds`` only recognises
   minutes/hours/days. Until PR 2 collapses both paths through this
   function, any row whose config snuck through with ``unit='weeks'``
   would get scheduled by the engine as 1-hour and by this function
   as 7-day — drift between two live implementations. Dropped
   ``weeks`` from the map to match the engine. Added a comment
   pinning the map to the engine's contract and a regression test
   that asserts ``unit='weeks'`` falls back to the same hours
   default the engine produces.

3. DST edge cases unverified
   The module docstring claims DST-aware via ``zoneinfo`` but no test
   pinned the spring-forward gap (02:30 LA on DST-Sunday doesn't
   exist) or fall-back ambiguity (01:30 LA on fall-Sunday happens
   twice). Three new tests:
   - ``test_dst_spring_forward_lands_after_the_gap`` — pins that the
     function doesn't crash + lands on a real instant past ``now``.
   - ``test_dst_fall_back_handles_ambiguous_local_time`` — pins
     zoneinfo's default-earlier-instant resolution for ambiguous
     local times (01:30 PDT vs 01:30 PST → picks PDT).
   - ``test_weekly_across_dst_boundary_keeps_local_wall_clock`` —
     pins that a "every Sunday at 09:00 LA" schedule keeps the
     local wall clock across the boundary even though the UTC
     equivalent shifts by an hour. This is the exact bug class
     that caused the May 2026 "next in 8h" tz mismatch.

Also loosened ``tzdata==2026.2`` to ``tzdata>=2024.1``. IANA tz data
changes a few times a year for real-world DST policy updates; pinning
to one snapshot would freeze the app's tz knowledge to the build date
and miss future government-mandated rule changes.

41 schedule tests pass (5 new); 240 across the full automation suite.
Ruff clean.
2026-05-27 11:33:05 -07:00
Broque Thomas
ec4a55c104 Add next_run_at pure function for Auto-Sync schedule types (PR 1/4)
Backend plumbing for upcoming weekly + monthly Auto-Sync schedules.
PR 1 of 4 in the schedule-types feature — see
``memory/project_auto_sync_schedule_types.md`` for the full plan.

Net behaviour change in this PR: zero. The automation engine still
computes next_run via its existing inline ``_calc_delay_seconds`` /
``_next_weekly_occurrence`` helpers; this module is unused until PR 2
wires the engine through. Lands separately so the foundation can sit
on dev for a beat before the engine change.

``core/automation/schedule.py:next_run_at(trigger_type, trigger_config,
now_utc, default_tz)``:
- Pure function. ``now_utc`` injected (tests freeze time without
  monkeypatching ``datetime.now``); ``default_tz`` injected (so daily /
  weekly / monthly schedules compute against the USER's timezone, not
  the server's — the same class of bug that produced the May 2026
  "Auto-Sync next in 8h" timezone fix).
- Returns aware-UTC ``datetime`` ready to serialise to the DB
  ``next_run`` column, or ``None`` for unrecognised / event-based
  triggers (callers should not write a next_run for those).
- Naive ``now_utc`` inputs are assumed UTC for defensive symmetry
  with the engine's DB-string parser convention.

Trigger types covered:
- ``schedule``: ``{interval: N, unit: 'minutes'|'hours'|'days'|'weeks'}``
  — matches engine's existing ``_calc_delay_seconds``. Unknown unit
  defaults to hours; zero/negative interval clamps to 1 (preserves
  the engine's guard against scheduling for the past); non-numeric
  interval falls back to 1.
- ``daily_time``: ``{time: 'HH:MM', tz: '<IANA>'}`` — DST-aware via
  ``zoneinfo``; ``tz`` falls back to ``default_tz``; unknown IANA
  string falls back to UTC; garbage ``time`` falls back to 00:00.
- ``weekly_time``: ``{time, days: ['mon',...], tz}`` — empty / all-
  invalid ``days`` list means "every day" (matches engine fallback);
  abbreviations case-insensitive; 8-day scan finds the next match.
- ``monthly_time``: ``{time, day_of_month: 1-31, tz}`` — NEW shape.
  Day clamped to [1, 31]. Months too short for the target day clamp
  to the LAST valid day rather than skipping a month (standard cron
  convention; running a day early in February is less surprising
  than missing the whole month). 12-iteration loop cap so a
  pathological config can't infinite-loop.

Tests (36 cases, all passing):
- Interval: every unit, unknown-unit fallback, zero/negative/garbage
  interval clamp, tz field ignored on interval (wall-clock-independent).
- Daily: today-at-future-time runs today, today-at-past-time rolls to
  tomorrow, exact-match rolls to tomorrow (no schedule-now-then-schedule-
  again-immediately), user-tz vs server-tz, default_tz fallback,
  garbage time / unknown tz defensive returns.
- Weekly: same-day-still-future qualifies, same-day-past rolls to next
  allowed day, wraps across week boundary, empty days = every day,
  garbage abbreviations dropped, case-insensitive, tz across day
  boundary (LA Wednesday evening is Thursday UTC).
- Monthly: target day this month, rolls to next month when passed,
  Feb 31 → Feb 28 / Feb 29 leap year, day_of_month above 31 / below
  1 clamp, Dec → Jan year roll, user-tz pre-midnight edge case.
- Result-shape contract: every returned datetime is aware UTC at
  offset zero (engine relies on this when serialising to the
  ``next_run`` string column).

Added ``tzdata==2026.2`` to requirements.txt. Windows ``zoneinfo`` and
minimal Docker base images ship without the system tz database;
without ``tzdata`` ``ZoneInfo('America/Los_Angeles')`` raises
``ZoneInfoNotFoundError`` and the helper silently falls back to UTC.

No WHATS_NEW entry — no user-visible behaviour change in this PR.
PR 2 (engine wire-through) will land the user-facing changelog entry
when ``monthly_time`` becomes a real schedulable trigger.
2026-05-27 11:15:47 -07:00
BoulderBadgeDad
d363572d87
Merge pull request #711 from Nezreka/fix/usenet-album-poll-sab-handoff
Fix/usenet album poll sab handoff
2026-05-27 10:20:15 -07:00
Broque Thomas
e2d45c51e5 Address kettui-flagged items on usenet poll fix (#706)
Follow-up to f13d3395. Five gaps called out on self-review:

1. Per-track inline transient tolerance was duplicated between
   usenet.py and torrent.py (~12 lines each, identical) and wasn't
   directly tested. Extracted into ``TransientMissCounter`` in
   ``album_bundle.py`` — small class with ``record_miss()`` returning
   True at threshold and ``reset()`` for successful reads. Both
   per-track flows AND the lifted ``poll_album_download`` now use
   the same counter, so the rule is in one place.

2. Threshold is now config-driven via
   ``download_source.album_bundle_transient_miss_threshold``
   (default 5). Same defensive pattern as ``get_poll_interval`` /
   ``get_poll_timeout`` — non-positive / non-numeric falls back to
   the default. Users with very slow servers (huge multi-disc box
   sets, slow disks) can extend the tolerance window without
   touching code.

3. SAB state map verified against the canonical Status enum in
   ``sabnzbd/constants.py`` (sabnzbd/constants.py:~95-118). Dropped
   six entries I'd guessed at and couldn't verify in source
   (``trying``, ``prop_paused``, ``prop_failed``, ``unpacking``,
   ``pp``, ``postprocessing``). Kept the verified ``deleted`` (lower-
   cased from SAB's ``Deleted``) and added the one real state I'd
   missed: ``Propagating`` (SAB's pre-download delay state — maps to
   ``queued`` since we're waiting on the NZB to be available, not
   actively downloading).

4. SAB integration test exercising the queue→history gap end-to-end
   through the real adapter HTTP layer. Mocks SAB's queue + history
   endpoints with the exact response shapes SAB emits, runs three
   gap polls (both endpoints empty), then a recovery poll where the
   slot appears in history as Completed. Confirms the TransientMissCounter
   absorbs the gap and ``poll_album_download`` returns the save_path
   without emitting terminal failure. This was the path I had only
   tested at the helper layer before — now pinned end-to-end through
   the adapter.

5. SAB state mapping has new tests: every Status value from SAB's
   canonical enum must map to a known adapter state (not the 'error'
   default fallback), Propagating routes to queued, Deleted routes
   to failed. Future SAB state additions that we miss will surface
   as 'error' default → transient-miss tolerance → terminal failure
   with a clear log line, but the explicit assertion list here means
   we'll catch the omission in CI before users do.

Test count after: 537 download-suite tests pass; 21 new
(``TransientMissCounter`` ×4, ``get_transient_miss_threshold`` ×3,
SAB state-coverage ×3, SAB direct ``nzo_ids`` lookup ×5, SAB
queue→history integration ×1, plus the existing helper-layer
coverage from the parent commit). Ruff clean.
2026-05-27 10:05:37 -07:00
Broque Thomas
f13d339584 Usenet album poll: tolerate SAB queue→history handoff, emit terminal failure (#706)
User reported usenet album downloads getting stuck on "downloading
release" while SABnzbd reported the job as complete. Container restart
did not help; reproducible on every usenet album download.

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

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

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

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

Plus:

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

Implementation:

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

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

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

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

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

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

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

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

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

Out of scope (separate issue): the same #634 report flagged track-count
and year fields rendering as 0 / empty in Discogs album search. Both
are inherent to Discogs `/database/search` response shape — search
results don't carry `tracklist` (only release detail does) and `year`
is often `0` in search payloads. Fixing requires lazy-fetching release
detail per row, which hits the 25 req/min unauth limit hard. Not
bundled here.
2026-05-27 09:05:47 -07:00
BoulderBadgeDad
36614e1a4d
Merge pull request #708 from Nezreka/fix/community-feedback-mbrainz-mirrored-enhanced
Fix/community feedback mbrainz mirrored enhanced
2026-05-27 08:36:04 -07:00
Broque Thomas
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.
2026-05-27 08:33:36 -07:00
Broque Thomas
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.
2026-05-27 08:13:28 -07:00
Broque Thomas
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.
2026-05-27 07:43:21 -07:00
Broque Thomas
b67d13164a Library: persist Enhanced / Standard view toggle in localStorage
User feedback: the Enhanced view toggle on the artist detail page reset
to Standard on every artist click, so admins who prefer Enhanced had to
re-flip the toggle every single time. Persist the choice in
localStorage and reapply on every artist navigation + page reload.

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

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

Three independent issues were combining to cause this:

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

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

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

Tests:
- new `test_manual_match_skipped_even_when_matched_data_incomplete`
  in `tests/discovery/test_discovery_playlist.py` pins the worker
  short-circuit using a realistic MB-shape matched_data (album dict
  without id / release_date, no top-level track_number). 16 existing
  tests still green; 848 across discovery / metadata / automation
  suites pass.
2026-05-27 06:59:58 -07:00
Broque Thomas
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
2026-05-26 23:00:19 -07:00
Broque Thomas
4555ff7eb9 Wishlist modal: surface most-advanced live phase, not least-complete
The sibling-merge aggregator from 7f751202 used "least-complete
phase wins", which made the modal appear frozen during parallel
album bundle downloads. The task table is phase-gated to
downloading/complete/error in downloads.js — so whenever any
sibling was still in album_downloading, the merged phase stayed
there and tasks for the sibling that had advanced past its bundle
never rendered. User reported: both albums downloading on slskd,
modal blank until one completes fully.

Flip the rule: surface the most-advanced live phase so the modal
renders task progress as soon as any sibling reaches it. The
all-siblings-in-album_downloading case still surfaces
album_downloading (bundle progress UI is correct there); error
stays sticky.

Updated WHATS_NEW under 2.6.3 to describe the corrected behavior.
Two new tests pin the regression:
- downloading + album_downloading → downloading
- album_downloading + album_downloading → album_downloading
2026-05-26 22:35:53 -07:00
Broque Thomas
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.
2026-05-26 22:17:04 -07:00
Broque Thomas
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.
2026-05-26 21:50:42 -07:00
Broque Thomas
7832acba31 Manual wishlist run: also split into per-album sub-batches
The Phase-1 fix (commit c3b88e69) only extended the per-album
bundle dispatch to ``process_wishlist_automatically``. The manual
"Run Wishlist Now" path goes through
``_prepare_and_run_manual_wishlist_batch`` instead, so the
behavior didn't change for users who triggered downloads from the
Wishlist tab UI — they still saw N per-track Soulseek searches
when N missing tracks all came from one album.

Caught in a real-app test: user added Katy Perry's PRISM (Deluxe)
to the wishlist + clicked "Download Wishlist" → app log shows
``_prepare_and_run_manual_wishlist_batch:421`` running a single
batch with 16 tracks + per-track searches firing one by one
("katy perry prism deluxe legendary lovers", "katy perry prism
deluxe roar", etc.), no album-bundle dispatch.

Fix:

- ``_prepare_and_run_manual_wishlist_batch`` now runs the same
  ``group_wishlist_tracks_by_album`` helper after filtering. For
  each detected album, it builds a sub-batch with
  ``is_album_download=True`` + populated album/artist context.
  Residual tracks (no resolvable album metadata) land in a single
  per-track residual batch.
- The first sub-batch re-uses the caller-allocated ``batch_id``
  so the frontend's existing poll against it keeps working;
  additional sub-batches get fresh ids materialized into
  ``download_batches`` so they show up in the Downloads view.
- Sub-batches dispatch serially — each ``run_full_missing_tracks_process``
  call blocks until the album-bundle staging + per-track tasks
  complete before the next album's bundle search fires.

New test ``test_manual_wishlist_splits_into_per_album_sub_batches``
pins the contract — multi-album wishlist content with
nested-spotify_data shape produces N master-worker calls (one per
album), each batch carries the album_context, first sub-batch
re-uses the original batch_id. 106 wishlist tests + 1099 across
the broader suite green.

Adding 16 Katy Perry PRISM tracks to wishlist + clicking download
should now fire ONE slskd album-bundle search for the release
instead of 16 individual searches.
2026-05-26 21:24:07 -07:00
Broque Thomas
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.
2026-05-26 21:13:34 -07:00
Broque Thomas
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.
2026-05-26 21:04:27 -07:00
Broque Thomas
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 5378b726) while we
chased down why only one rolling mirror was being created — turned
out the issue was simply needing two refresh cycles after the
rolling code shipped. Diagnostic served its purpose, removing
the noise from every LB refresh now.

- Dropped per-walk + per-match + summary INFO lines from
  ``_ensure_rolling_mirrors_from_cache`` — the loop is silent.
- Reverted the outer SELECT failure catch from ``logger.warning``
  back to ``logger.debug``.
- Kept the per-placeholder ``Pre-created rolling mirror
  placeholder`` INFO line in ``_ensure_rolling_series_mirror``
  since it's a genuine one-shot event (only fires when a new
  placeholder is actually inserted, not on every refresh).
2026-05-26 20:17:23 -07:00
Broque Thomas
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.
2026-05-26 20:12:15 -07:00
Broque Thomas
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).
2026-05-26 20:02:50 -07:00
Broque Thomas
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.
2026-05-26 19:46:03 -07:00
Broque Thomas
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.
2026-05-26 17:52:54 -07:00
Broque Thomas
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.
2026-05-26 17:13:36 -07:00
Broque Thomas
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.
2026-05-26 16:51:35 -07:00
Broque Thomas
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.
2026-05-26 16:25:25 -07:00
Broque Thomas
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.
2026-05-26 15:56:24 -07:00
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