Merge pull request #720 from Nezreka/dev

Dev
This commit is contained in:
BoulderBadgeDad 2026-05-27 22:36:13 -07:00 committed by GitHub
commit 80e39399cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 8824 additions and 405 deletions

View file

@ -9,9 +9,9 @@ on:
workflow_dispatch:
inputs:
version_tag:
description: 'Version tag (e.g. 2.6.2)'
description: 'Version tag (e.g. 2.6.3)'
required: true
default: '2.6.2'
default: '2.6.3'
jobs:
build-and-push:

View file

@ -51,6 +51,16 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[
playlist_id = config.get('playlist_id')
refresh_all = config.get('all', False)
auto_id = config.get('_automation_id')
# Pipeline runs (``run_mirrored_playlist_pipeline``) set this flag
# because Phase 2 of the pipeline already runs the playlist
# discovery worker — the same matching engine ``_maybe_discover``
# would call here. Running both means LB tracks discover twice
# AND the refresh-side discovery blocks 5+ minutes with no
# progress emission, leaving the UI stuck on "Refreshing:" until
# the loop returns. Standalone callers (Sync page, registration
# action) leave it False so LB tracks still get matched_data on
# refresh.
skip_discovery = bool(config.get('skip_discovery', False))
if refresh_all:
playlists = db.get_mirrored_playlists()
@ -93,7 +103,14 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[
# mark them ``needs_discovery=True``. Hand them to the
# adapter's matcher so the resulting mirror rows carry
# provider IDs + matched_data, ready for the sync pipeline.
detail_tracks = _maybe_discover(detail.tracks, source, deps)
#
# Pipeline runs skip this because Phase 2's discovery
# worker handles it with proper progress emission — see
# ``skip_discovery`` resolution at the top of this fn.
detail_tracks = (
detail.tracks if skip_discovery
else _maybe_discover(detail.tracks, source, deps)
)
tracks = [to_mirror_track_dict(t) for t in detail_tracks]
refreshed += _commit_refresh(pl, source, source_id, tracks, db, deps, auto_id)

321
core/automation/schedule.py Normal file
View file

@ -0,0 +1,321 @@
"""Pure functions for computing the next-run datetime of a scheduled
automation trigger.
The Auto-Sync schedule board currently exposes interval-based scheduling
(``every N hours``) backed by ``trigger_type='schedule'``. The
automation engine ALSO supports ``daily_time`` and ``weekly_time``
triggers via separate ``_setup_*_trigger`` methods inline on the engine
class. None of that logic is currently testable in isolation the
engine's ``_finish_run`` reaches for ``datetime.now()``, threads it
through ``_next_weekly_occurrence``, and writes the result to the DB,
all on the same call.
This module lifts the "given a trigger config, what's the next run?"
question out of the engine into a pure function:
next_run_at(trigger_type, trigger_config, now_utc, default_tz)
-> Optional[datetime]
That means:
- ``now_utc`` is INJECTED, not pulled from the system clock. Tests
freeze time without monkeypatching ``datetime.now``.
- ``default_tz`` is INJECTED. Daily / weekly / monthly schedules are
inherently in the USER'S timezone (cron "every Monday at 9am" is
not UTC), and the historic engine implicitly used the server's
local tz via naive ``datetime.now()``. That broke for users on a
different tz than their server. The pure function takes the tz
explicitly so the caller controls it.
- Returns an aware UTC ``datetime`` ready to serialise to the DB's
``next_run`` string column, or ``None`` for unrecognised /
event-based triggers (engine should not store a next_run for those).
PR 1 of the schedule-types feature ships ONLY this module + tests.
The engine continues to compute next_run via its existing inline
helpers; PR 2 collapses those into a single ``next_run_at`` call.
Net behavior is identical until the engine is wired through this
PR is pure plumbing.
Schedule types supported here:
- ``schedule`` (interval): ``{interval: N, unit: 'minutes'|'hours'|'days'}``
adds the interval to ``now_utc``; no tz needed.
- ``daily_time``: ``{time: 'HH:MM', tz: '<IANA>'}`` runs every day at
the given local time in the given timezone. ``tz`` falls back to
``default_tz`` when absent.
- ``weekly_time``: ``{time: 'HH:MM', days: ['mon','wed',...], tz: '<IANA>'}``
runs on the matching weekday(s) at the given local time. Empty
``days`` list means "every day" (matches the engine's existing
fallback in ``_next_weekly_occurrence``).
- ``monthly_time``: ``{time: 'HH:MM', day_of_month: 1-31, tz: '<IANA>'}``
runs on the given day each month. Days that don't exist in a
given month (Feb 30, Apr 31) clamp to the LAST valid day of that
month rather than skipping the run entirely; missing a whole
month silently because the schedule was over-eager is worse than
running a day early.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from utils.logging_config import get_logger
logger = get_logger("automation.schedule")
# Unknown-tz names already warned about in this process — avoids
# spamming the log on every poll cycle for the same misconfigured row.
_UNKNOWN_TZ_WARNED: set = set()
# Weekday abbreviation → ``datetime.weekday()`` index (Mon=0..Sun=6).
# Mirrors the engine's existing ``_next_weekly_occurrence`` mapping so
# schedules created against either implementation accept the same
# ``days`` strings.
_WEEKDAY_MAP = {
'mon': 0, 'tue': 1, 'wed': 2, 'thu': 3, 'fri': 4, 'sat': 5, 'sun': 6,
}
# Interval multipliers — kept aligned with the engine's existing
# ``_calc_delay_seconds`` in ``core/automation_engine.py``. Adding
# entries here without also updating the engine would silently drift:
# this function would honour the new unit while the live engine path
# defaults it to hours. Keep the maps in sync until PR 2 collapses the
# engine through this function.
_INTERVAL_MULTIPLIERS = {
'minutes': 60,
'hours': 60 * 60,
'days': 60 * 60 * 24,
}
def next_run_at(
trigger_type: str,
trigger_config: Dict[str, Any],
now_utc: datetime,
default_tz: str = 'UTC',
) -> Optional[datetime]:
"""Compute the next-run timestamp (UTC, aware) for a scheduled
trigger. Returns ``None`` for unrecognised types or event-based
triggers callers should not write a next_run for those.
See module docstring for supported trigger types + config shapes.
"""
if not isinstance(trigger_config, dict):
trigger_config = {}
if trigger_type == 'schedule':
return _next_interval(trigger_config, now_utc)
if trigger_type == 'daily_time':
return _next_daily(trigger_config, now_utc, default_tz)
if trigger_type == 'weekly_time':
return _next_weekly(trigger_config, now_utc, default_tz)
if trigger_type == 'monthly_time':
return _next_monthly(trigger_config, now_utc, default_tz)
return None
# ---------------------------------------------------------------------------
# Interval
# ---------------------------------------------------------------------------
def _next_interval(config: Dict[str, Any], now_utc: datetime) -> datetime:
"""``{interval: N, unit: 'hours'}`` → ``now_utc + N hours``.
Mirrors the engine's existing ``_calc_delay_seconds``. Unit defaults
to ``hours`` for backward compat with legacy DB rows that pre-date
the unit field being mandatory; interval defaults to 1 so a fully
empty config doesn't divide-by-zero or schedule for the past."""
try:
interval = max(int(config.get('interval', 1)), 1)
except (TypeError, ValueError):
interval = 1
unit = config.get('unit') or 'hours'
seconds = interval * _INTERVAL_MULTIPLIERS.get(unit, _INTERVAL_MULTIPLIERS['hours'])
return _ensure_utc(now_utc) + timedelta(seconds=seconds)
# ---------------------------------------------------------------------------
# Daily
# ---------------------------------------------------------------------------
def _next_daily(
config: Dict[str, Any], now_utc: datetime, default_tz: str,
) -> datetime:
"""``{time: 'HH:MM', tz: '<IANA>'}`` → next occurrence of that
wall-clock time in the user's timezone, expressed as aware UTC.
DST-aware via ``zoneinfo``: when the local time falls during a
spring-forward gap, the ``replace`` lands on a non-existent
instant; ``zoneinfo`` resolves that to the gap's later side
(e.g. 02:30 on the DST-forward day becomes 03:30 local). Tests
pin both spring-forward and fall-back behaviour."""
tz = _resolve_tz(config.get('tz') or default_tz)
hour, minute = _parse_hhmm(config.get('time'))
now_local = _ensure_utc(now_utc).astimezone(tz)
target_local = now_local.replace(hour=hour, minute=minute, second=0, microsecond=0)
if target_local <= now_local:
target_local = target_local + timedelta(days=1)
return target_local.astimezone(timezone.utc)
# ---------------------------------------------------------------------------
# Weekly
# ---------------------------------------------------------------------------
def _next_weekly(
config: Dict[str, Any], now_utc: datetime, default_tz: str,
) -> datetime:
"""``{time: 'HH:MM', days: ['mon',...], tz: '<IANA>'}`` → next
occurrence of that wall-clock time on any of the listed weekdays
in the user's timezone.
Empty ``days`` list every day, matching the engine's existing
fallback. Unrecognised day abbreviations are silently dropped
(an empty result-set then triggers the every-day fallback)."""
tz = _resolve_tz(config.get('tz') or default_tz)
hour, minute = _parse_hhmm(config.get('time'))
days = _parse_weekdays(config.get('days'))
now_local = _ensure_utc(now_utc).astimezone(tz)
# Scan today + next 7 days; the matching day with a future
# local time wins. 8-day scan is enough to handle the case where
# today already passed the time AND today is the only allowed
# weekday (next occurrence is exactly one week out).
for offset in range(8):
candidate = now_local + timedelta(days=offset)
if candidate.weekday() not in days:
continue
target = candidate.replace(hour=hour, minute=minute, second=0, microsecond=0)
if target > now_local:
return target.astimezone(timezone.utc)
# Shouldn't reach: 8-day scan always finds a hit when ``days``
# is non-empty. Defensive fallback: next week, same weekday as today.
fallback = (now_local + timedelta(days=7)).replace(
hour=hour, minute=minute, second=0, microsecond=0,
)
return fallback.astimezone(timezone.utc)
# ---------------------------------------------------------------------------
# Monthly
# ---------------------------------------------------------------------------
def _next_monthly(
config: Dict[str, Any], now_utc: datetime, default_tz: str,
) -> datetime:
"""``{time: 'HH:MM', day_of_month: 1-31, tz: '<IANA>'}`` → next
occurrence in the user's timezone.
``day_of_month`` is clamped to ``[1, 31]``. When the target day
doesn't exist in a given month (Feb 30, Apr 31), the schedule
falls back to the LAST valid day of that month running a day
or two early in short months is less surprising than skipping
a month entirely. This matches the convention every cron
implementation in the wild settled on."""
tz = _resolve_tz(config.get('tz') or default_tz)
hour, minute = _parse_hhmm(config.get('time'))
raw_day = config.get('day_of_month', 1)
try:
target_day = max(1, min(31, int(raw_day)))
except (TypeError, ValueError):
target_day = 1
now_local = _ensure_utc(now_utc).astimezone(tz)
# Try this month first; if the target day has already passed
# (or doesn't exist this month and the clamped day is in the
# past), advance to next month. Loop bounded to 12 iterations
# so a pathologically broken config can't infinite-loop us.
year, month = now_local.year, now_local.month
for _ in range(12):
day = min(target_day, _days_in_month(year, month))
target = now_local.replace(
year=year, month=month, day=day,
hour=hour, minute=minute, second=0, microsecond=0,
)
if target > now_local:
return target.astimezone(timezone.utc)
# Roll to next month.
if month == 12:
year, month = year + 1, 1
else:
month += 1
# Defensive — should be unreachable.
return (now_local + timedelta(days=30)).astimezone(timezone.utc)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _ensure_utc(dt: datetime) -> datetime:
"""Coerce a possibly-naive datetime to aware UTC. Naive inputs
are assumed UTC (matches the convention the engine uses when
parsing the DB ``next_run`` column)."""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _resolve_tz(name: Optional[str]):
"""Look up an IANA tz by name. Falls back to UTC when the name is
unknown ``ZoneInfoNotFoundError`` is the symptom of either a
typo in the tz string or ``tzdata`` missing on the host. Logged
once per unknown name so the user can see WHY their schedule
isn't running in the timezone they configured."""
if not name:
return timezone.utc
try:
return ZoneInfo(name)
except ZoneInfoNotFoundError:
if name not in _UNKNOWN_TZ_WARNED:
_UNKNOWN_TZ_WARNED.add(name)
logger.warning(
"Unknown timezone %r — schedule will run against UTC. "
"Check the spelling (IANA format like 'America/Los_Angeles') "
"or install the `tzdata` package on minimal hosts.",
name,
)
return timezone.utc
def _parse_hhmm(time_str: Optional[str]) -> tuple:
"""Parse ``HH:MM`` → ``(hour, minute)``. Defaults to 00:00 on
garbage input same defensive shape as the engine's existing
daily/weekly time parsing."""
if not isinstance(time_str, str):
return 0, 0
try:
h, m = time_str.split(':', 1)
return max(0, min(23, int(h))), max(0, min(59, int(m)))
except (ValueError, AttributeError):
return 0, 0
def _parse_weekdays(days) -> set:
"""``['mon', 'wed']`` → ``{0, 2}``. Empty / missing / all-invalid
list returns ``set(range(7))`` ("every day"), matching the
engine's existing ``_next_weekly_occurrence`` fallback."""
if not isinstance(days, (list, tuple)):
return set(range(7))
parsed = {_WEEKDAY_MAP[d.lower()] for d in days
if isinstance(d, str) and d.lower() in _WEEKDAY_MAP}
return parsed or set(range(7))
def _days_in_month(year: int, month: int) -> int:
"""Last calendar day of ``year-month``. Stdlib-only — no calendar
module import needed; cycle through the 12 months."""
if month == 12:
next_first = datetime(year + 1, 1, 1)
else:
next_first = datetime(year, month + 1, 1)
last_day = next_first - timedelta(days=1)
return last_day.day

View file

@ -18,10 +18,14 @@ import time
import threading
import requests
from datetime import datetime, timedelta, timezone
from typing import Optional
from utils.logging_config import get_logger
from core.automation.schedule import next_run_at
logger = get_logger("automation_engine")
def _utcnow():
"""Return current UTC time as timezone-aware datetime."""
return datetime.now(timezone.utc)
@ -34,6 +38,42 @@ def _utc_after(seconds):
"""Return UTC time N seconds from now as naive string for DB storage."""
return (datetime.now(timezone.utc) + timedelta(seconds=seconds)).strftime('%Y-%m-%d %H:%M:%S')
def _dt_to_db_str(dt: datetime) -> str:
"""Convert an aware-UTC datetime to the naive-UTC string the DB
``next_run`` column stores. Centralised so a tz mistake here
surfaces in one place, not scattered through every caller of
``next_run_at``."""
if dt.tzinfo is None:
return dt.strftime('%Y-%m-%d %H:%M:%S')
return dt.astimezone(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
def _resolve_system_default_tz() -> str:
"""Return the IANA tz name the engine uses when a schedule's
trigger config doesn't carry an explicit ``tz`` field.
Existing daily / weekly rows pre-date the ``tz`` field the
historic engine computed delays from naive ``datetime.now()``,
which is implicitly the server's local timezone. Falling back to
that same tz here preserves "every Monday at 09:00" running at
09:00 server local for rows that already exist in the DB.
Without ``tzlocal`` installed (or a system without a discoverable
tz), falls back to UTC."""
try:
import tzlocal
return tzlocal.get_localzone_name() or 'UTC'
except Exception:
return 'UTC'
# Server-local tz cached at import time. Re-reading per-call is
# pointless: the host's timezone doesn't change while the process is
# running. Tests that need a different default tz inject it through
# the engine's ``_default_tz`` attribute or via the
# ``automation.default_timezone`` config key.
_SYSTEM_DEFAULT_TZ = _resolve_system_default_tz()
SYSTEM_AUTOMATIONS = [
{
'name': 'Auto-Process Wishlist',
@ -134,11 +174,25 @@ class AutomationEngine:
self._max_chain_depth = 5
self._signal_cooldown_seconds = 10
# Default tz used when a schedule's ``trigger_config`` doesn't
# carry an explicit ``tz`` field — preserves historic behaviour
# for daily / weekly rows created before the field existed
# (engine used naive ``datetime.now()`` = server local). Reads
# from the ``automation.default_timezone`` config key first to
# let users override without touching env vars; falls back to
# the system-detected local tz.
try:
from config.settings import config_manager
self._default_tz = (config_manager.get('automation.default_timezone', '') or _SYSTEM_DEFAULT_TZ)
except Exception:
self._default_tz = _SYSTEM_DEFAULT_TZ
# Trigger registry: type → setup function (schedule only — events use emit())
self._trigger_handlers = {
'schedule': self._setup_schedule_trigger,
'daily_time': self._setup_daily_time_trigger,
'weekly_time': self._setup_weekly_time_trigger,
'monthly_time': self._setup_monthly_time_trigger,
}
# --- Action Handler Registration ---
@ -674,23 +728,21 @@ class AutomationEngine:
trigger_config = json.loads(auto.get('trigger_config') or '{}')
if retry_delay_seconds:
next_run_str = _utc_after(retry_delay_seconds)
elif trigger_type == 'daily_time':
# Next run is tomorrow at the configured time (compute delay from local time, store as UTC)
time_str = trigger_config.get('time', '00:00')
hour, minute = map(int, time_str.split(':'))
now_local = datetime.now()
target = now_local.replace(hour=hour, minute=minute, second=0, microsecond=0) + timedelta(days=1)
next_run_str = _utc_after((target - now_local).total_seconds())
elif trigger_type == 'weekly_time':
time_str = trigger_config.get('time', '00:00')
hour, minute = map(int, time_str.split(':'))
now_local = datetime.now()
target = self._next_weekly_occurrence(hour, minute, trigger_config.get('days', []))
next_run_str = _utc_after((target - now_local).total_seconds())
else:
delay = self._calc_delay_seconds(trigger_config)
if delay:
next_run_str = _utc_after(delay)
# Single integration point with ``next_run_at``. The
# helper handles every trigger type the engine
# supports (interval / daily / weekly / monthly) and
# returns aware-UTC; ``_dt_to_db_str`` normalises to
# the naive-UTC string the DB column stores. Tests
# injecting a different ``now_utc`` patch this same
# path — no scattered ``datetime.now()`` calls left.
next_run_dt = next_run_at(
trigger_type, trigger_config,
now_utc=_utcnow(),
default_tz=self._default_tz,
)
if next_run_dt is not None:
next_run_str = _dt_to_db_str(next_run_dt)
except Exception as e:
logger.debug("next run calc failed: %s", e)
@ -764,45 +816,72 @@ class AutomationEngine:
logger.debug(f"Scheduled automation {automation_id} in {delay:.0f}s")
def _setup_daily_time_trigger(self, automation_id, config):
"""Config: {"time": "03:00"} — runs daily at the specified local time."""
time_str = config.get('time', '00:00')
try:
hour, minute = map(int, time_str.split(':'))
except (ValueError, AttributeError):
hour, minute = 0, 0
now_local = datetime.now()
target = now_local.replace(hour=hour, minute=minute, second=0, microsecond=0)
if target <= now_local:
target += timedelta(days=1)
delay = (target - now_local).total_seconds()
next_run_str = _utc_after(delay)
self.db.update_automation(automation_id, next_run=next_run_str)
timer = threading.Timer(delay, self.run_automation, args=(automation_id,))
timer.daemon = True
timer.start()
with self._lock:
self._timers[automation_id] = timer
logger.debug(f"Daily automation {automation_id} scheduled for {time_str} (in {delay:.0f}s)")
"""Config: ``{"time": "03:00", "tz": "<IANA>"}`` — runs daily
at the specified local time. Tz defaults to ``self._default_tz``
when absent."""
self._setup_timed_trigger(automation_id, 'daily_time', config,
label=f"Daily at {config.get('time', '00:00')}")
def _setup_weekly_time_trigger(self, automation_id, config):
"""Config: {"time": "03:00", "days": ["mon", "wed", "fri"]}"""
time_str = config.get('time', '00:00')
try:
hour, minute = map(int, time_str.split(':'))
except (ValueError, AttributeError):
hour, minute = 0, 0
"""Config: ``{"time": "03:00", "days": ["mon","wed","fri"], "tz": "<IANA>"}``."""
day_names = ', '.join(config.get('days') or []) or 'every day'
self._setup_timed_trigger(automation_id, 'weekly_time', config,
label=f"Weekly {config.get('time', '00:00')} on {day_names}")
target = self._next_weekly_occurrence(hour, minute, config.get('days', []))
delay = (target - datetime.now()).total_seconds()
def _setup_monthly_time_trigger(self, automation_id, config):
"""Config: ``{"time": "09:00", "day_of_month": 15, "tz": "<IANA>"}``.
next_run_str = _utc_after(delay)
self.db.update_automation(automation_id, next_run=next_run_str)
Day clamped to [1, 31]; months too short for the target day
clamp to the last valid day (Feb 31 Feb 28 / Feb 29 leap
year) per standard cron convention see
``core.automation.schedule._next_monthly`` for the rule."""
day = config.get('day_of_month', 1)
self._setup_timed_trigger(automation_id, 'monthly_time', config,
label=f"Monthly {config.get('time', '00:00')} on day {day}")
def _setup_timed_trigger(self, automation_id, trigger_type, config, *, label):
"""Shared setup for daily / weekly / monthly time triggers.
All three flow through the same skeleton: compute next-run
via ``next_run_at``, persist to DB, arm a ``threading.Timer``
that fires the automation when the delay elapses. Lifting
these out of three near-identical methods means there's one
place to fix when (e.g.) timer rearm semantics need a tweak.
Honours an existing future ``next_run`` row in the DB
prevents losing a hand-edited next_run when the engine
reschedules at startup. Same guard as the interval path."""
target_dt = next_run_at(
trigger_type, config or {},
now_utc=_utcnow(),
default_tz=self._default_tz,
)
if target_dt is None:
logger.warning(
f"Skip scheduling automation {automation_id}: next_run_at returned "
f"None for {trigger_type!r}",
)
return
delay = max(0.0, (target_dt - _utcnow()).total_seconds())
# If the DB already carries a future next_run, prefer it —
# matches the interval-path behaviour and lets manual edits
# or pending retries survive a process restart.
auto = self.db.get_automation(automation_id)
if auto and auto.get('next_run'):
try:
existing = datetime.strptime(
auto['next_run'], '%Y-%m-%d %H:%M:%S',
).replace(tzinfo=timezone.utc)
remaining = (existing - _utcnow()).total_seconds()
if remaining > 0:
delay = remaining
target_dt = existing
except (ValueError, TypeError):
pass
self.db.update_automation(automation_id, next_run=_dt_to_db_str(target_dt))
timer = threading.Timer(delay, self.run_automation, args=(automation_id,))
timer.daemon = True
@ -811,25 +890,7 @@ class AutomationEngine:
with self._lock:
self._timers[automation_id] = timer
day_names = ', '.join(config.get('days', [])) or 'every day'
logger.debug(f"Weekly automation {automation_id} scheduled for {time_str} on {day_names} (in {delay:.0f}s)")
def _next_weekly_occurrence(self, hour, minute, days):
"""Find the next datetime matching one of the given weekday abbreviations."""
day_map = {'mon': 0, 'tue': 1, 'wed': 2, 'thu': 3, 'fri': 4, 'sat': 5, 'sun': 6}
allowed = {day_map[d] for d in days if d in day_map}
if not allowed:
allowed = set(range(7)) # no days selected = every day
now = datetime.now()
for offset in range(8): # check today + next 7 days
candidate = now + timedelta(days=offset)
if candidate.weekday() in allowed:
target = candidate.replace(hour=hour, minute=minute, second=0, microsecond=0)
if target > now:
return target
# Fallback: tomorrow (shouldn't happen with 8-day scan)
return now.replace(hour=hour, minute=minute, second=0, microsecond=0) + timedelta(days=1)
logger.debug(f"{label} automation {automation_id} scheduled (in {delay:.0f}s)")
# --- Then Actions (notifications + signals) ---

View file

@ -61,6 +61,24 @@ def rate_limited(func):
return wrapper
# Discogs disambiguates duplicate artist names two ways: numeric `(N)`
# suffix on the older convention (e.g. "Bullet (2)") and a trailing `*`
# on the newer convention (e.g. "John Smith*"). Both are presentation-
# only — the underlying canonical name is what users expect to see in
# search results, on cards, and especially in import paths (otherwise
# library folders end up named `Foo*` on disk). Strip both at every
# point a Discogs payload becomes a name string.
_DISCOGS_DISAMBIG_RE = re.compile(r'(?:\s*\(\d+\))?\s*\*+\s*$|\s*\(\d+\)\s*$')
def _clean_discogs_artist_name(name: Optional[str]) -> str:
"""Strip Discogs disambiguation suffixes — both `(N)` and trailing
`*` from an artist name. Returns '' for None / empty input."""
if not name:
return ''
return _DISCOGS_DISAMBIG_RE.sub('', name).strip()
# --- Shared dataclasses (same shape as iTunes/Deezer/Spotify) ---
@dataclass
@ -117,9 +135,10 @@ class Track:
# Artists from track-level or release-level
track_artists = []
if track_data.get('artists'):
track_artists = [a.get('name', '') for a in track_data['artists'] if a.get('name')]
track_artists = [_clean_discogs_artist_name(a.get('name', '')) for a in track_data['artists'] if a.get('name')]
if not track_artists and release.get('artists'):
track_artists = [a.get('name', '') for a in release['artists'] if a.get('name')]
track_artists = [_clean_discogs_artist_name(a.get('name', '')) for a in release['artists'] if a.get('name')]
track_artists = [a for a in track_artists if a]
if not track_artists:
track_artists = ['Unknown Artist']
@ -190,7 +209,9 @@ class Artist:
return cls(
id=str(artist_data.get('id', '')),
name=artist_data.get('name', artist_data.get('title', '')),
name=_clean_discogs_artist_name(
artist_data.get('name', artist_data.get('title', ''))
),
popularity=0,
genres=[],
followers=0,
@ -216,14 +237,15 @@ class Album:
artists = []
title = release_data.get('title', '')
if release_data.get('artists'):
artists = [a.get('name', '') for a in release_data['artists'] if a.get('name')]
artists = [_clean_discogs_artist_name(a.get('name', '')) for a in release_data['artists'] if a.get('name')]
elif release_data.get('artist'):
artists = [release_data['artist']]
artists = [_clean_discogs_artist_name(release_data['artist'])]
elif ' - ' in title:
# Search results: "Radiohead - OK Computer" → artist="Radiohead", title="OK Computer"
parts = title.split(' - ', 1)
artists = [parts[0].strip()]
artists = [_clean_discogs_artist_name(parts[0])]
title = parts[1].strip()
artists = [a for a in artists if a]
if not artists:
artists = ['Unknown Artist']
@ -444,8 +466,8 @@ class DiscogsClient:
artist_name = ''
if artists and isinstance(artists[0], dict):
artist_name = (artists[0].get('name') or '').strip()
# Strip trailing "(N)" disambiguation suffix Discogs adds.
artist_name = re.sub(r'\s*\(\d+\)$', '', artist_name)
# Strip Discogs disambiguation suffixes — both `(N)` and `*`.
artist_name = _clean_discogs_artist_name(artist_name)
if not title or not artist_name:
continue
@ -658,9 +680,13 @@ class DiscogsClient:
def get_artist_albums(self, artist_id: str, album_type: str = 'album,single', limit: int = 50) -> List[Album]:
"""Get releases by an artist. Prefers master releases, filters features."""
# First get the artist name for feature filtering
# First get the artist name for feature filtering. Strip Discogs
# disambiguation suffix so feature-vs-primary matching below
# compares against the canonical name, not "Beyoncé*".
artist_data = self._api_get(f'/artists/{artist_id}')
artist_name = artist_data.get('name', '').lower() if artist_data else ''
artist_name = _clean_discogs_artist_name(
artist_data.get('name', '') if artist_data else ''
).lower()
data = self._api_get(f'/artists/{artist_id}/releases', {
'sort': 'year', 'sort_order': 'desc', 'per_page': min(limit * 3, 200),
@ -765,7 +791,11 @@ class DiscogsClient:
# Get artists
artists_list = []
if data.get('artists'):
artists_list = [{'name': a.get('name', '')} for a in data['artists'] if a.get('name')]
artists_list = [
{'name': _clean_discogs_artist_name(a.get('name', ''))}
for a in data['artists']
if a.get('name') and _clean_discogs_artist_name(a.get('name', ''))
]
if not artists_list:
artists_list = [{'name': 'Unknown Artist'}]
@ -794,7 +824,11 @@ class DiscogsClient:
# Per-track artists or fall back to release artists
track_artists = artists_list
if t.get('artists'):
track_artists = [{'name': a.get('name', '')} for a in t['artists'] if a.get('name')]
track_artists = [
{'name': _clean_discogs_artist_name(a.get('name', ''))}
for a in t['artists']
if a.get('name') and _clean_discogs_artist_name(a.get('name', ''))
]
tracks.append({
'id': f"{release_id}_t{track_num}",

View file

@ -306,6 +306,18 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD
except Exception as e:
logger.debug("metadata cache lookup for album enrichment failed: %s", e)
# Always include ``track_number`` / ``disc_number``
# in the matched payload — None when unknown rather
# than omitting the key. Downstream consumers
# (``ensure_wishlist_track_format``, post-process
# pipeline) check for None to know "look this up
# somewhere else"; an absent key was indistinguishable
# from "value is 1" after older payload helpers
# silently filled the default. Pre-fix Deezer-sourced
# matches always omitted the key (Deezer's track shape
# uses ``track_position`` and the cache lookup at
# line 304 reads ``track_number`` literally so it
# returns None for Deezer rows).
matched_data = {
'id': best_match.id if hasattr(best_match, 'id') else '',
'name': best_match.name if hasattr(best_match, 'name') else '',
@ -314,11 +326,13 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD
'duration_ms': best_match.duration_ms if hasattr(best_match, 'duration_ms') else 0,
'image_url': match_image,
'source': discovery_source,
'track_number': track_number if track_number else (
getattr(best_match, 'track_number', None)
),
'disc_number': disc_number if disc_number else (
getattr(best_match, 'disc_number', None)
),
}
if track_number:
matched_data['track_number'] = track_number
if disc_number:
matched_data['disc_number'] = disc_number
extra_data = {
'discovered': True,

View file

@ -25,7 +25,7 @@ import shutil
import time
import uuid
from pathlib import Path
from typing import Iterable, Optional
from typing import Any, Callable, Iterable, Optional
from config.settings import config_manager
from utils.logging_config import get_logger
@ -177,6 +177,179 @@ def atomic_copy_to_staging(src: Path, dest: Path) -> bool:
raise
# Number of consecutive None-status reads tolerated before treating the
# job as gone. Sized for the SAB queue→history transition window: SAB
# removes the slot from the queue before adding it to history, and on a
# busy server (par2 verify + unrar) that window can be several poll
# intervals. At the default 2s interval, 5 retries = ~10s of tolerance
# before we give up and emit a terminal failure. Override via
# ``download_source.album_bundle_transient_miss_threshold`` for users
# whose servers need more headroom (very large multi-disc box sets,
# slow disks, etc.).
DEFAULT_TRANSIENT_MISS_THRESHOLD = 5
def get_transient_miss_threshold() -> int:
"""Return the configured transient-miss threshold for poll loops."""
raw = config_manager.get('download_source.album_bundle_transient_miss_threshold',
DEFAULT_TRANSIENT_MISS_THRESHOLD)
try:
value = int(raw)
if value > 0:
return value
except (TypeError, ValueError):
pass
return DEFAULT_TRANSIENT_MISS_THRESHOLD
class TransientMissCounter:
"""Bounded retry counter for adapter status reads.
Both the album-bundle poll (in ``poll_album_download``) and the
per-track download threads in ``usenet.py`` / ``torrent.py`` need
the same "tolerate N consecutive missing or unmapped reads before
declaring the job gone" logic. Lifted into one class so the rule
is in one place and unit-testable in isolation the per-track
paths used to carry inline counters that mirrored this logic by
hand, which is exactly the kind of duplication that drifts."""
def __init__(self, threshold: Optional[int] = None) -> None:
self.threshold = threshold if threshold is not None else get_transient_miss_threshold()
self.misses = 0
def record_miss(self) -> bool:
"""Bump the miss counter. Returns True when the counter has
reached the threshold (caller should give up)."""
self.misses += 1
return self.misses >= self.threshold
def reset(self) -> None:
"""Successful read — reset the counter back to zero."""
self.misses = 0
def poll_album_download(
*,
get_status: Callable[[], Optional[Any]],
title: str,
emit: Callable[..., None],
complete_states: frozenset,
failed_states: frozenset = frozenset(['failed']),
is_shutdown: Optional[Callable[[], bool]] = None,
transient_miss_threshold: int = DEFAULT_TRANSIENT_MISS_THRESHOLD,
poll_interval: Optional[float] = None,
timeout: Optional[float] = None,
sleep: Callable[[float], None] = time.sleep,
monotonic: Callable[[], float] = time.monotonic,
log_prefix: str = '[album_bundle]',
) -> Optional[str]:
"""Drive the per-poll status loop for an album-bundle download.
Lifted out of ``UsenetDownloadPlugin._poll_album_download`` and the
sibling torrent method so the loop is testable in isolation and so
both plugins share the same exit semantics.
Contract:
- ``get_status()`` returns the adapter status object for the bound
job, ``None`` when the client doesn't know about the job
currently (transient or terminal disambiguated by retry count).
- ``emit(state, **fields)`` is the plugin's progress callback —
this function calls it on EVERY successful poll with
``state='downloading'`` and ALWAYS calls it once more with
``state='failed'`` before returning ``None`` on any failure path,
so the UI doesn't freeze on the last 'downloading' emit.
- ``complete_states`` is the adapter's terminal-success set
('completed' alone for usenet; 'seeding' + 'completed' for
torrent because seeding-but-files-on-disk also counts).
- ``failed_states`` is the explicit-failure set. The adapter-level
'error' (unmapped state default) is intentionally NOT in here
that's treated as a transient miss because a real SAB / NZBGet
/ qBit never returns a literal 'error' state on a healthy job;
it's only our default fallback for unknown queue strings. Real
example: SAB's 'Pp' post-processing state was unmapped → became
'error' poll infinite-looped until the 6-hour timeout.
- ``transient_miss_threshold`` is the number of consecutive None /
'error' reads tolerated before declaring the job gone. Sized for
the SAB queuehistory gap window.
Returns the adapter's reported save_path on terminal success, or
``None`` on any failure (timeout / disappeared / explicit failed
/ shutdown). On every failure path emits ``'failed'`` once with an
``error`` field describing why.
"""
interval = poll_interval if poll_interval is not None else get_poll_interval()
deadline = monotonic() + (timeout if timeout is not None else get_poll_timeout())
last_save_path: Optional[str] = None
misses = TransientMissCounter(transient_miss_threshold)
def _fail(reason: str) -> None:
try:
emit('failed', release=title, error=reason)
except Exception as cb_exc:
logger.debug("%s terminal emit failed: %s", log_prefix, cb_exc)
while monotonic() < deadline:
if is_shutdown and is_shutdown():
# Shutdown is a clean exit — don't paint failure on the UI;
# the app is going away anyway.
return None
try:
status = get_status()
except Exception as e:
logger.warning("%s Poll error: %s", log_prefix, e)
status = None
if status is None:
if misses.record_miss():
logger.error(
"%s '%s' missing from client for %d consecutive polls — giving up",
log_prefix, title, misses.misses,
)
_fail('Disappeared from client (no status after retries)')
return None
sleep(interval)
continue
# Reset the miss counter only when the adapter returned a state
# we actually recognise. The default-fallback 'error' is treated
# as a continuing transient miss below, so it must NOT reset
# here — otherwise a persistently-unmapped state loops forever.
if status.state != 'error':
misses.reset()
emit('downloading', progress=status.progress, downloaded=status.downloaded,
speed=status.download_speed)
if status.save_path:
last_save_path = status.save_path
if status.state in complete_states:
return last_save_path
if status.state in failed_states:
error = getattr(status, 'error', None) or 'Client reported failure'
logger.error("%s '%s' failed: %s", log_prefix, title, error)
_fail(error)
return None
if status.state == 'error':
# Unmapped adapter state — see contract docstring. Warn so
# we hear about new states the adapter map needs to grow
# without breaking the user's download. The miss counter
# was intentionally NOT reset above for this branch.
logger.warning(
"%s '%s' returned unmapped state — treating as transient",
log_prefix, title,
)
if misses.record_miss():
_fail('Client returned unmapped state repeatedly')
return None
sleep(interval)
logger.error("%s '%s' timed out", log_prefix, title)
_fail('Download timed out')
return None
def copy_audio_files_atomically(
sources: Iterable[Path], staging_dir: Path,
) -> list:
@ -206,11 +379,15 @@ __all__ = [
"ALBUM_PICK_MAX_BYTES",
"DEFAULT_POLL_INTERVAL_SECONDS",
"DEFAULT_POLL_TIMEOUT_SECONDS",
"DEFAULT_TRANSIENT_MISS_THRESHOLD",
"TransientMissCounter",
"atomic_copy_to_staging",
"copy_audio_files_atomically",
"get_poll_interval",
"get_poll_timeout",
"get_transient_miss_threshold",
"pick_best_album_release",
"poll_album_download",
"quality_score",
"time",
"unique_staging_path",

View file

@ -59,10 +59,12 @@ from typing import Any, Dict, List, Optional, Tuple
from config.settings import config_manager
from core.archive_pipeline import collect_audio_after_extraction
from core.download_plugins.album_bundle import (
TransientMissCounter,
copy_audio_files_atomically,
get_poll_interval,
get_poll_timeout,
pick_best_album_release,
poll_album_download,
)
from core.download_plugins.base import DownloadSourcePlugin
from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult
@ -297,6 +299,11 @@ class TorrentDownloadPlugin(DownloadSourcePlugin):
deadline = time.monotonic() + _POLL_TIMEOUT_SECONDS
last_save_path: Optional[str] = None
# Tolerate transient None reads — covers network blips. Torrent
# adapters don't have an SAB-style queue→history transition,
# but the same tolerance keeps a one-off connection failure
# from killing an otherwise-healthy download.
misses = TransientMissCounter()
while time.monotonic() < deadline:
if self.shutdown_check and self.shutdown_check():
return
@ -307,9 +314,16 @@ class TorrentDownloadPlugin(DownloadSourcePlugin):
status = None
if status is None:
# Adapter forgot about the torrent — probably user-removed.
self._mark_error(download_id, "Torrent disappeared from client")
return
if misses.record_miss():
self._mark_error(
download_id,
f"Torrent disappeared from client (no status after {misses.threshold} polls)",
)
return
time.sleep(_POLL_INTERVAL_SECONDS)
continue
misses.reset()
with self._lock:
row = self.active_downloads.get(download_id)
@ -494,10 +508,32 @@ class TorrentDownloadPlugin(DownloadSourcePlugin):
result['error'] = 'Torrent client refused the release'
return result
# Phase 3: poll until complete.
# Phase 3: poll until complete. The lifted helper handles
# transient missing windows (uncommon for torrents — adapters
# don't have a queue→history transition like SAB — but the
# same path also catches network blips that would otherwise
# take down the whole download) and always emits a terminal
# 'failed' state on failure paths so the UI doesn't freeze on
# the last 'downloading' emit.
_emit('downloading', release=picked.title)
save_path = self._poll_album_download(adapter, torrent_id, picked.title, _emit)
save_path = poll_album_download(
get_status=lambda: run_async(adapter.get_status(torrent_id)),
title=picked.title,
emit=_emit,
# Torrent adapters flip to 'seeding' on completion (files
# on disk, share-ratio progress) — both states count as
# terminal success.
complete_states=frozenset(['seeding', 'completed']),
# qBit / Transmission / Deluge surface a real 'error'
# state when the torrent itself errors (tracker, missing
# files, etc.). That's distinct from the unmapped-state
# default-'error' fallback the helper treats as transient.
failed_states=frozenset(['error']),
is_shutdown=self.shutdown_check,
log_prefix='[Torrent album]',
)
if save_path is None:
# poll_album_download already emitted terminal 'failed'.
result['error'] = 'Torrent download failed or timed out'
return result
@ -522,34 +558,6 @@ class TorrentDownloadPlugin(DownloadSourcePlugin):
result['files'] = copied
return result
def _poll_album_download(self, adapter, torrent_id, title, emit) -> Optional[str]:
"""Poll the adapter until the torrent is complete. Returns
the save path or ``None`` on timeout / failure."""
deadline = time.monotonic() + _POLL_TIMEOUT_SECONDS
last_save_path: Optional[str] = None
while time.monotonic() < deadline:
if self.shutdown_check and self.shutdown_check():
return None
try:
status = run_async(adapter.get_status(torrent_id))
except Exception as e:
logger.warning("[Torrent album] Poll error: %s", e)
status = None
if status is None:
logger.error("[Torrent album] '%s' disappeared from client", title)
return None
emit('downloading', progress=status.progress, downloaded=status.downloaded,
speed=status.download_speed)
if status.save_path:
last_save_path = status.save_path
if status.state in _COMPLETE_STATES:
return last_save_path
if status.state == 'error':
logger.error("[Torrent album] '%s' errored: %s", title, status.error)
return None
time.sleep(_POLL_INTERVAL_SECONDS)
logger.error("[Torrent album] '%s' timed out", title)
return None
# ---------------------------------------------------------------------------

View file

@ -22,8 +22,10 @@ from typing import Any, Dict, List, Optional, Tuple
from core.archive_pipeline import collect_audio_after_extraction
from core.download_plugins.album_bundle import (
TransientMissCounter,
copy_audio_files_atomically,
pick_best_album_release,
poll_album_download,
)
from core.download_plugins.base import DownloadSourcePlugin
from core.download_plugins.torrent import (
@ -227,6 +229,11 @@ class UsenetDownloadPlugin(DownloadSourcePlugin):
deadline = time.monotonic() + _POLL_TIMEOUT_SECONDS
last_save_path: Optional[str] = None
# Tolerate transient None / unmapped 'error' reads — SAB
# removes a job from the queue before adding it to history,
# and on busy servers that gap spans several polls. See
# ``album_bundle.TransientMissCounter`` for the shared rule.
misses = TransientMissCounter()
while time.monotonic() < deadline:
if self.shutdown_check and self.shutdown_check():
return
@ -237,8 +244,17 @@ class UsenetDownloadPlugin(DownloadSourcePlugin):
status = None
if status is None:
self._mark_error(download_id, "Usenet job disappeared from client")
return
if misses.record_miss():
self._mark_error(
download_id,
f"Usenet job disappeared from client (no status after {misses.threshold} polls)",
)
return
time.sleep(_POLL_INTERVAL_SECONDS)
continue
if status.state != 'error':
misses.reset()
with self._lock:
row = self.active_downloads.get(download_id)
@ -258,6 +274,17 @@ class UsenetDownloadPlugin(DownloadSourcePlugin):
if status.state == 'failed':
self._mark_error(download_id, status.error or "Usenet client reported failure")
return
if status.state == 'error':
logger.warning(
"Usenet poll: '%s' returned unmapped state — treating as transient",
job_id,
)
if misses.record_miss():
self._mark_error(
download_id,
"Usenet client returned unmapped state repeatedly",
)
return
time.sleep(_POLL_INTERVAL_SECONDS)
@ -408,8 +435,21 @@ class UsenetDownloadPlugin(DownloadSourcePlugin):
return result
_emit('downloading', release=picked.title)
save_path = self._poll_album_download(adapter, job_id, picked.title, _emit)
save_path = poll_album_download(
get_status=lambda: run_async(adapter.get_status(job_id)),
title=picked.title,
emit=_emit,
# Usenet completes into history as 'completed'; no 'seeding'
# equivalent. Failed is explicit on history failures.
complete_states=frozenset(['completed']),
failed_states=frozenset(['failed']),
is_shutdown=self.shutdown_check,
log_prefix='[Usenet album]',
)
if save_path is None:
# poll_album_download already emitted the terminal 'failed'
# state on every failure path (timeout / disappeared /
# explicit failure / unmapped). UI is unstuck either way.
result['error'] = 'Usenet download failed or timed out'
return result
@ -433,29 +473,3 @@ class UsenetDownloadPlugin(DownloadSourcePlugin):
result['files'] = copied
return result
def _poll_album_download(self, adapter, job_id, title, emit) -> Optional[str]:
deadline = time.monotonic() + _POLL_TIMEOUT_SECONDS
last_save_path: Optional[str] = None
while time.monotonic() < deadline:
if self.shutdown_check and self.shutdown_check():
return None
try:
status = run_async(adapter.get_status(job_id))
except Exception as e:
logger.warning("[Usenet album] Poll error: %s", e)
status = None
if status is None:
logger.error("[Usenet album] '%s' disappeared from client", title)
return None
emit('downloading', progress=status.progress, downloaded=status.downloaded,
speed=status.download_speed)
if status.save_path:
last_save_path = status.save_path
if status.state in _COMPLETE_STATES:
return last_save_path
if status.state == 'failed':
logger.error("[Usenet album] '%s' failed: %s", title, status.error)
return None
time.sleep(_POLL_INTERVAL_SECONDS)
logger.error("[Usenet album] '%s' timed out", title)
return None

View file

@ -36,6 +36,7 @@ import os
from dataclasses import dataclass
from typing import Any, Callable
from core.downloads.track_metadata_backfill import hydrate_download_metadata
from core.runtime_state import (
download_tasks,
matched_context_lock,
@ -247,55 +248,30 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
enhanced_payload['artists'] = [{'name': artist} for artist in track.artists] if track.artists else []
logger.info(f"[Context] Using clean Spotify metadata - Album: '{track.album}', Title: '{track.name}'")
# Get track_number and disc_number — prefer track data we already have,
# fall back to detailed API call only if needed
got_track_number = False
# 1. Try track_info (from frontend, has album track data)
tn = track_info.get('track_number', 0) if isinstance(track_info, dict) else 0
dn = (track_info.get('disc_number') or 1) if isinstance(track_info, dict) else 1
if tn and tn > 0:
enhanced_payload['track_number'] = tn
enhanced_payload['disc_number'] = dn
got_track_number = True
logger.info(f"[Context] Added track_number from track_info: {tn}, disc_number: {dn}")
# 2. Try the track object itself (from album tracks response)
if not got_track_number and hasattr(track, 'track_number') and track.track_number:
enhanced_payload['track_number'] = track.track_number
enhanced_payload['disc_number'] = getattr(track, 'disc_number', 1) or 1
got_track_number = True
logger.info(f"[Context] Added track_number from track object: {track.track_number}, disc_number: {enhanced_payload['disc_number']}")
# 3. Last resort — fetch from metadata source API
if not got_track_number and hasattr(track, 'id') and track.id:
try:
detailed_track = deps.spotify_client.get_track_details(track.id)
if detailed_track and detailed_track.get('track_number'):
enhanced_payload['track_number'] = detailed_track['track_number']
enhanced_payload['disc_number'] = detailed_track.get('disc_number') or 1
got_track_number = True
logger.info(f"[Context] Added track_number from API: {detailed_track['track_number']}, disc_number: {enhanced_payload['disc_number']}")
# Backfill album metadata from detailed track when context
# has incomplete data (missing release_date, total_tracks, etc.)
if isinstance(detailed_track.get('album'), dict):
dt_album = detailed_track['album']
if not spotify_album_context.get('release_date') and dt_album.get('release_date'):
spotify_album_context['release_date'] = dt_album['release_date']
logger.info(f"[Context] Backfilled release_date from API: {dt_album['release_date']}")
if not spotify_album_context.get('album_type') and dt_album.get('album_type'):
spotify_album_context['album_type'] = dt_album['album_type']
if not spotify_album_context.get('total_tracks') and dt_album.get('total_tracks'):
spotify_album_context['total_tracks'] = dt_album['total_tracks']
if not spotify_album_context.get('id') and dt_album.get('id'):
spotify_album_context['id'] = dt_album['id']
if not spotify_album_context.get('image_url') and dt_album.get('images'):
spotify_album_context['image_url'] = dt_album['images'][0].get('url', '')
except Exception as e:
logger.error(f"[Context] API track details failed: {e}")
if not got_track_number:
# Resolve track_number / disc_number and hydrate
# lean album context. Extracted to
# track_metadata_backfill.hydrate_download_metadata
# — see that module for the precedence chain.
# Why the extract: the inline pre-fix coupled
# album-backfill to the "track_number missing"
# branch. When wishlist payloads carried a poisoned
# default-1 track_number (older routes.py used
# ``.get('track_number', 1)``) the API call short-
# circuited and the lean album_context (no
# release_date / total_tracks for Deezer-sourced
# discovery matches) survived untouched, producing
# folders without a year subfolder.
resolved = hydrate_download_metadata(
track, track_info, spotify_album_context, deps.spotify_client,
)
if resolved.track_number is not None:
enhanced_payload['track_number'] = resolved.track_number
enhanced_payload['disc_number'] = resolved.disc_number
logger.info(
f"[Context] Added track_number from {resolved.source}: "
f"{resolved.track_number}, disc_number: {resolved.disc_number}"
)
else:
enhanced_payload.setdefault('track_number', 0)
enhanced_payload.setdefault('disc_number', 1)
logger.warning("[Context] No track_number found from any source")

View file

@ -0,0 +1,275 @@
"""Robust completed-download file finder.
Walks a download directory (and optional transfer directory) to find
the local file matching an API-reported remote filename. Handles:
- Arbitrary subdirectory layouts (slskd flat / username-prefixed /
remote-tree-preserved). Pre-extract callers in the Soulseek
album-bundle path tried three hard-coded candidate paths and
silently failed on layouts that didn't match — see issue #715
(Billy Ocean album task fails after slskd finishes downloading
release). The per-track flow already used a recursive walk and
worked; the bundle path didn't, so users on common slskd configs
with username-prefixed downloads saw bundles time out 22 minutes
after slskd reported every transfer Completed.
- slskd dedup suffix ``_<10-or-more-digit-timestamp>`` appended when
a file with the same basename already exists.
- YouTube / Tidal encoded filename format ``id||title`` the
``||`` half is the human title and used for matching.
- Multiple files sharing a basename disambiguates by counting
how many remote-path directory components appear in the local
path.
This was lifted verbatim from ``web_server._find_completed_file_robust``
so both the per-track download poll AND the Soulseek album-bundle
poll go through one finder. Pre-extract the bundle path probed
three hard-coded candidates only, which is why bundle downloads
on slskd setups with username-prefixed download dirs silently
timed out (#715).
"""
from __future__ import annotations
import logging
import os
import re
from difflib import SequenceMatcher
from typing import Optional, Tuple
from unidecode import unidecode
logger = logging.getLogger(__name__)
AUDIO_EXTENSIONS = frozenset({
'.mp3', '.flac', '.m4a', '.aac', '.ogg', '.opus', '.wav', '.wma',
'.alac', '.aiff', '.aif', '.dsf', '.dff', '.ape',
})
# slskd appends a 10+ digit timestamp suffix when a file with the
# same basename already exists. Match-strip those so we still
# resolve the transfer.
_SLSKD_DEDUP_SUFFIX = re.compile(r'_\d{10,}$')
# AcoustID-quarantined files live under this dirname and must be
# skipped — they are known-wrong matches the verifier rejected.
_QUARANTINE_DIRNAME = 'ss_quarantine'
# Confidence floor for accepting a fuzzy basename match. Anything
# below this is treated as "no match" so we don't drag in unrelated
# files.
_FUZZY_THRESHOLD = 0.85
def _is_audio_candidate(path: str) -> bool:
return os.path.splitext(str(path or ''))[1].lower() in AUDIO_EXTENSIONS
def _normalize_for_finding(text: str) -> str:
"""Match-engine-style normalisation for fuzzy filename comparison.
Lowercases, transliterates unicode, drops bracketed content
("(Remastered 2016)", "[FLAC]"), strips punctuation, collapses
whitespace. Mirrors ``matching_engine.py``'s text normaliser so
finder + matcher agree on equivalence.
"""
if not text:
return ""
text = unidecode(text).lower()
text = re.sub(r'[._/]', ' ', text)
text = re.sub(r'[\[\(].*?[\]\)]', '', text)
text = re.sub(r'[^a-z0-9\s-]', '', text)
return ' '.join(text.split()).strip()
def _extract_basename(api_filename: str) -> str:
"""Cross-platform rightmost-separator split, with YouTube /
Tidal ``id||title`` encoded filenames pre-normalised the id
half is stripped so the title becomes the basename. Mirrors
the strip-then-split order ``web_server`` used."""
if not api_filename:
return ""
if '||' in api_filename:
_id, title = api_filename.split('||', 1)
api_filename = title
last_slash = max(api_filename.rfind('/'), api_filename.rfind('\\'))
return api_filename[last_slash + 1:] if last_slash != -1 else api_filename
def _api_dir_parts(api_filename: str) -> list[str]:
"""Lowercased remote-path directory components, sans the
filename itself. Used to disambiguate when several local files
share the same basename the one whose path mirrors the
remote folder structure wins."""
if not api_filename:
return []
normalized = api_filename.replace('\\', '/')
return [p.lower() for p in normalized.split('/')[:-1] if p]
def _path_matches_api_dirs(file_path: str, api_dirs: list[str]) -> bool:
"""``True`` iff every remote directory component appears as a
path part of the local file. Cheap "is this file on a sibling
tree" check."""
if not api_dirs:
return False
path_parts = set(p.lower() for p in file_path.replace('\\', '/').split('/'))
return all(d in path_parts for d in api_dirs)
def _search_in_directory(
search_dir: str,
location_name: str,
target_basename: str,
normalized_target: str,
api_dirs: list[str],
) -> Tuple[Optional[str], float]:
"""Walk ``search_dir`` once, return (best_match, similarity).
Priority order, highest first:
1. Exact basename match with directory-structure confirmation
2. Exact basename match without disambiguation (when no api_dirs)
3. slskd-dedup-suffix basename match (same two tiers as exact)
4. Best fuzzy basename match above ``_FUZZY_THRESHOLD``
"""
best_fuzzy_path: Optional[str] = None
highest_fuzzy_similarity = 0.0
exact_matches: list[str] = []
for root, dirs, files in os.walk(search_dir):
# Strip quarantine subdir from the walk in place — these
# files are known-bad and matching them would re-poison the
# post-process pipeline.
dirs[:] = [d for d in dirs if d != _QUARANTINE_DIRNAME]
for filename in files:
file_path = os.path.join(root, filename)
if not _is_audio_candidate(file_path):
continue
# Tier 1 + 2: exact basename match.
if filename == target_basename:
if api_dirs and _path_matches_api_dirs(file_path, api_dirs):
logger.info(
"Found path-confirmed match in %s: %s",
location_name, file_path,
)
return file_path, 1.0
if not api_dirs:
logger.info(
"Found exact match in %s: %s",
location_name, file_path,
)
return file_path, 1.0
exact_matches.append(file_path)
continue
# Tier 3: slskd dedup suffix.
stem, ext = os.path.splitext(filename)
stripped_stem = _SLSKD_DEDUP_SUFFIX.sub('', stem)
if stripped_stem != stem and stripped_stem + ext == target_basename:
if api_dirs and _path_matches_api_dirs(file_path, api_dirs):
logger.info(
"Found path-confirmed dedup match in %s: %s",
location_name, file_path,
)
return file_path, 1.0
if not api_dirs:
logger.info(
"Found dedup-suffix match in %s: %s",
location_name, file_path,
)
return file_path, 1.0
exact_matches.append(file_path)
continue
# Tier 4: fuzzy basename match. Cheaper than path-walking
# the whole tree a second time, so always compute and
# keep the best one as a fallback.
normalized_file = _normalize_for_finding(filename)
similarity = SequenceMatcher(
None, normalized_target, normalized_file,
).ratio()
if similarity > highest_fuzzy_similarity:
highest_fuzzy_similarity = similarity
best_fuzzy_path = file_path
if exact_matches:
if len(exact_matches) == 1:
logger.info(
"Found exact match in %s: %s",
location_name, exact_matches[0],
)
return exact_matches[0], 1.0
# Multiple basename collisions — pick the one whose path
# carries the most of the remote directory tree (album
# folder etc.). Breaks ties deterministically.
best = exact_matches[0]
best_score = -1
for m in exact_matches:
m_parts = set(p.lower() for p in m.replace('\\', '/').split('/'))
score = sum(1 for d in api_dirs if d in m_parts)
if score > best_score:
best_score = score
best = m
logger.info(
"Found %d files named '%s' in %s, picked best path match: %s",
len(exact_matches), target_basename, location_name, best,
)
return best, 1.0
return best_fuzzy_path, highest_fuzzy_similarity
def find_completed_audio_file(
download_dir: str,
api_filename: str,
transfer_dir: Optional[str] = None,
) -> Tuple[Optional[str], Optional[str]]:
"""Locate a completed download's local file via recursive walk.
Tries the downloads tree first; if nothing above the fuzzy
threshold lands AND a transfer_dir was passed, tries that too.
Returns ``(file_path, location)`` where ``location`` is
``'downloads'`` / ``'transfer'`` / ``None``. Both elements are
``None`` when the file isn't found anywhere — callers should
treat that as "not yet" (still mid-write) or "lost".
"""
# YouTube / Tidal encoded filenames carry the id ahead of ``||``.
# Strip it up front so basename + dir-component extraction both
# operate on the title half.
if api_filename and '||' in api_filename:
_id, api_filename = api_filename.split('||', 1)
target_basename = _extract_basename(api_filename)
normalized_target = _normalize_for_finding(target_basename)
api_dirs = _api_dir_parts(api_filename)
best_dl_path, dl_sim = _search_in_directory(
download_dir, 'downloads', target_basename, normalized_target, api_dirs,
)
if dl_sim > _FUZZY_THRESHOLD:
if dl_sim < 1.0:
logger.info(
"Found fuzzy match in downloads (%.2f): %s",
dl_sim, best_dl_path,
)
return (best_dl_path, 'downloads')
if transfer_dir and os.path.exists(transfer_dir):
best_tx_path, tx_sim = _search_in_directory(
transfer_dir, 'transfer', target_basename, normalized_target, api_dirs,
)
if tx_sim > _FUZZY_THRESHOLD:
if tx_sim < 1.0:
logger.info(
"Found fuzzy match in transfer (%.2f): %s",
tx_sim, best_tx_path,
)
return (best_tx_path, 'transfer')
return (None, None)
__all__ = ['find_completed_audio_file', 'AUDIO_EXTENSIONS']

View file

@ -49,15 +49,28 @@ def _safe_batch_dirname(batch_id: str) -> str:
return safe or 'batch'
def _cleanup_private_album_bundle_staging(batch_id: str, batch: dict) -> None:
"""Best-effort cleanup for torrent/usenet private staging copies.
_ALBUM_BUNDLE_CLEANED_SOURCES = ('soulseek', 'torrent', 'usenet')
The torrent/usenet clients keep their own completed download folders.
This only removes SoulSync's per-batch copy under album-bundle staging.
def _cleanup_private_album_bundle_staging(batch_id: str, batch: dict) -> None:
"""Best-effort cleanup for album-bundle private staging copies.
Fires when a batch reaches a terminal state. SoulSync's per-batch
copy lives at ``storage/album_bundle_staging/<batch_id>/``
safe to remove because by the time the batch is "complete" the
per-track workers have already claimed their files out of staging
via ``try_staging_match`` and moved them to the Transfer dir.
Pre-fix this only ran for torrent / usenet bundles because the
comment was "slskd keeps its own completed folders" but the
Soulseek bundle path ALSO copies files into the private staging
dir (``soulseek_client.py:1599``), so slskd bundle copies were
leaking forever. Coverage extended to all three bundle-capable
sources.
"""
if not batch.get('album_bundle_private_staging'):
return
if (batch.get('album_bundle_source') or '').lower() not in ('torrent', 'usenet'):
if (batch.get('album_bundle_source') or '').lower() not in _ALBUM_BUNDLE_CLEANED_SOURCES:
return
staging_path = batch.get('album_bundle_staging_path')
@ -85,6 +98,76 @@ def _cleanup_private_album_bundle_staging(batch_id: str, batch: dict) -> None:
logger.warning("[Album Bundle] Could not clean private staging folder %s: %s", staging_path, exc)
def sweep_orphan_album_bundle_staging(
staging_root: str,
*,
active_batch_ids: Optional[set] = None,
) -> int:
"""Remove orphan per-batch dirs from album-bundle staging.
An orphan is a ``<staging_root>/<dirname>`` subdir whose ``dirname``
matches no batch_id in the current ``download_batches`` runtime
state. Happens when:
- The app crashed mid-bundle (cleanup never fired).
- A batch errored on a non-completion code path.
- A pre-extension Soulseek bundle (where cleanup was gated to
torrent/usenet) left a copy behind.
Intended to run ONCE at server startup, before any new batch can
register an active staging dir. That guarantees ``active_batch_ids``
is genuinely empty / pre-existing; we don't race a starting batch.
Returns the count of dirs removed. Safe-by-design:
- Only touches subdirs of the configured staging root.
- Each candidate goes through the same ``_safe_batch_dirname``
name-guard as the per-batch cleanup, so escape-via-symlink
isn't possible.
- Refuses to act on non-directories.
- ``shutil.rmtree`` errors are logged, not raised sweep must
not crash app startup over a permission glitch.
"""
if not staging_root:
return 0
root = Path(staging_root)
if not root.exists() or not root.is_dir():
return 0
active = active_batch_ids if active_batch_ids is not None else set()
# Normalize active batch ids to their on-disk dirname form so the
# set lookup matches what's actually on disk.
active_dirnames = {_safe_batch_dirname(bid) for bid in active if bid}
removed = 0
try:
entries = list(root.iterdir())
except OSError as exc:
logger.warning("[Album Bundle Sweep] Could not list staging root %s: %s", staging_root, exc)
return 0
for entry in entries:
if not entry.is_dir():
continue
# The directory name MUST match what _safe_batch_dirname
# produces — anything else was hand-created and we leave it
# alone. Defensive against stray dirs the user might have
# placed in the staging root.
if entry.name != _safe_batch_dirname(entry.name):
continue
if entry.name in active_dirnames:
continue
try:
shutil.rmtree(entry)
removed += 1
logger.info("[Album Bundle Sweep] Removed orphan staging dir: %s", entry)
except OSError as exc:
logger.warning("[Album Bundle Sweep] Could not remove orphan staging dir %s: %s", entry, exc)
if removed:
logger.info("[Album Bundle Sweep] Cleaned %d orphan staging dir(s) under %s", removed, staging_root)
return removed
@dataclass
class LifecycleDeps:
"""Bundle of cross-cutting deps the batch lifecycle needs."""

View file

@ -167,15 +167,32 @@ def try_staging_match(task_id, batch_id, track, deps: StagingDeps):
Returns True if a match was found and the file was moved to the transfer folder.
Returns False to fall through to normal download.
Every silent-False exit point logs at INFO with the rejection reason
so #706 / #708-class "track staged but never imported, ends up
re-added to wishlist" loops can be diagnosed from app.log without
a re-instrumentation round-trip. Per-candidate skips log at DEBUG
so the noise stays out of INFO unless explicitly turned up.
"""
track_title = (track.name or '').strip() if hasattr(track, 'name') else ''
track_artist = (track.artists[0] if (hasattr(track, 'artists') and track.artists) else '').strip()
# Compact identifier for the log lines below so a multi-batch
# wishlist run can be greppable per-track.
_track_label = f"'{track_title}' by '{track_artist}'"
staging_files = deps.get_staging_file_cache(batch_id or task_id)
if not staging_files:
logger.info(
"[Staging] No match attempted for %s — staging cache empty for batch %s",
_track_label, batch_id or task_id,
)
return False
track_title = track.name or ''
track_artist = track.artists[0] if track.artists else ''
if not track_title:
logger.info(
"[Staging] No match attempted for task %s — track has empty title",
task_id,
)
return False
from difflib import SequenceMatcher
@ -186,12 +203,20 @@ def try_staging_match(task_id, batch_id, track, deps: StagingDeps):
best_match = None
best_score = 0.0
# Track per-candidate scoring so the rejection log can show the
# near-miss that DID exist (useful when title-sim is 0.79 — one
# point below the threshold).
candidate_scores: list = []
for sf in staging_files:
sf_title_variants = _staging_title_variants(sf['title'], normalize)
sf_norm_artist = normalize(sf['artist'])
if not sf_title_variants:
logger.debug(
"[Staging] Skip candidate %s — no usable title variants",
os.path.basename(sf.get('full_path', '?')),
)
continue
# Title similarity (primary)
@ -201,6 +226,12 @@ def try_staging_match(task_id, batch_id, track, deps: StagingDeps):
for candidate in sf_title_variants
)
if title_sim < 0.80:
logger.debug(
"[Staging] Skip candidate %s — title_sim=%.2f below 0.80 threshold (%s vs %s)",
os.path.basename(sf.get('full_path', '?')),
title_sim, norm_title, '|'.join(sf_title_variants[:3]),
)
candidate_scores.append((sf, title_sim, None, 0.0))
continue
# Artist similarity (secondary)
@ -221,12 +252,37 @@ def try_staging_match(task_id, batch_id, track, deps: StagingDeps):
else:
combined = (title_sim * 0.80) + (artist_sim * 0.20)
candidate_scores.append((sf, title_sim, artist_sim, combined))
if combined > best_score:
best_score = combined
best_match = sf
# Require high confidence to avoid false positives
if not best_match or best_score < 0.75:
# Log the rejection with the best near-miss so we can see why
# the staged files didn't claim this wishlist track. Pre-fix
# this returned False silently and the loop "download album,
# stage files, never claim them, re-add to wishlist" was
# impossible to debug from logs alone.
if candidate_scores:
near_miss = max(candidate_scores, key=lambda c: c[3])
sf, title_sim, artist_sim, combined = near_miss
logger.info(
"[Staging] No match for %s in batch %s — best candidate %s "
"(title_sim=%.2f, artist_sim=%s, combined=%.2f) below 0.75 threshold",
_track_label, batch_id or task_id,
os.path.basename(sf.get('full_path', '?')),
title_sim,
f"{artist_sim:.2f}" if artist_sim is not None else 'n/a',
combined,
)
else:
logger.info(
"[Staging] No match for %s in batch %s%d staging files "
"but none had usable title variants",
_track_label, batch_id or task_id, len(staging_files),
)
return False
logger.info(f"[Staging] Match found for '{track_title}' by '{track_artist}': "

View file

@ -265,7 +265,15 @@ def build_batch_status_data(batch_id: str, batch: dict, live_transfers_lookup: d
if album_bundle:
response_data['album_bundle'] = album_bundle
if response_data["phase"] == 'analysis':
if response_data["phase"] in ('analysis', 'queued'):
# Surface analysis_progress for both phases — ``queued`` rows
# haven't started analysis yet (processed=0) but the UI still
# needs ``total`` so it can render "Queued — N tracks". The
# master worker flips phase from ``queued`` to ``analysis`` on
# entry (see ``core/downloads/master.py:328``); the wishlist
# submission sites pre-populate ``analysis_total`` so the
# queued state isn't shape-mismatched against the analysis
# state for downstream consumers.
response_data['analysis_progress'] = {
'total': batch.get('analysis_total', 0),
'processed': batch.get('analysis_processed', 0),

View file

@ -0,0 +1,175 @@
"""Track-position resolution + album-context hydration helper.
Lifted out of ``core/downloads/candidates.py`` to break a quiet
regression. The pre-extract code fetched detailed track data from
Spotify only when ``track_number`` was missing the same API call
*also* backfilled the lean ``spotify_album_context`` (release_date,
total_tracks, image_url) but only as a side-effect of the
track_number branch. When a wishlist row carried a poisoned
``track_number=1`` (older payload helpers defaulted missing values
to 1), the conditional short-circuited, the API call never fired,
and the album context stayed lean producing folders without a
year subfolder for residual per-track wishlist downloads.
The fix splits the two concerns: ``track_number`` resolution
follows its precedence chain (track_info track object API),
but album hydration runs whenever ``spotify_album_context`` is
missing any of release_date / total_tracks regardless of whether
``track_number`` was already known. A single API call still serves
both the side-effect coupling is gone but the network cost
isn't paid twice.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, NamedTuple, Optional
logger = logging.getLogger(__name__)
class ResolvedTrackMetadata(NamedTuple):
"""Result of ``hydrate_download_metadata``.
``track_number`` is ``None`` when every source track_info,
track object, API failed to produce a positive integer. The
caller (candidates.py) treats ``None`` as "fall back to the
setdefault(0)" path so the existing 0-floor sentinel behaviour
is preserved.
"""
track_number: Optional[int]
disc_number: int
source: str # 'track_info' | 'track_object' | 'api' | 'none'
def _positive_int(value: Any) -> Optional[int]:
if isinstance(value, bool):
return None
if isinstance(value, int):
return value if value > 0 else None
if isinstance(value, str) and value.isdigit():
coerced = int(value)
return coerced if coerced > 0 else None
return None
def _album_is_lean(album_context: Any) -> bool:
"""An album context is "lean" when it lacks release_date OR
total_tracks. Both fields are required downstream:
``release_date`` drives the year folder, ``total_tracks`` drives
the TRCK tag denominator + the album-vs-single classification
in ``build_import_album_info``."""
if not isinstance(album_context, dict):
return True
if not album_context.get('release_date'):
return True
if not album_context.get('total_tracks'):
return True
return False
def _backfill_album_context(
album_context: Dict[str, Any], detailed_track: Dict[str, Any]
) -> None:
"""Copy missing fields from ``detailed_track['album']`` into the
in-place album_context dict. Existing values are preserved
never overwritten because the caller's context may carry
fields the API doesn't (e.g. enhanced search shapes can include
artists arrays the bare track endpoint omits)."""
dt_album = detailed_track.get('album')
if not isinstance(dt_album, dict) or not isinstance(album_context, dict):
return
for key in ('release_date', 'album_type', 'total_tracks', 'id'):
if not album_context.get(key) and dt_album.get(key):
album_context[key] = dt_album[key]
if not album_context.get('image_url'):
images = dt_album.get('images')
if isinstance(images, list) and images:
first = images[0]
if isinstance(first, dict) and first.get('url'):
album_context['image_url'] = first['url']
def hydrate_download_metadata(
track: Any,
track_info: Any,
spotify_album_context: Dict[str, Any],
spotify_client: Any,
) -> ResolvedTrackMetadata:
"""Resolve track position and hydrate lean album context.
Steps:
1. ``track_info['track_number']`` when positive
2. ``track.track_number`` when truthy
3. ``spotify_client.get_track_details(track.id)`` fires when
EITHER track_number unresolved OR album_context lean. The
same call serves both concerns; only one round-trip per task.
``spotify_album_context`` is mutated in place when API returns
richer data. Returns the resolved track_number / disc_number /
source. ``track_number=None`` signals "no usable value found";
the caller decides whether to floor it to 1 or leave 0.
"""
ti = track_info if isinstance(track_info, dict) else {}
# Step 1: track_info top-level — wishlist + frontend payloads.
tn = _positive_int(ti.get('track_number'))
if tn is not None:
dn = _positive_int(ti.get('disc_number')) or 1
source = 'track_info'
else:
tn = None
dn = 1
source = 'none'
# Step 2: track object — Spotify Track dataclass from search.
if tn is None:
track_tn = getattr(track, 'track_number', None)
coerced = _positive_int(track_tn)
if coerced is not None:
tn = coerced
dn = _positive_int(getattr(track, 'disc_number', None)) or 1
source = 'track_object'
needs_api_for_tn = tn is None
needs_api_for_album = _album_is_lean(spotify_album_context)
track_id = getattr(track, 'id', None)
if (needs_api_for_tn or needs_api_for_album) and track_id:
try:
detailed = spotify_client.get_track_details(track_id)
except Exception as e: # noqa: BLE001 — defensive log + continue
logger.error("[Context] API track details failed: %s", e)
detailed = None
if isinstance(detailed, dict):
if needs_api_for_tn:
api_tn = _positive_int(detailed.get('track_number'))
if api_tn is not None:
tn = api_tn
dn = _positive_int(detailed.get('disc_number')) or 1
source = 'api'
logger.info(
"[Context] Resolved track_number=%d disc_number=%d from API",
tn, dn,
)
if needs_api_for_album and isinstance(spotify_album_context, dict):
_backfill_album_context(spotify_album_context, detailed)
logger.info(
"[Context] Backfilled album context from API "
"(release_date=%r, total_tracks=%r)",
spotify_album_context.get('release_date'),
spotify_album_context.get('total_tracks'),
)
return ResolvedTrackMetadata(track_number=tn, disc_number=dn, source=source)
__all__ = [
'ResolvedTrackMetadata',
'hydrate_download_metadata',
]

View file

@ -418,8 +418,12 @@ def detect_album_info_web(context, artist_context=None):
context,
album_info={
"album_name": album_name,
"track_number": track_info.get("track_number", 1),
"disc_number": track_info.get("disc_number", 1),
# Preserve missing numbers as None so the import pipeline
# can fall through to ``extract_track_number_from_filename``
# at ``core/imports/pipeline.py:652`` instead of locking
# to track/disc 01 for every wishlist re-attempt.
"track_number": track_info.get("track_number"),
"disc_number": track_info.get("disc_number"),
"album_image_url": album_ctx.get("image_url", ""),
"confidence": 0.5,
},

View file

@ -642,20 +642,19 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
album_info=album_info,
default=original_search.get('title', 'Unknown Track'),
)
track_number = album_info.get('track_number', 1)
# Resolve track_number from the richest available source.
# See ``core/imports/track_number.py`` for the resolution
# chain — pure function, unit-tested in isolation, single
# place to fix the rule.
from core.imports.track_number import resolve_track_number
track_info_for_resolve = context.get('track_info') if isinstance(context, dict) else None
track_number = resolve_track_number(album_info, track_info_for_resolve, file_path)
logger.debug(
"Final track_number processing: source=%s album_info_track_number=%s track_number=%s",
"Final track_number processing: source=%s album_info=%s resolved=%s",
album_info.get('source', 'unknown'),
album_info.get('track_number', 'NOT_FOUND'),
track_number,
)
if track_number is None:
track_number = extract_track_number_from_filename(file_path)
logger.info(
"Track number was None; extracted from filename=%r -> %s",
os.path.basename(file_path),
track_number,
)
if not isinstance(track_number, int) or track_number < 1:
logger.error(f"Invalid track number ({track_number}), defaulting to 1")
track_number = 1

View file

@ -0,0 +1,105 @@
"""Pure-function resolver for the import pipeline's track_number lookup.
Lifted from ``core/imports/pipeline.py`` so the multi-source fallback
chain can be unit-tested in isolation. The pipeline integration is
one call site that delegates to ``resolve_track_number`` and then
applies the >=1 floor as the last-resort default.
Resolution order (first valid positive int wins):
1. ``album_info.track_number`` set by upstream album-info builders
when they have authoritative track position data (e.g. the
album-bundle dispatch from ``core/downloads/master.py``).
2. ``track_info.track_number`` Spotify-shaped track dict carried
on the per-task download context. Populated by the per-track
flow when the wishlist payload still has Spotify's position.
3. ``track_info.spotify_data.track_number`` nested spotify_data
dict inside track_info; common for wishlist-loop payloads that
wrapped the source spotify dict under an outer envelope.
4. ``extract_track_number_from_filename(file_path)`` last resort
when none of the metadata sources carried the value.
Pre-fix, the pipeline only consulted ``album_info`` and fell straight
to the filename when it was None. That broke for VA-collection
source files like ``417 Fountains of Wayne - Stacys Mom.flac`` where
the leading number isn't the album track position — extract returned
None or the wrong number, post-process defaulted to 1, and every
such wishlist import landed as ``01 - <title>`` regardless of the
real source position.
"""
from __future__ import annotations
import json
from typing import Any, Optional
from core.imports.filename import extract_explicit_track_number
def _coerce_positive(value: Any) -> Optional[int]:
"""Coerce ``value`` to a positive int, or return None when the
value is missing / non-numeric / non-positive. Centralised so
every check in ``resolve_track_number`` applies the same rules."""
try:
v = int(value)
return v if v >= 1 else None
except (TypeError, ValueError):
return None
def _coerce_spotify_data(track_info: Any) -> dict:
"""Extract the nested ``spotify_data`` dict from a track_info
payload, coercing string-JSON shapes and bad inputs to an empty
dict so the caller can use ``.get`` safely."""
if not isinstance(track_info, dict):
return {}
raw = track_info.get('spotify_data')
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
try:
parsed = json.loads(raw)
return parsed if isinstance(parsed, dict) else {}
except (ValueError, TypeError):
return {}
return {}
def resolve_track_number(
album_info: Any,
track_info: Any,
file_path: str,
) -> Optional[int]:
"""Walk the resolution chain and return the first valid positive
int found, or None when every source is missing / unusable.
Caller is responsible for the final default-1 floor leaving
that out of this function so tests can pin "everything missing
returns None" separate from the floor behaviour.
"""
album_info = album_info if isinstance(album_info, dict) else {}
track_info = track_info if isinstance(track_info, dict) else {}
spotify_data = _coerce_spotify_data(track_info)
resolved = (
_coerce_positive(album_info.get('track_number'))
or _coerce_positive(track_info.get('track_number'))
or _coerce_positive(spotify_data.get('track_number'))
)
if resolved is not None:
return resolved
# Filename fallback — use the EXPLICIT extractor variant which
# returns 0 when no numeric prefix is recognised (vs. the default
# variant that silently returns 1 for the unknown case). We want
# "unknown" to stay unknown here so the pipeline's final
# default-1 floor is the single source of that fallback —
# otherwise this resolver would silently fill 1 and the
# downstream floor logic would have no effect.
if not file_path:
return None
try:
from_filename = extract_explicit_track_number(file_path)
except Exception:
from_filename = None
return _coerce_positive(from_filename)

View file

@ -120,6 +120,69 @@ class ListenBrainzManager:
"summary": summary
}
def refresh_playlist(self, playlist_mbid: str) -> Dict:
"""Targeted single-playlist refresh.
Reads the cached ``playlist_type`` for the MBID, refetches the
playlist from ListenBrainz, runs the result through
``_update_playlist`` (the same upsert path ``update_all_playlists``
uses). Faster than ``update_all_playlists`` when only one
playlist needs refreshing (no per-type list pulls, no cleanup
sweep, no rolling-series re-walk) the original API was wired
up as the only entry-point even though most callers refresh
exactly one playlist.
Returns
-------
Dict with ``success`` (bool), ``result`` ("updated"/"skipped"/"new")
on success, or ``error`` (str) on failure. Caller is expected
to log + surface any failure the manager does NOT silently
swallow exceptions.
"""
if not self.client.is_authenticated():
logger.warning("ListenBrainz not authenticated, skipping refresh")
return {"success": False, "error": "Not authenticated"}
if not playlist_mbid:
return {"success": False, "error": "No playlist_mbid provided"}
conn = self._get_db_connection()
try:
cursor = conn.cursor()
cursor.execute(
"""
SELECT playlist_type FROM listenbrainz_playlists
WHERE playlist_mbid = ? AND profile_id = ?
""",
(playlist_mbid, self.profile_id),
)
row = cursor.fetchone()
finally:
conn.close()
# ``user`` is the safest default when the playlist isn't in
# cache yet — it maps to the simplest insert path in
# ``_update_playlist`` without triggering created-for-specific
# cleanup logic.
playlist_type = row[0] if row else "user"
logger.info(
f"Refreshing single LB playlist {playlist_mbid} (type={playlist_type})"
)
full_playlist = self.client.get_playlist_details(playlist_mbid)
if not full_playlist:
logger.warning(f"LB returned no data for playlist {playlist_mbid}")
return {"success": False, "error": "Playlist not found upstream"}
result = self._update_playlist(full_playlist, playlist_type)
return {
"success": True,
"result": result,
"playlist_mbid": playlist_mbid,
"playlist_type": playlist_type,
}
def _update_playlist(self, playlist_data: Dict, playlist_type: str) -> str:
"""
Update a single playlist. Returns 'updated', 'skipped', or 'new'

View file

@ -293,6 +293,12 @@ def _run_refresh_phase(
refresh_config = dict(config)
refresh_config['_automation_id'] = None
# Phase 2 below runs the discovery worker with proper progress
# emission — refresh shouldn't run it too. Without this flag, LB
# / Last.fm sources double-discover (5+ minutes silent block on
# the refresh side, then again in Phase 2) and the UI sits on
# "Refreshing:" the whole time.
refresh_config['skip_discovery'] = True
refresh_result = refresh_fn(refresh_config, deps)
refreshed = int(refresh_result.get('refreshed', 0))
refresh_errors = int(refresh_result.get('errors', 0))

View file

@ -14,6 +14,7 @@ in the DB) — there is no process-wide singleton to grab at import time.
from __future__ import annotations
import logging
from typing import Any, Callable, Dict, List, Optional
from core.playlists.sources.base import (
@ -24,6 +25,8 @@ from core.playlists.sources.base import (
SOURCE_LISTENBRAINZ,
)
logger = logging.getLogger(__name__)
# Type alias for the discovery callable: takes a list of MB-shaped
# track dicts, returns a parallel list of matched_data dicts (or None
@ -259,19 +262,51 @@ class ListenBrainzPlaylistSource(PlaylistSource):
return out
def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
"""Trigger a manager-side refresh, then return the new snapshot.
"""Targeted single-playlist refresh via the LB manager.
``update_all_playlists`` is the only refresh entry-point on the
manager it re-fetches every cached playlist. That's wasteful
for a single-playlist refresh; Phase 1 should add a targeted
``refresh_playlist(mbid)`` to the manager."""
Resolves synthetic series ids (``lb_weekly_jams_<user>``) to
the latest MBID, then calls ``manager.refresh_playlist(mbid)``
instead of the legacy ``update_all_playlists`` the old path
re-pulled every cached LB playlist (12+ API calls) just to
refresh one row.
Refresh failures log with traceback + return ``None`` so the
outer handler in ``core/automation/handlers/refresh_mirrored.py``
counts the error and surfaces it. Pre-fix this branch silently
swallowed every exception and read stale cache as if the
refresh succeeded, masking LB API outages as no-op refreshes.
"""
manager = self._manager()
if manager is None:
return None
target_mbid = playlist_id
from core.playlists.lb_series import is_series_synthetic_id
if is_series_synthetic_id(playlist_id):
resolved = self._resolve_series_to_latest_mbid(manager, playlist_id)
if not resolved:
logger.warning(
"LB rolling series %r has no cached members — refresh skipped",
playlist_id,
)
return None
target_mbid = resolved
try:
manager.update_all_playlists()
except Exception: # noqa: S110 — caller falls back to last cached playlist on refresh failure
pass
result = manager.refresh_playlist(target_mbid)
except Exception:
logger.exception("LB refresh_playlist(%s) failed", target_mbid)
return None
if not result.get("success"):
logger.warning(
"LB refresh did not succeed for %s: %s",
target_mbid, result.get("error", "unknown"),
)
# Read back via the same cache lookup ``get_playlist`` uses so
# the meta object preserves the caller's original id (synthetic
# or real). Refresh is decoupled from read intentionally.
return self.get_playlist(playlist_id)
# ---- projection helpers ------------------------------------------------

View file

@ -1536,12 +1536,21 @@ class SoulseekClient(DownloadSourcePlugin):
result['error'] = 'No suitable Soulseek album folder after filtering'
return result
# On the preflight-reuse path ``picked`` is None — the master
# already selected the folder so we never call _pick_album_bundle_folder.
# Read the track count off the preferred_tracks list in that
# case so the log line doesn't misleadingly report "0 tracks".
_log_track_count = (
getattr(picked, 'track_count', 0) if picked is not None
else len(folder_tracks)
)
_log_quality = getattr(picked, 'dominant_quality', '') if picked is not None else ''
logger.info(
"[Soulseek album] Picked %s:%s (%s tracks, quality=%s)",
username,
folder_path,
getattr(picked, 'track_count', 0),
getattr(picked, 'dominant_quality', ''),
_log_track_count,
_log_quality,
)
_emit(
'queued',
@ -1678,6 +1687,19 @@ class SoulseekClient(DownloadSourcePlugin):
interval = get_poll_interval()
completed_paths: Dict[tuple, Path] = {}
failed_states: Dict[tuple, str] = {}
# Track keys where slskd reports the transfer Completed /
# Succeeded but the local file finder can't yet locate the
# file on disk. Usually transient (slskd writes the file
# after announcing completion); becomes a hard failure when
# ALL remaining keys land here long enough — that's the
# symptom from issue #715 (Billy Ocean bundle hung 22 min
# after slskd finished). The grace window keeps the
# transient case from triggering; the all-stuck check
# short-circuits when there's no chance of progress.
unresolved_since: Dict[tuple, float] = {}
# Seconds an "slskd Completed but locally unresolved" key
# has to stay stuck before we give up on it.
_unresolved_grace = 45.0
while time.monotonic() < deadline:
try:
downloads = run_async(self.get_all_downloads())
@ -1716,7 +1738,13 @@ class SoulseekClient(DownloadSourcePlugin):
path = self._resolve_downloaded_album_file(track.filename)
if path:
completed_paths[key] = path
unresolved_since.pop(key, None)
else:
# First time we see slskd report this key as
# completed-but-locally-missing, stamp it.
# Subsequent iterations keep the original
# stamp so the grace window is real wall-time.
unresolved_since.setdefault(key, time.monotonic())
logger.debug(
"[Soulseek album] Transfer completed but local file not found yet: %s",
track.filename,
@ -1739,6 +1767,33 @@ class SoulseekClient(DownloadSourcePlugin):
return []
if len(completed_paths) == len(transfer_keys):
return list(completed_paths.values())
# Early exit when every remaining key is "slskd done +
# locally unresolved past grace". Pre-fix this was the
# silent timeout path from issue #715 — slskd finished
# downloading the whole album, but no local file ever
# resolved, so the poll spun until ``get_poll_timeout()``
# elapsed (default 30+ minutes) before failing the batch.
now = time.monotonic()
still_pending = [
k for k in transfer_keys
if k not in completed_paths and k not in failed_states
]
if still_pending and all(
k in unresolved_since and (now - unresolved_since[k]) >= _unresolved_grace
for k in still_pending
):
logger.error(
"[Soulseek album] %d transfer(s) reported Completed by slskd "
"but no local file could be resolved after %.0fs — likely a "
"``soulseek.download_path`` mismatch (Docker volume / "
"username-prefixed slskd config). Files this poll attempted: %s",
len(still_pending),
_unresolved_grace,
[transfer_keys[k].filename for k in still_pending[:5]],
)
return list(completed_paths.values())
time.sleep(interval)
pending = len(transfer_keys) - len(completed_paths) - len(failed_states)
if completed_paths:
@ -1758,9 +1813,26 @@ class SoulseekClient(DownloadSourcePlugin):
return []
def _resolve_downloaded_album_file(self, remote_filename: str) -> Optional[Path]:
# Pre-fix this tried three hardcoded candidate paths and
# silently returned None on anything else — including the
# common slskd config that nests downloads under
# ``<download_dir>/<username>/<filename>``. That mismatch
# caused issue #715: bundle downloads on those setups
# timed out 22 minutes after slskd reported every transfer
# Completed because the resolver never located a single
# local file.
#
# Now delegates to the shared robust finder which recursively
# walks the download dir by basename + path-confirms via the
# remote directory components. Same logic the per-track flow
# has used since 2.5.9.
from core.downloads.file_finder import find_completed_audio_file
basename = os.path.basename((remote_filename or '').replace('\\', '/'))
if not basename:
return None
# Fast path: the three hardcoded candidates still cover the
# default slskd-flat layout cheaply, and avoid an os.walk
# for the common case. Walk only when none hit.
candidates = [
self.download_path / remote_filename,
self.download_path / basename,
@ -1774,14 +1846,11 @@ class SoulseekClient(DownloadSourcePlugin):
return candidate
except OSError:
continue
try:
matches = list(self.download_path.rglob(basename))
except OSError:
matches = []
for match in matches:
if match.is_file():
return match
return None
found_path, _location = find_completed_audio_file(
str(self.download_path), remote_filename,
)
return Path(found_path) if found_path else None
async def check_connection(self) -> bool:
"""Check if slskd is running and connected to the Soulseek network"""

View file

@ -21,26 +21,31 @@ from utils.logging_config import get_logger
logger = get_logger("usenet.sabnzbd")
# SAB queue states + history states → adapter-uniform set.
# Queue: Idle, Paused, Downloading, Grabbing, Queued, Checking,
# QuickCheck, Verifying, Repairing, Fetching, Extracting, Moving,
# Running, Completed, Failed.
# SAB Status enum (sabnzbd/constants.py:~95-118) → adapter-uniform set.
# SAB emits PascalCase strings (``Idle``, ``Downloading``, ...) under
# the slot ``status`` field; this map is keyed lowercase because
# ``_map_state`` normalises before lookup. Anything unmapped lands on
# ``error`` via ``_map_state``'s default — the album-bundle poll
# helper treats that default as a transient miss so a brand-new
# unmapped state can't infinite-loop the poll.
_SAB_QUEUE_STATE_MAP = {
"idle": "queued",
"queued": "queued",
"grabbing": "queued",
"fetching": "downloading",
"downloading": "downloading",
"paused": "paused",
"checking": "verifying",
"quickcheck": "verifying",
"verifying": "verifying",
"repairing": "repairing",
"extracting": "extracting",
"moving": "extracting",
"running": "extracting",
"completed": "completed",
"failed": "failed",
"idle": "queued",
"queued": "queued",
"grabbing": "queued",
"propagating": "queued",
"fetching": "downloading",
"downloading": "downloading",
"paused": "paused",
"checking": "verifying",
"quickcheck": "verifying",
"verifying": "verifying",
"repairing": "repairing",
"extracting": "extracting",
"moving": "extracting",
"running": "extracting",
"completed": "completed",
"failed": "failed",
"deleted": "failed",
}
@ -156,7 +161,28 @@ class SABnzbdAdapter:
return await loop.run_in_executor(None, self._get_status_sync, job_id)
def _get_status_sync(self, job_id: str) -> Optional[UsenetStatus]:
# Check active queue first; if not found, fall back to history.
# Direct nzo_ids lookup against queue, then history. Falls back
# to the bulk fetch for SAB versions that don't honour the
# nzo_ids filter (very old SAB), but the direct path is the hot
# path because the bulk history fetch was limited to 50 entries
# — on a busy SAB server a recently-completed job would roll
# past the window and the poll would log "disappeared".
if not job_id:
return None
queue = self._call_sync('queue', nzo_ids=job_id)
if queue and isinstance(queue.get('queue'), dict):
for slot in queue['queue'].get('slots', []) or []:
if str(slot.get('nzo_id') or '') == job_id:
return self._parse_queue_slot(slot)
history = self._call_sync('history', nzo_ids=job_id)
if history and isinstance(history.get('history'), dict):
for slot in history['history'].get('slots', []) or []:
if str(slot.get('nzo_id') or '') == job_id:
return self._parse_history_slot(slot)
# Fallback: SAB version pre-dating nzo_ids filter support. The
# bulk path is still limit=50; the helper's transient-miss
# tolerance will cover the gap if the entry briefly rolls out
# of the window.
for status in self._get_all_sync():
if status.id == job_id:
return status

View file

@ -119,16 +119,26 @@ class WishlistGroupingResult:
def group_wishlist_tracks_by_album(
tracks: List[Dict[str, Any]],
*,
min_tracks_per_album: int = 1,
min_tracks_per_album: int = 2,
) -> WishlistGroupingResult:
"""Group wishlist tracks by their owning album.
``min_tracks_per_album`` controls the threshold for promoting an
album to its own sub-batch. Default ``1`` means even a single
missing track gets the album-bundle treatment (which is what the
user wants for releases where they only need one track from the
album). Set higher to require multiple missing tracks before
engaging the bundle search.
album to its own sub-batch. Default ``2`` means an album needs at
least two missing tracks before the album-bundle search engages
single-track items fall to ``residual_tracks`` and take the
classic per-track path. The 1-track case used to default to bundle
too, but real-world wishlists frequently look like "26 single
tracks from 26 different albums," and engaging bundle for each
one downloads ~85% of bandwidth as unwanted files, hammers slskd
with concurrent searches, and re-downloads the same album every
cycle when the staging-match step doesn't claim the requested
track. Bundle shines when several tracks from the same album are
missing that's the case worth the bandwidth premium.
Override via the ``wishlist.album_bundle_min_tracks`` config key
or by passing ``min_tracks_per_album=N`` explicitly (kept for
tests + power users who want different behaviour).
"""
result = WishlistGroupingResult()
if not tracks:

View file

@ -118,8 +118,15 @@ def ensure_wishlist_track_format(track_info):
'artists': artists_list,
'album': album,
'duration_ms': track_info.get('duration_ms', 0),
'track_number': track_info.get('track_number', 1),
'disc_number': track_info.get('disc_number', 1),
# track/disc numbers preserved as-is (no default-to-1 fallback).
# Defaulting to 1 here poisons the downstream chain — the import
# pipeline at ``core/imports/pipeline.py:645`` only runs its
# ``extract_track_number_from_filename`` fallback when this
# value is None, so a pre-filled 1 would lock every wishlist
# re-attempt to track 01 regardless of source filename. Leave
# missing values explicit so the pipeline can detect-and-recover.
'track_number': track_info.get('track_number'),
'disc_number': track_info.get('disc_number'),
'preview_url': track_info.get('preview_url'),
'external_urls': track_info.get('external_urls', {}),
'popularity': track_info.get('popularity', 0),
@ -156,18 +163,33 @@ def build_cancelled_task_wishlist_payload(task, profile_id: int = 1):
album_raw = track_info.get('album', {})
if isinstance(album_raw, dict):
# Full-dict shape — copy as-is so release_date / id / total_tracks
# / artists survive the round-trip. Earlier this only ``setdefault``ed
# name/album_type/images, but ``dict(album_raw)`` already
# preserves every other key the source carries; the setdefaults
# only fill blanks. release_date in particular MUST survive so
# the import path-template renders the year in the folder name.
album_data = dict(album_raw)
album_data.setdefault('name', 'Unknown Album')
album_data.setdefault('album_type', track_info.get('album_type', 'album'))
if 'images' not in album_data and track_info.get('album_image_url'):
album_data['images'] = [{'url': track_info.get('album_image_url')}]
else:
# String-shape album — preserve every adjacent track_info field
# we know about so the constructed dict is still usable
# downstream. Pre-fix this only set name + album_type, dropping
# release_date / total_tracks / id even when the caller had them.
album_data = {
'name': str(album_raw) if album_raw else 'Unknown Album',
'album_type': track_info.get('album_type', 'album'),
}
if track_info.get('album_image_url'):
album_data['images'] = [{'url': track_info.get('album_image_url')}]
if track_info.get('album_release_date') or track_info.get('release_date'):
album_data['release_date'] = (
track_info.get('album_release_date')
or track_info.get('release_date')
)
track_data = {
'id': track_info.get('id'),
@ -175,6 +197,13 @@ def build_cancelled_task_wishlist_payload(task, profile_id: int = 1):
'artists': formatted_artists,
'album': album_data,
'duration_ms': track_info.get('duration_ms'),
# Preserve track / disc position so a cancellation→re-add doesn't
# lock the next attempt to track 01. ``None`` is intentional —
# downstream ``core/imports/pipeline.py:645`` reads None as
# "extract from filename instead", which is what we want when
# the position genuinely isn't known.
'track_number': track_info.get('track_number'),
'disc_number': track_info.get('disc_number'),
}
source_context = {
@ -263,24 +292,76 @@ def track_object_to_dict(track_object) -> Dict[str, Any]:
else:
artists_list.append({"name": str(artist)})
album_name = "Unknown Album"
if hasattr(track_object, "album") and track_object.album:
if hasattr(track_object.album, "name"):
album_name = track_object.album.name
else:
album_name = str(track_object.album)
# Build the album dict by preserving every field the track
# object carries about its album. Pre-fix only ``name`` was
# surfaced — release_date / images / album_type / total_tracks
# / id / artists were all silently dropped during the
# Track→dict conversion that runs whenever the wishlist payload
# arrives as a Track dataclass (vs. a raw spotify_data dict).
# That regression poisoned every wishlist row added from a
# Track object: stored album={'name': X} only, so downstream
# path-template rendering had no year and post-process had no
# track count for relative-position math.
album_attr = getattr(track_object, "album", None) if hasattr(track_object, "album") else None
if isinstance(album_attr, dict):
# Album was already a dict on the track object — preserve
# every key the source put there.
album_dict = dict(album_attr)
album_dict.setdefault("name", "Unknown Album")
elif album_attr is not None and hasattr(album_attr, "name"):
# Album was a sub-object — pull every common field across
# the Spotify / Deezer / iTunes Album dataclass shapes.
album_dict = {
"name": getattr(album_attr, "name", "") or "Unknown Album",
}
for src_attr, dest_key in (
("id", "id"),
("release_date", "release_date"),
("album_type", "album_type"),
("total_tracks", "total_tracks"),
("total_discs", "total_discs"),
("artists", "artists"),
("images", "images"),
("image_url", "image_url"),
):
val = getattr(album_attr, src_attr, None)
if val not in (None, "", [], 0):
album_dict[dest_key] = val
else:
# Album was a bare string OR truthy-but-untyped — pull
# adjacent track-object attrs to flesh it out.
album_name = str(album_attr) if album_attr else "Unknown Album"
album_dict = {"name": album_name}
for src_attr, dest_key in (
("release_date", "release_date"),
("album_id", "id"),
("album_type", "album_type"),
("total_tracks", "total_tracks"),
):
val = getattr(track_object, src_attr, None)
if val not in (None, "", [], 0):
album_dict[dest_key] = val
# ``image_url`` lives on the track object on the Deezer /
# iTunes Track dataclasses — surface as album.images so the
# path template's artwork hook can find it.
img = getattr(track_object, "image_url", None)
if img and "images" not in album_dict:
album_dict["images"] = [{"url": img}]
result = {
"id": getattr(track_object, "id", None),
"name": getattr(track_object, "name", "Unknown Track"),
"artists": artists_list,
"album": {"name": album_name},
"album": album_dict,
"duration_ms": getattr(track_object, "duration_ms", 0),
"preview_url": getattr(track_object, "preview_url", None),
"external_urls": getattr(track_object, "external_urls", {}),
"popularity": getattr(track_object, "popularity", 0),
"track_number": getattr(track_object, "track_number", 1),
"disc_number": getattr(track_object, "disc_number", 1),
# See ``ensure_wishlist_track_format`` — preserve missing
# values as None so the import pipeline's filename fallback
# can fire instead of locking to track/disc 01.
"track_number": getattr(track_object, "track_number", None),
"disc_number": getattr(track_object, "disc_number", None),
}
logger.debug(

View file

@ -20,6 +20,33 @@ module_logger = get_logger("wishlist.processing")
logger = module_logger
# Album-bundle is wasteful for single-track wishlist items (downloads
# the entire album to claim one file), so the bundle path only engages
# when an album has ``N`` or more missing tracks in the wishlist. Default
# 2 catches the "this user is missing several tracks from one album"
# case while keeping single-track-per-album items on the cheaper
# per-track flow. Configurable via ``wishlist.album_bundle_min_tracks``
# for users who want different behaviour.
_DEFAULT_ALBUM_BUNDLE_MIN_TRACKS = 2
def _resolve_album_bundle_threshold() -> int:
"""Return the configured min-tracks-per-album threshold for the
wishlist album-bundle grouper. Falls back to the default when the
config key is missing or carries garbage same defensive shape as
the rest of the config-driven knobs in core/."""
try:
from config.settings import config_manager
raw = config_manager.get('wishlist.album_bundle_min_tracks',
_DEFAULT_ALBUM_BUNDLE_MIN_TRACKS)
value = int(raw)
if value >= 1:
return value
except Exception: # noqa: S110 — defensive config-read fallback; uses default below
pass
return _DEFAULT_ALBUM_BUNDLE_MIN_TRACKS
@dataclass
class WishlistAutoProcessingRuntime:
"""Dependencies needed to run automatic wishlist processing outside the controller."""
@ -533,7 +560,10 @@ def _prepare_and_run_manual_wishlist_batch(
# Tracks the grouper can't bucket fall through to a residual
# batch with the classic per-track flow.
from core.wishlist.album_grouping import group_wishlist_tracks_by_album
grouping = group_wishlist_tracks_by_album(wishlist_tracks)
grouping = group_wishlist_tracks_by_album(
wishlist_tracks,
min_tracks_per_album=_resolve_album_bundle_threshold(),
)
# Build the final payload list (batch_id, tracks, album_context,
# artist_context, is_album). The first payload re-uses the
@ -806,7 +836,10 @@ def process_wishlist_automatically(runtime: WishlistAutoProcessingRuntime, autom
_submitted_batches: list[str] = []
if current_cycle == 'albums':
from core.wishlist.album_grouping import group_wishlist_tracks_by_album
grouping = group_wishlist_tracks_by_album(wishlist_tracks)
grouping = group_wishlist_tracks_by_album(
wishlist_tracks,
min_tracks_per_album=_resolve_album_bundle_threshold(),
)
else:
grouping = None
@ -824,7 +857,19 @@ def process_wishlist_automatically(runtime: WishlistAutoProcessingRuntime, autom
)
with runtime.tasks_lock:
runtime.download_batches[album_batch_id] = {
'phase': 'analysis',
# ``queued`` until the master worker
# picks the batch up from the
# ``missing_download_executor`` pool
# (max_workers=3 by default). The worker
# flips phase to ``analysis`` as its
# first action — see
# ``core/downloads/master.py:328``.
# Pre-fix the row was created with
# ``analysis`` directly, so a wishlist
# run with N > 3 sub-batches looked like
# all N were working when really only
# 3 were running.
'phase': 'queued',
'playlist_id': playlist_id,
'playlist_name': album_batch_name,
'queue': [],
@ -871,7 +916,9 @@ def process_wishlist_automatically(runtime: WishlistAutoProcessingRuntime, autom
playlist_name = f"Wishlist (Auto - {current_cycle.capitalize()})"
with runtime.tasks_lock:
runtime.download_batches[batch_id] = {
'phase': 'analysis',
# See album sub-batch above — ``queued``
# until the master worker picks it up.
'phase': 'queued',
'playlist_id': playlist_id,
'playlist_name': playlist_name,
'queue': [],

View file

@ -47,6 +47,26 @@ def _build_album_images(album: Dict[str, Any]) -> list[dict[str, Any]]:
def _build_track_data(track: Dict[str, Any], album: Dict[str, Any]) -> Dict[str, Any]:
"""Project a wishlist-modal add payload into the canonical
wishlist track shape.
Pre-fix this used default values (``track_number=1``,
``disc_number=1``, ``total_tracks=1``, ``release_date=''``) when
the upstream UI omitted a field. That silently poisoned every
wishlist row added from the library "add to wishlist" modal:
track_number locked to 1 regardless of source position, year
dropped from folder paths because release_date was empty. The
library modal flow is what's used for "add this album's missing
tracks to wishlist" and "add this playlist to wishlist" bulk
actions the most common user path, so the regression was
everywhere.
Now preserve missing values explicitly (None for numeric
positions, omit-or-empty for release_date) so the downstream
import pipeline can detect-and-recover via
``core/imports/track_number.py:resolve_track_number`` instead
of locking to 1.
"""
album_images = _build_album_images(album)
return {
"id": track.get("id"),
@ -58,12 +78,20 @@ def _build_track_data(track: Dict[str, Any], album: Dict[str, Any]) -> Dict[str,
"artists": album.get("artists", []),
"images": album_images,
"album_type": album.get("album_type", "album"),
# release_date stays as whatever the upstream sent
# (including '' when truly unknown). Path template
# gracefully omits the year when empty; we don't fake
# a date.
"release_date": album.get("release_date", ""),
"total_tracks": album.get("total_tracks", 1),
# total_tracks=None preserves "we don't know"; UI uses
# this for category classification + path math. Pre-fix
# default of 1 mislabelled multi-track albums as singles.
"total_tracks": album.get("total_tracks"),
},
"duration_ms": track.get("duration_ms", 0),
"track_number": track.get("track_number", 1),
"disc_number": track.get("disc_number", 1),
# Numeric positions: None when missing, not 1.
"track_number": track.get("track_number"),
"disc_number": track.get("disc_number"),
"explicit": track.get("explicit", False),
"popularity": track.get("popularity", 0),
"preview_url": track.get("preview_url"),

View file

@ -28,6 +28,24 @@ beautifulsoup4==4.14.3
# System monitoring
psutil==7.2.2
# IANA timezone data — required by ``zoneinfo`` on Windows hosts and
# minimal Docker base images that ship without the system tz database.
# Consumed by ``core/automation/schedule.py`` for daily / weekly /
# monthly schedule next-run computation in the user's local timezone.
# Loose-pinned because 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.
tzdata>=2024.1
# Cross-platform IANA timezone detection — returns the server's local
# tz as a string ('America/Los_Angeles', not 'PDT'). Consumed by the
# automation engine to preserve historic behaviour for daily / weekly
# trigger rows that don't carry an explicit ``tz`` field: the old
# engine computed delays from naive ``datetime.now()``, which is
# implicitly the server's local tz, so falling back to the same tz
# keeps existing schedules running at the same wall-clock time.
tzlocal>=5.0
# YouTube support -- unpinned; yt-dlp must track upstream releases to stay functional
yt-dlp>=2026.3.17

View file

@ -0,0 +1,405 @@
"""Engine-level integration tests for the next_run_at refactor (PR 2).
PR 1 lifted next-run computation into ``core/automation/schedule.py``
as a pure function. PR 2 wires the engine through it three setup
methods (daily / weekly / monthly) collapse to one ``_setup_timed_trigger``
helper, ``_finish_run`` drops its inline daily / weekly arithmetic,
and ``monthly_time`` becomes a real registered trigger type.
These tests pin the integration surface:
- ``_finish_run`` dispatches through ``next_run_at`` for every trigger
type with the right args (trigger_type, trigger_config, now_utc,
default_tz) and serialises the result into the DB ``next_run`` column.
- Retry-delay short-circuit bypasses ``next_run_at`` (immediate
reschedule on transient failure, not on the regular cadence).
- Error path swallows + writes None next_run instead of crashing.
- Backward-compat: existing daily / weekly rows without an explicit
``tz`` field use the engine's ``_default_tz`` (server-local IANA),
preserving "every Monday 09:00 server-local" behaviour.
- New ``monthly_time`` trigger registers in ``_trigger_handlers`` and
arms a timer correctly.
- ``_setup_timed_trigger`` honours an existing future ``next_run`` in
the DB (lets manual edits / restart-resume survive).
- ``_dt_to_db_str`` correctly normalises aware + naive datetimes to
the engine's naive-UTC string convention.
"""
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
import pytest
from core.automation_engine import (
AutomationEngine,
_dt_to_db_str,
_resolve_system_default_tz,
)
def _utc(year, month, day, hour=0, minute=0, second=0):
"""Aware UTC datetime for test clarity — matches the convention
used in tests/automation/test_schedule.py."""
return datetime(year, month, day, hour, minute, second, tzinfo=timezone.utc)
# ---------------------------------------------------------------------------
# _dt_to_db_str — engine-side serialiser for ``next_run_at`` results.
# ---------------------------------------------------------------------------
def test_dt_to_db_str_normalises_aware_utc():
"""Aware-UTC datetime → naive-UTC string the DB column expects.
Format matches the engine's existing ``_utc_after``."""
dt = _utc(2026, 5, 27, 14, 30, 0)
assert _dt_to_db_str(dt) == '2026-05-27 14:30:00'
def test_dt_to_db_str_converts_aware_non_utc_to_utc_first():
"""An aware datetime in a non-UTC tz must be converted to UTC
before stringifying otherwise the DB column would silently
hold a tz-shifted instant. This is the bug class the PR 1
tests already cover at the next_run_at layer; pin it here so a
future refactor that drops the ``.astimezone(UTC)`` step fails
fast."""
from zoneinfo import ZoneInfo
la = ZoneInfo('America/Los_Angeles')
dt = datetime(2026, 5, 27, 9, 0, 0, tzinfo=la) # 09:00 PDT
# 09:00 PDT (UTC-7) → 16:00 UTC.
assert _dt_to_db_str(dt) == '2026-05-27 16:00:00'
def test_dt_to_db_str_assumes_naive_is_utc():
"""Defensive — naive inputs are assumed UTC (matches the engine's
convention when parsing the DB column back out)."""
dt = datetime(2026, 5, 27, 14, 30, 0) # naive
assert _dt_to_db_str(dt) == '2026-05-27 14:30:00'
# ---------------------------------------------------------------------------
# _resolve_system_default_tz — engine's tz fallback chain.
# ---------------------------------------------------------------------------
def test_resolve_system_default_tz_returns_iana_string():
"""The engine caches this at import time; the result must be a
string (not a ZoneInfo object) so it can flow into next_run_at's
``default_tz`` param."""
result = _resolve_system_default_tz()
assert isinstance(result, str)
assert len(result) > 0
def test_resolve_system_default_tz_falls_back_to_utc_when_tzlocal_missing():
"""tzlocal is in requirements but the engine should still boot
without it minimal Docker images / dev environments where
tzlocal didn't install. Defensive fallback to UTC instead of
crashing the engine."""
with patch.dict('sys.modules', {'tzlocal': None}):
# Force ImportError on the in-function import.
import importlib
import core.automation_engine as engine_mod
importlib.reload(engine_mod)
result = engine_mod._resolve_system_default_tz()
assert result == 'UTC'
# Reload again to restore normal state for subsequent tests.
importlib.reload(engine_mod)
# ---------------------------------------------------------------------------
# Engine fixture — minimal AutomationEngine with mocked DB.
# ---------------------------------------------------------------------------
@pytest.fixture
def engine_with_db():
"""AutomationEngine wired to a mock DB. Used across the
integration tests below each test sets ``trigger_type`` and
``trigger_config`` on the mock's ``get_automation`` return value."""
db_mock = MagicMock()
db_mock.update_automation_run = MagicMock(return_value=True)
db_mock.update_automation = MagicMock(return_value=True)
db_mock.get_automation.return_value = None # tests override
engine = AutomationEngine(db_mock)
engine._running = True
return engine, db_mock
# ---------------------------------------------------------------------------
# _finish_run — single integration point with next_run_at.
# ---------------------------------------------------------------------------
def test_finish_run_dispatches_interval_trigger_through_next_run_at(engine_with_db):
"""Interval trigger flows through the same next_run_at call as
daily/weekly/monthly no special-case branch left in the engine
for the legacy ``schedule`` type."""
engine, db_mock = engine_with_db
auto = {
'id': 1, 'enabled': True,
'trigger_type': 'schedule',
'trigger_config': json.dumps({'interval': 6, 'unit': 'hours'}),
}
with patch('core.automation_engine.next_run_at') as mock_nra:
mock_nra.return_value = _utc(2026, 5, 27, 18, 0)
engine._finish_run(auto, 1, {'status': 'completed'}, error=None)
assert mock_nra.called
call = mock_nra.call_args
assert call.args[0] == 'schedule'
assert call.args[1] == {'interval': 6, 'unit': 'hours'}
assert call.kwargs['default_tz'] == engine._default_tz
def test_finish_run_dispatches_daily_time_through_next_run_at(engine_with_db):
"""Daily trigger no longer has its own inline arithmetic — the
refactor must route through next_run_at with the unmodified
trigger_config so tz / time fields flow through cleanly."""
engine, db_mock = engine_with_db
auto = {
'id': 1, 'enabled': True,
'trigger_type': 'daily_time',
'trigger_config': json.dumps({'time': '09:00', 'tz': 'America/Los_Angeles'}),
}
with patch('core.automation_engine.next_run_at') as mock_nra:
mock_nra.return_value = _utc(2026, 5, 27, 16, 0)
engine._finish_run(auto, 1, {}, error=None)
assert mock_nra.call_args.args[0] == 'daily_time'
assert mock_nra.call_args.args[1] == {'time': '09:00', 'tz': 'America/Los_Angeles'}
def test_finish_run_dispatches_weekly_time_through_next_run_at(engine_with_db):
"""Weekly trigger same as daily — single integration point."""
engine, db_mock = engine_with_db
cfg = {'time': '09:00', 'days': ['mon', 'wed', 'fri'], 'tz': 'America/Los_Angeles'}
auto = {
'id': 1, 'enabled': True,
'trigger_type': 'weekly_time',
'trigger_config': json.dumps(cfg),
}
with patch('core.automation_engine.next_run_at') as mock_nra:
mock_nra.return_value = _utc(2026, 5, 27, 16, 0)
engine._finish_run(auto, 1, {}, error=None)
assert mock_nra.call_args.args[0] == 'weekly_time'
assert mock_nra.call_args.args[1] == cfg
def test_finish_run_dispatches_monthly_time_through_next_run_at(engine_with_db):
"""New monthly_time trigger — added to _trigger_handlers in PR 2.
Without this entry, the if-trigger_type-in-handlers gate above
skips computation entirely and the DB next_run stays stale."""
engine, db_mock = engine_with_db
cfg = {'time': '09:00', 'day_of_month': 15, 'tz': 'America/Los_Angeles'}
auto = {
'id': 1, 'enabled': True,
'trigger_type': 'monthly_time',
'trigger_config': json.dumps(cfg),
}
with patch('core.automation_engine.next_run_at') as mock_nra:
mock_nra.return_value = _utc(2026, 6, 15, 16, 0)
engine._finish_run(auto, 1, {}, error=None)
assert mock_nra.call_args.args[0] == 'monthly_time'
def test_finish_run_retry_delay_short_circuits_next_run_at(engine_with_db):
"""When a transient failure asks for a retry-delay reschedule
(e.g. action handler returns ``status='retry'``), the next_run
is just now+delay NOT the regular schedule cadence. The
refactor must preserve this short-circuit path."""
engine, db_mock = engine_with_db
auto = {
'id': 1, 'enabled': True,
'trigger_type': 'daily_time',
'trigger_config': json.dumps({'time': '09:00'}),
}
with patch('core.automation_engine.next_run_at') as mock_nra:
engine._finish_run(auto, 1, {}, error='boom', retry_delay_seconds=120)
# next_run_at NOT called — we used the retry delay instead.
mock_nra.assert_not_called()
# DB write happened (with a next_run computed from now + 120s).
assert db_mock.update_automation_run.called
written = db_mock.update_automation_run.call_args.kwargs.get('next_run')
assert written is not None
def test_finish_run_writes_none_when_next_run_at_returns_none(engine_with_db):
"""Defensive — next_run_at can return None for unknown trigger
types or completely broken configs. The engine must write
None to the DB rather than skip the update (which would leave
a stale next_run sitting in the row forever)."""
engine, db_mock = engine_with_db
auto = {
'id': 1, 'enabled': True,
'trigger_type': 'daily_time',
'trigger_config': json.dumps({'time': '09:00'}),
}
with patch('core.automation_engine.next_run_at', return_value=None):
engine._finish_run(auto, 1, {}, error=None)
assert db_mock.update_automation_run.called
assert db_mock.update_automation_run.call_args.kwargs.get('next_run') is None
def test_finish_run_swallows_next_run_at_exception(engine_with_db):
"""next_run_at is pure so it shouldn't raise — but if it does
(programmer error in the helper, weird tz lookup), the engine
must not crash the finish-run cycle. Existing behaviour
swallows + logs at debug; the refactor preserves that."""
engine, db_mock = engine_with_db
auto = {
'id': 1, 'enabled': True,
'trigger_type': 'daily_time',
'trigger_config': json.dumps({'time': '09:00'}),
}
with patch('core.automation_engine.next_run_at', side_effect=RuntimeError('boom')):
engine._finish_run(auto, 1, {}, error=None)
# DB write still happens, just with None next_run.
assert db_mock.update_automation_run.called
def test_finish_run_skips_next_run_for_event_triggers(engine_with_db):
"""Event-based triggers (not in _trigger_handlers) have no
scheduled next-run the existing gate must still skip them
after the refactor."""
engine, db_mock = engine_with_db
auto = {
'id': 1, 'enabled': True,
'trigger_type': 'event',
'trigger_config': json.dumps({}),
}
with patch('core.automation_engine.next_run_at') as mock_nra:
engine._finish_run(auto, 1, {}, error=None)
mock_nra.assert_not_called()
# update_automation_run still fires but with next_run=None.
assert db_mock.update_automation_run.call_args.kwargs.get('next_run') is None
def test_finish_run_passes_engine_default_tz(engine_with_db):
"""Backward-compat: existing daily/weekly rows without ``tz`` in
their config must use the engine's ``_default_tz`` (server-local
IANA via tzlocal). Pre-fix, the engine implicitly used naive
``datetime.now()`` = server local; post-fix the explicit
default_tz preserves that behaviour."""
engine, db_mock = engine_with_db
engine._default_tz = 'America/Los_Angeles' # simulate server-local
auto = {
'id': 1, 'enabled': True,
'trigger_type': 'daily_time',
'trigger_config': json.dumps({'time': '09:00'}), # NO tz field
}
with patch('core.automation_engine.next_run_at') as mock_nra:
mock_nra.return_value = _utc(2026, 5, 27, 16, 0)
engine._finish_run(auto, 1, {}, error=None)
assert mock_nra.call_args.kwargs['default_tz'] == 'America/Los_Angeles'
# ---------------------------------------------------------------------------
# Trigger handler registration.
# ---------------------------------------------------------------------------
def test_engine_registers_monthly_time_trigger(engine_with_db):
"""``monthly_time`` joins schedule / daily_time / weekly_time in
the _trigger_handlers registry without this, calling
``schedule_automation`` on a monthly row falls through the
``trigger_type in self._trigger_handlers`` gate and the
automation never gets armed."""
engine, _ = engine_with_db
assert 'monthly_time' in engine._trigger_handlers
assert callable(engine._trigger_handlers['monthly_time'])
def test_engine_keeps_existing_trigger_registrations(engine_with_db):
"""Backward-compat: the refactor must not drop the historic
trigger types. schedule / daily_time / weekly_time stay
registered alongside the new monthly_time."""
engine, _ = engine_with_db
assert 'schedule' in engine._trigger_handlers
assert 'daily_time' in engine._trigger_handlers
assert 'weekly_time' in engine._trigger_handlers
# ---------------------------------------------------------------------------
# _setup_timed_trigger — shared skeleton for daily / weekly / monthly.
# ---------------------------------------------------------------------------
def test_setup_monthly_time_trigger_writes_next_run_and_arms_timer(engine_with_db):
"""Sanity check that the new monthly handler actually wires up
a timer (it's the new-shaped trigger so a "no timer armed"
regression would otherwise be silent the automation just
never fires)."""
engine, db_mock = engine_with_db
db_mock.get_automation.return_value = {'id': 1, 'next_run': None}
with patch('core.automation_engine.threading.Timer') as mock_timer_cls:
mock_timer = MagicMock()
mock_timer_cls.return_value = mock_timer
engine._setup_monthly_time_trigger(
1, {'time': '09:00', 'day_of_month': 15, 'tz': 'UTC'},
)
# Timer armed.
assert mock_timer.start.called
# next_run written to DB.
assert db_mock.update_automation.called
written = db_mock.update_automation.call_args.kwargs.get('next_run')
assert written is not None and isinstance(written, str)
def test_setup_timed_trigger_honours_future_db_next_run(engine_with_db):
"""If the DB row already has a future ``next_run`` (e.g. a
manual edit, or a process restart picking up where it left
off), the setup must keep that instant not recompute from
scratch. Matches the existing interval-path behaviour and
prevents losing pending retries."""
engine, db_mock = engine_with_db
# Far-future next_run in the DB.
future = (datetime.now(timezone.utc) + timedelta(hours=24)).strftime('%Y-%m-%d %H:%M:%S')
db_mock.get_automation.return_value = {'id': 1, 'next_run': future}
with patch('core.automation_engine.threading.Timer') as mock_timer_cls:
mock_timer_cls.return_value = MagicMock()
engine._setup_daily_time_trigger(1, {'time': '09:00', 'tz': 'UTC'})
# Engine writes the EXISTING next_run back (the if-future-in-DB
# branch overrides the freshly-computed delay).
written = db_mock.update_automation.call_args.kwargs.get('next_run')
assert written == future
def test_setup_timed_trigger_skips_when_next_run_at_returns_none(engine_with_db):
"""If next_run_at can't compute a valid next-run (e.g. broken
config that defeats every defensive fallback in the helper),
the setup must NOT arm a timer with bogus delay. Skip-with-log
is safer than scheduling-for-the-past or scheduling-immediately."""
engine, db_mock = engine_with_db
db_mock.get_automation.return_value = {'id': 1, 'next_run': None}
with patch('core.automation_engine.next_run_at', return_value=None), \
patch('core.automation_engine.threading.Timer') as mock_timer_cls:
engine._setup_monthly_time_trigger(1, {})
# No timer armed.
mock_timer_cls.assert_not_called()
# ---------------------------------------------------------------------------
# End-to-end: real next_run_at + engine wiring (no mocks).
# ---------------------------------------------------------------------------
def test_end_to_end_monthly_schedule_produces_valid_db_string(engine_with_db):
"""No-mock smoke: monthly_time config flows from engine through
the real next_run_at into a valid DB string. Catches any
serialisation drift between PR 1 (helper returns aware UTC) and
PR 2 (engine writes naive UTC string)."""
engine, db_mock = engine_with_db
engine._default_tz = 'UTC'
db_mock.get_automation.return_value = {'id': 1, 'next_run': None}
with patch('core.automation_engine.threading.Timer') as mock_timer_cls:
mock_timer_cls.return_value = MagicMock()
engine._setup_monthly_time_trigger(
1, {'time': '09:00', 'day_of_month': 15},
)
written = db_mock.update_automation.call_args.kwargs['next_run']
# Format matches the engine's existing _utcnow_str / _utc_after.
parsed = datetime.strptime(written, '%Y-%m-%d %H:%M:%S')
# Day-of-month is 15 in the user's tz (UTC here).
assert parsed.day == 15
assert parsed.hour == 9
assert parsed.minute == 0

View file

@ -474,6 +474,14 @@ class TestRefreshMirrored:
def update_all_playlists(self):
pass
def refresh_playlist(self, mbid):
# Adapter now calls the targeted refresh instead of
# the legacy ``update_all_playlists``. Trivial stub —
# the test only cares about the discovery + commit
# paths downstream.
return {'success': True, 'result': 'skipped',
'playlist_mbid': mbid}
discovery_calls = []
def fake_discover(track_dicts):
@ -537,6 +545,95 @@ class TestRefreshMirrored:
assert extra['provider'] == 'spotify'
assert extra['matched_data']['id'] == 'sp-matched'
def test_skip_discovery_flag_bypasses_matcher(self):
"""``skip_discovery=True`` (set by the pipeline runner) must
prevent ``_maybe_discover`` from invoking the matching engine
on LB / Last.fm tracks. Pipeline's Phase 2 runs the discovery
worker with proper progress emission running it during
refresh too blocks the UI for minutes with no updates."""
from core.playlists.sources.bootstrap import build_playlist_source_registry
class _StubLBManager:
def get_cached_playlists(self, playlist_type):
if playlist_type == 'created_for_user':
return [{
'playlist_mbid': 'lb-skip',
'title': 'LB Weekly',
'creator': 'ListenBrainz',
'track_count': 1,
'annotation': {},
'last_updated': '2026-05-26',
}]
return []
def get_playlist_type(self, mbid):
return 'created_for_user' if mbid == 'lb-skip' else ''
def get_cached_tracks(self, mbid):
if mbid == 'lb-skip':
return [{
'track_name': 'MB Song',
'artist_name': 'MB Artist',
'album_name': 'MB Album',
'duration_ms': 240_000,
'recording_mbid': 'rec-skip',
'release_mbid': '',
'album_cover_url': '',
'additional_metadata': {},
}]
return []
def refresh_playlist(self, mbid):
# Test only cares that discovery is skipped, not the
# refresh path itself — keep this trivial.
return {'success': True, 'result': 'skipped'}
discovery_calls = []
def fake_discover(track_dicts):
discovery_calls.append(list(track_dicts))
return [] # never returned because we expect skip
registry = build_playlist_source_registry(
spotify_client_getter=lambda: None,
tidal_client_getter=lambda: None,
qobuz_client_getter=lambda: None,
deezer_client_getter=lambda: None,
listenbrainz_manager_getter=lambda: _StubLBManager(),
discover_callable=fake_discover,
)
db = _StubDB(playlists=[
{
'id': 77,
'name': 'LB Weekly',
'source': 'listenbrainz',
'source_playlist_id': 'lb-skip',
'profile_id': 1,
},
])
deps = _build_deps(get_database=lambda: db)
object.__setattr__(deps, 'playlist_source_registry', registry)
# Pipeline-style call: include the skip_discovery flag.
result = auto_refresh_mirrored(
{'playlist_id': '77', 'skip_discovery': True}, deps,
)
assert result['status'] == 'completed'
assert result['refreshed'] == '1'
# CRITICAL: the matcher was NOT invoked.
assert discovery_calls == []
# The mirror row still landed — just without matched_data.
# Phase 2 of the pipeline picks it up from needs_discovery.
call = db.mirror_calls[0]
assert len(call['tracks']) == 1
# No discovery → no matched_data, and the source_track_id
# falls back to the recording_mbid the LB adapter projects.
track = call['tracks'][0]
assert track['source_track_id'] == 'rec-skip'
def test_spotify_public_uses_authed_spotify_when_signed_in(self):
"""The handler-level fallback chain: when Spotify is authed
AND the public URL is a playlist URL, prefer the authed API so

View file

@ -0,0 +1,519 @@
"""Tests for ``core/automation/schedule.py:next_run_at``.
Pure function over (trigger_type, trigger_config, now_utc, default_tz)
so each case can pin a single rule without monkeypatching the system
clock. Covers the existing engine behaviour (interval, daily, weekly)
plus the new ``monthly_time`` shape PR 1 introduces.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
import pytest
from core.automation.schedule import next_run_at
# ---------------------------------------------------------------------------
# Helper — clear, timezone-aware datetime construction in test bodies.
# ---------------------------------------------------------------------------
def _utc(year: int, month: int, day: int, hour: int = 0, minute: int = 0) -> datetime:
"""Aware UTC datetime — every ``now_utc`` injection in tests
flows through this so a stray timezone bug is impossible."""
return datetime(year, month, day, hour, minute, tzinfo=timezone.utc)
# ---------------------------------------------------------------------------
# Dispatcher: trigger_type routing.
# ---------------------------------------------------------------------------
def test_returns_none_for_unrecognised_trigger_type():
"""Event-based / unknown trigger types are not scheduled — the
caller should NOT write a next_run for them."""
now = _utc(2026, 5, 27, 12, 0)
assert next_run_at('event', {}, now) is None
assert next_run_at('garbage', {'interval': 1}, now) is None
assert next_run_at('', {}, now) is None
def test_returns_none_for_non_dict_config():
"""Defensive — callers may pass through whatever ``json.loads``
returned. Non-dict configs trigger the fallback path which is
'treat as empty dict + use defaults'."""
now = _utc(2026, 5, 27, 12, 0)
# Interval-typed with garbage config falls back to defaults
# (interval=1, unit='hours') rather than crashing.
result = next_run_at('schedule', None, now)
assert result == now + timedelta(hours=1)
# ---------------------------------------------------------------------------
# Interval (``trigger_type='schedule'``)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize('unit,seconds_per_unit', [
('minutes', 60),
('hours', 3600),
('days', 86400),
])
def test_interval_units(unit, seconds_per_unit):
"""Every supported unit scales the interval correctly. Kept in
lockstep with the engine's existing ``_calc_delay_seconds`` map
see _INTERVAL_MULTIPLIERS docstring."""
now = _utc(2026, 5, 27, 12, 0)
result = next_run_at('schedule', {'interval': 3, 'unit': unit}, now)
assert result == now + timedelta(seconds=3 * seconds_per_unit)
def test_interval_weeks_unit_falls_back_to_hours_matching_engine():
"""Engine's ``_calc_delay_seconds`` only recognises minutes / hours
/ days anything else defaults to hours. Drift between this helper
and the engine would silently mis-schedule rows whose config snuck
through with an unsupported unit. Pin the alignment until PR 2
collapses both paths through this function."""
now = _utc(2026, 5, 27, 12, 0)
# 'weeks' is not in our map; falls back to hours.
assert next_run_at('schedule', {'interval': 2, 'unit': 'weeks'}, now) == now + timedelta(hours=2)
def test_interval_unknown_unit_defaults_to_hours():
"""Backward compat with DB rows whose ``unit`` field is missing
or an unrecognised value engine's historic behaviour was to
treat as hours, and we preserve that."""
now = _utc(2026, 5, 27, 12, 0)
assert next_run_at('schedule', {'interval': 2, 'unit': 'fortnights'}, now) == now + timedelta(hours=2)
assert next_run_at('schedule', {'interval': 2}, now) == now + timedelta(hours=2)
def test_interval_clamps_zero_and_negative_to_one():
"""Without a floor a zero/negative interval would schedule for
the past or fire instantly in a loop. Engine clamped to >=1 via
``max(int(interval), 1)``; we preserve that contract."""
now = _utc(2026, 5, 27, 12, 0)
assert next_run_at('schedule', {'interval': 0, 'unit': 'hours'}, now) == now + timedelta(hours=1)
assert next_run_at('schedule', {'interval': -5, 'unit': 'hours'}, now) == now + timedelta(hours=1)
def test_interval_garbage_interval_falls_back_to_one():
"""Non-numeric ``interval`` → default of 1. Survives a JSON column
where the field was typed as a string by an old admin script."""
now = _utc(2026, 5, 27, 12, 0)
assert next_run_at('schedule', {'interval': 'oops', 'unit': 'hours'}, now) == now + timedelta(hours=1)
def test_interval_ignores_tz_field():
"""Interval scheduling is wall-clock-independent — adding 6 hours
is the same in every timezone. The ``tz`` field is ignored even
if a caller mistakenly sets it."""
now = _utc(2026, 5, 27, 12, 0)
result = next_run_at('schedule',
{'interval': 6, 'unit': 'hours', 'tz': 'America/Los_Angeles'},
now)
assert result == now + timedelta(hours=6)
# ---------------------------------------------------------------------------
# Daily (``trigger_type='daily_time'``)
# ---------------------------------------------------------------------------
def test_daily_today_at_future_time_runs_today():
"""It's 12:00 UTC and the schedule says 18:00 UTC — next run is
today at 18:00, not tomorrow."""
now = _utc(2026, 5, 27, 12, 0)
result = next_run_at('daily_time', {'time': '18:00', 'tz': 'UTC'}, now)
assert result == _utc(2026, 5, 27, 18, 0)
def test_daily_today_at_past_time_runs_tomorrow():
"""It's 18:00 UTC and the schedule says 09:00 UTC — next run is
tomorrow at 09:00."""
now = _utc(2026, 5, 27, 18, 0)
result = next_run_at('daily_time', {'time': '09:00', 'tz': 'UTC'}, now)
assert result == _utc(2026, 5, 28, 9, 0)
def test_daily_at_exact_target_time_runs_tomorrow():
"""Edge case: schedule fires at exactly 09:00, and ``now`` is
exactly 09:00. ``<=`` check pushes to tomorrow otherwise we'd
immediately reschedule for the present moment and the engine
would run again in 0s."""
now = _utc(2026, 5, 27, 9, 0)
result = next_run_at('daily_time', {'time': '09:00', 'tz': 'UTC'}, now)
assert result == _utc(2026, 5, 28, 9, 0)
def test_daily_respects_user_timezone_not_server_local():
"""User on Pacific time, schedule says ``09:00 America/Los_Angeles``.
Server is UTC. At 12:00 UTC = 05:00 LA local, next run is 09:00 LA
today = 16:00 UTC. Pre-fix the engine used naive ``datetime.now()``
and read 12:00 as if it were the user's tz, mis-scheduling by the
server-vs-user tz offset."""
now = _utc(2026, 5, 27, 12, 0)
result = next_run_at('daily_time',
{'time': '09:00', 'tz': 'America/Los_Angeles'},
now)
# 09:00 LA on 2026-05-27 → 16:00 UTC (PDT, UTC-7).
assert result == _utc(2026, 5, 27, 16, 0)
def test_daily_falls_back_to_default_tz_when_config_missing():
"""``tz`` field absent on the config — pulls from ``default_tz``
(typically the app-level setting)."""
now = _utc(2026, 5, 27, 12, 0)
result = next_run_at('daily_time', {'time': '09:00'}, now,
default_tz='America/Los_Angeles')
assert result == _utc(2026, 5, 27, 16, 0)
def test_daily_garbage_time_string_defaults_to_midnight():
"""Bad ``time`` string → defaults to 00:00 (engine's existing
behaviour). Better than crashing the scheduler when a row's
config was hand-edited."""
now = _utc(2026, 5, 27, 12, 0)
result = next_run_at('daily_time', {'time': 'garbage', 'tz': 'UTC'}, now)
# 00:00 today already passed → tomorrow at 00:00.
assert result == _utc(2026, 5, 28, 0, 0)
def test_daily_unknown_tz_falls_back_to_utc():
"""Unknown IANA tz string → fall back to UTC rather than crash."""
now = _utc(2026, 5, 27, 12, 0)
result = next_run_at('daily_time',
{'time': '15:00', 'tz': 'Imaginary/Place'},
now)
# Treated as UTC → next run today at 15:00 UTC.
assert result == _utc(2026, 5, 27, 15, 0)
def test_unknown_tz_logs_warning_once(caplog):
"""Silent fallback to UTC was a bug — user configures
'America/Los_Angeles' but tzdata is missing schedule runs at the
wrong hour with no log line. Log once per unknown name so the
misconfiguration is debuggable from a single grep, and don't spam
the log on every poll cycle for the same row."""
import logging
from core.automation import schedule
schedule._UNKNOWN_TZ_WARNED.clear() # fresh state for the test
now = _utc(2026, 5, 27, 12, 0)
with caplog.at_level(logging.WARNING, logger='soulsync.automation.schedule'):
# Two calls with the same bad name — only ONE warning emitted.
next_run_at('daily_time', {'time': '09:00', 'tz': 'Bogus/Tz'}, now)
next_run_at('daily_time', {'time': '09:00', 'tz': 'Bogus/Tz'}, now)
matching = [r for r in caplog.records if 'Bogus/Tz' in r.getMessage()]
assert len(matching) == 1
assert 'tzdata' in matching[0].getMessage().lower()
def test_unknown_tz_warning_includes_helpful_hint():
"""Log line must point the user at the two real causes: typo in
the IANA name, or missing tzdata on the host. Without that hint
the symptom (schedule running at UTC offset) is bewildering."""
import logging
from core.automation import schedule
schedule._UNKNOWN_TZ_WARNED.clear()
caplog_records = []
class _Capture(logging.Handler):
def emit(self, record):
caplog_records.append(record.getMessage())
handler = _Capture()
logger_obj = logging.getLogger('soulsync.automation.schedule')
logger_obj.addHandler(handler)
try:
next_run_at('daily_time', {'time': '09:00', 'tz': 'Made/Up'},
_utc(2026, 5, 27, 12, 0))
finally:
logger_obj.removeHandler(handler)
assert any("'Made/Up'" in m for m in caplog_records)
assert any('IANA' in m for m in caplog_records)
# ---------------------------------------------------------------------------
# DST edge cases — pin that ``zoneinfo``'s default resolution handles
# spring-forward gap + fall-back ambiguity sensibly. Both transitions
# happen in the user's local tz, NOT in UTC, so the result UTC offset
# changes across the boundary.
# ---------------------------------------------------------------------------
def test_dst_spring_forward_lands_after_the_gap():
"""In Los Angeles, 2026-03-08 02:30 doesn't exist — clocks jump
from 02:00 PST directly to 03:00 PDT. A schedule for 02:30 daily
that fires through this transition must NOT raise and must land
on a real instant. ``zoneinfo``'s default resolution maps the
gap to the post-jump side (treating 02:30 as 03:30 PDT), so the
UTC equivalent shifts by an hour relative to non-DST days."""
# 2026-03-08 00:00 UTC = 2026-03-07 16:00 PST (still PST).
# Schedule fires 02:30 LA daily. Next run on 03-07 was 02:30 PST
# = 10:30 UTC. We're querying after that → next run is 03-08
# 02:30 LA, which falls in the spring-forward gap. zoneinfo
# resolves to 03:30 PDT = 10:30 UTC (offset already shifted to
# PDT for the rest of the day post-transition).
now = _utc(2026, 3, 8, 0, 0)
result = next_run_at('daily_time',
{'time': '02:30', 'tz': 'America/Los_Angeles'},
now)
# Must be aware UTC, must NOT crash on the gap.
assert result is not None
assert result.tzinfo is not None
# Result is somewhere on 03-08 — exact time depends on zoneinfo's
# gap-resolution policy, but it must be on the right day and
# past ``now``.
assert result > now
assert result.date() == datetime(2026, 3, 8).date()
def test_dst_fall_back_handles_ambiguous_local_time():
"""2026-11-01 01:30 in Los Angeles happens TWICE (once at PDT
UTC-7, once at PST UTC-8 after the fall-back). A daily schedule
for 01:30 must resolve to ONE instant ``zoneinfo``'s default
picks the first occurrence (PDT), so the UTC time is 08:30."""
# 2026-11-01 00:00 UTC = 2026-10-31 17:00 PDT.
# Next 01:30 LA is 2026-11-01 — ambiguous, zoneinfo defaults to
# the earlier (PDT) instant: 08:30 UTC.
now = _utc(2026, 11, 1, 0, 0)
result = next_run_at('daily_time',
{'time': '01:30', 'tz': 'America/Los_Angeles'},
now)
assert result is not None
# 01:30 PDT (UTC-7) → 08:30 UTC.
assert result == _utc(2026, 11, 1, 8, 30)
def test_weekly_across_dst_boundary_keeps_local_wall_clock():
"""User schedules "every Sunday at 09:00 LA". Crossing the
spring-forward DST boundary, the LOCAL wall clock stays at 09:00
even though the UTC equivalent shifts by an hour. Pre-DST Sunday
09:00 PST = 17:00 UTC; post-DST Sunday 09:00 PDT = 16:00 UTC."""
# Pre-DST Sunday: 2026-03-01.
pre_dst = _utc(2026, 3, 1, 10, 0) # Sunday 02:00 PST already past 09:00? No — 02:00 < 09:00, so today still qualifies.
result_pre = next_run_at('weekly_time',
{'time': '09:00', 'days': ['sun'],
'tz': 'America/Los_Angeles'},
pre_dst)
# 09:00 PST = 17:00 UTC.
assert result_pre == _utc(2026, 3, 1, 17, 0)
# Post-DST Sunday: 2026-03-15 (the 8th was DST switch day).
post_dst = _utc(2026, 3, 15, 10, 0) # 03:00 PDT — before 09:00.
result_post = next_run_at('weekly_time',
{'time': '09:00', 'days': ['sun'],
'tz': 'America/Los_Angeles'},
post_dst)
# 09:00 PDT = 16:00 UTC.
assert result_post == _utc(2026, 3, 15, 16, 0)
# Same local wall clock, different UTC — the kind of bug that
# caused the May 2026 "next in 8h" tz mismatch.
assert result_pre.hour == 17
assert result_post.hour == 16
# ---------------------------------------------------------------------------
# Weekly (``trigger_type='weekly_time'``)
# ---------------------------------------------------------------------------
def test_weekly_picks_next_matching_weekday():
"""It's Wednesday and the schedule wants Mon/Wed/Fri — same day
qualifies if the time is still in the future."""
# 2026-05-27 is a Wednesday.
now = _utc(2026, 5, 27, 8, 0)
result = next_run_at('weekly_time',
{'time': '14:00', 'days': ['mon', 'wed', 'fri'], 'tz': 'UTC'},
now)
assert result == _utc(2026, 5, 27, 14, 0)
def test_weekly_rolls_to_next_allowed_day_when_today_passed():
"""Wednesday 18:00 UTC, schedule wants Mon/Wed/Fri at 14:00 —
Wed 14:00 already passed today, next match is Friday at 14:00."""
now = _utc(2026, 5, 27, 18, 0) # Wed
result = next_run_at('weekly_time',
{'time': '14:00', 'days': ['mon', 'wed', 'fri'], 'tz': 'UTC'},
now)
assert result == _utc(2026, 5, 29, 14, 0) # Fri
def test_weekly_wraps_to_next_week():
"""Sunday past the time, schedule wants only Monday — next match
is the very next day."""
# 2026-05-31 is a Sunday.
now = _utc(2026, 5, 31, 15, 0)
result = next_run_at('weekly_time',
{'time': '09:00', 'days': ['mon'], 'tz': 'UTC'},
now)
assert result == _utc(2026, 6, 1, 9, 0) # next Monday
def test_weekly_empty_days_means_every_day():
"""Empty ``days`` list → treat as every weekday. Matches the
engine's existing fallback in ``_next_weekly_occurrence``."""
now = _utc(2026, 5, 27, 8, 0)
result = next_run_at('weekly_time',
{'time': '14:00', 'days': [], 'tz': 'UTC'},
now)
# Today (Wed) qualifies since 14:00 is still future.
assert result == _utc(2026, 5, 27, 14, 0)
def test_weekly_unrecognised_day_abbreviations_dropped():
"""``'mond'`` / ``'frid'`` are not in the map — silently drop.
If ALL listed days are invalid, fall through to the every-day
default (matches the empty-list behaviour)."""
now = _utc(2026, 5, 27, 8, 0)
result = next_run_at('weekly_time',
{'time': '14:00', 'days': ['mond', 'frid'], 'tz': 'UTC'},
now)
# All garbage → every day → today (Wed) qualifies.
assert result == _utc(2026, 5, 27, 14, 0)
def test_weekly_day_abbreviations_case_insensitive():
"""``MON`` / ``Mon`` / ``mon`` all parse to weekday 0."""
now = _utc(2026, 5, 27, 8, 0) # Wed
result = next_run_at('weekly_time',
{'time': '14:00', 'days': ['MON', 'WED'], 'tz': 'UTC'},
now)
assert result == _utc(2026, 5, 27, 14, 0)
def test_weekly_respects_user_tz_across_day_boundary():
"""It's 23:30 UTC on Wednesday → 16:30 LA local (still Wed).
Schedule fires Mon/Wed/Fri at 18:00 LA. Next run is 18:00 LA
today (Wed in LA, but Thursday in UTC because of the 7h offset)."""
now = _utc(2026, 5, 27, 23, 30) # Wed 23:30 UTC / Wed 16:30 LA
result = next_run_at('weekly_time',
{'time': '18:00', 'days': ['mon', 'wed', 'fri'],
'tz': 'America/Los_Angeles'},
now)
# 2026-05-27 18:00 LA → 2026-05-28 01:00 UTC.
assert result == _utc(2026, 5, 28, 1, 0)
# ---------------------------------------------------------------------------
# Monthly (``trigger_type='monthly_time'`` — NEW in PR 1)
# ---------------------------------------------------------------------------
def test_monthly_picks_target_day_this_month_when_future():
"""It's the 5th, schedule fires on the 15th — next run is the
15th of the current month."""
now = _utc(2026, 5, 5, 12, 0)
result = next_run_at('monthly_time',
{'time': '09:00', 'day_of_month': 15, 'tz': 'UTC'},
now)
assert result == _utc(2026, 5, 15, 9, 0)
def test_monthly_rolls_to_next_month_when_target_day_passed():
"""It's the 20th, schedule fires on the 15th — already past in
May, next run is June 15."""
now = _utc(2026, 5, 20, 12, 0)
result = next_run_at('monthly_time',
{'time': '09:00', 'day_of_month': 15, 'tz': 'UTC'},
now)
assert result == _utc(2026, 6, 15, 9, 0)
def test_monthly_clamps_to_last_day_when_month_too_short():
"""Schedule wants day 31; February has 28 (or 29). Clamp to the
LAST valid day of that month running a day or two early in
short months is less surprising than silently skipping a month
entirely. Standard cron convention."""
now = _utc(2026, 2, 1, 12, 0) # 2026 is not a leap year
result = next_run_at('monthly_time',
{'time': '09:00', 'day_of_month': 31, 'tz': 'UTC'},
now)
# 2026 Feb has 28 days → run on the 28th instead.
assert result == _utc(2026, 2, 28, 9, 0)
def test_monthly_handles_leap_year_february():
"""2024 was a leap year — February has 29 days, so day-31 clamps
to the 29th, not the 28th."""
now = _utc(2024, 2, 1, 12, 0)
result = next_run_at('monthly_time',
{'time': '09:00', 'day_of_month': 31, 'tz': 'UTC'},
now)
assert result == _utc(2024, 2, 29, 9, 0)
def test_monthly_clamps_day_above_31_and_below_1():
"""Defensive — config values outside [1, 31] clamp to the nearest
valid bound rather than crashing the scheduler."""
now = _utc(2026, 5, 5, 12, 0)
high = next_run_at('monthly_time',
{'time': '09:00', 'day_of_month': 99, 'tz': 'UTC'},
now)
low = next_run_at('monthly_time',
{'time': '09:00', 'day_of_month': -5, 'tz': 'UTC'},
now)
# 99 → clamped to 31 → May has 31 days → May 31st.
assert high == _utc(2026, 5, 31, 9, 0)
# -5 → clamped to 1 → next 1st is June 1 (May 1 already passed).
assert low == _utc(2026, 6, 1, 9, 0)
def test_monthly_rolls_year_at_december_to_january():
"""December 20, schedule fires on the 5th — next run is January 5
of the FOLLOWING year, not month 13 of the current year."""
now = _utc(2026, 12, 20, 12, 0)
result = next_run_at('monthly_time',
{'time': '09:00', 'day_of_month': 5, 'tz': 'UTC'},
now)
assert result == _utc(2027, 1, 5, 9, 0)
def test_monthly_respects_user_tz():
"""Schedule wants the 1st of each month at 02:00 LA. ``now`` is
May 1 at 06:00 UTC = April 30 at 23:00 LA. So locally we haven't
hit May 1 02:00 LA yet next run is May 1 02:00 LA = May 1 09:00
UTC (PDT, UTC-7)."""
now = _utc(2026, 5, 1, 6, 0)
result = next_run_at('monthly_time',
{'time': '02:00', 'day_of_month': 1,
'tz': 'America/Los_Angeles'},
now)
assert result == _utc(2026, 5, 1, 9, 0)
# ---------------------------------------------------------------------------
# Result shape — every returned datetime must be aware UTC so the engine
# can serialise it to the DB ``next_run`` column without ambiguity.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize('trigger_type,config', [
('schedule', {'interval': 1, 'unit': 'hours'}),
('daily_time', {'time': '09:00', 'tz': 'America/Los_Angeles'}),
('weekly_time', {'time': '09:00', 'days': ['mon'], 'tz': 'America/Los_Angeles'}),
('monthly_time', {'time': '09:00', 'day_of_month': 15, 'tz': 'America/Los_Angeles'}),
])
def test_result_is_always_aware_utc(trigger_type, config):
"""Engine writes the result as a naive string to the DB but the
convention is "stored as UTC". Returning a naive datetime would
leak the caller's local tz into the column. Pin the contract:
every result has ``tzinfo`` and is at UTC offset zero."""
now = _utc(2026, 5, 27, 12, 0)
result = next_run_at(trigger_type, config, now)
assert result is not None
assert result.tzinfo is not None
assert result.utcoffset() == timedelta(0)
def test_naive_now_utc_is_coerced_to_aware_utc():
"""Defensive — naive ``now_utc`` inputs are assumed UTC and the
result is still aware UTC. Matches the engine's convention
when parsing the DB ``next_run`` column."""
naive_now = datetime(2026, 5, 27, 12, 0)
result = next_run_at('schedule', {'interval': 1, 'unit': 'hours'}, naive_now)
assert result == _utc(2026, 5, 27, 13, 0)
assert result.tzinfo is not None

View file

@ -305,6 +305,64 @@ def test_match_above_threshold_writes_extra_data():
assert deps._db.cache_saves # saved to cache
def test_matched_data_always_includes_track_and_disc_number_keys():
"""Discovery's matched_data must ALWAYS include ``track_number``
and ``disc_number`` keys None when unknown, not omitted. Pre-fix
the keys were only added when truthy, so Deezer-sourced matches
(where the cache stores ``track_position`` not ``track_number``)
saved payloads without the key entirely. Downstream consumers
couldn't distinguish "value is 1" from "key is missing" and the
chain silently filled 1 every time. Pin the consistent-shape
contract here."""
match = _FakeMatch()
match.track_number = None # simulate Deezer-sourced sparse match
match.disc_number = None
tracks = [_track(track_id=1)]
deps = _build_deps(
tracks_by_playlist={'p1': tracks},
spotify_results=[match],
score_result=(match, 0.95, 0),
)
dp.run_playlist_discovery_worker([_playlist('p1')], deps=deps)
assert len(deps._db.extra_data_writes) == 1
_, extra = deps._db.extra_data_writes[0]
matched = extra['matched_data']
# Keys MUST be present even when value is None — downstream relies
# on explicit None to know "look this up elsewhere".
assert 'track_number' in matched
assert 'disc_number' in matched
assert matched['track_number'] is None
assert matched['disc_number'] is None
def test_matched_data_pulls_track_number_from_best_match_when_cache_misses():
"""Cache enrichment may return None (Deezer key-mismatch case),
but the Track dataclass best_match itself often carries the
track_number from the source-shape mapping. matched_data must
fall back to ``best_match.track_number`` instead of silently
dropping the field."""
match = _FakeMatch()
match.track_number = 8 # populated by Track.from_deezer_track
match.disc_number = 2
tracks = [_track(track_id=1)]
deps = _build_deps(
tracks_by_playlist={'p1': tracks},
spotify_results=[match],
score_result=(match, 0.95, 0),
)
dp.run_playlist_discovery_worker([_playlist('p1')], deps=deps)
matched = deps._db.extra_data_writes[0][1]['matched_data']
# When the cache lookup returns None for track_number, fall back
# to best_match.track_number (populated by the Track dataclass'
# from_<source>_track classmethod).
assert matched['track_number'] == 8
assert matched['disc_number'] == 2
def test_match_below_threshold_falls_back_to_wing_it():
"""No high-confidence match → Wing It stub written."""
match = _FakeMatch()

View file

@ -0,0 +1,237 @@
"""Tests for ``sweep_orphan_album_bundle_staging``.
The album-bundle staging dir (``storage/album_bundle_staging/<batch_id>/``)
accumulates orphan subdirs when:
- The app crashed mid-bundle (per-batch cleanup never fired).
- A batch errored on a code path the cleanup gate didn't catch.
- A pre-fix Soulseek bundle ran (the cleanup gate was torrent/usenet
only slskd bundle copies leaked).
The sweep runs once at server startup, BEFORE any new batch can
register a staging dir, so ``active_batch_ids`` is empty / pre-existing.
Tests pin: orphans removed, active dirs preserved, name-guard rejects
escape attempts, sweep no-ops gracefully on missing/empty roots,
non-dir entries skipped.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from core.downloads.lifecycle import sweep_orphan_album_bundle_staging
def _make_batch_dir(root: Path, name: str, with_file: bool = True) -> Path:
"""Create ``root/name/`` with optional placeholder content."""
bd = root / name
bd.mkdir(parents=True, exist_ok=True)
if with_file:
(bd / 'leftover.flac').write_bytes(b'audio')
return bd
# ---------------------------------------------------------------------------
# Happy path — orphans removed, active preserved.
# ---------------------------------------------------------------------------
def test_removes_orphan_dirs_when_no_active_batches(tmp_path):
"""No batches running → every dir under staging root is orphan."""
root = tmp_path / 'album_bundle_staging'
a = _make_batch_dir(root, 'b_abc123')
b = _make_batch_dir(root, 'b_def456')
removed = sweep_orphan_album_bundle_staging(str(root))
assert removed == 2
assert not a.exists()
assert not b.exists()
assert root.exists() # Root itself preserved.
def test_preserves_active_batch_dirs(tmp_path):
"""Dirs whose batch_id is in the active set must NOT be removed.
Belt-and-suspenders sweep runs at startup before batches exist,
but the active-set guard protects against a runtime re-sweep too."""
root = tmp_path / 'album_bundle_staging'
active = _make_batch_dir(root, 'b_running')
orphan = _make_batch_dir(root, 'b_dead')
removed = sweep_orphan_album_bundle_staging(
str(root), active_batch_ids={'b_running'},
)
assert removed == 1
assert active.exists()
assert not orphan.exists()
def test_active_batch_id_with_special_chars_normalises_for_match(tmp_path):
"""The on-disk dirname is ``_safe_batch_dirname(batch_id)``
(alphanumeric + ``-`` + ``_``). The sweep normalises the
provided active batch ids the same way so a batch_id like
``user:123`` (which lands on disk as ``user_123``) still gets
correctly excluded from the orphan set."""
root = tmp_path / 'album_bundle_staging'
active = _make_batch_dir(root, 'user_123')
removed = sweep_orphan_album_bundle_staging(
str(root), active_batch_ids={'user:123'},
)
assert removed == 0
assert active.exists()
# ---------------------------------------------------------------------------
# Safe-by-design — defensive guards against escape / weird state.
# ---------------------------------------------------------------------------
def test_no_op_when_staging_root_missing(tmp_path):
"""Staging root doesn't exist (fresh install) → no-op, no error."""
missing = tmp_path / 'nope'
removed = sweep_orphan_album_bundle_staging(str(missing))
assert removed == 0
def test_no_op_when_staging_root_empty(tmp_path):
"""Root exists but empty → returns 0 cleanly."""
root = tmp_path / 'album_bundle_staging'
root.mkdir()
removed = sweep_orphan_album_bundle_staging(str(root))
assert removed == 0
def test_no_op_when_staging_root_path_empty():
"""Empty string config value → no-op."""
assert sweep_orphan_album_bundle_staging('') == 0
def test_skips_non_directory_entries(tmp_path):
"""Stray files in the staging root must NOT be removed — sweep
only touches batch-id-shaped subdirs."""
root = tmp_path / 'album_bundle_staging'
root.mkdir()
stray_file = root / 'README.txt'
stray_file.write_text('do not delete')
orphan = _make_batch_dir(root, 'b_orphan')
removed = sweep_orphan_album_bundle_staging(str(root))
assert removed == 1
assert stray_file.exists()
assert not orphan.exists()
def test_skips_dirs_with_unsafe_names(tmp_path):
"""A dir whose name doesn't round-trip through
``_safe_batch_dirname`` (e.g. contains ``..`` or a colon) must
be left alone defensive against hand-placed dirs the user
might have created under the staging root for other purposes."""
root = tmp_path / 'album_bundle_staging'
root.mkdir()
# ``.git`` would normalise to ``_git`` so the name doesn't
# round-trip → sweep ignores it.
hand_made = root / '.git'
hand_made.mkdir()
(hand_made / 'config').write_text('[core]')
orphan = _make_batch_dir(root, 'b_orphan')
removed = sweep_orphan_album_bundle_staging(str(root))
assert removed == 1
assert hand_made.exists() # Unsafe-name dir preserved.
assert not orphan.exists()
def test_partial_failure_does_not_abort_remaining(tmp_path, monkeypatch):
"""If shutil.rmtree fails on one dir (permission denied etc.),
the sweep logs and continues must not abort and leak the
rest."""
root = tmp_path / 'album_bundle_staging'
blocking = _make_batch_dir(root, 'b_blocked')
okay = _make_batch_dir(root, 'b_ok')
import core.downloads.lifecycle as lc_mod
real_rmtree = lc_mod.shutil.rmtree
def _selective_rmtree(path, *args, **kwargs):
if Path(path).name == 'b_blocked':
raise OSError('permission denied')
return real_rmtree(path, *args, **kwargs)
monkeypatch.setattr(lc_mod.shutil, 'rmtree', _selective_rmtree)
removed = sweep_orphan_album_bundle_staging(str(root))
assert removed == 1
assert blocking.exists()
assert not okay.exists()
def test_returns_zero_when_listdir_raises(tmp_path, monkeypatch):
"""If the staging root can't be iterated (rare, e.g. permission
issue), sweep logs + returns 0 instead of crashing startup."""
root = tmp_path / 'album_bundle_staging'
root.mkdir()
_make_batch_dir(root, 'b_orphan')
import core.downloads.lifecycle as lc_mod
real_iterdir = Path.iterdir
def _broken_iterdir(self):
if self == root:
raise OSError('listdir blew up')
return real_iterdir(self)
monkeypatch.setattr(Path, 'iterdir', _broken_iterdir)
removed = sweep_orphan_album_bundle_staging(str(root))
assert removed == 0
# ---------------------------------------------------------------------------
# active_batch_ids edge cases.
# ---------------------------------------------------------------------------
def test_none_active_batch_ids_treated_as_empty(tmp_path):
"""``active_batch_ids=None`` (the default) → every dir is orphan."""
root = tmp_path / 'album_bundle_staging'
a = _make_batch_dir(root, 'b_a')
b = _make_batch_dir(root, 'b_b')
removed = sweep_orphan_album_bundle_staging(str(root), active_batch_ids=None)
assert removed == 2
assert not a.exists()
assert not b.exists()
def test_active_set_ignores_empty_or_none_entries(tmp_path):
"""Defensive — caller may pass a set containing None / empty
strings from a partially-initialised state. Skip them so they
don't accidentally match the dirname ``batch`` (the
``_safe_batch_dirname`` fallback)."""
root = tmp_path / 'album_bundle_staging'
orphan = _make_batch_dir(root, 'b_orphan')
removed = sweep_orphan_album_bundle_staging(
str(root), active_batch_ids={'', None, 'b_other'},
)
assert removed == 1
assert not orphan.exists()
if __name__ == '__main__':
pytest.main([__file__, '-v'])

View file

@ -350,6 +350,33 @@ def test_batch_completion_cleans_private_album_bundle_staging(tmp_path):
assert not staging_dir.exists()
def test_batch_completion_cleans_soulseek_bundle_staging(tmp_path):
"""Regression: Soulseek bundles also copy files into the private
staging dir (``soulseek_client.py:1599``). Pre-fix the cleanup
gate excluded ``soulseek`` because of an outdated comment about
slskd "keeping its own completed folders" so slskd bundle
copies leaked under storage/album_bundle_staging forever.
Now soulseek is in the cleanup set alongside torrent / usenet."""
staging_dir = tmp_path / 'b_slskd'
staging_dir.mkdir()
(staging_dir / 'leftover.flac').write_bytes(b'audio')
download_tasks['t1'] = {'status': 'completed', 'track_info': {'name': 'X'}}
download_batches['b_slskd'] = {
'queue': ['t1'], 'queue_index': 1, 'active_count': 1,
'max_concurrent': 1, 'permanently_failed_tracks': [],
'cancelled_tracks': set(),
'album_bundle_private_staging': True,
'album_bundle_source': 'soulseek',
'album_bundle_staging_path': str(staging_dir),
}
deps, _ = _build_deps()
lc.on_download_completed('b_slskd', 't1', True, deps)
assert not staging_dir.exists()
def test_batch_completion_keeps_unexpected_staging_path(tmp_path):
staging_dir = tmp_path / 'shared-staging'
staging_dir.mkdir()

View file

@ -91,6 +91,25 @@ def test_analysis_phase_includes_analysis_progress_and_results():
assert out['analysis_results'] == [{'track_index': 0, 'found': True}]
def test_queued_phase_surfaces_analysis_progress_for_ui_count():
"""A batch in ``queued`` state hasn't been picked up by the
executor yet, so analysis_processed is 0. The UI still needs
``analysis_total`` so it can render "Queued — N tracks" instead
of showing an empty card. Pre-fix the queued phase fell through
to the default branch and the UI lost the track count entirely."""
deps, _ = _build_deps()
batch = {
'phase': 'queued',
'analysis_total': 17,
'analysis_processed': 0,
'analysis_results': [],
}
out = st.build_batch_status_data('b1', batch, {}, deps)
assert out['phase'] == 'queued'
assert out['analysis_progress'] == {'total': 17, 'processed': 0}
assert out['analysis_results'] == []
def test_album_downloading_phase_exposes_bundle_progress_without_task_safety_valve():
deps, _ = _build_deps(config=_FakeConfig({'soulseek.download_timeout': 1}))
download_tasks['t1'] = {

View file

@ -0,0 +1,345 @@
"""Tests for ``core/downloads/file_finder.py``.
Real-world regression these tests pin: the Soulseek album-bundle
post-process previously had its own three-candidate probe for the
local downloaded file. When slskd was configured to nest downloads
under a username subdir (a common config) NONE of the three
candidates matched, the poll silently timed out 22 minutes later,
and the batch went to "failed" even though slskd had successfully
downloaded every track of the album. The per-track download path
already used the recursive-walk finder and worked fine these
tests pin the lifted shared finder so both paths now find files
no matter what layout slskd writes.
Issue: #715 (Billy Ocean — Download album task fails after slskd
finishes downloading release).
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from core.downloads.file_finder import find_completed_audio_file
def _touch(path: Path, content: bytes = b'\x00\x00'):
"""Create a small placeholder file at ``path``."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
# ---------------------------------------------------------------------------
# Layout coverage — slskd writes downloads to many different paths
# depending on its config. The bundle resolver must find the file in
# all of these layouts; the pre-lift code only handled the first two.
# ---------------------------------------------------------------------------
def test_finds_file_in_flat_slskd_layout(tmp_path):
"""Default slskd config: ``<download_dir>/<basename>``."""
downloads = tmp_path / 'downloads'
target = downloads / '01 - Suddenly.flac'
_touch(target)
found, location = find_completed_audio_file(
str(downloads), r'shared\Billy Ocean\Very Best Of\01 - Suddenly.flac',
)
assert found == str(target)
assert location == 'downloads'
def test_finds_file_under_username_subdir(tmp_path):
"""Regression for #715. slskd configured with
``directories.downloads.username = true`` writes to
``<download_dir>/<username>/<filename>``. Pre-lift the bundle
resolver missed this layout because it only probed
``download_dir/filename`` / ``download_dir/basename`` /
``download_dir/normalized_remote_path`` none included a
username segment, so every file looked missing."""
downloads = tmp_path / 'downloads'
target = downloads / '3opgkrpokgreg' / '01 - Suddenly.flac'
_touch(target)
found, location = find_completed_audio_file(
str(downloads), r'shared\Billy Ocean\Very Best Of\01 - Suddenly.flac',
)
assert found == str(target)
assert location == 'downloads'
def test_finds_file_when_slskd_preserves_remote_tree(tmp_path):
"""slskd config where the remote sharer's folder tree is
mirrored locally ``<download_dir>/shared/<artist>/<album>/<file>``."""
downloads = tmp_path / 'downloads'
target = downloads / 'shared' / 'Billy Ocean' / 'Very Best Of' / '01 - Suddenly.flac'
_touch(target)
found, location = find_completed_audio_file(
str(downloads), r'shared\Billy Ocean\Very Best Of\01 - Suddenly.flac',
)
assert found == str(target)
assert location == 'downloads'
def test_finds_file_in_deeply_nested_user_tree(tmp_path):
"""Some slskd setups combine username + preserved tree. Finder
walks recursively so depth doesn't matter."""
downloads = tmp_path / 'downloads'
target = (downloads / '3opgkrpokgreg' / 'Music'
/ 'Billy Ocean' / 'Very Best Of'
/ '01 - Suddenly.flac')
_touch(target)
found, location = find_completed_audio_file(
str(downloads), r'shared\Billy Ocean\Very Best Of\01 - Suddenly.flac',
)
assert found == str(target)
def test_returns_none_when_file_missing(tmp_path):
"""File genuinely not on disk → both elements ``None``."""
downloads = tmp_path / 'downloads'
downloads.mkdir()
_touch(downloads / '02 - Caribbean Queen.flac') # different file
found, location = find_completed_audio_file(
str(downloads), r'shared\Billy Ocean\Very Best Of\01 - Suddenly.flac',
)
assert found is None
assert location is None
# ---------------------------------------------------------------------------
# Disambiguation — when multiple files share a basename, the path
# whose components mirror the remote tree wins.
# ---------------------------------------------------------------------------
def test_path_confirms_against_remote_dirs_when_basename_collides(tmp_path):
"""Two albums both contain ``01 - Intro.flac``. The finder
picks the one whose path carries the most remote-dir components
keeps two simultaneous bundle downloads from claiming each
other's file."""
downloads = tmp_path / 'downloads'
other_album = downloads / 'Some Other Artist' / 'Other Album' / '01 - Intro.flac'
target_album = downloads / 'Billy Ocean' / 'Very Best Of' / '01 - Intro.flac'
_touch(other_album)
_touch(target_album)
found, _ = find_completed_audio_file(
str(downloads), r'shared\Billy Ocean\Very Best Of\01 - Intro.flac',
)
assert found == str(target_album)
def test_returns_only_match_when_no_disambiguation_possible(tmp_path):
"""Single basename match in the tree → return it regardless of
whether the remote dir components line up."""
downloads = tmp_path / 'downloads'
target = downloads / 'random' / 'subdir' / '01 - Suddenly.flac'
_touch(target)
found, _ = find_completed_audio_file(
str(downloads), r'shared\Billy Ocean\Very Best Of\01 - Suddenly.flac',
)
assert found == str(target)
# ---------------------------------------------------------------------------
# slskd dedup suffix — when a file with the same name already exists,
# slskd appends ``_<timestamp>`` to the new file. The finder must
# strip this and still match the original API filename.
# ---------------------------------------------------------------------------
def test_matches_slskd_dedup_suffix(tmp_path):
"""``Song.flac`` requested → ``Song_639067852665564677.flac``
on disk (slskd dedup). Finder strips the timestamp suffix and
returns the deduped file."""
downloads = tmp_path / 'downloads'
target = downloads / '01 - Suddenly_639067852665564677.flac'
_touch(target)
found, _ = find_completed_audio_file(
str(downloads), r'shared\Billy Ocean\Very Best Of\01 - Suddenly.flac',
)
assert found == str(target)
def test_dedup_suffix_short_digits_not_treated_as_dedup(tmp_path):
"""Year-style ``_2007`` (4 digits) is NOT slskd's dedup format —
that's a 10+ digit timestamp. A file like ``Greatest Hits_2007.flac``
that exists alongside the requested ``Greatest Hits.flac`` must
not be returned as a tier-1 dedup match. (Fuzzy may still grab
it as a lower-confidence fallback when no real match exists,
which is intentional the strict-dedup tier is what we're
pinning here.)"""
downloads = tmp_path / 'downloads'
# Real match exists too — so the dedup-incorrect file doesn't
# win by default. Tests the priority order.
real_match = downloads / 'Billy Ocean' / '01 - Suddenly.flac'
near_miss = downloads / '01 - Suddenly_2007.flac'
_touch(real_match)
_touch(near_miss)
found, _ = find_completed_audio_file(
str(downloads), r'shared\Billy Ocean\Very Best Of\01 - Suddenly.flac',
)
# Real exact-basename + path-confirmed match must win over the
# year-suffixed file.
assert found == str(real_match)
# ---------------------------------------------------------------------------
# Special inputs — YouTube/Tidal encoded filenames, quarantine,
# empty paths, transfer-dir fallback.
# ---------------------------------------------------------------------------
def test_skips_quarantine_subdir(tmp_path):
"""Files under ``ss_quarantine/`` are known-wrong AcoustID
rejects the finder must ignore them so a quarantined-but-still-
present file doesn't get re-claimed."""
downloads = tmp_path / 'downloads'
quarantined = downloads / 'ss_quarantine' / '01 - Suddenly.flac'
real = downloads / 'Billy Ocean' / '01 - Suddenly.flac'
_touch(quarantined)
_touch(real)
found, _ = find_completed_audio_file(
str(downloads), r'shared\Billy Ocean\Very Best Of\01 - Suddenly.flac',
)
assert found == str(real)
def test_handles_youtube_tidal_encoded_filename(tmp_path):
"""YouTube / Tidal downloads encode the API filename as
``id||title``. The finder strips the id and matches against
the title half."""
downloads = tmp_path / 'downloads'
target = downloads / 'My Song.mp3'
_touch(target)
found, _ = find_completed_audio_file(str(downloads), 'abc123||My Song.mp3')
assert found == str(target)
def test_falls_back_to_transfer_dir_when_download_dir_misses(tmp_path):
"""File has already moved into the transfer dir. The finder
falls through to the second search root rather than returning
None covers the post-process race where a file is mid-move."""
downloads = tmp_path / 'downloads'
downloads.mkdir()
transfer = tmp_path / 'transfer'
moved = transfer / 'Billy Ocean' / '01 - Suddenly.flac'
_touch(moved)
found, location = find_completed_audio_file(
str(downloads),
r'shared\Billy Ocean\Very Best Of\01 - Suddenly.flac',
transfer_dir=str(transfer),
)
assert found == str(moved)
assert location == 'transfer'
def test_returns_none_when_neither_dir_has_file(tmp_path):
"""Missing in both download AND transfer → ``(None, None)``."""
downloads = tmp_path / 'downloads'
transfer = tmp_path / 'transfer'
downloads.mkdir()
transfer.mkdir()
found, location = find_completed_audio_file(
str(downloads),
r'shared\Billy Ocean\Very Best Of\01 - Suddenly.flac',
transfer_dir=str(transfer),
)
assert found is None
assert location is None
def test_ignores_non_audio_files(tmp_path):
"""Cover art, NFO, and text files in the slskd dir must not be
surfaced as audio matches even when their stem aligns."""
downloads = tmp_path / 'downloads'
_touch(downloads / '01 - Suddenly.txt') # wrong extension
_touch(downloads / '01 - Suddenly.nfo')
_touch(downloads / 'cover.jpg')
target = downloads / '01 - Suddenly.flac'
_touch(target)
found, _ = find_completed_audio_file(
str(downloads), r'shared\Billy Ocean\Very Best Of\01 - Suddenly.flac',
)
assert found == str(target)
def test_empty_api_filename_returns_none(tmp_path):
"""Defensive — empty / None filename can't resolve to anything."""
downloads = tmp_path / 'downloads'
downloads.mkdir()
_touch(downloads / '01 - Suddenly.flac')
found, location = find_completed_audio_file(str(downloads), '')
assert found is None
assert location is None
# ---------------------------------------------------------------------------
# Fuzzy fallback — when no exact / dedup match lands, the finder
# falls back to the closest-basename fuzzy match above 0.85.
# ---------------------------------------------------------------------------
def test_fuzzy_matches_punctuation_variant(tmp_path):
"""Soulseek shares vary in separator style — underscore vs
dash vs period. The normaliser collapses all three to spaces
so the fuzzy comparator can match across the variation."""
downloads = tmp_path / 'downloads'
# On disk: underscore. API filename: dash. Both normalise to
# the same token stream.
target = downloads / "01_Caribbean_Queen.flac"
_touch(target)
found, _ = find_completed_audio_file(
str(downloads),
r"shared\Billy Ocean\Very Best Of\01 - Caribbean Queen.flac",
)
assert found == str(target)
def test_fuzzy_rejects_low_similarity(tmp_path):
"""A completely different filename in the same dir must not
fuzzy-match the 0.85 floor keeps unrelated files out."""
downloads = tmp_path / 'downloads'
_touch(downloads / 'random-unrelated-file.flac')
found, _ = find_completed_audio_file(
str(downloads), r'shared\Billy Ocean\Very Best Of\01 - Suddenly.flac',
)
assert found is None
if __name__ == '__main__':
pytest.main([__file__, '-v'])

View file

@ -0,0 +1,511 @@
"""Tests for ``core/downloads/track_metadata_backfill.py``.
Real-world regression these tests pin: wishlist rows carrying a
poisoned ``track_number=1`` (older payload helpers defaulted
missing values to 1) used to prevent the Spotify-API backfill
that hydrated lean ``spotify_album_context`` (release_date,
total_tracks). Result: residual per-track wishlist downloads
produced folders without a year subfolder when the wishlist row
came from a Deezer-sourced discovery match.
The split-concern fix:
- ``track_number`` precedence: track_info track object API
- album hydration: runs whenever release_date / total_tracks
missing, INDEPENDENT of whether track_number was already known
- single API call serves both no double round-trip
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Optional
from unittest.mock import MagicMock
import pytest
from core.downloads.track_metadata_backfill import (
ResolvedTrackMetadata,
hydrate_download_metadata,
)
@dataclass
class _FakeTrack:
"""Stand-in for the Spotify Track dataclass — only the attrs the
backfill helper reads."""
id: Optional[str] = 'spotify_track_id'
track_number: Optional[int] = None
disc_number: Optional[int] = None
def _api_payload(
track_number: Optional[int] = 5,
disc_number: int = 1,
release_date: str = '2013-10-22',
total_tracks: int = 16,
album_id: str = 'spotify_album_id',
image_url: str = 'https://i.scdn.co/cover.jpg',
album_type: str = 'album',
) -> dict:
"""Build a plausible ``spotify_client.get_track_details`` response."""
images: list[dict[str, Any]] = []
if image_url:
images.append({'url': image_url, 'height': 640, 'width': 640})
return {
'id': 'spotify_track_id',
'name': 'Roar',
'track_number': track_number,
'disc_number': disc_number,
'album': {
'id': album_id,
'name': 'PRISM (Deluxe)',
'release_date': release_date,
'total_tracks': total_tracks,
'album_type': album_type,
'images': images,
},
}
# ---------------------------------------------------------------------------
# Track-number precedence chain.
# ---------------------------------------------------------------------------
def test_track_info_track_number_wins_over_track_object():
"""track_info has a real value → use it, skip lower-priority sources."""
client = MagicMock()
album_ctx = {'release_date': '2013-10-22', 'total_tracks': 16}
resolved = hydrate_download_metadata(
_FakeTrack(track_number=9), # would resolve to 9 if track_info empty
{'track_number': 5, 'disc_number': 2},
album_ctx,
client,
)
assert resolved == ResolvedTrackMetadata(track_number=5, disc_number=2, source='track_info')
client.get_track_details.assert_not_called()
def test_track_object_used_when_track_info_missing():
"""track_info has no track_number → fall to track.track_number."""
client = MagicMock()
album_ctx = {'release_date': '2013-10-22', 'total_tracks': 16}
resolved = hydrate_download_metadata(
_FakeTrack(track_number=7, disc_number=2),
{}, # no track_number key
album_ctx,
client,
)
assert resolved == ResolvedTrackMetadata(track_number=7, disc_number=2, source='track_object')
client.get_track_details.assert_not_called()
def test_api_used_when_track_info_and_track_object_missing():
"""No local source has track_number → fire the API."""
client = MagicMock()
client.get_track_details.return_value = _api_payload(track_number=8, disc_number=1)
album_ctx = {'release_date': '2013-10-22', 'total_tracks': 16}
resolved = hydrate_download_metadata(
_FakeTrack(track_number=None),
{},
album_ctx,
client,
)
assert resolved == ResolvedTrackMetadata(track_number=8, disc_number=1, source='api')
client.get_track_details.assert_called_once_with('spotify_track_id')
def test_zero_track_number_in_track_info_treated_as_missing():
"""track_info.track_number=0 is a sentinel for "missing", not a
valid position fall through to the next source."""
client = MagicMock()
client.get_track_details.return_value = _api_payload(track_number=3)
album_ctx = {'release_date': '2013-10-22', 'total_tracks': 16}
resolved = hydrate_download_metadata(
_FakeTrack(track_number=None),
{'track_number': 0},
album_ctx,
client,
)
assert resolved.track_number == 3
assert resolved.source == 'api'
def test_string_track_number_coerced_to_int():
"""Some legacy payloads pass track_number as a string. Coerce, not reject."""
client = MagicMock()
album_ctx = {'release_date': '2013-10-22', 'total_tracks': 16}
resolved = hydrate_download_metadata(
_FakeTrack(),
{'track_number': '11'},
album_ctx,
client,
)
assert resolved.track_number == 11
assert resolved.source == 'track_info'
def test_boolean_track_number_rejected_not_treated_as_int():
"""Python ints include bools — ``True == 1`` would erroneously
pass the >0 gate. Reject explicitly so a malformed payload
falls through instead of being mistaken for track 1."""
client = MagicMock()
client.get_track_details.return_value = _api_payload(track_number=4)
album_ctx = {'release_date': '2013-10-22', 'total_tracks': 16}
resolved = hydrate_download_metadata(
_FakeTrack(),
{'track_number': True},
album_ctx,
client,
)
assert resolved.track_number == 4
assert resolved.source == 'api'
# ---------------------------------------------------------------------------
# THE REGRESSION FIX: album backfill runs even when track_number known.
# ---------------------------------------------------------------------------
def test_poisoned_default_track_number_does_NOT_block_album_backfill():
"""Regression pin. track_info carries the poisoned ``track_number=1``
legacy default; album_context is lean (Deezer-sourced discovery
match, no release_date, no total_tracks). Pre-fix: API skipped
because tn>0, folder lost year. Post-fix: API still fires for
album hydration; track_number stays at 1 (caller's precedence),
but release_date / total_tracks now populate."""
client = MagicMock()
client.get_track_details.return_value = _api_payload(
track_number=4, # the API knows the *real* track number
release_date='2007-03-12',
total_tracks=12,
)
album_ctx = {'name': 'Welcome Interstate Managers'} # lean
resolved = hydrate_download_metadata(
_FakeTrack(id='spotify_track_id', track_number=None),
{'track_number': 1}, # POISONED default
album_ctx,
client,
)
# track_number stays at 1 — track_info precedence is sacred,
# we don't second-guess it. The album fix is what matters.
assert resolved.track_number == 1
assert resolved.source == 'track_info'
# CRITICAL: album_context got hydrated despite track_number being "resolved".
assert album_ctx['release_date'] == '2007-03-12'
assert album_ctx['total_tracks'] == 12
# API call still fired — for the album, even though tn already known.
client.get_track_details.assert_called_once_with('spotify_track_id')
def test_rich_album_context_skips_api_when_track_number_resolved():
"""Cost guard. Both concerns satisfied locally → no API call.
Keeps the network-cost contract from the pre-extract code."""
client = MagicMock()
album_ctx = {
'name': 'PRISM (Deluxe)',
'release_date': '2013-10-22',
'total_tracks': 16,
}
resolved = hydrate_download_metadata(
_FakeTrack(),
{'track_number': 5},
album_ctx,
client,
)
assert resolved.track_number == 5
client.get_track_details.assert_not_called()
def test_api_fires_for_lean_album_even_with_track_object_track_number():
"""track_number from the Track dataclass (search-result level) but
album_context still lean API must fire for album hydration."""
client = MagicMock()
client.get_track_details.return_value = _api_payload()
album_ctx = {'name': 'PRISM (Deluxe)'} # lean
resolved = hydrate_download_metadata(
_FakeTrack(track_number=9, disc_number=1),
{},
album_ctx,
client,
)
assert resolved.track_number == 9
assert resolved.source == 'track_object'
assert album_ctx['release_date'] == '2013-10-22'
assert album_ctx['total_tracks'] == 16
client.get_track_details.assert_called_once()
def test_album_with_only_release_date_still_triggers_backfill_for_total_tracks():
"""release_date populated but total_tracks missing → still lean →
API fires to fill total_tracks. Either missing field triggers."""
client = MagicMock()
client.get_track_details.return_value = _api_payload()
album_ctx = {'release_date': '2013-10-22'} # total_tracks missing
hydrate_download_metadata(_FakeTrack(), {'track_number': 5}, album_ctx, client)
assert album_ctx['total_tracks'] == 16
client.get_track_details.assert_called_once()
def test_album_with_only_total_tracks_still_triggers_backfill_for_release_date():
"""Inverse: total_tracks present but release_date missing → still lean."""
client = MagicMock()
client.get_track_details.return_value = _api_payload()
album_ctx = {'total_tracks': 16} # release_date missing
hydrate_download_metadata(_FakeTrack(), {'track_number': 5}, album_ctx, client)
assert album_ctx['release_date'] == '2013-10-22'
client.get_track_details.assert_called_once()
# ---------------------------------------------------------------------------
# Album backfill: preserve existing values, never overwrite.
# ---------------------------------------------------------------------------
def test_album_backfill_does_not_overwrite_existing_release_date():
"""Caller's release_date is sacred — only fill when absent. Source
of truth may legitimately diverge between context and API (e.g.
region-specific releases). Don't second-guess the caller."""
client = MagicMock()
client.get_track_details.return_value = _api_payload(release_date='2099-01-01')
album_ctx = {'release_date': '2013-10-22', 'total_tracks': 16, 'name': 'Real'}
hydrate_download_metadata(_FakeTrack(), {'track_number': 1}, album_ctx, client)
# Not lean → API never called.
client.get_track_details.assert_not_called()
assert album_ctx['release_date'] == '2013-10-22'
def test_album_backfill_fills_missing_image_url_from_images_array():
"""API responses use the Spotify ``images`` array shape. Helper
flattens to the ``image_url`` string the path builder reads."""
client = MagicMock()
client.get_track_details.return_value = _api_payload(
image_url='https://i.scdn.co/cover.jpg',
)
album_ctx = {'name': 'PRISM'} # lean
hydrate_download_metadata(_FakeTrack(), {}, album_ctx, client)
assert album_ctx['image_url'] == 'https://i.scdn.co/cover.jpg'
def test_album_backfill_handles_empty_images_array_gracefully():
"""API response with empty ``images`` array (rare but possible
on tracks the album-cover service hasn't indexed yet) must not
crash the resolver."""
client = MagicMock()
payload = _api_payload(image_url='') # produces images=[]
client.get_track_details.return_value = payload
album_ctx = {'name': 'PRISM'}
hydrate_download_metadata(_FakeTrack(), {}, album_ctx, client)
# No image_url set, but other fields hydrated.
assert 'image_url' not in album_ctx
assert album_ctx['release_date'] == '2013-10-22'
def test_album_backfill_skips_when_detailed_album_not_dict():
"""API response with a non-dict ``album`` field (defensive — old
enrichment caches stored string album names) is safely skipped."""
client = MagicMock()
client.get_track_details.return_value = {
'id': 'spotify_track_id',
'track_number': 3,
'album': 'Just a string',
}
album_ctx = {'name': 'PRISM'}
resolved = hydrate_download_metadata(_FakeTrack(), {}, album_ctx, client)
# track_number resolved, album_context untouched.
assert resolved.track_number == 3
assert 'release_date' not in album_ctx
# ---------------------------------------------------------------------------
# Defensive: missing IDs, exceptions, non-dict inputs.
# ---------------------------------------------------------------------------
def test_no_track_id_skips_api_call():
"""Without ``track.id`` the API can't be queried. Returns whatever
was resolved locally, no exception."""
client = MagicMock()
album_ctx = {'name': 'PRISM'} # lean — but no ID to fix it
resolved = hydrate_download_metadata(
_FakeTrack(id=None),
{},
album_ctx,
client,
)
assert resolved.track_number is None
assert resolved.source == 'none'
client.get_track_details.assert_not_called()
def test_api_exception_does_not_propagate():
"""Network blip / Spotify 5xx must not crash the download pipeline.
Returns whatever was resolved before the failed call."""
client = MagicMock()
client.get_track_details.side_effect = RuntimeError('429 rate limited')
album_ctx = {'name': 'PRISM'} # lean
resolved = hydrate_download_metadata(
_FakeTrack(),
{'track_number': 5}, # already have tn from track_info
album_ctx,
client,
)
assert resolved.track_number == 5
assert 'release_date' not in album_ctx # backfill failed silently
def test_api_returns_none_does_not_mutate_context():
"""API call that returns None (no result for this Spotify ID) is
a no-op for album hydration."""
client = MagicMock()
client.get_track_details.return_value = None
album_ctx = {'name': 'PRISM'}
resolved = hydrate_download_metadata(_FakeTrack(), {}, album_ctx, client)
assert resolved.track_number is None
assert 'release_date' not in album_ctx
def test_non_dict_track_info_treated_as_empty():
"""Defensive — a malformed task payload with a non-dict track_info
falls through to the track object / API instead of crashing."""
client = MagicMock()
client.get_track_details.return_value = _api_payload(track_number=6)
album_ctx = {'release_date': '2013-10-22', 'total_tracks': 16}
resolved = hydrate_download_metadata(
_FakeTrack(),
'not a dict', # type: ignore[arg-type]
album_ctx,
client,
)
assert resolved.source == 'api'
assert resolved.track_number == 6
def test_non_dict_album_context_treated_as_lean():
"""Defensive — a None album_context is treated as lean, so the API
still fires but mutation is silently skipped (helper can't mutate
a non-dict in place)."""
client = MagicMock()
client.get_track_details.return_value = _api_payload()
# Should not raise — passing None for album_context just means
# the backfill no-ops (can't mutate None) but the call signature
# is preserved.
resolved = hydrate_download_metadata(
_FakeTrack(),
{'track_number': 5},
None, # type: ignore[arg-type]
client,
)
# tn resolved from track_info, no crash.
assert resolved.track_number == 5
# ---------------------------------------------------------------------------
# disc_number resolution.
# ---------------------------------------------------------------------------
def test_disc_number_from_track_info_paired_with_track_number():
"""When track_info supplies track_number, its disc_number rides along."""
client = MagicMock()
album_ctx = {'release_date': '2013-10-22', 'total_tracks': 16}
resolved = hydrate_download_metadata(
_FakeTrack(),
{'track_number': 5, 'disc_number': 2},
album_ctx,
client,
)
assert resolved.disc_number == 2
def test_disc_number_defaults_to_1_when_only_track_number_present():
"""track_info has track_number but no disc_number → disc=1."""
client = MagicMock()
album_ctx = {'release_date': '2013-10-22', 'total_tracks': 16}
resolved = hydrate_download_metadata(
_FakeTrack(),
{'track_number': 5},
album_ctx,
client,
)
assert resolved.disc_number == 1
def test_disc_number_zero_from_api_floored_to_1():
"""API response with disc_number=0 (some niche releases) gets
floored to 1 albums are 1-indexed."""
client = MagicMock()
client.get_track_details.return_value = _api_payload(track_number=5, disc_number=0)
album_ctx = {'release_date': '2013-10-22', 'total_tracks': 16}
resolved = hydrate_download_metadata(_FakeTrack(), {}, album_ctx, client)
assert resolved.disc_number == 1
# ---------------------------------------------------------------------------
# Cost guard: API called at most once.
# ---------------------------------------------------------------------------
def test_api_called_at_most_once_per_invocation():
"""Even when API serves both concerns (track_number AND album),
only one ``get_track_details`` call is made."""
client = MagicMock()
client.get_track_details.return_value = _api_payload()
album_ctx = {'name': 'PRISM'} # lean
hydrate_download_metadata(_FakeTrack(track_number=None), {}, album_ctx, client)
assert client.get_track_details.call_count == 1
if __name__ == '__main__':
pytest.main([__file__, '-v'])

View file

@ -0,0 +1,228 @@
"""Tests for ``core/imports/track_number.py:resolve_track_number``.
Pure-function resolver lifted out of the import pipeline so the
multi-source fallback chain can be pinned in isolation. Real-world
bug it addresses: wishlist-loop tracks were importing as ``01 -
<title>`` because the pipeline only consulted ``album_info.track_number``
and fell straight to the filename. When the filename was VA-collection
shaped (``417 Fountains of Wayne - Stacys Mom.flac``), the extractor
either returned the wrong number or None, the pipeline floored to 1,
and the wishlist track's actual Spotify track_number was discarded.
"""
from __future__ import annotations
from unittest.mock import patch
from core.imports.track_number import resolve_track_number
# ---------------------------------------------------------------------------
# Resolution chain — album_info wins when populated.
# ---------------------------------------------------------------------------
def test_album_info_track_number_wins_over_track_info():
"""When album_info has a real track_number, the resolver returns
it without consulting track_info / spotify_data / filename. This
is the album-bundle dispatch case where master.py has already
resolved authoritative position data."""
result = resolve_track_number(
album_info={'track_number': 8},
track_info={'track_number': 3}, # stale wishlist data
file_path='/some/path/08 No Sleep Till Brooklyn.flac',
)
assert result == 8
def test_track_info_used_when_album_info_missing():
"""Per-track flow lands here — wishlist payload had track_number
8 from Spotify, album_info wasn't populated by an album-bundle
dispatch."""
result = resolve_track_number(
album_info={},
track_info={'track_number': 8},
file_path='/some/path/417 Stacy.flac',
)
assert result == 8
def test_spotify_data_used_when_track_info_top_level_missing():
"""Some wishlist payloads carry the full Spotify track dict nested
under ``spotify_data`` rather than at the top level. The resolver
must dig into the nested shape when the top-level key is absent."""
result = resolve_track_number(
album_info={},
track_info={'spotify_data': {'track_number': 5}},
file_path='/some/path/file.flac',
)
assert result == 5
def test_spotify_data_string_json_parsed_then_read():
"""Some legacy payloads stored spotify_data as a JSON string
instead of a dict (round-tripped through DB blob fields).
Resolver must parse and read it same data, different shape."""
result = resolve_track_number(
album_info={},
track_info={'spotify_data': '{"track_number": 12}'},
file_path='/some/path/file.flac',
)
assert result == 12
def test_spotify_data_garbage_string_falls_through():
"""Non-JSON string in spotify_data must NOT crash — fall through
to the next source (filename) as if it weren't there."""
result = resolve_track_number(
album_info={},
track_info={'spotify_data': 'not json at all'},
file_path='/dir/03 - Song.flac',
)
# Filename has '03 - ' prefix → extract returns 3.
assert result == 3
# ---------------------------------------------------------------------------
# Filename fallback.
# ---------------------------------------------------------------------------
def test_filename_fallback_when_all_metadata_sources_missing():
"""No album_info, no track_info → resolver tries the filename."""
result = resolve_track_number(
album_info={},
track_info={},
file_path='/dl/12 - Track Title.flac',
)
assert result == 12
def test_filename_fallback_handles_zero_padded_prefixes():
"""Standard ripped-album naming ``NN - Title.flac`` produces the
correct track position from the filename extractor."""
result = resolve_track_number(
album_info={},
track_info={},
file_path='/dl/05 - Whatever.flac',
)
assert result == 5
def test_filename_extractor_exception_silenced_to_none():
"""If the filename extractor raises (defensive — shouldn't in
practice), resolver returns None rather than blowing up the
whole post-process chain."""
with patch('core.imports.track_number.extract_explicit_track_number',
side_effect=RuntimeError('boom')):
result = resolve_track_number({}, {}, '/path/05 - Track.flac')
assert result is None
def test_no_file_path_returns_none_for_filename_step():
"""Empty file_path skips the filename extractor — resolver
returns None instead of crashing on the next-step coercion."""
result = resolve_track_number({}, {}, '')
assert result is None
# ---------------------------------------------------------------------------
# Defensive: invalid / zero / non-numeric inputs.
# ---------------------------------------------------------------------------
def test_album_info_zero_track_number_falls_through():
"""``track_number=0`` is invalid (album positions are 1-indexed),
so the resolver treats it as missing and tries the next source."""
result = resolve_track_number(
album_info={'track_number': 0},
track_info={'track_number': 7},
file_path='/dir/file.flac',
)
assert result == 7
def test_negative_track_number_treated_as_missing():
"""Defensive — a hand-edited row carrying -3 falls through."""
result = resolve_track_number(
album_info={'track_number': -3},
track_info={'track_number': 7},
file_path='/dir/file.flac',
)
assert result == 7
def test_non_numeric_track_number_treated_as_missing():
"""Garbage string falls through to the next source."""
result = resolve_track_number(
album_info={'track_number': 'oops'},
track_info={'track_number': 7},
file_path='/dir/file.flac',
)
assert result == 7
def test_string_numeric_track_number_coerced_to_int():
"""Some payloads store track_number as ``'8'`` (string) instead
of ``8`` (int) particularly from older DB serialisation paths.
Resolver must coerce, not reject."""
result = resolve_track_number(
album_info={'track_number': '8'},
track_info={},
file_path='/dir/file.flac',
)
assert result == 8
def test_all_sources_missing_returns_none():
"""When every source is missing AND the filename doesn't carry
a positional prefix, resolver returns None. Caller (the pipeline)
then applies the final default-1 floor."""
result = resolve_track_number({}, {}, '/no-prefix-here.flac')
assert result is None
def test_va_collection_filename_returns_bogus_number_not_one():
"""Real-world regression case: ``417 Fountains of Wayne - Stacys Mom.flac``
is a VA-collection file where the leading ``417`` is a playlist
position, not the album track number. The filename extractor
returns whatever it returns (currently None because the regex
requires NN- prefix with the dash); the resolver's job is to
let that flow through faithfully so the caller's default-1
floor catches it. Pin the bug-trigger filename shape so a
future "smart" extractor that returns 417 here still produces
a behaviour the pipeline floor can correct."""
# Empty album_info + track_info + nothing else → resolver
# delegates to the filename extractor. Whatever it returns for
# this VA-shape file, the pipeline applies the >=1 floor.
result = resolve_track_number(
album_info={},
track_info={},
file_path='/dl/417 Fountains of Wayne - Stacys Mom.flac',
)
# We don't pin the exact value here because the underlying
# extractor's contract for non-canonical filenames is fuzzy.
# What we DO pin: the resolver doesn't crash, returns either
# None or a positive int. Pipeline's floor handles the rest.
assert result is None or (isinstance(result, int) and result >= 1)
# ---------------------------------------------------------------------------
# Non-dict inputs (defensive).
# ---------------------------------------------------------------------------
def test_none_album_info_treated_as_empty_dict():
"""Defensive — caller might pass None when album_info wasn't built."""
result = resolve_track_number(
None,
{'track_number': 3},
'/dir/file.flac',
)
assert result == 3
def test_non_dict_track_info_treated_as_empty():
"""Defensive — non-dict track_info won't crash the resolver."""
result = resolve_track_number({}, 'not a dict', '/dir/file.flac')
assert result is None

View file

@ -392,3 +392,295 @@ describe('getMirroredSourceRef', () => {
assert.equal(sb.getMirroredSourceRef({}), '');
});
});
// =========================================================================
// Weekly schedule helpers — PR 3 of the schedule-types feature.
// =========================================================================
describe('detectBrowserTimezone', () => {
test('returns IANA tz from Intl in the test runtime', () => {
const sb = makeSandbox();
// Node runs with a system tz; the resolved value must be a
// non-empty string (any IANA name is acceptable here).
const tz = sb.detectBrowserTimezone();
assert.equal(typeof tz, 'string');
assert.ok(tz.length > 0);
});
});
describe('autoSyncWeeklyTrigger', () => {
test('builds a clean payload from picker input', () => {
const sb = makeSandbox();
const result = sb.autoSyncWeeklyTrigger({
time: '09:00',
days: ['mon', 'wed', 'fri'],
tz: 'America/Los_Angeles',
});
deepShapeEqual(result, {
time: '09:00',
days: ['mon', 'wed', 'fri'],
tz: 'America/Los_Angeles',
});
});
test('garbage time string defaults to 09:00', () => {
const sb = makeSandbox();
const result = sb.autoSyncWeeklyTrigger({
time: 'lol', days: ['mon'], tz: 'UTC',
});
assert.equal(result.time, '09:00');
});
test('unrecognised weekday abbreviations dropped from payload', () => {
const sb = makeSandbox();
const result = sb.autoSyncWeeklyTrigger({
time: '09:00',
days: ['mon', 'garbage', 'wed', 'mond'],
tz: 'UTC',
});
deepShapeEqual(result.days, ['mon', 'wed']);
});
test('missing tz falls back to browser-detected default', () => {
const sb = makeSandbox();
const result = sb.autoSyncWeeklyTrigger({
time: '09:00', days: ['mon'],
});
assert.equal(typeof result.tz, 'string');
assert.ok(result.tz.length > 0);
});
test('empty argument object produces all-defaults payload', () => {
const sb = makeSandbox();
const result = sb.autoSyncWeeklyTrigger({});
assert.equal(result.time, '09:00');
deepShapeEqual(result.days, []);
assert.equal(typeof result.tz, 'string');
});
test('non-array days param defaults to empty', () => {
const sb = makeSandbox();
const result = sb.autoSyncWeeklyTrigger({
time: '09:00', days: 'mon', tz: 'UTC',
});
deepShapeEqual(result.days, []);
});
});
describe('autoSyncWeeklyFromTrigger', () => {
test('round-trips with autoSyncWeeklyTrigger when days non-empty', () => {
const sb = makeSandbox();
const cfg = sb.autoSyncWeeklyTrigger({
time: '09:00', days: ['mon', 'wed'], tz: 'UTC',
});
const parsed = sb.autoSyncWeeklyFromTrigger(cfg);
deepShapeEqual(parsed, {
time: '09:00', days: ['mon', 'wed'], tz: 'UTC',
});
});
test('empty days list expands to every weekday', () => {
const sb = makeSandbox();
// Matches the next_run_at convention: empty days = every day.
// UI needs the expanded form so the schedule renders under all
// 7 day columns instead of looking unscheduled.
const parsed = sb.autoSyncWeeklyFromTrigger({
time: '14:00', days: [], tz: 'UTC',
});
deepShapeEqual(parsed.days,
['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
});
test('uppercased / mixed-case day abbreviations normalised', () => {
const sb = makeSandbox();
const parsed = sb.autoSyncWeeklyFromTrigger({
time: '09:00', days: ['MON', 'Wed'], tz: 'UTC',
});
deepShapeEqual(parsed.days, ['mon', 'wed']);
});
test('null / undefined config returns null', () => {
const sb = makeSandbox();
assert.equal(sb.autoSyncWeeklyFromTrigger(null), null);
assert.equal(sb.autoSyncWeeklyFromTrigger(undefined), null);
});
test('garbage time falls back to 09:00', () => {
const sb = makeSandbox();
const parsed = sb.autoSyncWeeklyFromTrigger({
time: 'garbage', days: ['mon'], tz: 'UTC',
});
assert.equal(parsed.time, '09:00');
});
test('missing tz defaults to UTC (not browser tz)', () => {
// Trigger configs persisted in the DB without a tz field came
// from the legacy engine path that used server-local naive
// ``datetime.now()``. PR 2 routes those through the engine's
// ``_default_tz``, NOT the browser's. So parse-back must surface
// a stable default (UTC) — the UI should NOT silently rewrite
// an existing row's tz when the user opens the editor.
const sb = makeSandbox();
const parsed = sb.autoSyncWeeklyFromTrigger({
time: '09:00', days: ['mon'],
});
assert.equal(parsed.tz, 'UTC');
});
});
describe('buildAutoSyncScheduleState — weekly_time bucketing', () => {
test('weekly_time owned automation lands in weeklySchedules', () => {
const sb = makeSandbox();
const playlists = [{ id: 7, name: 'Daily Mix', source: 'spotify' }];
const automations = [{
id: 42,
name: 'Auto-Sync: Daily Mix',
enabled: true,
owned_by: 'auto_sync',
action_type: 'playlist_pipeline',
action_config: { playlist_id: '7', all: false },
trigger_type: 'weekly_time',
trigger_config: { time: '09:00', days: ['mon', 'wed', 'fri'], tz: 'America/Los_Angeles' },
next_run: '2026-06-01 16:00:00',
}];
const state = sb.buildAutoSyncScheduleState(playlists, automations);
assert.ok(state.weeklySchedules);
const sched = state.weeklySchedules[7];
assert.ok(sched, 'weekly schedule must surface in state.weeklySchedules[playlistId]');
assert.equal(sched.automation_id, 42);
assert.equal(sched.time, '09:00');
deepShapeEqual(sched.days, ['mon', 'wed', 'fri']);
assert.equal(sched.tz, 'America/Los_Angeles');
// And NOT in playlistSchedules (mutual exclusion at the bucket level).
assert.equal(state.playlistSchedules[7], undefined);
});
test('schedule (interval) automation stays in playlistSchedules', () => {
const sb = makeSandbox();
const playlists = [{ id: 7, name: 'Daily Mix', source: 'spotify' }];
const automations = [{
id: 42,
owned_by: 'auto_sync',
action_type: 'playlist_pipeline',
action_config: { playlist_id: '7', all: false },
trigger_type: 'schedule',
trigger_config: { interval: 6, unit: 'hours' },
enabled: true,
}];
const state = sb.buildAutoSyncScheduleState(playlists, automations);
assert.ok(state.playlistSchedules[7]);
assert.equal(state.weeklySchedules[7], undefined);
});
test('non-owned weekly_time automation falls through to automationPipelines', () => {
// Backward-compat: a hand-created weekly_time automation
// NOT owned by auto_sync must NOT hijack the Weekly Board
// — it stays as a regular automation pipeline visible on
// the Automation Pipelines tab.
const sb = makeSandbox();
const playlists = [{ id: 7, name: 'Daily Mix', source: 'spotify' }];
const automations = [{
id: 99,
name: 'My Custom Weekly Thing',
// No owned_by, no Auto-Sync: prefix, no Playlist Auto-Sync group.
action_type: 'playlist_pipeline',
action_config: { playlist_id: '7', all: false },
trigger_type: 'weekly_time',
trigger_config: { time: '09:00', days: ['mon'], tz: 'UTC' },
enabled: true,
}];
const state = sb.buildAutoSyncScheduleState(playlists, automations);
assert.equal(state.weeklySchedules[7], undefined);
assert.equal(state.playlistSchedules[7], undefined);
assert.equal(state.automationPipelines.length, 1);
assert.equal(state.automationPipelines[0].id, 99);
});
test('legacy-named (Auto-Sync: prefix) weekly_time still recognised', () => {
// Rows pre-dating the owned_by column should still be picked
// up by the legacy name/group fallback.
const sb = makeSandbox();
const playlists = [{ id: 7, name: 'Daily Mix', source: 'spotify' }];
const automations = [{
id: 99,
name: 'Auto-Sync: Daily Mix', // legacy convention
group_name: 'Playlist Auto-Sync',
action_type: 'playlist_pipeline',
action_config: { playlist_id: '7', all: false },
trigger_type: 'weekly_time',
trigger_config: { time: '09:00', days: ['mon'], tz: 'UTC' },
enabled: true,
}];
const state = sb.buildAutoSyncScheduleState(playlists, automations);
assert.ok(state.weeklySchedules[7], 'legacy-named auto-sync row should bucket weekly');
});
test('garbage weekly_time config falls through to automationPipelines', () => {
// Defensive — a hand-edited row with malformed trigger_config
// should NOT crash state-build. autoSyncWeeklyFromTrigger
// returns null for non-object configs; the bucket logic
// routes nulls to automationPipelines as the catch-all.
const sb = makeSandbox();
const playlists = [{ id: 7, name: 'Daily Mix', source: 'spotify' }];
const automations = [{
id: 42,
owned_by: 'auto_sync',
action_type: 'playlist_pipeline',
action_config: { playlist_id: '7', all: false },
trigger_type: 'weekly_time',
trigger_config: null,
enabled: true,
}];
const state = sb.buildAutoSyncScheduleState(playlists, automations);
assert.equal(state.weeklySchedules[7], undefined);
assert.equal(state.automationPipelines.length, 1);
});
});
describe('autoSyncWeeklyLabel', () => {
test('multi-day schedule renders ordered day list with time', () => {
const sb = makeSandbox();
// Input intentionally in non-canonical order to verify sort.
const label = sb.autoSyncWeeklyLabel({
time: '09:00', days: ['fri', 'mon', 'wed'], tz: 'UTC',
});
assert.equal(label, 'Mon, Wed, Fri @ 09:00');
});
test('full-week schedule collapses to Daily', () => {
const sb = makeSandbox();
const label = sb.autoSyncWeeklyLabel({
time: '14:30',
days: ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'],
tz: 'UTC',
});
assert.equal(label, 'Daily @ 14:30');
});
test('single-day schedule shows just that day', () => {
const sb = makeSandbox();
const label = sb.autoSyncWeeklyLabel({
time: '20:00', days: ['sun'], tz: 'UTC',
});
assert.equal(label, 'Sun @ 20:00');
});
test('null parsed value returns Unscheduled', () => {
const sb = makeSandbox();
assert.equal(sb.autoSyncWeeklyLabel(null), 'Unscheduled');
});
test('empty days array treated as daily (matches engine semantic)', () => {
const sb = makeSandbox();
const label = sb.autoSyncWeeklyLabel({
time: '09:00', days: [], tz: 'UTC',
});
assert.equal(label, 'Daily @ 09:00');
});
});

View file

@ -299,3 +299,360 @@ def test_get_poll_timeout_falls_back_on_garbage() -> None:
assert get_poll_timeout() == DEFAULT_POLL_TIMEOUT_SECONDS
cm.get.return_value = 0
assert get_poll_timeout() == DEFAULT_POLL_TIMEOUT_SECONDS
# ---------------------------------------------------------------------------
# poll_album_download — lifted poll loop for both torrent + usenet plugins.
# ---------------------------------------------------------------------------
from core.download_plugins.album_bundle import (
DEFAULT_TRANSIENT_MISS_THRESHOLD,
TransientMissCounter,
get_transient_miss_threshold,
poll_album_download,
)
# ---------------------------------------------------------------------------
# TransientMissCounter — shared retry-counter used by every poll loop.
# ---------------------------------------------------------------------------
def test_counter_starts_at_zero_and_uses_default_threshold():
"""No config override → uses DEFAULT_TRANSIENT_MISS_THRESHOLD,
starts at zero misses."""
with patch('core.download_plugins.album_bundle.config_manager') as cm:
cm.get.return_value = DEFAULT_TRANSIENT_MISS_THRESHOLD
counter = TransientMissCounter()
assert counter.threshold == DEFAULT_TRANSIENT_MISS_THRESHOLD
assert counter.misses == 0
def test_counter_honors_explicit_threshold_over_config():
"""Explicit threshold takes precedence over the config-driven default."""
counter = TransientMissCounter(threshold=3)
assert counter.threshold == 3
def test_counter_record_miss_returns_false_until_threshold():
"""record_miss returns True only on the iteration that pushes
the count to threshold earlier calls return False so the caller
knows to keep polling."""
counter = TransientMissCounter(threshold=3)
assert counter.record_miss() is False # 1
assert counter.record_miss() is False # 2
assert counter.record_miss() is True # 3 → at threshold
def test_counter_reset_zeros_count():
"""A successful read between transient misses resets the counter
so isolated network blips don't accumulate toward the threshold."""
counter = TransientMissCounter(threshold=3)
counter.record_miss()
counter.record_miss()
counter.reset()
assert counter.misses == 0
# After reset we should need a full threshold of fresh misses again.
assert counter.record_miss() is False
assert counter.record_miss() is False
assert counter.record_miss() is True
def test_get_transient_miss_threshold_uses_default_when_unset():
with patch('core.download_plugins.album_bundle.config_manager') as cm:
cm.get.return_value = DEFAULT_TRANSIENT_MISS_THRESHOLD
assert get_transient_miss_threshold() == DEFAULT_TRANSIENT_MISS_THRESHOLD
def test_get_transient_miss_threshold_honors_config_override():
"""Users with very slow servers (huge multi-disc box sets, slow
disks) need to bump the tolerance window."""
with patch('core.download_plugins.album_bundle.config_manager') as cm:
cm.get.return_value = 20
assert get_transient_miss_threshold() == 20
def test_get_transient_miss_threshold_falls_back_on_garbage():
"""Non-positive / non-numeric config values fall back to the
default same defensive pattern as get_poll_interval."""
with patch('core.download_plugins.album_bundle.config_manager') as cm:
cm.get.return_value = 'oops'
assert get_transient_miss_threshold() == DEFAULT_TRANSIENT_MISS_THRESHOLD
cm.get.return_value = 0
assert get_transient_miss_threshold() == DEFAULT_TRANSIENT_MISS_THRESHOLD
cm.get.return_value = -3
assert get_transient_miss_threshold() == DEFAULT_TRANSIENT_MISS_THRESHOLD
@dataclass
class _Status:
"""Duck-typed sibling of UsenetStatus / TorrentStatus — only the
fields poll_album_download reads."""
state: str
save_path: Optional[str] = None
progress: float = 0.0
downloaded: int = 0
download_speed: int = 0
error: Optional[str] = None
class _ScriptedClock:
"""Deterministic monotonic-time + sleep stand-in for poll tests.
Each call to ``sleep(n)`` advances ``now`` by ``n`` seconds with
no real wall-clock delay. Lets us run multi-iteration poll
scenarios in milliseconds and assert on the exact iteration count
each branch took."""
def __init__(self) -> None:
self.now = 0.0
self.sleep_calls = 0
def monotonic(self) -> float:
return self.now
def sleep(self, seconds: float) -> None:
self.now += seconds
self.sleep_calls += 1
def _make_emit_recorder():
"""Collects (state, kwargs) tuples so tests can assert on the
emit sequence the UI would see."""
calls = []
def _emit(state: str, **fields) -> None:
calls.append((state, fields))
return _emit, calls
def test_poll_returns_save_path_on_completed_state() -> None:
"""Happy path — adapter says 'completed' with a save_path on the
first poll; function returns the path and emits a single
'downloading' (NOT a terminal 'failed') so the caller can chain
'staging' / 'staged' next."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
status = _Status(state='completed', save_path='/dl/album', progress=1.0)
result = poll_album_download(
get_status=lambda: status,
title='Album X',
emit=emit,
complete_states=frozenset(['completed']),
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=60.0,
)
assert result == '/dl/album'
states = [c[0] for c in calls]
assert 'failed' not in states
assert 'downloading' in states
def test_poll_tolerates_transient_missing_during_sab_handoff() -> None:
"""SAB removes a job from the queue before adding it to history.
Pre-fix: one None read = give up + log 'disappeared from client'
even though SAB was healthy and just mid-handoff. Now we tolerate
up to ``transient_miss_threshold`` consecutive None reads before
declaring the job gone. Recovery to a real status MUST reset the
miss counter."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
sequence = iter([None, None, None,
_Status(state='completed', save_path='/sab/done')])
result = poll_album_download(
get_status=lambda: next(sequence),
title='Album X', emit=emit,
complete_states=frozenset(['completed']),
transient_miss_threshold=5,
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=60.0,
)
assert result == '/sab/done'
assert 'failed' not in [c[0] for c in calls]
def test_poll_gives_up_after_threshold_consecutive_misses() -> None:
"""When the job genuinely is gone (user deleted it from SAB), the
transient tolerance still has a floor after N misses, fail
explicitly and emit a terminal 'failed' so the UI doesn't freeze."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
result = poll_album_download(
get_status=lambda: None,
title='Album X', emit=emit,
complete_states=frozenset(['completed']),
transient_miss_threshold=3,
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=600.0,
)
assert result is None
failed_calls = [c for c in calls if c[0] == 'failed']
assert len(failed_calls) == 1
assert 'Disappeared' in failed_calls[0][1].get('error', '')
def test_poll_emits_terminal_failed_on_explicit_failed_state() -> None:
"""Adapter says 'failed' (real failure, not transient). Function
returns None AND emits 'failed' with the adapter's error message."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
status = _Status(state='failed', error='par2 unrecoverable')
result = poll_album_download(
get_status=lambda: status,
title='Album X', emit=emit,
complete_states=frozenset(['completed']),
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=60.0,
)
assert result is None
failed_calls = [c for c in calls if c[0] == 'failed']
assert len(failed_calls) == 1
assert failed_calls[0][1].get('error') == 'par2 unrecoverable'
def test_poll_emits_terminal_failed_on_timeout() -> None:
"""When the deadline passes without success or explicit failure,
emit 'failed' once so the UI exits the 'downloading' state."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
status = _Status(state='downloading', progress=0.5)
result = poll_album_download(
get_status=lambda: status,
title='Album X', emit=emit,
complete_states=frozenset(['completed']),
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=10.0,
)
assert result is None
failed_calls = [c for c in calls if c[0] == 'failed']
assert len(failed_calls) == 1
assert 'timed out' in failed_calls[0][1].get('error', '').lower()
def test_poll_treats_default_error_state_as_transient_not_terminal() -> None:
"""The adapter state-map's default-fallback for unmapped strings
is 'error' (real-world: SAB's 'Pp' state used to land here and
cause the poll to infinite-loop because 'error' wasn't in the
failed set and wasn't in the complete set). Now: treat as a
transient miss so the poll recovers when the unmapped state
transitions to a known one. If it stays unmapped for the threshold
of consecutive polls, emit terminal failed."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
sequence = iter([
_Status(state='error'),
_Status(state='error'),
_Status(state='completed', save_path='/done'),
])
result = poll_album_download(
get_status=lambda: next(sequence),
title='Album X', emit=emit,
complete_states=frozenset(['completed']),
transient_miss_threshold=5,
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=60.0,
)
assert result == '/done'
assert 'failed' not in [c[0] for c in calls]
def test_poll_gives_up_when_default_error_state_persists() -> None:
"""If the adapter keeps returning the unmapped 'error' state past
the threshold, fail rather than burning the full 6-hour timeout."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
result = poll_album_download(
get_status=lambda: _Status(state='error'),
title='Album X', emit=emit,
complete_states=frozenset(['completed']),
transient_miss_threshold=3,
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=600.0,
)
assert result is None
failed_calls = [c for c in calls if c[0] == 'failed']
assert len(failed_calls) == 1
assert 'unmapped' in failed_calls[0][1].get('error', '').lower()
def test_poll_shutdown_returns_none_without_terminal_emit() -> None:
"""Process shutdown is a clean exit — don't paint failure on the
UI; the app is going away anyway."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
result = poll_album_download(
get_status=lambda: _Status(state='downloading', progress=0.5),
title='Album X', emit=emit,
complete_states=frozenset(['completed']),
is_shutdown=lambda: True,
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=60.0,
)
assert result is None
assert 'failed' not in [c[0] for c in calls]
def test_poll_torrent_seeding_counts_as_complete() -> None:
"""Torrent plugin passes ``complete_states={'seeding', 'completed'}``
because qBit / Transmission flip the torrent to 'seeding' on
completion (files already on disk + share ratio progress). Same
poll function must accept either state as terminal success."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
status = _Status(state='seeding', save_path='/dl/album.torrent')
result = poll_album_download(
get_status=lambda: status,
title='Album X', emit=emit,
complete_states=frozenset(['seeding', 'completed']),
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=60.0,
)
assert result == '/dl/album.torrent'
def test_poll_save_path_captured_across_iterations() -> None:
"""save_path can appear mid-poll (e.g. once SAB moves the slot
out of the queue and into history). The last non-empty save_path
seen during the run is what we return on terminal success even
if the final status read happens to have it cleared."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
sequence = iter([
_Status(state='downloading', progress=0.4),
_Status(state='downloading', save_path='/late/path', progress=0.9),
_Status(state='completed', progress=1.0),
])
result = poll_album_download(
get_status=lambda: next(sequence),
title='Album X', emit=emit,
complete_states=frozenset(['completed']),
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=60.0,
)
assert result == '/late/path'
def test_poll_get_status_exception_treated_as_transient_miss() -> None:
"""Adapter raising (network blip, JSON decode error) shouldn't
blow up the poll thread caught, logged, counted as a transient
miss alongside None returns."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
counter = {'n': 0}
def _raising_then_success():
counter['n'] += 1
if counter['n'] <= 2:
raise RuntimeError('network blip')
return _Status(state='completed', save_path='/recovered')
result = poll_album_download(
get_status=_raising_then_success,
title='Album X', emit=emit,
complete_states=frozenset(['completed']),
transient_miss_threshold=5,
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=60.0,
)
assert result == '/recovered'
assert 'failed' not in [c[0] for c in calls]

View file

@ -22,7 +22,34 @@ from unittest.mock import patch
import pytest
from core.discogs_client import DiscogsClient
from core.discogs_client import DiscogsClient, _clean_discogs_artist_name
# ---------------------------------------------------------------------------
# _clean_discogs_artist_name — disambiguation suffix helper
# ---------------------------------------------------------------------------
@pytest.mark.parametrize('raw,expected', [
('Madonna', 'Madonna'),
('Madonna (3)', 'Madonna'), # legacy (N) suffix
('Madonna*', 'Madonna'), # newer asterisk suffix
('John Smith*', 'John Smith'),
('Bullet (2)', 'Bullet'),
('Foo (12)', 'Foo'), # double-digit (N)
('Baz**', 'Baz'), # repeated asterisks
('Qux* ', 'Qux'), # trailing whitespace after *
('Artist (3) *', 'Artist'), # both suffixes, space-separated
('Foo (3)*', 'Foo'), # both suffixes, no space
('No Suffix Here', 'No Suffix Here'),
('', ''),
(None, ''),
(' ', ''),
])
def test_clean_discogs_artist_name(raw, expected):
"""Helper strips both `(N)` and trailing `*` disambiguation suffixes
from Discogs artist names. Closes #634."""
assert _clean_discogs_artist_name(raw) == expected
# ---------------------------------------------------------------------------
@ -105,6 +132,28 @@ def test_get_user_collection_strips_discogs_disambiguation_suffix(authed_client)
assert result[0]['artist_name'] == 'Madonna'
def test_get_user_collection_strips_discogs_asterisk_disambiguation(authed_client):
"""Discogs also appends '*' (asterisk) as a disambiguation suffix
for the newer naming convention (e.g. 'John Smith*'). Without
stripping, library folders would land on disk named 'John Smith*'
and downstream matchers wouldn't equate it to the canonical name
other providers return. Closes #634."""
fake_response = {
'pagination': {'pages': 1, 'page': 1},
'releases': [
{'id': 1, 'basic_information': {
'title': 'X', 'artists': [{'name': 'John Smith*'}],
'cover_image': '', 'year': 2020,
}},
],
}
with patch.object(authed_client, '_api_get',
side_effect=lambda e, p=None: ({'username': 'u'} if e == '/oauth/identity' else fake_response)):
result = authed_client.get_user_collection()
assert result[0]['artist_name'] == 'John Smith'
def test_get_user_collection_handles_missing_year(authed_client):
"""Year 0 / missing → empty release_date string (NOT '0')."""
fake_response = {

View file

@ -0,0 +1,249 @@
"""Tests for ``core/listenbrainz_manager.py``.
Coverage focus: the new ``refresh_playlist(mbid)`` targeted refresh.
Pre-fix the manager only exposed ``update_all_playlists`` every
caller that wanted to refresh ONE playlist had to re-pull all 14
cached LB playlists' details. Wasted API calls + slow + the LB
adapter's silent ``except Exception: pass`` wrapper masked the real
slowness as a UI hang.
"""
from __future__ import annotations
import sqlite3
import tempfile
from pathlib import Path
from typing import Any, Dict, List
from unittest.mock import MagicMock
import pytest
from core.listenbrainz_manager import ListenBrainzManager
@pytest.fixture
def tmp_db():
"""Per-test SQLite file so ``_ensure_tables`` can build its schema."""
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
yield path
try:
Path(path).unlink()
except OSError:
pass
def _seed_playlist(db_path: str, mbid: str, title: str, ptype: str, track_count: int):
"""Insert one cached LB playlist row + matching schema bootstrap."""
conn = sqlite3.connect(db_path)
try:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO listenbrainz_playlists
(playlist_mbid, title, creator, playlist_type, track_count, annotation_data, profile_id)
VALUES (?, ?, 'ListenBrainz', ?, ?, '{}', 1)
""",
(mbid, title, ptype, track_count),
)
conn.commit()
finally:
conn.close()
def _build_manager(db_path: str, *, authed: bool = True) -> ListenBrainzManager:
"""Construct a manager + stub the client so we don't hit the network."""
mgr = ListenBrainzManager(db_path=db_path, profile_id=1)
mgr.client = MagicMock()
mgr.client.is_authenticated.return_value = authed
return mgr
# ---------------------------------------------------------------------------
# refresh_playlist: happy path
# ---------------------------------------------------------------------------
def test_refresh_playlist_fetches_single_playlist_only(tmp_db):
"""``refresh_playlist`` calls ONLY ``get_playlist_details`` for the
targeted mbid not any of the list-pulling methods that
``update_all_playlists`` uses."""
mgr = _build_manager(tmp_db)
_seed_playlist(tmp_db, "mbid-1", "Weekly Jams", "created_for", 50)
# Non-empty ``track`` so ``_update_playlist`` doesn't trigger its
# own defensive re-fetch (that branch is for legacy callers that
# pass slim list-row data — not us).
mgr.client.get_playlist_details.return_value = {
"playlist": {
"identifier": "https://listenbrainz.org/playlist/mbid-1",
"title": "Weekly Jams",
"creator": "ListenBrainz",
"track": [
{
"identifier": "https://musicbrainz.org/recording/rec-1",
"title": "Song",
"creator": "Artist",
}
],
"annotation": "",
}
}
result = mgr.refresh_playlist("mbid-1")
assert result["success"] is True
assert result["playlist_mbid"] == "mbid-1"
assert result["playlist_type"] == "created_for"
mgr.client.get_playlist_details.assert_called_once_with("mbid-1")
# The wasteful list-pulling methods must NOT be touched.
mgr.client.get_playlists_created_for_user.assert_not_called()
mgr.client.get_user_playlists.assert_not_called()
mgr.client.get_collaborative_playlists.assert_not_called()
def test_refresh_playlist_returns_skipped_when_track_count_unchanged(tmp_db):
"""``_update_playlist``'s smart-comparison returns "skipped" when
the track count matches the cached value. ``refresh_playlist``
propagates that signal back to the caller."""
mgr = _build_manager(tmp_db)
_seed_playlist(tmp_db, "mbid-stable", "Stable", "user", 7)
# Build a payload with the same 7 tracks.
tracks = [
{
"identifier": f"https://musicbrainz.org/recording/rec-{i}",
"title": f"Track {i}",
"creator": "Artist",
}
for i in range(7)
]
mgr.client.get_playlist_details.return_value = {
"playlist": {
"identifier": "https://listenbrainz.org/playlist/mbid-stable",
"title": "Stable",
"creator": "ListenBrainz",
"track": tracks,
"annotation": "",
}
}
result = mgr.refresh_playlist("mbid-stable")
assert result["success"] is True
assert result["result"] == "skipped"
# ---------------------------------------------------------------------------
# refresh_playlist: defensive / failure modes
# ---------------------------------------------------------------------------
def test_refresh_playlist_unauthenticated_returns_failure_without_fetching(tmp_db):
"""No auth → no LB API calls. Pre-fix, ``update_all_playlists``
had this check; the new targeted entry-point must enforce it
consistently."""
mgr = _build_manager(tmp_db, authed=False)
result = mgr.refresh_playlist("mbid-anything")
assert result["success"] is False
assert "Not authenticated" in result["error"]
mgr.client.get_playlist_details.assert_not_called()
def test_refresh_playlist_empty_mbid_returns_failure(tmp_db):
"""Defensive — empty mbid is a caller bug; fail loud with a
clear error message rather than firing a malformed API call."""
mgr = _build_manager(tmp_db)
result = mgr.refresh_playlist("")
assert result["success"] is False
assert "No playlist_mbid" in result["error"]
mgr.client.get_playlist_details.assert_not_called()
def test_refresh_playlist_returns_failure_when_upstream_returns_none(tmp_db):
"""LB API returning ``None`` (deleted playlist, transient 404)
is a clean failure not a silent skip. The caller decides
whether to retry / surface."""
mgr = _build_manager(tmp_db)
_seed_playlist(tmp_db, "mbid-gone", "Old", "user", 10)
mgr.client.get_playlist_details.return_value = None
result = mgr.refresh_playlist("mbid-gone")
assert result["success"] is False
assert "not found upstream" in result["error"]
def test_refresh_playlist_defaults_to_user_type_for_unknown_mbid(tmp_db):
"""When the mbid isn't in the cache yet (new discovery), the
manager defaults the playlist_type to ``user`` so the insert
path in ``_update_playlist`` works. Avoids a NULL playlist_type
column on the new row."""
mgr = _build_manager(tmp_db)
# No seed — mbid isn't cached yet.
mgr.client.get_playlist_details.return_value = {
"playlist": {
"identifier": "https://listenbrainz.org/playlist/mbid-new",
"title": "Newly Discovered",
"creator": "ListenBrainz",
"track": [],
"annotation": "",
}
}
result = mgr.refresh_playlist("mbid-new")
assert result["success"] is True
assert result["playlist_type"] == "user"
def test_refresh_playlist_exception_propagates_not_swallowed(tmp_db):
"""If the LB client raises (network failure, JSON parse error),
the exception must propagate. Pre-fix the wrapping adapter
silently swallowed; the manager is the right layer to surface
real errors so the adapter can decide how to log."""
mgr = _build_manager(tmp_db)
_seed_playlist(tmp_db, "mbid-boom", "Boom", "user", 1)
mgr.client.get_playlist_details.side_effect = ConnectionError("LB unreachable")
with pytest.raises(ConnectionError):
mgr.refresh_playlist("mbid-boom")
# ---------------------------------------------------------------------------
# Cost guard: refresh_playlist is strictly cheaper than update_all_playlists.
# ---------------------------------------------------------------------------
def test_refresh_playlist_does_not_walk_cleanup_or_rolling_series_for_unrelated_playlists(tmp_db):
"""``update_all_playlists`` runs ``_cleanup_old_playlists`` +
``_ensure_rolling_mirrors_from_cache`` at the tail. Those are
fine for a full-refresh batch but wasted work for a single-
playlist refresh. Pin that the targeted method skips them."""
mgr = _build_manager(tmp_db)
_seed_playlist(tmp_db, "mbid-narrow", "Narrow", "user", 0)
mgr.client.get_playlist_details.return_value = {
"playlist": {
"identifier": "https://listenbrainz.org/playlist/mbid-narrow",
"title": "Narrow",
"creator": "ListenBrainz",
"track": [],
"annotation": "",
}
}
# Spy the cleanup method.
cleanup_calls: List[Any] = []
original_cleanup = mgr._cleanup_old_playlists
mgr._cleanup_old_playlists = lambda: cleanup_calls.append(True) or original_cleanup()
mgr.refresh_playlist("mbid-narrow")
assert cleanup_calls == [] # Cleanup must NOT fire for targeted refresh.

View file

@ -389,6 +389,9 @@ class _FakeLBManager:
}],
}
self.refresh_called = False
self.refresh_playlist_calls: list[str] = []
# Toggle to raise from refresh_playlist for the silent-swallow test.
self.refresh_raises: Optional[Exception] = None
def get_cached_playlists(self, playlist_type: str):
return self._rows.get(playlist_type, [])
@ -403,8 +406,17 @@ class _FakeLBManager:
return self._tracks.get(mbid, [])
def update_all_playlists(self):
# Pre-fix fallback — kept so adapters that haven't been
# migrated still work, and so an accidental return to the
# legacy entry-point is detectable in tests.
self.refresh_called = True
def refresh_playlist(self, mbid: str):
self.refresh_playlist_calls.append(mbid)
if self.refresh_raises is not None:
raise self.refresh_raises
return {"success": True, "result": "skipped", "playlist_mbid": mbid}
def test_listenbrainz_adapter_marks_needs_discovery():
manager = _FakeLBManager()
@ -424,11 +436,79 @@ def test_listenbrainz_adapter_marks_needs_discovery():
assert t.extra["recording_mbid"] == "rec-1"
def test_listenbrainz_adapter_refresh_calls_manager():
def test_listenbrainz_adapter_refresh_uses_targeted_manager_call():
"""Adapter must call ``manager.refresh_playlist(mbid)`` — the
targeted single-playlist refresh not the legacy
``update_all_playlists`` which re-pulls every cached LB row.
"""
manager = _FakeLBManager()
src = ListenBrainzPlaylistSource(lambda: manager)
src.refresh_playlist("lb-1")
assert manager.refresh_called is True
detail = src.refresh_playlist("lb-1")
assert manager.refresh_playlist_calls == ["lb-1"]
# Legacy entry-point must NOT be touched.
assert manager.refresh_called is False
# Refresh returned a detail (read-back via get_playlist).
assert detail is not None
assert detail.meta.source_playlist_id == "lb-1"
def test_listenbrainz_adapter_refresh_logs_and_returns_none_on_manager_error():
"""When the LB manager raises, the adapter MUST surface the
failure as ``None`` (so the outer handler logs + counts it),
not silently swallow and return a stale cache read.
Pre-fix: ``except Exception: pass`` then ``return get_playlist()``
masking every LB API failure as a successful no-op refresh.
"""
manager = _FakeLBManager()
manager.refresh_raises = RuntimeError("LB API timed out")
src = ListenBrainzPlaylistSource(lambda: manager)
result = src.refresh_playlist("lb-1")
assert result is None
assert manager.refresh_playlist_calls == ["lb-1"]
def test_listenbrainz_adapter_refresh_resolves_synthetic_series_id():
"""Rolling-series synthetic ids (``lb_weekly_jams_<user>``) must
resolve to the latest cached member MBID before calling the
targeted manager refresh. Without resolution the manager would
try to fetch the synthetic id as a real MBID and 404."""
manager = _FakeLBManager()
# Re-shape the fake's rows so the title matches a series LIKE pattern.
manager._rows["created_for_user"] = [{
"playlist_mbid": "weekly-mbid",
"title": "Weekly Jams for nezreka, week of 2026-05-25 Mon",
"creator": "ListenBrainz",
"track_count": 1,
"annotation": {},
"last_updated": "2026-05-26",
}]
manager._tracks = {"weekly-mbid": []}
# Stub the manager DB connection used by the resolution helper.
import sqlite3
conn = sqlite3.connect(":memory:")
cur = conn.cursor()
cur.execute(
"CREATE TABLE listenbrainz_playlists "
"(playlist_mbid TEXT, title TEXT, profile_id INTEGER, last_updated TEXT)"
)
cur.execute(
"INSERT INTO listenbrainz_playlists VALUES "
"('weekly-mbid', 'Weekly Jams for nezreka, week of 2026-05-25 Mon', 1, '2026-05-26')"
)
conn.commit()
manager.profile_id = 1
manager._get_db_connection = lambda: conn
src = ListenBrainzPlaylistSource(lambda: manager)
src.refresh_playlist("lb_weekly_jams_nezreka")
# Manager refresh got called with the RESOLVED real MBID, not the
# synthetic one.
assert manager.refresh_playlist_calls == ["weekly-mbid"]
# ─── Last.fm ────────────────────────────────────────────────────────────

View file

@ -82,6 +82,38 @@ def test_sab_state_mapping_covers_queue_states() -> None:
assert sab_map('') == 'error'
def test_sab_state_mapping_covers_full_sab_status_enum() -> None:
"""Every Status value SAB's sabnzbd/constants.py:Status emits must
map to a known adapter state, NOT to the default 'error' fallback.
Pre-fix: SAB's ``Deleted`` and ``Propagating`` were unmapped,
fell through to the 'error' default, and the poll loop treated
'error' as neither complete nor failed it just spun until the
6-hour timeout."""
canonical = [
'Idle', 'Queued', 'Grabbing', 'Propagating',
'Fetching', 'Downloading', 'Paused',
'Checking', 'QuickCheck', 'Verifying', 'Repairing',
'Extracting', 'Moving', 'Running',
'Completed', 'Failed', 'Deleted',
]
for state in canonical:
assert sab_map(state) != 'error', f'{state!r} fell through to error default'
def test_sab_state_mapping_propagating_routes_to_queued() -> None:
"""Propagating is SAB's pre-download delay state — semantically
'we're waiting for the NZB to be available', map to queued so
the poll doesn't treat it as downloading progress."""
assert sab_map('Propagating') == 'queued'
def test_sab_state_mapping_deleted_routes_to_failed() -> None:
"""User removed the job mid-flight — terminal failure from
SoulSync's perspective. Without this, the poll would keep
spinning waiting for a job that's never coming back."""
assert sab_map('Deleted') == 'failed'
def test_sab_parse_timeleft_handles_hhmmss() -> None:
# SABnzbd's timeleft is always HH:MM:SS (or H:MM:SS).
assert SABnzbdAdapter._parse_timeleft('01:30:00') == 5400
@ -176,6 +208,134 @@ def test_sab_get_all_merges_queue_and_history() -> None:
assert [s.state for s in statuses] == ['downloading', 'completed']
def test_sab_get_status_uses_direct_nzo_ids_lookup_against_queue() -> None:
"""Targeted nzo_ids query against queue first — avoids paging
the full 50-entry history bulk fetch on every poll."""
adapter = _sab_with_config()
queue_resp = _mock_response(200, {'queue': {'slots': [
{'nzo_id': 'target', 'filename': 'Album.nzb', 'status': 'Downloading',
'percentage': '50', 'mb': '100', 'mbleft': '50', 'timeleft': '0:01:00'},
]}})
with patch('core.usenet_clients.sabnzbd.http_requests.get',
return_value=queue_resp) as mock_get:
status = adapter._get_status_sync('target')
assert status is not None
assert status.id == 'target'
assert status.state == 'downloading'
# First call must include the nzo_ids filter — that's the whole
# point of the change.
assert mock_get.call_args.kwargs['params']['mode'] == 'queue'
assert mock_get.call_args.kwargs['params']['nzo_ids'] == 'target'
def test_sab_get_status_falls_through_to_history_when_queue_empty() -> None:
"""Job already moved out of queue → check history with the same
nzo_ids filter. Direct lookup means SoulSync doesn't lose the
job on a busy SAB where it's rolled past the bulk history limit."""
adapter = _sab_with_config()
empty_queue = _mock_response(200, {'queue': {'slots': []}})
history_resp = _mock_response(200, {'history': {'slots': [
{'nzo_id': 'target', 'name': 'Album.nzb', 'status': 'Completed',
'bytes': 1024, 'storage': '/done/Album'},
]}})
with patch('core.usenet_clients.sabnzbd.http_requests.get',
side_effect=[empty_queue, history_resp]) as mock_get:
status = adapter._get_status_sync('target')
assert status is not None
assert status.id == 'target'
assert status.state == 'completed'
assert status.save_path == '/done/Album'
# Second call must hit the history endpoint, also filtered by id.
second_params = mock_get.call_args_list[1].kwargs['params']
assert second_params['mode'] == 'history'
assert second_params['nzo_ids'] == 'target'
def test_sab_get_status_returns_none_when_neither_endpoint_finds_id() -> None:
"""Mid SAB queue→history transition window: the slot is gone
from the queue but not yet in history. Direct lookup returns
None the poll layer treats this as a transient miss and
retries, NOT as a terminal failure. Pre-fix this was the most
likely trigger for the user's stuck-at-downloading bug (#706).
Bulk fallback also returns nothing (both endpoints reported
empty); ``_get_status_sync`` returns None rather than raising."""
adapter = _sab_with_config()
empty_queue = _mock_response(200, {'queue': {'slots': []}})
empty_history = _mock_response(200, {'history': {'slots': []}})
# Three calls: direct queue, direct history, bulk fallback queue
# + bulk fallback history. Empty for all four.
with patch('core.usenet_clients.sabnzbd.http_requests.get',
side_effect=[empty_queue, empty_history, empty_queue, empty_history]):
status = adapter._get_status_sync('target')
assert status is None
def test_sab_get_status_empty_job_id_returns_none_without_hitting_api() -> None:
"""Defensive — an empty job_id from upstream shouldn't fire
HTTP queries we know will be wrong."""
adapter = _sab_with_config()
with patch('core.usenet_clients.sabnzbd.http_requests.get') as mock_get:
assert adapter._get_status_sync('') is None
mock_get.assert_not_called()
def test_sab_poll_recovers_after_queue_to_history_handoff_gap() -> None:
"""Integration test: simulate the SAB queue→history transition
window end-to-end through the adapter. Sequence: 3 polls where
SAB has moved the slot out of queue but hasn't added it to
history yet (both endpoints return empty), followed by the slot
appearing in history as Completed with a save_path. Pre-fix,
the first None read on the SAB side surfaced to the poll layer
as 'disappeared' terminal failure even though SAB was healthy
and just mid-handoff. Post-fix the adapter still returns None
during the gap, but the poll helper's TransientMissCounter
absorbs the gap and recovers when the history entry appears."""
from core.download_plugins.album_bundle import poll_album_download
adapter = _sab_with_config()
empty_queue = _mock_response(200, {'queue': {'slots': []}})
empty_history = _mock_response(200, {'history': {'slots': []}})
final_history = _mock_response(200, {'history': {'slots': [
{'nzo_id': 'target', 'name': 'Album.nzb', 'status': 'Completed',
'bytes': 1024, 'storage': '/done/Album'},
]}})
# Each _get_status_sync call hits two endpoints (queue + history).
# Three gap polls + one recovery poll = 4 * 2 = 8 HTTP calls.
# On recovery the queue is still empty but the history finds the job.
poll_results = [
empty_queue, empty_history, # gap poll 1
empty_queue, empty_history, # gap poll 2
empty_queue, empty_history, # gap poll 3
empty_queue, final_history, # recovery
]
class _Clock:
def __init__(self): self.now = 0.0
def monotonic(self): return self.now
def sleep(self, s): self.now += s
clock = _Clock()
emits: list = []
with patch('core.usenet_clients.sabnzbd.http_requests.get',
side_effect=poll_results):
result = poll_album_download(
get_status=lambda: adapter._get_status_sync('target'),
title='Linkin Park - From Zero',
emit=lambda state, **kw: emits.append((state, kw)),
complete_states=frozenset(['completed']),
failed_states=frozenset(['failed']),
transient_miss_threshold=5,
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=60.0,
)
assert result == '/done/Album'
# Terminal failure must NOT have been emitted — the gap was transient.
assert 'failed' not in [e[0] for e in emits]
# ---------------------------------------------------------------------------
# NZBGet
# ---------------------------------------------------------------------------

View file

@ -55,20 +55,19 @@ def test_single_album_groups_all_tracks_together():
def test_multiple_albums_emit_separate_groups():
"""Two tracks in alb1 promotes that album to a group at the default
threshold of 2; alb2's one solo track falls to residual."""
tracks = [
_wt('Song A', 'Artist 1', 'alb1', 'Album 1'),
_wt('Song B', 'Artist 1', 'alb1', 'Album 1'),
_wt('Song C', 'Artist 2', 'alb2', 'Album 2'),
]
res = group_wishlist_tracks_by_album(tracks)
assert len(res.album_groups) == 2
keys = {g.album_key for g in res.album_groups}
assert keys == {'alb1', 'alb2'}
for g in res.album_groups:
if g.album_key == 'alb1':
assert len(g.tracks) == 2
else:
assert len(g.tracks) == 1
assert len(res.album_groups) == 1
assert res.album_groups[0].album_key == 'alb1'
assert len(res.album_groups[0].tracks) == 2
assert len(res.residual_tracks) == 1
assert res.residual_tracks[0]['track_name'] == 'Song C'
def test_missing_album_metadata_falls_through_to_residual():
@ -99,9 +98,9 @@ def test_missing_artist_demotes_to_residual():
def test_min_tracks_threshold_demotes_solos():
"""When ``min_tracks_per_album=2``, single-track albums fall to
residual so the user doesn't fire a bundle search for a 1-track
rip when per-track would do."""
"""When ``min_tracks_per_album=2`` (the default), single-track
albums fall to residual so the user doesn't fire a bundle search
for a 1-track rip when per-track would do."""
tracks = [
_wt('Solo Track', 'Artist 1', 'alb1', 'Album 1'),
_wt('Song A', 'Artist 2', 'alb2', 'Album 2'),
@ -114,12 +113,44 @@ def test_min_tracks_threshold_demotes_solos():
assert res.residual_tracks[0]['track_name'] == 'Solo Track'
def test_default_threshold_promotes_solo_albums():
"""Default ``min_tracks_per_album=1`` — even one missing track
triggers the album-bundle path. Matches the user's stated
preference (don't gate on track count)."""
def test_default_threshold_demotes_solo_albums():
"""Default ``min_tracks_per_album=2`` — a wishlist with one missing
track from one album falls to residual so the per-track flow
handles it instead of engaging album-bundle for a single file.
Real-world reason: typical wishlist looks like "26 single tracks
from 26 different albums" and bundling each one downloads ~85%
bandwidth as unwanted files, hammers slskd with concurrent
searches, and re-downloads the same album every cycle when the
staging-match step doesn't claim the requested track."""
tracks = [_wt('Solo', 'Artist 1', 'alb1', 'Album 1')]
res = group_wishlist_tracks_by_album(tracks)
assert res.album_groups == []
assert len(res.residual_tracks) == 1
assert res.residual_tracks[0]['track_name'] == 'Solo'
def test_default_threshold_promotes_multi_track_albums():
"""Default still promotes albums with 2+ missing tracks — the
album-bundle path is the right tool when several files from the
same release are missing (downloading 5 tracks to claim 2 still
beats five separate per-track searches against the same album)."""
tracks = [
_wt('Song A', 'Artist', 'alb1', 'Album'),
_wt('Song B', 'Artist', 'alb1', 'Album'),
]
res = group_wishlist_tracks_by_album(tracks)
assert len(res.album_groups) == 1
assert len(res.album_groups[0].tracks) == 2
assert res.residual_tracks == []
def test_explicit_threshold_one_restores_solo_promotion():
"""Power users / tests can opt back into the old "bundle even one
track" behaviour by passing ``min_tracks_per_album=1`` explicitly.
Default change is opt-out via the public kwarg, not a removed
capability."""
tracks = [_wt('Solo', 'Artist 1', 'alb1', 'Album 1')]
res = group_wishlist_tracks_by_album(tracks, min_tracks_per_album=1)
assert len(res.album_groups) == 1
assert res.residual_tracks == []
@ -154,6 +185,8 @@ def test_nested_track_data_payloads_normalized():
},
},
}]
res = group_wishlist_tracks_by_album(tracks)
# Use threshold=1 here — the test is about payload-shape parsing,
# not the threshold rule, so don't conflate the two.
res = group_wishlist_tracks_by_album(tracks, min_tracks_per_album=1)
assert len(res.album_groups) == 1
assert res.album_groups[0].album_key == 'a'

View file

@ -228,7 +228,12 @@ def test_process_wishlist_automatically_creates_batch_for_matching_tracks():
assert submitted_args[2][0]["_original_index"] == 0
assert len(batch_map) == 1
batch = next(iter(batch_map.values()))
assert batch["phase"] == "analysis"
# ``queued`` is the initial state — the master worker flips it
# to ``analysis`` as its first action when the executor picks
# the batch up. Without this, wishlist runs with N > 3
# sub-batches all rendered "Analyzing..." simultaneously even
# though only 3 workers were running (UI lie).
assert batch["phase"] == "queued"
assert batch["playlist_name"] == "Wishlist (Auto - Albums)"
assert batch["analysis_total"] == 1
assert any(kwargs.get("progress") == 50 for _args, kwargs in progress_calls)
@ -238,14 +243,17 @@ def test_process_wishlist_automatically_creates_batch_for_matching_tracks():
def test_wishlist_albums_cycle_splits_into_per_album_batches():
"""Multi-album wishlist run: each album emits its own sub-batch
"""Multi-album wishlist run: each album with at least the
threshold of missing tracks (default 2) emits its own sub-batch
with ``is_album_download=True`` + populated album/artist context.
Pinned so the album-bundle dispatch gate (which keys on those
fields) engages per album instead of falling through to per-track
on a single mixed batch."""
Single-track-per-album items fall to residual and take the
per-track path. Pinned so the album-bundle dispatch gate (which
keys on those fields) engages per album instead of falling through
to per-track on a single mixed batch."""
batch_map = {}
runtime, _service, _profiles_db, music_db, executor, _logger, _progress, _guards = _build_runtime(
tracks=[
# Album one: 2 missing tracks → promotes to album-bundle.
{
"name": "Song A1",
"artists": [{"name": "Artist 1"}],
@ -262,6 +270,7 @@ def test_wishlist_albums_cycle_splits_into_per_album_batches():
"artists": [{"name": "Artist 1"}],
},
},
# Album two: 3 missing tracks → also promotes.
{
"name": "Song B1",
"artists": [{"name": "Artist 2"}],
@ -270,9 +279,25 @@ def test_wishlist_albums_cycle_splits_into_per_album_batches():
"artists": [{"name": "Artist 2"}],
},
},
{
"name": "Song B2",
"artists": [{"name": "Artist 2"}],
"spotify_data": {
"album": {"id": "alb2", "name": "Album Two", "album_type": "album"},
"artists": [{"name": "Artist 2"}],
},
},
{
"name": "Song B3",
"artists": [{"name": "Artist 2"}],
"spotify_data": {
"album": {"id": "alb2", "name": "Album Two", "album_type": "album"},
"artists": [{"name": "Artist 2"}],
},
},
],
cycle_value="albums",
count=3,
count=5,
batch_map=batch_map,
)
@ -290,7 +315,7 @@ def test_wishlist_albums_cycle_splits_into_per_album_batches():
submitted_track_lists = [submitted_args[2] for _fn, submitted_args, _kw in executor.submissions]
track_counts = sorted(len(tracks) for tracks in submitted_track_lists)
assert track_counts == [1, 2]
assert track_counts == [2, 3]
# All sub-batches of one wishlist invocation share a single
# ``wishlist_run_id`` so the completion handler can gate the
@ -303,13 +328,16 @@ def test_wishlist_albums_cycle_splits_into_per_album_batches():
def test_wishlist_albums_cycle_residual_for_orphan_tracks():
"""Tracks without resolvable album metadata fall to the classic
per-track residual batch (no ``is_album_download`` flag), while
sibling tracks with valid album info still get their own
album-bundle sub-batch."""
sibling tracks with valid album info AND enough missing tracks to
clear the bundle threshold still get their own album-bundle
sub-batch. With the default threshold bumped to 2, the album side
needs at least two tracks from the same album to promote."""
batch_map = {}
runtime, _service, _profiles_db, music_db, executor, _logger, _progress, _guards = _build_runtime(
tracks=[
# Album side: two missing tracks from Album One → promotes.
{
"name": "Real Album Track",
"name": "Real Album Track 1",
"artists": [{"name": "Artist 1"}],
"spotify_data": {
"album": {"id": "alb1", "name": "Album One", "album_type": "album"},
@ -317,14 +345,22 @@ def test_wishlist_albums_cycle_residual_for_orphan_tracks():
},
},
{
# No album id, no album name — orphan
"name": "Real Album Track 2",
"artists": [{"name": "Artist 1"}],
"spotify_data": {
"album": {"id": "alb1", "name": "Album One", "album_type": "album"},
"artists": [{"name": "Artist 1"}],
},
},
# Orphan: no album id, no album name → residual.
{
"name": "Orphan",
"artists": [{"name": "X"}],
"spotify_data": {"album": {"album_type": "album"}, "artists": [{"name": "X"}]},
},
],
cycle_value="albums",
count=2,
count=3,
batch_map=batch_map,
)
@ -337,6 +373,7 @@ def test_wishlist_albums_cycle_residual_for_orphan_tracks():
assert len(album_batches) == 1
assert len(residual_batches) == 1
assert album_batches[0]["album_context"]["name"] == "Album One"
assert album_batches[0]["analysis_total"] == 2
assert residual_batches[0]["analysis_total"] == 1

View file

@ -234,15 +234,17 @@ def test_start_manual_wishlist_download_batch_does_not_run_library_cleanup():
def test_manual_wishlist_splits_into_per_album_sub_batches():
"""Manual wishlist run with multi-album content splits into one
sub-batch per album. Each sub-batch flips
``is_album_download=True`` + populates album/artist context so
the slskd / Prowlarr album-bundle dispatch engages.
sub-batch per album that has at least the threshold of missing
tracks (default 2). Each sub-batch flips ``is_album_download=True``
+ populates album/artist context so the slskd / Prowlarr
album-bundle dispatch engages.
Pinned to verify the manual path matches the auto path's
behavior the user's first real-world test hit the manual
flow, not the auto flow."""
runtime, _service, _db, executor, _logger, _activity, batch_map, master_calls = _build_runtime(
tracks=[
# Album one: 2 missing tracks → promotes to album-bundle.
{
"id": "trk-a1",
"spotify_track_id": "trk-a1",
@ -263,6 +265,7 @@ def test_manual_wishlist_splits_into_per_album_sub_batches():
"artists": [{"name": "Artist 1"}],
},
},
# Album two: 2 missing tracks → also promotes.
{
"id": "trk-b1",
"spotify_track_id": "trk-b1",
@ -273,6 +276,16 @@ def test_manual_wishlist_splits_into_per_album_sub_batches():
"artists": [{"name": "Artist 2"}],
},
},
{
"id": "trk-b2",
"spotify_track_id": "trk-b2",
"name": "Song B2",
"artists": [{"name": "Artist 2"}],
"spotify_data": {
"album": {"id": "alb2", "name": "Album Two", "album_type": "album"},
"artists": [{"name": "Artist 2"}],
},
},
]
)
@ -294,9 +307,9 @@ def test_manual_wishlist_splits_into_per_album_sub_batches():
assert second_args[0] in batch_map
assert batch_map[second_args[0]].get("is_album_download") is True
# Track counts across the two sub-batches sum to the wishlist total.
# Track counts across the two sub-batches: 2 each at threshold=2.
counts = sorted(len(args[2]) for args, _ in master_calls)
assert counts == [1, 2]
assert counts == [2, 2]
# Both sub-batches carry album context populated from spotify_data.
album_names = {

View file

@ -106,6 +106,204 @@ def test_extract_spotify_track_from_modal_info_reconstructs_from_slskd_result():
assert out["album"]["name"] == "Album Three"
# ---------------------------------------------------------------------------
# track_number / disc_number preservation through the wishlist payload
# helpers — pins the bug A fix from PR 2/4. Pre-fix the helpers
# defaulted missing numbers to 1, which locked every wishlist retry
# to track 01 because the import pipeline's filename-extract fallback
# only fires when the value is None (not the pre-filled 1).
# ---------------------------------------------------------------------------
def test_ensure_wishlist_track_format_preserves_real_track_number():
"""Real track positions must survive the format helper. Pre-fix
the helper read ``track_info.get('track_number', 1)`` which always
returned 1 if the upstream payload had dropped the key the
desired number was lost on every round-trip."""
track = {
"name": "No Sleep Till Brooklyn",
"artist": "Beastie Boys",
"album": {"name": "Licensed to Ill", "release_date": "1986-11-15"},
"track_number": 8,
"disc_number": 1,
}
out = payloads.ensure_wishlist_track_format(track)
assert out["track_number"] == 8
assert out["disc_number"] == 1
def test_ensure_wishlist_track_format_keeps_missing_track_number_as_none():
"""When the upstream payload doesn't carry a track number, the
helper must NOT pre-fill 1 that poisons the chain and locks the
file to track 01. Leave None so the import pipeline's filename
fallback at ``core/imports/pipeline.py:652`` can fire."""
track = {
"name": "Mystery Track",
"artist": "Artist",
"album": {"name": "Album"},
}
out = payloads.ensure_wishlist_track_format(track)
assert out["track_number"] is None
assert out["disc_number"] is None
def test_build_cancelled_task_wishlist_payload_preserves_track_number():
"""Cancellation→re-add path was the worst offender — the payload
builder dropped track_number from the saved data entirely (didn't
even include the key). Next wishlist cycle saw missing key
helper defaulted to 1 file imported as 01. Now both the
cancellation payload AND the helper preserve real positions."""
task = {
"track_info": {
"id": "trk-1", "name": "Brass Monkey",
"artists": [{"name": "Beastie Boys"}],
"album": {"name": "Licensed to Ill", "release_date": "1986-11-15"},
"track_number": 11,
"disc_number": 1,
},
"playlist_name": "Wishlist",
"playlist_id": "p1",
}
out = payloads.build_cancelled_task_wishlist_payload(task)
td = out["track_data"]
assert td["track_number"] == 11
assert td["disc_number"] == 1
# Album release_date survives the round-trip so the path template
# renders the year in the folder name.
assert td["album"]["release_date"] == "1986-11-15"
def test_track_object_to_dict_preserves_full_album_dict():
"""When the input track has an album as a DICT (e.g. raw Spotify
spotify_track_data), every album field must survive the
Trackdict conversion. Pre-fix the conversion built
``album = {'name': X}`` only, silently dropping release_date /
images / album_type / total_tracks. Result: every wishlist row
added from a Track-object path had empty release_date in the DB
import path-template rendered without year user's main
complaint."""
track_obj = SimpleNamespace(
id="track-1",
name="Never Gonna Give You Up",
artists=[{"name": "Rick Astley"}],
album={
"id": "alb-1",
"name": "Whenever You Need Somebody",
"release_date": "1987-11-12",
"album_type": "album",
"total_tracks": 10,
"images": [{"url": "https://cdn.example/cover.jpg"}],
"artists": [{"name": "Rick Astley"}],
},
duration_ms=213000,
track_number=1,
disc_number=1,
)
out = payloads.track_object_to_dict(track_obj)
assert out["album"]["name"] == "Whenever You Need Somebody"
assert out["album"]["release_date"] == "1987-11-12"
assert out["album"]["album_type"] == "album"
assert out["album"]["total_tracks"] == 10
assert out["album"]["id"] == "alb-1"
assert out["album"]["images"] == [{"url": "https://cdn.example/cover.jpg"}]
assert out["album"]["artists"] == [{"name": "Rick Astley"}]
def test_track_object_to_dict_extracts_release_date_from_album_object():
"""When the album is a sub-OBJECT (e.g. Spotify/Deezer Album
dataclass), the conversion must pull release_date / album_type /
total_tracks from its attributes not assume dict-only access.
Pre-fix the only attr read was ``.name``, dropping everything
else even when the object had it."""
class _AlbumLike:
name = "Licensed to Ill"
id = "alb-li"
release_date = "1986-11-15"
album_type = "album"
total_tracks = 13
artists = ["Beastie Boys"]
images = [{"url": "https://cover.example/li.jpg"}]
track_obj = SimpleNamespace(
id="track-2",
name="No Sleep Till Brooklyn",
artists=[{"name": "Beastie Boys"}],
album=_AlbumLike(),
duration_ms=242000,
track_number=8,
disc_number=1,
)
out = payloads.track_object_to_dict(track_obj)
assert out["album"]["name"] == "Licensed to Ill"
assert out["album"]["release_date"] == "1986-11-15"
assert out["album"]["total_tracks"] == 13
assert out["album"]["id"] == "alb-li"
assert out["track_number"] == 8
def test_track_object_to_dict_string_album_pulls_release_date_from_track_attrs():
"""When the album is a bare STRING (the lean Track dataclass
shape used by some metadata sources), the album dict has to be
built from scratch. Pull release_date + album_type from adjacent
track-object attrs and image_url from the track itself so we
don't lose the path-template inputs entirely."""
track_obj = SimpleNamespace(
id="track-3",
name="Stacy's Mom",
artists=[{"name": "Fountains of Wayne"}],
album="Welcome Interstate Managers",
release_date="2003-06-10",
album_type="album",
total_tracks=15,
image_url="https://cover.example/wim.jpg",
track_number=3,
disc_number=1,
duration_ms=200000,
)
out = payloads.track_object_to_dict(track_obj)
assert out["album"]["name"] == "Welcome Interstate Managers"
assert out["album"]["release_date"] == "2003-06-10"
assert out["album"]["album_type"] == "album"
assert out["album"]["total_tracks"] == 15
assert out["album"]["images"] == [{"url": "https://cover.example/wim.jpg"}]
assert out["track_number"] == 3
def test_track_object_to_dict_missing_track_number_stays_none():
"""Track-object-style sources that genuinely don't know the track
position must surface as None (not pre-filled 1), so the import
pipeline's filename-extract fallback can fire."""
track_obj = SimpleNamespace(
id="track-4",
name="Unknown Track",
artists=[{"name": "Artist"}],
album="Album",
)
out = payloads.track_object_to_dict(track_obj)
assert out["track_number"] is None
assert out["disc_number"] is None
def test_build_cancelled_task_wishlist_payload_string_album_pulls_release_date_from_track_info():
"""When the source ``album`` field is a bare string, the payload
builder constructs an album dict from scratch it must pull
release_date / album_image_url / etc. from the adjacent
track_info fields rather than dropping them silently."""
task = {
"track_info": {
"id": "trk-2", "name": "Song",
"artists": [{"name": "Artist"}],
"album": "Bare String Album",
"release_date": "2020-06-01",
},
}
out = payloads.build_cancelled_task_wishlist_payload(task)
album = out["track_data"]["album"]
assert album["name"] == "Bare String Album"
assert album["release_date"] == "2020-06-01"
def test_ensure_wishlist_track_format_defaults_non_dict_album_to_album_type():
"""When ``album`` arrives as a non-dict (legacy/reconstruction path) we
must not stamp ``album_type='single'`` that lies about the origin

View file

@ -1,8 +1,49 @@
from contextlib import contextmanager
from unittest.mock import patch
from core.wishlist import processing
# ---------------------------------------------------------------------------
# _resolve_album_bundle_threshold — config-driven wishlist bundle threshold
# ---------------------------------------------------------------------------
def test_resolve_album_bundle_threshold_uses_default_when_config_missing():
"""Default 2 matches the bumped ``min_tracks_per_album`` floor in
``group_wishlist_tracks_by_album`` single-track wishlist items
take the per-track path, multi-track items take the bundle path."""
with patch('config.settings.config_manager') as cm:
cm.get.return_value = processing._DEFAULT_ALBUM_BUNDLE_MIN_TRACKS
assert processing._resolve_album_bundle_threshold() == 2
def test_resolve_album_bundle_threshold_honors_config_override():
"""Power users who want bundle for every wishlist track (old
behaviour) can set the key to 1. Users who want bundle only on
bigger gaps can set higher."""
with patch('config.settings.config_manager') as cm:
cm.get.return_value = 1
assert processing._resolve_album_bundle_threshold() == 1
cm.get.return_value = 5
assert processing._resolve_album_bundle_threshold() == 5
def test_resolve_album_bundle_threshold_falls_back_on_garbage():
"""Non-numeric / non-positive / config-manager-raise all fall back
to the safe default. Same defensive shape as get_poll_interval /
get_transient_miss_threshold."""
with patch('config.settings.config_manager') as cm:
cm.get.return_value = 'oops'
assert processing._resolve_album_bundle_threshold() == 2
cm.get.return_value = 0
assert processing._resolve_album_bundle_threshold() == 2
cm.get.return_value = -3
assert processing._resolve_album_bundle_threshold() == 2
cm.get.side_effect = RuntimeError('boom')
assert processing._resolve_album_bundle_threshold() == 2
class _FakeLogger:
def __init__(self):
self.errors = []

View file

@ -40,7 +40,7 @@ logger = setup_logging(_log_level, _log_path)
# App version — single source of truth for backup metadata, system-info, update check, etc.
# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release.
_SOULSYNC_BASE_VERSION = "2.6.2"
_SOULSYNC_BASE_VERSION = "2.6.3"
def _build_version_string():
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
@ -5984,14 +5984,24 @@ def start_download():
def _find_completed_file_robust(download_dir, api_filename, transfer_dir=None):
"""
Robustly finds a completed file on disk, accounting for name variations and
unexpected subdirectories. This version uses the superior normalization logic
from the GUI's matching_engine.py to ensure consistency.
"""Thin wrapper around the shared file finder.
First searches in download_dir, then optionally searches in transfer_dir if provided.
Returns tuple (file_path, location) where location is 'downloads' or 'transfer'.
Behaviour preserved verbatim the implementation lives in
``core/downloads/file_finder.py`` so the Soulseek album-bundle
poll (which previously had its own 3-candidate probe and
silently timed out on slskd configs that nested downloads under
a username subdir, see issue #715) and the per-track download
poll go through the same recursive-walk + path-confirm logic.
"""
from core.downloads.file_finder import find_completed_audio_file
return find_completed_audio_file(download_dir, api_filename, transfer_dir)
def _find_completed_file_robust_legacy(download_dir, api_filename, transfer_dir=None):
"""Legacy inline implementation, kept for reference. Unused
after the lift to ``core/downloads/file_finder.py``. Will be
removed after the next release ships and the new finder
proves itself in the field."""
import re
import os
from difflib import SequenceMatcher
@ -36677,6 +36687,33 @@ def start_runtime_services():
else:
logger.warning("No stuck flags detected - system healthy")
# Album-bundle staging sweep — remove orphan ``<batch_id>``
# dirs left behind by previous-session crashes, errored
# batches, or pre-fix Soulseek bundles that the per-batch
# cleanup gate excluded. Runs once at startup, before any
# new batch can register a staging dir, so we can't race a
# starting batch. download_batches is empty at this point
# (no batches survive a process restart) — every dir on
# disk is by definition an orphan.
try:
from core.downloads.lifecycle import sweep_orphan_album_bundle_staging
_staging_root = config_manager.get(
'download_source.album_bundle_staging_path',
'storage/album_bundle_staging',
) or 'storage/album_bundle_staging'
_swept = sweep_orphan_album_bundle_staging(
_staging_root,
active_batch_ids=set(download_batches.keys()),
)
if _swept:
logger.warning(
"[Startup] Swept %d orphan album-bundle staging dir(s) from %s",
_swept, _staging_root,
)
except Exception as _sweep_err:
# Sweep must not crash startup — log and continue.
logger.warning("[Startup] Album-bundle staging sweep failed: %s", _sweep_err)
# Start simple background monitor when server starts
logger.info("Starting simple background monitor...")
start_simple_background_monitor()

View file

@ -996,51 +996,51 @@
<!-- Left Panel: Tabbed Playlist Section -->
<div class="sync-main-panel">
<div class="sync-tabs">
<button class="sync-tab-button sync-tab-server active" data-tab="server">
<span class="tab-icon server-icon"></span> Server Playlists
<button class="sync-tab-button sync-tab-server active" data-tab="server" title="Server Playlists">
<span class="tab-icon server-icon"></span><span class="sync-tab-label">Server Playlists</span>
</button>
<div class="sync-tab-divider"></div>
<button class="sync-tab-button" data-tab="spotify">
<span class="tab-icon spotify-icon"></span> Spotify
<button class="sync-tab-button" data-tab="spotify" title="Spotify">
<span class="tab-icon spotify-icon"></span><span class="sync-tab-label">Spotify</span>
</button>
<button class="sync-tab-button" data-tab="spotify-public">
<span class="tab-icon spotify-icon"></span> Spotify Link
<button class="sync-tab-button" data-tab="spotify-public" data-link="true" title="Spotify Link">
<span class="tab-icon spotify-icon"></span><span class="sync-tab-label">Spotify Link</span>
</button>
<button class="sync-tab-button" data-tab="itunes-link">
<span class="tab-icon itunes-icon"></span> iTunes Link
<button class="sync-tab-button" data-tab="itunes-link" data-link="true" title="iTunes Link">
<span class="tab-icon itunes-icon"></span><span class="sync-tab-label">iTunes Link</span>
</button>
<button class="sync-tab-button" data-tab="tidal">
<span class="tab-icon tidal-icon"></span> Tidal
<button class="sync-tab-button" data-tab="tidal" title="Tidal">
<span class="tab-icon tidal-icon"></span><span class="sync-tab-label">Tidal</span>
</button>
<button class="sync-tab-button" data-tab="qobuz">
<span class="tab-icon qobuz-icon"></span> Qobuz
<button class="sync-tab-button" data-tab="qobuz" title="Qobuz">
<span class="tab-icon qobuz-icon"></span><span class="sync-tab-label">Qobuz</span>
</button>
<button class="sync-tab-button" data-tab="deezer">
<span class="tab-icon deezer-icon"></span> Deezer
<button class="sync-tab-button" data-tab="deezer" title="Deezer">
<span class="tab-icon deezer-icon"></span><span class="sync-tab-label">Deezer</span>
</button>
<button class="sync-tab-button" data-tab="deezer-link">
<span class="tab-icon deezer-icon"></span> Deezer Link
<button class="sync-tab-button" data-tab="deezer-link" data-link="true" title="Deezer Link">
<span class="tab-icon deezer-icon"></span><span class="sync-tab-label">Deezer Link</span>
</button>
<button class="sync-tab-button" data-tab="youtube">
<span class="tab-icon youtube-icon"></span> YouTube
<button class="sync-tab-button" data-tab="youtube" title="YouTube">
<span class="tab-icon youtube-icon"></span><span class="sync-tab-label">YouTube</span>
</button>
<button class="sync-tab-button" data-tab="beatport">
<span class="tab-icon beatport-icon"></span> Beatport
<button class="sync-tab-button" data-tab="beatport" title="Beatport">
<span class="tab-icon beatport-icon"></span><span class="sync-tab-label">Beatport</span>
</button>
<button class="sync-tab-button" data-tab="listenbrainz-sync">
<span class="tab-icon listenbrainz-icon"></span> ListenBrainz
<button class="sync-tab-button" data-tab="listenbrainz-sync" title="ListenBrainz">
<span class="tab-icon listenbrainz-icon"></span><span class="sync-tab-label">ListenBrainz</span>
</button>
<button class="sync-tab-button" data-tab="lastfm-sync">
<span class="tab-icon lastfm-icon"></span> Last.fm
<button class="sync-tab-button" data-tab="lastfm-sync" title="Last.fm">
<span class="tab-icon lastfm-icon"></span><span class="sync-tab-label">Last.fm</span>
</button>
<button class="sync-tab-button" data-tab="soulsync-discovery-sync">
<span class="tab-icon soulsync-discovery-icon"></span> SoulSync Discovery
<button class="sync-tab-button" data-tab="soulsync-discovery-sync" title="SoulSync Discovery">
<span class="tab-icon soulsync-discovery-icon"></span><span class="sync-tab-label">SoulSync Discovery</span>
</button>
<button class="sync-tab-button" data-tab="import-file">
<span class="tab-icon import-file-icon"></span> Import
<button class="sync-tab-button" data-tab="import-file" title="Import">
<span class="tab-icon import-file-icon"></span><span class="sync-tab-label">Import</span>
</button>
<button class="sync-tab-button" data-tab="mirrored">
<span class="tab-icon mirrored-icon"></span> Mirrored
<button class="sync-tab-button" data-tab="mirrored" title="Mirrored">
<span class="tab-icon mirrored-icon"></span><span class="sync-tab-label">Mirrored</span>
</button>
</div>

View file

@ -19,6 +19,31 @@ const _RATE_GAUGE_COLORS = {
amazon: '#FF9900',
};
// Brand logos rendered inside each equalizer bar's avatar disc.
// Same URLs the header-actions worker-orb buttons use — sourced
// from each service's official press / SVG-repo asset so the row
// reads as branded chips, not anonymous initials. AudioDB ships
// no public logo URL, so it lives as a local static file.
const _RATE_GAUGE_LOGOS = {
spotify: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png',
itunes: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/ITunes_logo.svg/960px-ITunes_logo.svg.png',
deezer: 'https://cdn.brandfetch.io/idEUKgCNtu/theme/dark/symbol.svg?c=1bxid64Mup7aczewSAYMX&t=1758260798610',
lastfm: 'https://www.last.fm/static/images/lastfm_avatar_twitter.52a5d69a85ac.png',
genius: 'https://images.genius.com/8ed669cadd956443e29c70361ec4f372.1000x1000x1.png',
musicbrainz:'https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/MusicBrainz_Logo_%282016%29.svg/500px-MusicBrainz_Logo_%282016%29.svg.png',
audiodb: '/static/audiodb.png',
tidal: 'https://www.svgrepo.com/show/519734/tidal.svg',
qobuz: 'https://www.svgrepo.com/show/504778/qobuz.svg',
discogs: 'https://www.svgrepo.com/show/305957/discogs.svg',
amazon: '/static/amazon.svg',
};
// Per-service display state for the equalizer bars. Holds the last
// rendered count so we can animate digit changes via easing, and the
// last fill height so the spike-detect peak flash only fires on a
// real upward step (not on repaint / equal-value socket updates).
const _eqDisplay = {};
// SVG constants — 240° arc, gap at bottom
const _G = { size: 160, cx: 80, cy: 84, r: 56, stroke: 8, startAngle: 240, totalArc: 240 };
@ -40,6 +65,19 @@ function _handleRateMonitorUpdate(data) {
const grid = document.getElementById('rate-monitor-grid');
if (!grid) return;
// The dashboard rate monitor uses the equalizer-bar visual — a
// vertical-bar VU-meter row that fits any service count without
// an orphan grid cell. Detail page / mobile breakpoints keep the
// legacy speedometer card markup so existing CSS still applies.
const useEqualizer = grid.classList.contains('rate-monitor-grid--equalizer')
|| grid.closest('[data-card="enrichment"]') !== null;
if (useEqualizer) {
grid.classList.add('rate-monitor-grid--equalizer');
_renderEqualizerBars(grid, data);
return;
}
if (!grid.children.length) {
for (const svc of _RATE_GAUGE_SERVICES) {
const div = document.createElement('div');
@ -143,6 +181,180 @@ function _handleRateMonitorUpdate(data) {
}
}
// ── Equalizer-bar renderer (dashboard) ─────────────────────────────────
//
// VU-meter aesthetic: one vertical bar per service. Bar height = current
// rate / limit. Service brand color fades up the bar with an animated
// glow at the tip when active. Click opens the same detail modal the
// speedometer used. Symmetric by design — any service count fits one
// flex row regardless of viewport.
function _renderEqualizerBars(grid, data) {
if (!grid.children.length) {
for (const svc of _RATE_GAUGE_SERVICES) {
const accent = _RATE_GAUGE_COLORS[svc] || '#888';
const label = _RATE_GAUGE_LABELS[svc] || svc;
const logoSrc = _RATE_GAUGE_LOGOS[svc] || '';
const fallbackGlyph = (label[0] || '?').toUpperCase();
const bar = document.createElement('button');
bar.type = 'button';
bar.className = 'rate-eq';
bar.id = `rate-eq-${svc}`;
bar.dataset.svc = svc;
bar.style.setProperty('--eq-accent', accent);
bar.setAttribute('aria-label', `${label} rate detail`);
bar.onclick = () => _openRateModal(svc);
// Layout, top-to-bottom:
// - avatar disc (brand logo) anchored above the track —
// ``onerror`` swap to the initial-letter glyph keeps the
// UI legible if a CDN URL ever breaks
// - track (with reflection puddle as ::after) holding the
// fill / shimmer / peak tip / live count
// - meta column (state pill + service name)
const avatar = logoSrc
? `<div class="rate-eq-avatar" aria-hidden="true">
<img class="rate-eq-avatar-logo" src="${logoSrc}" alt=""
onerror="this.parentElement.classList.add('rate-eq-avatar--fallback');this.replaceWith(Object.assign(document.createElement('span'),{className:'rate-eq-avatar-glyph',textContent:'${fallbackGlyph}'}));">
</div>`
: `<div class="rate-eq-avatar rate-eq-avatar--fallback" aria-hidden="true">
<span class="rate-eq-avatar-glyph">${fallbackGlyph}</span>
</div>`;
bar.innerHTML = `
${avatar}
<div class="rate-eq-track">
<div class="rate-eq-ticks"></div>
<div class="rate-eq-fill">
<div class="rate-eq-shimmer"></div>
<div class="rate-eq-tip"></div>
</div>
<div class="rate-eq-value">0</div>
</div>
<div class="rate-eq-meta">
<span class="rate-eq-state" data-status="stopped">
<span class="rate-eq-state-dot"></span>
<span class="rate-eq-state-text">Stopped</span>
</span>
<span class="rate-eq-name">${label}</span>
</div>
`;
grid.appendChild(bar);
_eqDisplay[svc] = { value: 0, pct: 0.04 };
}
}
for (const svc of _RATE_GAUGE_SERVICES) {
const d = data[svc];
if (!d) continue;
_rateMonitorState[svc] = d;
const bar = document.getElementById(`rate-eq-${svc}`);
if (!bar) continue;
const value = d.cpm || 0;
const max = d.limit || 60;
// Clamp the visual fill to [4%, 100%]. The 4% floor lets idle
// bars still show a sliver of accent so the row reads as
// present-but-quiet instead of empty — critical for the
// "everything alive" vibe; an actual zero would make most
// services disappear most of the time.
const pct = Math.max(0.04, Math.min(value / max, 1));
const worker = d.worker || {};
const wStatus = worker.status || 'stopped';
const isRateLimited = d.rate_limited === true;
const prev = _eqDisplay[svc] || { value: 0, pct: 0.04 };
const fill = bar.querySelector('.rate-eq-fill');
if (fill) fill.style.height = `${pct * 100}%`;
// The reflection puddle (CSS ::after on the track) fades in
// proportional to the real (unclamped) rate so idle services
// don't pollute the row with a floor of glow. Bound to a
// CSS variable so the puddle and any future glow-aware
// styling can share the same source-of-truth value.
const realPct = max > 0 ? value / max : 0;
bar.style.setProperty('--eq-glow', String(Math.min(1, realPct + 0.04)));
// Rolling counter — animate the digit change instead of
// snapping. Premium feel; matches the smooth height
// transition the fill already has.
const val = bar.querySelector('.rate-eq-value');
if (val) {
const rounded = Math.round(value);
const prevRounded = Math.round(prev.value);
if (rounded !== prevRounded) {
_animateRollingNumber(val, prevRounded, rounded);
} else {
val.textContent = rounded;
}
}
// Peak-flash detector: if the rate stepped UPWARD between
// socket updates, briefly trigger the .peak-flash class on
// the bar so the tip emits a quick accent burst. Mimics a
// hardware VU meter's peak-detect LED — sells the "alive"
// feeling and ties bar movement to actual call activity.
// Only fires on real increases above a small noise floor
// (jitter on near-zero rates would otherwise pulse the
// bar constantly).
const PEAK_JITTER_THRESHOLD = 1;
if (value - prev.value > PEAK_JITTER_THRESHOLD) {
bar.classList.remove('peak-flash');
// Force a reflow so re-adding the class restarts the
// animation even if it was already mid-cycle.
void bar.offsetWidth; // eslint-disable-line no-unused-expressions
bar.classList.add('peak-flash');
window.setTimeout(() => bar.classList.remove('peak-flash'), 700);
}
const state = bar.querySelector('.rate-eq-state');
if (state) {
state.dataset.status = wStatus;
const text = state.querySelector('.rate-eq-state-text');
if (text) text.textContent = _workerStatusLabel(wStatus, worker);
}
// Danger threshold uses the REAL (unclamped) ratio so the
// 4% visual floor for idle bars doesn't push everything into
// a permanent green state — the threshold reads true rate.
bar.classList.toggle('danger', realPct > 0.8 || isRateLimited);
bar.classList.toggle('active', value > 0 || wStatus === 'running');
bar.classList.toggle('rate-limited', isRateLimited);
_eqDisplay[svc] = { value, pct };
}
}
// Animate a single integer counter from `from` to `to` with an
// easeOutCubic curve. Used by the equalizer bars so the live count
// digit-rolls instead of snapping when sockets push a new value.
const _eqRollingHandles = new WeakMap();
function _animateRollingNumber(el, from, to, duration = 520) {
// Cancel any animation already running on this element so we
// don't get two RAF loops fighting over its textContent.
const prev = _eqRollingHandles.get(el);
if (prev) cancelAnimationFrame(prev);
if (from === to) {
el.textContent = String(to);
return;
}
const start = performance.now();
const span = to - from;
function step(now) {
const t = Math.min(1, (now - start) / duration);
const eased = 1 - Math.pow(1 - t, 3);
el.textContent = String(Math.round(from + span * eased));
if (t < 1) {
_eqRollingHandles.set(el, requestAnimationFrame(step));
} else {
_eqRollingHandles.delete(el);
}
}
_eqRollingHandles.set(el, requestAnimationFrame(step));
}
function _workerStatusLabel(status, worker) {
if (status === 'not_configured') return 'Not configured';
if (status === 'paused') return worker.yield_reason === 'downloads' ? 'Yielding' : 'Paused';

BIN
webui/static/audiodb.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View file

@ -16,6 +16,7 @@ let _autoSyncScheduleState = {
playlists: [],
automations: [],
playlistSchedules: {},
weeklySchedules: {},
automationPipelines: [],
runHistory: [],
runHistoryTotal: 0,
@ -24,6 +25,11 @@ let _autoSyncActiveTab = 'schedule';
let _autoSyncSidebarFilter = '';
let _autoSyncHistoryFilter = 'all'; // 'all' | 'error' | 'completed' | 'skipped'
let _autoSyncHistoryLimit = 50;
// Open weekly-editor popover state. ``null`` when no popover is open.
// Tracks playlist id + the current draft (time / days / tz) so the
// editor is a controlled component — clicking outside without saving
// discards the draft.
let _autoSyncWeeklyEditor = null;
function getMirroredSourceRef(p) {
if (p && p.source_ref) return String(p.source_ref);
@ -67,6 +73,76 @@ function autoSyncIntervalLabel(hours) {
return `Every ${hours} hour${hours === 1 ? '' : 's'}`;
}
// Browser-detected default tz for new schedules. Used when the user
// creates a weekly schedule and hasn't picked an explicit tz — falls
// back to UTC on browsers where Intl is unavailable (very old ones).
function detectBrowserTimezone() {
try {
const tz = typeof Intl !== 'undefined'
&& Intl.DateTimeFormat
&& Intl.DateTimeFormat().resolvedOptions().timeZone;
return tz || 'UTC';
} catch (_) {
return 'UTC';
}
}
// Canonical weekday order Mon-Sun. Matches both the backend
// ``next_run_at`` weekday_map and the column ordering in the UI.
// Keeping the abbreviations short-lowercase ('mon' not 'MON' / 'Mon')
// matches the engine's existing config payload convention.
const AUTO_SYNC_WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
const AUTO_SYNC_WEEKDAY_LABELS = {
mon: 'Mon', tue: 'Tue', wed: 'Wed', thu: 'Thu',
fri: 'Fri', sat: 'Sat', sun: 'Sun',
};
// Build a ``weekly_time`` trigger_config payload from picker input.
// Defensive — caller may pass garbage; we clamp / drop / default so
// the resulting payload always passes ``next_run_at`` validation.
function autoSyncWeeklyTrigger({ time, days, tz } = {}) {
const safeTime = typeof time === 'string' && /^\d{1,2}:\d{2}$/.test(time)
? time : '09:00';
const safeDays = Array.isArray(days)
? days.filter(d => AUTO_SYNC_WEEKDAYS.includes(d))
: [];
const safeTz = (typeof tz === 'string' && tz) ? tz : detectBrowserTimezone();
return { time: safeTime, days: safeDays, tz: safeTz };
}
// Parse the days/time/tz back out of a ``weekly_time`` trigger_config,
// with defensive fallbacks so a hand-edited row doesn't crash render.
// Returns null when the config isn't recognisable as a weekly trigger.
function autoSyncWeeklyFromTrigger(config) {
if (!config || typeof config !== 'object') return null;
const rawTime = typeof config.time === 'string' && /^\d{1,2}:\d{2}$/.test(config.time)
? config.time : '09:00';
let days = Array.isArray(config.days)
? config.days.map(d => String(d).toLowerCase()).filter(d => AUTO_SYNC_WEEKDAYS.includes(d))
: [];
// Empty / all-invalid days = "every day" per next_run_at convention.
// Surface that so the UI can render the schedule under all 7 day
// columns instead of treating it as unscheduled.
if (days.length === 0) days = [...AUTO_SYNC_WEEKDAYS];
const tz = (typeof config.tz === 'string' && config.tz) ? config.tz : 'UTC';
return { time: rawTime, days, tz };
}
// Human-readable label for a weekly schedule. Used on card metadata
// and column tooltips. Multi-day schedules collapse to "Mon, Wed, Fri
// @09:00"; full-week schedules collapse to "Daily @ 09:00".
function autoSyncWeeklyLabel(parsed) {
if (!parsed) return 'Unscheduled';
const { time, days } = parsed;
if (!Array.isArray(days) || days.length === 0) return `Daily @ ${time}`;
if (days.length === 7) return `Daily @ ${time}`;
// Sort to canonical Mon-Sun order so card text doesn't shuffle
// when the user toggles days on/off in arbitrary order.
const ordered = AUTO_SYNC_WEEKDAYS.filter(d => days.includes(d));
const dayList = ordered.map(d => AUTO_SYNC_WEEKDAY_LABELS[d]).join(', ');
return `${dayList} @ ${time}`;
}
function autoSyncSourceLabel(source) {
const labels = {
spotify: 'Spotify',
@ -85,6 +161,35 @@ function autoSyncSourceLabel(source) {
return labels[source] || source || 'Other';
}
// Per-source logo URLs for the sidebar source-group headers and
// anywhere else a small branded chip helps disambiguate the source.
// Same URLs the dashboard equalizer / header-action orbs reference
// so the visual language stays consistent. Sources without a
// readily available logo (``beatport``, ``file``) fall through to
// no-image; the source-icon element drops to display:none via
// the ``<img onerror>`` swap so the header renders cleanly without
// a broken-image placeholder.
const _AUTO_SYNC_SOURCE_LOGOS = {
spotify: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png',
spotify_public: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png',
tidal: 'https://www.svgrepo.com/show/519734/tidal.svg',
youtube: 'https://www.svgrepo.com/show/13671/youtube.svg',
deezer: 'https://cdn.brandfetch.io/idEUKgCNtu/theme/dark/symbol.svg?c=1bxid64Mup7aczewSAYMX&t=1758260798610',
qobuz: 'https://www.svgrepo.com/show/504778/qobuz.svg',
itunes_link: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/ITunes_logo.svg/960px-ITunes_logo.svg.png',
lastfm: 'https://www.last.fm/static/images/lastfm_avatar_twitter.52a5d69a85ac.png',
listenbrainz: 'https://listenbrainz.org/static/img/listenbrainz-logo-no-text.svg',
soulsync_discovery: '/static/favicon.png',
};
function autoSyncSourceIconHtml(source) {
const src = _AUTO_SYNC_SOURCE_LOGOS[source];
if (!src) return '';
return `<img class="auto-sync-source-icon" data-svc="${_escAttr(source)}"
src="${src}" alt="" aria-hidden="true"
onerror="this.style.display='none'">`;
}
function autoSyncCanSchedulePlaylist(playlist) {
if (!playlist) return false;
const src = playlist.source || '';
@ -125,29 +230,58 @@ function autoSyncIsScheduleOwned(auto) {
function buildAutoSyncScheduleState(playlists, automations, historyData = {}) {
const playlistSchedules = {};
const weeklySchedules = {};
const automationPipelines = [];
const pipelineAutomations = automations.filter(autoSyncIsPipelineAutomation);
pipelineAutomations.forEach(auto => {
const playlistId = autoSyncPlaylistIdFromAutomation(auto);
const hours = auto.trigger_type === 'schedule' ? autoSyncHoursFromTrigger(auto.trigger_config || {}) : null;
if (playlistId && hours && autoSyncIsScheduleOwned(auto)) {
playlistSchedules[playlistId] = {
automation_id: auto.id,
automation_name: auto.name,
hours,
enabled: auto.enabled !== false && auto.enabled !== 0,
owned: true,
next_run: auto.next_run,
trigger_config: auto.trigger_config || {},
};
} else {
automationPipelines.push(auto);
const isOwned = autoSyncIsScheduleOwned(auto);
if (playlistId && isOwned && auto.trigger_type === 'schedule') {
const hours = autoSyncHoursFromTrigger(auto.trigger_config || {});
if (hours) {
playlistSchedules[playlistId] = {
automation_id: auto.id,
automation_name: auto.name,
hours,
enabled: auto.enabled !== false && auto.enabled !== 0,
owned: true,
next_run: auto.next_run,
trigger_config: auto.trigger_config || {},
};
return;
}
}
if (playlistId && isOwned && auto.trigger_type === 'weekly_time') {
// No ``|| {}`` coercion here on purpose — null / non-object
// trigger_config from a hand-edited row should fall through
// to automationPipelines as a "broken row" rather than be
// silently bucketed as an every-day schedule. The helper
// returns null for those cases; truthy config flows through
// the helper's defensive defaults.
const parsed = autoSyncWeeklyFromTrigger(auto.trigger_config);
if (parsed) {
weeklySchedules[playlistId] = {
automation_id: auto.id,
automation_name: auto.name,
time: parsed.time,
days: parsed.days,
tz: parsed.tz,
enabled: auto.enabled !== false && auto.enabled !== 0,
owned: true,
next_run: auto.next_run,
trigger_config: auto.trigger_config || {},
};
return;
}
}
automationPipelines.push(auto);
});
return {
playlists,
automations,
playlistSchedules,
weeklySchedules,
automationPipelines,
runHistory: historyData.history || [],
runHistoryTotal: historyData.total || 0,
@ -220,16 +354,19 @@ function renderAutoSyncScheduleModal() {
const overlay = document.getElementById('auto-sync-schedule-modal');
if (!overlay) return;
const { playlists, playlistSchedules, automationPipelines, runHistory, runHistoryTotal } = _autoSyncScheduleState;
const scheduledCount = Object.keys(playlistSchedules).length;
const enabledCount = Object.values(playlistSchedules).filter(s => s.enabled).length;
const { playlists, playlistSchedules, weeklySchedules, automationPipelines, runHistory, runHistoryTotal } = _autoSyncScheduleState;
const scheduledCount = Object.keys(playlistSchedules).length + Object.keys(weeklySchedules || {}).length;
const enabledCount = Object.values(playlistSchedules).filter(s => s.enabled).length
+ Object.values(weeklySchedules || {}).filter(s => s.enabled).length;
const pipelineCount = automationPipelines.length;
const totalTracks = playlists.reduce((sum, p) => sum + (parseInt(p.track_count, 10) || 0), 0);
const scheduleActive = _autoSyncActiveTab === 'schedule';
const weeklyActive = _autoSyncActiveTab === 'weekly';
const automationsActive = _autoSyncActiveTab === 'automations';
const historyActive = _autoSyncActiveTab === 'history';
const schedulePanel = renderAutoSyncSchedulePanel(playlists, playlistSchedules);
const weeklyPanel = renderAutoSyncWeeklyPanel(playlists, playlistSchedules);
const automationPanel = renderAutoSyncAutomationPanel(automationPipelines, playlists);
const historyPanel = renderAutoSyncHistoryPanel(runHistory, runHistoryTotal);
const monitor = renderAutoSyncPipelineMonitor(playlists);
@ -252,7 +389,8 @@ function renderAutoSyncScheduleModal() {
</div>
${monitor}
<div class="auto-sync-tabs">
<button class="${scheduleActive ? 'active' : ''}" onclick="setAutoSyncTab('schedule')">Schedule Board</button>
<button class="${scheduleActive ? 'active' : ''}" onclick="setAutoSyncTab('schedule')">Hourly Board</button>
<button class="${weeklyActive ? 'active' : ''}" onclick="setAutoSyncTab('weekly')">Weekly Board</button>
<button class="${automationsActive ? 'active' : ''}" onclick="setAutoSyncTab('automations')">Automation Pipelines</button>
<button class="${historyActive ? 'active' : ''}" onclick="setAutoSyncTab('history')">
Run History
@ -263,6 +401,7 @@ function renderAutoSyncScheduleModal() {
</button>
</div>
<div class="auto-sync-tab-panel ${scheduleActive ? 'active' : ''}" id="auto-sync-schedule-panel">${schedulePanel}</div>
<div class="auto-sync-tab-panel ${weeklyActive ? 'active' : ''}" id="auto-sync-weekly-panel">${weeklyPanel}</div>
<div class="auto-sync-tab-panel ${automationsActive ? 'active' : ''}" id="auto-sync-automation-panel">${automationPanel}</div>
<div class="auto-sync-tab-panel ${historyActive ? 'active' : ''}" id="auto-sync-history-panel">${historyPanel}</div>
</div>
@ -272,7 +411,11 @@ function renderAutoSyncScheduleModal() {
}
function setAutoSyncTab(tab) {
_autoSyncActiveTab = ['automations', 'history'].includes(tab) ? tab : 'schedule';
const allowed = ['schedule', 'weekly', 'automations', 'history'];
_autoSyncActiveTab = allowed.includes(tab) ? tab : 'schedule';
// Switching tabs closes any open weekly editor so the popover
// doesn't ghost-render over the wrong panel.
if (_autoSyncActiveTab !== 'weekly') _autoSyncWeeklyEditor = null;
renderAutoSyncScheduleModal();
}
@ -293,7 +436,10 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
const sidebarHtml = sourceKeys.length ? sourceKeys.map(source => `
<div class="auto-sync-source-group">
<div class="auto-sync-source-group-head">
<span class="auto-sync-source-title">${_esc(autoSyncSourceLabel(source))}</span>
<span class="auto-sync-source-title">
${autoSyncSourceIconHtml(source)}
<span class="auto-sync-source-title-label">${_esc(autoSyncSourceLabel(source))}</span>
</span>
<button type="button" class="auto-sync-source-bulk-btn"
onclick="event.stopPropagation(); openAutoSyncBulkMenu(event, '${_escAttr(source)}')"
title="Schedule all ${_escAttr(autoSyncSourceLabel(source))} playlists at the same interval">
@ -373,6 +519,231 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
`;
}
// ──────────────────────────────────────────────────────────────────────
// Weekly schedule board — PR 3 of the schedule-types feature.
// Renders 7 day columns Mon-Sun. Each column lists the playlists
// scheduled to run on that weekday (multi-day schedules render
// under EACH matching column, matching how the user thinks about
// "this playlist runs on Mon AND Wed AND Fri"). Drag a playlist onto
// a column → create a single-day weekly schedule at the default time.
// Click a scheduled card → opens an editor popover for time + days
// + tz adjustments.
// ──────────────────────────────────────────────────────────────────────
function renderAutoSyncWeeklyPanel(playlists, playlistSchedules) {
const weeklySchedules = _autoSyncScheduleState.weeklySchedules || {};
const filter = (_autoSyncSidebarFilter || '').trim().toLowerCase();
const matchesFilter = (p) => !filter || (p.name || '').toLowerCase().includes(filter)
|| autoSyncSourceLabel(p.source || '').toLowerCase().includes(filter);
const schedulablePlaylists = playlists.filter(p => autoSyncCanSchedulePlaylist(p) && matchesFilter(p));
const unavailablePlaylists = playlists.filter(p => !autoSyncCanSchedulePlaylist(p) && matchesFilter(p));
const grouped = schedulablePlaylists.reduce((acc, p) => {
const key = p.source || 'other';
if (!acc[key]) acc[key] = [];
acc[key].push(p);
return acc;
}, {});
const sourceKeys = Object.keys(grouped).sort((a, b) => autoSyncSourceLabel(a).localeCompare(autoSyncSourceLabel(b)));
const sidebarHtml = sourceKeys.length ? sourceKeys.map(source => `
<div class="auto-sync-source-group">
<div class="auto-sync-source-group-head">
<span class="auto-sync-source-title">
${autoSyncSourceIconHtml(source)}
<span class="auto-sync-source-title-label">${_esc(autoSyncSourceLabel(source))}</span>
</span>
</div>
${grouped[source].map(p => {
const weekly = weeklySchedules[p.id];
const hourly = playlistSchedules[p.id];
let assigned = 'Unscheduled';
if (weekly) assigned = autoSyncWeeklyLabel(weekly);
else if (hourly) assigned = `Hourly (${autoSyncIntervalLabel(hourly.hours).toLowerCase()})`;
return `
<div class="auto-sync-playlist ${weekly ? 'scheduled' : (hourly ? 'scheduled-elsewhere' : '')}"
draggable="true" data-playlist-id="${p.id}"
ondragstart="autoSyncWeeklyDragStart(event)" ondragend="autoSyncWeeklyDragEnd()">
<div class="auto-sync-playlist-name">${_esc(p.name)}</div>
<div class="auto-sync-playlist-meta">${p.track_count || 0} tracks &middot; ${_esc(assigned)}</div>
</div>
`;
}).join('')}
</div>
`).join('') : '<div class="auto-sync-empty">No refreshable mirrored playlists yet.</div>';
const unavailableHtml = unavailablePlaylists.length ? `
<div class="auto-sync-source-group auto-sync-source-group-disabled">
<div class="auto-sync-source-title">Not schedulable</div>
${unavailablePlaylists.map(p => `
<div class="auto-sync-playlist unavailable">
<div class="auto-sync-playlist-name">${_esc(p.name)}</div>
<div class="auto-sync-playlist-meta">${_esc(autoSyncSourceLabel(p.source))} &middot; refresh not supported</div>
</div>
`).join('')}
</div>
` : '';
// Build per-day column lists. Iterate the weeklySchedules dict
// once instead of per-day scanning so multi-day schedules render
// under each matching column without a double-loop.
const playlistsById = new Map(schedulablePlaylists.map(p => [parseInt(p.id, 10), p]));
const cardsByDay = {};
AUTO_SYNC_WEEKDAYS.forEach(d => { cardsByDay[d] = []; });
Object.entries(weeklySchedules).forEach(([pid, sched]) => {
const playlist = playlistsById.get(parseInt(pid, 10));
if (!playlist) return;
(sched.days || []).forEach(day => {
if (cardsByDay[day]) cardsByDay[day].push({ playlist, schedule: sched });
});
});
const dayColumnsHtml = AUTO_SYNC_WEEKDAYS.map(day => {
const cards = cardsByDay[day];
const cardHtml = cards.length
? cards.map(({ playlist, schedule }) => autoSyncWeeklyCardHtml(playlist, schedule)).join('')
: '<div class="auto-sync-drop-hint"><strong>Drop here</strong><span>Schedule playlists on this day</span></div>';
return `
<div class="auto-sync-column auto-sync-weekly-column" data-day="${day}"
ondragover="autoSyncWeeklyDragOver(event)"
ondragleave="autoSyncWeeklyDragLeave(event)"
ondrop="autoSyncWeeklyDrop(event, '${day}')">
<div class="auto-sync-column-head">
<span>${AUTO_SYNC_WEEKDAY_LABELS[day]}</span>
<small>${cards.length} playlist${cards.length === 1 ? '' : 's'}</small>
</div>
<div class="auto-sync-column-list">${cardHtml}</div>
</div>
`;
}).join('');
const filterValue = _esc(_autoSyncSidebarFilter || '');
const editorHtml = _autoSyncWeeklyEditor ? renderAutoSyncWeeklyEditor() : '';
return `
<div class="auto-sync-board-intro">
<div>
<strong>Drag playlists onto a day</strong>
<span>Each placement creates a weekly-time schedule. Click a card to edit time, additional days, or timezone.</span>
</div>
<button onclick="refreshAutoSyncScheduleModal()">Refresh</button>
</div>
<div class="auto-sync-body">
<aside class="auto-sync-sidebar">
<div class="auto-sync-sidebar-title">Mirrored playlists</div>
<div class="auto-sync-sidebar-filter">
<input type="search" class="auto-sync-sidebar-search" placeholder="Filter playlists…"
value="${filterValue}" oninput="setAutoSyncSidebarFilter(this.value)" />
${_autoSyncSidebarFilter ? `<button type="button" class="auto-sync-sidebar-filter-clear" onclick="setAutoSyncSidebarFilter('')" aria-label="Clear filter">&times;</button>` : ''}
</div>
<div class="auto-sync-source-list">${sidebarHtml}${unavailableHtml}</div>
</aside>
<main class="auto-sync-board auto-sync-weekly-board">${dayColumnsHtml}</main>
</div>
${editorHtml}
`;
}
function autoSyncWeeklyCardHtml(playlist, schedule) {
// Mirror the hourly board's ``autoSyncScheduledCardHtml`` shape so
// the two boards stay visually consistent — same name + meta +
// timing + actions row regardless of whether the schedule is
// hourly or weekday-based. The only per-board differences are:
// - timing line: weekly label vs interval label
// - click opens the weekly editor (hourly board has no editor)
// - drag fns use the weekly-specific ondragstart / ondragend
// - unschedule calls the weekly-specific helper
const enabled = schedule?.enabled !== false;
const nextLabel = schedule?.next_run ? autoSyncNextRunLabel(schedule.next_run) : '';
const isRunning = playlist.pipeline_state?.status === 'running';
const health = autoSyncPlaylistHealth(playlist.id);
const healthClass = health.level === 'failing' ? 'failing'
: health.level === 'warning' ? 'warning'
: '';
const label = autoSyncWeeklyLabel(schedule);
const tz = schedule?.tz || 'UTC';
return `
<div class="auto-sync-scheduled-card auto-sync-weekly-card ${enabled ? '' : 'disabled'} ${healthClass}"
draggable="true"
data-playlist-id="${playlist.id}"
ondragstart="autoSyncWeeklyDragStart(event)"
ondragend="autoSyncWeeklyDragEnd()"
onclick="openAutoSyncWeeklyEditor(${playlist.id})">
<div class="auto-sync-scheduled-main">
<div class="auto-sync-scheduled-name">
${health.level !== 'ok' ? `<span class="auto-sync-scheduled-health ${healthClass}" title="${_escAttr(health.tooltip)}">${health.level === 'failing' ? '!' : '⚠'}</span>` : ''}
${_esc(playlist.name)}
</div>
<div class="auto-sync-scheduled-meta">${_esc(autoSyncSourceLabel(playlist.source))} &middot; ${playlist.track_count || 0} tracks</div>
<div class="auto-sync-scheduled-timing">
<span>${_esc(label)}</span>
<small>${_esc(tz)}</small>
${nextLabel ? `<small>${_esc(nextLabel)}</small>` : ''}
</div>
</div>
<div class="auto-sync-scheduled-actions">
<button class="run" onclick="event.stopPropagation(); runAutoSyncScheduledPlaylist(${playlist.id})" title="Run the playlist pipeline now" ${isRunning ? 'disabled' : ''}>${isRunning ? 'Running' : 'Run now'}</button>
<button onclick="event.stopPropagation(); unscheduleAutoSyncWeekly(${playlist.id})" title="Remove this weekly schedule">&times;</button>
</div>
</div>
`;
}
function renderAutoSyncWeeklyEditor() {
const draft = _autoSyncWeeklyEditor;
if (!draft) return '';
const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(draft.playlistId, 10));
if (!playlist) return '';
const dayToggles = AUTO_SYNC_WEEKDAYS.map(day => {
const on = draft.days.includes(day);
return `
<button type="button"
class="auto-sync-weekly-day-toggle ${on ? 'active' : ''}"
onclick="toggleAutoSyncWeeklyEditorDay('${day}')">
${AUTO_SYNC_WEEKDAY_LABELS[day]}
</button>
`;
}).join('');
const existing = _autoSyncScheduleState.weeklySchedules?.[draft.playlistId];
return `
<div class="auto-sync-weekly-editor-backdrop" onclick="closeAutoSyncWeeklyEditor()">
<div class="auto-sync-weekly-editor" onclick="event.stopPropagation()">
<div class="auto-sync-weekly-editor-head">
<h4>Weekly schedule</h4>
<button type="button" class="auto-sync-close" onclick="closeAutoSyncWeeklyEditor()">&times;</button>
</div>
<div class="auto-sync-weekly-editor-playlist">${_esc(playlist.name)}</div>
<div class="auto-sync-weekly-editor-section">
<label>Days</label>
<div class="auto-sync-weekly-editor-days">${dayToggles}</div>
</div>
<div class="auto-sync-weekly-editor-section">
<label for="auto-sync-weekly-time">Time</label>
<input type="time" id="auto-sync-weekly-time"
value="${_escAttr(draft.time)}"
oninput="setAutoSyncWeeklyEditorTime(this.value)" />
</div>
<div class="auto-sync-weekly-editor-section">
<label for="auto-sync-weekly-tz">Timezone (IANA)</label>
<input type="text" id="auto-sync-weekly-tz"
value="${_escAttr(draft.tz)}"
oninput="setAutoSyncWeeklyEditorTz(this.value)" />
<small class="auto-sync-weekly-editor-hint">e.g. America/Los_Angeles, Europe/London, Asia/Tokyo</small>
</div>
<div class="auto-sync-weekly-editor-actions">
${existing ? `<button class="auto-sync-weekly-editor-delete" onclick="unscheduleAutoSyncWeeklyFromEditor()">Unschedule</button>` : ''}
<div class="auto-sync-weekly-editor-actions-right">
<button class="auto-sync-weekly-editor-cancel" onclick="closeAutoSyncWeeklyEditor()">Cancel</button>
<button class="auto-sync-weekly-editor-save" onclick="saveAutoSyncWeeklyFromEditor()">Save</button>
</div>
</div>
</div>
</div>
`;
}
function setAutoSyncSidebarFilter(value) {
_autoSyncSidebarFilter = String(value || '');
// Only re-render the sidebar/board portion to keep input focus.
@ -1311,6 +1682,17 @@ async function saveAutoSyncPlaylistSchedule(playlistId, hours) {
showToast('That playlist source cannot be refreshed by Auto-Sync.', 'info');
return;
}
// Enforce one-schedule-per-playlist: if a weekly schedule exists,
// drop it before installing the hourly one. Mirrors the same
// mutual-exclusion the weekly save path enforces in reverse.
const existingWeekly = _autoSyncScheduleState.weeklySchedules?.[playlistId];
if (existingWeekly) {
try {
await fetch(`/api/automations/${existingWeekly.automation_id}`, { method: 'DELETE' });
} catch (_) { /* best-effort cleanup */ }
}
const existing = _autoSyncScheduleState.playlistSchedules[playlistId];
const payload = {
name: `Auto-Sync: ${playlist.name}`,
@ -1353,6 +1735,205 @@ async function unscheduleAutoSyncPlaylist(playlistId) {
}
}
// ──────────────────────────────────────────────────────────────────────
// Weekly-tab drag-drop + editor state mutators.
// ──────────────────────────────────────────────────────────────────────
function autoSyncWeeklyDragStart(event) {
_autoSyncIsDragging = true;
const id = event.currentTarget?.dataset?.playlistId || '';
event.dataTransfer.setData('text/plain', id);
event.dataTransfer.effectAllowed = 'move';
}
function autoSyncWeeklyDragEnd() {
_autoSyncIsDragging = false;
}
function autoSyncWeeklyDragOver(event) {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
const col = event.currentTarget;
if (col && !col.classList.contains('drag-over')) {
col.classList.add('drag-over');
}
}
function autoSyncWeeklyDragLeave(event) {
const col = event.currentTarget;
if (!col) return;
col.classList.remove('drag-over');
}
async function autoSyncWeeklyDrop(event, day) {
event.preventDefault();
_autoSyncIsDragging = false;
const col = event.currentTarget;
if (col) col.classList.remove('drag-over');
const playlistId = parseInt(event.dataTransfer.getData('text/plain'), 10);
if (!playlistId) return;
if (!AUTO_SYNC_WEEKDAYS.includes(day)) return;
const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === playlistId);
if (!playlist || !autoSyncCanSchedulePlaylist(playlist)) {
showToast('That playlist source cannot be refreshed by Auto-Sync.', 'info');
return;
}
// Augment OR create: if a weekly schedule already exists for this
// playlist, append the dropped day (no-op when already present);
// otherwise create a single-day schedule with default time + browser tz.
const existing = _autoSyncScheduleState.weeklySchedules?.[playlistId];
const days = existing
? (existing.days.includes(day) ? existing.days : [...existing.days, day])
: [day];
const time = existing?.time || '09:00';
const tz = existing?.tz || detectBrowserTimezone();
await saveAutoSyncWeeklySchedule(playlistId, { time, days, tz });
}
function openAutoSyncWeeklyEditor(playlistId) {
const pid = parseInt(playlistId, 10);
if (!pid) return;
const existing = _autoSyncScheduleState.weeklySchedules?.[pid];
_autoSyncWeeklyEditor = {
playlistId: pid,
time: existing?.time || '09:00',
days: existing ? [...existing.days] : [],
tz: existing?.tz || detectBrowserTimezone(),
};
renderAutoSyncScheduleModal();
}
function closeAutoSyncWeeklyEditor() {
_autoSyncWeeklyEditor = null;
renderAutoSyncScheduleModal();
}
function toggleAutoSyncWeeklyEditorDay(day) {
if (!_autoSyncWeeklyEditor) return;
if (!AUTO_SYNC_WEEKDAYS.includes(day)) return;
const idx = _autoSyncWeeklyEditor.days.indexOf(day);
if (idx >= 0) {
_autoSyncWeeklyEditor.days.splice(idx, 1);
} else {
_autoSyncWeeklyEditor.days.push(day);
}
renderAutoSyncScheduleModal();
}
function setAutoSyncWeeklyEditorTime(value) {
if (!_autoSyncWeeklyEditor) return;
_autoSyncWeeklyEditor.time = String(value || '09:00');
}
function setAutoSyncWeeklyEditorTz(value) {
if (!_autoSyncWeeklyEditor) return;
_autoSyncWeeklyEditor.tz = String(value || 'UTC');
}
async function saveAutoSyncWeeklyFromEditor() {
if (!_autoSyncWeeklyEditor) return;
const { playlistId, time, days, tz } = _autoSyncWeeklyEditor;
if (!days.length) {
showToast('Pick at least one day for the weekly schedule.', 'error');
return;
}
await saveAutoSyncWeeklySchedule(playlistId, { time, days, tz });
_autoSyncWeeklyEditor = null;
}
async function unscheduleAutoSyncWeeklyFromEditor() {
if (!_autoSyncWeeklyEditor) return;
const { playlistId } = _autoSyncWeeklyEditor;
_autoSyncWeeklyEditor = null;
await unscheduleAutoSyncWeekly(playlistId);
}
async function saveAutoSyncWeeklySchedule(playlistId, { time, days, tz }) {
const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10));
if (!playlist) return;
if (!autoSyncCanSchedulePlaylist(playlist)) {
showToast('That playlist source cannot be refreshed by Auto-Sync.', 'info');
return;
}
const triggerConfig = autoSyncWeeklyTrigger({ time, days, tz });
if (!triggerConfig.days.length) {
showToast('Pick at least one day for the weekly schedule.', 'error');
return;
}
// Enforce one-schedule-per-playlist: if the playlist currently has
// an hourly schedule, drop it before installing the weekly one. The
// engine can technically run both side-by-side as two separate
// automations, but the UI assumes one schedule per playlist and
// showing two cards under the same playlist row would surprise
// users. Delete-then-create is safe — the worst case (POST fails)
// leaves the playlist unscheduled, which is recoverable from the UI.
const existingHourly = _autoSyncScheduleState.playlistSchedules?.[playlistId];
if (existingHourly) {
try {
await fetch(`/api/automations/${existingHourly.automation_id}`, { method: 'DELETE' });
} catch (_) { /* best-effort cleanup */ }
}
const existingWeekly = _autoSyncScheduleState.weeklySchedules?.[playlistId];
const payload = {
name: `Auto-Sync: ${playlist.name}`,
trigger_type: 'weekly_time',
trigger_config: triggerConfig,
action_type: 'playlist_pipeline',
action_config: { playlist_id: String(playlistId), all: false },
then_actions: [],
group_name: 'Playlist Auto-Sync',
owned_by: 'auto_sync',
};
try {
const res = await fetch(existingWeekly ? `/api/automations/${existingWeekly.automation_id}` : '/api/automations', {
method: existingWeekly ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Failed to save weekly schedule');
showToast(`${playlist.name} scheduled ${autoSyncWeeklyLabel(triggerConfig).toLowerCase()}`, 'success');
await refreshAutoSyncScheduleModal();
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
}
async function unscheduleAutoSyncWeekly(playlistId) {
const schedule = _autoSyncScheduleState.weeklySchedules?.[playlistId];
const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10));
if (!schedule) return;
if (!await showConfirmDialog({ title: 'Remove Weekly Schedule', message: `Remove weekly schedule for "${playlist?.name || 'this playlist'}"?` })) return;
try {
const res = await fetch(`/api/automations/${schedule.automation_id}`, { method: 'DELETE' });
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Failed to remove weekly schedule');
showToast('Weekly schedule removed', 'success');
await refreshAutoSyncScheduleModal();
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
}
async function runAutoSyncScheduledPlaylist(playlistId) {
const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10));
if (!playlist) return;

View file

@ -3484,7 +3484,18 @@ function processModalStatusUpdate(playlistId, data) {
// Note: Wishlist modal visibility is now managed by handleWishlistButtonClick() only
// Auto-show logic has been simplified to prevent conflicts
if (data.phase === 'analysis') {
if (data.phase === 'queued') {
// Submitted to the executor but no worker has picked it up yet.
// ``missing_download_executor`` is bounded (max_workers=3 by
// default) so wishlist runs with N > 3 sub-batches park the
// rest at this phase. Show distinct text so users don't think
// 26 batches are all in-flight at once.
const total = data.analysis_progress?.total || 0;
const elText = document.getElementById(`analysis-progress-text-${playlistId}`);
const elFill = document.getElementById(`analysis-progress-fill-${playlistId}`);
if (elText) elText.textContent = `Queued — waiting for worker (${total} tracks)`;
if (elFill) elFill.style.width = '0%';
} else if (data.phase === 'analysis') {
const progress = data.analysis_progress;
const percent = progress.total > 0 ? (progress.processed / progress.total) * 100 : 0;
document.getElementById(`analysis-progress-fill-${playlistId}`).style.width = `${percent}%`;

View file

@ -3414,7 +3414,22 @@ function closeHelperSearch() {
// release time and add a real `date:` line at the top of the version block.
const WHATS_NEW = {
'2.6.3': [
{ unreleased: true },
{ date: 'May 27, 2026 — 2.6.3 release' },
{ title: 'Album-bundle staging: clean up Soulseek copies + startup sweep for orphan dirs', desc: 'two related cleanup issues with the album-bundle staging dir (``storage/album_bundle_staging/<batch_id>/``). (1) Pre-fix the per-batch cleanup at the end of a download only ran for torrent / usenet bundles — Soulseek bundles were excluded with a comment about slskd "keeping its own completed folders", but Soulseek\'s bundle path ALSO copies completed files into the private staging dir for the per-track workers to claim. Those copies leaked forever; users with active Soulseek album downloads accumulated stale GB under the staging root over time. Extended the cleanup gate to include ``soulseek`` so the per-batch dir is removed once the bundle completes — same code path that already worked for torrent / usenet. (2) Added a startup sweep that removes any orphan ``<batch_id>`` subdirs left behind by previous-session crashes, errored batches, or pre-fix Soulseek bundles. Sweep runs once at server boot before any batch can register a staging dir, so it can\'t race a starting batch — safe by construction. Defensive guards: name-shape check rejects non-batch-id dirs (so a hand-placed ``.git`` or ``README.txt`` is left alone), only acts inside the configured staging root, ``shutil.rmtree`` errors log + continue instead of crashing startup. For users who already have a "clean up old files" automation pointed at this dir: stop pointing it there if you want, the auto cleanup + startup sweep cover it now; or leave it as belt-and-suspenders with a relaxed 1h+ mtime threshold. 11 new unit tests pin every edge case.', page: 'downloads' },
{ title: 'Sync page: collapse tabs to brand-logo chips, active swells into a label pill', desc: 'with 14 sync sources now (Spotify, Spotify Link, iTunes Link, Tidal, Qobuz, Deezer, Deezer Link, YouTube, Beatport, ListenBrainz, Last.fm, SoulSync Discovery, Import, Mirrored, plus Server Playlists), the old "row of equal-width labeled pills" tab strip ran out of horizontal room — labels wrapped to 2 lines, the active pill ate disproportionate space, the whole row felt cramped. Restyled the tab strip as a row of circular brand-logo chips (40px on desktop, smaller on tablet/mobile); the currently-active tab swells into a pill with its label inline. Hover tooltip surfaces the source name via the title attribute. Each chip carries its source\'s brand color as a hover ring + active fill (Spotify green, Tidal orange, Qobuz blue, Deezer purple, iTunes coral, YouTube red, Beatport green, LB orange, Last.fm red, SSD teal). To disambiguate the three duplicate-logo sources (Spotify Link / Deezer Link / iTunes Link share a logo with their native-source siblings), each "Link" variant carries a small chain-link badge bottom-right. CSS-only swap — zero JS / behavior changes, same tab click handler, same data-tab routing. The active tab\'s label still shows so context is never lost; non-active tabs trade their label for ~70% horizontal savings.', page: 'sync' },
{ title: 'Fix: Soulseek album downloads stuck on "failed" after slskd finished the release (#715)', desc: 'when an album was downloaded via the Soulseek album-bundle path (the release-first flow added in 2.6), the batch would frequently mark itself as failed even though slskd had successfully downloaded every track of the album. Worst on users running slskd with the common ``directories.downloads.username = true`` config — files landed at ``<download_dir>/<username>/<filename>`` instead of the three hard-coded candidate paths the bundle resolver probed, so every file looked locally missing and the poll spun until the ~30 minute deadline expired. Per-track Soulseek downloads were unaffected because they already routed through a recursive walk-by-basename helper. Lifted that helper into ``core/downloads/file_finder.py`` and rewired the bundle path to use it — same logic the per-track flow has used since 2.5.9. Also: when the bundle poll detects that slskd reports every transfer Completed but no local file resolves within a 45-second grace window, it now exits with a clear log line pointing at the likely ``soulseek.download_path`` mismatch instead of silently spinning until the master deadline. Misleading "(0 tracks, quality=)" log on the preflight-reuse path also fixed — was reading attrs off a None object. 17 new unit tests pin every slskd layout shape (flat, username-prefixed, full-tree-preserved, deep nested, dedup-suffix, quarantine-skip, YouTube/Tidal encoded names, transfer-dir fallback, fuzzy punctuation variants). Closes #715.', page: 'downloads' },
{ title: 'Auto-Sync manager: full visual overhaul to match the dashboard vibe', desc: 'the Auto-Sync manager modal had been carrying its original visual treatment forward unchanged while the rest of the app moved toward the glassy / accent-radial / gradient-border aesthetic the dashboard now sets. Restyled every surface inside the modal to match — selector-based override layer at the end of style.css so functionality is untouched (zero JS or HTML changes). Touched surfaces: modal shell (glass shell with thin accent border + corner radial wash + inner top-edge highlight), header (gradient text title + accent-tinted hairline separator + spinning-X close), KPI summary tiles (dashboard-style gradient tiles with accent top-edge glow on hover + gradient stat numbers), live monitor strip (accent-tinted glass card with status-colored borders), refresh / intro buttons (pill primary with accent fill + glow on hover), tabs (underline-style with accent fill + soft radial glow on the active tab), sidebar (glass panel with accent-tinted source-group cards, accent border on scheduled playlists, accent ring on the filter input focus), board area (subtle accent radial spotlight + column cards with gradient headers + drag-over accent glow), drop zones (animated dashed pull with accent radial wash + accent-tinted text on drag-over), scheduled cards (accent left-edge stripe, gradient pill timing badges, pill "Run now" primary + rotating ghost X), run history rows (dashboard recent-activity aesthetic with accent hover lift + pill status badges), bulk-schedule popover, weekly editor (full glass modal matching the version-modal vibe, day-toggle pills, accent-pill save button), and empty / monitor-empty states. Soft accent-tinted scrollbars throughout. Delete the v2 block at the bottom of style.css to revert.', page: 'automations' },
{ title: 'Auto-Sync: weekly board cards now match the hourly board', desc: 'when the Weekly Board shipped, its scheduled-card visual diverged from the hourly board — weekly cards only showed the playlist name + weekly label + timezone, while hourly cards carry the full action row (Run now button, unschedule X, next-run countdown, health badge). Standardised the weekly card on the hourly shape so dropping a playlist onto a day column produces a card with the exact same affordances as dropping it onto an hourly bucket: health indicator dot when recent runs failed, source + track count meta line, weekly label + timezone + "next in Nh" pills, Run-now button (disabled while pipeline is running, same gating as hourly), and an unschedule X. Click anywhere outside the buttons still opens the weekly editor for changing days / time / tz. Weekly cards also became draggable between day columns now — drop on a new column appends that day to the schedule (matches the existing multi-day editor flow).', page: 'automations' },
{ title: 'Dashboard: enrichment equalizer bars get next-level polish', desc: 'follow-up to the equalizer redesign. four upgrades that take the bars from "nice" to "vibey": (1) every bar now carries a circular avatar disc above the track loading the service\'s real brand logo (the same images the header-action worker orbs use — Spotify, Apple Music, Deezer, Last.fm, Genius, MusicBrainz, AudioDB, Tidal, Qobuz, Discogs, Amazon Music). Disc has a dark glass backdrop, accent-tinted ring, and slow halo pulse when the worker is running; the brand color shows through as a drop-shadow on the logo + ring tint. Initial-letter fallback kicks in automatically if any CDN URL fails; (2) peak-flash detector — when the worker\'s calls/min actually steps upward between updates, the peak tip briefly flares white-hot and the fill flashes brighter for ~650ms, like a hardware VU meter\'s peak-detect LED catching a beat. only fires on real increases above a noise threshold so jitter doesn\'t pulse the bar constantly; (3) rolling number counter — the live count digit-animates from old→new value with easeOutCubic instead of snapping, the same premium-counter feel you\'d see on a fancy dashboard; (4) glass-surface reflection puddle under each bar — soft accent-colored glow whose opacity tracks the real (unclamped) rate, so the row looks like the bars are standing on a polished pane rather than floating in space. puddle ripples on peak-flash too. all four tied together by the same --eq-accent / --eq-glow CSS variables so future tweaks stay coherent.', page: 'dashboard' },
{ title: 'Dashboard: equalizer-bar redesign for the Enrichment Services panel', desc: 'the enrichment rate monitor on the dashboard used a 10-column grid of circular speedometers, which (a) lost symmetry the moment a new service was added — 11 services meant 10 in one row and a lonely orphan in the next, (b) wasted ~80% of pixels on empty gauge arcs showing "0" most of the time. Replaced with a VU-meter / equalizer-bar row: each service is a vertical capsule with the service\'s brand-color fill height tracking calls/min, a continuous shimmer scan + glowing peak tip when active, a slow breathing animation when idle, and a red pulsing variant when rate-limited. Symmetric by design at any service count — the row uses flex with even gaps so new services just slot in. Bar tip + value text adapt color to the service\'s accent. Status pill below each bar (Running / Idle / Paused / Stopped) with its own pulsing dot. Click any bar to open the same detail modal the speedometer used; nothing changed about the underlying data flow.', page: 'dashboard' },
{ title: 'Auto-Sync: fix ListenBrainz pipelines stuck on "Refreshing:" for 5+ minutes', desc: 'Auto-Sync pipeline runs against a ListenBrainz playlist (Weekly Jams / Weekly Exploration / Top Discoveries / etc.) would sit on "Refreshing: \'<playlist name>\'" for minutes with zero UI updates before anything else happened. Two real bugs ganging up: (1) the refresh path ran the LB matching engine on every track to produce matched_data — 5+ minutes blocking with no progress emission — and Phase 2 of the pipeline then ran the SAME matching engine on the same tracks via the discovery worker. So LB tracks discovered twice, the first time silently. (2) ListenBrainz manager only exposed an `update_all_playlists` entry-point — refreshing one playlist re-pulled all 12+ cached LB playlists\' details from the API even though we only cared about one. Pipeline now skips the refresh-side discovery (Phase 2 handles it with progress emits every 3s — same pattern that already works for Spotify); refresh uses a new targeted `LBManager.refresh_playlist(mbid)` that hits just the requested playlist. Also killed a silent `except Exception: pass` in the LB adapter that was masking real API failures as stale-cache hits — refresh errors now log with traceback and surface to the run-history error tally. Pinned with 12 new unit tests.', page: 'automations' },
{ title: 'Wishlist: harden Spotify backfill so a poisoned track_number can\'t mask a lean album', desc: 'follow-up to the earlier wishlist import-path work. residual per-track wishlist downloads (single tracks from different albums, falling below the album-bundle threshold) were producing folders without a year subfolder whenever the underlying wishlist row had a track_number=1 from an older default — the candidate dispatcher\'s "fall back to Spotify API" branch was gated entirely on track_number being missing, but the same API call was also the only thing that hydrated the lean album_context (release_date / total_tracks / cover art) when the original discovery match came from Deezer\'s search endpoint which doesn\'t carry those fields. so any row whose track_number happened to look "filled" (1 from a default) would short-circuit the API call and the year disappeared from the folder path even though the API knew it. split the two concerns: track_number resolution keeps its track_info → track object → API precedence, but album hydration now runs whenever release_date or total_tracks are missing regardless of where track_number came from. one network round-trip still serves both. lifted into core/downloads/track_metadata_backfill.py with 24 unit tests pinning every branch — including the regression: poisoned default-1 track_number does NOT block album backfill anymore.' },
{ title: 'Wishlist: fix imports landing as track 01 with no year in folder name', desc: 'follow-up to the earlier wishlist album-bundle work. tracks with rich Spotify metadata were still importing as `01 - <title>` with no year in the folder path because three regressions stacked: the Track→dict conversion in payload helpers dropped everything except `album.name` (silently throwing away release_date / images / album_type / total_tracks), Deezer-sourced discovery matches saved their payloads without `track_number` / `disc_number` keys at all (Deezer uses `track_position` while the cache lookup read `track_number` literally), and the import pipeline only consulted `album_info.track_number` before falling to the filename — which fails for VA-collection source files like `417 Fountains of Wayne - Stacys Mom.flac` where the leading number is a playlist position not the album track. all three patched, with the track_number resolution chain lifted into `core/imports/track_number.py` as a pure function with 18 unit tests pinning every branch.' },
{ title: 'Wishlist: distinguish Queued from Analyzing batches in the UI', desc: 'wishlist runs with more than 3 sub-batches used to render every batch as "Analyzing..." simultaneously — even though the download worker pool only runs 3 at a time. so a 26-album wishlist scan looked like all 26 were working in parallel when really 23 were just sitting in the executor queue waiting their turn. now batches show a distinct "Queued ⏳" state while parked in the executor queue; they flip to "Analyzing..." when a worker actually picks them up. zero behavior change — just less misleading UI.' },
{ title: 'Wishlist: only engage album-bundle when several tracks from the same album are missing', desc: 'auto-wishlist + manual-wishlist runs were promoting every single-track wishlist item to a per-album bundle search — so a wishlist of "26 single tracks from 26 different albums" downloaded full albums (5-42 files each, ~85% wasted bandwidth) and hammered slskd with concurrent searches. now the bundle path only engages when an album has 2 or more missing tracks in the wishlist; single-track items take the cheaper per-track path that already works fine. fewer slskd searches per cycle, less wasted bandwidth, no more downloading the same album three times in a row when staging-match misses a single track. configurable via `wishlist.album_bundle_min_tracks` if you want the old behaviour (set to 1) or stricter (set to 3+).' },
{ title: 'Auto-Sync: schedule playlists by weekday + time, not just by hour interval', desc: 'new Weekly Board tab on the Auto-Sync manager. drag a playlist onto a day column (Mon-Sun) to schedule it for that weekday at the default time, then click the card to open an editor for multi-day picks, custom time, and timezone. existing hourly schedules stay on the Hourly Board tab — one playlist gets exactly one schedule so swapping between weekly and hourly auto-replaces the old one. timezone defaults to whatever your browser reports (e.g. America/Los_Angeles); the editor accepts any IANA tz string. backend already had the plumbing from earlier 2.6.3 commits — this lights up the UI.', page: 'automations' },
{ title: 'Fix: usenet album downloads stuck on "downloading release" even after SAB finished', desc: 'when SAB moves a job from its queue into history (post-processing handoff — verify + unrar window), there\'s a brief gap where the job is in neither. SoulSync\'s poll treated one missing read as terminal failure and gave up silently, leaving the download modal frozen on "downloading release" forever. SAB queue states like `Pp` (post-processing) also weren\'t in the state map, hit the default-error fallback, and hung the poll the same way. now the poll tolerates ~10 seconds of transient misses, treats unmapped queue states as transient instead of silently looping, fires a terminal failed event on every failure path so the modal exits the downloading state, and queries SAB by nzo_id directly instead of paging the last-50-entries history (avoids losing the job on busy servers). same tolerance applies to torrent downloads — covers network blips that used to kill an otherwise healthy download.' },
{ title: 'Discogs: strip artist disambiguation suffixes everywhere', desc: 'Discogs marks duplicate-named artists with either `(N)` numeric suffixes ("Bullet (2)") or trailing asterisks ("John Smith*"). Only the `(N)` form was stripped, and only on the collection-import path — so artist + album search results in the UI surfaced raw "Foo*" or "Bar (2)" rows, and worse, import path inherited those characters into folder names on disk. centralized the cleanup into a single helper applied at every site a Discogs payload becomes a name string (artist search, album search, track lookups, get_artist_albums feature filtering). closes #634.' },
{ title: 'Library: Enhanced / Standard view toggle now sticks per browser', desc: 'the artist detail page used to revert to Standard view every time you clicked into a new artist — flipping to Enhanced was a per-click thing with no memory. now the choice is persisted in localStorage and reapplied automatically on every artist open. survives page reloads too. non-admins (who can\'t toggle Enhanced anyway) are unaffected; admins who never touched the Enhanced view stay on Standard since the saved value defaults to it.' },
{ title: 'Fix popup: manual matches survive Playlist Pipeline runs', desc: 'manually mapping a mirrored-playlist track via the Fix popup (search or MBID) saved correctly and the download went through, but the next Playlist Pipeline run kept reverting the match — the track flipped back to "Provider Changed" and you had to map it again. three things were ganging up on it: the manual-fix save always stamped `provider: \'spotify\'` even when the match came from MusicBrainz / iTunes / Deezer, so prepare-discovery saw a provider mismatch on the next run; the discovery worker also re-queued any matched_data that lacked `track_number` / `album.id` / `release_date` for re-discovery, and Fix-popup saves never carry those fields by design (search results don\'t include track_number, MBID lookups don\'t include album.id) — so manual fixes always looked "incomplete" and got overwritten; and the prepare-discovery provider check didn\'t honour the `manual_match` flag at all. all three patched — manual matches now stamp the actual source, the worker short-circuits on `manual_match` before the incomplete-data branch, and prepare-discovery treats manual fixes as cached regardless of provider drift.' },
{ title: 'Fix popup: artist + track fields no longer surface unrelated covers', desc: 'separate artist / track inputs in the Fix popup used to dump both into a bare MusicBrainz keyword query — and MB\'s scorer heavily favours title matches, so searching "Coffee Break" + "Zeds Dead" surfaced random "Coffee Break" tracks by other artists ahead of the canonical Zeds Dead one. fields-mode now uses a field-scoped Lucene query that actually anchors the artist, with the old bare query kept as a fallback for diacritic mismatches like "Bjork" vs "Björk" where strict phrase match misses. results also stable-prefer entries with known track length so the canonical 3:04 sibling sits above the 0:00 duplicate.' },

View file

@ -2603,7 +2603,16 @@ function _adlRenderBatchPanel() {
// Phase label with icon
let phaseText = '';
let phaseIcon = '';
if (batch.phase === 'analysis') {
if (batch.phase === 'queued') {
// Batch is in the executor queue waiting for a worker slot.
// ``missing_download_executor`` has max_workers=3 by default,
// so wishlist runs with >3 sub-batches park the rest at this
// state until a worker frees up. Pre-fix this status rendered
// as "Analyzing..." which misled users into thinking 26
// batches were all working when really only 3 were running.
phaseText = 'Queued';
phaseIcon = '<span style="margin-right:4px;opacity:0.6">⏳</span>';
} else if (batch.phase === 'analysis') {
phaseText = 'Analyzing...';
phaseIcon = '<span class="adl-spinner" style="margin-right:4px"></span>';
} else if (batch.phase === 'album_downloading') {

File diff suppressed because it is too large Load diff