Pipeline-driven Auto-Sync runs against any ListenBrainz playlist
(Weekly Jams, Weekly Exploration, Top Discoveries, etc.) would sit
on ``Refreshing: "<name>"`` with no UI updates for 5-7 minutes
before the pipeline progressed. Two real bugs stacked:
1. **Double discovery.** The refresh handler called
``_maybe_discover`` (matching engine, per-track Spotify/iTunes/
Deezer matches) inline for any source returning
``needs_discovery=True`` tracks. Phase 2 of the pipeline then
ran the SAME matching engine via ``run_playlist_discovery_worker``
on the same tracks. The refresh-side run blocked the loop with
zero progress emission; Phase 2's already has the timed
progress-poll pattern. So LB tracks discovered twice, the first
time silently.
Pipeline now sets ``skip_discovery=True`` on its refresh config.
The handler honors the flag and lets Phase 2 handle discovery
end-to-end. Standalone callers (Sync-page tab, registration
action) leave the flag unset so they still get matched_data
on refresh.
2. **No targeted LB refresh.** The LB adapter's ``refresh_playlist``
called ``manager.update_all_playlists()`` — the only refresh
entry-point the manager exposed — which re-pulls every cached
LB playlist's details from the API (~12+ round-trips) even
when only one playlist needed refreshing. Wasteful;
tax-on-everyone for one-playlist work.
Added ``LBManager.refresh_playlist(mbid)`` — reads the cached
playlist_type, fetches just that playlist's details, runs the
normal ``_update_playlist`` upsert path. Defaults type to
``user`` for un-cached mbids so new-playlist discovery still
works. Skips ``_cleanup_old_playlists`` and
``_ensure_rolling_mirrors_from_cache`` (wasted work for a
single-playlist refresh).
Also: killed a silent ``except Exception: pass`` in the LB
adapter's old refresh wrapper that was masking every LB API
failure as a stale-cache hit. Refresh errors now log with full
traceback at warning level and propagate ``None`` so the outer
handler at ``refresh_mirrored.py:104`` counts the error and
surfaces it to the run-history error tally.
Pinned with 12 new unit tests across:
- ``tests/test_listenbrainz_manager.py`` (8): targeted refresh
happy path, unauthenticated guard, empty-mbid guard, upstream
``None`` return, default playlist_type for unknown mbid,
exception propagation, cost guard skipping cleanup, skipped-
when-unchanged signal
- ``tests/test_playlist_sources_adapters.py`` (3): adapter uses
targeted call (not legacy), adapter returns ``None`` on manager
error (not silent swallow), adapter resolves synthetic series
ids before calling the manager
- ``tests/automation/test_handlers_playlist.py`` (1):
skip_discovery flag bypasses ``_maybe_discover`` end-to-end
329 lines
13 KiB
Python
329 lines
13 KiB
Python
"""Automation handler: ``refresh_mirrored`` action.
|
|
|
|
Re-pulls track lists from each mirrored playlist's source via the
|
|
unified ``PlaylistSourceRegistry`` (Phase 1 of the Discover-to-Sync
|
|
unification). The pre-extraction handler had ~190 lines of per-source
|
|
if/elif branches; this version delegates to the adapter for each
|
|
source, leaving the handler responsible only for:
|
|
|
|
- filtering sources that can't be refreshed (``file``, ``beatport``),
|
|
- extracting upstream URLs from the stored ``description`` for URL-
|
|
backed sources (``spotify_public``, ``youtube``),
|
|
- the Spotify-public → authenticated-Spotify fallback (uses the
|
|
``spotify`` adapter when the user is signed in so the mirror keeps
|
|
album art),
|
|
- the Tidal-not-authenticated skip log type (vs error),
|
|
- preserving existing per-track ``extra_data`` on tracks that survive
|
|
the refresh, and
|
|
- emitting the ``playlist_changed`` automation event when the track
|
|
set actually shifts.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from core.automation.deps import AutomationDeps
|
|
from core.playlists.source_refs import require_refresh_url
|
|
from core.playlists.sources import PlaylistDetail, to_mirror_track_dict
|
|
from core.playlists.sources.base import (
|
|
SOURCE_SPOTIFY,
|
|
SOURCE_SPOTIFY_PUBLIC,
|
|
SOURCE_TIDAL,
|
|
SOURCE_YOUTUBE,
|
|
)
|
|
|
|
|
|
# Sources that store the upstream URL in ``description`` (because their
|
|
# ``source_playlist_id`` is a deterministic hash, not the native ID).
|
|
# The refresh path has to recover the URL before calling the adapter.
|
|
_URL_BACKED_SOURCES = {SOURCE_SPOTIFY_PUBLIC, SOURCE_YOUTUBE}
|
|
|
|
|
|
def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
|
"""Refresh mirrored playlist(s) from source.
|
|
|
|
Returns ``{'status': 'completed', 'refreshed': '<int>',
|
|
'errors': '<int>'}`` on success (counts stringified to match the
|
|
automation engine's stat-rendering convention).
|
|
"""
|
|
db = deps.get_database()
|
|
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()
|
|
elif playlist_id:
|
|
p = db.get_mirrored_playlist(int(playlist_id))
|
|
playlists = [p] if p else []
|
|
else:
|
|
return {'status': 'error', 'reason': 'No playlist specified'}
|
|
|
|
# Filter out sources that can't be refreshed (no external API).
|
|
playlists = [pl for pl in playlists if pl.get('source', '') not in ('file', 'beatport')]
|
|
|
|
refreshed = 0
|
|
errors: List[str] = []
|
|
for idx, pl in enumerate(playlists):
|
|
try:
|
|
source = pl.get('source', '')
|
|
source_id = pl.get('source_playlist_id', '')
|
|
deps.update_progress(
|
|
auto_id,
|
|
progress=(idx / max(1, len(playlists))) * 100,
|
|
phase=f'Refreshing: "{pl.get("name", "")}"',
|
|
current_item=pl.get('name', ''),
|
|
)
|
|
|
|
detail = _fetch_detail(source, source_id, pl, deps, auto_id)
|
|
if detail is None:
|
|
# _fetch_detail already logged the specific failure;
|
|
# mark the playlist as a generic refresh error so the
|
|
# automation result tally matches the legacy handler.
|
|
errors.append(f"{pl.get('name', '?')}: no tracks returned from source")
|
|
deps.update_progress(
|
|
auto_id,
|
|
log_line=f'Refresh failed: "{pl.get("name", "")}" - no tracks returned from source',
|
|
log_type='error',
|
|
)
|
|
continue
|
|
|
|
# Sources that return MB-metadata-only tracks (LB, Last.fm)
|
|
# 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.
|
|
#
|
|
# 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)
|
|
except _SkipPlaylist:
|
|
# Source-specific soft-skip (e.g. Tidal not authenticated).
|
|
# Logging was already emitted; do not count as error.
|
|
continue
|
|
except Exception as e:
|
|
errors.append(f"{pl.get('name', '?')}: {str(e)}")
|
|
deps.update_progress(
|
|
auto_id,
|
|
log_line=f'Error: {pl.get("name", "?")} — {str(e)}',
|
|
log_type='error',
|
|
)
|
|
return {'status': 'completed', 'refreshed': str(refreshed), 'errors': str(len(errors))}
|
|
|
|
|
|
def _maybe_discover(
|
|
tracks: List[Any],
|
|
source: str,
|
|
deps: AutomationDeps,
|
|
) -> List[Any]:
|
|
"""Run the adapter's ``discover_tracks`` when any track needs it.
|
|
|
|
Most sources are no-ops here (their tracks have provider IDs
|
|
already). LB / Last.fm are the ones that actually do work."""
|
|
if not tracks:
|
|
return tracks
|
|
if not any(getattr(t, "needs_discovery", False) for t in tracks):
|
|
return tracks
|
|
registry = deps.playlist_source_registry
|
|
if registry is None:
|
|
return tracks
|
|
adapter = registry.get_source(source)
|
|
if adapter is None:
|
|
return tracks
|
|
try:
|
|
return adapter.discover_tracks(tracks)
|
|
except Exception as exc:
|
|
deps.logger.warning(f"{source} discover_tracks failed: {exc}")
|
|
return tracks
|
|
|
|
|
|
class _SkipPlaylist(Exception):
|
|
"""Internal sentinel: source-specific soft-skip (e.g. not authed).
|
|
|
|
The per-playlist loop catches it specifically so the skip isn't
|
|
counted in the error tally — matches the pre-extraction behavior
|
|
where ``continue`` was used inline."""
|
|
|
|
|
|
def _fetch_detail(
|
|
source: str,
|
|
source_id: str,
|
|
pl: Dict[str, Any],
|
|
deps: AutomationDeps,
|
|
auto_id: Optional[str],
|
|
) -> Optional[PlaylistDetail]:
|
|
"""Resolve the playlist's tracks through the registry.
|
|
|
|
Handler-level branches (URL extraction, Spotify-public→authed
|
|
fallback, Tidal not-authed skip) live here; everything else
|
|
delegates to the adapter."""
|
|
registry = deps.playlist_source_registry
|
|
if registry is None:
|
|
return None
|
|
|
|
# URL-backed sources: pull the upstream URL out of `description`.
|
|
playlist_input = source_id
|
|
if source in _URL_BACKED_SOURCES:
|
|
# ``require_refresh_url`` raises ValueError on missing URL.
|
|
# The outer try/except in the loop catches it and reports as
|
|
# an error — matching the pre-extraction behavior.
|
|
playlist_input = require_refresh_url(
|
|
source, pl.get('description', ''), pl.get('name', '')
|
|
)
|
|
|
|
# Spotify-public refresh: prefer the authenticated Spotify API
|
|
# when the user is signed in. Better album art, matches the
|
|
# pre-extraction handler. Falls through to the public scraper on
|
|
# auth failure or non-playlist URL types (e.g. album URLs).
|
|
if source == SOURCE_SPOTIFY_PUBLIC:
|
|
detail = _try_spotify_authed_for_public(playlist_input, deps)
|
|
if detail is not None:
|
|
return detail
|
|
|
|
# Tidal not-authed: soft-skip with a 'skip' log line, not an error.
|
|
if source == SOURCE_TIDAL:
|
|
tidal_source = registry.get_source(SOURCE_TIDAL)
|
|
if tidal_source is None or not tidal_source.is_authenticated():
|
|
deps.logger.warning(
|
|
f"Tidal not authenticated — skipping refresh for '{pl.get('name', '')}'"
|
|
)
|
|
deps.update_progress(
|
|
auto_id,
|
|
log_line=f'Skipped "{pl.get("name", "")}" — Tidal not authenticated',
|
|
log_type='skip',
|
|
)
|
|
raise _SkipPlaylist
|
|
|
|
adapter = registry.get_source(source)
|
|
if adapter is None:
|
|
return None
|
|
try:
|
|
return adapter.refresh_playlist(playlist_input)
|
|
except Exception as exc:
|
|
deps.logger.warning(
|
|
f"{source} playlist refresh failed for {playlist_input}: {exc}"
|
|
)
|
|
return None
|
|
|
|
|
|
def _try_spotify_authed_for_public(
|
|
spotify_url: str, deps: AutomationDeps
|
|
) -> Optional[PlaylistDetail]:
|
|
"""Best-effort: use the authenticated Spotify adapter on a public URL.
|
|
|
|
Returns ``None`` to signal "fall through to the public-scraper
|
|
adapter" — never raises. Only applies to ``playlist``-type URLs;
|
|
album URLs fall through unconditionally."""
|
|
if not spotify_url:
|
|
return None
|
|
spotify_client = deps.spotify_client
|
|
if spotify_client is None or not spotify_client.is_spotify_authenticated():
|
|
return None
|
|
try:
|
|
from core.spotify_public_scraper import parse_spotify_url
|
|
parsed = parse_spotify_url(spotify_url)
|
|
except Exception:
|
|
return None
|
|
if not parsed or parsed.get('type') != 'playlist':
|
|
return None
|
|
adapter = deps.playlist_source_registry.get_source(SOURCE_SPOTIFY)
|
|
if adapter is None:
|
|
return None
|
|
try:
|
|
return adapter.refresh_playlist(parsed['id'])
|
|
except Exception as exc:
|
|
deps.logger.debug(f"Spotify authed fallback for public mirror failed: {exc}")
|
|
return None
|
|
|
|
|
|
def _commit_refresh(
|
|
pl: Dict[str, Any],
|
|
source: str,
|
|
source_id: str,
|
|
tracks: List[Dict[str, Any]],
|
|
db: Any,
|
|
deps: AutomationDeps,
|
|
auto_id: Optional[str],
|
|
) -> int:
|
|
"""Persist the refreshed track list + emit playlist_changed when delta.
|
|
|
|
Returns 1 when a refresh successfully landed, 0 otherwise. The
|
|
caller is responsible for incrementing the running tally."""
|
|
old_tracks = db.get_mirrored_playlist_tracks(pl['id']) if pl.get('id') else []
|
|
old_ids = {t.get('source_track_id') for t in old_tracks if t.get('source_track_id')}
|
|
new_ids = {t.get('source_track_id') for t in tracks if t.get('source_track_id')}
|
|
|
|
# Preserve existing extra_data (matched_data + discovery state)
|
|
# for tracks that still exist in the refreshed snapshot, unless
|
|
# the adapter already provided fresh extra_data for that track.
|
|
old_extra_map = (
|
|
db.get_mirrored_tracks_extra_data_map(pl['id']) if pl.get('id') else {}
|
|
)
|
|
for t in tracks:
|
|
sid = t.get('source_track_id', '')
|
|
if sid and sid in old_extra_map and 'extra_data' not in t:
|
|
t['extra_data'] = old_extra_map[sid]
|
|
|
|
db.mirror_playlist(
|
|
source=source,
|
|
source_playlist_id=source_id,
|
|
name=pl['name'],
|
|
tracks=tracks,
|
|
profile_id=pl.get('profile_id', 1),
|
|
description=pl.get('description'),
|
|
owner=pl.get('owner'),
|
|
image_url=pl.get('image_url'),
|
|
)
|
|
|
|
if old_ids != new_ids:
|
|
added = len(new_ids - old_ids)
|
|
removed = len(old_ids - new_ids)
|
|
deps.logger.info(
|
|
f"[AUTOMATION] Playlist changed: '{pl.get('name', '')}' — "
|
|
f"{added} added, {removed} removed (old={len(old_ids)}, new={len(new_ids)})"
|
|
)
|
|
deps.update_progress(
|
|
auto_id,
|
|
log_line=f'"{pl.get("name", "")}" — {added} added, {removed} removed',
|
|
log_type='success',
|
|
)
|
|
try:
|
|
if deps.engine:
|
|
deps.engine.emit('playlist_changed', {
|
|
'playlist_name': pl.get('name', ''),
|
|
'playlist_id': str(pl.get('id', '')),
|
|
'old_count': str(len(old_ids)),
|
|
'new_count': str(len(new_ids)),
|
|
'added': str(added),
|
|
'removed': str(removed),
|
|
})
|
|
except Exception as e:
|
|
deps.logger.debug("playlist_synced automation emit failed: %s", e)
|
|
else:
|
|
deps.logger.warning(
|
|
f"[AUTOMATION] No changes: '{pl.get('name', '')}' (tracks={len(old_ids)})"
|
|
)
|
|
deps.update_progress(
|
|
auto_id,
|
|
log_line=f'No changes: "{pl.get("name", "")}"',
|
|
log_type='skip',
|
|
)
|
|
|
|
return 1
|