Adds ``discover_tracks(tracks) -> List[NormalizedTrack]`` to the PlaylistSource interface. Sources whose tracks already carry provider IDs (Spotify, Tidal, Qobuz, YouTube, Deezer, Spotify public, iTunes link, SoulSync Discovery) inherit a no-op default; ListenBrainz + Last.fm override to run the matching engine. This closes the last gap before LB / Last.fm / SoulSync Discovery can land as Sync-page mirror sources: the refresh handler now calls ``source.discover_tracks(...)`` whenever a source returns tracks with ``needs_discovery=True``, so mirrored LB rows arrive already discovered + ready for the sync pipeline. Previously, LB playlists ran through a separate state-machine worker tied to the Discover-page UI, with results stored in ``discovery_cache`` instead of ``mirrored_playlist_tracks.extra_data``. Changes: - ``core/playlists/sources/base.py`` — PlaylistSource switches from Protocol to ABC so a concrete default for ``discover_tracks`` can live on the base class. The four real-work methods stay ``@abstractmethod``; instantiating an adapter that forgets one fails loudly at construction. - ``core/discovery/matching.py`` (new) — pure ``match_mb_tracks`` helper that runs Strategy-1-only matching-engine queries against Spotify (primary) or iTunes (fallback). No state machine, no discovery-cache writes, no wing-it stub — that richer flow stays in ``core/discovery/listenbrainz.py`` for the Discover-page UI. - ``ListenBrainzPlaylistSource`` + ``LastFMPlaylistSource`` take an optional ``discover_callable`` constructor arg. Last.fm reuses the LB implementation since the track shape is identical. - ``bootstrap.build_playlist_source_registry`` accepts a ``discover_callable`` kwarg and wires it into LB + Last.fm adapters. - ``web_server.py`` boot constructs the discovery callable from the existing matching engine + ``_discovery_score_candidates`` + Spotify / iTunes clients, passes through to the registry. - ``refresh_mirrored.py`` adds a small ``_maybe_discover`` helper that calls ``source.discover_tracks(...)`` between fetch and ``to_mirror_track_dict`` projection — only fires when at least one track has ``needs_discovery=True``, so the normal Spotify / Tidal / etc. refresh path stays a zero-cost pass-through. Tests: - 5 new adapter tests: default no-op pass-through, LB discovery with mixed matches/misses, LB no-callable fallback, Last.fm shares the LB implementation, mirror-dict spotify_hint emit. - 1 new automation test: end-to-end LB refresh with a stub discover_callable proves the matched_data lands in ``mirror_playlist_tracks.extra_data`` after the registry refresh + discover hop. 225 tests across adapter + automation suites green.
312 lines
12 KiB
Python
312 lines
12 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')
|
|
|
|
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.
|
|
detail_tracks = _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
|