soulsync/core/automation/handlers/refresh_mirrored.py
Broque Thomas 246503066b Fold provider-matching into PlaylistSource contract (Phase 1b)
Adds ``discover_tracks(tracks) -> List[NormalizedTrack]`` to the
PlaylistSource interface. Sources whose tracks already carry
provider IDs (Spotify, Tidal, Qobuz, YouTube, Deezer, Spotify
public, iTunes link, SoulSync Discovery) inherit a no-op default;
ListenBrainz + Last.fm override to run the matching engine.

This closes the last gap before LB / Last.fm / SoulSync Discovery
can land as Sync-page mirror sources: the refresh handler now
calls ``source.discover_tracks(...)`` whenever a source returns
tracks with ``needs_discovery=True``, so mirrored LB rows arrive
already discovered + ready for the sync pipeline. Previously, LB
playlists ran through a separate state-machine worker tied to the
Discover-page UI, with results stored in ``discovery_cache``
instead of ``mirrored_playlist_tracks.extra_data``.

Changes:

- ``core/playlists/sources/base.py`` — PlaylistSource switches from
  Protocol to ABC so a concrete default for ``discover_tracks``
  can live on the base class. The four real-work methods stay
  ``@abstractmethod``; instantiating an adapter that forgets one
  fails loudly at construction.
- ``core/discovery/matching.py`` (new) — pure ``match_mb_tracks``
  helper that runs Strategy-1-only matching-engine queries against
  Spotify (primary) or iTunes (fallback). No state machine, no
  discovery-cache writes, no wing-it stub — that richer flow stays
  in ``core/discovery/listenbrainz.py`` for the Discover-page UI.
- ``ListenBrainzPlaylistSource`` + ``LastFMPlaylistSource`` take
  an optional ``discover_callable`` constructor arg. Last.fm reuses
  the LB implementation since the track shape is identical.
- ``bootstrap.build_playlist_source_registry`` accepts a
  ``discover_callable`` kwarg and wires it into LB + Last.fm
  adapters.
- ``web_server.py`` boot constructs the discovery callable from the
  existing matching engine + ``_discovery_score_candidates`` +
  Spotify / iTunes clients, passes through to the registry.
- ``refresh_mirrored.py`` adds a small ``_maybe_discover`` helper
  that calls ``source.discover_tracks(...)`` between fetch and
  ``to_mirror_track_dict`` projection — only fires when at least
  one track has ``needs_discovery=True``, so the normal Spotify /
  Tidal / etc. refresh path stays a zero-cost pass-through.

Tests:

- 5 new adapter tests: default no-op pass-through, LB discovery
  with mixed matches/misses, LB no-callable fallback, Last.fm
  shares the LB implementation, mirror-dict spotify_hint emit.
- 1 new automation test: end-to-end LB refresh with a stub
  discover_callable proves the matched_data lands in
  ``mirror_playlist_tracks.extra_data`` after the registry
  refresh + discover hop.

225 tests across adapter + automation suites green.
2026-05-26 13:07:01 -07:00

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