Refactor refresh_mirrored to use unified PlaylistSource registry
Phase 1a of the Discover-to-Sync unification. The mirrored-playlist refresh handler used to branch per-source through a ~190-line if/elif chain (Spotify, Spotify public, Deezer, Tidal, YouTube). Each branch hand-built its own ``extra_data`` JSON for the matched- data block. With every new source we considered for Sync-page mirror support (ListenBrainz, Last.fm radio, SoulSync Discovery, iTunes link), that chain would have grown a new elif. This commit lifts the per-source logic into the existing adapter layer and collapses the dispatch to a registry lookup: - ``core/playlists/sources/deezer.py`` — new adapter so the registry covers every source the refresh handler previously branched on. - ``core/playlists/sources/bootstrap.py`` — single helper that builds a populated registry from injected getter callables. Both ``web_server.py`` boot and the automation test fixtures call it, so the two construction paths can't drift. - ``core/playlists/sources/base.py`` — ``to_mirror_track_dict`` projection helper centralises the NormalizedTrack → DB-row conversion (including the discovered/matched_data and spotify_hint extra_data shapes the downstream sync + wishlist consumers already expect). - Spotify adapter now populates ``extra['discovered']`` + an ``extra['matched_data']`` block when fetching via the authed API, so Spotify mirrors keep landing pre-discovered (matches the pre-refactor contract pinned by ``test_spotify_refresh_writes_to_db``). - Spotify-public adapter populates ``extra['spotify_hint']`` so the discovery worker can skip its search step and jump straight to enrichment for the known track ID. - All artist-name fields now project to first-artist-only across every adapter — matches the pre-refactor mirror_playlist DB shape (``t.artists[0]``). ``refresh_mirrored.py`` shrinks ~190 → ~80 lines and keeps: - the file/beatport unrefreshable-source filter, - URL extraction from ``description`` via ``require_refresh_url`` for spotify_public + youtube, - the Spotify-public → authed-Spotify fallback when the user is signed in (handler-level branch, not in any adapter), - the Tidal-not-authenticated soft-skip log (skip, not error), - existing-extra_data preservation across refreshes, - the ``playlist_changed`` automation event emit on track-set delta. Test scaffolding: - ``_build_deps`` in ``tests/automation/test_handlers_playlist.py`` now builds a default registry from the passed clients via ``build_playlist_source_registry``, so existing refresh tests exercise the same path without per-test changes. New tests cover Tidal-not-authed soft-skip, Deezer refresh writes plain tracks, YouTube refresh reads URL from description, and Spotify-public uses authed Spotify when signed in. - 4 new adapter tests for Deezer projection + ``to_mirror_track_dict`` (minimal track, Spotify matched_data, Spotify-public spotify_hint). - ``playlist_source_registry`` field on ``AutomationDeps`` defaults to ``None`` so the other 5 automation test files (which don't exercise refresh_mirrored) keep working unchanged. 220 tests across automation + adapter suites green.
This commit is contained in:
parent
c5898c3b9b
commit
8c41b05fe8
16 changed files with 953 additions and 295 deletions
|
|
@ -152,3 +152,10 @@ class AutomationDeps:
|
|||
# PersonalizedPlaylistManager per run (cheap accessors inside,
|
||||
# no caching needed yet).
|
||||
build_personalized_manager: Callable[[], Any]
|
||||
|
||||
# --- Unified PlaylistSource registry ---
|
||||
# Optional so test fixtures that don't exercise refresh_mirrored
|
||||
# can keep their existing scaffolding. Production wiring in
|
||||
# ``web_server.py`` always populates it via
|
||||
# ``core.playlists.sources.bootstrap.build_playlist_source_registry``.
|
||||
playlist_source_registry: Optional[Any] = None
|
||||
|
|
|
|||
|
|
@ -1,24 +1,43 @@
|
|||
"""Automation handler: ``refresh_mirrored`` action.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers`` (the
|
||||
``_auto_refresh_mirrored`` closure). Re-pulls track lists from each
|
||||
mirrored playlist's source (Spotify / Tidal / Deezer / YouTube),
|
||||
updates the local mirror DB, and emits a ``playlist_changed``
|
||||
automation event when the track set actually shifts.
|
||||
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:
|
||||
|
||||
Source-specific branches (Spotify auth + public-embed fallback,
|
||||
``spotify_public`` URL→ID resolution, Deezer / Tidal / YouTube)
|
||||
remain identical to the pre-extraction closure — this is a
|
||||
mechanical lift, not a redesign.
|
||||
- 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
|
||||
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
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]:
|
||||
|
|
@ -45,7 +64,7 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[
|
|||
playlists = [pl for pl in playlists if pl.get('source', '') not in ('file', 'beatport')]
|
||||
|
||||
refreshed = 0
|
||||
errors = []
|
||||
errors: List[str] = []
|
||||
for idx, pl in enumerate(playlists):
|
||||
try:
|
||||
source = pl.get('source', '')
|
||||
|
|
@ -56,201 +75,12 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[
|
|||
phase=f'Refreshing: "{pl.get("name", "")}"',
|
||||
current_item=pl.get('name', ''),
|
||||
)
|
||||
tracks = None
|
||||
|
||||
if source == 'spotify':
|
||||
# Try authenticated API first, fall back to public embed scraper.
|
||||
if deps.spotify_client and deps.spotify_client.is_spotify_authenticated():
|
||||
playlist_obj = deps.spotify_client.get_playlist_by_id(source_id)
|
||||
if playlist_obj and playlist_obj.tracks:
|
||||
tracks = []
|
||||
for t in playlist_obj.tracks:
|
||||
artist_name = t.artists[0] if t.artists else ''
|
||||
track_dict = {
|
||||
'track_name': t.name or '',
|
||||
'artist_name': str(artist_name),
|
||||
'album_name': t.album or '',
|
||||
'duration_ms': t.duration_ms or 0,
|
||||
'source_track_id': t.id or '',
|
||||
}
|
||||
# Spotify data IS official — auto-mark as discovered.
|
||||
if t.id:
|
||||
_album_obj = {'name': t.album or ''}
|
||||
if getattr(t, 'image_url', None):
|
||||
_album_obj['images'] = [{'url': t.image_url, 'height': 600, 'width': 600}]
|
||||
track_dict['extra_data'] = json.dumps({
|
||||
'discovered': True,
|
||||
'provider': 'spotify',
|
||||
'confidence': 1.0,
|
||||
'matched_data': {
|
||||
'id': t.id,
|
||||
'name': t.name or '',
|
||||
'artists': [{'name': str(a)} for a in (t.artists or [])],
|
||||
'album': _album_obj,
|
||||
'duration_ms': t.duration_ms or 0,
|
||||
'image_url': getattr(t, 'image_url', None),
|
||||
}
|
||||
})
|
||||
tracks.append(track_dict)
|
||||
|
||||
# Fallback: public embed scraper (no auth needed).
|
||||
if tracks is None:
|
||||
try:
|
||||
from core.spotify_public_scraper import scrape_spotify_embed
|
||||
embed_data = scrape_spotify_embed('playlist', source_id)
|
||||
if embed_data and not embed_data.get('error') and embed_data.get('tracks'):
|
||||
embed_album = embed_data.get('name', '') if embed_data.get('type') == 'album' else ''
|
||||
tracks = []
|
||||
for t in embed_data['tracks']:
|
||||
artist_names = [a['name'] for a in t.get('artists', [])]
|
||||
artist_name = artist_names[0] if artist_names else ''
|
||||
track_dict = {
|
||||
'track_name': t.get('name', ''),
|
||||
'artist_name': artist_name,
|
||||
'album_name': embed_album,
|
||||
'duration_ms': t.get('duration_ms', 0),
|
||||
'source_track_id': t.get('id', ''),
|
||||
}
|
||||
# Store Spotify track ID hint but don't mark discovered —
|
||||
# Discover step needs to run for proper album art.
|
||||
if t.get('id'):
|
||||
track_dict['extra_data'] = json.dumps({
|
||||
'discovered': False,
|
||||
'spotify_hint': {
|
||||
'id': t['id'],
|
||||
'name': t.get('name', ''),
|
||||
'artists': t.get('artists', []),
|
||||
}
|
||||
})
|
||||
tracks.append(track_dict)
|
||||
except Exception as e:
|
||||
deps.logger.warning(f"Spotify public scraper fallback failed for {source_id}: {e}")
|
||||
|
||||
elif source == 'spotify_public':
|
||||
# source_playlist_id is an MD5 hash; extract actual Spotify ID from stored description (URL).
|
||||
try:
|
||||
from core.spotify_public_scraper import parse_spotify_url, scrape_spotify_embed
|
||||
spotify_url = require_refresh_url(source, pl.get('description', ''), pl.get('name', ''))
|
||||
parsed = parse_spotify_url(spotify_url) if spotify_url else None
|
||||
|
||||
# If Spotify is authenticated, use the full API (auto-discovers with album art).
|
||||
if (parsed and parsed.get('type') == 'playlist'
|
||||
and deps.spotify_client and deps.spotify_client.is_spotify_authenticated()):
|
||||
playlist_obj = deps.spotify_client.get_playlist_by_id(parsed['id'])
|
||||
if playlist_obj and playlist_obj.tracks:
|
||||
tracks = []
|
||||
for t in playlist_obj.tracks:
|
||||
artist_name = t.artists[0] if t.artists else ''
|
||||
track_dict = {
|
||||
'track_name': t.name or '',
|
||||
'artist_name': str(artist_name),
|
||||
'album_name': t.album or '',
|
||||
'duration_ms': t.duration_ms or 0,
|
||||
'source_track_id': t.id or '',
|
||||
}
|
||||
if t.id:
|
||||
_album_obj = {'name': t.album or ''}
|
||||
if getattr(t, 'image_url', None):
|
||||
_album_obj['images'] = [{'url': t.image_url, 'height': 600, 'width': 600}]
|
||||
track_dict['extra_data'] = json.dumps({
|
||||
'discovered': True,
|
||||
'provider': 'spotify',
|
||||
'confidence': 1.0,
|
||||
'matched_data': {
|
||||
'id': t.id,
|
||||
'name': t.name or '',
|
||||
'artists': [{'name': str(a)} for a in (t.artists or [])],
|
||||
'album': _album_obj,
|
||||
'duration_ms': t.duration_ms or 0,
|
||||
'image_url': getattr(t, 'image_url', None),
|
||||
}
|
||||
})
|
||||
tracks.append(track_dict)
|
||||
|
||||
# Fallback: public embed scraper (no auth or album-type URL).
|
||||
if tracks is None and parsed:
|
||||
embed_data = scrape_spotify_embed(parsed['type'], parsed['id'])
|
||||
if embed_data and not embed_data.get('error') and embed_data.get('tracks'):
|
||||
embed_album = embed_data.get('name', '') if embed_data.get('type') == 'album' else ''
|
||||
tracks = []
|
||||
for t in embed_data['tracks']:
|
||||
artist_names = [a['name'] for a in t.get('artists', [])]
|
||||
artist_name = artist_names[0] if artist_names else ''
|
||||
tracks.append({
|
||||
'track_name': t.get('name', ''),
|
||||
'artist_name': artist_name,
|
||||
'album_name': embed_album,
|
||||
'duration_ms': t.get('duration_ms', 0),
|
||||
'source_track_id': t.get('id', ''),
|
||||
})
|
||||
# No extra_data — let preservation code keep existing discovery data.
|
||||
except Exception as e:
|
||||
deps.logger.warning(f"Spotify public playlist refresh failed for {source_id}: {e}")
|
||||
errors.append(f"{pl.get('name', '?')}: {str(e)}")
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
log_line=f'Refresh failed: "{pl.get("name", "")}" - {str(e)}',
|
||||
log_type='error',
|
||||
)
|
||||
continue
|
||||
|
||||
elif source == 'deezer':
|
||||
try:
|
||||
deezer = deps.get_deezer_client()
|
||||
playlist_data = deezer.get_playlist(source_id)
|
||||
if playlist_data and playlist_data.get('tracks'):
|
||||
tracks = []
|
||||
for t in playlist_data['tracks']:
|
||||
artist_name = t['artists'][0] if t.get('artists') else ''
|
||||
tracks.append({
|
||||
'track_name': t.get('name', ''),
|
||||
'artist_name': str(artist_name),
|
||||
'album_name': t.get('album', ''),
|
||||
'duration_ms': t.get('duration_ms', 0),
|
||||
'source_track_id': str(t.get('id', '')),
|
||||
})
|
||||
except Exception as e:
|
||||
deps.logger.warning(f"Deezer playlist refresh failed for {source_id}: {e}")
|
||||
|
||||
elif source == 'tidal':
|
||||
if not deps.tidal_client or not deps.tidal_client.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',
|
||||
)
|
||||
continue
|
||||
full_playlist = deps.tidal_client.get_playlist(source_id)
|
||||
if full_playlist and full_playlist.tracks:
|
||||
tracks = []
|
||||
for t in full_playlist.tracks:
|
||||
artist_name = t.artists[0] if t.artists else ''
|
||||
tracks.append({
|
||||
'track_name': t.name or '',
|
||||
'artist_name': str(artist_name),
|
||||
'album_name': t.album or '',
|
||||
'duration_ms': t.duration_ms or 0,
|
||||
'source_track_id': t.id or '',
|
||||
})
|
||||
|
||||
elif source == 'youtube':
|
||||
# source_playlist_id is now a deterministic hash; use stored description (original URL) for refresh.
|
||||
yt_url = require_refresh_url(source, pl.get('description', ''), pl.get('name', ''))
|
||||
playlist_data = deps.parse_youtube_playlist(yt_url)
|
||||
if playlist_data and playlist_data.get('tracks'):
|
||||
tracks = []
|
||||
for t in playlist_data['tracks']:
|
||||
artist_name = t['artists'][0] if t.get('artists') else ''
|
||||
tracks.append({
|
||||
'track_name': t.get('name', ''),
|
||||
'artist_name': str(artist_name),
|
||||
'album_name': '',
|
||||
'duration_ms': t.get('duration_ms', 0),
|
||||
'source_track_id': t.get('id', ''),
|
||||
})
|
||||
|
||||
if tracks is None:
|
||||
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,
|
||||
|
|
@ -259,63 +89,12 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[
|
|||
)
|
||||
continue
|
||||
|
||||
if tracks is not None:
|
||||
# Compare old vs new track IDs to detect changes.
|
||||
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 discovery extra_data for tracks that still exist.
|
||||
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'),
|
||||
)
|
||||
refreshed += 1
|
||||
|
||||
# Emit playlist_changed if tracks actually changed.
|
||||
if old_ids != new_ids:
|
||||
added_count = len(new_ids - old_ids)
|
||||
removed_count = len(old_ids - new_ids)
|
||||
deps.logger.info(
|
||||
f"[AUTOMATION] Playlist changed: '{pl.get('name', '')}' — "
|
||||
f"{added_count} added, {removed_count} removed (old={len(old_ids)}, new={len(new_ids)})"
|
||||
)
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
log_line=f'"{pl.get("name", "")}" — {added_count} added, {removed_count} 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_count),
|
||||
'removed': str(removed_count),
|
||||
})
|
||||
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',
|
||||
)
|
||||
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(
|
||||
|
|
@ -324,3 +103,178 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[
|
|||
log_type='error',
|
||||
)
|
||||
return {'status': 'completed', 'refreshed': str(refreshed), 'errors': str(len(errors))}
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from core.playlists.sources.base import (
|
|||
PlaylistMeta,
|
||||
PlaylistSource,
|
||||
NormalizedTrack,
|
||||
to_mirror_track_dict,
|
||||
)
|
||||
from core.playlists.sources.registry import (
|
||||
PlaylistSourceRegistry,
|
||||
|
|
@ -29,4 +30,5 @@ __all__ = [
|
|||
"NormalizedTrack",
|
||||
"PlaylistSourceRegistry",
|
||||
"get_registry",
|
||||
"to_mirror_track_dict",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
|||
# create a new "source".
|
||||
SOURCE_SPOTIFY = "spotify"
|
||||
SOURCE_SPOTIFY_PUBLIC = "spotify_public"
|
||||
SOURCE_DEEZER = "deezer"
|
||||
SOURCE_TIDAL = "tidal"
|
||||
SOURCE_QOBUZ = "qobuz"
|
||||
SOURCE_YOUTUBE = "youtube"
|
||||
|
|
@ -44,6 +45,7 @@ SOURCE_SOULSYNC_DISCOVERY = "soulsync_discovery"
|
|||
ALL_SOURCES = (
|
||||
SOURCE_SPOTIFY,
|
||||
SOURCE_SPOTIFY_PUBLIC,
|
||||
SOURCE_DEEZER,
|
||||
SOURCE_TIDAL,
|
||||
SOURCE_QOBUZ,
|
||||
SOURCE_YOUTUBE,
|
||||
|
|
@ -149,3 +151,67 @@ class PlaylistSource(Protocol):
|
|||
Default behavior is identical to ``get_playlist``. Sources whose
|
||||
refresh has side effects (e.g. ListenBrainz cache update,
|
||||
SoulSync Discovery regeneration) override this."""
|
||||
|
||||
|
||||
# ─── projection helpers ────────────────────────────────────────────────
|
||||
#
|
||||
# Adapters return NormalizedTrack objects; the mirrored-playlist DB
|
||||
# writer (``MusicDatabase.mirror_playlist``) accepts a list of dicts
|
||||
# with a specific shape. ``to_mirror_track_dict`` is the single,
|
||||
# tested projection between the two — kept here (not in the handler)
|
||||
# so every caller that writes mirrored tracks uses the same mapping.
|
||||
|
||||
|
||||
import json as _json
|
||||
|
||||
|
||||
def to_mirror_track_dict(track: NormalizedTrack) -> Dict[str, Any]:
|
||||
"""Project a NormalizedTrack into the shape ``mirror_playlist`` expects.
|
||||
|
||||
Adapter conventions consumed:
|
||||
|
||||
- ``track.extra['discovered']`` (bool) — when True, the adapter has
|
||||
enough metadata to skip the discovery worker and write a fully-
|
||||
populated ``matched_data`` block straight into ``extra_data``.
|
||||
Spotify's authenticated API path sets this.
|
||||
- ``track.extra['provider']`` (str) — provider name to record on
|
||||
the matched_data block (e.g. 'spotify').
|
||||
- ``track.extra['confidence']`` (float) — 0..1 match confidence;
|
||||
defaults to 1.0 when ``discovered`` is True.
|
||||
- ``track.extra['matched_data']`` (dict) — pre-built matched_data
|
||||
payload. Overrides the auto-derived payload below.
|
||||
- ``track.extra['spotify_hint']`` (dict) — public-embed scraper
|
||||
path: the Spotify track ID + artists hint that lets the
|
||||
discovery worker skip its search and go straight to enrichment.
|
||||
|
||||
When none of the above are present, the result has only the core
|
||||
fields and no ``extra_data`` — the discovery worker handles the
|
||||
track from scratch.
|
||||
"""
|
||||
result: Dict[str, Any] = {
|
||||
"track_name": track.track_name or "",
|
||||
"artist_name": track.artist_name or "",
|
||||
"album_name": track.album_name or "",
|
||||
"duration_ms": int(track.duration_ms or 0),
|
||||
"source_track_id": track.source_track_id or "",
|
||||
}
|
||||
|
||||
extra = track.extra or {}
|
||||
matched_data = extra.get("matched_data")
|
||||
is_discovered = bool(extra.get("discovered"))
|
||||
spotify_hint = extra.get("spotify_hint")
|
||||
|
||||
if is_discovered and matched_data:
|
||||
result["extra_data"] = _json.dumps({
|
||||
"discovered": True,
|
||||
"provider": extra.get("provider") or "unknown",
|
||||
"confidence": float(extra.get("confidence", 1.0)),
|
||||
"matched_data": matched_data,
|
||||
})
|
||||
elif spotify_hint:
|
||||
result["extra_data"] = _json.dumps({
|
||||
"discovered": False,
|
||||
"spotify_hint": spotify_hint,
|
||||
})
|
||||
|
||||
return result
|
||||
|
|
|
|||
97
core/playlists/sources/bootstrap.py
Normal file
97
core/playlists/sources/bootstrap.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""Helper for constructing + populating a PlaylistSourceRegistry.
|
||||
|
||||
Both ``web_server.py`` (at app boot) and the automation test fixtures
|
||||
build a registry the same way: take the client / parser / manager
|
||||
getters that already exist as module globals, wire them into the
|
||||
adapter constructors, register each adapter under its canonical name.
|
||||
|
||||
This module owns that wiring so the two call sites can't drift.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from core.playlists.sources.base import (
|
||||
SOURCE_DEEZER,
|
||||
SOURCE_ITUNES_LINK,
|
||||
SOURCE_LASTFM,
|
||||
SOURCE_LISTENBRAINZ,
|
||||
SOURCE_QOBUZ,
|
||||
SOURCE_SOULSYNC_DISCOVERY,
|
||||
SOURCE_SPOTIFY,
|
||||
SOURCE_SPOTIFY_PUBLIC,
|
||||
SOURCE_TIDAL,
|
||||
SOURCE_YOUTUBE,
|
||||
)
|
||||
from core.playlists.sources.deezer import DeezerPlaylistSource
|
||||
from core.playlists.sources.itunes_link import ITunesLinkPlaylistSource
|
||||
from core.playlists.sources.lastfm import LastFMPlaylistSource
|
||||
from core.playlists.sources.listenbrainz import ListenBrainzPlaylistSource
|
||||
from core.playlists.sources.qobuz import QobuzPlaylistSource
|
||||
from core.playlists.sources.registry import PlaylistSourceRegistry
|
||||
from core.playlists.sources.soulsync_discovery import (
|
||||
SoulSyncDiscoveryPlaylistSource,
|
||||
)
|
||||
from core.playlists.sources.spotify import SpotifyPlaylistSource
|
||||
from core.playlists.sources.spotify_public import SpotifyPublicPlaylistSource
|
||||
from core.playlists.sources.tidal import TidalPlaylistSource
|
||||
from core.playlists.sources.youtube import YouTubePlaylistSource
|
||||
|
||||
|
||||
def build_playlist_source_registry(
|
||||
*,
|
||||
spotify_client_getter: Callable[[], Any],
|
||||
tidal_client_getter: Callable[[], Any],
|
||||
qobuz_client_getter: Callable[[], Any],
|
||||
deezer_client_getter: Callable[[], Any],
|
||||
itunes_link_parser: Optional[Callable[[str], Optional[dict]]] = None,
|
||||
youtube_parser: Optional[Callable[[str], Optional[dict]]] = None,
|
||||
listenbrainz_manager_getter: Optional[Callable[[], Any]] = None,
|
||||
lastfm_manager_getter: Optional[Callable[[], Any]] = None,
|
||||
personalized_manager_getter: Optional[Callable[[], Any]] = None,
|
||||
profile_id_getter: Optional[Callable[[], int]] = None,
|
||||
) -> PlaylistSourceRegistry:
|
||||
"""Build a fresh registry with every default adapter registered.
|
||||
|
||||
Each parameter is the getter the corresponding adapter needs. Pass
|
||||
``lambda: None`` (or omit) for sources you don't want to expose —
|
||||
the adapter will simply degrade to empty results when its backing
|
||||
client is None / its parser is unset.
|
||||
"""
|
||||
reg = PlaylistSourceRegistry()
|
||||
|
||||
reg.register(SOURCE_SPOTIFY, lambda: SpotifyPlaylistSource(spotify_client_getter))
|
||||
reg.register(SOURCE_SPOTIFY_PUBLIC, lambda: SpotifyPublicPlaylistSource())
|
||||
reg.register(SOURCE_DEEZER, lambda: DeezerPlaylistSource(deezer_client_getter))
|
||||
reg.register(SOURCE_TIDAL, lambda: TidalPlaylistSource(tidal_client_getter))
|
||||
reg.register(SOURCE_QOBUZ, lambda: QobuzPlaylistSource(qobuz_client_getter))
|
||||
|
||||
_no_url_parser = lambda url: None
|
||||
reg.register(
|
||||
SOURCE_YOUTUBE,
|
||||
lambda: YouTubePlaylistSource(youtube_parser or _no_url_parser),
|
||||
)
|
||||
reg.register(
|
||||
SOURCE_ITUNES_LINK,
|
||||
lambda: ITunesLinkPlaylistSource(itunes_link_parser or _no_url_parser),
|
||||
)
|
||||
|
||||
_no_manager = lambda: None
|
||||
reg.register(
|
||||
SOURCE_LISTENBRAINZ,
|
||||
lambda: ListenBrainzPlaylistSource(listenbrainz_manager_getter or _no_manager),
|
||||
)
|
||||
reg.register(
|
||||
SOURCE_LASTFM,
|
||||
lambda: LastFMPlaylistSource(lastfm_manager_getter or _no_manager),
|
||||
)
|
||||
reg.register(
|
||||
SOURCE_SOULSYNC_DISCOVERY,
|
||||
lambda: SoulSyncDiscoveryPlaylistSource(
|
||||
personalized_manager_getter or _no_manager,
|
||||
profile_id_getter=profile_id_getter,
|
||||
),
|
||||
)
|
||||
|
||||
return reg
|
||||
108
core/playlists/sources/deezer.py
Normal file
108
core/playlists/sources/deezer.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"""Deezer playlist source adapter.
|
||||
|
||||
Wraps ``core.deezer_client.DeezerClient.get_playlist``. Deezer's public
|
||||
API needs no auth, so ``is_authenticated`` always returns True. Listing
|
||||
the *user's* playlists requires OAuth — surfaced via the underlying
|
||||
``is_user_authenticated`` flag — but the get-by-id flow works on any
|
||||
public playlist regardless.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from core.playlists.sources.base import (
|
||||
NormalizedTrack,
|
||||
PlaylistDetail,
|
||||
PlaylistMeta,
|
||||
PlaylistSource,
|
||||
SOURCE_DEEZER,
|
||||
)
|
||||
|
||||
|
||||
class DeezerPlaylistSource(PlaylistSource):
|
||||
name = SOURCE_DEEZER
|
||||
supports_listing = True # user playlists need OAuth; falls back to []
|
||||
supports_refresh = True
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self, client_getter: Callable[[], Any]):
|
||||
self._client_getter = client_getter
|
||||
|
||||
def _client(self):
|
||||
return self._client_getter()
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
client = self._client()
|
||||
if client is None:
|
||||
return False
|
||||
# Deezer's `is_authenticated` is True even with no OAuth token —
|
||||
# the public API works without one. Use that as our liveness signal.
|
||||
return bool(client.is_authenticated())
|
||||
|
||||
def list_playlists(self) -> List[PlaylistMeta]:
|
||||
client = self._client()
|
||||
if client is None:
|
||||
return []
|
||||
# User playlists need OAuth; `get_user_playlists` returns [] when
|
||||
# the stub-interface variant is in use. Honor whatever the client
|
||||
# actually returns.
|
||||
try:
|
||||
playlists = client.get_user_playlists() or []
|
||||
except Exception:
|
||||
return []
|
||||
return [self._meta_from_playlist(p) for p in playlists]
|
||||
|
||||
def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
|
||||
client = self._client()
|
||||
if client is None:
|
||||
return None
|
||||
data = client.get_playlist(playlist_id)
|
||||
if not data:
|
||||
return None
|
||||
meta = self._meta_from_dict(data)
|
||||
tracks_raw = data.get("tracks") or []
|
||||
tracks = [self._track_from_dict(t, idx) for idx, t in enumerate(tracks_raw)]
|
||||
meta.track_count = len(tracks)
|
||||
return PlaylistDetail(meta=meta, tracks=tracks)
|
||||
|
||||
def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
|
||||
return self.get_playlist(playlist_id)
|
||||
|
||||
# ---- projection helpers ------------------------------------------------
|
||||
|
||||
def _meta_from_playlist(self, playlist: Any) -> PlaylistMeta:
|
||||
"""Project a ``DeezerClient.Playlist`` dataclass into PlaylistMeta."""
|
||||
return PlaylistMeta(
|
||||
source=self.name,
|
||||
source_playlist_id=str(getattr(playlist, "id", "")),
|
||||
name=getattr(playlist, "name", "Deezer Playlist"),
|
||||
description=getattr(playlist, "description", None),
|
||||
track_count=int(getattr(playlist, "total_tracks", 0) or 0),
|
||||
owner=getattr(playlist, "owner", None),
|
||||
)
|
||||
|
||||
def _meta_from_dict(self, p: Dict[str, Any]) -> PlaylistMeta:
|
||||
return PlaylistMeta(
|
||||
source=self.name,
|
||||
source_playlist_id=str(p.get("id", "")),
|
||||
name=p.get("name", "Deezer Playlist"),
|
||||
description=p.get("description") or None,
|
||||
owner=p.get("owner") or None,
|
||||
image_url=p.get("image_url") or None,
|
||||
track_count=int(p.get("track_count", 0) or 0),
|
||||
)
|
||||
|
||||
def _track_from_dict(self, t: Dict[str, Any], position: int) -> NormalizedTrack:
|
||||
artists = t.get("artists") or []
|
||||
artist_name = artists[0] if artists else "Unknown Artist"
|
||||
return NormalizedTrack(
|
||||
position=position,
|
||||
track_name=t.get("name", "Unknown Track"),
|
||||
artist_name=artist_name,
|
||||
album_name=t.get("album") or None,
|
||||
duration_ms=int(t.get("duration_ms", 0) or 0),
|
||||
source_track_id=str(t.get("id", "")),
|
||||
needs_discovery=False,
|
||||
extra={"track_number": t.get("track_number")},
|
||||
)
|
||||
|
|
@ -80,9 +80,11 @@ class ITunesLinkPlaylistSource(PlaylistSource):
|
|||
def _track_from_itunes(self, track: dict, position: int) -> NormalizedTrack:
|
||||
artists = track.get("artists") or []
|
||||
if artists and isinstance(artists[0], dict):
|
||||
artist_name = ", ".join(a.get("name", "") for a in artists if a.get("name"))
|
||||
artist_name = artists[0].get("name", "") or ""
|
||||
elif artists:
|
||||
artist_name = str(artists[0])
|
||||
else:
|
||||
artist_name = ", ".join(str(a) for a in artists if a)
|
||||
artist_name = ""
|
||||
if not artist_name:
|
||||
artist_name = "Unknown Artist"
|
||||
album = track.get("album")
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class QobuzPlaylistSource(PlaylistSource):
|
|||
|
||||
def _track_from_dict(self, t: Dict[str, Any], position: int) -> NormalizedTrack:
|
||||
artists = t.get("artists") or []
|
||||
artist_name = ", ".join(artists) if artists else "Unknown Artist"
|
||||
artist_name = artists[0] if artists else "Unknown Artist"
|
||||
return NormalizedTrack(
|
||||
position=position,
|
||||
track_name=t.get("name", "Unknown Track"),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ client used everywhere else. Adapter projects Spotify's ``Playlist`` /
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, List, Optional
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from core.playlists.sources.base import (
|
||||
NormalizedTrack,
|
||||
|
|
@ -39,18 +39,25 @@ class SpotifyPlaylistSource(PlaylistSource):
|
|||
client = self._client()
|
||||
if client is None:
|
||||
return False
|
||||
return bool(client.is_authenticated())
|
||||
# ``is_spotify_authenticated`` is the Spotify-specific check;
|
||||
# ``is_authenticated`` on SpotifyClient is a metadata-aware
|
||||
# superset that returns True even when only the iTunes
|
||||
# fallback is available. The adapter needs the strict check
|
||||
# because it calls Spotify-only endpoints (get_user_playlists,
|
||||
# get_playlist_by_id).
|
||||
check = getattr(client, "is_spotify_authenticated", None) or client.is_authenticated
|
||||
return bool(check())
|
||||
|
||||
def list_playlists(self) -> List[PlaylistMeta]:
|
||||
client = self._client()
|
||||
if client is None or not client.is_authenticated():
|
||||
if client is None or not self.is_authenticated():
|
||||
return []
|
||||
playlists = client.get_user_playlists_metadata_only()
|
||||
return [self._meta_from_playlist(p) for p in playlists]
|
||||
|
||||
def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
|
||||
client = self._client()
|
||||
if client is None or not client.is_authenticated():
|
||||
if client is None or not self.is_authenticated():
|
||||
return None
|
||||
playlist = client.get_playlist_by_id(playlist_id)
|
||||
if playlist is None:
|
||||
|
|
@ -81,19 +88,50 @@ class SpotifyPlaylistSource(PlaylistSource):
|
|||
|
||||
def _track_from_spotify(self, track: Any, position: int) -> NormalizedTrack:
|
||||
artists = getattr(track, "artists", None) or []
|
||||
artist_name = ", ".join(artists) if artists else "Unknown Artist"
|
||||
artist_name = artists[0] if artists else "Unknown Artist"
|
||||
track_id = str(track.id) if getattr(track, "id", None) else ""
|
||||
track_name = track.name or ""
|
||||
album_name = getattr(track, "album", "") or ""
|
||||
duration_ms = int(getattr(track, "duration_ms", 0) or 0)
|
||||
image_url = getattr(track, "image_url", None)
|
||||
|
||||
# Spotify's authenticated API IS canonical metadata — populate
|
||||
# the discovered/matched_data block so to_mirror_track_dict emits
|
||||
# the same extra_data shape downstream consumers (sync, wishlist)
|
||||
# already expect from this path.
|
||||
extra: Dict[str, Any] = {
|
||||
"popularity": getattr(track, "popularity", 0),
|
||||
"external_urls": getattr(track, "external_urls", None),
|
||||
"preview_url": getattr(track, "preview_url", None),
|
||||
}
|
||||
if track_id:
|
||||
album_obj: Dict[str, Any] = {"name": album_name}
|
||||
if image_url:
|
||||
album_obj["images"] = [{
|
||||
"url": image_url,
|
||||
"height": 600,
|
||||
"width": 600,
|
||||
}]
|
||||
extra["discovered"] = True
|
||||
extra["provider"] = "spotify"
|
||||
extra["confidence"] = 1.0
|
||||
extra["matched_data"] = {
|
||||
"id": track_id,
|
||||
"name": track_name,
|
||||
"artists": [{"name": str(a)} for a in artists],
|
||||
"album": album_obj,
|
||||
"duration_ms": duration_ms,
|
||||
"image_url": image_url,
|
||||
}
|
||||
|
||||
return NormalizedTrack(
|
||||
position=position,
|
||||
track_name=track.name,
|
||||
artist_name=artist_name,
|
||||
album_name=getattr(track, "album", None),
|
||||
duration_ms=int(getattr(track, "duration_ms", 0) or 0),
|
||||
source_track_id=str(track.id),
|
||||
image_url=getattr(track, "image_url", None),
|
||||
track_name=track_name,
|
||||
artist_name=str(artist_name),
|
||||
album_name=album_name or None,
|
||||
duration_ms=duration_ms,
|
||||
source_track_id=track_id,
|
||||
image_url=image_url,
|
||||
needs_discovery=False,
|
||||
extra={
|
||||
"popularity": getattr(track, "popularity", 0),
|
||||
"external_urls": getattr(track, "external_urls", None),
|
||||
"preview_url": getattr(track, "preview_url", None),
|
||||
},
|
||||
extra=extra,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ library" to enumerate; the user pastes a URL and the adapter fetches.
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import List, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.playlists.sources.base import (
|
||||
NormalizedTrack,
|
||||
|
|
@ -77,19 +77,38 @@ class SpotifyPublicPlaylistSource(PlaylistSource):
|
|||
|
||||
def _track_from_embed(self, track: dict, position: int) -> NormalizedTrack:
|
||||
artists = track.get("artists") or []
|
||||
artist_name = ", ".join(
|
||||
a.get("name", "") for a in artists if isinstance(a, dict)
|
||||
) or "Unknown Artist"
|
||||
if artists and isinstance(artists[0], dict):
|
||||
artist_name = artists[0].get("name", "") or "Unknown Artist"
|
||||
elif artists:
|
||||
artist_name = str(artists[0])
|
||||
else:
|
||||
artist_name = "Unknown Artist"
|
||||
track_id = str(track.get("id", ""))
|
||||
track_name = track.get("name", "")
|
||||
|
||||
# Public-embed data isn't canonical (no album art in the embed
|
||||
# response), so we DON'T set ``discovered=True``. Instead, plant
|
||||
# a ``spotify_hint`` so the downstream discovery worker can skip
|
||||
# its search step and go straight to Spotify enrichment for the
|
||||
# known track ID. Matches the pre-extraction handler behavior.
|
||||
extra: Dict[str, Any] = {
|
||||
"explicit": bool(track.get("is_explicit", False)),
|
||||
"track_number": track.get("track_number"),
|
||||
}
|
||||
if track_id:
|
||||
extra["spotify_hint"] = {
|
||||
"id": track_id,
|
||||
"name": track_name,
|
||||
"artists": artists,
|
||||
}
|
||||
|
||||
return NormalizedTrack(
|
||||
position=position,
|
||||
track_name=track.get("name", "Unknown Track"),
|
||||
track_name=track_name,
|
||||
artist_name=artist_name,
|
||||
album_name=None,
|
||||
duration_ms=int(track.get("duration_ms", 0) or 0),
|
||||
source_track_id=str(track.get("id", "")),
|
||||
source_track_id=track_id,
|
||||
needs_discovery=False,
|
||||
extra={
|
||||
"explicit": bool(track.get("is_explicit", False)),
|
||||
"track_number": track.get("track_number"),
|
||||
},
|
||||
extra=extra,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -84,7 +84,9 @@ class TidalPlaylistSource(PlaylistSource):
|
|||
|
||||
def _track_from_tidal(self, track: Any, position: int) -> NormalizedTrack:
|
||||
artists = getattr(track, "artists", None) or []
|
||||
artist_name = ", ".join(artists) if artists else "Unknown Artist"
|
||||
# First artist only — matches the mirrored_playlist shape the
|
||||
# legacy refresh_mirrored handler wrote.
|
||||
artist_name = artists[0] if artists else "Unknown Artist"
|
||||
return NormalizedTrack(
|
||||
position=position,
|
||||
track_name=track.name,
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ class YouTubePlaylistSource(PlaylistSource):
|
|||
|
||||
def _track_from_yt(self, track: dict, position: int) -> NormalizedTrack:
|
||||
artists = track.get("artists") or []
|
||||
artist_name = ", ".join(artists) if artists else "Unknown Artist"
|
||||
artist_name = artists[0] if artists else "Unknown Artist"
|
||||
return NormalizedTrack(
|
||||
position=position,
|
||||
track_name=track.get("name", "Unknown Track"),
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ from core.automation.handlers._pipeline_shared import run_sync_and_wishlist
|
|||
from core.automation.handlers.playlist_pipeline import auto_playlist_pipeline
|
||||
from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored
|
||||
from core.automation.handlers.sync_playlist import auto_sync_playlist
|
||||
from core.playlists.sources.bootstrap import build_playlist_source_registry
|
||||
|
||||
|
||||
# ─── shared scaffolding ──────────────────────────────────────────────
|
||||
|
|
@ -74,6 +75,22 @@ class _StubDB:
|
|||
|
||||
|
||||
def _build_deps(**overrides) -> AutomationDeps:
|
||||
# Build a default registry from whatever clients the test passed.
|
||||
# The refresh_mirrored handler reads from deps.playlist_source_registry
|
||||
# exclusively, so the registry must mirror the passed clients to
|
||||
# preserve the pre-refactor test behavior.
|
||||
_spotify = overrides.get('spotify_client')
|
||||
_tidal = overrides.get('tidal_client')
|
||||
_get_deezer = overrides.get('get_deezer_client', lambda: None)
|
||||
_parse_youtube = overrides.get('parse_youtube_playlist', lambda url: None)
|
||||
_registry = build_playlist_source_registry(
|
||||
spotify_client_getter=lambda: _spotify,
|
||||
tidal_client_getter=lambda: _tidal,
|
||||
qobuz_client_getter=lambda: None,
|
||||
deezer_client_getter=_get_deezer,
|
||||
youtube_parser=_parse_youtube,
|
||||
)
|
||||
|
||||
defaults = dict(
|
||||
engine=object(),
|
||||
state=AutomationState(),
|
||||
|
|
@ -94,6 +111,7 @@ def _build_deps(**overrides) -> AutomationDeps:
|
|||
load_sync_status_file=lambda: {},
|
||||
get_deezer_client=lambda: None,
|
||||
parse_youtube_playlist=lambda url: None,
|
||||
playlist_source_registry=_registry,
|
||||
get_sync_states=lambda: {},
|
||||
set_db_update_automation_id=lambda v: None,
|
||||
get_db_update_state=lambda: {},
|
||||
|
|
@ -190,6 +208,17 @@ class _StubSpotifyTrack:
|
|||
@dataclass
|
||||
class _StubSpotifyPlaylist:
|
||||
tracks: list
|
||||
# Adapter-side projection reads metadata fields off the playlist
|
||||
# object (the real ``core.spotify_client.Playlist`` dataclass).
|
||||
# Provide minimal defaults so the stub stays a one-liner at call
|
||||
# sites that only care about tracks.
|
||||
id: str = 'spot-id'
|
||||
name: str = 'My Spot'
|
||||
description: Optional[str] = ''
|
||||
owner: str = 'me'
|
||||
public: bool = True
|
||||
collaborative: bool = False
|
||||
total_tracks: int = 0
|
||||
|
||||
|
||||
class _StubSpotifyClient:
|
||||
|
|
@ -285,6 +314,163 @@ class TestRefreshMirrored:
|
|||
assert db.mirror_calls == []
|
||||
assert any(p.get('log_type') == 'error' and 'missing its original source URL' in p.get('log_line', '') for p in progress)
|
||||
|
||||
def test_tidal_not_authenticated_emits_skip_not_error(self):
|
||||
"""Soft-skip preserves the legacy log_type='skip' contract — the
|
||||
run still counts as completed with 0 errors so the automation
|
||||
doesn't surface a Tidal-down condition as a refresh failure."""
|
||||
class _UnauthedTidal:
|
||||
def is_authenticated(self):
|
||||
return False
|
||||
|
||||
db = _StubDB(playlists=[
|
||||
{'id': 7, 'name': 'My Tidal', 'source': 'tidal',
|
||||
'source_playlist_id': 'tid-id', 'profile_id': 1},
|
||||
])
|
||||
progress = []
|
||||
deps = _build_deps(
|
||||
get_database=lambda: db,
|
||||
tidal_client=_UnauthedTidal(),
|
||||
update_progress=lambda *a, **k: progress.append(k),
|
||||
)
|
||||
|
||||
result = auto_refresh_mirrored({'playlist_id': '7'}, deps)
|
||||
|
||||
assert result['status'] == 'completed'
|
||||
assert result['refreshed'] == '0'
|
||||
assert result['errors'] == '0' # skip, not error
|
||||
assert db.mirror_calls == []
|
||||
assert any(
|
||||
p.get('log_type') == 'skip' and 'Tidal not authenticated' in p.get('log_line', '')
|
||||
for p in progress
|
||||
)
|
||||
|
||||
def test_deezer_refresh_writes_plain_tracks_no_matched_data(self):
|
||||
class _StubDeezer:
|
||||
def is_authenticated(self):
|
||||
return True
|
||||
|
||||
def get_user_playlists(self):
|
||||
return []
|
||||
|
||||
def get_playlist(self, playlist_id):
|
||||
return {
|
||||
'id': playlist_id,
|
||||
'name': 'Deez',
|
||||
'description': '',
|
||||
'track_count': 1,
|
||||
'image_url': '',
|
||||
'owner': '',
|
||||
'tracks': [{
|
||||
'id': 'dz1',
|
||||
'name': 'Track',
|
||||
'artists': ['Deez Artist'],
|
||||
'album': 'Deez Album',
|
||||
'duration_ms': 200_000,
|
||||
}],
|
||||
}
|
||||
|
||||
db = _StubDB(playlists=[
|
||||
{'id': 9, 'name': 'Deez Mix', 'source': 'deezer',
|
||||
'source_playlist_id': 'dz-id', 'profile_id': 1},
|
||||
])
|
||||
deps = _build_deps(
|
||||
get_database=lambda: db,
|
||||
get_deezer_client=lambda: _StubDeezer(),
|
||||
)
|
||||
|
||||
result = auto_refresh_mirrored({'playlist_id': '9'}, deps)
|
||||
|
||||
assert result['status'] == 'completed'
|
||||
assert result['refreshed'] == '1'
|
||||
assert len(db.mirror_calls) == 1
|
||||
call = db.mirror_calls[0]
|
||||
assert call['source'] == 'deezer'
|
||||
assert len(call['tracks']) == 1
|
||||
# Deezer tracks don't carry discovery state — no extra_data.
|
||||
assert 'extra_data' not in call['tracks'][0]
|
||||
assert call['tracks'][0]['source_track_id'] == 'dz1'
|
||||
|
||||
def test_youtube_refresh_reads_url_from_description(self):
|
||||
"""URL-backed sources store the hash in source_playlist_id and
|
||||
the canonical URL in description. The handler has to pull the
|
||||
URL out before passing to the adapter."""
|
||||
parsed_calls = []
|
||||
|
||||
def _fake_parser(url):
|
||||
parsed_calls.append(url)
|
||||
return {
|
||||
'id': 'yt_pl',
|
||||
'name': 'YT Mix',
|
||||
'url': url,
|
||||
'track_count': 1,
|
||||
'tracks': [{
|
||||
'id': 'vid1',
|
||||
'name': 'Track',
|
||||
'artists': ['Channel'],
|
||||
'duration_ms': 240_000,
|
||||
}],
|
||||
}
|
||||
|
||||
db = _StubDB(playlists=[
|
||||
{
|
||||
'id': 11,
|
||||
'name': 'YT Mix',
|
||||
'source': 'youtube',
|
||||
'source_playlist_id': 'hashhash',
|
||||
'description': 'https://youtube.com/playlist?list=yt_pl',
|
||||
'profile_id': 1,
|
||||
},
|
||||
])
|
||||
deps = _build_deps(
|
||||
get_database=lambda: db,
|
||||
parse_youtube_playlist=_fake_parser,
|
||||
)
|
||||
|
||||
result = auto_refresh_mirrored({'playlist_id': '11'}, deps)
|
||||
|
||||
assert result['refreshed'] == '1'
|
||||
# Parser was called with the URL from description, not the hash.
|
||||
assert parsed_calls == ['https://youtube.com/playlist?list=yt_pl']
|
||||
assert db.mirror_calls[0]['tracks'][0]['source_track_id'] == 'vid1'
|
||||
|
||||
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
|
||||
the mirror gets album-art-bearing matched_data instead of the
|
||||
bare scraper output."""
|
||||
track = _StubSpotifyTrack(
|
||||
id='auth-trk', name='From Auth', artists=['Artist'],
|
||||
album='Album', duration_ms=200_000, image_url='img',
|
||||
)
|
||||
spotify = _StubSpotifyClient(_StubSpotifyPlaylist(
|
||||
tracks=[track], id='auth-pid', name='Auth',
|
||||
))
|
||||
|
||||
db = _StubDB(playlists=[
|
||||
{
|
||||
'id': 22,
|
||||
'name': 'Pub',
|
||||
'source': 'spotify_public',
|
||||
'source_playlist_id': 'hash',
|
||||
'description': 'https://open.spotify.com/playlist/abc123def456',
|
||||
'profile_id': 1,
|
||||
},
|
||||
])
|
||||
deps = _build_deps(
|
||||
get_database=lambda: db,
|
||||
spotify_client=spotify,
|
||||
)
|
||||
|
||||
result = auto_refresh_mirrored({'playlist_id': '22'}, deps)
|
||||
|
||||
assert result['refreshed'] == '1'
|
||||
call = db.mirror_calls[0]
|
||||
# Track came from the authed Spotify path → carries matched_data.
|
||||
extra = json.loads(call['tracks'][0]['extra_data'])
|
||||
assert extra['discovered'] is True
|
||||
assert extra['provider'] == 'spotify'
|
||||
assert extra['matched_data']['id'] == 'auth-trk'
|
||||
|
||||
|
||||
# ─── sync_playlist ───────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,9 @@ from core.playlists.sources import (
|
|||
PlaylistSource,
|
||||
PlaylistSourceRegistry,
|
||||
get_registry,
|
||||
to_mirror_track_dict,
|
||||
)
|
||||
from core.playlists.sources.deezer import DeezerPlaylistSource
|
||||
from core.playlists.sources.itunes_link import ITunesLinkPlaylistSource
|
||||
from core.playlists.sources.lastfm import LastFMPlaylistSource
|
||||
from core.playlists.sources.listenbrainz import ListenBrainzPlaylistSource
|
||||
|
|
@ -104,10 +106,20 @@ def test_spotify_adapter_lists_and_fetches():
|
|||
t = detail.tracks[0]
|
||||
assert t.source_track_id == "t1"
|
||||
assert t.track_name == "Run"
|
||||
assert t.artist_name == "A, B"
|
||||
# First artist only — matches the mirrored_playlist shape that the
|
||||
# legacy refresh_mirrored handler wrote (``t.artists[0]``).
|
||||
assert t.artist_name == "A"
|
||||
assert t.album_name == "Album"
|
||||
assert t.duration_ms == 180_000
|
||||
assert t.needs_discovery is False
|
||||
# Spotify authenticated API path emits matched_data so the discovery
|
||||
# worker can skip its search step and go straight to enrichment.
|
||||
assert t.extra["discovered"] is True
|
||||
assert t.extra["provider"] == "spotify"
|
||||
assert t.extra["matched_data"]["id"] == "t1"
|
||||
assert t.extra["matched_data"]["artists"] == [{"name": "A"}, {"name": "B"}]
|
||||
assert t.extra["matched_data"]["album"]["name"] == "Album"
|
||||
assert t.extra["matched_data"]["album"]["images"][0]["url"] == "img"
|
||||
|
||||
|
||||
def test_spotify_adapter_handles_unauthed():
|
||||
|
|
@ -571,3 +583,133 @@ def test_registry_re_register_invalidates_instance():
|
|||
def test_registry_unknown_source_returns_none():
|
||||
reg = PlaylistSourceRegistry()
|
||||
assert reg.get_source("nope") is None
|
||||
|
||||
|
||||
# ─── Deezer ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeDeezerClient:
|
||||
def is_authenticated(self) -> bool:
|
||||
return True # Deezer public API always available
|
||||
|
||||
def get_user_playlists(self):
|
||||
return [] # stub-interface variant returns []
|
||||
|
||||
def get_playlist(self, playlist_id: str):
|
||||
return {
|
||||
"id": playlist_id,
|
||||
"name": "Deez Mix",
|
||||
"description": "deezer",
|
||||
"track_count": 1,
|
||||
"image_url": "img",
|
||||
"owner": "user",
|
||||
"tracks": [{
|
||||
"id": "dt1",
|
||||
"name": "Song",
|
||||
"artists": ["Deez Artist"],
|
||||
"album": "Deez Album",
|
||||
"duration_ms": 240_000,
|
||||
"track_number": 1,
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def test_deezer_adapter_projection():
|
||||
src = DeezerPlaylistSource(lambda: _FakeDeezerClient())
|
||||
assert src.is_authenticated() is True
|
||||
assert src.list_playlists() == [] # user playlists need OAuth
|
||||
|
||||
detail = src.get_playlist("d1")
|
||||
assert detail is not None
|
||||
assert detail.meta.source == "deezer"
|
||||
assert detail.meta.image_url == "img"
|
||||
t = detail.tracks[0]
|
||||
assert t.source_track_id == "dt1"
|
||||
assert t.artist_name == "Deez Artist"
|
||||
assert t.album_name == "Deez Album"
|
||||
assert t.needs_discovery is False
|
||||
|
||||
|
||||
# ─── to_mirror_track_dict projection helper ─────────────────────────────
|
||||
|
||||
|
||||
def test_mirror_dict_minimal_track_has_no_extra_data():
|
||||
track = NormalizedTrack(
|
||||
position=0,
|
||||
track_name="Song",
|
||||
artist_name="Artist",
|
||||
album_name="Album",
|
||||
duration_ms=200_000,
|
||||
source_track_id="abc",
|
||||
)
|
||||
d = to_mirror_track_dict(track)
|
||||
assert d == {
|
||||
"track_name": "Song",
|
||||
"artist_name": "Artist",
|
||||
"album_name": "Album",
|
||||
"duration_ms": 200_000,
|
||||
"source_track_id": "abc",
|
||||
}
|
||||
assert "extra_data" not in d
|
||||
|
||||
|
||||
def test_mirror_dict_spotify_authed_emits_matched_data():
|
||||
"""The Spotify adapter's authenticated-API path planted
|
||||
``discovered`` + ``matched_data`` in ``extra``; projection must
|
||||
serialize them into ``extra_data`` matching the legacy refresh
|
||||
handler's shape (pre-extraction)."""
|
||||
track = NormalizedTrack(
|
||||
position=0,
|
||||
track_name="Run",
|
||||
artist_name="Adele",
|
||||
album_name="25",
|
||||
duration_ms=295_000,
|
||||
source_track_id="track123",
|
||||
extra={
|
||||
"discovered": True,
|
||||
"provider": "spotify",
|
||||
"confidence": 1.0,
|
||||
"matched_data": {
|
||||
"id": "track123",
|
||||
"name": "Run",
|
||||
"artists": [{"name": "Adele"}],
|
||||
"album": {"name": "25"},
|
||||
"duration_ms": 295_000,
|
||||
"image_url": None,
|
||||
},
|
||||
},
|
||||
)
|
||||
d = to_mirror_track_dict(track)
|
||||
assert "extra_data" in d
|
||||
import json as _json
|
||||
extra = _json.loads(d["extra_data"])
|
||||
assert extra["discovered"] is True
|
||||
assert extra["provider"] == "spotify"
|
||||
assert extra["confidence"] == 1.0
|
||||
assert extra["matched_data"]["id"] == "track123"
|
||||
assert extra["matched_data"]["artists"] == [{"name": "Adele"}]
|
||||
|
||||
|
||||
def test_mirror_dict_spotify_public_emits_spotify_hint():
|
||||
"""Public-embed path: track ID known but album art / canonical
|
||||
metadata missing, so we emit a ``spotify_hint`` for the discovery
|
||||
worker instead of marking discovered."""
|
||||
track = NormalizedTrack(
|
||||
position=0,
|
||||
track_name="Song",
|
||||
artist_name="Artist",
|
||||
duration_ms=200_000,
|
||||
source_track_id="sptrk",
|
||||
extra={
|
||||
"spotify_hint": {
|
||||
"id": "sptrk",
|
||||
"name": "Song",
|
||||
"artists": [{"name": "Artist"}],
|
||||
},
|
||||
},
|
||||
)
|
||||
d = to_mirror_track_dict(track)
|
||||
import json as _json
|
||||
extra = _json.loads(d["extra_data"])
|
||||
assert extra["discovered"] is False
|
||||
assert extra["spotify_hint"]["id"] == "sptrk"
|
||||
|
|
|
|||
|
|
@ -961,6 +961,7 @@ def _register_automation_handlers():
|
|||
|
||||
from core.automation.deps import AutomationDeps, AutomationState
|
||||
from core.automation.handlers import register_all as _register_extracted_handlers
|
||||
from core.playlists.sources.bootstrap import build_playlist_source_registry
|
||||
|
||||
# Mutable shared state previously lived as module-level globals
|
||||
# (`_scan_library_automation_id`, `_pipeline_running`, etc).
|
||||
|
|
@ -971,6 +972,38 @@ def _register_automation_handlers():
|
|||
|
||||
from core.watchlist_scanner import get_watchlist_scanner as _get_watchlist_scanner_fn
|
||||
|
||||
# ListenBrainz / Last.fm are profile-scoped, so the manager getter
|
||||
# resolves the current profile's manager on each call. iTunes-link
|
||||
# parsing lives as a module helper rather than a class — wrap it in
|
||||
# a callable that matches the adapter contract.
|
||||
def _lb_manager_for_registry():
|
||||
try:
|
||||
manager, _username, _source = _get_profile_lb_manager()
|
||||
return manager
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _itunes_link_parser_for_registry(url):
|
||||
# ``parse_itunes_link_endpoint`` is a Flask route handler; the
|
||||
# actual parsing work needs the underlying helpers. For now
|
||||
# we punt — Phase 2 will lift the parsing into a pure helper
|
||||
# the adapter can call directly. Refresh of itunes_link
|
||||
# mirrors is not yet wired (current handler skips them).
|
||||
return None
|
||||
|
||||
_playlist_source_registry = build_playlist_source_registry(
|
||||
spotify_client_getter=lambda: spotify_client,
|
||||
tidal_client_getter=lambda: tidal_client,
|
||||
qobuz_client_getter=_get_qobuz_client_for_sync,
|
||||
deezer_client_getter=_get_deezer_client,
|
||||
youtube_parser=parse_youtube_playlist,
|
||||
itunes_link_parser=_itunes_link_parser_for_registry,
|
||||
listenbrainz_manager_getter=_lb_manager_for_registry,
|
||||
lastfm_manager_getter=_lb_manager_for_registry,
|
||||
personalized_manager_getter=_build_personalized_manager,
|
||||
profile_id_getter=get_current_profile_id,
|
||||
)
|
||||
|
||||
_automation_deps = AutomationDeps(
|
||||
engine=automation_engine,
|
||||
state=_automation_state,
|
||||
|
|
@ -992,6 +1025,7 @@ def _register_automation_handlers():
|
|||
get_deezer_client=_get_deezer_client,
|
||||
parse_youtube_playlist=parse_youtube_playlist,
|
||||
get_sync_states=lambda: sync_states,
|
||||
playlist_source_registry=_playlist_source_registry,
|
||||
set_db_update_automation_id=_set_db_update_automation_id,
|
||||
get_db_update_state=lambda: db_update_state,
|
||||
db_update_lock=db_update_lock,
|
||||
|
|
|
|||
|
|
@ -3416,6 +3416,7 @@ const WHATS_NEW = {
|
|||
'2.6.3': [
|
||||
{ unreleased: true },
|
||||
{ title: 'Groundwork: unified playlist source layer', desc: 'first slice of a refactor that\'ll let ListenBrainz, Last.fm radio, and SoulSync Discovery playlists live as Sync-page tabs alongside Spotify / Tidal / Qobuz / YouTube — so they can be mirrored + scheduled like the rest. this commit adds the shared adapter layer all those sources will plug into; no UI changes yet. nothing to do on your end.' },
|
||||
{ title: 'Auto-Sync refresh now routes through the unified source layer', desc: 'follow-up to the groundwork above. the mirrored-playlist auto-refresh handler used to have a ~190-line if/elif chain branching per source (one branch each for Spotify, Spotify public, Deezer, Tidal, YouTube). now it asks the source registry for the right adapter and calls one refresh method. behavior identical — same matched_data, same Tidal-skip-on-no-auth log, same Spotify-public-prefers-authed-API fallback. unlocks ListenBrainz / Last.fm / SoulSync Discovery as future Sync-page mirror sources without a fresh elif branch each time.' },
|
||||
],
|
||||
'2.6.2': [
|
||||
{ date: 'May 24, 2026 — 2.6.2 release' },
|
||||
|
|
|
|||
Loading…
Reference in a new issue