Symmetric to the post-download reconcile (which handles ADDITIONS): when a playlist's membership is re-synced (the mirror step — scheduled refresh or the manual mirror endpoint), rebuild its folder from current membership WITH prune IF it's organize-by-playlist. So a track that just LEFT the playlist has its symlink cleaned up the instant membership changes, not only on the next download. Factored a shared _rebuild_one_from_db (used by the manual 'Rebuild' button and the mirror hook) + rebuild_mirrored_playlist_if_organized. Gated to organized playlists, non-fatal at both mirror call sites. Now the invariant 'folder = the playlist's current owned members' holds on every change: additions caught at download, removals caught at mirror. 2 new tests (removed track pruned; non-organized skipped). 985 + 277 tests pass.
341 lines
13 KiB
Python
341 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'),
|
|
)
|
|
|
|
# Membership just changed — if this playlist is organize-by-playlist, rebuild
|
|
# its folder (with prune) so a track that LEFT the playlist has its symlink
|
|
# cleaned up now. Gated to organized playlists, non-fatal — never disturbs
|
|
# the refresh. (Additions are handled by the post-download reconcile.)
|
|
try:
|
|
from core.playlists.materialize_service import rebuild_mirrored_playlist_if_organized
|
|
rebuild_mirrored_playlist_if_organized(
|
|
db, deps.config_manager, pl.get('id'), profile_id=pl.get('profile_id', 1)
|
|
)
|
|
except Exception as _mat_err:
|
|
deps.logger.debug(f"[Playlist Folder] mirror-refresh cleanup skipped: {_mat_err}")
|
|
|
|
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
|