Merge pull request #709 from Nezreka/dev

Dev
This commit is contained in:
BoulderBadgeDad 2026-05-27 08:46:57 -07:00 committed by GitHub
commit b470eae0b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
93 changed files with 16476 additions and 857 deletions

View file

@ -9,9 +9,9 @@ on:
workflow_dispatch:
inputs:
version_tag:
description: 'Version tag (e.g. 2.6.1)'
description: 'Version tag (e.g. 2.6.2)'
required: true
default: '2.6.1'
default: '2.6.2'
jobs:
build-and-push:

View file

@ -172,9 +172,11 @@ def create_automation(
return {'error': f'Signal cycle detected: {cycle_path}. This would cause an infinite loop.'}, 400
group_name = data.get('group_name') or None
owned_by = data.get('owned_by') or None
auto_id = database.create_automation(
name, trigger_type, trigger_config, action_type, action_config,
profile_id, notify_type, notify_config, then_actions_json, group_name,
owned_by=owned_by,
)
if auto_id is None:
return {'error': 'Failed to create automation'}, 500
@ -217,6 +219,8 @@ def update_automation(
update_fields['notify_config'] = json.dumps(data['notify_config'])
if 'group_name' in data:
update_fields['group_name'] = data['group_name'] or None
if 'owned_by' in data:
update_fields['owned_by'] = data['owned_by'] or None
if not update_fields:
return {'error': 'No fields to update'}, 400
@ -225,6 +229,12 @@ def update_automation(
if cycle_path:
return {'error': f'Signal cycle detected: {cycle_path}. This would cause an infinite loop.'}, 400
# Schedule-shape changes must invalidate the stored next_run so the
# scheduler recomputes it; otherwise restart-survival logic keeps the
# leftover timestamp from the previous interval.
if {'trigger_type', 'trigger_config'} & update_fields.keys():
update_fields['next_run'] = None
success = database.update_automation(automation_id, **update_fields)
if not success:
return {'error': 'Automation not found'}, 404

View file

@ -56,6 +56,14 @@ class AutomationState:
with self.lock:
return self.pipeline_running
def try_start_pipeline(self) -> bool:
"""Atomically mark the shared playlist pipeline as running."""
with self.lock:
if self.pipeline_running:
return False
self.pipeline_running = True
return True
def set_scan_library_id(self, automation_id: Optional[str]) -> None:
with self.lock:
self.scan_library_automation_id = automation_id
@ -144,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

View file

@ -80,9 +80,11 @@ def run_sync_and_wishlist(
if sync_status == 'started':
sync_id = sync_id_for_fn(pl)
sync_poll_start = time.time()
timed_out = True
while time.time() - sync_poll_start < _SYNC_PER_PLAYLIST_TIMEOUT_SECONDS:
if (sync_id in sync_states
and sync_states[sync_id].get('status') in _SYNC_TERMINAL_STATUSES):
timed_out = False
break
time.sleep(2)
elapsed = int(time.time() - sync_poll_start)
@ -94,6 +96,25 @@ def run_sync_and_wishlist(
)
ss = sync_states.get(sync_id, {})
final_status = ss.get('status')
if timed_out:
sync_errors += 1
deps.update_progress(
automation_id,
log_line=f'Sync timed out "{pl_name}" after {_SYNC_PER_PLAYLIST_TIMEOUT_SECONDS}s',
log_type='error',
)
continue
if final_status in ('error', 'failed'):
sync_errors += 1
reason = ss.get('error') or ss.get('reason') or 'background sync failed'
deps.update_progress(
automation_id,
log_line=f'Sync error "{pl_name}": {reason}',
log_type='error',
)
continue
ss_result = ss.get('result', ss.get('progress', {}))
matched = ss_result.get('matched_tracks', 0) if isinstance(ss_result, dict) else 0
total_synced += int(matched) if matched else 0

View file

@ -1,227 +1,27 @@
"""Automation handler: ``playlist_pipeline`` action.
"""Automation adapter for the mirrored playlist pipeline.
Lifted from ``web_server._register_automation_handlers`` (the
``_auto_playlist_pipeline`` closure). Runs the full playlist
lifecycle in a single trigger:
Phase 1: REFRESH -- pull fresh track lists from sources
Phase 2: DISCOVER -- look up official Spotify/iTunes metadata
Phase 3: SYNC -- push the result to the active media server
Phase 4: WISHLIST -- queue any missing tracks for download
Each phase emits its own progress range so the trigger card shows
useful per-phase percentages instead of "loading...". Phase 4 is
optional via ``skip_wishlist`` config.
Composition: this handler invokes ``auto_refresh_mirrored`` and
``auto_sync_playlist`` directly (passing ``_automation_id: None`` so
the sub-handlers don't hijack pipeline progress) instead of going
through the engine keeps the four phases observable as one
trigger from the user's perspective. Pipeline-level guard
(``state.pipeline_running``) prevents overlapping runs.
The actual all-in-one playlist lifecycle lives in
``core.playlists.pipeline`` so it can be reused by non-automation UI actions.
This module only wires automation-specific dependencies and handlers.
"""
from __future__ import annotations
import threading
import time
from typing import Any, Dict
from core.automation.deps import AutomationDeps
from core.automation.handlers._pipeline_shared import run_sync_and_wishlist
from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored
from core.automation.handlers.sync_playlist import auto_sync_playlist
# Per-playlist sync poll cap inside Phase 3.
# Discovery poll cap inside Phase 2.
_DISCOVERY_TIMEOUT_SECONDS = 3600
from core.playlists.pipeline import run_mirrored_playlist_pipeline
def auto_playlist_pipeline(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
"""Run REFRESH → DISCOVER → SYNC → WISHLIST in sequence.
Sets / clears ``deps.state.pipeline_running`` around the whole
run so the registration guard can short-circuit overlapping
triggers.
"""
deps.state.set_pipeline_running(True)
automation_id = config.get('_automation_id')
pipeline_start = time.time()
try:
db = deps.get_database()
playlist_id = config.get('playlist_id')
process_all = config.get('all', False)
skip_wishlist = config.get('skip_wishlist', False)
# Resolve playlists.
if process_all:
playlists = db.get_mirrored_playlists()
elif playlist_id:
p = db.get_mirrored_playlist(int(playlist_id))
playlists = [p] if p else []
else:
deps.state.set_pipeline_running(False)
return {'status': 'error', 'error': 'No playlist specified'}
playlists = [pl for pl in playlists if pl.get('source', '') not in ('file', 'beatport')]
if not playlists:
deps.state.set_pipeline_running(False)
return {'status': 'error', 'error': 'No refreshable playlists found'}
pl_names = ', '.join(p.get('name', '?') for p in playlists[:3])
if len(playlists) > 3:
pl_names += f' (+{len(playlists) - 3} more)'
deps.update_progress(
automation_id,
progress=2,
phase=f'Pipeline: {len(playlists)} playlist(s)',
log_line=f'Starting pipeline for: {pl_names}',
log_type='info',
)
# ── PHASE 1: REFRESH ──────────────────────────────────────────
deps.update_progress(
automation_id,
progress=3,
phase='Phase 1/4: Refreshing playlists...',
log_line='Phase 1: Refresh',
log_type='info',
)
refresh_config = dict(config)
refresh_config['_automation_id'] = None # Don't let sub-handler hijack pipeline progress.
refresh_result = auto_refresh_mirrored(refresh_config, deps)
refreshed = int(refresh_result.get('refreshed', 0))
refresh_errors = int(refresh_result.get('errors', 0))
deps.update_progress(
automation_id,
progress=25,
phase='Phase 1/4: Refresh complete',
log_line=f'Phase 1 done: {refreshed} refreshed, {refresh_errors} errors',
log_type='success' if refresh_errors == 0 else 'warning',
)
# ── PHASE 2: DISCOVER ─────────────────────────────────────────
deps.update_progress(
automation_id,
progress=26,
phase='Phase 2/4: Discovering metadata...',
log_line='Phase 2: Discover',
log_type='info',
)
# Reload playlists (refresh may have updated them).
if process_all:
disc_playlists = db.get_mirrored_playlists()
else:
disc_playlists = [db.get_mirrored_playlist(int(playlist_id))]
disc_playlists = [p for p in disc_playlists if p]
# Run discovery in a thread and wait for it.
disc_done = threading.Event()
def _disc_wrapper(pls):
try:
# The worker updates automation_progress internally,
# but we pass None so it doesn't conflict with our
# pipeline progress.
deps.run_playlist_discovery_worker(pls, automation_id=None)
except Exception as e:
deps.logger.error(f"[Pipeline] Discovery error: {e}")
finally:
disc_done.set()
threading.Thread(
target=_disc_wrapper, args=(disc_playlists,),
daemon=True, name='pipeline-discover',
).start()
# Poll for completion with progress updates.
poll_start = time.time()
while not disc_done.wait(timeout=3):
elapsed = int(time.time() - poll_start)
deps.update_progress(
automation_id,
progress=min(26 + elapsed // 4, 54),
phase=f'Phase 2/4: Discovering... ({elapsed}s)',
)
if elapsed > _DISCOVERY_TIMEOUT_SECONDS:
deps.update_progress(
automation_id,
log_line='Discovery timed out after 1 hour',
log_type='warning',
)
break
deps.update_progress(
automation_id,
progress=55,
phase='Phase 2/4: Discovery complete',
log_line='Phase 2 done: discovery complete',
log_type='success',
)
# ── PHASE 3 + 4: SYNC + WISHLIST (delegated to shared helper) ──
# Each mirrored playlist payload only needs `id` + `name` for
# the helper; `auto_sync_playlist` reads the rest from the
# mirrored DB by id.
sync_summary = run_sync_and_wishlist(
deps,
automation_id,
[pl for pl in playlists if pl.get('id')],
sync_one_fn=lambda pl: auto_sync_playlist(
{'playlist_id': str(pl['id']), '_automation_id': None},
deps,
),
sync_id_for_fn=lambda pl: f"auto_mirror_{pl['id']}",
skip_wishlist=skip_wishlist,
progress_start=56,
progress_end=85,
sync_phase_label='Phase 3/4: Syncing to server...',
sync_phase_start_log='Phase 3: Sync',
wishlist_phase_label='Phase 4/4: Processing wishlist...',
wishlist_phase_start_log='Phase 4: Wishlist',
)
total_synced = sync_summary['synced']
total_skipped = sync_summary['skipped']
sync_errors = sync_summary['errors']
wishlist_queued = sync_summary['wishlist_queued']
# ── COMPLETE ──────────────────────────────────────────────────
duration = int(time.time() - pipeline_start)
deps.update_progress(
automation_id,
status='finished',
progress=100,
phase='Pipeline complete',
log_line=f'Pipeline finished in {duration // 60}m {duration % 60}s',
log_type='success',
)
deps.state.set_pipeline_running(False)
return {
'status': 'completed',
'_manages_own_progress': True,
'playlists_refreshed': str(refreshed),
'tracks_discovered': 'completed',
'tracks_synced': str(total_synced),
'sync_skipped': str(total_skipped),
'wishlist_queued': str(wishlist_queued),
'duration_seconds': str(duration),
}
except Exception as e:
deps.state.set_pipeline_running(False)
deps.update_progress(
automation_id,
status='error',
progress=100,
phase='Pipeline error',
log_line=f'Pipeline failed: {e}',
log_type='error',
)
return {'status': 'error', 'error': str(e), '_manages_own_progress': True}
"""Run REFRESH -> DISCOVER -> SYNC -> WISHLIST for mirrored playlists."""
return run_mirrored_playlist_pipeline(
config,
deps,
refresh_fn=auto_refresh_mirrored,
sync_one_fn=auto_sync_playlist,
sync_and_wishlist_fn=run_sync_and_wishlist,
)

View file

@ -1,23 +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`` URLID 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]:
@ -44,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', '')
@ -55,249 +75,32 @@ 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 = pl.get('description', '')
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}")
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 = pl.get('description', '') or f"https://www.youtube.com/playlist?list={source_id}"
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 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),
owner=pl.get('owner'),
image_url=pl.get('image_url'),
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',
)
refreshed += 1
continue
# 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',
)
# 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(
@ -306,3 +109,204 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[
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-publicauthed
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

View file

@ -617,7 +617,7 @@ class AutomationEngine:
self._progress_finish_fn(automation_id, result)
except Exception as e:
logger.debug("scheduled progress finish (skipped): %s", e)
self._finish_run(auto, automation_id, result, error=None)
self._finish_run(auto, automation_id, result, error=None, retry_delay_seconds=300)
return
# Initialize progress tracking (skip if already done during delay)
@ -664,7 +664,7 @@ class AutomationEngine:
self._finish_run(auto, automation_id, result, error)
def _finish_run(self, auto, automation_id, result, error):
def _finish_run(self, auto, automation_id, result, error, retry_delay_seconds=None):
"""Update DB with run stats and reschedule."""
next_run_str = None
trigger_type = auto.get('trigger_type', '')
@ -672,7 +672,9 @@ class AutomationEngine:
if trigger_type in self._trigger_handlers:
try:
trigger_config = json.loads(auto.get('trigger_config') or '{}')
if trigger_type == 'daily_time':
if retry_delay_seconds:
next_run_str = _utc_after(retry_delay_seconds)
elif trigger_type == 'daily_time':
# Next run is tomorrow at the configured time (compute delay from local time, store as UTC)
time_str = trigger_config.get('time', '00:00')
hour, minute = map(int, time_str.split(':'))
@ -997,6 +999,8 @@ class AutomationEngine:
"""Send message via Telegram Bot API."""
bot_token = config.get('bot_token', '').strip()
chat_id = config.get('chat_id', '').strip()
thread_id = config.get('thread_id', '').strip()
if not bot_token or not chat_id:
raise ValueError("Bot token and chat ID are required for Telegram")
@ -1005,9 +1009,16 @@ class AutomationEngine:
for key, value in variables.items():
message = message.replace('{' + key + '}', value)
payload = {"chat_id": chat_id, "text": message, "parse_mode": "HTML"}
if thread_id:
try:
payload["message_thread_id"] = int(thread_id)
except ValueError:
pass # invalid — fall back to main chat
resp = requests.post(
f'https://api.telegram.org/bot{bot_token}/sendMessage',
json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"},
json=payload,
timeout=10,
)
data = resp.json() if resp.status_code == 200 else {}

View file

@ -0,0 +1,70 @@
"""Helpers for Fix-popup manual match persistence.
When the user manually fixes a mirrored-playlist discovery via the Fix
popup, two questions land at the web_server route layer that are easier
to test in isolation:
1. *Which metadata source did the manual match come from?* the popup
cascade queries the user's primary source first, then Spotify /
Deezer / iTunes / MusicBrainz as fallbacks; each search endpoint
stamps `source` on its rows but the MBID-paste lookup uses a lean
flat shape that doesn't carry it. `derive_manual_match_provider`
collapses the fallback chain into a single string.
2. *Should the discovery layer re-run for this track when the current
active provider differs from the cached one?* re-running silently
overwrites the user's deliberate pick with whatever the auto-search
ranks first, so manual matches are exempt regardless of provider
drift. `is_drifted_for_redo` encapsulates the decision.
"""
from __future__ import annotations
from typing import Any, Dict, Optional
def derive_manual_match_provider(
payload_track: Dict[str, Any],
active_provider: Optional[str],
) -> str:
"""Return the provider string to stamp on a manually-fixed match.
Resolution order:
1. ``payload_track['source']`` every *_search_tracks endpoint
sets this; the MBID-paste path doesn't.
2. ``active_provider`` what the user has configured as their
primary discovery source.
3. ``'spotify'`` last-ditch default matching the historic
hardcode (so behaviour is identical when both upstream
signals are absent).
"""
if not isinstance(payload_track, dict):
payload_track = {}
source = payload_track.get('source')
if source:
return source
if active_provider:
return active_provider
return 'spotify'
def is_drifted_for_redo(
extra_data: Optional[Dict[str, Any]],
active_provider: Optional[str],
) -> bool:
"""Return True when a cached discovery entry should be treated as
stale because the user's active provider has changed since it was
cached AND the entry isn't a manual match.
Manual matches are *always* considered fresh: re-running discovery
against the current source would overwrite the user's deliberate
pick with whatever auto-search ranks first. The first Playlist
Pipeline run after a manual fix used to clobber it for exactly
this reason the check lives here now so it's pinned by tests.
"""
if not isinstance(extra_data, dict):
return False
if extra_data.get('manual_match'):
return False
cached_provider = extra_data.get('provider', 'spotify')
return cached_provider != active_provider

134
core/discovery/matching.py Normal file
View file

@ -0,0 +1,134 @@
"""Pure helper for matching raw MusicBrainz-metadata tracks against
Spotify / iTunes.
Used by the PlaylistSource adapters whose ``get_playlist`` returns
tracks with ``needs_discovery=True`` (ListenBrainz, Last.fm radio).
Phase 1b ships Strategy 1 only (matching-engine queries search
score pick best 0.9). The richer multi-strategy +
discovery-cache flow stays in
``core.discovery.listenbrainz.run_listenbrainz_discovery_worker``
for the Discover-page state-machine UI; this helper is the slimmer
version used by the auto-refresh pipeline.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional
logger = logging.getLogger(__name__)
@dataclass
class MBMatchDeps:
"""Bundle of primitives the matcher needs.
Wired up at bootstrap. Tests pass stub callables / clients."""
matching_engine: Any
score_candidates: Callable[..., Any]
spotify_client_getter: Callable[[], Any]
itunes_client_getter: Callable[[], Any]
prefer_spotify_getter: Callable[[], bool]
min_confidence: float = 0.9
def match_mb_track(
track: Dict[str, Any], deps: MBMatchDeps
) -> Optional[Dict[str, Any]]:
"""Try to match a single MB-metadata track.
Input shape:
``{'track_name', 'artist_name', 'album_name', 'duration_ms'}``
Returns the matched_data dict (Spotify/iTunes track projection)
or ``None`` when no candidate cleared the confidence threshold.
"""
title = track.get("track_name") or ""
artist = track.get("artist_name") or ""
album = track.get("album_name") or ""
duration_ms = int(track.get("duration_ms") or 0)
if not title or not artist:
return None
spotify_client = deps.spotify_client_getter()
itunes_client = deps.itunes_client_getter()
use_spotify = bool(
deps.prefer_spotify_getter()
and spotify_client is not None
and getattr(spotify_client, "is_spotify_authenticated", lambda: False)()
)
if not use_spotify and itunes_client is None:
return None
# Strategy 1 — matching-engine query generation.
try:
temp_track = type("_TempTrack", (), {
"name": title,
"artists": [artist],
"album": album or None,
})()
queries = deps.matching_engine.generate_download_queries(temp_track)
except Exception as exc:
logger.debug(f"matching_engine query-gen failed: {exc}")
queries = [f"{artist} {title}", title]
best_match: Any = None
best_confidence = 0.0
for query in queries:
try:
if use_spotify:
results = spotify_client.search_tracks(query, limit=10)
else:
results = itunes_client.search_tracks(query, limit=10)
except Exception as exc:
logger.debug(f"search failed for query={query!r}: {exc}")
continue
if not results:
continue
try:
match, confidence, _ = deps.score_candidates(
title, artist, duration_ms, results
)
except Exception as exc:
logger.debug(f"score_candidates failed: {exc}")
continue
if match and confidence > best_confidence and confidence >= deps.min_confidence:
best_match = match
best_confidence = confidence
if best_confidence >= deps.min_confidence:
break
if not best_match:
return None
provider = "spotify" if use_spotify else "itunes"
image_url = getattr(best_match, "image_url", None) or ""
album_data: Dict[str, Any] = {
"name": getattr(best_match, "album", "") or "",
}
if image_url:
album_data["images"] = [{"url": image_url}]
return {
"id": getattr(best_match, "id", "") or "",
"name": getattr(best_match, "name", "") or "",
"artists": list(getattr(best_match, "artists", []) or []),
"album": album_data,
"duration_ms": int(getattr(best_match, "duration_ms", 0) or 0),
"image_url": image_url,
"source": provider,
"_provider": provider,
"_confidence": float(best_confidence),
}
def match_mb_tracks(
tracks: List[Dict[str, Any]], deps: MBMatchDeps
) -> List[Optional[Dict[str, Any]]]:
"""Vectorized variant — runs ``match_mb_track`` per track.
Phase 1b is sequential. If profiling shows it's too slow on big
LB playlists, this becomes the natural spot to thread-pool the
per-track searches."""
return [match_mb_track(t, deps) for t in tracks]

View file

@ -125,6 +125,19 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD
if existing_extra.get('wing_it_fallback'):
# Wing It stub — always re-attempt to find a real match
undiscovered_tracks.append(track)
elif existing_extra.get('manual_match'):
# User explicitly picked this match via the Fix popup.
# Manual fixes are authoritative: they may lack
# track_number / album.id / release_date (the Fix-popup
# save shape is intentionally lean — search-result rows
# don't include track_number, and the MBID-lookup flat
# shape doesn't carry album.id), but re-running discovery
# against the active source would overwrite the user's
# deliberate pick with whatever the auto-search ranks
# first. Skip — pipeline only re-discovers when the user
# has cleared the match.
pl_skipped += 1
total_skipped += 1
else:
# Check if matched_data is complete — old discoveries may be missing
# track_number/release_date due to the Track dataclass stripping them.

View file

@ -55,13 +55,17 @@ class SpotifyPublicDiscoveryDeps:
search_spotify_for_tidal_track: Callable
build_discovery_wing_it_stub: Callable
add_activity_item: Callable
source_label: str = "Spotify Public"
activity_label: str = "Spotify Link"
original_track_key: str = "spotify_public_track"
def run_spotify_public_discovery_worker(url_hash, deps: SpotifyPublicDiscoveryDeps):
"""Background worker for Spotify Public discovery process (Spotify preferred, iTunes fallback)"""
_ew_state = {}
try:
_ew_state = deps.pause_enrichment_workers('Spotify Public discovery')
worker_label = f"{deps.source_label} discovery"
_ew_state = deps.pause_enrichment_workers(worker_label)
state = deps.spotify_public_discovery_states[url_hash]
playlist = state['playlist']
@ -74,7 +78,7 @@ def run_spotify_public_discovery_worker(url_hash, deps: SpotifyPublicDiscoveryDe
if not use_spotify:
itunes_client_instance = deps.get_metadata_fallback_client()
logger.info(f"Starting Spotify Public discovery for: {playlist['name']} (using {discovery_source.upper()})")
logger.info(f"Starting {deps.source_label} discovery for: {playlist['name']} (using {discovery_source.upper()})")
# Store discovery source in state for frontend
state['discovery_source'] = discovery_source
@ -126,7 +130,7 @@ def run_spotify_public_discovery_worker(url_hash, deps: SpotifyPublicDiscoveryDe
cached_album = cached_album.get('name', '')
result = {
'spotify_public_track': {
deps.original_track_key: {
'id': track_id,
'name': track_name,
'artists': track_artists or [],
@ -170,7 +174,7 @@ def run_spotify_public_discovery_worker(url_hash, deps: SpotifyPublicDiscoveryDe
# Create result entry
result = {
'spotify_public_track': {
deps.original_track_key: {
'id': track_id,
'name': track_name,
'artists': track_artists or [],
@ -270,7 +274,7 @@ def run_spotify_public_discovery_worker(url_hash, deps: SpotifyPublicDiscoveryDe
# Auto Wing It fallback for unmatched tracks
if result['status_class'] == 'not-found':
sp_t = result.get('spotify_public_track', {})
sp_t = result.get(deps.original_track_key, {})
stub = deps.build_discovery_wing_it_stub(
sp_t.get('name', ''),
', '.join(sp_t.get('artists', [])),
@ -299,7 +303,7 @@ def run_spotify_public_discovery_worker(url_hash, deps: SpotifyPublicDiscoveryDe
logger.error(f"Error processing track {i+1}: {e}")
# Add error result
result = {
'spotify_public_track': {
deps.original_track_key: {
'name': sp_track.get('name', 'Unknown'),
'artists': sp_track.get('artists', []),
},
@ -324,14 +328,14 @@ def run_spotify_public_discovery_worker(url_hash, deps: SpotifyPublicDiscoveryDe
# Add activity for discovery completion
source_label = discovery_source.upper()
deps.add_activity_item("", f"Spotify Link Discovery Complete ({source_label})", f"'{playlist['name']}' - {successful_discoveries}/{len(tracks)} tracks found", "Now")
deps.add_activity_item("", f"{deps.activity_label} Discovery Complete ({source_label})", f"'{playlist['name']}' - {successful_discoveries}/{len(tracks)} tracks found", "Now")
logger.info(f"Spotify Public discovery complete ({source_label}): {successful_discoveries}/{len(tracks)} tracks found")
logger.info(f"{deps.source_label} discovery complete ({source_label}): {successful_discoveries}/{len(tracks)} tracks found")
except Exception as e:
logger.error(f"Error in Spotify Public discovery worker: {e}")
logger.error(f"Error in {deps.source_label} discovery worker: {e}")
if url_hash in deps.spotify_public_discovery_states:
deps.spotify_public_discovery_states[url_hash]['phase'] = 'error'
deps.spotify_public_discovery_states[url_hash]['status'] = f'error: {str(e)}'
finally:
deps.resume_enrichment_workers(_ew_state, 'Spotify Public discovery')
deps.resume_enrichment_workers(_ew_state, f"{deps.source_label} discovery")

View file

@ -179,6 +179,8 @@ def try_dispatch(
'phase': 'analysis',
'album_bundle_state': 'fallback',
'album_bundle_error': err,
'album_bundle_private_staging': False,
'album_bundle_staging_path': None,
})
return False
logger.error("[Album Bundle] %s flow failed for '%s': %s",

View file

@ -84,6 +84,11 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
if not task:
return False
used_sources = task.get('used_sources', set())
# User-initiated manual picks (candidates modal) bypass quarantine
# gates downstream. The user already accepted the risk by choosing
# the file; we trust their selection over AcoustID disagreement so
# repeated manual picks don't loop back into quarantine.
user_manual_pick = bool(task.get('_user_manual_pick', False))
# Try each candidate until one succeeds (like GUI's fallback logic)
for candidate_index, candidate in enumerate(candidates):
@ -331,6 +336,20 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
"track_info": track_info, # Add track_info for playlist folder mode
"_download_username": username, # Source username for AcoustID skip logic
}
if user_manual_pick:
# The user explicitly picked this candidate via the
# candidates modal — trust their metadata judgement
# over AcoustID disagreement so manual picks don't
# loop back into quarantine. Integrity + bit-depth
# gates still run because those check the new file's
# actual condition, not its identity.
matched_downloads_context[context_key]['_skip_quarantine_check'] = 'acoustid'
matched_downloads_context[context_key]['_user_manual_pick'] = True
logger.info(
"[Context] User manual pick — bypassing AcoustID for "
"task=%s username=%s filename=%s",
task_id, username, os.path.basename(filename),
)
logger.info(f"[Context] Set is_album_download: {is_album_context} (has clean data: {has_clean_spotify_data})")
logger.debug(f"[Debug] Context creation - track_info: {track_info is not None}, playlist_folder_mode: {track_info.get('_playlist_folder_mode', False) if track_info else False}")

View file

@ -356,26 +356,6 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
if force_download_all:
logger.warning(f"[Force Download] Force download mode enabled for batch {batch_id} - treating all tracks as missing")
# Album-bundle gate for torrent / usenet single-source mode.
# See ``core/downloads/album_bundle_dispatch`` for the full
# narrow-gate rationale. Returns True iff the master worker
# should stop (gate fired and failed); False = engaged-and-
# succeeded OR didn't engage, both fall through to per-track.
_bundle_state = _BatchStateAccessImpl()
_album_bundle_source = _resolve_album_bundle_source(deps.config_manager)
if _album_bundle_source and _album_bundle_source != 'soulseek':
if _album_bundle_dispatch.try_dispatch(
batch_id=batch_id,
is_album=batch_is_album,
album_context=batch_album_context,
artist_context=batch_artist_context,
config_get=deps.config_manager.get,
plugin_resolver=deps.download_orchestrator.client,
state=_bundle_state,
source_override=_album_bundle_source,
):
return
# Allow duplicate tracks across albums — when enabled, only skip tracks already
# owned in THIS album, not tracks owned in other albums
allow_duplicates = deps.config_manager.get('wishlist.allow_duplicate_tracks', True)
@ -671,6 +651,25 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
batch_playlist_folder_mode = batch.get('playlist_folder_mode', False)
batch_playlist_name = batch.get('playlist_name', 'Unknown Playlist')
# Album-bundle sources download a whole release into private staging,
# then the normal per-track workers claim those staged files. Run this
# only after analysis has found missing tracks; otherwise an already
# owned album would still trigger a release download.
_bundle_state = _BatchStateAccessImpl()
_album_bundle_source = _resolve_album_bundle_source(deps.config_manager)
if _album_bundle_source and _album_bundle_source != 'soulseek':
if _album_bundle_dispatch.try_dispatch(
batch_id=batch_id,
is_album=batch_is_album,
album_context=batch_album_context,
artist_context=batch_artist_context,
config_get=deps.config_manager.get,
plugin_resolver=deps.download_orchestrator.client,
state=_bundle_state,
source_override=_album_bundle_source,
):
return
# === ALBUM PRE-FLIGHT: Search for complete album folder before track-by-track ===
# Only run pre-flight when Soulseek is the download source (or hybrid with soulseek)
preflight_source = None

View file

@ -78,6 +78,30 @@ def _extract_explicit_track_number(filename: str) -> int:
return 0
def _extract_release_filename_title(stem: str) -> str:
"""Extract the trailing title segment from a release-style filename stem.
Slskd album bundles often arrive untagged with stems like
'Artist - Album - 03 - Title' or '03 - Title'. The full stem is too
noisy to fuzzy-match against a clean Spotify title, so when we
detect a bare track-number segment between ' - ' delimiters we
return the trailing segments as an extra match candidate.
Returns '' when no clear track-number signal is present so we don't
accidentally extract tails from real song titles that legitimately
contain ' - ' (e.g. 'Hold Me - Live').
"""
if not stem:
return ''
parts = [p.strip() for p in stem.split(' - ') if p.strip()]
if len(parts) < 2:
return ''
for i, part in enumerate(parts):
if re.fullmatch(r'\d{1,3}', part) and i < len(parts) - 1:
return ' - '.join(parts[i + 1:]).strip()
return ''
def _staging_title_variants(title: Any, normalize: Callable[[str], str]) -> list[str]:
"""Return conservative title variants for release-file matching.
@ -112,8 +136,10 @@ def _staging_title_variants(title: Any, normalize: Callable[[str], str]) -> list
flags=re.IGNORECASE,
)
release_tail = _extract_release_filename_title(compacted_separators)
variants: list[str] = []
for candidate in (raw, compacted_separators, without_feat, without_bonus):
for candidate in (raw, compacted_separators, without_feat, without_bonus, release_tail):
normalized = normalize(candidate)
if normalized and normalized not in variants:
variants.append(normalized)

View file

@ -476,7 +476,16 @@ def build_single_batch_status(batch_id: str, deps: StatusDeps) -> tuple[Optional
def build_batched_status(requested_batch_ids: list, deps: StatusDeps) -> dict:
"""For /api/download_status/batch. Returns the full response dict (always 200)."""
"""For /api/download_status/batch. Returns the full response dict (always 200).
When a requested batch carries a ``wishlist_run_id`` (Phase 1c.2.1
per-album split), the response merges in every sibling sub-batch
of the same run via ``merge_wishlist_run_status``. The merged view
lands keyed under the originally-requested ``batch_id`` so the
frontend modal (which polls one batch id) sees every sibling's
tasks + progress without needing to know about the split."""
from core.downloads.wishlist_aggregator import merge_wishlist_run_status
live_transfers_lookup = deps.get_cached_transfer_data()
response: dict[str, Any] = {"batches": {}}
@ -489,11 +498,49 @@ def build_batched_status(requested_batch_ids: list, deps: StatusDeps) -> dict:
else:
target_batches = download_batches.copy()
# Pre-index sibling batch ids by wishlist_run_id so the per-
# batch loop below can find them in O(1). Snapshot under the
# held lock; subsequent dict mutations don't matter for this
# build.
run_id_to_batch_ids: dict[str, list[str]] = {}
for bid, batch_row in download_batches.items():
run_id = (batch_row or {}).get('wishlist_run_id') if isinstance(batch_row, dict) else None
if run_id:
run_id_to_batch_ids.setdefault(str(run_id), []).append(bid)
for batch_id, batch in target_batches.items():
try:
response["batches"][batch_id] = build_batch_status_data(
primary_status = build_batch_status_data(
batch_id, batch, live_transfers_lookup, deps,
)
# Wishlist-run merge — kicks in only when this batch
# has a run_id AND at least one sibling exists. Falls
# through to legacy single-batch shape otherwise.
run_id = batch.get('wishlist_run_id') if isinstance(batch, dict) else None
sibling_ids = run_id_to_batch_ids.get(str(run_id), []) if run_id else []
if run_id and len(sibling_ids) > 1:
sibling_statuses = []
for sib_id in sibling_ids:
if sib_id == batch_id:
continue
sib_batch = download_batches.get(sib_id)
if not isinstance(sib_batch, dict):
continue
try:
sibling_statuses.append(
build_batch_status_data(
sib_id, sib_batch, live_transfers_lookup, deps,
)
)
except Exception as sib_err:
logger.warning(
f"[Wishlist Run] Sibling status build failed for {sib_id}: {sib_err}"
)
merged = merge_wishlist_run_status(primary_status, sibling_statuses)
response["batches"][batch_id] = merged
else:
response["batches"][batch_id] = primary_status
except Exception as batch_error:
logger.error(f"Error processing batch {batch_id}: {batch_error}")
response["batches"][batch_id] = {"error": str(batch_error)}

View file

@ -0,0 +1,198 @@
"""Merge sibling download_batches statuses into one view for the
wishlist-run model.
When the wishlist runs are split into per-album sub-batches
(Phase 1c.2.1), the frontend modal polls the ORIGINAL batch id
allocated by ``start_manual_wishlist_download_batch`` /
``process_wishlist_automatically``. That batch id is now just one
sibling among N. Without merging, the modal goes blank after the
first sibling finishes because subsequent siblings live under
fresh batch ids the modal never learned about.
This module is the merge layer: pure function, no IO, no runtime
state. ``build_batched_status`` in ``core/downloads/status.py``
calls into it when a requested batch has ``wishlist_run_id`` set
and at least one sibling exists.
Design notes:
- ``track_index`` re-indexed to a global 0..N-1 across the merged
results so the modal's ``data-track-index`` DOM keys don't
collide between siblings (each sibling locally starts at 0).
Tasks reference their analysis result via track_index, so the
remap is applied to tasks too.
- ``task_id`` is a uuid per task no collision concern across
siblings.
- Phase aggregation surfaces the MOST advanced live phase so the
modal renders task rows the moment any sibling reaches the task
stage. The earlier "least-complete" rule made the modal appear
frozen during parallel album_downloading because the task table
is phase-gated to ``downloading``/``complete``/``error``. Sticky
``error`` so failures don't get hidden by a running sibling.
- ``album_bundle`` is picked from whichever sibling currently has
an active bundle download gives the user a useful progress
bar even when the primary sibling is past its bundle stage.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
_ACTIVE_BUNDLE_STATES = frozenset({
'searching',
'downloading',
'downloading_release',
'staging',
})
def _aggregate_phases(phases: List[str]) -> str:
"""Pick the merged phase for a multi-sibling wishlist run.
Surfaces the MOST advanced live phase across the run so the
modal renders task progress the moment any sibling reaches
the task stage. Earlier ("least complete") aggregation hid
downloading tasks behind the bundle progress UI when any
sibling was still in ``album_downloading``, so the modal
appeared frozen for the entire duration of the slowest
sibling's bundle phase.
Rules:
- ``error`` is sticky if any sibling errored, surface error
so the user notices the failure even mid-run.
- ``downloading``/``complete`` (the phases that produce a
task table on the modal side) WIN over earlier phases.
Any sibling at this stage means "show tasks now". All-
complete returns ``complete``; mixed complete + downloading
stays ``downloading`` so the modal keeps polling.
- ``album_downloading`` wins over ``analysis`` only when no
sibling has reached the task stage.
- ``analysis`` is the last resort.
"""
phases = [p for p in phases if p]
if not phases:
return 'unknown'
if 'error' in phases:
return 'error'
if all(p == 'complete' for p in phases):
return 'complete'
if any(p in ('downloading', 'complete') for p in phases):
return 'downloading'
if 'album_downloading' in phases:
return 'album_downloading'
if 'analysis' in phases:
return 'analysis'
return phases[0]
def _pick_active_album_bundle(statuses: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Return the album_bundle of whichever sibling is currently
staging or downloading. Falls back to the first non-empty
bundle when nothing is active (so a completed bundle still
shows up vs. a totally empty progress bar)."""
fallback = None
for s in statuses:
bundle = s.get('album_bundle')
if not bundle:
continue
if fallback is None:
fallback = bundle
state = (bundle.get('state') or '').lower()
if state in _ACTIVE_BUNDLE_STATES:
return bundle
return fallback
def merge_wishlist_run_status(
primary: Dict[str, Any],
siblings: List[Dict[str, Any]],
) -> Dict[str, Any]:
"""Return a status dict that merges ``siblings`` into ``primary``.
Empty ``siblings`` is the legacy single-batch case primary
is returned unchanged.
The returned dict has the same shape as a single-batch status
response from ``build_batch_status_data`` so the frontend
modal needs no changes to consume it. Tracks and tasks are
re-indexed globally; phase + progress + active_count
aggregated across the run.
"""
if not siblings:
return primary
all_statuses = [primary] + list(siblings)
# Phase aggregation.
merged_phase = _aggregate_phases([s.get('phase', '') for s in all_statuses])
# Analysis progress — sum across siblings.
total = 0
processed = 0
has_progress = False
for s in all_statuses:
ap = s.get('analysis_progress')
if isinstance(ap, dict):
total += int(ap.get('total') or 0)
processed += int(ap.get('processed') or 0)
has_progress = True
# Analysis results — concat + re-index. Build a (batch_obj_id,
# old_track_index) -> new_track_index map so tasks can be
# re-indexed consistently.
merged_results: List[Dict[str, Any]] = []
track_index_remap: Dict[tuple, int] = {}
next_index = 0
for s in all_statuses:
batch_ref = id(s)
for r in (s.get('analysis_results') or []):
old_idx = int(r.get('track_index') or 0)
track_index_remap[(batch_ref, old_idx)] = next_index
new_r = dict(r)
new_r['track_index'] = next_index
merged_results.append(new_r)
next_index += 1
# Tasks — concat + re-index using the remap above. Tasks
# without a remapped entry keep their original track_index
# (defensive — shouldn't happen if analysis_results is
# consistent with the task list).
merged_tasks: List[Dict[str, Any]] = []
for s in all_statuses:
batch_ref = id(s)
for t in (s.get('tasks') or []):
old_idx = int(t.get('track_index') or 0)
new_t = dict(t)
new_t['track_index'] = track_index_remap.get((batch_ref, old_idx), old_idx)
merged_tasks.append(new_t)
merged_tasks.sort(key=lambda x: x.get('track_index', 0))
# Album bundle — pick the active sibling's, fall back to first
# bundle present, omit if none.
merged_bundle = _pick_active_album_bundle(all_statuses)
# Worker accounting — sum active_count across siblings so the
# modal's overall download progress display reflects total
# in-flight work; max_concurrent stays from primary as
# representative.
active_total = sum(int(s.get('active_count') or 0) for s in all_statuses)
merged = dict(primary) # keeps playlist_id, playlist_name, error, etc.
merged['phase'] = merged_phase
if has_progress:
merged['analysis_progress'] = {'total': total, 'processed': processed}
merged['analysis_results'] = merged_results
if merged_tasks or 'tasks' in primary:
merged['tasks'] = merged_tasks
if merged_bundle:
merged['album_bundle'] = merged_bundle
elif 'album_bundle' in primary:
merged['album_bundle'] = primary['album_bundle']
merged['active_count'] = active_total
return merged
__all__ = ['merge_wishlist_run_status']

View file

@ -19,6 +19,7 @@ from core.wishlist.processing import (
build_wishlist_source_context as _build_wishlist_source_context,
recover_uncaptured_failed_tracks as _recover_uncaptured_failed_tracks,
remove_completed_tracks_from_wishlist as _remove_completed_tracks_from_wishlist,
resolve_wishlist_source_type_for_batch as _resolve_wishlist_source_type_for_batch,
)
from core.wishlist.resolution import (
check_and_remove_from_wishlist as _check_and_remove_from_wishlist,
@ -107,10 +108,11 @@ def _process_failed_tracks_to_wishlist_exact(batch_id):
if permanently_failed_tracks:
try:
wishlist_service = get_wishlist_service()
# Create source_context identical to sync.py
source_context = _build_wishlist_source_context(batch)
batch_source_type = _resolve_wishlist_source_type_for_batch(batch)
# Process each failed track (matching sync.py's loop) with safety limit
max_failed_tracks = min(len(permanently_failed_tracks), 50) # Safety limit
wing_it_skipped = 0
@ -129,10 +131,10 @@ def _process_failed_tracks_to_wishlist_exact(batch_id):
continue
logger.error(f"[Wishlist Processing] Adding track {i+1}/{max_failed_tracks}: {track_name}")
success = wishlist_service.add_failed_track_from_modal(
track_info=failed_track_info,
source_type='playlist',
source_type=batch_source_type,
source_context=source_context,
profile_id=batch.get('profile_id', 1)
)

View file

@ -15,8 +15,32 @@ _TRACK_PATTERNS = (
def extract_track_number_from_filename(filename: str, title: str = None) -> int:
"""Extract track number from a filename. Returns 1 if not found."""
basename = os.path.splitext(os.path.basename(filename))[0].strip()
"""Extract track number from a filename. Returns 1 if not found.
Use ``extract_explicit_track_number`` instead when the caller needs
to distinguish "track 1" from "unknown" staging-file readers in
particular MUST NOT conflate a bare title (no numeric prefix) with
track 1, or every untagged album-bundle file gets imported as
``track_number=1`` and downstream callers can't recover the real
number from authoritative metadata (Spotify track list, etc.).
"""
num = extract_explicit_track_number(filename)
return num if num > 0 else 1
def extract_explicit_track_number(filename: str) -> int:
"""Extract a track number only when the filename visibly carries one.
Returns the parsed track number when the basename starts with a
recognizable numeric prefix (``"01 - Title"``, ``"1-03 Title"``,
``"(01) Title"``, ``"[01] Title"``); returns ``0`` when no such
prefix is present. This is the contract staging readers want
"unknown" must stay unknown so a downstream consumer with better
info (Spotify metadata, MusicBrainz, etc.) can fill it in.
"""
basename = os.path.splitext(os.path.basename(str(filename or "")))[0].strip()
if not basename:
return 0
match = re.match(r"^\d[\-\.](\d{1,2})\s*[\-\.]\s*", basename)
if match:
@ -30,7 +54,7 @@ def extract_track_number_from_filename(filename: str, title: str = None) -> int:
if 1 <= num <= 999:
return num
return 1
return 0
def parse_filename_metadata(filename: str) -> Dict[str, Any]:

View file

@ -7,7 +7,10 @@ import threading
from typing import Any, Dict, Iterable, List, Optional, Tuple
from core.imports.paths import docker_resolve_path
from core.imports.filename import extract_track_number_from_filename
from core.imports.filename import (
extract_explicit_track_number,
extract_track_number_from_filename,
)
from utils.logging_config import get_logger
logger = get_logger("imports.staging")
@ -103,12 +106,19 @@ def read_staging_file_metadata(file_path: str, filename: Optional[str] = None) -
if not albumartist:
albumartist = artist
track_number = extract_track_number_from_filename(filename or file_path)
# Use the strict extractor here: when the filename has no visible
# track-number prefix, return 0 instead of pretending it's track 1.
# Downstream consumers (staging match in core/downloads/staging.py)
# will then fall through to authoritative metadata (track_info from
# the original Spotify / API source) rather than locking the import
# to track_number=1 for every file in the bundle.
track_number = extract_explicit_track_number(filename or file_path)
try:
# Preserve tag-based numbers when present, but still fall back to the filename parser.
tag_track_number = _first_tag("tracknumber", "track_number")
if tag_track_number:
track_number = int(str(tag_track_number).split("/")[0].strip() or track_number)
parsed_tag = int(str(tag_track_number).split("/")[0].strip())
if parsed_tag > 0:
track_number = parsed_tag
except (TypeError, ValueError):
pass

View file

@ -166,6 +166,12 @@ class ListenBrainzManager:
# Skip if track count hasn't changed (playlist content likely the same)
if db_track_count == track_count:
logger.debug(f"Playlist '{title}' unchanged, skipping")
# Even on the skip path, make sure the rolling-series
# mirror placeholder exists — otherwise users whose LB
# cache never has "changed" updates would never see the
# rolling Auto-Sync entries appear.
self._ensure_rolling_series_mirror(cursor, title)
conn.commit()
conn.close()
return "skipped"
@ -209,11 +215,82 @@ class ListenBrainzManager:
if tracks:
self._cache_tracks(playlist_id, playlist_mbid, tracks, cursor)
# Ensure a rolling-series mirror row exists for known LB series
# (Weekly Jams / Weekly Exploration / Top Discoveries / Top
# Missed Recordings). The Auto-Sync sidebar then surfaces the
# rolling entry as schedulable even before the user has
# explicitly discovered any per-period card — first scheduled
# refresh fills tracks via the LB adapter's synthetic-id
# resolution.
self._ensure_rolling_series_mirror(cursor, title)
conn.commit()
conn.close()
return result_type
def _ensure_rolling_mirrors_from_cache(self, cursor):
"""Walk every cached LB playlist row + ensure its rolling
series mirror exists. Catch-all that runs regardless of which
``_update_playlist`` paths fired (skipped vs updated vs new).
Cheap one SELECT + per-row helper call, helper is
idempotent INSERT OR IGNORE."""
try:
cursor.execute(
"""
SELECT DISTINCT title FROM listenbrainz_playlists
WHERE profile_id = ?
""",
(self.profile_id,),
)
titles = [row[0] for row in cursor.fetchall() if row[0]]
for title in titles:
self._ensure_rolling_series_mirror(cursor, title)
except Exception as exc:
logger.debug(f"Bulk rolling-mirror ensure skipped: {exc}")
def _ensure_rolling_series_mirror(self, cursor, playlist_title: str):
"""Upsert a placeholder ``mirrored_playlists`` row for the
rolling series this title belongs to.
Idempotent uses ``INSERT OR IGNORE``, so existing rolling
mirrors (which may already have discovered tracks) are not
touched. No-op for non-series titles (Last.fm radios,
user-created playlists, collaborative playlists)."""
try:
# Defer import to avoid a top-level dependency loop — the
# series detector lives in core.playlists which itself
# transitively imports manager-flavor helpers.
from core.playlists.lb_series import detect_series
match = detect_series(playlist_title or "")
if match is None:
return
cursor.execute(
"""
INSERT OR IGNORE INTO mirrored_playlists
(source, source_playlist_id, name, description, owner, image_url, track_count, profile_id, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
""",
(
match.source_for_mirror,
match.series_id,
match.canonical_name,
"Rolling ListenBrainz series — refresh resolves to the latest period automatically.",
"ListenBrainz",
"",
0,
self.profile_id,
),
)
if cursor.rowcount:
logger.info(
f"Pre-created rolling mirror placeholder '{match.canonical_name}' "
f"(series id: {match.series_id})"
)
except Exception as exc:
logger.debug(f"Rolling-series mirror ensure skipped: {exc}")
def _cache_tracks(self, playlist_id: int, playlist_mbid: str, tracks: List[Dict], cursor):
"""
Cache tracks for a playlist, including fetching cover art URLs in parallel
@ -326,31 +403,131 @@ class ListenBrainzManager:
covers_found = sum(1 for t in track_data_list if t.get('album_cover_url'))
logger.info(f"Fetched {covers_found}/{len(track_data_list)} cover art URLs")
def _cleanup_per_period_series_mirrors(self, cursor):
"""Delete mirrored_playlists rows that belong to a rotating LB
series but were created under the per-period MBID instead of
the new synthetic series id.
Background: pre-Phase-1c.2.1 the auto-mirror hook keyed mirrors
by the per-week (or per-year) MBID, so users accumulated one
mirror per period. The new flow collapses them into a single
rolling mirror per series. This sweeper removes the legacy
per-period rows so the Mirrored / Auto-Sync UIs only show the
consolidated rolling mirror. Idempotent only matches titles
that were once per-period."""
# Each pattern's WHERE clause matches per-period titles
# ("Weekly Jams for X, week of YYYY-MM-DD ...") but NOT the
# canonical rolling-mirror titles ("ListenBrainz Weekly Jams").
per_period_title_patterns = [
('listenbrainz', 'Weekly Jams for %, week of %'),
('listenbrainz', 'Weekly Exploration for %, week of %'),
('listenbrainz', 'Top Discoveries of % for %'),
('listenbrainz', 'Top Missed Recordings of % for %'),
]
try:
total = 0
for source, like in per_period_title_patterns:
cursor.execute(
"""
SELECT id FROM mirrored_playlists
WHERE source = ? AND name LIKE ?
""",
(source, like),
)
mirror_ids = [row[0] for row in cursor.fetchall()]
if not mirror_ids:
continue
ph = ','.join('?' * len(mirror_ids))
cursor.execute(
f"DELETE FROM mirrored_playlist_tracks WHERE playlist_id IN ({ph})",
mirror_ids,
)
cursor.execute(
f"DELETE FROM mirrored_playlists WHERE id IN ({ph})",
mirror_ids,
)
total += len(mirror_ids)
if total:
logger.info(
f"Removed {total} legacy per-period LB series mirrors "
"(consolidated into rolling series mirrors)"
)
except Exception as exc:
logger.debug(f"Per-period series mirror cleanup skipped: {exc}")
def _retag_misrouted_lastfm_radio_mirrors(self, cursor):
"""Re-tag mirrored_playlists rows that should be 'lastfm' but
were inserted as 'listenbrainz'.
Backfill for the Phase 1c.1 bug where the auto-mirror helper
hardcoded ``source='listenbrainz'`` regardless of playlist
origin. Last.fm Radio playlists carry a consistent
"Last.fm Radio: <seed>" title prefix from
``save_lastfm_radio_playlist``, so any mirror row matching
that prefix should sit under the Last.fm group instead of
the ListenBrainz one. Idempotent only updates rows that
are still misrouted."""
try:
cursor.execute(
"""
UPDATE mirrored_playlists
SET source = 'lastfm'
WHERE source = 'listenbrainz'
AND name LIKE 'Last.fm Radio:%'
"""
)
if cursor.rowcount:
logger.info(
f"Re-tagged {cursor.rowcount} Last.fm Radio mirror rows "
"from source='listenbrainz' to source='lastfm'"
)
except Exception as exc:
logger.debug(f"Last.fm radio mirror retag skipped: {exc}")
def _cleanup_old_playlists(self):
"""Remove old playlists, keeping only the 25 most recent per type"""
conn = self._get_db_connection()
cursor = conn.cursor()
# For each playlist type, keep only the N most recent
# lastfm_radio keeps fewer since they're auto-regenerated weekly
# One-shot backfill for legacy misrouting (see method docstring).
self._retag_misrouted_lastfm_radio_mirrors(cursor)
# Consolidate legacy per-week / per-year LB series mirrors into
# the new rolling series mirrors (Phase 1c.2.1).
self._cleanup_per_period_series_mirrors(cursor)
# Safety net: ensure rolling mirror placeholders exist for every
# series with at least one cached LB playlist row. Catches the
# case where every ``_update_playlist`` call took the "skipped"
# short-circuit (unchanged track count) and so the ensure-hook
# in the per-playlist path never fired on first run after the
# rolling feature shipped.
self._ensure_rolling_mirrors_from_cache(cursor)
# For each playlist type, keep only the N most recent.
# Last.fm radios are per-seed-track snapshots that don't update
# on the Last.fm side — capping the cache (and via the cascade
# below, the matching mirror rows) keeps the Mirrored tab from
# accumulating one row per random seed track the user ever
# picked. 10 is the user-facing limit.
playlist_type_limits = {
'created_for': 25,
'user': 25,
'collaborative': 25,
'lastfm_radio': 5,
'lastfm_radio': 10,
}
for playlist_type, keep_count in playlist_type_limits.items():
try:
# Get IDs of playlists to delete (all except keep_count most recent)
cursor.execute("""
SELECT id FROM listenbrainz_playlists
SELECT id, playlist_mbid FROM listenbrainz_playlists
WHERE playlist_type = ? AND profile_id = ?
ORDER BY last_updated DESC
LIMIT -1 OFFSET ?
""", (playlist_type, self.profile_id, keep_count))
old_playlist_ids = [row[0] for row in cursor.fetchall()]
stale_rows = cursor.fetchall()
old_playlist_ids = [row[0] for row in stale_rows]
old_mbids = [row[1] for row in stale_rows if row[1]]
if old_playlist_ids:
# Delete tracks for old playlists
@ -362,12 +539,66 @@ class ListenBrainzManager:
logger.info(f"Removed {len(old_playlist_ids)} old {playlist_type} playlists")
# Cascade delete: matching mirrored_playlists rows go too.
# LB Weekly Jams / Weekly Exploration get new MBIDs every
# week — without this, the user accumulates dead mirror
# rows that point at LB playlists the cache already pruned.
# Downloaded tracks stay in the library; only the mirror
# row + its track refs are removed.
if old_mbids:
mirror_source = (
'lastfm' if playlist_type == 'lastfm_radio' else 'listenbrainz'
)
self._cascade_delete_mirrored_for_mbids(cursor, old_mbids, mirror_source)
except Exception as e:
logger.error(f"Error cleaning up {playlist_type} playlists: {e}")
conn.commit()
conn.close()
def _cascade_delete_mirrored_for_mbids(self, cursor, mbids, source):
"""Delete mirrored_playlists rows whose source_playlist_id matches
any of ``mbids`` for this profile + source.
Runs on the same cursor as the caller so the cleanup lands in
the same transaction. Silent on failure (cleanup is best-effort
losing the cache-prune-mirror link in rare edge cases is
preferable to crashing the LB update loop)."""
if not mbids:
return
try:
placeholders = ','.join('?' * len(mbids))
# Find matching mirror IDs first so we can delete tracks +
# row in two well-defined steps. ``mirrored_playlist_tracks``
# has no ON DELETE CASCADE constraint enforced unless PRAGMA
# foreign_keys is on, so do it explicitly.
cursor.execute(
f"""
SELECT id FROM mirrored_playlists
WHERE source = ? AND profile_id = ?
AND source_playlist_id IN ({placeholders})
""",
(source, self.profile_id, *mbids),
)
mirror_ids = [row[0] for row in cursor.fetchall()]
if not mirror_ids:
return
mid_ph = ','.join('?' * len(mirror_ids))
cursor.execute(
f"DELETE FROM mirrored_playlist_tracks WHERE playlist_id IN ({mid_ph})",
mirror_ids,
)
cursor.execute(
f"DELETE FROM mirrored_playlists WHERE id IN ({mid_ph})",
mirror_ids,
)
logger.info(
f"Cascade-removed {len(mirror_ids)} stale {source} mirrored playlists"
)
except Exception as exc:
logger.warning(f"Cascade delete of mirrored {source} rows failed: {exc}")
def save_lastfm_radio_playlist(self, seed_track: str, seed_artist: str, similar_tracks: List[Dict]) -> str:
"""
Persist a Last.fm similar-tracks playlist to the DB under playlist_type='lastfm_radio'.
@ -500,6 +731,21 @@ class ListenBrainzManager:
"""Delete a cached playlist and its tracks (CASCADE handles tracks via FK)"""
conn = self._get_db_connection()
cursor = conn.cursor()
# Figure out the source flavor before deleting the row — the
# cascade below needs to know whether the matching mirror is
# ``source='listenbrainz'`` or ``source='lastfm'``.
playlist_type = ''
try:
cursor.execute(
"SELECT playlist_type FROM listenbrainz_playlists WHERE playlist_mbid = ? AND profile_id = ?",
(playlist_mbid, self.profile_id),
)
row = cursor.fetchone()
playlist_type = row[0] if row else ''
except Exception: # noqa: S110 — best-effort lookup, delete proceeds either way
pass
# Delete tracks first (SQLite FK CASCADE requires PRAGMA foreign_keys=ON)
cursor.execute("""
DELETE FROM listenbrainz_tracks WHERE playlist_id IN (
@ -510,6 +756,12 @@ class ListenBrainzManager:
"DELETE FROM listenbrainz_playlists WHERE playlist_mbid = ? AND profile_id = ?",
(playlist_mbid, self.profile_id)
)
# Cascade the delete into mirrored_playlists so the user's
# Mirrored tab doesn't accumulate dead LB rows.
mirror_source = 'lastfm' if playlist_type == 'lastfm_radio' else 'listenbrainz'
self._cascade_delete_mirrored_for_mbids(cursor, [playlist_mbid], mirror_source)
conn.commit()
conn.close()

View file

@ -295,6 +295,7 @@ def rerank_tracks(
*,
expected_title: str,
expected_artist: str,
prefer_known_duration: bool = False,
) -> List[Track]:
"""Return a copy of ``tracks`` sorted by descending relevance
score against the expected title + artist.
@ -304,6 +305,22 @@ def rerank_tracks(
fallback when two candidates score identically the source's
popularity signal is still useful as a tiebreak).
``prefer_known_duration``: when True, recordings with non-zero
``duration_ms`` get a score boost. Used for MusicBrainz, which
often has several recordings per song (single edition, album
edition, compilations, remasters) where some carry length data
and some don't. The boost is set above the album_type weight
spread so length-known recordings can beat length-less
siblings even when the sibling sits on a higher-weighted
album-type real case: Zeds Dead "Coffee Break" canonical
recording lives on the Single release (album_type='single',
weight 0.85) while a length-less sibling lives on an Album
release (weight 1.0). Without the boost, the length-less album
edition wins and the user sees 0:00 instead of 3:04. Cover /
karaoke penalties dominate the boost (their penalty is 0.05)
so a length-known tribute still loses to a length-less
canonical match.
No-op when both ``expected_title`` and ``expected_artist`` are
empty (no signal to rank against return input order)."""
if not expected_title and not expected_artist:
@ -312,6 +329,17 @@ def rerank_tracks(
(score_track(t, expected_title=expected_title, expected_artist=expected_artist), idx, t)
for idx, t in enumerate(tracks)
]
if prefer_known_duration:
# Multiplier sized above the album-type weight spread (album 1.0
# vs single 0.85 = ~18%) so length-known recordings can overcome
# the album-vs-single penalty when scores would otherwise tie on
# title + artist match. Penalty multipliers (cover/karaoke=0.05,
# variant=0.85) still dominate, so this only flips order among
# close-relevance siblings — exactly the MB-duplicate case.
scored = [
(score * 1.25 if (t.duration_ms or 0) > 0 else score, idx, t)
for score, idx, t in scored
]
# Sort by score desc; idx asc as tiebreaker preserves stable order.
scored.sort(key=lambda x: (-x[0], x[1]))
return [t for _score, _idx, t in scored]

View file

@ -757,19 +757,43 @@ class MusicBrainzSearchClient:
`search_tracks`'s structured-query dispatch (`Artist - Track`
splitting, bare-name artist-first browse).
Uses bare-query mode (`strict=False`) diacritic-folded, hits
alias/sortname indexes, no `AND`-clause that kills recall when
either side mis-matches. Score floor lowered to 20 (vs the search
tab's 80) so MB recordings whose title doesn't literally contain
the artist name still enter the candidate pool the endpoint's
`rerank_tracks` pass then sorts by artist-match relevance. Without
this, queries like `Army of Me` + `Bjork` only surface covers
(score 73-100) and miss Björk's canonical recording (score 28).
When both fields are present: strict-first, bare-as-fallback. The
strict pass builds a field-scoped Lucene query (`recording:"<t>"
AND artist:"<a>"`) which anchors the artist and prunes title-
collision covers fixes the "Coffee Break" + "Zeds Dead" case
where MB's title-text-biased scorer surfaced Emapea / Vidalias /
West One Orchestra ahead of the canonical Zeds Dead recording.
`min_score=0` on strict because the field-scoped query is itself
precise, and the endpoint's `rerank_tracks` pass does the final
ordering. Bare query runs only when strict returns nothing
catches diacritic / alias mismatches (`Bjork` query vs canonical
`Björk` artist) where strict phrase match never hits.
When only one field is present: bare-query mode directly same
recall-over-precision tradeoff the old single-path took.
Callers wanting MB-specific length-preference ordering (multi-
edition recordings where some lack length data) should pass
``prefer_known_duration=True`` to ``rerank_tracks`` downstream
a stable sort here would just be re-sorted away by the rerank
pass anyway.
"""
if not track and not artist:
return []
return self._search_tracks_text(track, artist or None, limit,
strict=False, min_score=20)
if track and artist:
results = self._search_tracks_text(
track, artist, limit, strict=True, min_score=0
)
if not results:
results = self._search_tracks_text(
track, artist, limit, strict=False, min_score=20
)
return results
return self._search_tracks_text(
track, artist or None, limit, strict=False, min_score=20
)
def _pick_representative_release(self, releases: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Pick the best release out of a release-group's editions.

View file

@ -67,6 +67,7 @@ class NavidromeAlbum:
self.year = navidrome_data.get('year')
self.addedAt = self._parse_date(navidrome_data.get('created'))
self._artist_id = navidrome_data.get('artistId', '')
self.thumb = self._get_album_image_url()
def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
if not date_str:
@ -76,6 +77,18 @@ class NavidromeAlbum:
except:
return None
def _get_album_image_url(self) -> Optional[str]:
"""Generate a Subsonic getCoverArt URL for this album.
Navidrome exposes the stable artwork key as ``coverArt``. Falling
back to the album ID keeps compatibility with older responses while
ensuring library refreshes do not mark albums as artless.
"""
cover_id = self._data.get('coverArt') or self.ratingKey
if not cover_id:
return None
return f"/rest/getCoverArt?id={cover_id}"
def artist(self) -> Optional[NavidromeArtist]:
"""Get the album artist"""
if self._artist_id:
@ -1242,4 +1255,4 @@ class NavidromeClient(MediaServerClient):
except Exception as e:
logger.error(f"Error searching for tracks: {e}")
return []
return []

125
core/playlists/lb_series.py Normal file
View file

@ -0,0 +1,125 @@
"""ListenBrainz series detection for rolling mirrored playlists.
ListenBrainz publishes a few playlist families that get a brand new
MBID every period (week or year) e.g. "Weekly Jams for Nezreka,
week of 2026-05-25 Mon" gets a fresh row each Monday, the previous
Monday's row rotates out of the cache after ~25 weeks. Auto-syncing
the per-period MBID is useless because the underlying ListenBrainz
playlist never updates only the new period gets new tracks.
This module lets the auto-mirror code collapse those families into
a single rolling mirror per series. The mirror's
``source_playlist_id`` is a synthetic identifier (e.g.
``lb_weekly_jams_Nezreka``) instead of the per-period MBID, and the
refresh path resolves the synthetic id back to the latest period's
cached playlist at refresh time.
One-off playlists (user-created, collaborative, Last.fm radios) are
NOT collapsed they have stable identifiers in their own right.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import List, Optional
@dataclass(frozen=True)
class SeriesMatch:
"""A playlist whose title matches one of the rotating series."""
series_id: str # synthetic id, e.g. "lb_weekly_jams_Nezreka"
canonical_name: str # display name for the rolling mirror
source_for_mirror: str # "listenbrainz" or "lastfm"
title_pattern: str # SQL LIKE pattern for finding members
# (e.g. "Weekly Jams for Nezreka, week of %")
# Each series is identified by a regex + a template for the
# canonical mirror name + the source field the resulting mirror
# should sit under. ``user`` is the ListenBrainz username.
_SERIES_PATTERNS = [
{
"regex": re.compile(r"^Weekly Jams for (?P<user>.+?), week of "),
"series_format": "lb_weekly_jams_{user}",
"canonical_name": "ListenBrainz Weekly Jams",
"source": "listenbrainz",
"like_format": "Weekly Jams for {user}, week of %",
},
{
"regex": re.compile(r"^Weekly Exploration for (?P<user>.+?), week of "),
"series_format": "lb_weekly_exploration_{user}",
"canonical_name": "ListenBrainz Weekly Exploration",
"source": "listenbrainz",
"like_format": "Weekly Exploration for {user}, week of %",
},
{
"regex": re.compile(r"^Top Discoveries of (?P<year>\d{4}) for (?P<user>.+)$"),
"series_format": "lb_top_discoveries_{user}",
"canonical_name": "ListenBrainz Top Discoveries (latest year)",
"source": "listenbrainz",
# ``$`` end-anchor on the year means trailing whitespace would
# break the LIKE — but ListenBrainz titles don't have trailing
# whitespace; the % covers the year position.
"like_format": "Top Discoveries of % for {user}",
},
{
"regex": re.compile(r"^Top Missed Recordings of (?P<year>\d{4}) for (?P<user>.+)$"),
"series_format": "lb_top_missed_{user}",
"canonical_name": "ListenBrainz Top Missed Recordings (latest year)",
"source": "listenbrainz",
"like_format": "Top Missed Recordings of % for {user}",
},
]
def detect_series(title: str) -> Optional[SeriesMatch]:
"""Return a ``SeriesMatch`` if ``title`` belongs to a known series,
else ``None``.
``title`` is the raw playlist title as stored on the LB cache row
(e.g. ``"Weekly Jams for Nezreka, week of 2026-05-25 Mon"``).
"""
if not title:
return None
for spec in _SERIES_PATTERNS:
m = spec["regex"].match(title)
if not m:
continue
groups = m.groupdict()
# The pattern only ever captures ``user`` (and optionally
# ``year``); ``series_format`` / ``like_format`` reference
# ``user`` so both interpolate cleanly with .format(**groups).
return SeriesMatch(
series_id=spec["series_format"].format(**groups),
canonical_name=spec["canonical_name"],
source_for_mirror=spec["source"],
title_pattern=spec["like_format"].format(**groups),
)
return None
def list_series_synthetic_ids() -> List[str]:
"""Return all known series-id PREFIXES (e.g. ``lb_weekly_jams_``).
Used by callers (e.g. the LB adapter's refresh path) to tell
whether a ``source_playlist_id`` is a synthetic series id and
needs special resolution."""
return [
spec["series_format"].format(user="").rstrip("_") + "_"
for spec in _SERIES_PATTERNS
]
def is_series_synthetic_id(source_playlist_id: str) -> bool:
"""Cheap check: is the value one of our synthetic series ids?
All series ids start with ``lb_`` and contain a recognizable
series tag. MusicBrainz MBIDs are 8-4-4-4-12 hex with dashes; no
overlap risk."""
if not source_playlist_id or not source_playlist_id.startswith("lb_"):
return False
return any(
source_playlist_id.startswith(pref) for pref in list_series_synthetic_ids()
)

371
core/playlists/pipeline.py Normal file
View file

@ -0,0 +1,371 @@
"""Mirrored playlist lifecycle pipeline.
This module is the playlist-domain home for the all-in-one mirrored
playlist pipeline:
refresh source -> discover metadata -> sync to server -> process wishlist
Automation remains one caller, but the orchestration itself lives here so a
future playlist-card "Run Pipeline" button can call the same command.
"""
from __future__ import annotations
import json
import threading
import time
from datetime import datetime, timezone
from typing import Any, Callable, Dict, List
DISCOVERY_TIMEOUT_SECONDS = 3600
RefreshFn = Callable[[Dict[str, Any], Any], Dict[str, Any]]
SyncOneFn = Callable[[Dict[str, Any], Any], Dict[str, Any]]
SyncAndWishlistFn = Callable[..., Dict[str, int]]
def run_mirrored_playlist_pipeline(
config: Dict[str, Any],
deps: Any,
*,
refresh_fn: RefreshFn,
sync_one_fn: SyncOneFn,
sync_and_wishlist_fn: SyncAndWishlistFn,
) -> Dict[str, Any]:
"""Run REFRESH -> DISCOVER -> SYNC -> WISHLIST in sequence.
``deps`` intentionally uses duck typing. Today it is ``AutomationDeps``;
a future web/UI runner can provide the same small surface without becoming
an automation.
"""
if hasattr(deps.state, 'try_start_pipeline'):
if not deps.state.try_start_pipeline():
return {
'status': 'skipped',
'reason': 'playlist_pipeline is already running',
'_manages_own_progress': True,
}
else:
deps.state.set_pipeline_running(True)
automation_id = config.get('_automation_id')
trigger_source = config.get('_trigger_source') or (
'manual' if str(automation_id or '').startswith('mirrored_') else 'automation'
)
pipeline_start = time.time()
history_playlists: List[Dict[str, Any]] = []
before_snapshots: Dict[int, Dict[str, Any]] = {}
try:
db = deps.get_database()
playlist_id = config.get('playlist_id')
process_all = config.get('all', False)
skip_wishlist = config.get('skip_wishlist', False)
playlists = _resolve_pipeline_playlists(db, playlist_id, process_all)
if playlists is None:
deps.state.set_pipeline_running(False)
return {'status': 'error', 'error': 'No playlist specified'}
playlists = _filter_refreshable_playlists(playlists)
if not playlists:
deps.state.set_pipeline_running(False)
return {'status': 'error', 'error': 'No refreshable playlists found'}
history_playlists = list(playlists)
before_snapshots = {
int(pl['id']): _playlist_history_snapshot(db, pl)
for pl in history_playlists
if pl.get('id')
}
deps.update_progress(
automation_id,
progress=2,
phase=f'Pipeline: {len(playlists)} playlist(s)',
log_line=f'Starting pipeline for: {_summarize_playlist_names(playlists)}',
log_type='info',
)
refreshed, refresh_errors = _run_refresh_phase(
config,
deps,
automation_id,
refresh_fn=refresh_fn,
)
_run_discovery_phase(
deps,
automation_id,
db=db,
playlist_id=playlist_id,
process_all=process_all,
)
sync_summary = sync_and_wishlist_fn(
deps,
automation_id,
[pl for pl in playlists if pl.get('id')],
sync_one_fn=lambda pl: sync_one_fn(
{'playlist_id': str(pl['id']), '_automation_id': None},
deps,
),
sync_id_for_fn=lambda pl: f"auto_mirror_{pl['id']}",
skip_wishlist=skip_wishlist,
progress_start=56,
progress_end=85,
sync_phase_label='Phase 3/4: Syncing to server...',
sync_phase_start_log='Phase 3: Sync',
wishlist_phase_label='Phase 4/4: Processing wishlist...',
wishlist_phase_start_log='Phase 4: Wishlist',
)
duration = int(time.time() - pipeline_start)
deps.update_progress(
automation_id,
status='finished',
progress=100,
phase='Pipeline complete',
log_line=f'Pipeline finished in {duration // 60}m {duration % 60}s',
log_type='success',
)
result = {
'status': 'completed',
'_manages_own_progress': True,
'playlists_refreshed': str(refreshed),
'tracks_discovered': 'completed',
'tracks_synced': str(sync_summary['synced']),
'sync_skipped': str(sync_summary['skipped']),
'wishlist_queued': str(sync_summary['wishlist_queued']),
'duration_seconds': str(duration),
}
try:
_record_playlist_pipeline_history(
db,
history_playlists,
before_snapshots,
result,
status='completed',
started_at=pipeline_start,
finished_at=time.time(),
trigger_source=trigger_source,
)
except Exception as history_error: # noqa: BLE001 - history should never fail a successful pipeline
deps.logger.debug(f"[Pipeline] History recording failed: {history_error}")
deps.state.set_pipeline_running(False)
return result
except Exception as e: # noqa: BLE001 - pipeline callers should receive status dicts
deps.state.set_pipeline_running(False)
try:
if history_playlists:
_record_playlist_pipeline_history(
db,
history_playlists,
before_snapshots,
{'status': 'error', 'error': str(e), '_manages_own_progress': True},
status='error',
started_at=pipeline_start,
finished_at=time.time(),
trigger_source=trigger_source,
)
except Exception as history_error: # noqa: BLE001 - history should never mask pipeline errors
deps.logger.debug(f"[Pipeline] History recording failed after error: {history_error}")
deps.update_progress(
automation_id,
status='error',
progress=100,
phase='Pipeline error',
log_line=f'Pipeline failed: {e}',
log_type='error',
)
return {'status': 'error', 'error': str(e), '_manages_own_progress': True}
def _pipeline_history_timestamp(ts: float) -> str:
return datetime.fromtimestamp(ts, timezone.utc).isoformat()
def _playlist_history_snapshot(db: Any, playlist: Dict[str, Any]) -> Dict[str, Any]:
playlist_id = int(playlist['id'])
current = db.get_mirrored_playlist(playlist_id) or playlist
counts = db.get_mirrored_playlist_status_counts(playlist_id)
return {
'playlist_id': playlist_id,
'name': current.get('name') or playlist.get('name') or '',
'source': current.get('source') or playlist.get('source') or '',
'track_count': int(counts.get('total') or current.get('track_count') or 0),
'discovered_count': int(counts.get('discovered') or 0),
'wishlisted_count': int(counts.get('wishlisted') or 0),
'in_library_count': int(counts.get('in_library') or 0),
}
def _playlist_history_summary(before: Dict[str, Any], after: Dict[str, Any], status: str) -> str:
before_tracks = int(before.get('track_count') or 0)
after_tracks = int(after.get('track_count') or 0)
track_delta = after_tracks - before_tracks
before_discovered = int(before.get('discovered_count') or 0)
after_discovered = int(after.get('discovered_count') or 0)
discovered_delta = after_discovered - before_discovered
parts = [status.capitalize()]
parts.append(f"{before_tracks} -> {after_tracks} tracks")
if track_delta:
parts.append(f"{track_delta:+d} tracks")
if discovered_delta:
parts.append(f"{discovered_delta:+d} discovered")
return ' | '.join(parts)
def _record_playlist_pipeline_history(
db: Any,
playlists: List[Dict[str, Any]],
before_snapshots: Dict[int, Dict[str, Any]],
result: Dict[str, Any],
*,
status: str,
started_at: float,
finished_at: float,
trigger_source: str,
) -> None:
if not hasattr(db, 'insert_playlist_pipeline_run_history'):
return
duration = max(0, finished_at - started_at)
for playlist in playlists:
if not playlist.get('id'):
continue
playlist_id = int(playlist['id'])
before = before_snapshots.get(playlist_id, {})
after = _playlist_history_snapshot(db, playlist)
db.insert_playlist_pipeline_run_history(
playlist_id=playlist_id,
playlist_name=after.get('name') or playlist.get('name') or '',
source=after.get('source') or playlist.get('source') or '',
profile_id=int(playlist.get('profile_id') or 1),
trigger_source=trigger_source,
started_at=_pipeline_history_timestamp(started_at),
finished_at=_pipeline_history_timestamp(finished_at),
duration_seconds=duration,
status=status,
summary=_playlist_history_summary(before, after, status),
before_json=json.dumps(before),
after_json=json.dumps(after),
result_json=json.dumps(result),
log_lines=None,
)
def _resolve_pipeline_playlists(db: Any, playlist_id: Any, process_all: bool) -> List[Dict[str, Any]] | None:
if process_all:
return db.get_mirrored_playlists()
if playlist_id:
playlist = db.get_mirrored_playlist(int(playlist_id))
return [playlist] if playlist else []
return None
def _filter_refreshable_playlists(playlists: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
return [pl for pl in playlists if pl.get('source', '') not in ('file', 'beatport')]
def _summarize_playlist_names(playlists: List[Dict[str, Any]]) -> str:
pl_names = ', '.join(p.get('name', '?') for p in playlists[:3])
if len(playlists) > 3:
pl_names += f' (+{len(playlists) - 3} more)'
return pl_names
def _run_refresh_phase(
config: Dict[str, Any],
deps: Any,
automation_id: Any,
*,
refresh_fn: RefreshFn,
) -> tuple[int, int]:
deps.update_progress(
automation_id,
progress=3,
phase='Phase 1/4: Refreshing playlists...',
log_line='Phase 1: Refresh',
log_type='info',
)
refresh_config = dict(config)
refresh_config['_automation_id'] = None
refresh_result = refresh_fn(refresh_config, deps)
refreshed = int(refresh_result.get('refreshed', 0))
refresh_errors = int(refresh_result.get('errors', 0))
deps.update_progress(
automation_id,
progress=25,
phase='Phase 1/4: Refresh complete',
log_line=f'Phase 1 done: {refreshed} refreshed, {refresh_errors} errors',
log_type='success' if refresh_errors == 0 else 'warning',
)
return refreshed, refresh_errors
def _run_discovery_phase(
deps: Any,
automation_id: Any,
*,
db: Any,
playlist_id: Any,
process_all: bool,
) -> None:
deps.update_progress(
automation_id,
progress=26,
phase='Phase 2/4: Discovering metadata...',
log_line='Phase 2: Discover',
log_type='info',
)
if process_all:
disc_playlists = db.get_mirrored_playlists()
else:
disc_playlists = [db.get_mirrored_playlist(int(playlist_id))]
disc_playlists = [p for p in disc_playlists if p]
disc_done = threading.Event()
def _disc_wrapper(pls):
try:
deps.run_playlist_discovery_worker(pls, automation_id=None)
except Exception as e: # noqa: BLE001 - logged into pipeline progress
deps.logger.error(f"[Pipeline] Discovery error: {e}")
finally:
disc_done.set()
threading.Thread(
target=_disc_wrapper,
args=(disc_playlists,),
daemon=True,
name='pipeline-discover',
).start()
poll_start = time.time()
while not disc_done.wait(timeout=3):
elapsed = int(time.time() - poll_start)
deps.update_progress(
automation_id,
progress=min(26 + elapsed // 4, 54),
phase=f'Phase 2/4: Discovering... ({elapsed}s)',
)
if elapsed > DISCOVERY_TIMEOUT_SECONDS:
deps.update_progress(
automation_id,
log_line='Discovery timed out after 1 hour',
log_type='warning',
)
break
deps.update_progress(
automation_id,
progress=55,
phase='Phase 2/4: Discovery complete',
log_line='Phase 2 done: discovery complete',
log_type='success',
)

View file

@ -0,0 +1,156 @@
"""Helpers for mirrored-playlist upstream source references.
Mirrored playlist rows have two legacy fields:
- ``source_playlist_id``: the stable lookup key used for uniqueness.
- ``description``: for URL-backed mirrors, the original/canonical URL.
Keeping the normalization here prevents the refresh worker, API endpoint,
and UI repair flow from each inventing a slightly different meaning.
"""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass
from typing import Mapping, Optional
from urllib.parse import parse_qs, urlparse
_SPOTIFY_ID_RE = re.compile(r"^[A-Za-z0-9]{16,32}$")
@dataclass(frozen=True)
class MirroredSourceRef:
source_playlist_id: str
description: Optional[str]
@dataclass(frozen=True)
class MirroredSourceRefView:
source_ref: str
source_ref_kind: str
source_ref_status: str
source_ref_error: Optional[str] = None
def normalize_mirrored_source_ref(
source: str,
source_ref: str,
existing_description: str = "",
) -> MirroredSourceRef:
"""Normalize a user-provided source URL/ID for storage.
URL-backed sources keep a deterministic hash in ``source_playlist_id`` and
store the canonical URL in ``description``. Direct-ID sources store the ID
directly and preserve the existing description unless a source-specific URL
parser says otherwise.
"""
source = (source or "").strip().lower()
source_ref = (source_ref or "").strip()
existing_description = (existing_description or "").strip()
if not source_ref:
raise ValueError("Source link or ID is required")
if source == "spotify_public":
canonical_url = _canonical_spotify_url(source_ref)
return MirroredSourceRef(_short_hash(canonical_url), canonical_url)
if source == "youtube":
canonical_url = _canonical_youtube_url(source_ref)
return MirroredSourceRef(_short_hash(canonical_url), canonical_url)
if source == "deezer" and source_ref.startswith(("http://", "https://")):
from core.deezer_client import DeezerClient
parsed_id = DeezerClient.parse_playlist_url(source_ref)
if not parsed_id:
raise ValueError("Use a valid Deezer playlist URL or playlist ID")
return MirroredSourceRef(str(parsed_id), existing_description or None)
return MirroredSourceRef(source_ref, existing_description or None)
def require_refresh_url(source: str, description: str, playlist_name: str = "") -> str:
"""Return a URL required by hash-backed refresh sources, or raise clearly."""
source = (source or "").strip().lower()
description = (description or "").strip()
if source in {"spotify_public", "youtube"}:
if not description.startswith(("http://", "https://")):
label = f" '{playlist_name}'" if playlist_name else ""
raise ValueError(f"{source} mirror{label} is missing its original source URL")
return description
def describe_mirrored_source_ref(playlist: Mapping[str, object]) -> MirroredSourceRefView:
"""Build a UI/API friendly view of a mirrored playlist's refresh ref."""
source = str(playlist.get("source") or "").strip().lower()
source_playlist_id = str(playlist.get("source_playlist_id") or "").strip()
description = str(playlist.get("description") or "").strip()
name = str(playlist.get("name") or "")
if source in {"spotify_public", "youtube"}:
if description.startswith(("http://", "https://")):
return MirroredSourceRefView(description, "url", "ok")
try:
require_refresh_url(source, description, name)
except ValueError as exc:
return MirroredSourceRefView(
source_playlist_id,
"url",
"missing",
str(exc),
)
return MirroredSourceRefView(source_playlist_id, "id", "ok" if source_playlist_id else "missing")
def _canonical_spotify_url(source_ref: str) -> str:
parsed = _parse_spotify_ref(source_ref)
if parsed:
return f"https://open.spotify.com/{parsed['type']}/{parsed['id']}"
# Repair flow convenience: if the user pastes only a Spotify ID, assume
# playlist. Album URLs still need their URL/URI so the type is explicit.
if _SPOTIFY_ID_RE.match(source_ref):
return f"https://open.spotify.com/playlist/{source_ref}"
raise ValueError("Use a valid open.spotify.com playlist/album URL, Spotify URI, or playlist ID")
def _parse_spotify_ref(source_ref: str) -> Optional[dict]:
uri_match = re.match(r"spotify:(playlist|album):([A-Za-z0-9]+)", source_ref)
if uri_match:
return {"type": uri_match.group(1), "id": uri_match.group(2)}
url_match = re.search(
r"https?://open\.spotify\.com/(?:embed/)?(playlist|album)/([A-Za-z0-9]+)",
source_ref,
)
if url_match:
return {"type": url_match.group(1), "id": url_match.group(2)}
return None
def _canonical_youtube_url(source_ref: str) -> str:
parsed_url = urlparse(source_ref)
playlist_id = ""
if parsed_url.scheme and parsed_url.netloc:
host = parsed_url.netloc.lower()
if not ("youtube.com" in host or "music.youtube.com" in host):
raise ValueError("Use a valid YouTube playlist URL")
playlist_id = parse_qs(parsed_url.query).get("list", [""])[0]
else:
playlist_id = source_ref
if not playlist_id:
raise ValueError("YouTube playlist URL must include a list= playlist id")
return f"https://youtube.com/playlist?list={playlist_id}"
def _short_hash(value: str) -> str:
return hashlib.md5(value.encode()).hexdigest()[:12]

View file

@ -0,0 +1,34 @@
"""Unified playlist-source abstraction.
Phase 0 of the Discover-to-Sync unification. Each external playlist
provider (Spotify, Tidal, Qobuz, YouTube, Spotify public, iTunes link,
ListenBrainz, Last.fm radio, SoulSync Discovery) gets an adapter that
exposes the same ``PlaylistSource`` Protocol, so callers no longer have
to branch on ``source`` string with an if/elif chain.
The existing client modules are left untouched adapters wrap them.
Once every caller speaks the unified interface, the legacy dispatch
sites (``refresh_mirrored.py`` etc.) collapse to a registry lookup.
"""
from core.playlists.sources.base import (
PlaylistDetail,
PlaylistMeta,
PlaylistSource,
NormalizedTrack,
to_mirror_track_dict,
)
from core.playlists.sources.registry import (
PlaylistSourceRegistry,
get_registry,
)
__all__ = [
"PlaylistDetail",
"PlaylistMeta",
"PlaylistSource",
"NormalizedTrack",
"PlaylistSourceRegistry",
"get_registry",
"to_mirror_track_dict",
]

View file

@ -0,0 +1,244 @@
"""PlaylistSource Protocol + normalized data containers.
These dataclasses define the *single* shape every adapter must return.
The legacy backing clients each return slightly different dicts /
dataclasses; the adapter's job is to project those into ``PlaylistMeta``
and ``NormalizedTrack`` so callers don't have to know which source they
got the data from.
Two distinct shapes:
- ``PlaylistMeta``: cheap, lightweight used for "list playlists for a
tab" responses. No tracks.
- ``PlaylistDetail``: meta + full normalized track list. Used after the
user selects a playlist to mirror.
Discovery flag:
- ``NormalizedTrack.needs_discovery`` is True for sources that return
raw metadata only (ListenBrainz, Last.fm radio) the caller must run
the match step before the track is usable in the download pipeline.
Sources that already carry a provider ID (Spotify, Tidal, etc.) set
this to False.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
# Canonical source identifiers used as the key in mirrored_playlists.source
# and in the registry. Centralized so a typo in one place doesn't silently
# create a new "source".
SOURCE_SPOTIFY = "spotify"
SOURCE_SPOTIFY_PUBLIC = "spotify_public"
SOURCE_DEEZER = "deezer"
SOURCE_TIDAL = "tidal"
SOURCE_QOBUZ = "qobuz"
SOURCE_YOUTUBE = "youtube"
SOURCE_ITUNES_LINK = "itunes_link"
SOURCE_LISTENBRAINZ = "listenbrainz"
SOURCE_LASTFM = "lastfm"
SOURCE_SOULSYNC_DISCOVERY = "soulsync_discovery"
ALL_SOURCES = (
SOURCE_SPOTIFY,
SOURCE_SPOTIFY_PUBLIC,
SOURCE_DEEZER,
SOURCE_TIDAL,
SOURCE_QOBUZ,
SOURCE_YOUTUBE,
SOURCE_ITUNES_LINK,
SOURCE_LISTENBRAINZ,
SOURCE_LASTFM,
SOURCE_SOULSYNC_DISCOVERY,
)
@dataclass
class PlaylistMeta:
"""Lightweight playlist descriptor — no tracks."""
source: str
source_playlist_id: str
name: str
track_count: int = 0
owner: Optional[str] = None
description: Optional[str] = None
image_url: Optional[str] = None
# Original URL for URL-backed sources (youtube, spotify_public,
# itunes_link). Used by the refresh path to re-fetch.
source_url: Optional[str] = None
# Free-form per-source passthrough — adapter can stash whatever the
# native API returned for downstream consumers that need richer data
# (e.g. ListenBrainz creator/MBID, Spotify snapshot_id).
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class NormalizedTrack:
"""A single track in normalized shape.
``source_track_id`` is the native ID at the source Spotify track
ID, Tidal ID, YouTube video ID, ListenBrainz recording MBID, etc.
Empty string is allowed for sources that don't have a stable per-
track ID (rare).
"""
position: int
track_name: str
artist_name: str
album_name: Optional[str] = None
duration_ms: int = 0
source_track_id: Optional[str] = None
image_url: Optional[str] = None
# True when the track needs a discovery / match step before it can be
# downloaded (e.g. ListenBrainz returns MB recording metadata only —
# no Spotify/iTunes ID, so the matching engine has to run first).
needs_discovery: bool = False
# Passthrough for source-specific extras (explicit flag, popularity,
# external_urls, recording_mbid, etc.). Adapters decide what to stash.
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class PlaylistDetail:
"""Full playlist payload — meta + tracks."""
meta: PlaylistMeta
tracks: List[NormalizedTrack] = field(default_factory=list)
class PlaylistSource(ABC):
"""Contract every playlist source adapter implements.
Capability flags let callers query the adapter's shape before
invoking it (e.g. ``supports_listing=False`` for URL-only sources
means the Sync page should render a paste-URL input instead of a
playlist picker).
ABC rather than Protocol so we can ship a concrete default for
``discover_tracks`` (sources without provider matching just return
the input list unchanged) only the MB-metadata-only sources
(ListenBrainz, Last.fm radio) need to override.
"""
# Class-level attributes; subclasses pin them to concrete values.
name: str = ""
supports_listing: bool = True
supports_refresh: bool = True
requires_auth: bool = False
@abstractmethod
def is_authenticated(self) -> bool:
"""Return True if the adapter can currently call its backend.
For sources without auth (YouTube, Spotify public, iTunes link),
this is always True. For sources where auth check is expensive,
the adapter may cache (existing clients already do this)."""
@abstractmethod
def list_playlists(self) -> List[PlaylistMeta]:
"""Return all playlists the user has access to.
For ``supports_listing=False`` sources, return ``[]`` and let
the caller use ``get_playlist`` with a URL/ID directly."""
@abstractmethod
def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
"""Fetch full playlist (meta + tracks) by source-native ID.
For URL-backed sources, ``playlist_id`` is the full URL. For ID-
backed sources it's the native ID string. Returns ``None`` if
the playlist isn't reachable (404, auth failure, etc.)."""
@abstractmethod
def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
"""Re-fetch a playlist for the auto-refresh pipeline.
Default behavior is usually identical to ``get_playlist``.
Sources whose refresh has side effects (e.g. ListenBrainz cache
update, SoulSync Discovery regeneration) do real work here."""
def discover_tracks(self, tracks: List[NormalizedTrack]) -> List[NormalizedTrack]:
"""Match raw tracks against a provider (Spotify / iTunes / etc.).
Default no-op: returns ``tracks`` unchanged. Only the MB-
metadata-only sources (ListenBrainz, Last.fm radio) override
this every other adapter already returns tracks with
``needs_discovery=False`` and provider IDs filled in.
Matched tracks should have ``extra['discovered']=True`` +
``extra['matched_data']`` populated so ``to_mirror_track_dict``
produces the canonical ``extra_data`` JSON shape downstream
consumers (mirrored-playlist DB, sync pipeline, wishlist)
already expect. Unmatched tracks should be returned as-is
with ``needs_discovery`` left True so the caller can decide
what to do (mark as wing-it, skip, retry later)."""
return tracks
# ─── 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

View file

@ -0,0 +1,104 @@
"""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,
discover_callable: Optional[Callable[..., Any]] = 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,
discover_callable=discover_callable,
),
)
reg.register(
SOURCE_LASTFM,
lambda: LastFMPlaylistSource(
lastfm_manager_getter or _no_manager,
discover_callable=discover_callable,
),
)
reg.register(
SOURCE_SOULSYNC_DISCOVERY,
lambda: SoulSyncDiscoveryPlaylistSource(
personalized_manager_getter or _no_manager,
profile_id_getter=profile_id_getter,
),
)
return reg

View 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")},
)

View file

@ -0,0 +1,109 @@
"""iTunes / Apple Music link playlist source adapter.
Wraps the iTunes-link parsing logic that currently lives in
``web_server.py`` (commit 718eb0cb). Phase 0 doesn't move it — adapter
takes a parser callable that returns the parsed-playlist dict matching
the ``parse_itunes_link_endpoint`` response shape::
{
'id', 'type', 'name', 'subtitle', 'url', 'url_hash',
'track_count', 'image_url',
'tracks': [{ 'id', 'name', 'artists', 'album', 'duration_ms', ... }],
}
``supports_listing=False`` Apple Music has no "user library" listing,
the user pastes a URL.
"""
from __future__ import annotations
from typing import Any, Callable, List, Optional
from core.playlists.sources.base import (
NormalizedTrack,
PlaylistDetail,
PlaylistMeta,
PlaylistSource,
SOURCE_ITUNES_LINK,
)
class ITunesLinkPlaylistSource(PlaylistSource):
name = SOURCE_ITUNES_LINK
supports_listing = False
supports_refresh = True
requires_auth = False
def __init__(self, parser: Callable[[str], Optional[dict]]):
"""``parser(url)`` returns the parsed playlist dict, or ``None``
if the URL is invalid / unreachable. Injected by ``web_server.py``
at startup, pointing at the existing module-level helpers."""
self._parser = parser
def is_authenticated(self) -> bool:
return True
def list_playlists(self) -> List[PlaylistMeta]:
return []
def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
"""``playlist_id`` is the full Apple Music / iTunes URL."""
data = self._parser(playlist_id)
if not data:
return None
source_url = data.get("url") or playlist_id
tracks_raw = data.get("tracks") or []
meta = PlaylistMeta(
source=self.name,
source_playlist_id=str(data.get("url_hash") or data.get("id") or ""),
name=data.get("name", "Apple Music Link"),
owner=data.get("subtitle"),
image_url=data.get("image_url") or None,
track_count=int(data.get("track_count", len(tracks_raw))),
source_url=source_url,
extra={
"itunes_type": data.get("type"),
"itunes_id": data.get("id"),
},
)
tracks = [self._track_from_itunes(t, idx) for idx, t in enumerate(tracks_raw) if t]
return PlaylistDetail(meta=meta, tracks=tracks)
def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
return self.get_playlist(playlist_id)
# ---- projection helpers ------------------------------------------------
def _track_from_itunes(self, track: dict, position: int) -> NormalizedTrack:
artists = track.get("artists") or []
if artists and isinstance(artists[0], dict):
artist_name = artists[0].get("name", "") or ""
elif artists:
artist_name = str(artists[0])
else:
artist_name = ""
if not artist_name:
artist_name = "Unknown Artist"
album = track.get("album")
album_name: Optional[str] = None
if isinstance(album, dict):
album_name = album.get("name") or None
elif album:
album_name = str(album)
return NormalizedTrack(
position=position,
track_name=track.get("name", "Unknown Track"),
artist_name=artist_name,
album_name=album_name,
duration_ms=int(track.get("duration_ms", 0) or 0),
source_track_id=str(track.get("id", "")),
image_url=track.get("image_url"),
needs_discovery=False,
extra={
"external_urls": track.get("external_urls"),
"preview_url": track.get("preview_url"),
},
)

View file

@ -0,0 +1,134 @@
"""Last.fm radio playlist source adapter.
Last.fm radio playlists are persisted by
``ListenBrainzManager.save_lastfm_radio_playlist`` under
``playlist_type='lastfm_radio'`` in the ``listenbrainz_playlists``
table they share the same storage as ListenBrainz playlists but
originate from Last.fm's similar-tracks API.
Like ListenBrainz, tracks are MB metadata only, so ``needs_discovery``
is True on every track.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional
from core.playlists.sources.base import (
NormalizedTrack,
PlaylistDetail,
PlaylistMeta,
PlaylistSource,
SOURCE_LASTFM,
)
from core.playlists.sources.listenbrainz import (
DiscoverCallable,
ListenBrainzPlaylistSource,
)
LASTFM_PLAYLIST_TYPE = "lastfm_radio"
class LastFMPlaylistSource(PlaylistSource):
name = SOURCE_LASTFM
supports_listing = True
supports_refresh = False # Refresh requires re-running the radio generator
requires_auth = True
def __init__(
self,
manager_getter: Callable[[], Any],
discover_callable: Optional[DiscoverCallable] = None,
):
"""``manager_getter`` returns the profile's ``ListenBrainzManager``
(Last.fm radio playlists share that storage layer).
``discover_callable`` runs matching-engine + provider search;
Last.fm radio tracks are MB-metadata only, so this is needed
for ``discover_tracks`` to do real work."""
self._manager_getter = manager_getter
self._discover_callable = discover_callable
def _manager(self):
return self._manager_getter()
def is_authenticated(self) -> bool:
# Last.fm radio rows exist independently of any auth state —
# they're persisted snapshots. Treat "manager exists" as enough.
return self._manager() is not None
def list_playlists(self) -> List[PlaylistMeta]:
manager = self._manager()
if manager is None:
return []
try:
rows = manager.get_cached_playlists(LASTFM_PLAYLIST_TYPE) or []
except Exception:
rows = []
return [self._meta_from_cache_row(r) for r in rows]
def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
manager = self._manager()
if manager is None:
return None
try:
rows = manager.get_cached_playlists(LASTFM_PLAYLIST_TYPE) or []
except Exception:
rows = []
meta_row = next(
(r for r in rows if str(r.get("playlist_mbid")) == str(playlist_id)),
None,
)
if meta_row is None:
return None
try:
tracks_raw = manager.get_cached_tracks(playlist_id) or []
except Exception:
tracks_raw = []
meta = self._meta_from_cache_row(meta_row)
meta.track_count = len(tracks_raw)
tracks = [self._track_from_cache_row(t, idx) for idx, t in enumerate(tracks_raw)]
return PlaylistDetail(meta=meta, tracks=tracks)
def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
# Regenerating a Last.fm radio playlist needs the seed track +
# the Last.fm client — that lives outside this adapter. Phase 1
# wires up the regeneration; Phase 0 just returns the current
# snapshot.
return self.get_playlist(playlist_id)
# Discovery shares the LB adapter's implementation — same track
# shape (MB metadata), same matching needs.
discover_tracks = ListenBrainzPlaylistSource.discover_tracks
# ---- projection helpers ------------------------------------------------
def _meta_from_cache_row(self, row: Dict[str, Any]) -> PlaylistMeta:
return PlaylistMeta(
source=self.name,
source_playlist_id=str(row.get("playlist_mbid", "")),
name=row.get("title") or "Last.fm Radio",
owner=row.get("creator") or "Last.fm",
track_count=int(row.get("track_count", 0) or 0),
extra={
"annotation": row.get("annotation") or {},
"last_updated": row.get("last_updated"),
},
)
def _track_from_cache_row(self, row: Dict[str, Any], position: int) -> NormalizedTrack:
return NormalizedTrack(
position=position,
track_name=row.get("track_name", "Unknown Track"),
artist_name=row.get("artist_name", "Unknown Artist"),
album_name=row.get("album_name") or None,
duration_ms=int(row.get("duration_ms", 0) or 0),
source_track_id=row.get("recording_mbid") or None,
image_url=row.get("album_cover_url") or None,
needs_discovery=True,
extra={
"recording_mbid": row.get("recording_mbid"),
"release_mbid": row.get("release_mbid"),
},
)

View file

@ -0,0 +1,308 @@
"""ListenBrainz playlist source adapter.
Wraps ``core.listenbrainz_manager.ListenBrainzManager``. ListenBrainz
playlists carry only MusicBrainz recording metadata no Spotify /
iTunes IDs so every track returned by this adapter has
``needs_discovery=True``. Phase 1+ will route those through the
existing ``run_listenbrainz_discovery_worker`` and persist the matched
provider IDs into ``mirrored_playlist_tracks.extra_data``.
Construction takes a manager getter callable because the manager is
profile-scoped (one instance per profile, built from credentials stored
in the DB) there is no process-wide singleton to grab at import time.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional
from core.playlists.sources.base import (
NormalizedTrack,
PlaylistDetail,
PlaylistMeta,
PlaylistSource,
SOURCE_LISTENBRAINZ,
)
# Type alias for the discovery callable: takes a list of MB-shaped
# track dicts, returns a parallel list of matched_data dicts (or None
# when no match). Kept narrow so test stubs are easy.
DiscoverCallable = Callable[[List[Dict[str, Any]]], List[Optional[Dict[str, Any]]]]
class ListenBrainzPlaylistSource(PlaylistSource):
name = SOURCE_LISTENBRAINZ
supports_listing = True
supports_refresh = True
requires_auth = True
# ListenBrainz manager caches three "playlist types" — surface all
# three under this source. The Sync page can group / filter by
# ``meta.extra['playlist_type']`` if it wants per-type sub-tabs.
PLAYLIST_TYPES = ("created_for_user", "user_created", "collaborative")
def __init__(
self,
manager_getter: Callable[[], Any],
discover_callable: Optional[DiscoverCallable] = None,
):
"""``manager_getter`` returns a live ``ListenBrainzManager`` for
the current profile. ``None`` is allowed and means "no LB
configured" — adapter degrades to empty results.
``discover_callable`` runs the actual matching-engine + provider
search. ``None`` means no discovery is wired (Phase 0 default):
``discover_tracks`` returns the input list unchanged."""
self._manager_getter = manager_getter
self._discover_callable = discover_callable
def _manager(self):
return self._manager_getter()
def is_authenticated(self) -> bool:
manager = self._manager()
if manager is None:
return False
client = getattr(manager, "client", None)
if client is None or not hasattr(client, "is_authenticated"):
return False
return bool(client.is_authenticated())
def list_playlists(self) -> List[PlaylistMeta]:
manager = self._manager()
if manager is None:
return []
out: List[PlaylistMeta] = []
for ptype in self.PLAYLIST_TYPES:
try:
rows = manager.get_cached_playlists(ptype) or []
except Exception:
rows = []
for row in rows:
out.append(self._meta_from_cache_row(row, ptype))
return out
def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
"""``playlist_id`` is the ListenBrainz playlist MBID, OR a
synthetic series id (e.g. ``lb_weekly_jams_<user>``) that
resolves to the newest member of a rotating series."""
manager = self._manager()
if manager is None:
return None
# Rolling-series resolution: synthetic ids look up the
# latest matching cache row and continue with that MBID.
from core.playlists.lb_series import is_series_synthetic_id
if is_series_synthetic_id(playlist_id):
resolved_mbid = self._resolve_series_to_latest_mbid(manager, playlist_id)
if not resolved_mbid:
return None
return self._fetch_playlist_by_mbid(manager, resolved_mbid, override_meta_id=playlist_id)
return self._fetch_playlist_by_mbid(manager, playlist_id)
def _fetch_playlist_by_mbid(
self,
manager: Any,
playlist_mbid: str,
override_meta_id: Optional[str] = None,
) -> Optional[PlaylistDetail]:
"""Resolve a real LB playlist MBID into a PlaylistDetail.
``override_meta_id`` lets the rolling-series path keep the
synthetic id on the meta object so the caller can write the
mirror row back under that id."""
ptype = ""
try:
ptype = manager.get_playlist_type(playlist_mbid) or ""
except Exception:
ptype = ""
cached_rows = []
try:
cached_rows = manager.get_cached_playlists(ptype) if ptype else []
except Exception:
cached_rows = []
meta_row = next(
(r for r in cached_rows if str(r.get("playlist_mbid")) == str(playlist_mbid)),
None,
)
try:
tracks_raw = manager.get_cached_tracks(playlist_mbid) or []
except Exception:
tracks_raw = []
if meta_row is None and not tracks_raw:
return None
meta = self._meta_from_cache_row(
meta_row or {"playlist_mbid": playlist_mbid, "track_count": len(tracks_raw)},
ptype or "listenbrainz",
)
if override_meta_id:
meta.source_playlist_id = override_meta_id
meta.track_count = len(tracks_raw)
tracks = [self._track_from_cache_row(t, idx) for idx, t in enumerate(tracks_raw)]
return PlaylistDetail(meta=meta, tracks=tracks)
def _resolve_series_to_latest_mbid(self, manager: Any, series_id: str) -> Optional[str]:
"""Find the newest LB cache row matching a series synthetic id.
Series synthetic ids encode both the series type and the
ListenBrainz username. We query the LB cache (via the
manager's DB connection) for the row whose title matches the
series' LIKE pattern and has the most recent ``last_updated``,
then return that row's MBID for normal fetching downstream."""
try:
# The synthetic id alone doesn't carry the title pattern,
# so we re-derive it from any per-period sibling that's
# already in the cache. Iterate the known series specs and
# ask which one this synthetic id belongs to.
from core.playlists.lb_series import _SERIES_PATTERNS
spec = None
user_token = ""
for entry in _SERIES_PATTERNS:
series_prefix = entry["series_format"].format(user="").rstrip("_") + "_"
if series_id.startswith(series_prefix):
spec = entry
user_token = series_id[len(series_prefix):]
break
if spec is None or not user_token:
return None
like_pattern = spec["like_format"].format(user=user_token)
# Query the LB cache for the newest matching row. The
# manager's connection helper returns a plain sqlite3
# connection — explicit try/finally for close parity with
# the manager's own usage pattern.
conn = manager._get_db_connection()
try:
cur = conn.cursor()
cur.execute(
"""
SELECT playlist_mbid FROM listenbrainz_playlists
WHERE profile_id = ? AND title LIKE ?
ORDER BY last_updated DESC
LIMIT 1
""",
(manager.profile_id, like_pattern),
)
row = cur.fetchone()
finally:
conn.close()
return row[0] if row else None
except Exception:
return None
def discover_tracks(self, tracks: List[NormalizedTrack]) -> List[NormalizedTrack]:
"""Run each MB-metadata track through the matching engine.
Tracks with ``needs_discovery=False`` (e.g. already-matched
survivors of a previous refresh) pass through unchanged.
Matched tracks get ``extra['discovered']=True`` + a
``matched_data`` block so the projection helper can produce
the canonical ``extra_data`` JSON; ``needs_discovery`` flips
to False on them.
Unmatched tracks stay ``needs_discovery=True`` so the caller
can decide how to handle them (wing-it stub, skip, retry)."""
if not tracks or self._discover_callable is None:
return tracks
to_match: List[Dict[str, Any]] = []
match_indices: List[int] = []
for idx, t in enumerate(tracks):
if not t.needs_discovery:
continue
to_match.append({
"track_name": t.track_name,
"artist_name": t.artist_name,
"album_name": t.album_name or "",
"duration_ms": t.duration_ms or 0,
})
match_indices.append(idx)
if not to_match:
return tracks
try:
matched = self._discover_callable(to_match) or []
except Exception:
return tracks
out = list(tracks)
for slot_idx, result in zip(match_indices, matched, strict=False):
if not result:
continue
track = out[slot_idx]
provider = result.pop("_provider", None) or "unknown"
confidence = result.pop("_confidence", None)
new_extra = dict(track.extra or {})
new_extra["discovered"] = True
new_extra["provider"] = provider
if confidence is not None:
new_extra["confidence"] = confidence
new_extra["matched_data"] = result
out[slot_idx] = NormalizedTrack(
position=track.position,
track_name=track.track_name,
artist_name=track.artist_name,
album_name=track.album_name,
duration_ms=track.duration_ms,
source_track_id=result.get("id") or track.source_track_id,
image_url=result.get("image_url") or track.image_url,
needs_discovery=False,
extra=new_extra,
)
return out
def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
"""Trigger a manager-side refresh, then return the new snapshot.
``update_all_playlists`` is the only refresh entry-point on the
manager it re-fetches every cached playlist. That's wasteful
for a single-playlist refresh; Phase 1 should add a targeted
``refresh_playlist(mbid)`` to the manager."""
manager = self._manager()
if manager is None:
return None
try:
manager.update_all_playlists()
except Exception: # noqa: S110 — caller falls back to last cached playlist on refresh failure
pass
return self.get_playlist(playlist_id)
# ---- projection helpers ------------------------------------------------
def _meta_from_cache_row(self, row: Dict[str, Any], playlist_type: str) -> PlaylistMeta:
return PlaylistMeta(
source=self.name,
source_playlist_id=str(row.get("playlist_mbid", "")),
name=row.get("title") or "ListenBrainz Playlist",
owner=row.get("creator") or None,
track_count=int(row.get("track_count", 0) or 0),
extra={
"playlist_type": playlist_type,
"annotation": row.get("annotation") or {},
"last_updated": row.get("last_updated"),
},
)
def _track_from_cache_row(self, row: Dict[str, Any], position: int) -> NormalizedTrack:
return NormalizedTrack(
position=position,
track_name=row.get("track_name", "Unknown Track"),
artist_name=row.get("artist_name", "Unknown Artist"),
album_name=row.get("album_name") or None,
duration_ms=int(row.get("duration_ms", 0) or 0),
source_track_id=row.get("recording_mbid") or None,
image_url=row.get("album_cover_url") or None,
needs_discovery=True,
extra={
"recording_mbid": row.get("recording_mbid"),
"release_mbid": row.get("release_mbid"),
"additional_metadata": row.get("additional_metadata"),
},
)

View file

@ -0,0 +1,94 @@
"""Qobuz playlist source adapter.
Wraps ``core.qobuz_client.QobuzClient``. The client already returns
playlist/track dicts in a normalized Sync-page shape (see
``_normalize_qobuz_playlist`` / ``_normalize_qobuz_track``), so the
adapter is mostly a key remap.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional
from core.playlists.sources.base import (
NormalizedTrack,
PlaylistDetail,
PlaylistMeta,
PlaylistSource,
SOURCE_QOBUZ,
)
class QobuzPlaylistSource(PlaylistSource):
name = SOURCE_QOBUZ
supports_listing = True
supports_refresh = True
requires_auth = True
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
return bool(client.is_authenticated())
def list_playlists(self) -> List[PlaylistMeta]:
client = self._client()
if client is None or not client.is_authenticated():
return []
playlists = client.get_user_playlists() or []
return [self._meta_from_dict(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():
return None
playlist = client.get_playlist(playlist_id)
if not playlist:
return None
meta = self._meta_from_dict(playlist)
tracks_raw = playlist.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_dict(self, p: Dict[str, Any]) -> PlaylistMeta:
return PlaylistMeta(
source=self.name,
source_playlist_id=str(p.get("id", "")),
name=p.get("name", "Qobuz Playlist"),
description=p.get("description") or None,
image_url=p.get("image_url") or None,
track_count=int(p.get("track_count", 0) or 0),
extra={
"public": bool(p.get("public", False)),
"external_urls": p.get("external_urls", {}),
},
)
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", "")),
image_url=t.get("image_url") or None,
needs_discovery=False,
extra={k: v for k, v in t.items() if k not in {
"id", "name", "artists", "album", "duration_ms", "image_url",
}},
)

View file

@ -0,0 +1,77 @@
"""Registry for playlist source adapters.
Adapters are registered as zero-arg factories so we can lazy-construct
them. This matters because some adapters need late-binding to globals
that aren't ready at import time (e.g. the YouTube adapter wraps a
parser defined in ``web_server.py`` importing it eagerly would cause
a circular import).
Usage::
registry = get_registry()
registry.register("spotify", lambda: SpotifyPlaylistSource(...))
source = registry.get_source("spotify")
In Phase 0 the registry is set up but not yet consumed by the dispatch
sites. Phase 1+ wires it in.
"""
from __future__ import annotations
from threading import Lock
from typing import Callable, Dict, List, Optional
from core.playlists.sources.base import PlaylistSource
class PlaylistSourceRegistry:
"""Thread-safe registry mapping source name → cached adapter instance."""
def __init__(self) -> None:
self._factories: Dict[str, Callable[[], PlaylistSource]] = {}
self._instances: Dict[str, PlaylistSource] = {}
self._lock = Lock()
def register(self, name: str, factory: Callable[[], PlaylistSource]) -> None:
"""Register an adapter factory under ``name``.
Re-registering replaces the previous factory and invalidates the
cached instance. Used by tests to swap in stubs."""
with self._lock:
self._factories[name] = factory
self._instances.pop(name, None)
def unregister(self, name: str) -> None:
with self._lock:
self._factories.pop(name, None)
self._instances.pop(name, None)
def get_source(self, name: str) -> Optional[PlaylistSource]:
"""Return the adapter for ``name``, building it on first access."""
with self._lock:
if name in self._instances:
return self._instances[name]
factory = self._factories.get(name)
if factory is None:
return None
instance = factory()
self._instances[name] = instance
return instance
def known_names(self) -> List[str]:
with self._lock:
return sorted(self._factories.keys())
def reset(self) -> None:
"""Drop all registrations + cached instances. Test-only."""
with self._lock:
self._factories.clear()
self._instances.clear()
_default_registry = PlaylistSourceRegistry()
def get_registry() -> PlaylistSourceRegistry:
"""Return the process-wide default registry."""
return _default_registry

View file

@ -0,0 +1,149 @@
"""SoulSync Discovery (personalized playlists) source adapter.
Wraps ``core.personalized.manager.PersonalizedPlaylistManager``. Unlike
ListenBrainz / Last.fm, personalized playlists already carry source
IDs (Spotify / iTunes / Deezer track IDs) they were built from
``discovery_pool`` rows. ``needs_discovery=False`` on every track.
Playlist IDs here are the integer DB row IDs (``personalized_playlists.id``)
converted to strings so the unified interface stays string-keyed. The
adapter parses them back to ints when calling the manager.
"""
from __future__ import annotations
from typing import Any, Callable, List, Optional
from core.playlists.sources.base import (
NormalizedTrack,
PlaylistDetail,
PlaylistMeta,
PlaylistSource,
SOURCE_SOULSYNC_DISCOVERY,
)
class SoulSyncDiscoveryPlaylistSource(PlaylistSource):
name = SOURCE_SOULSYNC_DISCOVERY
supports_listing = True
supports_refresh = True
requires_auth = False
def __init__(
self,
manager_getter: Callable[[], Any],
profile_id_getter: Optional[Callable[[], int]] = None,
):
self._manager_getter = manager_getter
self._profile_id_getter = profile_id_getter or (lambda: 1)
def _manager(self):
return self._manager_getter()
def is_authenticated(self) -> bool:
return self._manager() is not None
def list_playlists(self) -> List[PlaylistMeta]:
manager = self._manager()
if manager is None:
return []
try:
records = manager.list_playlists(profile_id=self._profile_id_getter()) or []
except Exception:
return []
return [self._meta_from_record(r) for r in records]
def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
"""``playlist_id`` is the stringified ``personalized_playlists.id``."""
manager = self._manager()
if manager is None:
return None
try:
row_id = int(playlist_id)
except (TypeError, ValueError):
return None
records = []
try:
records = manager.list_playlists(profile_id=self._profile_id_getter()) or []
except Exception:
records = []
record = next((r for r in records if int(r.id) == row_id), None)
if record is None:
return None
try:
tracks_raw = manager.get_playlist_tracks(row_id) or []
except Exception:
tracks_raw = []
meta = self._meta_from_record(record)
meta.track_count = len(tracks_raw)
tracks = [self._track_from_record(t, idx) for idx, t in enumerate(tracks_raw)]
return PlaylistDetail(meta=meta, tracks=tracks)
def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
manager = self._manager()
if manager is None:
return None
try:
row_id = int(playlist_id)
except (TypeError, ValueError):
return None
records = manager.list_playlists(profile_id=self._profile_id_getter()) or []
record = next((r for r in records if int(r.id) == row_id), None)
if record is None:
return None
try:
manager.refresh_playlist(
kind=record.kind,
variant=record.variant,
profile_id=record.profile_id,
)
except Exception: # noqa: S110 — manager persists last_generation_error on failure; surface existing snapshot
pass
return self.get_playlist(playlist_id)
# ---- projection helpers ------------------------------------------------
def _meta_from_record(self, record: Any) -> PlaylistMeta:
return PlaylistMeta(
source=self.name,
source_playlist_id=str(record.id),
name=record.name,
track_count=int(record.track_count or 0),
description=record.kind,
extra={
"kind": record.kind,
"variant": record.variant,
"profile_id": record.profile_id,
"is_stale": bool(record.is_stale),
"last_generated_at": record.last_generated_at,
"last_synced_at": record.last_synced_at,
"last_generation_source": record.last_generation_source,
"last_generation_error": record.last_generation_error,
},
)
def _track_from_record(self, track: Any, position: int) -> NormalizedTrack:
primary_id = track.primary_id()
return NormalizedTrack(
position=position,
track_name=track.track_name,
artist_name=track.artist_name,
album_name=track.album_name or None,
duration_ms=int(track.duration_ms or 0),
source_track_id=primary_id,
image_url=track.album_cover_url or None,
needs_discovery=False,
extra={
"spotify_track_id": track.spotify_track_id,
"itunes_track_id": track.itunes_track_id,
"deezer_track_id": track.deezer_track_id,
"popularity": track.popularity,
"track_data_json": track.track_data_json,
"source_hint": track.source,
},
)

View file

@ -0,0 +1,137 @@
"""Spotify playlist source adapter.
Wraps ``core.spotify_client.SpotifyClient`` the authenticated Spotify
client used everywhere else. Adapter projects Spotify's ``Playlist`` /
``Track`` dataclasses into ``PlaylistMeta`` / ``NormalizedTrack``.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional
from core.playlists.sources.base import (
NormalizedTrack,
PlaylistDetail,
PlaylistMeta,
PlaylistSource,
SOURCE_SPOTIFY,
)
class SpotifyPlaylistSource(PlaylistSource):
name = SOURCE_SPOTIFY
supports_listing = True
supports_refresh = True
requires_auth = True
def __init__(self, client_getter: Callable[[], Any]):
"""``client_getter`` returns the live ``SpotifyClient`` singleton.
We accept a getter (not the client itself) so the adapter can be
constructed at import time, before ``web_server.py`` has wired
up the singleton."""
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
# ``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 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 self.is_authenticated():
return None
playlist = client.get_playlist_by_id(playlist_id)
if playlist is None:
return None
meta = self._meta_from_playlist(playlist)
tracks = [self._track_from_spotify(t, idx) for idx, t in enumerate(playlist.tracks)]
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:
return PlaylistMeta(
source=self.name,
source_playlist_id=str(playlist.id),
name=playlist.name,
owner=playlist.owner,
description=playlist.description,
track_count=int(getattr(playlist, "total_tracks", 0) or 0),
extra={
"public": bool(getattr(playlist, "public", False)),
"collaborative": bool(getattr(playlist, "collaborative", False)),
},
)
def _track_from_spotify(self, track: Any, position: int) -> NormalizedTrack:
artists = getattr(track, "artists", None) or []
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=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=extra,
)

View file

@ -0,0 +1,114 @@
"""Spotify public-embed playlist source adapter.
Wraps ``core.spotify_public_scraper`` no auth, scrapes the public
embed page. ``supports_listing=False`` because there's no "user
library" to enumerate; the user pastes a URL and the adapter fetches.
"""
from __future__ import annotations
import hashlib
from typing import Any, Dict, List, Optional
from core.playlists.sources.base import (
NormalizedTrack,
PlaylistDetail,
PlaylistMeta,
PlaylistSource,
SOURCE_SPOTIFY_PUBLIC,
)
class SpotifyPublicPlaylistSource(PlaylistSource):
name = SOURCE_SPOTIFY_PUBLIC
supports_listing = False
supports_refresh = True
requires_auth = False
def is_authenticated(self) -> bool:
return True
def list_playlists(self) -> List[PlaylistMeta]:
return []
def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
"""``playlist_id`` is a Spotify URL or ``open.spotify.com`` URI."""
from core.spotify_public_scraper import (
parse_spotify_url,
scrape_spotify_embed,
)
parsed = parse_spotify_url(playlist_id)
if not parsed:
return None
data = scrape_spotify_embed(parsed["type"], parsed["id"])
if not isinstance(data, dict) or data.get("error"):
return None
source_url = data.get("url") or playlist_id
url_hash = data.get("url_hash") or hashlib.md5(source_url.encode()).hexdigest()[:12]
tracks_raw = data.get("tracks") or []
meta = PlaylistMeta(
source=self.name,
source_playlist_id=url_hash,
name=data.get("name", "Spotify Playlist"),
owner=data.get("subtitle"),
track_count=len(tracks_raw),
source_url=source_url,
extra={
"spotify_type": data.get("type"),
"spotify_id": data.get("id"),
},
)
tracks = [
self._track_from_embed(t, idx)
for idx, t in enumerate(tracks_raw)
if t and t.get("id")
]
return PlaylistDetail(meta=meta, tracks=tracks)
def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
return self.get_playlist(playlist_id)
# ---- projection helpers ------------------------------------------------
def _track_from_embed(self, track: dict, position: int) -> NormalizedTrack:
artists = track.get("artists") or []
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_name,
artist_name=artist_name,
album_name=None,
duration_ms=int(track.get("duration_ms", 0) or 0),
source_track_id=track_id,
needs_discovery=False,
extra=extra,
)

View file

@ -0,0 +1,103 @@
"""Tidal playlist source adapter.
Wraps ``core.tidal_client.TidalClient``. Tidal recognizes the virtual
``tidal-favorites`` ID inside ``get_playlist`` already, so the adapter
doesn't need to special-case it.
"""
from __future__ import annotations
from typing import Any, Callable, List, Optional
from core.playlists.sources.base import (
NormalizedTrack,
PlaylistDetail,
PlaylistMeta,
PlaylistSource,
SOURCE_TIDAL,
)
class TidalPlaylistSource(PlaylistSource):
name = SOURCE_TIDAL
supports_listing = True
supports_refresh = True
requires_auth = True
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
return bool(client.is_authenticated())
def list_playlists(self) -> List[PlaylistMeta]:
client = self._client()
if client is None or not client.is_authenticated():
return []
playlists = client.get_user_playlists_metadata_only() or []
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():
return None
playlist = client.get_playlist(playlist_id)
if playlist is None:
return None
meta = self._meta_from_playlist(playlist)
tracks_raw = getattr(playlist, "tracks", None) or []
tracks = [self._track_from_tidal(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:
owner_field = getattr(playlist, "owner", None)
owner_name: Optional[str] = None
if isinstance(owner_field, dict):
owner_name = owner_field.get("name") or owner_field.get("id")
elif owner_field:
owner_name = str(owner_field)
tracks_raw = getattr(playlist, "tracks", None) or []
return PlaylistMeta(
source=self.name,
source_playlist_id=str(playlist.id),
name=playlist.name,
owner=owner_name,
description=getattr(playlist, "description", "") or None,
track_count=len(tracks_raw),
extra={
"public": bool(getattr(playlist, "public", True)),
"external_urls": getattr(playlist, "external_urls", {}),
},
)
def _track_from_tidal(self, track: Any, position: int) -> NormalizedTrack:
artists = getattr(track, "artists", None) or []
# 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,
artist_name=artist_name,
album_name=getattr(track, "album", "") or None,
duration_ms=int(getattr(track, "duration_ms", 0) or 0),
source_track_id=str(track.id),
needs_discovery=False,
extra={
"explicit": bool(getattr(track, "explicit", False)),
"external_urls": getattr(track, "external_urls", {}),
"popularity": getattr(track, "popularity", 0),
},
)

View file

@ -0,0 +1,87 @@
"""YouTube playlist source adapter.
Wraps ``parse_youtube_playlist`` (currently a free function in
``web_server.py`` Phase 0 doesn't move it, just calls it via an
injected callable to avoid the circular import). ``supports_listing``
is False YouTube playlists are URL-input only, no user library.
"""
from __future__ import annotations
import hashlib
from typing import Any, Callable, List, Optional
from core.playlists.sources.base import (
NormalizedTrack,
PlaylistDetail,
PlaylistMeta,
PlaylistSource,
SOURCE_YOUTUBE,
)
class YouTubePlaylistSource(PlaylistSource):
name = SOURCE_YOUTUBE
supports_listing = False
supports_refresh = True
requires_auth = False
def __init__(self, parser: Callable[[str], Optional[dict]]):
"""``parser`` matches the signature of ``parse_youtube_playlist``
in web_server.py takes a URL, returns the playlist dict or
``None``. Injected so adapter can be constructed at import time."""
self._parser = parser
def is_authenticated(self) -> bool:
return True
def list_playlists(self) -> List[PlaylistMeta]:
return []
def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
"""``playlist_id`` is the full YouTube playlist URL."""
data = self._parser(playlist_id)
if not data:
return None
source_url = data.get("url") or playlist_id
url_hash = hashlib.md5(source_url.encode()).hexdigest()[:12]
tracks_raw = data.get("tracks") or []
meta = PlaylistMeta(
source=self.name,
source_playlist_id=url_hash,
name=data.get("name", "YouTube Playlist"),
track_count=int(data.get("track_count", len(tracks_raw))),
image_url=data.get("image_url") or None,
source_url=source_url,
extra={
"youtube_playlist_id": data.get("id"),
},
)
tracks = [self._track_from_yt(t, idx) for idx, t in enumerate(tracks_raw) if t]
return PlaylistDetail(meta=meta, tracks=tracks)
def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
return self.get_playlist(playlist_id)
# ---- projection helpers ------------------------------------------------
def _track_from_yt(self, track: dict, position: int) -> NormalizedTrack:
artists = track.get("artists") or []
artist_name = artists[0] if artists else "Unknown Artist"
return NormalizedTrack(
position=position,
track_name=track.get("name", "Unknown Track"),
artist_name=artist_name,
album_name=None,
duration_ms=int(track.get("duration_ms", 0) or 0),
source_track_id=str(track.get("id", "")),
needs_discovery=False,
extra={
"url": track.get("url"),
"raw_title": track.get("raw_title"),
"raw_artist": track.get("raw_artist"),
},
)

0
core/text/__init__.py Normal file
View file

41
core/text/normalize.py Normal file
View file

@ -0,0 +1,41 @@
"""Shared text-normalization helpers.
Extracted from `MusicDatabase._normalize_for_comparison` so callers
outside the database layer (matching engine, sync candidate pool,
import comparisons) don't have to reach across the module boundary
into a leading-underscore "private" method.
Pure functions, no I/O.
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
try:
from unidecode import unidecode as _unidecode
_HAS_UNIDECODE = True
except ImportError:
_unidecode = None # type: ignore[assignment]
_HAS_UNIDECODE = False
logger.warning("unidecode not available, accent matching may be limited")
def normalize_for_comparison(text: str) -> str:
"""Lowercase + strip whitespace + fold accents to ASCII.
``é e``, ``ñ n``, ``Björk bjork``. Used as the dictionary key
for the sync candidate pool and for fuzzy library lookups where
diacritic differences must NOT split a single artist into two pool
entries.
Empty / falsy input returns ``""`` so callers can blindly key dicts
with the result.
"""
if not text:
return ""
if _HAS_UNIDECODE:
text = _unidecode(text)
return text.lower().strip()

View file

@ -0,0 +1,201 @@
"""Wishlist album grouping for the per-album bundle dispatch.
When the auto-wishlist cycle is ``'albums'`` the user expects each
album with missing tracks to fire ONE album-bundle search instead
of one per-track search per missing track. Track lists in the
wishlist may span multiple albums in one cycle, so we group them
upfront + emit one sub-batch per album.
Pure function no IO, no runtime-state dependency so it can be
unit-tested without standing up the wishlist runner.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
def _extract_track_data(track: Dict[str, Any]) -> Dict[str, Any]:
"""Mirror of ``classification._extract_track_data``: unwrap nested
Spotify payloads regardless of which key the wishlist row chose
to stash them under."""
for key in ("track_data", "spotify_data", "metadata", "track"):
data = track.get(key)
if isinstance(data, str):
try:
data = json.loads(data)
except Exception:
data = {}
if isinstance(data, dict) and data:
nested = (
data.get("track_data")
or data.get("spotify_data")
or data.get("metadata")
or data.get("track")
)
if isinstance(nested, str):
try:
nested = json.loads(nested)
except Exception:
nested = {}
if isinstance(nested, dict) and nested:
return nested
return data
return {}
def _album_key(spotify_data: Dict[str, Any]) -> Optional[str]:
"""Derive a stable grouping key from a track's Spotify metadata.
Prefers album id (canonical). Falls back to a name-normalized
key when the album row has no id (older wishlist rows can be
missing it). Returns ``None`` when no album information is
available at all those tracks can't participate in an
album-bundle search and stay on the residual per-track flow.
"""
album = spotify_data.get('album') or {}
if not isinstance(album, dict):
return None
album_id = album.get('id')
if isinstance(album_id, str) and album_id.strip():
return album_id.strip()
name = album.get('name')
if isinstance(name, str) and name.strip():
return f"_name_{name.strip().lower()}"
return None
def _artist_name_from_track(spotify_data: Dict[str, Any], track: Dict[str, Any]) -> str:
"""Pick a primary artist name from the track's metadata.
Album-bundle search needs an artist string. Prefer the first
Spotify artist (most accurate), fall back to ``track_info['artist']``
or ``track['artist_name']`` from the wishlist row, then to empty
string (caller will skip the bundle).
"""
artists = spotify_data.get('artists') or []
if isinstance(artists, list) and artists:
first = artists[0]
if isinstance(first, dict):
name = first.get('name')
if isinstance(name, str) and name.strip():
return name.strip()
elif isinstance(first, str) and first.strip():
return first.strip()
for key in ('artist_name', 'artist'):
val = track.get(key)
if isinstance(val, str) and val.strip():
return val.strip()
return ''
@dataclass
class WishlistAlbumGroup:
"""One album's worth of wishlist tracks ready for a sub-batch."""
album_key: str
album_context: Dict[str, Any]
artist_context: Dict[str, Any]
tracks: List[Dict[str, Any]] = field(default_factory=list)
@dataclass
class WishlistGroupingResult:
"""Aggregated grouping output.
- ``album_groups``: one entry per resolvable album. Each carries
enough context to be submitted as an album-bundle batch.
- ``residual_tracks``: tracks that couldn't be grouped (no
album metadata + no artist). They fall through to the normal
per-track flow.
"""
album_groups: List[WishlistAlbumGroup] = field(default_factory=list)
residual_tracks: List[Dict[str, Any]] = field(default_factory=list)
def group_wishlist_tracks_by_album(
tracks: List[Dict[str, Any]],
*,
min_tracks_per_album: int = 1,
) -> WishlistGroupingResult:
"""Group wishlist tracks by their owning album.
``min_tracks_per_album`` controls the threshold for promoting an
album to its own sub-batch. Default ``1`` means even a single
missing track gets the album-bundle treatment (which is what the
user wants for releases where they only need one track from the
album). Set higher to require multiple missing tracks before
engaging the bundle search.
"""
result = WishlistGroupingResult()
if not tracks:
return result
# First pass: bucket by album key.
buckets: Dict[str, WishlistAlbumGroup] = {}
unbucketable: List[Dict[str, Any]] = []
for track in tracks:
spotify_data = _extract_track_data(track)
key = _album_key(spotify_data)
if key is None:
unbucketable.append(track)
continue
artist_name = _artist_name_from_track(spotify_data, track)
if not artist_name:
unbucketable.append(track)
continue
album = spotify_data.get('album') or {}
if not isinstance(album, dict):
album = {}
album_name = album.get('name', '')
if not (isinstance(album_name, str) and album_name.strip()):
unbucketable.append(track)
continue
group = buckets.get(key)
if group is None:
album_context = {
'id': album.get('id') or key,
'name': album_name.strip(),
'release_date': album.get('release_date', ''),
'total_tracks': album.get('total_tracks', 0),
'album_type': album.get('album_type', 'album'),
'images': album.get('images', []),
'artists': album.get('artists', []),
}
artist_context = {
'id': 'wishlist',
'name': artist_name,
'genres': [],
}
group = WishlistAlbumGroup(
album_key=key,
album_context=album_context,
artist_context=artist_context,
)
buckets[key] = group
group.tracks.append(track)
# Second pass: promote groups meeting the threshold; demote
# smaller groups to residual.
for group in buckets.values():
if len(group.tracks) >= min_tracks_per_album:
result.album_groups.append(group)
else:
result.residual_tracks.extend(group.tracks)
result.residual_tracks.extend(unbucketable)
return result
__all__ = [
'group_wishlist_tracks_by_album',
'WishlistAlbumGroup',
'WishlistGroupingResult',
]

View file

@ -106,8 +106,6 @@ def ensure_wishlist_track_format(track_info):
else:
album = {
'name': str(album_data) if album_data else track_info.get('name', 'Unknown Album'),
'album_type': 'single',
'total_tracks': 1,
'release_date': '',
}
album.setdefault('images', [])
@ -351,7 +349,7 @@ def extract_wishlist_track_from_modal_info(track_info: Dict[str, Any]) -> Option
"id": f"reconstructed_{hash(f'{slskd_result.artist}_{slskd_result.title}')}",
"name": getattr(slskd_result, "title", "Unknown Track"),
"artists": [{"name": getattr(slskd_result, "artist", "Unknown Artist")}],
"album": {"name": album_name, "images": [], "album_type": "single", "total_tracks": 1},
"album": {"name": album_name, "images": [], "album_type": "album", "total_tracks": 0},
"duration_ms": 0,
"reconstructed": True,
}

View file

@ -148,15 +148,67 @@ def recover_uncaptured_failed_tracks(
return recovered_count
def resolve_wishlist_source_type_for_batch(batch: Dict[str, Any]) -> str:
"""Pick the wishlist ``source_type`` for failed tracks coming out of a
download batch.
Album-context batches must produce ``'album'`` provenance the legacy
hardcoded ``'playlist'`` mislabels every album-batch failure, breaks
the wishlist UI's source filter, and makes ``by_source_type`` stats
look like all wishlist work came from playlists.
"""
return 'album' if batch.get('is_album_download') else 'playlist'
def build_wishlist_source_context(batch: Dict[str, Any], current_time: datetime | None = None) -> Dict[str, Any]:
"""Build the source_context payload used when adding failed tracks back to the wishlist."""
current_time = current_time or datetime.now()
return {
context = {
'playlist_name': batch.get('playlist_name', 'Unknown Playlist'),
'playlist_id': batch.get('playlist_id', None),
'added_from': 'webui_modal',
'timestamp': current_time.isoformat(),
}
# Preserve album-batch provenance so wishlist requeue has a real signal
# for album-vs-single routing instead of relying on per-track album dicts
# that may have been mangled by reconstruction fallbacks.
if batch.get('is_album_download'):
context['is_album_download'] = True
album_ctx = batch.get('album_context')
if isinstance(album_ctx, dict):
context['album_context'] = album_ctx
artist_ctx = batch.get('artist_context')
if isinstance(artist_ctx, dict):
context['artist_context'] = artist_ctx
return context
def _wishlist_run_has_siblings_still_active(
download_batches: Dict[str, Dict[str, Any]],
run_id: str,
completing_batch_id: str,
) -> bool:
"""Return True if any sibling batch sharing ``run_id`` is still
pre-terminal.
Used by ``finalize_auto_wishlist_completion`` to gate the run-
level cycle toggle. The caller already holds ``tasks_lock``.
The completing batch may or may not have its phase flipped to
'complete' yet by the time we land here; either way we skip it
in the sibling scan since we're handling its completion now."""
terminal_phases = {'complete', 'error', 'cancelled'}
for sibling_id, sibling in download_batches.items():
if sibling_id == completing_batch_id:
continue
if not isinstance(sibling, dict):
continue
if sibling.get('wishlist_run_id') != run_id:
continue
if sibling.get('phase') in terminal_phases:
continue
return True
return False
def finalize_auto_wishlist_completion(
@ -171,7 +223,17 @@ def finalize_auto_wishlist_completion(
db_factory: Callable[[], Any],
logger=logger,
) -> Dict[str, Any]:
"""Finalize auto wishlist processing after a batch finishes."""
"""Finalize auto wishlist processing after a batch finishes.
For wishlist runs that split into multiple sub-batches (Phase
1c.2.1: per-album bundle dispatch), the cycle toggle + state
reset only fire when the LAST sibling sub-batch of the same
``wishlist_run_id`` completes. Earlier completions just record
their per-batch summary and return without toggling.
Back-compat: legacy single-batch runs (no ``wishlist_run_id``
field on the batch) keep the original toggle-immediately
behavior the gate treats a missing run_id as "lone batch"."""
tracks_added = completion_summary.get('tracks_added', 0)
total_failed = completion_summary.get('total_failed', 0)
logger.error(
@ -181,6 +243,22 @@ def finalize_auto_wishlist_completion(
if tracks_added > 0:
add_activity_item("", "Wishlist Updated", f"{tracks_added} failed tracks added to wishlist", "Now")
# Run-level gate: if siblings of the same wishlist run are still
# active, defer cycle toggle + state reset until they finish.
with tasks_lock:
run_id = ''
if batch_id in download_batches:
run_id = download_batches[batch_id].get('wishlist_run_id') or ''
siblings_active = bool(run_id) and _wishlist_run_has_siblings_still_active(
download_batches, run_id, batch_id,
)
if siblings_active:
logger.info(
f"[Auto-Wishlist] Sub-batch {batch_id[:8]} done; waiting on sibling sub-batches "
f"of run {run_id[:8]} before toggling cycle"
)
return completion_summary
try:
with tasks_lock:
if batch_id in download_batches:
@ -445,15 +523,117 @@ def _prepare_and_run_manual_wishlist_batch(
for i, track in enumerate(wishlist_tracks):
track['_original_index'] = i
# Update batch with the real track count now that filtering is done
with runtime.tasks_lock:
if batch_id in runtime.download_batches:
runtime.download_batches[batch_id]['analysis_total'] = len(wishlist_tracks)
runtime.add_activity_item("", "Wishlist Download Started", f"{len(wishlist_tracks)} tracks", "Now")
logger.info(f"Starting wishlist batch {batch_id} with {len(wishlist_tracks)} tracks")
runtime.run_full_missing_tracks_process(batch_id, "wishlist", wishlist_tracks)
# Try to split into per-album sub-batches so each album fires
# ONE slskd / torrent / usenet album-bundle search (gates on
# ``is_album_download`` + populated album/artist context).
# When a single category was requested (or no category filter)
# we apply the same grouping the auto-wishlist path uses.
# Tracks the grouper can't bucket fall through to a residual
# batch with the classic per-track flow.
from core.wishlist.album_grouping import group_wishlist_tracks_by_album
grouping = group_wishlist_tracks_by_album(wishlist_tracks)
# Build the final payload list (batch_id, tracks, album_context,
# artist_context, is_album). The first payload re-uses the
# caller-allocated ``batch_id`` so the frontend's existing poll
# against it keeps working. Subsequent payloads get fresh ids.
payloads = []
for group in grouping.album_groups:
payloads.append({
'tracks': group.tracks,
'is_album': True,
'album_context': group.album_context,
'artist_context': group.artist_context,
'display_name': f"Wishlist (Album: {group.album_context.get('name', 'Unknown')})",
})
if grouping.residual_tracks:
payloads.append({
'tracks': grouping.residual_tracks,
'is_album': False,
'album_context': None,
'artist_context': None,
'display_name': "Wishlist (Residual)",
})
if not payloads:
# Nothing to download — clear out the original batch.
with runtime.tasks_lock:
if batch_id in runtime.download_batches:
runtime.download_batches[batch_id]['analysis_total'] = 0
runtime.download_batches[batch_id]['phase'] = 'complete'
return
# Attach the original batch_id to the first payload; allocate
# fresh batch_ids for the rest.
payloads[0]['batch_id'] = batch_id
for payload in payloads[1:]:
payload['batch_id'] = str(uuid.uuid4())
# Reify "wishlist run" — one shared id stamped on every sub-
# batch this manual invocation produces. Mirrors the auto
# path. Note manual wishlist completion currently doesn't
# toggle the cycle (only auto does), but the id is set anyway
# so future code + UI grouping have a consistent hook.
wishlist_run_id = str(uuid.uuid4())
# Materialize each sub-batch's row state up-front so the
# frontend's polling can see them all under the original
# batch's flow.
with runtime.tasks_lock:
if batch_id in runtime.download_batches:
# Re-purpose the existing row for the first payload.
first = payloads[0]
runtime.download_batches[batch_id]['analysis_total'] = len(first['tracks'])
runtime.download_batches[batch_id]['wishlist_run_id'] = wishlist_run_id
if first['is_album']:
runtime.download_batches[batch_id]['is_album_download'] = True
runtime.download_batches[batch_id]['album_context'] = first['album_context']
runtime.download_batches[batch_id]['artist_context'] = first['artist_context']
runtime.download_batches[batch_id]['playlist_name'] = first['display_name']
for payload in payloads[1:]:
runtime.download_batches[payload['batch_id']] = {
'phase': 'analysis',
'playlist_id': 'wishlist',
'playlist_name': payload['display_name'],
'queue': [],
'active_count': 0,
'max_concurrent': runtime.get_batch_max_concurrent(),
'queue_index': 0,
'analysis_total': len(payload['tracks']),
'analysis_processed': 0,
'analysis_results': [],
'permanently_failed_tracks': [],
'cancelled_tracks': set(),
'force_download_all': True,
'profile_id': runtime.profile_id,
'is_album_download': bool(payload['is_album']),
'album_context': payload['album_context'],
'artist_context': payload['artist_context'],
'wishlist_run_id': wishlist_run_id,
}
logger.info(
f"[Manual-Wishlist] Split into {len(payloads)} sub-batch(es) "
f"({sum(1 for p in payloads if p['is_album'])} album + "
f"{sum(1 for p in payloads if not p['is_album'])} residual)"
)
# Serial dispatch — each album-bundle search happens one at a
# time so the slskd / Prowlarr pipeline doesn't fan out across
# multiple parallel release searches.
for payload in payloads:
label = (
f"album '{payload['album_context'].get('name')}'"
if payload['is_album'] else 'residual per-track'
)
logger.info(
f"[Manual-Wishlist] Running sub-batch {payload['batch_id']} "
f"({label}, {len(payload['tracks'])} tracks)"
)
runtime.run_full_missing_tracks_process(
payload['batch_id'], "wishlist", payload['tracks'],
)
except Exception as exc:
logger.error(f"Error preparing manual wishlist batch {batch_id}: {exc}")
@ -615,45 +795,124 @@ def process_wishlist_automatically(runtime: WishlistAutoProcessingRuntime, autom
for i, track in enumerate(wishlist_tracks):
track['_original_index'] = i
# Create batch for automatic processing
batch_id = str(uuid.uuid4())
playlist_name = f"Wishlist (Auto - {current_cycle.capitalize()})"
# When the cycle is 'albums', try to split the wishlist
# into per-album sub-batches so each album fires ONE
# album-bundle search (slskd / torrent / usenet) instead
# of N per-track searches. Residual tracks (no resolvable
# album metadata) fall through to a normal per-track
# batch. Singles cycle keeps its original single-batch
# shape — Spotify already classifies them away from
# albums.
_submitted_batches: list[str] = []
if current_cycle == 'albums':
from core.wishlist.album_grouping import group_wishlist_tracks_by_album
grouping = group_wishlist_tracks_by_album(wishlist_tracks)
else:
grouping = None
# Create task queue - convert wishlist tracks to expected format
with runtime.tasks_lock:
runtime.download_batches[batch_id] = {
'phase': 'analysis',
'playlist_id': playlist_id,
'playlist_name': playlist_name,
'queue': [],
'active_count': 0,
'max_concurrent': runtime.get_batch_max_concurrent(), # Wishlist always does single-track downloads, not folder grabs
'queue_index': 0,
'analysis_total': len(wishlist_tracks),
'analysis_processed': 0,
'analysis_results': [],
# Track state management (replicating sync.py)
'permanently_failed_tracks': [],
'cancelled_tracks': set(),
# Wishlist tracks are already known-missing — skip the expensive library check
'force_download_all': True,
# Mark as auto-initiated
'auto_initiated': True,
'auto_processing_timestamp': runtime.current_time_fn(),
# Store current cycle for toggling after completion
'current_cycle': current_cycle,
# Profile context for failed track wishlist re-adds (auto = profile 1 default)
'profile_id': runtime.profile_id,
}
# Reify "wishlist run" — one shared id stamped on every
# sub-batch this invocation produces. The completion
# handler uses it to gate the once-per-run cycle toggle
# (so it doesn't fire N times for N sub-batches).
wishlist_run_id = str(uuid.uuid4())
logger.info(f"Starting automatic wishlist batch {batch_id} with {len(wishlist_tracks)} tracks")
runtime.update_automation_progress(automation_id, progress=50, phase=f'Downloading {len(wishlist_tracks)} tracks',
log_line=f'Started batch: {len(wishlist_tracks)} {current_cycle}', log_type='success')
if grouping and grouping.album_groups:
for album_idx, group in enumerate(grouping.album_groups):
album_batch_id = str(uuid.uuid4())
album_batch_name = (
f"Wishlist (Auto - Album: {group.album_context.get('name', 'Unknown')})"
)
with runtime.tasks_lock:
runtime.download_batches[album_batch_id] = {
'phase': 'analysis',
'playlist_id': playlist_id,
'playlist_name': album_batch_name,
'queue': [],
'active_count': 0,
'max_concurrent': runtime.get_batch_max_concurrent(),
'queue_index': 0,
'analysis_total': len(group.tracks),
'analysis_processed': 0,
'analysis_results': [],
'permanently_failed_tracks': [],
'cancelled_tracks': set(),
'force_download_all': True,
'auto_initiated': True,
'auto_processing_timestamp': runtime.current_time_fn(),
'current_cycle': current_cycle,
'profile_id': runtime.profile_id,
# Album-bundle dispatch gate reads these
# three. With them set, the master worker
# routes through slskd / torrent / usenet
# album-bundle search instead of per-track.
'is_album_download': True,
'album_context': group.album_context,
'artist_context': group.artist_context,
'wishlist_run_id': wishlist_run_id,
}
logger.info(
f"[Auto-Wishlist] Album sub-batch {album_idx + 1}/{len(grouping.album_groups)}: "
f"'{group.album_context.get('name')}' by '{group.artist_context.get('name')}' "
f"({len(group.tracks)} tracks) → {album_batch_id} [run {wishlist_run_id[:8]}]"
)
_submitted_batches.append(album_batch_id)
runtime.missing_download_executor.submit(
runtime.run_full_missing_tracks_process,
album_batch_id, playlist_id, group.tracks,
)
# Submit the wishlist processing job using existing infrastructure
runtime.missing_download_executor.submit(runtime.run_full_missing_tracks_process, batch_id, playlist_id, wishlist_tracks)
# Residual tracks (no album group could be formed, OR
# singles cycle): one classic per-track batch as before.
residual_tracks = (
grouping.residual_tracks if grouping is not None else wishlist_tracks
)
if residual_tracks:
batch_id = str(uuid.uuid4())
playlist_name = f"Wishlist (Auto - {current_cycle.capitalize()})"
with runtime.tasks_lock:
runtime.download_batches[batch_id] = {
'phase': 'analysis',
'playlist_id': playlist_id,
'playlist_name': playlist_name,
'queue': [],
'active_count': 0,
'max_concurrent': runtime.get_batch_max_concurrent(),
'queue_index': 0,
'analysis_total': len(residual_tracks),
'analysis_processed': 0,
'analysis_results': [],
'permanently_failed_tracks': [],
'cancelled_tracks': set(),
'force_download_all': True,
'auto_initiated': True,
'auto_processing_timestamp': runtime.current_time_fn(),
'current_cycle': current_cycle,
'profile_id': runtime.profile_id,
'wishlist_run_id': wishlist_run_id,
}
_submitted_batches.append(batch_id)
runtime.missing_download_executor.submit(
runtime.run_full_missing_tracks_process,
batch_id, playlist_id, residual_tracks,
)
logger.info(
f"Starting wishlist residual batch {batch_id} with {len(residual_tracks)} tracks "
f"({'singles' if current_cycle == 'singles' else 'unbucketed albums'}) "
f"[run {wishlist_run_id[:8]}]"
)
# Don't mark auto_processing as False here - let completion handler do it
_summary_parts: list[str] = []
if grouping and grouping.album_groups:
_summary_parts.append(f"{len(grouping.album_groups)} album batch(es)")
if residual_tracks:
_summary_parts.append(f"{len(residual_tracks)} per-track")
_summary_text = ', '.join(_summary_parts) or 'no batches'
runtime.update_automation_progress(
automation_id, progress=50,
phase=f'Downloading {len(wishlist_tracks)} tracks',
log_line=f'Started: {_summary_text} for cycle {current_cycle}',
log_type='success',
)
except Exception as e:
logger.error(f"Error in automatic wishlist processing: {e}")

View file

@ -531,6 +531,30 @@ class MusicDatabase:
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_arh_automation_id ON automation_run_history(automation_id)")
# Playlist pipeline run history table
cursor.execute("""
CREATE TABLE IF NOT EXISTS playlist_pipeline_run_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id INTEGER,
playlist_name TEXT,
source TEXT,
profile_id INTEGER DEFAULT 1,
trigger_source TEXT DEFAULT 'pipeline',
started_at TIMESTAMP,
finished_at TIMESTAMP,
duration_seconds REAL,
status TEXT NOT NULL,
summary TEXT,
before_json TEXT,
after_json TEXT,
result_json TEXT,
log_lines TEXT,
FOREIGN KEY (playlist_id) REFERENCES mirrored_playlists(id) ON DELETE SET NULL
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_pprh_playlist_id ON playlist_pipeline_run_history(playlist_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_pprh_profile_id ON playlist_pipeline_run_history(profile_id)")
# Add explored_at to mirrored_playlists (migration)
self._add_mirrored_playlist_explored_column(cursor)
@ -539,6 +563,7 @@ class MusicDatabase:
self._add_automation_system_column(cursor)
self._add_automation_then_actions_column(cursor)
self._add_automation_group_name_column(cursor)
self._add_automation_owned_by_column(cursor)
# Library issues — user-reported problems with tracks/albums/artists
cursor.execute("""
@ -845,6 +870,28 @@ class MusicDatabase:
except Exception as e:
logger.error(f"Error adding automation group_name column: {e}")
def _add_automation_owned_by_column(self, cursor):
"""Add owned_by column so feature surfaces (Auto-Sync schedule
board, future pipeline groups) can recognize automations they
manage without relying on fragile name-prefix string matches."""
try:
cursor.execute("PRAGMA table_info(automations)")
cols = [c[1] for c in cursor.fetchall()]
if 'owned_by' not in cols:
cursor.execute("ALTER TABLE automations ADD COLUMN owned_by TEXT DEFAULT NULL")
logger.info("Added owned_by column to automations table")
# Backfill existing Auto-Sync automations created via the
# name/group-prefix convention so the board keeps managing them.
cursor.execute("""
UPDATE automations
SET owned_by = 'auto_sync'
WHERE (group_name = 'Playlist Auto-Sync' OR name LIKE 'Auto-Sync:%')
AND owned_by IS NULL
""")
logger.info(f"Backfilled {cursor.rowcount} existing Auto-Sync automations with owned_by='auto_sync'")
except Exception as e:
logger.error(f"Error adding automation owned_by column: {e}")
def _add_automation_then_actions_column(self, cursor):
"""Add then_actions column to automations table and migrate existing notify data."""
try:
@ -5200,7 +5247,7 @@ class MusicDatabase:
# Album exists - update it (update server_source if different)
cursor.execute("""
UPDATE albums
SET artist_id = ?, title = ?, year = ?, thumb_url = ?, genres = ?,
SET artist_id = ?, title = ?, year = ?, thumb_url = COALESCE(NULLIF(?, ''), thumb_url), genres = ?,
track_count = ?, duration = ?, server_source = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (artist_id, title, year, thumb_url, genres_json, track_count, duration, server_source, album_id))
@ -5233,6 +5280,7 @@ class MusicDatabase:
# Read enrichment data from old album
cursor.execute("SELECT * FROM albums WHERE id = ?", (old_id,))
old_row = cursor.fetchone()
preserved_thumb_url = thumb_url or (old_row['thumb_url'] if old_row and 'thumb_url' in old_row.keys() else None)
# Insert new album with fresh server metadata + preserved created_at
old_created = old_row['created_at'] if old_row else None
@ -5240,7 +5288,7 @@ class MusicDatabase:
INSERT INTO albums (id, artist_id, title, year, thumb_url, genres,
track_count, duration, server_source, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
""", (album_id, artist_id, title, year, thumb_url, genres_json,
""", (album_id, artist_id, title, year, preserved_thumb_url, genres_json,
track_count, duration, server_source, old_created))
# Copy enrichment data from old record to new record
@ -6370,6 +6418,54 @@ class MusicDatabase:
logger.error(f"Error fetching candidate albums for artist '{artist}': {e}")
return candidates
def get_artist_tracks_indexed(self, name: str, server_source: Optional[str] = None, limit: int = 10000) -> List[DatabaseTrack]:
"""Indexed two-step lookup: artist_id by exact name (then case-insensitive
fallback), then tracks via `artist_id IN (...)`. Avoids the function-in-WHERE
pattern in search_tracks that defeats the artists.name index. Returns []
when the artist isn't in the library — caller can decide to fall back to
the slower LIKE-based path for track_artist / diacritic recall."""
if not name:
return []
try:
conn = self._get_connection()
cursor = conn.cursor()
# Step 1: exact case-sensitive match — hits idx_artists_name in O(log n).
# Spotify's canonical artist names match the library 90%+ of the time.
cursor.execute("SELECT id FROM artists WHERE name = ?", (name,))
artist_ids = [r['id'] for r in cursor.fetchall()]
# Step 2: case-insensitive fallback if exact missed. Full scan, but only
# runs on the (uncommon) miss path so amortized cost stays low.
if not artist_ids:
cursor.execute("SELECT id FROM artists WHERE LOWER(name) = LOWER(?)", (name,))
artist_ids = [r['id'] for r in cursor.fetchall()]
if not artist_ids:
return []
placeholders = ','.join('?' for _ in artist_ids)
where = f"t.artist_id IN ({placeholders})"
params: list = list(artist_ids)
if server_source:
where += " AND t.server_source = ?"
params.append(server_source)
params.append(limit)
cursor.execute(f"""
SELECT t.*, a.name as artist_name, al.title as album_title,
al.thumb_url as album_thumb_url
FROM tracks t
JOIN artists a ON a.id = t.artist_id
JOIN albums al ON al.id = t.album_id
WHERE {where}
LIMIT ?
""", params)
return self._rows_to_tracks(cursor.fetchall())
except Exception as e:
logger.error(f"Error fetching indexed artist tracks for '{name}': {e}")
return []
def get_candidate_tracks_for_albums(self, album_ids: List) -> List[DatabaseTrack]:
"""
Fetch every track belonging to the given set of album IDs in a single query.
@ -6897,22 +6993,12 @@ class MusicDatabase:
return unique_variations
def _normalize_for_comparison(self, text: str) -> str:
"""Normalize text for comparison with Unicode accent handling"""
if not text:
return ""
# Try to use unidecode for accent normalization, fallback to basic if not available
try:
from unidecode import unidecode
# Convert accents: é→e, ñ→n, ü→u, etc.
normalized = unidecode(text)
except ImportError:
# Fallback: basic normalization without accent handling
normalized = text
logger.warning("unidecode not available, accent matching may be limited")
# Convert to lowercase and strip
return normalized.lower().strip()
"""Delegates to `core.text.normalize.normalize_for_comparison`.
Kept as an instance method so existing internal callers don't need
to be touched new code should import the public helper directly.
"""
from core.text.normalize import normalize_for_comparison
return normalize_for_comparison(text)
def _calculate_track_confidence(self, search_title: str, search_artist: str, db_track: DatabaseTrack) -> float:
"""Calculate confidence score for track match with enhanced cleaning and Unicode normalization"""
@ -11828,7 +11914,7 @@ class MusicDatabase:
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(source, source_playlist_id, profile_id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
description = COALESCE(NULLIF(excluded.description, ''), mirrored_playlists.description),
owner = excluded.owner,
image_url = excluded.image_url,
track_count = excluded.track_count,
@ -11939,6 +12025,39 @@ class MusicDatabase:
logger.error(f"Error getting mirrored playlist tracks: {e}")
return []
def update_mirrored_playlist_source_ref(
self,
playlist_id: int,
source_playlist_id: str,
description: Optional[str] = None,
) -> bool:
"""Update a mirrored playlist's upstream source reference.
This intentionally leaves mirrored tracks and discovery extra_data
untouched; refresh/discovery can use the new source reference on the
next run without losing existing local state.
"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
if description is None:
cursor.execute("""
UPDATE mirrored_playlists
SET source_playlist_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (source_playlist_id, playlist_id))
else:
cursor.execute("""
UPDATE mirrored_playlists
SET source_playlist_id = ?, description = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (source_playlist_id, description, playlist_id))
conn.commit()
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error updating mirrored playlist source reference: {e}")
return False
def update_mirrored_track_extra_data(self, track_id: int, extra_data_dict: dict) -> bool:
"""Merge new data into a mirrored track's extra_data JSON field."""
try:
@ -12018,6 +12137,82 @@ class MusicDatabase:
logger.error(f"Error getting mirrored playlist discovery counts: {e}")
return (0, 0)
def get_all_mirrored_playlist_status_counts(self, profile_id: int = 1) -> dict:
"""Return status counts for every mirrored playlist owned by the profile
in a single round-trip. Replaces N×4-query per-playlist loop on the
Auto-Sync modal load path. Result is `{playlist_id: {total, discovered,
wishlisted, in_library}}`."""
result: dict = {}
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT mp.id as playlist_id
FROM mirrored_playlists mp
WHERE mp.profile_id = ?
""", (profile_id,))
for row in cursor.fetchall():
result[row['playlist_id']] = {'total': 0, 'discovered': 0, 'wishlisted': 0, 'in_library': 0}
# Core counts: total + discovered, grouped per playlist
cursor.execute("""
SELECT mpt.playlist_id,
COUNT(*) as total,
SUM(CASE WHEN mpt.extra_data LIKE '%"discovered": true%' THEN 1 ELSE 0 END) as discovered
FROM mirrored_playlist_tracks mpt
JOIN mirrored_playlists mp ON mp.id = mpt.playlist_id
WHERE mp.profile_id = ?
GROUP BY mpt.playlist_id
""", (profile_id,))
for row in cursor.fetchall():
pid = row['playlist_id']
if pid not in result:
result[pid] = {'total': 0, 'discovered': 0, 'wishlisted': 0, 'in_library': 0}
result[pid]['total'] = row['total'] or 0
result[pid]['discovered'] = row['discovered'] or 0
# Wishlist counts in one shot
try:
cursor.execute("""
SELECT mpt.playlist_id, COUNT(*) as wishlisted
FROM mirrored_playlist_tracks mpt
JOIN mirrored_playlists mp ON mp.id = mpt.playlist_id
WHERE mp.profile_id = ?
AND mpt.source_track_id IS NOT NULL AND mpt.source_track_id != ''
AND EXISTS (SELECT 1 FROM wishlist_tracks wt
WHERE wt.spotify_track_id = mpt.source_track_id)
GROUP BY mpt.playlist_id
""", (profile_id,))
for row in cursor.fetchall():
pid = row['playlist_id']
if pid in result:
result[pid]['wishlisted'] = row['wishlisted'] or 0
except Exception as e:
logger.debug(f"Batch wishlist counts failed: {e}")
# In-library counts in one shot. Case-sensitive join so
# idx_artists_name + idx_tracks_title kick in.
try:
cursor.execute("""
SELECT mpt.playlist_id, COUNT(DISTINCT mpt.id) as in_library
FROM mirrored_playlist_tracks mpt
JOIN mirrored_playlists mp ON mp.id = mpt.playlist_id
JOIN artists a ON a.name = mpt.artist_name
JOIN tracks t ON t.artist_id = a.id
AND t.title = mpt.track_name
WHERE mp.profile_id = ?
GROUP BY mpt.playlist_id
""", (profile_id,))
for row in cursor.fetchall():
pid = row['playlist_id']
if pid in result:
result[pid]['in_library'] = row['in_library'] or 0
except Exception as e:
logger.debug(f"Batch library counts failed: {e}")
except Exception as e:
logger.error(f"Error getting batch mirrored playlist status counts: {e}")
return result
def get_mirrored_playlist_status_counts(self, playlist_id: int) -> dict:
"""Return discovery, wishlisted, and downloaded counts for a mirrored playlist.
Discovery counts are critical (same as old method). Library/wishlist counts are
@ -12039,26 +12234,39 @@ class MusicDatabase:
)
result['discovered'] = cursor.fetchone()['discovered']
# Best-effort extras — won't break if tracks table has issues
# Best-effort extras — won't break if tracks table has issues.
# Wishlisted: indexed via wishlist_tracks.spotify_track_id.
try:
cursor.execute("""
SELECT
SUM(CASE WHEN mpt.source_track_id IS NOT NULL AND mpt.source_track_id != ''
AND EXISTS (SELECT 1 FROM wishlist_tracks wt
WHERE wt.spotify_track_id = mpt.source_track_id)
THEN 1 ELSE 0 END) as wishlisted,
SUM(CASE WHEN EXISTS (SELECT 1 FROM tracks t
WHERE t.title = mpt.track_name COLLATE NOCASE
AND t.artist = mpt.artist_name COLLATE NOCASE)
THEN 1 ELSE 0 END) as in_library
SELECT COUNT(*) as wishlisted
FROM mirrored_playlist_tracks mpt
WHERE mpt.playlist_id = ?
AND mpt.source_track_id IS NOT NULL AND mpt.source_track_id != ''
AND EXISTS (SELECT 1 FROM wishlist_tracks wt
WHERE wt.spotify_track_id = mpt.source_track_id)
""", (playlist_id,))
row = cursor.fetchone()
result['wishlisted'] = row['wishlisted'] or 0
result['in_library'] = row['in_library'] or 0
result['wishlisted'] = cursor.fetchone()['wishlisted'] or 0
except Exception as extra_err:
logger.debug(f"Optional status counts failed for playlist {playlist_id}: {extra_err}")
logger.debug(f"Wishlist count failed for playlist {playlist_id}: {extra_err}")
# In-library: case-sensitive equality so SQLite can use
# `idx_artists_name` and `idx_tracks_title`. COLLATE NOCASE on
# the join columns prevents index usage and takes ~18s per
# playlist on a 300k-track library; the case-sensitive variant
# is ~6ms. Misses purely-case-different matches (rare — Spotify
# canonicalizes artist/track names that match library imports).
try:
cursor.execute("""
SELECT COUNT(DISTINCT mpt.id) as in_library
FROM mirrored_playlist_tracks mpt
JOIN artists a ON a.name = mpt.artist_name
JOIN tracks t ON t.artist_id = a.id
AND t.title = mpt.track_name
WHERE mpt.playlist_id = ?
""", (playlist_id,))
result['in_library'] = cursor.fetchone()['in_library'] or 0
except Exception as extra_err:
logger.debug(f"Library count failed for playlist {playlist_id}: {extra_err}")
except Exception as e:
logger.error(f"Error getting mirrored playlist status counts: {e}")
@ -12083,15 +12291,22 @@ class MusicDatabase:
def create_automation(self, name: str, trigger_type: str, trigger_config: str,
action_type: str, action_config: str, profile_id: int = 1,
notify_type: str = None, notify_config: str = '{}',
then_actions: str = '[]', group_name: str = None):
"""Create a new automation. Returns the new automation ID or None."""
then_actions: str = '[]', group_name: str = None,
owned_by: str = None):
"""Create a new automation. Returns the new automation ID or None.
``owned_by`` tags an automation as managed by a feature surface
(e.g. ``'auto_sync'`` for entries the Playlist Auto-Sync board
creates) so that surface can recognize its own rows without
scraping the display name.
"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO automations (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions, group_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions, group_name))
INSERT INTO automations (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions, group_name, owned_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions, group_name, owned_by))
conn.commit()
return cursor.lastrowid
except Exception as e:
@ -12138,7 +12353,7 @@ class MusicDatabase:
def update_automation(self, automation_id: int, **kwargs) -> bool:
"""Update automation fields."""
allowed = {'name', 'enabled', 'trigger_type', 'trigger_config', 'action_type', 'action_config', 'next_run', 'notify_type', 'notify_config', 'last_result', 'is_system', 'then_actions', 'group_name'}
allowed = {'name', 'enabled', 'trigger_type', 'trigger_config', 'action_type', 'action_config', 'next_run', 'notify_type', 'notify_config', 'last_result', 'is_system', 'then_actions', 'group_name', 'owned_by'}
updates = {k: v for k, v in kwargs.items() if k in allowed}
if not updates:
return False
@ -12316,6 +12531,73 @@ class MusicDatabase:
logger.error(f"Error clearing automation run history: {e}")
return 0
def insert_playlist_pipeline_run_history(self, playlist_id, playlist_name, source,
profile_id, trigger_source, started_at,
finished_at, duration_seconds, status,
summary=None, before_json=None,
after_json=None, result_json=None,
log_lines=None):
"""Insert a playlist pipeline run history entry and retain recent rows per profile."""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO playlist_pipeline_run_history
(playlist_id, playlist_name, source, profile_id, trigger_source,
started_at, finished_at, duration_seconds, status, summary,
before_json, after_json, result_json, log_lines)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
playlist_id, playlist_name, source, profile_id, trigger_source,
started_at, finished_at, duration_seconds, status, summary,
before_json, after_json, result_json, log_lines,
))
cursor.execute("""
DELETE FROM playlist_pipeline_run_history
WHERE profile_id = ? AND id NOT IN (
SELECT id FROM playlist_pipeline_run_history
WHERE profile_id = ?
ORDER BY id DESC LIMIT 300
)
""", (profile_id, profile_id))
conn.commit()
return True
except Exception as e:
logger.error(f"Error inserting playlist pipeline run history for {playlist_id}: {e}")
return False
def get_playlist_pipeline_run_history(self, profile_id=1, playlist_id=None, limit=50, offset=0):
"""Get playlist pipeline run history, newest first."""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
where = ["profile_id = ?"]
params = [profile_id]
if playlist_id:
where.append("playlist_id = ?")
params.append(playlist_id)
where_sql = " AND ".join(where)
cursor.execute(
f"SELECT COUNT(*) FROM playlist_pipeline_run_history WHERE {where_sql}",
params,
)
total = cursor.fetchone()[0]
cursor.execute(f"""
SELECT id, playlist_id, playlist_name, source, profile_id, trigger_source,
started_at, finished_at, duration_seconds, status, summary,
before_json, after_json, result_json, log_lines
FROM playlist_pipeline_run_history
WHERE {where_sql}
ORDER BY id DESC
LIMIT ? OFFSET ?
""", [*params, limit, offset])
cols = [d[0] for d in cursor.description]
rows = [dict(zip(cols, row, strict=False)) for row in cursor.fetchall()]
return {'history': rows, 'total': total}
except Exception as e:
logger.error(f"Error getting playlist pipeline run history: {e}")
return {'history': [], 'total': 0}
def get_radio_tracks(self, track_id, limit=20, exclude_ids=None) -> Dict[str, Any]:
"""Find similar tracks for radio mode auto-play queue.

View file

@ -10,6 +10,24 @@ from core.matching_engine import MusicMatchingEngine, MatchResult
logger = get_logger("sync_service")
# Per-artist track pool cap. High enough that no plausible artist hits it
# (the largest catalogs in our test libraries sit in the low thousands), low
# enough to avoid pathological pulls if the DB ever returns garbage.
_POOL_FETCH_LIMIT = 10000
def _artist_name(artist) -> str:
"""Pull the display name out of a Spotify artist entry — they come back
as bare strings on some endpoints and ``{name: ...}`` dicts on others.
Falls back to ``str(artist)`` so callers never get None."""
if isinstance(artist, str):
return artist
if isinstance(artist, dict):
name = artist.get('name')
if isinstance(name, str):
return name
return str(artist) if artist is not None else ''
@dataclass
class SyncResult:
playlist_name: str
@ -209,33 +227,33 @@ class PlaylistSyncService:
return self._create_error_result(playlist.name, ["Sync cancelled"])
total_tracks = len(playlist.tracks)
media_client, server_type = self._get_active_media_client()
media_client, server_type = self._get_active_media_client()
self._update_progress(playlist.name, f"Matching tracks against {server_type.title()} library", "", 20, 5, 2, total_tracks=total_tracks)
# Empty dict (not None) signals pooling is enabled for this sync;
# entries are filled lazily by `_find_track_in_media_server` so warm
# caches pay zero pool cost.
candidate_pool: Dict[str, list] = {}
# Use the same robust matching approach as "Download Missing Tracks"
match_results = []
for i, track in enumerate(playlist.tracks):
if self._cancelled:
return self._create_error_result(playlist.name, ["Sync cancelled"])
# Update progress for each track
progress_percent = 20 + (40 * (i + 1) / total_tracks) # 20-60% for matching
# Extract artist name from both string and dict formats
if track.artists:
first_artist = track.artists[0]
artist_name = first_artist if isinstance(first_artist, str) else (first_artist.get('name', 'Unknown') if isinstance(first_artist, dict) else str(first_artist))
current_track_name = f"{artist_name} - {track.name}"
current_track_name = f"{_artist_name(track.artists[0]) or 'Unknown'} - {track.name}"
else:
current_track_name = track.name
self._update_progress(playlist.name, "Matching tracks", current_track_name, progress_percent, 5, 2,
self._update_progress(playlist.name, "Matching tracks", current_track_name, progress_percent, 5, 2,
total_tracks=total_tracks,
matched_tracks=len([r for r in match_results if r.is_match]),
failed_tracks=len([r for r in match_results if not r.is_match]))
# Use the robust search approach
plex_match, confidence = await self._find_track_in_media_server(track)
plex_match, confidence = await self._find_track_in_media_server(track, candidate_pool=candidate_pool)
match_result = MatchResult(
spotify_track=track,
@ -469,7 +487,42 @@ class PlaylistSyncService:
self.clear_progress_callback(playlist.name)
self._cancelled = False
async def _find_track_in_media_server(self, spotify_track: SpotifyTrack) -> Tuple[Optional[TrackInfo], float]:
def _get_or_fetch_artist_candidates(self, candidate_pool: Optional[Dict[str, list]], db, artist_name: str, active_server) -> Optional[list]:
"""Lazy per-artist pool fetch. Returns None when pooling is off so the
caller falls back to the per-track SQL loop; otherwise returns the
cached list (possibly empty), fetching on first miss."""
if candidate_pool is None:
return None
from core.text.normalize import normalize_for_comparison
key = normalize_for_comparison(artist_name)
if key in candidate_pool:
return candidate_pool[key]
try:
# Fast path — indexed artist_id lookup. Hits idx_artists_name +
# idx_tracks_artist_id, returns in milliseconds for both hits and
# misses. Handles the 90%+ exact-name case.
candidates = db.get_artist_tracks_indexed(
artist_name,
server_source=active_server,
limit=_POOL_FETCH_LIMIT,
)
# Slow-path fallback only when fast path found nothing. Preserves
# recall for diacritic variants and `tracks.track_artist` features
# (compilations / soundtracks where the per-track artist differs
# from the album artist).
if not candidates:
candidates = db.search_tracks(
artist=artist_name,
limit=_POOL_FETCH_LIMIT,
server_source=active_server,
)
candidate_pool[key] = candidates or []
return candidate_pool[key]
except Exception as fetch_err:
logger.debug(f"Candidate pool fetch failed for '{artist_name}': {fetch_err}")
return None
async def _find_track_in_media_server(self, spotify_track: SpotifyTrack, candidate_pool: Optional[Dict[str, list]] = None) -> Tuple[Optional[TrackInfo], float]:
"""Find a track using the same improved database matching as Download Missing Tracks modal"""
try:
# Check active media server connection
@ -477,7 +530,7 @@ class PlaylistSyncService:
if not media_client or not media_client.is_connected():
logger.warning(f"{server_type.upper()} client not connected")
return None, 0.0
# Use the SAME improved database matching as PlaylistTrackAnalysisWorker
from database.music_database import MusicDatabase
from config.settings import config_manager
@ -531,18 +584,19 @@ class PlaylistSyncService:
if self._cancelled:
return None, 0.0
# Extract artist name from both string and dict formats
if isinstance(artist, str):
artist_name = artist
elif isinstance(artist, dict) and 'name' in artist:
artist_name = artist['name']
else:
artist_name = str(artist)
artist_name = _artist_name(artist)
# Use the improved database check_track_exists method with server awareness
try:
db = MusicDatabase()
db_track, confidence = db.check_track_exists(original_title, artist_name, confidence_threshold=0.7, server_source=active_server)
artist_candidates = self._get_or_fetch_artist_candidates(
candidate_pool, db, artist_name, active_server,
)
db_track, confidence = db.check_track_exists(
original_title, artist_name,
confidence_threshold=0.7, server_source=active_server,
candidate_tracks=artist_candidates,
)
if db_track and confidence >= 0.7:
logger.debug(f"Database match found for '{original_title}' by '{artist_name}': '{db_track.title}' with confidence {confidence:.2f}")

View file

@ -28,7 +28,8 @@ class _FakeDB:
return dict(self.automations[automation_id]) if automation_id in self.automations else None
def create_automation(self, name, trigger_type, trigger_config, action_type, action_config,
profile_id, notify_type, notify_config, then_actions, group_name):
profile_id, notify_type, notify_config, then_actions, group_name,
owned_by=None):
aid = self._next_id
self._next_id += 1
self.automations[aid] = {
@ -37,6 +38,7 @@ class _FakeDB:
'action_config': action_config, 'profile_id': profile_id,
'notify_type': notify_type, 'notify_config': notify_config,
'then_actions': then_actions, 'group_name': group_name,
'owned_by': owned_by,
'enabled': 1, 'is_system': 0,
}
return aid
@ -289,6 +291,91 @@ def test_update_then_actions_clears_notify_when_empty():
assert db.automations[1]['notify_config'] == '{}'
def test_update_trigger_config_change_resets_next_run():
"""Changing the interval must blank stored next_run so the engine
recomputes from scratch otherwise dragging a playlist from the 8h
Auto-Sync column to the 1h column keeps firing at the old 8h mark."""
db = _FakeDB()
db.automations[1] = {
'id': 1, 'name': 'a', 'enabled': 1, 'is_system': 0,
'trigger_type': 'schedule',
'trigger_config': '{"interval": 8, "unit": "hours"}',
'then_actions': '[]',
'next_run': '2026-05-25 05:00:00',
}
eng = _FakeEngine()
body, status = api.update_automation(db, eng, automation_id=1, data={
'trigger_config': {'interval': 1, 'unit': 'hours'},
})
assert status == 200
assert db.automations[1]['next_run'] is None
assert eng.scheduled == [1]
def test_update_trigger_type_change_resets_next_run():
"""Switching from interval to daily_time must also reset next_run."""
db = _FakeDB()
db.automations[1] = {
'id': 1, 'name': 'a', 'enabled': 1, 'is_system': 0,
'trigger_type': 'schedule', 'trigger_config': '{}',
'then_actions': '[]',
'next_run': '2026-05-25 05:00:00',
}
api.update_automation(db, _FakeEngine(), automation_id=1, data={
'trigger_type': 'daily_time',
})
assert db.automations[1]['next_run'] is None
def test_update_non_trigger_field_preserves_next_run():
"""Renaming an automation must NOT disturb its scheduled clock —
the reset is scoped to schedule-shape changes only."""
db = _FakeDB()
db.automations[1] = {
'id': 1, 'name': 'a', 'enabled': 1, 'is_system': 0,
'trigger_type': 'schedule', 'trigger_config': '{}',
'then_actions': '[]',
'next_run': '2026-05-25 05:00:00',
}
api.update_automation(db, _FakeEngine(), automation_id=1, data={'name': 'renamed'})
assert db.automations[1].get('next_run') == '2026-05-25 05:00:00'
def test_create_with_owned_by_persists_marker():
"""Auto-Sync schedule board posts `owned_by: 'auto_sync'` so it can
recognize its own rows on subsequent reads without name-prefix
string scraping."""
db = _FakeDB()
api.create_automation(db, _FakeEngine(), profile_id=1, data={
'name': 'Auto-Sync: Discover Weekly',
'trigger_type': 'schedule',
'trigger_config': {'interval': 1, 'unit': 'hours'},
'action_type': 'playlist_pipeline',
'owned_by': 'auto_sync',
})
assert db.automations[1]['owned_by'] == 'auto_sync'
def test_create_without_owned_by_defaults_to_none():
db = _FakeDB()
api.create_automation(db, _FakeEngine(), profile_id=1, data={
'name': 'Plain', 'trigger_type': 'schedule', 'action_type': 'process_wishlist',
})
assert db.automations[1]['owned_by'] is None
def test_update_can_set_or_clear_owned_by():
db = _FakeDB()
db.automations[1] = {
'id': 1, 'name': 'a', 'enabled': 1, 'is_system': 0,
'trigger_type': 'schedule', 'owned_by': None,
}
api.update_automation(db, _FakeEngine(), automation_id=1, data={'owned_by': 'auto_sync'})
assert db.automations[1]['owned_by'] == 'auto_sync'
api.update_automation(db, _FakeEngine(), automation_id=1, data={'owned_by': None})
assert db.automations[1]['owned_by'] is None
# ---------------------------------------------------------------------------
# batch_update_group
# ---------------------------------------------------------------------------

View file

@ -132,3 +132,38 @@ def test_skipped_status_records_no_error(engine_with_handler) -> None:
engine.run_automation(1, skip_delay=True)
kwargs = db_mock.update_automation_run.call_args.kwargs
assert kwargs.get('error') is None
def test_guarded_scheduled_skip_retries_soon() -> None:
"""A busy guarded action should retry soon instead of waiting a full interval."""
db_mock = MagicMock()
db_mock.get_automation.return_value = {
'id': 1,
'name': 'Playlist Pipeline',
'enabled': True,
'action_type': 'playlist_pipeline',
'action_config': '{}',
'trigger_type': 'schedule',
'trigger_config': '{"interval": 6, "unit": "hours"}',
}
db_mock.update_automation_run = MagicMock(return_value=True)
engine = AutomationEngine(db_mock)
engine._running = True
engine.schedule_automation = MagicMock()
engine._action_handlers['playlist_pipeline'] = {
'handler': lambda config: {'status': 'completed'},
'guard': lambda: True,
}
engine.run_automation(1, skip_delay=True)
kwargs = db_mock.update_automation_run.call_args.kwargs
assert kwargs.get('error') is None
assert kwargs.get('next_run') is not None
# It should be scheduled for roughly five minutes, not the configured six hours.
from datetime import datetime, timezone
next_run = datetime.strptime(kwargs['next_run'], '%Y-%m-%d %H:%M:%S').replace(tzinfo=timezone.utc)
delay = (next_run - datetime.now(timezone.utc)).total_seconds()
assert 0 < delay <= 360

View file

@ -26,9 +26,11 @@ import pytest
from core.automation.deps import AutomationDeps, AutomationState
from core.automation.handlers.discover_playlist import auto_discover_playlist
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 ──────────────────────────────────────────────
@ -73,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(),
@ -93,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: {},
@ -189,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:
@ -266,6 +296,285 @@ class TestRefreshMirrored:
assert result['errors'] == '1'
assert result['refreshed'] == '0'
def test_spotify_public_missing_source_url_is_reported_as_error(self):
db = _StubDB(playlists=[
{'id': 1, 'name': 'No URL', 'source': 'spotify_public', 'source_playlist_id': 'hash'},
])
progress = []
deps = _build_deps(
get_database=lambda: db,
update_progress=lambda *a, **k: progress.append(k),
)
result = auto_refresh_mirrored({'playlist_id': '1'}, deps)
assert result['status'] == 'completed'
assert result['refreshed'] == '0'
assert result['errors'] == '1'
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_listenbrainz_refresh_runs_discovery_and_writes_matched_data(self):
"""End-to-end: LB cached playlist → adapter projects to
NormalizedTrack with needs_discovery=True refresh_mirrored
calls source.discover_tracks matched_data lands in
extra_data on the mirror DB row."""
from core.playlists.sources.bootstrap import build_playlist_source_registry
from core.playlists.sources.listenbrainz import ListenBrainzPlaylistSource
class _StubLBManager:
def get_cached_playlists(self, playlist_type):
if playlist_type == 'created_for_user':
return [{
'playlist_mbid': 'lb-1',
'title': 'LB Weekly',
'creator': 'ListenBrainz',
'track_count': 1,
'annotation': {},
'last_updated': '2026-05-26',
}]
return []
def get_playlist_type(self, mbid):
return 'created_for_user' if mbid == 'lb-1' else ''
def get_cached_tracks(self, mbid):
if mbid == 'lb-1':
return [{
'track_name': 'MB Song',
'artist_name': 'MB Artist',
'album_name': 'MB Album',
'duration_ms': 240_000,
'recording_mbid': 'rec-1',
'release_mbid': 'rel-1',
'album_cover_url': '',
'additional_metadata': {},
}]
return []
def update_all_playlists(self):
pass
discovery_calls = []
def fake_discover(track_dicts):
discovery_calls.append(list(track_dicts))
return [{
'id': 'sp-matched',
'name': 'Matched',
'artists': ['Spotify Artist'],
'album': {'name': 'Spotify Album'},
'duration_ms': 240_000,
'image_url': 'art',
'source': 'spotify',
'_provider': 'spotify',
'_confidence': 0.93,
}]
# Build a registry with the LB adapter pre-wired with discovery.
# The default _build_deps registry won't have discovery wired,
# so we override it for this test.
registry = build_playlist_source_registry(
spotify_client_getter=lambda: None,
tidal_client_getter=lambda: None,
qobuz_client_getter=lambda: None,
deezer_client_getter=lambda: None,
listenbrainz_manager_getter=lambda: _StubLBManager(),
discover_callable=fake_discover,
)
db = _StubDB(playlists=[
{
'id': 33,
'name': 'LB Weekly',
'source': 'listenbrainz',
'source_playlist_id': 'lb-1',
'profile_id': 1,
},
])
deps = _build_deps(get_database=lambda: db)
# Replace the default registry with the one wired up above.
# AutomationDeps is a frozen dataclass-like — re-assign via
# object.__setattr__ since it's a plain dataclass.
object.__setattr__(deps, 'playlist_source_registry', registry)
result = auto_refresh_mirrored({'playlist_id': '33'}, deps)
assert result['status'] == 'completed'
assert result['refreshed'] == '1'
# discover_tracks ran once, with the single MB track.
assert len(discovery_calls) == 1
assert discovery_calls[0][0]['track_name'] == 'MB Song'
assert discovery_calls[0][0]['artist_name'] == 'MB Artist'
# Mirror DB row carries the matched_data extra_data.
call = db.mirror_calls[0]
assert call['source'] == 'listenbrainz'
assert len(call['tracks']) == 1
track = call['tracks'][0]
assert track['source_track_id'] == 'sp-matched'
extra = json.loads(track['extra_data'])
assert extra['discovered'] is True
assert extra['provider'] == 'spotify'
assert extra['matched_data']['id'] == 'sp-matched'
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 ───────────────────────────────────────────────────
@ -368,6 +677,19 @@ class TestSyncPlaylist:
class TestPlaylistPipeline:
def test_pipeline_skips_when_shared_lock_is_already_running(self):
deps = _build_deps()
deps.state.set_pipeline_running(True)
result = auto_playlist_pipeline({'all': True}, deps)
assert result == {
'status': 'skipped',
'reason': 'playlist_pipeline is already running',
'_manages_own_progress': True,
}
assert deps.state.pipeline_running is True
def test_no_playlist_specified_returns_error(self):
deps = _build_deps()
result = auto_playlist_pipeline({}, deps)
@ -398,3 +720,26 @@ class TestPlaylistPipeline:
assert result['status'] == 'error'
assert result['_manages_own_progress'] is True
assert deps.state.pipeline_running is False
def test_shared_sync_tail_counts_background_sync_errors(self):
progress = []
sync_states = {
'auto_mirror_1': {'status': 'error', 'error': 'media server unavailable'},
}
deps = _build_deps(
get_sync_states=lambda: sync_states,
update_progress=lambda *a, **k: progress.append(k),
)
result = run_sync_and_wishlist(
deps,
'auto-1',
[{'id': 1, 'name': 'Broken'}],
sync_one_fn=lambda _pl: {'status': 'started'},
sync_id_for_fn=lambda _pl: 'auto_mirror_1',
skip_wishlist=True,
)
assert result['errors'] == 1
assert result['synced'] == 0
assert any(p.get('log_type') == 'error' and 'media server unavailable' in p.get('log_line', '') for p in progress)

View file

@ -311,6 +311,14 @@ class TestAutomationState:
s.set_pipeline_running(False)
assert s.is_pipeline_running() is False
def test_try_start_pipeline_is_atomic(self):
s = AutomationState()
assert s.try_start_pipeline() is True
assert s.is_pipeline_running() is True
assert s.try_start_pipeline() is False
s.set_pipeline_running(False)
assert s.try_start_pipeline() is True
def test_concurrent_set_safe_via_lock(self):
# Smoke test: two threads flipping the same field don't crash.
# Lock ensures the final value is consistent.

View file

@ -0,0 +1,87 @@
from __future__ import annotations
import sqlite3
from database.music_database import MusicDatabase
class _InMemoryDB(MusicDatabase):
def __init__(self):
self._conn = sqlite3.connect(":memory:")
self._conn.row_factory = sqlite3.Row
def _get_connection(self):
return _NonClosingConn(self._conn)
class _NonClosingConn:
def __init__(self, real):
self._real = real
def cursor(self):
return self._real.cursor()
def commit(self):
return self._real.commit()
def close(self):
pass
class _Album:
ratingKey = "album-1"
title = "Flower Boy"
year = 2017
leafCount = 15
duration = 2940
genres = []
thumb = None
def _seed(db):
cur = db._conn.cursor()
cur.execute("""
CREATE TABLE albums (
id TEXT PRIMARY KEY,
artist_id TEXT,
title TEXT,
year INTEGER,
thumb_url TEXT,
genres TEXT,
track_count INTEGER,
duration INTEGER,
server_source TEXT,
created_at TEXT,
updated_at TEXT
)
""")
cur.execute("""
INSERT INTO albums
(id, artist_id, title, year, thumb_url, server_source)
VALUES
('album-1', 'artist-1', 'Flower Boy', 2017, '/rest/getCoverArt?id=correct-cover', 'navidrome')
""")
db._conn.commit()
def test_album_refresh_preserves_existing_thumb_when_incoming_thumb_missing():
db = _InMemoryDB()
_seed(db)
assert db.insert_or_update_media_album(_Album(), "artist-1", server_source="navidrome") is True
row = db._conn.execute("SELECT thumb_url FROM albums WHERE id = 'album-1'").fetchone()
assert row["thumb_url"] == "/rest/getCoverArt?id=correct-cover"
def test_album_refresh_updates_existing_thumb_when_incoming_thumb_present():
db = _InMemoryDB()
_seed(db)
album = _Album()
album.thumb = "/rest/getCoverArt?id=new-cover"
assert db.insert_or_update_media_album(album, "artist-1", server_source="navidrome") is True
row = db._conn.execute("SELECT thumb_url FROM albums WHERE id = 'album-1'").fetchone()
assert row["thumb_url"] == "/rest/getCoverArt?id=new-cover"

View file

@ -0,0 +1,127 @@
"""Tests for `MusicDatabase.get_artist_tracks_indexed` — the indexed
two-step lookup that backs the sync candidate pool fast path."""
from __future__ import annotations
from database.music_database import MusicDatabase
def _seed(db: MusicDatabase, rows):
"""Insert (artist_name, album_title, track_title, server_source) tuples.
IDs are TEXT PRIMARY KEY in this schema so we hand-mint string IDs to
keep foreign-key wiring happy."""
conn = db._get_connection()
cursor = conn.cursor()
artist_ids: dict = {}
album_ids: dict = {}
track_counter = 0
for artist_name, album_title, track_title, server_source in rows:
if artist_name not in artist_ids:
aid = f"a-{len(artist_ids) + 1}"
cursor.execute(
"INSERT INTO artists (id, name, server_source) VALUES (?, ?, ?)",
(aid, artist_name, server_source),
)
artist_ids[artist_name] = aid
album_key = (artist_name, album_title, server_source)
if album_key not in album_ids:
alid = f"al-{len(album_ids) + 1}"
cursor.execute(
"INSERT INTO albums (id, artist_id, title, server_source) VALUES (?, ?, ?, ?)",
(alid, artist_ids[artist_name], album_title, server_source),
)
album_ids[album_key] = alid
track_counter += 1
cursor.execute(
"INSERT INTO tracks (id, album_id, artist_id, title, server_source) VALUES (?, ?, ?, ?, ?)",
(f"t-{track_counter}", album_ids[album_key], artist_ids[artist_name], track_title, server_source),
)
conn.commit()
def test_exact_name_match_returns_tracks(tmp_path):
db = MusicDatabase(str(tmp_path / "music.db"))
_seed(db, [
('Drake', 'For All The Dogs', 'First Person Shooter', 'plex'),
('Drake', 'For All The Dogs', 'Slime You Out', 'plex'),
('SZA', 'SOS', 'Kill Bill', 'plex'),
])
tracks = db.get_artist_tracks_indexed('Drake')
titles = sorted(t.title for t in tracks)
assert titles == ['First Person Shooter', 'Slime You Out']
def test_case_insensitive_fallback_finds_artist(tmp_path):
"""Exact match misses 'DRAKE' (case-sensitive index lookup), but the
fallback LOWER() comparison still finds the canonical 'Drake' row."""
db = MusicDatabase(str(tmp_path / "music.db"))
_seed(db, [
('Drake', 'FATD', 'IDGAF', 'plex'),
])
tracks = db.get_artist_tracks_indexed('DRAKE')
assert len(tracks) == 1
assert tracks[0].title == 'IDGAF'
def test_artist_absent_returns_empty_list(tmp_path):
"""Genuinely missing artists must fall straight through both steps
and return [] that's what lets the caller skip the slow LIKE
fallback when the artist isn't in the library at all."""
db = MusicDatabase(str(tmp_path / "music.db"))
_seed(db, [
('Drake', 'FATD', 'IDGAF', 'plex'),
])
assert db.get_artist_tracks_indexed('Nonexistent Artist') == []
def test_empty_name_returns_empty(tmp_path):
db = MusicDatabase(str(tmp_path / "music.db"))
assert db.get_artist_tracks_indexed('') == []
def test_server_source_filter_excludes_other_servers(tmp_path):
"""The pool is per-server — Plex sync must not see Jellyfin tracks
even when the artist exists on both."""
db = MusicDatabase(str(tmp_path / "music.db"))
_seed(db, [
('Drake', 'Plex Album', 'Plex Track', 'plex'),
('Drake', 'Jellyfin Album', 'Jellyfin Track', 'jellyfin'),
])
plex_tracks = db.get_artist_tracks_indexed('Drake', server_source='plex')
jellyfin_tracks = db.get_artist_tracks_indexed('Drake', server_source='jellyfin')
assert [t.title for t in plex_tracks] == ['Plex Track']
assert [t.title for t in jellyfin_tracks] == ['Jellyfin Track']
def test_no_server_filter_returns_all_servers(tmp_path):
db = MusicDatabase(str(tmp_path / "music.db"))
_seed(db, [
('Drake', 'Plex Album', 'Plex Track', 'plex'),
('Drake', 'Jellyfin Album', 'Jellyfin Track', 'jellyfin'),
])
tracks = db.get_artist_tracks_indexed('Drake')
titles = sorted(t.title for t in tracks)
assert titles == ['Jellyfin Track', 'Plex Track']
def test_limit_caps_result_set(tmp_path):
db = MusicDatabase(str(tmp_path / "music.db"))
_seed(db, [('Drake', 'Album', f'Track {i}', 'plex') for i in range(10)])
tracks = db.get_artist_tracks_indexed('Drake', limit=3)
assert len(tracks) == 3
def test_returned_tracks_carry_artist_and_album_fields(tmp_path):
"""check_track_exists' batched path reads `artist_name` and
`album_title` off each track for confidence scoring verify the
indexed query attaches them like search_tracks does."""
db = MusicDatabase(str(tmp_path / "music.db"))
_seed(db, [
('Drake', 'For All The Dogs', 'First Person Shooter', 'plex'),
])
tracks = db.get_artist_tracks_indexed('Drake')
assert len(tracks) == 1
t = tracks[0]
assert t.artist_name == 'Drake'
assert t.album_title == 'For All The Dogs'
assert t.title == 'First Person Shooter'

View file

@ -0,0 +1,62 @@
from database.music_database import MusicDatabase
def test_update_mirrored_playlist_source_ref_preserves_tracks(tmp_path):
db = MusicDatabase(str(tmp_path / "music.db"))
playlist_id = db.mirror_playlist(
source="youtube",
source_playlist_id="oldhash",
name="Mirror",
tracks=[
{
"track_name": "Song",
"artist_name": "Artist",
"source_track_id": "yt1",
"extra_data": {"discovered": True},
}
],
profile_id=1,
description="https://youtube.com/playlist?list=old",
)
assert playlist_id is not None
updated = db.update_mirrored_playlist_source_ref(
playlist_id,
"newhash",
"https://youtube.com/playlist?list=new",
)
assert updated is True
playlist = db.get_mirrored_playlist(playlist_id)
assert playlist["source_playlist_id"] == "newhash"
assert playlist["description"] == "https://youtube.com/playlist?list=new"
tracks = db.get_mirrored_playlist_tracks(playlist_id)
assert len(tracks) == 1
assert tracks[0]["track_name"] == "Song"
assert tracks[0]["extra_data"] is not None
def test_mirror_playlist_refresh_preserves_existing_description(tmp_path):
db = MusicDatabase(str(tmp_path / "music.db"))
playlist_id = db.mirror_playlist(
source="spotify_public",
source_playlist_id="hash",
name="Release Radar",
tracks=[{"track_name": "Song", "artist_name": "Artist"}],
profile_id=1,
description="https://open.spotify.com/playlist/abc",
)
refreshed_id = db.mirror_playlist(
source="spotify_public",
source_playlist_id="hash",
name="Release Radar",
tracks=[{"track_name": "New Song", "artist_name": "Artist"}],
profile_id=1,
)
assert refreshed_id == playlist_id
playlist = db.get_mirrored_playlist(playlist_id)
assert playlist["description"] == "https://open.spotify.com/playlist/abc"

View file

@ -232,6 +232,36 @@ def test_unmatched_by_user_respected():
assert deps._db.extra_data_writes == []
def test_manual_match_skipped_even_when_matched_data_incomplete():
"""manual_match=True must skip the incomplete-matched_data re-discovery
branch. The Fix-popup save shape is intentionally lean search-result
rows don't carry track_number, and the MBID-lookup flat shape doesn't
carry album.id / release_date so a manual fix always looks 'incomplete'
to the old check and used to be re-discovered every pipeline run,
overwriting the user's deliberate pick with whatever the auto-search
ranked first. Pin the fix: manual matches stay put."""
extra = {
'discovered': True,
'manual_match': True,
'provider': 'musicbrainz',
'matched_data': {
'id': 'mb-rec-id',
'name': 'Coffee Break',
'artists': ['Zeds Dead'],
'album': {'name': 'Coffee Break'}, # no id, no release_date
'source': 'musicbrainz',
# no track_number — Fix-popup shape never has it
},
}
tracks = [_track(track_id=1, extra_data=extra)]
deps = _build_deps(tracks_by_playlist={'p1': tracks})
dp.run_playlist_discovery_worker([_playlist('p1')], deps=deps)
# No extra_data writes — the manual match wasn't overwritten
assert deps._db.extra_data_writes == []
# ---------------------------------------------------------------------------
# Cache hit short-circuit
# ---------------------------------------------------------------------------

View file

@ -0,0 +1,106 @@
"""Tests for core.discovery.manual_match helpers.
These pin the contract for two route-layer decisions lifted out of
web_server.py so the Fix-popup mirrored-playlist back-sync flow is
testable in isolation (per kettui's standing rule that web_server.py
behavior is reproduced in core/ modules with real unit tests, not by
AST-parsing the route file).
"""
from core.discovery.manual_match import (
derive_manual_match_provider,
is_drifted_for_redo,
)
# ---------------------------------------------------------------------------
# derive_manual_match_provider
# ---------------------------------------------------------------------------
def test_derive_uses_payload_source_when_present():
"""Search-endpoint payloads always stamp `source` — that's the
authoritative provider for a manual match."""
payload = {'id': 'rec-1', 'source': 'musicbrainz', 'name': 'Track'}
assert derive_manual_match_provider(payload, 'spotify') == 'musicbrainz'
def test_derive_falls_back_to_active_when_payload_missing_source():
"""MBID-paste path returns a lean flat shape without `source`. Fall
back to the user's active discovery source so the cached match
matches whatever provider next compares against it."""
payload = {'id': 'mb-mbid', 'name': 'Track'} # no `source`
assert derive_manual_match_provider(payload, 'musicbrainz') == 'musicbrainz'
def test_derive_falls_back_to_spotify_when_both_missing():
"""Last-ditch default matches the historic hardcode so behaviour is
identical when both upstream signals are absent (e.g. broken
config, missing active source)."""
assert derive_manual_match_provider({}, None) == 'spotify'
assert derive_manual_match_provider({}, '') == 'spotify'
def test_derive_handles_non_dict_payload_gracefully():
"""Defensive — caller passes whatever request.get_json() returned."""
assert derive_manual_match_provider(None, 'spotify') == 'spotify'
assert derive_manual_match_provider('not-a-dict', 'musicbrainz') == 'musicbrainz'
def test_derive_payload_source_wins_even_when_active_set():
"""`source` on payload is authoritative — even if the user's active
source changed mid-flow, the match came from whatever the popup
cascade actually queried."""
payload = {'source': 'itunes'}
assert derive_manual_match_provider(payload, 'spotify') == 'itunes'
# ---------------------------------------------------------------------------
# is_drifted_for_redo
# ---------------------------------------------------------------------------
def test_drift_redo_when_provider_changed_and_not_manual():
"""Standard provider-drift case: cached provider differs from
active, no manual flag re-discover so active source's IDs /
artwork take effect."""
extra = {'discovered': True, 'provider': 'spotify'}
assert is_drifted_for_redo(extra, 'musicbrainz') is True
def test_drift_no_redo_when_provider_matches():
"""Same provider → cached entry is fresh, no redo needed."""
extra = {'discovered': True, 'provider': 'spotify'}
assert is_drifted_for_redo(extra, 'spotify') is False
def test_drift_no_redo_when_manual_match_even_if_provider_drifted():
"""The crux of the bug fix: manual matches are exempt from
provider-drift redo. Re-running would overwrite the user's pick."""
extra = {'discovered': True, 'provider': 'musicbrainz', 'manual_match': True}
assert is_drifted_for_redo(extra, 'spotify') is False
def test_drift_no_redo_when_manual_match_with_matching_provider():
"""Manual + provider match: trivially fresh."""
extra = {'discovered': True, 'provider': 'spotify', 'manual_match': True}
assert is_drifted_for_redo(extra, 'spotify') is False
def test_drift_no_redo_when_extra_data_missing():
"""No cached entry → nothing to drift from."""
assert is_drifted_for_redo(None, 'spotify') is False
assert is_drifted_for_redo({}, 'spotify') is False
def test_drift_handles_non_dict_extra_data():
"""Defensive — extra_data deserialisation can land non-dict shapes."""
assert is_drifted_for_redo('not-a-dict', 'spotify') is False
def test_drift_default_provider_is_spotify_when_absent():
"""Historic cached entries may pre-date the provider column being
populated treat absent provider as 'spotify' (the legacy default)."""
extra = {'discovered': True} # no provider field
assert is_drifted_for_redo(extra, 'spotify') is False
assert is_drifted_for_redo(extra, 'musicbrainz') is True

View file

@ -419,6 +419,48 @@ def test_candidates_with_equal_confidence_both_tried():
assert deps.download_orchestrator.download_calls[0][1] == "a.flac"
def test_user_manual_pick_injects_acoustid_bypass_into_post_process_context():
"""Issue #701: when the user picks a specific candidate via the
candidates modal, the download_selected_candidate endpoint sets
`_user_manual_pick=True` on the task. The candidates helper must
propagate that into the stored post-process context as
`_skip_quarantine_check='acoustid'`; without it the manual pick
loops straight back into quarantine whenever AcoustID disagrees
with the user's selection."""
deps = _build_deps()
_seed_task("t_manual_pick")
download_tasks["t_manual_pick"]["_user_manual_pick"] = True
candidates = [_Candidate(filename="picked.flac", confidence=0.99)]
track = _Track()
result = dc.attempt_download_with_candidates("t_manual_pick", candidates, track, batch_id="b1", deps=deps)
assert result is True
ctx = matched_downloads_context["user1::picked.flac"]
assert ctx["_skip_quarantine_check"] == "acoustid"
assert ctx["_user_manual_pick"] is True
def test_auto_search_pick_does_not_inject_acoustid_bypass():
"""The bypass is ONLY for user-initiated manual picks. Auto-search
candidate picks (which run during the normal download flow) must
still get AcoustID verification they're the canonical guard
against the wrong-file leak that quarantine exists to catch."""
deps = _build_deps()
_seed_task("t_auto_pick") # No _user_manual_pick flag set
candidates = [_Candidate(filename="auto.flac", confidence=0.99)]
track = _Track()
result = dc.attempt_download_with_candidates("t_auto_pick", candidates, track, batch_id="b1", deps=deps)
assert result is True
ctx = matched_downloads_context["user1::auto.flac"]
assert "_skip_quarantine_check" not in ctx
assert "_user_manual_pick" not in ctx
def test_equal_confidence_candidates_prefer_better_peer_quality():
"""Equal-confidence Soulseek candidates use peer quality as the tiebreaker."""
deps = _build_deps()

View file

@ -526,6 +526,36 @@ def test_no_missing_with_auto_wishlist_submits_completion(monkeypatch):
assert args == ('B7',)
def test_no_missing_album_does_not_dispatch_torrent_bundle(monkeypatch):
"""Release-level sources must wait until analysis confirms missing tracks."""
album = _DBAlbum(id_=42, title='Test Album')
db = _FakeDB(album=album, album_tracks=[_DBTrack('T1')])
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
plugin = _FakeAlbumBundleSoulseek()
deps = _build_deps(
config=_FakeConfig({'download_source.mode': 'torrent'}),
soulseek=_FakePluginWrapper({'torrent': plugin}),
)
_seed_batch(
'B7a',
is_album_download=True,
album_context={'name': 'Test Album', 'total_tracks': 1},
artist_context={'name': 'Artist'},
)
mw.run_full_missing_tracks_process(
'B7a',
'album:1',
[{'name': 'T1', 'artists': ['Artist'], 'track_number': 1}],
deps,
)
assert plugin.calls == []
assert download_batches['B7a']['phase'] == 'complete'
assert 'album_bundle_source' not in download_batches['B7a']
# ---------------------------------------------------------------------------
# Album fast path
# ---------------------------------------------------------------------------
@ -862,6 +892,36 @@ def test_hybrid_first_torrent_uses_album_bundle_before_per_track(monkeypatch):
assert download_batches['B27']['album_bundle_source'] == 'torrent'
def test_album_bundle_fallback_clears_private_staging(monkeypatch):
db = _FakeDB()
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
plugin = _FakeAlbumBundleSoulseek({
'success': False,
'fallback': True,
'error': 'No release passed validation',
})
deps = _build_deps(
config=_FakeConfig({'download_source.mode': 'torrent'}),
soulseek=_FakePluginWrapper({'torrent': plugin}),
)
_seed_batch(
'B29',
is_album_download=True,
album_context={'name': 'Test Album', 'total_tracks': 1},
artist_context={'name': 'Artist'},
)
tracks = [{'name': 'T1', 'artists': ['Artist'], 'track_number': 1}]
mw.run_full_missing_tracks_process('B29', 'album:1', tracks, deps)
assert len(plugin.calls) == 1
assert download_batches['B29']['album_bundle_state'] == 'fallback'
assert download_batches['B29']['album_bundle_private_staging'] is False
assert download_batches['B29']['album_bundle_staging_path'] is None
assert len(download_batches['B29']['queue']) == 1
# ---------------------------------------------------------------------------
# Task creation
# ---------------------------------------------------------------------------

View file

@ -495,6 +495,74 @@ def test_staging_title_match_keeps_wrong_versions_separate(tmp_path):
assert 'staging_t_wrong_version' not in matched_downloads_context
def test_staging_title_match_handles_untagged_release_filename(tmp_path):
"""Album-bundle slskd downloads often arrive without ID3 tags.
When that happens the staging cache falls back to the file stem
for the title (e.g. 'Kendrick Lamar - GNX - 03 - Reincarnated').
The full stem is too noisy to fuzzy-match against the clean
Spotify title at the 0.80 threshold, so the variant generator
pulls out the trailing-title segment when a track-number block
is present between ' - ' delimiters.
"""
src_file = tmp_path / 'staging' / 'Kendrick Lamar - GNX - 03 - Reincarnated.flac'
src_file.parent.mkdir()
src_file.touch()
deps = _build_deps(
transfer_path=str(tmp_path / 'transfer'),
staging_files=[
{
'full_path': str(src_file),
'title': 'Kendrick Lamar - GNX - 03 - Reincarnated',
'artist': 'Kendrick Lamar',
'track_number': 3,
},
],
)
_seed_task('t_untagged', track_info={
'_is_explicit_album_download': True,
'_explicit_album_context': {'id': 'alb', 'name': 'GNX'},
'_explicit_artist_context': {'id': 'art', 'name': 'Kendrick Lamar'},
})
result = ds.try_staging_match(
't_untagged', 'b1',
_Track(name='Reincarnated', artists=['Kendrick Lamar']),
deps,
)
assert result is True
assert matched_downloads_context['staging_t_untagged']['track_info']['track_number'] == 3
def test_staging_title_match_keeps_dash_titles_intact(tmp_path):
"""The trailing-title variant must not fire when there's no track-number
segment otherwise a legit title like 'Hold Me - Live' would generate
a 'Live' variant and false-match unrelated 'Live' stems on disk.
"""
src_file = tmp_path / 'staging' / 'Live.flac'
src_file.parent.mkdir()
src_file.touch()
deps = _build_deps(
transfer_path=str(tmp_path / 'transfer'),
staging_files=[
{'full_path': str(src_file), 'title': 'Live', 'artist': 'Other Artist'},
],
)
_seed_task('t_dash')
result = ds.try_staging_match(
't_dash', 'b1',
_Track(name='Hold Me - Live', artists=['Some Artist']),
deps,
)
assert result is False
assert 'staging_t_dash' not in matched_downloads_context
def test_fallback_context_synthesizes_from_track(tmp_path):
"""Without explicit context, synthesizes spotify_artist/album from the track."""
src_file = tmp_path / 'staging' / 'Hello.flac'

View file

@ -0,0 +1,192 @@
"""Unit tests for ``core/downloads/wishlist_aggregator.merge_wishlist_run_status``.
Pins the merge contract the wishlist-modal status path depends on
(Phase 1c.2.1 follow-up): when one logical wishlist run is split
across N sub-batches, the frontend modal polls the original
batch_id and expects a unified view that covers every sibling.
"""
from __future__ import annotations
from core.downloads.wishlist_aggregator import merge_wishlist_run_status
def _status(phase, **kwargs):
"""Build a minimal per-batch status dict shaped like
``build_batch_status_data``'s output."""
base = {
'phase': phase,
'playlist_id': 'wishlist',
'playlist_name': 'Wishlist',
'active_count': 0,
'max_concurrent': 3,
}
base.update(kwargs)
return base
def test_empty_siblings_returns_primary_unchanged():
primary = _status('downloading', tasks=[{'task_id': 't1', 'track_index': 0}])
out = merge_wishlist_run_status(primary, [])
assert out is primary
def test_two_siblings_merge_tasks_with_reindexed_track_index():
"""Both siblings locally start at track_index 0 — after merge,
indices are globally unique 0..N-1."""
primary = _status(
'downloading',
analysis_results=[
{'track_index': 0, 'track': {'name': 'A1'}, 'found': False, 'confidence': 0.0},
{'track_index': 1, 'track': {'name': 'A2'}, 'found': False, 'confidence': 0.0},
],
tasks=[
{'task_id': 'task-a1', 'track_index': 0, 'status': 'downloading'},
{'task_id': 'task-a2', 'track_index': 1, 'status': 'downloading'},
],
)
sibling = _status(
'downloading',
analysis_results=[
{'track_index': 0, 'track': {'name': 'B1'}, 'found': False, 'confidence': 0.0},
],
tasks=[
{'task_id': 'task-b1', 'track_index': 0, 'status': 'searching'},
],
)
merged = merge_wishlist_run_status(primary, [sibling])
# Three globally-unique track indices.
assert [r['track_index'] for r in merged['analysis_results']] == [0, 1, 2]
# Each task's track_index re-indexed to match its analysis_result.
indices_by_task = {t['task_id']: t['track_index'] for t in merged['tasks']}
assert indices_by_task == {'task-a1': 0, 'task-a2': 1, 'task-b1': 2}
# Tasks sorted by their new track_index.
assert [t['task_id'] for t in merged['tasks']] == ['task-a1', 'task-a2', 'task-b1']
def test_phase_aggregation_most_advanced_live_phase_wins():
"""analysis + downloading + complete → downloading. Modal's task
table is phase-gated to downloading/complete/error so we must
surface that phase the moment any sibling has tasks, else the
modal stays in bundle/analysis UI and tasks stay invisible."""
primary = _status('complete')
sibling1 = _status('downloading')
sibling2 = _status('analysis')
merged = merge_wishlist_run_status(primary, [sibling1, sibling2])
assert merged['phase'] == 'downloading'
def test_phase_aggregation_downloading_wins_over_album_downloading():
"""Regression test for the parallel-bundle modal-blank bug:
one sibling past its bundle into the task stage means the
modal MUST be in 'downloading' phase, otherwise the task
rows for the advanced sibling don't render and the user
sees nothing while both albums actually download on slskd."""
primary = _status('downloading')
sibling = _status('album_downloading')
merged = merge_wishlist_run_status(primary, [sibling])
assert merged['phase'] == 'downloading'
def test_phase_aggregation_all_album_downloading_stays_album_downloading():
"""No sibling has reached the task stage yet — bundle progress
UI is the right thing to show."""
primary = _status('album_downloading')
sibling = _status('album_downloading')
merged = merge_wishlist_run_status(primary, [sibling])
assert merged['phase'] == 'album_downloading'
def test_phase_aggregation_album_downloading_wins_over_analysis():
primary = _status('analysis')
sibling = _status('album_downloading')
merged = merge_wishlist_run_status(primary, [sibling])
assert merged['phase'] == 'album_downloading'
def test_phase_aggregation_all_complete_returns_complete():
primary = _status('complete')
sibling1 = _status('complete')
merged = merge_wishlist_run_status(primary, [sibling1])
assert merged['phase'] == 'complete'
def test_phase_aggregation_mixed_complete_and_other_returns_downloading():
"""A finished sibling alongside a still-downloading sibling
surfaces 'downloading' (the run isn't done)."""
primary = _status('complete')
sibling = _status('downloading')
merged = merge_wishlist_run_status(primary, [sibling])
assert merged['phase'] == 'downloading'
def test_phase_aggregation_error_is_sticky():
"""If any sibling errored, the merged phase is 'error' even
if other siblings are still running. Modal should show the
failure so the user notices."""
primary = _status('downloading')
sibling = _status('error')
merged = merge_wishlist_run_status(primary, [sibling])
assert merged['phase'] == 'error'
def test_analysis_progress_summed_across_siblings():
primary = _status(
'analysis',
analysis_progress={'total': 10, 'processed': 7},
)
sibling = _status(
'analysis',
analysis_progress={'total': 5, 'processed': 2},
)
merged = merge_wishlist_run_status(primary, [sibling])
assert merged['analysis_progress'] == {'total': 15, 'processed': 9}
def test_album_bundle_picks_active_sibling_over_idle():
"""Primary is past its bundle stage (state='staged');
sibling is currently downloading_release. Merge surfaces the
active sibling's bundle so the progress bar stays useful."""
primary = _status(
'downloading',
album_bundle={'state': 'staged', 'progress': 100, 'release': 'PRISM (Deluxe)'},
)
sibling = _status(
'album_downloading',
album_bundle={'state': 'downloading_release', 'progress': 42, 'release': '1432'},
)
merged = merge_wishlist_run_status(primary, [sibling])
assert merged['album_bundle']['release'] == '1432'
assert merged['album_bundle']['progress'] == 42
def test_album_bundle_falls_back_when_no_active_sibling():
primary = _status(
'complete',
album_bundle={'state': 'staged', 'progress': 100, 'release': 'PRISM (Deluxe)'},
)
sibling = _status(
'complete',
album_bundle={'state': 'staged', 'progress': 100, 'release': '1432'},
)
merged = merge_wishlist_run_status(primary, [sibling])
# Falls back to primary's bundle (first non-empty).
assert merged['album_bundle']['release'] == 'PRISM (Deluxe)'
def test_active_count_summed_across_siblings():
primary = _status('downloading', active_count=2)
sibling = _status('downloading', active_count=1)
merged = merge_wishlist_run_status(primary, [sibling])
assert merged['active_count'] == 3
def test_primary_playlist_id_preserved():
primary = _status('downloading', playlist_id='wishlist', playlist_name='Wishlist (Auto)')
sibling = _status('downloading', playlist_id='wishlist', playlist_name='Wishlist (Album: 1432)')
merged = merge_wishlist_run_status(primary, [sibling])
# Primary's playlist_name + playlist_id propagate (it's the row the modal opened against).
assert merged['playlist_id'] == 'wishlist'
assert merged['playlist_name'] == 'Wishlist (Auto)'

View file

@ -5,16 +5,40 @@ from core.imports.file_ops import (
cleanup_empty_directories,
safe_move_file,
)
from core.imports.filename import extract_track_number_from_filename
from core.imports.filename import (
extract_explicit_track_number,
extract_track_number_from_filename,
)
from core.imports.staging import read_staging_file_metadata
def test_extract_track_number_from_filename_handles_common_patterns():
assert extract_track_number_from_filename("01 - Song.mp3") == 1
assert extract_track_number_from_filename("1-03 - Song.mp3") == 3
# Bare filename keeps the auto-import-friendly default of 1 — there's
# no upstream metadata to recover from in that flow.
assert extract_track_number_from_filename("Artist - Song.mp3") == 1
def test_extract_explicit_track_number_returns_zero_when_no_prefix():
"""Staging readers need to distinguish 'track 1' from 'unknown'.
Pinned because:
- the legacy extractor defaults to 1 (auto-import semantics),
- staging file scanners that conflate the two end up writing every
file in an untagged album bundle to track_number=1.
"""
# Bare titles with no numeric prefix → 0 (unknown).
assert extract_explicit_track_number("Artist - Song.mp3") == 0
assert extract_explicit_track_number("Cha-La Head-Cha-La.flac") == 0
assert extract_explicit_track_number("") == 0
# Real prefixes still parse correctly.
assert extract_explicit_track_number("01 - Song.mp3") == 1
assert extract_explicit_track_number("(03) Song.mp3") == 3
# Disc-track format requires a separator after the track number.
assert extract_explicit_track_number("1-07 - Song.mp3") == 7
def test_safe_move_file_replaces_existing_destination(tmp_path):
src = tmp_path / "source.flac"
dst_dir = tmp_path / "dest"
@ -92,6 +116,27 @@ def test_read_staging_file_metadata_falls_back_to_filename_track_number(monkeypa
assert metadata["disc_number"] == 1
def test_read_staging_file_metadata_returns_zero_track_when_unknown(monkeypatch, tmp_path):
"""Bare filename + no tags → track_number=0, not 1.
Pre-fix this returned 1 because the filename extractor's default
was 1. The bug caused every untagged file in an album-bundle
download to land in the staging cache with track_number=1, which
then short-circuited the downstream resolution chain that should
have picked up the real number from track_info.
"""
file_path = tmp_path / "Cha-La Head-Cha-La.flac"
file_path.write_text("fake")
fake_mutagen = types.ModuleType("mutagen")
fake_mutagen.File = lambda path, easy=True: None
monkeypatch.setitem(sys.modules, "mutagen", fake_mutagen)
metadata = read_staging_file_metadata(str(file_path), file_path.name)
assert metadata["track_number"] == 0
def test_read_staging_file_metadata_uses_filename_fallbacks_when_tags_are_invalid(monkeypatch, tmp_path):
file_path = tmp_path / "02 - Song Three.flac"
file_path.write_text("fake")

View file

@ -12,7 +12,7 @@ from unittest.mock import MagicMock, patch
import pytest
from core.navidrome_client import NavidromeClient
from core.navidrome_client import NavidromeAlbum, NavidromeClient
@pytest.fixture
@ -77,3 +77,22 @@ def test_get_all_album_ids_returns_set(nav_client):
assert isinstance(result, set)
assert result == {'nav-1', 'nav-2'}
def test_navidrome_album_exposes_cover_art_url(nav_client):
album = NavidromeAlbum({
'id': 'album-1',
'name': 'Flower Boy',
'coverArt': 'cover-123',
}, nav_client)
assert album.thumb == '/rest/getCoverArt?id=cover-123'
def test_navidrome_album_cover_art_falls_back_to_album_id(nav_client):
album = NavidromeAlbum({
'id': 'album-1',
'name': 'Flower Boy',
}, nav_client)
assert album.thumb == '/rest/getCoverArt?id=album-1'

View file

@ -1000,33 +1000,84 @@ def test_get_recording_flat_swallows_client_errors():
# search_tracks_with_artist — Fix-popup cascade adapter
# ---------------------------------------------------------------------------
def test_search_tracks_with_artist_uses_bare_query_mode():
"""The Fix-popup cascade needs MB's bare-query mode so diacritics and
bracketed suffixes don't kill recall. The adapter must pass strict=False
through to the underlying search_recording call."""
def test_search_tracks_with_artist_strict_first_when_both_fields():
"""Both fields present → strict field-scoped Lucene query first
(`recording:"<t>" AND artist:"<a>"`). Fixes the "Coffee Break" +
"Zeds Dead" case where bare query lets MB's title-text-biased
scorer surface unrelated covers ahead of the canonical recording."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_recording.return_value = [
{'id': 'rec-1', 'title': 'Army of Me', 'score': 95,
'releases': [{'id': 'rel-1', 'title': 'Post', 'date': '1995'}],
'artist-credit': [{'name': 'Björk'}]},
{'id': 'rec-1', 'title': 'Coffee Break', 'score': 95,
'length': 184000,
'releases': [{'id': 'rel-1', 'title': 'Coffee Break', 'date': '2015'}],
'artist-credit': [{'name': 'Zeds Dead'}]},
]
tracks = client.search_tracks_with_artist('Army of Me', 'Björk', limit=10)
tracks = client.search_tracks_with_artist('Coffee Break', 'Zeds Dead', limit=10)
# strict=False is the critical bit — fuzzy recall, not phrase precision
# strict=True is the critical bit — anchors artist via Lucene AND clause
client._client.search_recording.assert_called_once_with(
'Army of Me', artist_name='Björk', limit=10, strict=False
'Coffee Break', artist_name='Zeds Dead', limit=10, strict=True
)
assert len(tracks) == 1
assert tracks[0].name == 'Army of Me'
assert 'Björk' in tracks[0].artists
assert tracks[0].name == 'Coffee Break'
assert 'Zeds Dead' in tracks[0].artists
def test_search_tracks_with_artist_falls_back_to_bare_when_strict_empty():
"""Strict phrase match misses diacritic / alias cases ("Bjork" query
vs canonical "Björk" artist). When strict returns nothing, fall
through to bare query so rerank can still surface the right answer."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_recording.side_effect = [
[], # strict pass → no hits (Lucene phrase match fails on diacritic)
[ # bare pass → recall via alias index
{'id': 'rec-canonical', 'title': 'Army of Me', 'score': 28,
'releases': [], 'artist-credit': [{'name': 'Björk'}]},
],
]
tracks = client.search_tracks_with_artist('Army of Me', 'Bjork', limit=10)
assert client._client.search_recording.call_count == 2
first_call = client._client.search_recording.call_args_list[0]
second_call = client._client.search_recording.call_args_list[1]
assert first_call.kwargs['strict'] is True
assert second_call.kwargs['strict'] is False
assert len(tracks) == 1
assert tracks[0].id == 'rec-canonical'
def test_search_tracks_with_artist_does_not_resort_by_length():
"""Length-preference ordering lives downstream in
``rerank_tracks(..., prefer_known_duration=True)`` sorting here
would be re-sorted away by rerank anyway, so this method preserves
the order MB returned. Pin the contract: this method does not
re-shuffle by duration_ms."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_recording.return_value = [
{'id': 'rec-no-length', 'title': 'Coffee Break', 'score': 100,
'releases': [], 'artist-credit': [{'name': 'Zeds Dead'}]},
{'id': 'rec-with-length', 'title': 'Coffee Break', 'score': 90,
'length': 184000,
'releases': [], 'artist-credit': [{'name': 'Zeds Dead'}]},
]
tracks = client.search_tracks_with_artist('Coffee Break', 'Zeds Dead', limit=10)
# MB's order is preserved here — rerank applies length-pref downstream.
assert tracks[0].id == 'rec-no-length'
assert tracks[1].id == 'rec-with-length'
def test_search_tracks_with_artist_handles_missing_artist():
"""Track-only query (no artist) still works — empty string becomes
None, and the underlying client searches recordings without an
artist filter."""
"""Track-only query (no artist) still works — single-field path takes
bare-query mode directly (no strict-first round-trip since there's no
artist to anchor). Empty string becomes None so MB drops the AND
clause."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_recording.return_value = [
@ -1036,7 +1087,6 @@ def test_search_tracks_with_artist_handles_missing_artist():
client.search_tracks_with_artist('Some Song', '', limit=5)
# Empty artist → None passed to the client so MB drops the AND clause
client._client.search_recording.assert_called_once_with(
'Some Song', artist_name=None, limit=5, strict=False
)
@ -1051,33 +1101,33 @@ def test_search_tracks_with_artist_empty_returns_empty_list():
client._client.search_recording.assert_not_called()
def test_search_tracks_with_artist_keeps_low_score_for_rerank():
"""Cascade path uses a low score floor (20) so MB recordings whose
title doesn't literally contain the artist name still enter the
candidate pool the endpoint's rerank pass surfaces them by
artist-match relevance. Real example: "Army of Me" + "Bjork" the
canonical Björk recording scores 28 in MB (title doesn't contain
"Bjork"), while title-collision covers like "Army of Me (Bjork)"
score 73-100. Strict 80 floor drops the right answer."""
def test_search_tracks_with_artist_bare_fallback_keeps_low_score_for_rerank():
"""When strict returns nothing and we fall through to bare, the bare
pass uses a low score floor (20) so MB recordings whose title doesn't
literally contain the artist name still enter the candidate pool
the endpoint's rerank pass surfaces them by artist-match relevance.
Real example: "Army of Me" + "Bjork" strict fails on the diacritic
mismatch, bare picks up the canonical Björk recording at score 28
while filtering true noise at score 5."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_recording.return_value = [
{'id': 'rec-cover', 'title': 'Army of Me (Bjork)', 'score': 100,
'releases': [], 'artist-credit': [{'name': 'HIRS Collective'}]},
{'id': 'rec-canonical', 'title': 'Army of Me', 'score': 28,
'releases': [], 'artist-credit': [{'name': 'Björk'}]},
{'id': 'rec-noise', 'title': 'Bjork', 'score': 5,
'releases': [], 'artist-credit': [{'name': 'Random'}]},
client._client.search_recording.side_effect = [
[], # strict pass → no hits
[ # bare pass
{'id': 'rec-cover', 'title': 'Army of Me (Bjork)', 'score': 100,
'releases': [], 'artist-credit': [{'name': 'HIRS Collective'}]},
{'id': 'rec-canonical', 'title': 'Army of Me', 'score': 28,
'releases': [], 'artist-credit': [{'name': 'Björk'}]},
{'id': 'rec-noise', 'title': 'Bjork', 'score': 5,
'releases': [], 'artist-credit': [{'name': 'Random'}]},
],
]
tracks = client.search_tracks_with_artist('Army of Me', 'Bjork', limit=50)
ids = [t.id for t in tracks]
# Score=28 canonical Björk recording is kept — the endpoint's rerank
# will surface it by artist match.
assert 'rec-canonical' in ids
assert 'rec-cover' in ids
# Score=5 is below the 20 floor — true garbage still filtered out.
assert 'rec-noise' not in ids
@ -1120,7 +1170,10 @@ def test_search_tracks_text_strict_param_default_true():
def test_search_tracks_with_artist_swallows_client_errors():
"""MB client raising must not crash the endpoint — return [] so the
Fix-popup cascade falls through to the next source."""
Fix-popup cascade falls through to the next source. Both strict and
bare passes swallow exceptions independently, so a strict-pass raise
still lets the bare-pass run; a bare-pass raise after empty strict
returns []."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_recording.side_effect = RuntimeError('network down')

View file

@ -50,6 +50,7 @@ def _track(
album: str = 'Unknown',
album_type: str = 'album',
track_id: str = 't',
duration_ms: int = 200000,
) -> Track:
"""Tiny Track factory — keeps test bodies focused on the
fields under test."""
@ -58,7 +59,7 @@ def _track(
name=name,
artists=[artist],
album=album,
duration_ms=200000,
duration_ms=duration_ms,
album_type=album_type,
)
@ -366,6 +367,56 @@ class TestRerankTracks:
ranked = rerank_tracks([a, b], expected_title='Track', expected_artist='Artist')
assert [t.id for t in ranked] == ['first', 'second']
def test_prefer_known_duration_promotes_length_known_on_ties(self):
"""MB has multiple recordings per song where some lack length
data. With ``prefer_known_duration=True`` and equal relevance
scores, the recording with non-zero duration_ms must surface
ahead of the length-less sibling."""
no_length = _track('Track', artist='Artist', track_id='no-len', duration_ms=0)
with_length = _track('Track', artist='Artist', track_id='with-len', duration_ms=184000)
ranked = rerank_tracks(
[no_length, with_length],
expected_title='Track',
expected_artist='Artist',
prefer_known_duration=True,
)
assert [t.id for t in ranked] == ['with-len', 'no-len']
def test_prefer_known_duration_does_not_override_relevance(self):
"""Length-preference is a TIEBREAKER, not a global resort. A
length-less track that scores higher on relevance must still
win only equal-score pairs use length as the deciding bit."""
# Wrong-artist length-known: scored low. Right-artist length-less:
# scored high.
length_known_cover = _track(
'Track', artist='Karaoke Channel', album='Karaoke Hits',
album_type='compilation', track_id='cover', duration_ms=184000,
)
length_less_real = _track(
'Track', artist='Real Artist', track_id='real', duration_ms=0,
)
ranked = rerank_tracks(
[length_known_cover, length_less_real],
expected_title='Track',
expected_artist='Real Artist',
prefer_known_duration=True,
)
assert ranked[0].id == 'real', "relevance must beat length-pref"
def test_prefer_known_duration_default_off(self):
"""Default behaviour unchanged — length-preference is opt-in
for MB callers; Spotify / iTunes / Deezer don't need it
because their search results always include length."""
no_length = _track('Track', artist='Artist', track_id='no-len', duration_ms=0)
with_length = _track('Track', artist='Artist', track_id='with-len', duration_ms=184000)
ranked = rerank_tracks(
[no_length, with_length],
expected_title='Track',
expected_artist='Artist',
)
# Default: stable input order, no length-pref re-shuffle.
assert [t.id for t in ranked] == ['no-len', 'with-len']
# ---------------------------------------------------------------------------
# filter_and_rerank — score floor convenience

View file

@ -0,0 +1,82 @@
import pytest
from core.playlists.source_refs import (
describe_mirrored_source_ref,
normalize_mirrored_source_ref,
require_refresh_url,
)
def test_spotify_public_url_stores_hash_and_canonical_url():
out = normalize_mirrored_source_ref(
"spotify_public",
"https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M?si=abc",
)
assert out.source_playlist_id == "5e7de827abd1"
assert out.description == "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M"
def test_spotify_public_raw_id_defaults_to_playlist_url():
out = normalize_mirrored_source_ref("spotify_public", "37i9dQZF1DXcBWIGoYBM5M")
assert out.source_playlist_id == "5e7de827abd1"
assert out.description == "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M"
def test_spotify_public_embed_url_canonicalizes_to_open_url():
out = normalize_mirrored_source_ref(
"spotify_public",
"https://open.spotify.com/embed/playlist/37i9dQZF1DX0kbJZpiYdZl?pi=abc",
)
assert out.source_playlist_id == "d29b105efc8e"
assert out.description == "https://open.spotify.com/playlist/37i9dQZF1DX0kbJZpiYdZl"
def test_youtube_url_stores_hash_and_canonical_url():
out = normalize_mirrored_source_ref(
"youtube",
"https://music.youtube.com/playlist?list=PL123&si=abc",
)
assert out.source_playlist_id == "e5f5ab31b8f0"
assert out.description == "https://youtube.com/playlist?list=PL123"
def test_direct_id_sources_preserve_existing_description():
out = normalize_mirrored_source_ref("tidal", "abc123", "Original service description")
assert out.source_playlist_id == "abc123"
assert out.description == "Original service description"
def test_hash_backed_refresh_requires_url():
with pytest.raises(ValueError, match="missing its original source URL"):
require_refresh_url("spotify_public", "", "Release Radar")
def test_describe_hash_backed_ref_reports_missing_url():
view = describe_mirrored_source_ref({
"source": "spotify_public",
"source_playlist_id": "hash",
"description": "",
"name": "Release Radar",
})
assert view.source_ref == "hash"
assert view.source_ref_kind == "url"
assert view.source_ref_status == "missing"
assert "missing its original source URL" in view.source_ref_error
def test_describe_hash_backed_ref_uses_url_when_present():
view = describe_mirrored_source_ref({
"source": "spotify_public",
"source_playlist_id": "hash",
"description": "https://open.spotify.com/playlist/abc",
})
assert view.source_ref == "https://open.spotify.com/playlist/abc"
assert view.source_ref_kind == "url"
assert view.source_ref_status == "ok"

View file

@ -0,0 +1,394 @@
// Tests for the pure-function helpers in `webui/static/auto-sync.js`.
// Run via:
//
// node --test tests/static/test_auto_sync.mjs
//
// The pytest wrapper at `tests/test_auto_sync_js.py` shells out to
// `node --test` and surfaces the result inside the regular pytest run.
//
// The module is loaded into a sandboxed `vm` context with stubs for
// the few globals it relies on (`_autoParseUTC` for the timezone-aware
// next_run label). No DOM — just the calculation contract.
import { test, describe, before } from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
// Values returned from the sandboxed VM context are cross-realm — their
// prototype chain differs from the test realm's, so deepStrictEqual on
// raw VM objects fails even when shape and values match. JSON-round-trip
// to compare structural equality only.
function deepShapeEqual(actual, expected, msg) {
assert.deepEqual(
JSON.parse(JSON.stringify(actual)),
JSON.parse(JSON.stringify(expected)),
msg,
);
}
const __dirname = dirname(fileURLToPath(import.meta.url));
const AUTOSYNC_PATH = resolve(__dirname, '..', '..', 'webui', 'static', 'auto-sync.js');
let AUTOSYNC_SOURCE;
before(() => {
AUTOSYNC_SOURCE = readFileSync(AUTOSYNC_PATH, 'utf8');
});
// Match the actual implementation in stats-automations.js so the
// timezone-bug fix is exercised end-to-end through auto-sync.js.
function realAutoParseUTC(ts) {
if (!ts) return NaN;
if (/[Zz]$/.test(ts) || /[+-]\d{2}:\d{2}$/.test(ts)) return new Date(ts).getTime();
return new Date(ts + 'Z').getTime();
}
function makeSandbox() {
const sandbox = {
window: {},
document: { getElementById: () => null, body: {} },
console: { debug: () => {}, error: () => {}, log: () => {} },
fetch: async () => { throw new Error('fetch not stubbed for this test'); },
// Globals that auto-sync.js expects to find in the window namespace
_autoParseUTC: realAutoParseUTC,
_autoFormatTrigger: () => 'trigger',
_esc: (s) => String(s),
_escAttr: (s) => String(s),
showToast: () => {},
showConfirmDialog: async () => true,
loadMirroredPlaylists: () => {},
updateMirroredCardPhase: () => {},
openMirroredPlaylistModal: () => {},
closeMirroredModal: () => {},
youtubePlaylistStates: {},
setInterval: () => 0,
clearInterval: () => {},
};
vm.createContext(sandbox);
vm.runInContext(AUTOSYNC_SOURCE, sandbox);
return sandbox;
}
// =========================================================================
// autoSyncTriggerForHours / autoSyncHoursFromTrigger — round-trip
// =========================================================================
describe('autoSyncTriggerForHours', () => {
test('sub-day intervals become hours', () => {
const sb = makeSandbox();
deepShapeEqual(sb.autoSyncTriggerForHours(1), { interval: 1, unit: 'hours' });
deepShapeEqual(sb.autoSyncTriggerForHours(12), { interval: 12, unit: 'hours' });
});
test('whole-day multiples become days', () => {
const sb = makeSandbox();
deepShapeEqual(sb.autoSyncTriggerForHours(24), { interval: 1, unit: 'days' });
deepShapeEqual(sb.autoSyncTriggerForHours(48), { interval: 2, unit: 'days' });
deepShapeEqual(sb.autoSyncTriggerForHours(168), { interval: 7, unit: 'days' });
});
test('non-day-multiple > 24 stays as hours', () => {
const sb = makeSandbox();
// 36h doesn't divide evenly into days, stay as hours
deepShapeEqual(sb.autoSyncTriggerForHours(36), { interval: 36, unit: 'hours' });
});
test('invalid input defaults to 24 hours', () => {
const sb = makeSandbox();
// Per autoSyncTriggerForHours: `parseInt(undefined, 10) || 24` → 24, becomes 1 day
deepShapeEqual(sb.autoSyncTriggerForHours(undefined), { interval: 1, unit: 'days' });
});
});
describe('autoSyncHoursFromTrigger', () => {
test('hours unit returned directly', () => {
const sb = makeSandbox();
assert.equal(sb.autoSyncHoursFromTrigger({ interval: 6, unit: 'hours' }), 6);
});
test('days unit multiplied by 24', () => {
const sb = makeSandbox();
assert.equal(sb.autoSyncHoursFromTrigger({ interval: 3, unit: 'days' }), 72);
});
test('weeks unit multiplied by 168', () => {
const sb = makeSandbox();
assert.equal(sb.autoSyncHoursFromTrigger({ interval: 1, unit: 'weeks' }), 168);
});
test('minutes unit rounds up to at least 1 hour', () => {
const sb = makeSandbox();
assert.equal(sb.autoSyncHoursFromTrigger({ interval: 30, unit: 'minutes' }), 1);
assert.equal(sb.autoSyncHoursFromTrigger({ interval: 90, unit: 'minutes' }), 2);
});
test('zero or missing interval returns null', () => {
const sb = makeSandbox();
assert.equal(sb.autoSyncHoursFromTrigger({}), null);
assert.equal(sb.autoSyncHoursFromTrigger({ interval: 0 }), null);
});
test('round-trip with autoSyncTriggerForHours preserves hour count', () => {
const sb = makeSandbox();
for (const hours of [1, 4, 12, 24, 48, 168]) {
const config = sb.autoSyncTriggerForHours(hours);
assert.equal(sb.autoSyncHoursFromTrigger(config), hours, `round-trip ${hours}`);
}
});
});
// =========================================================================
// Label helpers
// =========================================================================
describe('autoSyncBucketLabel', () => {
test('weekly bucket', () => {
const sb = makeSandbox();
assert.equal(sb.autoSyncBucketLabel(168), 'Weekly');
});
test('day-multiple buckets', () => {
const sb = makeSandbox();
assert.equal(sb.autoSyncBucketLabel(24), '1d');
assert.equal(sb.autoSyncBucketLabel(48), '2d');
});
test('sub-day buckets', () => {
const sb = makeSandbox();
assert.equal(sb.autoSyncBucketLabel(1), '1h');
assert.equal(sb.autoSyncBucketLabel(12), '12h');
});
});
describe('autoSyncIntervalLabel', () => {
test('pluralization', () => {
const sb = makeSandbox();
assert.equal(sb.autoSyncIntervalLabel(1), 'Every 1 hour');
assert.equal(sb.autoSyncIntervalLabel(2), 'Every 2 hours');
assert.equal(sb.autoSyncIntervalLabel(24), 'Every 1 day');
assert.equal(sb.autoSyncIntervalLabel(48), 'Every 2 days');
assert.equal(sb.autoSyncIntervalLabel(168), 'Every week');
});
});
describe('autoSyncSourceLabel', () => {
test('known sources mapped', () => {
const sb = makeSandbox();
assert.equal(sb.autoSyncSourceLabel('spotify'), 'Spotify');
assert.equal(sb.autoSyncSourceLabel('youtube'), 'YouTube');
});
test('unknown source returns the raw key', () => {
const sb = makeSandbox();
assert.equal(sb.autoSyncSourceLabel('newthing'), 'newthing');
});
test('falsy source returns "Other"', () => {
const sb = makeSandbox();
assert.equal(sb.autoSyncSourceLabel(''), 'Other');
assert.equal(sb.autoSyncSourceLabel(null), 'Other');
});
});
// =========================================================================
// Schedulability and ownership predicates
// =========================================================================
describe('autoSyncCanSchedulePlaylist', () => {
test('blocks file and beatport sources', () => {
const sb = makeSandbox();
assert.equal(sb.autoSyncCanSchedulePlaylist({ source: 'file' }), false);
assert.equal(sb.autoSyncCanSchedulePlaylist({ source: 'beatport' }), false);
});
test('allows refreshable sources', () => {
const sb = makeSandbox();
assert.equal(sb.autoSyncCanSchedulePlaylist({ source: 'spotify' }), true);
assert.equal(sb.autoSyncCanSchedulePlaylist({ source: 'youtube' }), true);
});
test('null/undefined playlist returns falsy', () => {
const sb = makeSandbox();
assert.ok(!sb.autoSyncCanSchedulePlaylist(null));
assert.ok(!sb.autoSyncCanSchedulePlaylist(undefined));
});
});
describe('autoSyncIsPipelineAutomation', () => {
test('matches playlist_pipeline action type', () => {
const sb = makeSandbox();
assert.equal(sb.autoSyncIsPipelineAutomation({ action_type: 'playlist_pipeline' }), true);
assert.equal(sb.autoSyncIsPipelineAutomation({ action_type: 'process_wishlist' }), false);
});
});
describe('autoSyncPlaylistIdFromAutomation', () => {
test('extracts numeric playlist_id', () => {
const sb = makeSandbox();
const auto = { action_type: 'playlist_pipeline', action_config: { playlist_id: '42' } };
assert.equal(sb.autoSyncPlaylistIdFromAutomation(auto), 42);
});
test('returns null when all=true (catch-all pipeline)', () => {
const sb = makeSandbox();
const auto = { action_type: 'playlist_pipeline', action_config: { all: true } };
assert.equal(sb.autoSyncPlaylistIdFromAutomation(auto), null);
});
test('returns null for non-pipeline automations', () => {
const sb = makeSandbox();
assert.equal(sb.autoSyncPlaylistIdFromAutomation({ action_type: 'other' }), null);
});
test('returns null when playlist_id missing', () => {
const sb = makeSandbox();
const auto = { action_type: 'playlist_pipeline', action_config: {} };
assert.equal(sb.autoSyncPlaylistIdFromAutomation(auto), null);
});
});
describe('autoSyncIsScheduleOwned', () => {
test('owned_by="auto_sync" wins over name/group', () => {
const sb = makeSandbox();
assert.equal(sb.autoSyncIsScheduleOwned({
owned_by: 'auto_sync', name: 'Whatever', group_name: 'unrelated',
}), true);
});
test('legacy name-prefix still recognized', () => {
const sb = makeSandbox();
assert.equal(sb.autoSyncIsScheduleOwned({ name: 'Auto-Sync: Discover Weekly' }), true);
});
test('legacy group_name still recognized', () => {
const sb = makeSandbox();
assert.equal(sb.autoSyncIsScheduleOwned({ group_name: 'Playlist Auto-Sync' }), true);
});
test('automation with no owned_by and no legacy markers returns false', () => {
const sb = makeSandbox();
assert.equal(sb.autoSyncIsScheduleOwned({ name: 'My Custom Pipeline' }), false);
});
});
// =========================================================================
// State partitioning
// =========================================================================
describe('buildAutoSyncScheduleState', () => {
test('partitions board-owned schedules from custom pipelines', () => {
const sb = makeSandbox();
const playlists = [{ id: 1, name: 'Discover Weekly' }, { id: 2, name: 'Top Hits' }];
const automations = [
{
id: 10, action_type: 'playlist_pipeline', trigger_type: 'schedule',
trigger_config: { interval: 1, unit: 'hours' },
action_config: { playlist_id: '1' },
owned_by: 'auto_sync',
enabled: 1,
},
{
id: 11, action_type: 'playlist_pipeline', trigger_type: 'schedule',
trigger_config: { interval: 1, unit: 'days' },
action_config: { playlist_id: '99' }, // not in playlists, but custom-owned
enabled: 1,
// no owned_by → custom pipeline
},
];
const state = sb.buildAutoSyncScheduleState(playlists, automations);
assert.equal(Object.keys(state.playlistSchedules).length, 1);
assert.equal(state.playlistSchedules[1].automation_id, 10);
assert.equal(state.playlistSchedules[1].hours, 1);
assert.equal(state.automationPipelines.length, 1);
assert.equal(state.automationPipelines[0].id, 11);
});
test('non-pipeline automations are ignored entirely', () => {
const sb = makeSandbox();
const automations = [
{ id: 20, action_type: 'process_wishlist', trigger_type: 'schedule' },
];
const state = sb.buildAutoSyncScheduleState([], automations);
deepShapeEqual(state.playlistSchedules, {});
deepShapeEqual(state.automationPipelines, []);
});
});
// =========================================================================
// Timezone-aware countdown — the headline bug this branch fixed
// =========================================================================
describe('autoSyncNextRunLabel', () => {
test('empty string for missing input', () => {
const sb = makeSandbox();
assert.equal(sb.autoSyncNextRunLabel(''), '');
assert.equal(sb.autoSyncNextRunLabel(null), '');
});
test('naive UTC string is parsed as UTC, not local', () => {
const sb = makeSandbox();
// Pick a time exactly one hour from now in UTC. If the parser
// mistakenly treats the bare timestamp as LOCAL it would land
// wildly far from 1h on machines in non-UTC timezones —
// that's exactly the bug Cin's review flagged.
const future = new Date(Date.now() + 60 * 60 * 1000);
const iso = future.toISOString().slice(0, 19).replace('T', ' ');
const label = sb.autoSyncNextRunLabel(iso);
// Allow either "next in 60m" (right at the boundary) or "next in 1h"
assert.ok(/^next in (60m|1h)$/.test(label), `expected ~1h, got "${label}"`);
});
test('"due now" when timestamp is in the past', () => {
const sb = makeSandbox();
const past = new Date(Date.now() - 60 * 1000).toISOString().slice(0, 19).replace('T', ' ');
assert.equal(sb.autoSyncNextRunLabel(past), 'due now');
});
test('day-scale label when timestamp is days out', () => {
const sb = makeSandbox();
const future = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000);
const iso = future.toISOString().slice(0, 19).replace('T', ' ');
const label = sb.autoSyncNextRunLabel(iso);
assert.match(label, /^next in \dd$/);
});
});
// =========================================================================
// getMirroredSourceRef — playlist source URL resolution
// =========================================================================
describe('getMirroredSourceRef', () => {
test('explicit source_ref wins', () => {
const sb = makeSandbox();
assert.equal(
sb.getMirroredSourceRef({ source_ref: 'https://example.com/x' }),
'https://example.com/x',
);
});
test('falls back to description URL for spotify_public', () => {
const sb = makeSandbox();
const p = {
source: 'spotify_public',
description: 'https://open.spotify.com/playlist/abc',
};
assert.equal(sb.getMirroredSourceRef(p), 'https://open.spotify.com/playlist/abc');
});
test('non-URL description ignored, falls through to source_playlist_id', () => {
const sb = makeSandbox();
const p = {
source: 'spotify_public',
description: 'just a note about this playlist',
source_playlist_id: 'abc123',
};
assert.equal(sb.getMirroredSourceRef(p), 'abc123');
});
test('empty playlist returns empty string', () => {
const sb = makeSandbox();
assert.equal(sb.getMirroredSourceRef({}), '');
});
});

View file

@ -0,0 +1,43 @@
"""Tests for `_artist_name` in services/sync_service.py — the helper
that pulls a string name out of Spotify's bare-string / dict / fallback
artist representations."""
from services.sync_service import _artist_name
def test_bare_string_returned_as_is():
assert _artist_name('Drake') == 'Drake'
def test_dict_with_name_field():
assert _artist_name({'name': 'Drake', 'id': '3TVXt'}) == 'Drake'
def test_dict_without_name_field_falls_back_to_str_repr():
"""Missing name field shouldn't crash — caller should still get a
string back, even if it's the awkward dict repr."""
out = _artist_name({'id': '3TVXt'})
assert isinstance(out, str)
assert out != ''
def test_dict_with_non_string_name_falls_back():
"""Defensive — if some endpoint ever returns {name: None} or a list,
the helper must not propagate the bad type."""
out = _artist_name({'name': None})
assert isinstance(out, str)
def test_none_returns_empty_string():
assert _artist_name(None) == ''
def test_unexpected_type_returns_string_repr():
"""A weird type (int, custom object) must coerce to a string instead
of raising sync iterates a lot of inputs and one bad row shouldn't
crash the whole loop."""
assert _artist_name(12345) == '12345'
def test_empty_string_stays_empty():
assert _artist_name('') == ''

View file

@ -0,0 +1,208 @@
"""Tests for the lazy per-artist candidate pool in PlaylistSyncService.
The pool replaces a per-track SQL storm: instead of running ~30
title-variation × artist-variation queries for every playlist track,
sync now fetches each unique artist's library tracks once and feeds the
matcher via the in-memory `candidate_tracks` path. The fetch is *lazy*
it only fires when a track actually misses the sync_match_cache,
so warm-cache playlists pay zero pool cost.
"""
from __future__ import annotations
from unittest.mock import MagicMock
from services.sync_service import PlaylistSyncService
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_service() -> PlaylistSyncService:
"""Bare PlaylistSyncService — pool helper doesn't touch service state."""
return PlaylistSyncService(
spotify_client=MagicMock(),
download_orchestrator=MagicMock(),
media_server_engine=MagicMock(),
)
def _make_db_stub(indexed_returns=None, search_returns=None, raise_on_search=None) -> MagicMock:
"""MusicDatabase stub mirroring the contract the helper relies on:
- get_artist_tracks_indexed is the fast path (indexed artist_id lookup)
- search_tracks is the slow LIKE-based fallback for recall edge cases
Pool-key normalization runs through `core.text.normalize` directly,
not through the db, so no `_normalize_for_comparison` stub is needed.
"""
db = MagicMock()
db.get_artist_tracks_indexed.return_value = indexed_returns if indexed_returns is not None else []
if raise_on_search is not None:
db.search_tracks.side_effect = raise_on_search
db.get_artist_tracks_indexed.side_effect = raise_on_search
else:
db.search_tracks.return_value = search_returns if search_returns is not None else []
return db
# ---------------------------------------------------------------------------
# Pooling disabled — legacy fallback
# ---------------------------------------------------------------------------
def test_returns_none_when_pool_disabled():
"""candidate_pool=None signals callers to fall through to the legacy
per-track SQL loop. Helper must not touch the DB."""
svc = _make_service()
db = _make_db_stub()
result = svc._get_or_fetch_artist_candidates(None, db, 'Drake', 'plex')
assert result is None
db.search_tracks.assert_not_called()
db.get_artist_tracks_indexed.assert_not_called()
# ---------------------------------------------------------------------------
# Lazy population
# ---------------------------------------------------------------------------
def test_indexed_fast_path_hits_skip_the_like_fallback():
"""When the indexed lookup finds tracks, the LIKE-based fallback must
NOT run that's the whole perf point of the fast path."""
svc = _make_service()
pool: dict = {}
db = _make_db_stub(indexed_returns=['t1', 't2'])
result = svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex')
assert result == ['t1', 't2']
assert pool == {'drake': ['t1', 't2']}
db.get_artist_tracks_indexed.assert_called_once_with(
'Drake', server_source='plex', limit=10000,
)
db.search_tracks.assert_not_called()
def test_like_fallback_runs_when_indexed_returns_empty():
"""Diacritics / featured-artist recall lives in the LIKE path. The
helper must fall through to search_tracks when the indexed lookup
finds nothing, otherwise sync regresses on those cases. Note that
the pool key is accent-folded (`Beyoncé` `beyonce`) so library
spellings with/without diacritics share one entry."""
svc = _make_service()
pool: dict = {}
db = _make_db_stub(indexed_returns=[], search_returns=['feature-track'])
result = svc._get_or_fetch_artist_candidates(pool, db, 'Beyoncé', 'plex')
assert result == ['feature-track']
assert pool == {'beyonce': ['feature-track']}
db.get_artist_tracks_indexed.assert_called_once()
db.search_tracks.assert_called_once_with(
artist='Beyoncé', limit=10000, server_source='plex',
)
def test_second_call_for_same_artist_reuses_cache():
"""Once an artist's pool is populated, subsequent lookups must not
re-fetch that's the whole perf point of the pool."""
svc = _make_service()
pool = {'drake': ['cached']}
db = _make_db_stub(indexed_returns=['fresh'], search_returns=['stale'])
result = svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex')
assert result == ['cached']
db.get_artist_tracks_indexed.assert_not_called()
db.search_tracks.assert_not_called()
def test_artist_absent_from_library_cached_as_empty_list():
"""Both paths return [] → cache [] so the next call short-circuits
via check_track_exists' batched path without firing SQL again."""
svc = _make_service()
pool: dict = {}
db = _make_db_stub(indexed_returns=[], search_returns=[])
result = svc._get_or_fetch_artist_candidates(pool, db, 'Obscure', 'plex')
assert result == []
assert pool == {'obscure': []}
def test_none_return_normalized_to_empty_list():
"""Defensive — if both paths ever return None, helper must coerce
to [] so the cached value is still a valid iterable for the matcher."""
svc = _make_service()
pool: dict = {}
db = _make_db_stub()
db.get_artist_tracks_indexed.return_value = None
db.search_tracks.return_value = None
result = svc._get_or_fetch_artist_candidates(pool, db, 'Anyone', 'plex')
assert result == []
assert pool == {'anyone': []}
# ---------------------------------------------------------------------------
# Failure modes
# ---------------------------------------------------------------------------
def test_fetch_failure_returns_none_and_does_not_cache():
"""A pool fetch exception must not poison the dict — the per-track
legacy path still has a chance to run for this track, and a later
track for the same artist can retry the fetch."""
svc = _make_service()
pool: dict = {}
db = _make_db_stub(raise_on_search=RuntimeError('DB exploded'))
result = svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex')
assert result is None
assert pool == {}
# ---------------------------------------------------------------------------
# Normalization
# ---------------------------------------------------------------------------
def test_pool_key_is_normalized_so_casing_variants_share_one_fetch():
"""'Drake' and 'DRAKE' must hash to the same pool entry — otherwise
a playlist that mixes casing would re-fetch the same artist twice."""
svc = _make_service()
pool: dict = {}
db = _make_db_stub(indexed_returns=['t'])
svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex')
db.get_artist_tracks_indexed.reset_mock()
db.search_tracks.reset_mock()
result = svc._get_or_fetch_artist_candidates(pool, db, 'DRAKE', 'plex')
assert result == ['t']
db.get_artist_tracks_indexed.assert_not_called()
db.search_tracks.assert_not_called()
def test_different_artists_get_separate_pool_entries():
svc = _make_service()
pool: dict = {}
db = _make_db_stub()
db.get_artist_tracks_indexed.side_effect = [['drake-track'], ['sza-track']]
svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex')
svc._get_or_fetch_artist_candidates(pool, db, 'SZA', 'plex')
assert pool == {'drake': ['drake-track'], 'sza': ['sza-track']}
assert db.get_artist_tracks_indexed.call_count == 2
# ---------------------------------------------------------------------------
# Server source plumbing
# ---------------------------------------------------------------------------
def test_active_server_is_passed_through_to_indexed_path():
"""Misrouting server_source would make the pool include tracks from
the wrong server (e.g. Plex tracks in a Jellyfin sync) verify it
survives the trip on the fast path."""
svc = _make_service()
pool: dict = {}
db = _make_db_stub(indexed_returns=['t'])
svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'jellyfin')
db.get_artist_tracks_indexed.assert_called_once_with(
'Drake', server_source='jellyfin', limit=10000,
)
def test_active_server_is_passed_through_to_like_fallback():
"""Same server_source check for the slow LIKE-based fallback path."""
svc = _make_service()
pool: dict = {}
db = _make_db_stub(indexed_returns=[], search_returns=['t'])
svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'jellyfin')
db.search_tracks.assert_called_once_with(
artist='Drake', limit=10000, server_source='jellyfin',
)

View file

@ -0,0 +1,72 @@
"""Run the JS tests for `webui/static/auto-sync.js` under the regular
pytest sweep.
The actual contract tests live in `tests/static/test_auto_sync.mjs`
and run via Node.js's stable built-in test runner (`node --test`).
This shim shells out to that runner and asserts a clean exit so the
JS tests fail the suite if the auto-sync helpers regress.
Skipped when:
- `node` isn't on PATH (e.g. Python-only dev container).
- Node version < 22 (the built-in `--test` runner went stable in 18
but the assert-flavor we use is 22+).
Run directly:
node --test tests/static/test_auto_sync.mjs
"""
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO_ROOT = Path(__file__).resolve().parents[1]
_TEST_FILE = _REPO_ROOT / "tests" / "static" / "test_auto_sync.mjs"
def _node_available() -> bool:
if not shutil.which("node"):
return False
try:
result = subprocess.run(
["node", "--version"],
capture_output=True, text=True, timeout=10,
)
except (subprocess.SubprocessError, FileNotFoundError):
return False
if result.returncode != 0:
return False
raw = (result.stdout or "").strip().lstrip("v")
try:
major = int(raw.split(".")[0])
except (ValueError, IndexError):
return False
return major >= 22
def test_auto_sync_js():
"""Pin the auto-sync helper contract via `node --test`."""
if not _node_available():
pytest.skip("Node.js >= 22 required to run the JS auto-sync tests")
if not _TEST_FILE.exists():
pytest.skip(f"JS test file missing: {_TEST_FILE}")
result = subprocess.run(
["node", "--test", str(_TEST_FILE)],
capture_output=True, text=True,
cwd=str(_REPO_ROOT),
timeout=60,
)
if result.returncode != 0:
pytest.fail(
"JS auto-sync tests failed:\n\n"
f"--- stdout ---\n{result.stdout}\n"
f"--- stderr ---\n{result.stderr}",
pytrace=False,
)

View file

@ -0,0 +1,89 @@
"""Tests for the LB rotating-series detector that powers the
rolling-mirror collapse on the Sync page.
Pins the title patterns + canonical-name templates so accidental
regex tweaks don't silently break the auto-mirror grouping the
Auto-Sync manager + Mirrored tab rely on.
"""
from __future__ import annotations
import pytest
from core.playlists.lb_series import (
detect_series,
is_series_synthetic_id,
list_series_synthetic_ids,
)
class TestDetectSeries:
def test_weekly_jams_collapses_into_rolling_series(self):
m = detect_series("Weekly Jams for Nezreka, week of 2026-05-25 Mon")
assert m is not None
assert m.series_id == "lb_weekly_jams_Nezreka"
assert m.canonical_name == "ListenBrainz Weekly Jams"
assert m.source_for_mirror == "listenbrainz"
assert m.title_pattern == "Weekly Jams for Nezreka, week of %"
def test_weekly_exploration_collapses_into_rolling_series(self):
m = detect_series("Weekly Exploration for Nezreka, week of 2026-04-13 Mon")
assert m is not None
assert m.series_id == "lb_weekly_exploration_Nezreka"
assert m.canonical_name == "ListenBrainz Weekly Exploration"
assert m.title_pattern == "Weekly Exploration for Nezreka, week of %"
def test_top_discoveries_collapses_per_user(self):
m = detect_series("Top Discoveries of 2024 for Nezreka")
assert m is not None
assert m.series_id == "lb_top_discoveries_Nezreka"
assert m.canonical_name == "ListenBrainz Top Discoveries (latest year)"
assert m.title_pattern == "Top Discoveries of % for Nezreka"
def test_top_missed_collapses_per_user(self):
m = detect_series("Top Missed Recordings of 2025 for Nezreka")
assert m is not None
assert m.series_id == "lb_top_missed_Nezreka"
assert m.canonical_name == "ListenBrainz Top Missed Recordings (latest year)"
def test_user_with_spaces_in_name(self):
# ListenBrainz allows usernames with spaces; the regex should
# still match and the series id propagates the literal user
# token. Whether SQLite LIKE works on that is the caller's
# problem — we just preserve the captured value.
m = detect_series("Weekly Jams for Some User, week of 2026-01-05 Mon")
assert m is not None
assert m.series_id == "lb_weekly_jams_Some User"
def test_lastfm_radio_is_not_a_series(self):
# Last.fm radios get their own per-seed MBID — they should NOT
# be collapsed into a rolling series.
assert detect_series("Last.fm Radio: Selfish by Madison Beer") is None
def test_user_created_playlist_is_not_a_series(self):
assert detect_series("My Custom Playlist") is None
def test_empty_title_returns_none(self):
assert detect_series("") is None
assert detect_series(None) is None # type: ignore[arg-type]
class TestSyntheticIdHelpers:
def test_known_prefixes_listed(self):
prefixes = list_series_synthetic_ids()
assert "lb_weekly_jams_" in prefixes
assert "lb_weekly_exploration_" in prefixes
assert "lb_top_discoveries_" in prefixes
assert "lb_top_missed_" in prefixes
def test_is_series_synthetic_id_matches_known(self):
assert is_series_synthetic_id("lb_weekly_jams_Nezreka") is True
assert is_series_synthetic_id("lb_weekly_exploration_OtherUser") is True
assert is_series_synthetic_id("lb_top_discoveries_X") is True
def test_is_series_synthetic_id_rejects_mbids(self):
# Real LB playlist MBIDs are UUID-shaped, never start with ``lb_``.
assert is_series_synthetic_id("4badb5c9-266e-42ef-9d06-879ee311c9e0") is False
assert is_series_synthetic_id("") is False
assert is_series_synthetic_id("lb_") is False # not a real series
assert is_series_synthetic_id("lb_random_thing") is False

View file

@ -0,0 +1,45 @@
from __future__ import annotations
from web_server import _build_library_tag_db_data
def test_library_tag_payload_preserves_track_artist_and_artists_list():
payload = _build_library_tag_db_data(
{
'title': 'Duet',
'artist_name': 'Album Artist',
'track_artist': 'Artist A; Artist B',
'album_title': 'Compilation',
'year': 2026,
'track_number': 3,
'disc_number': 1,
'bpm': 124,
'track_count': 12,
'album_thumb_url': 'https://example.test/cover.jpg',
'artist_thumb_url': 'https://example.test/artist.jpg',
},
['Electronic', 'Dance'],
)
assert payload['artist_name'] == 'Album Artist'
assert payload['track_artist'] == 'Artist A; Artist B'
assert payload['artists_list'] == ['Artist A', 'Artist B']
assert payload['genres'] == ['Electronic', 'Dance']
assert payload['thumb_url'] == 'https://example.test/cover.jpg'
def test_library_tag_payload_falls_back_without_track_artist():
payload = _build_library_tag_db_data(
{
'title': 'Solo',
'artist_name': 'Solo Artist',
'track_artist': None,
'album_title': 'Solo Album',
'artist_thumb_url': 'https://example.test/artist.jpg',
}
)
assert payload['track_artist'] is None
assert 'artists_list' not in payload
assert payload['artist_name'] == 'Solo Artist'
assert payload['thumb_url'] == 'https://example.test/artist.jpg'

View file

@ -0,0 +1,837 @@
"""Adapter contract tests for ``core/playlists/sources/``.
These pin the projection from each backing client's native shape into
the unified ``PlaylistMeta`` / ``NormalizedTrack`` shape. Adapters are
fed minimal fakes (not real clients) so the test is independent of the
live API surface the goal is to lock in the field mapping so later
phases that consume the unified interface can rely on it.
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any, Callable, List, Optional
import pytest
from core.playlists.sources import (
NormalizedTrack,
PlaylistDetail,
PlaylistMeta,
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
from core.playlists.sources.qobuz import QobuzPlaylistSource
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
# ─── Spotify ────────────────────────────────────────────────────────────
class _FakeSpotifyClient:
"""Stand-in for ``core.spotify_client.SpotifyClient``."""
def __init__(self, authed: bool = True):
self._authed = authed
def is_authenticated(self) -> bool:
return self._authed
def get_user_playlists_metadata_only(self):
return [
SimpleNamespace(
id="pl1",
name="Drive",
description="long drives",
owner="me",
public=True,
collaborative=False,
tracks=[],
total_tracks=2,
)
]
def get_playlist_by_id(self, playlist_id: str):
track = SimpleNamespace(
id="t1",
name="Run",
artists=["A", "B"],
album="Album",
duration_ms=180_000,
popularity=50,
preview_url=None,
external_urls={"spotify": "url"},
image_url="img",
)
return SimpleNamespace(
id=playlist_id,
name="Drive",
description="long drives",
owner="me",
public=True,
collaborative=False,
tracks=[track],
total_tracks=1,
)
def test_spotify_adapter_lists_and_fetches():
client = _FakeSpotifyClient()
src = SpotifyPlaylistSource(lambda: client)
assert isinstance(src, PlaylistSource)
assert src.is_authenticated() is True
metas = src.list_playlists()
assert len(metas) == 1
m = metas[0]
assert m.source == "spotify"
assert m.source_playlist_id == "pl1"
assert m.name == "Drive"
assert m.track_count == 2
detail = src.get_playlist("pl1")
assert detail is not None
assert detail.meta.track_count == 1
t = detail.tracks[0]
assert t.source_track_id == "t1"
assert t.track_name == "Run"
# 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():
src = SpotifyPlaylistSource(lambda: _FakeSpotifyClient(authed=False))
assert src.is_authenticated() is False
assert src.list_playlists() == []
assert src.get_playlist("pl1") is None
def test_spotify_adapter_handles_missing_client():
src = SpotifyPlaylistSource(lambda: None)
assert src.is_authenticated() is False
assert src.list_playlists() == []
# ─── Tidal ──────────────────────────────────────────────────────────────
class _FakeTidalClient:
def __init__(self, authed: bool = True):
self._authed = authed
def is_authenticated(self) -> bool:
return self._authed
def get_user_playlists_metadata_only(self):
return [
SimpleNamespace(
id="tpl",
name="Tidal Mix",
description="",
tracks=[],
external_urls={"tidal": "url"},
owner={"name": "broque"},
public=True,
)
]
def get_playlist(self, playlist_id: str):
track = SimpleNamespace(
id="ttrk",
name="Wave",
artists=["X"],
album="Ocean",
duration_ms=200_000,
external_urls={},
popularity=0,
explicit=False,
)
return SimpleNamespace(
id=playlist_id,
name="Tidal Mix",
description="",
tracks=[track],
external_urls={},
owner={"name": "broque"},
public=True,
)
def test_tidal_adapter_projection():
src = TidalPlaylistSource(lambda: _FakeTidalClient())
metas = src.list_playlists()
assert metas[0].owner == "broque"
assert metas[0].source == "tidal"
detail = src.get_playlist("tpl")
assert detail is not None
assert detail.tracks[0].source_track_id == "ttrk"
assert detail.tracks[0].album_name == "Ocean"
assert detail.tracks[0].needs_discovery is False
# ─── Qobuz ──────────────────────────────────────────────────────────────
class _FakeQobuzClient:
def is_authenticated(self) -> bool:
return True
def get_user_playlists(self):
return [{
"id": "q1",
"name": "Q Mix",
"description": "qobuz",
"public": False,
"track_count": 2,
"image_url": "img",
"external_urls": {"qobuz": "url"},
}]
def get_playlist(self, playlist_id: str):
return {
"id": playlist_id,
"name": "Q Mix",
"description": "qobuz",
"public": False,
"track_count": 1,
"image_url": "img",
"external_urls": {"qobuz": "url"},
"tracks": [{
"id": "qt1",
"name": "Track",
"artists": ["Q-Artist"],
"album": "Q-Album",
"duration_ms": 300_000,
"image_url": "ti",
}],
}
def test_qobuz_adapter_projection():
src = QobuzPlaylistSource(lambda: _FakeQobuzClient())
metas = src.list_playlists()
assert metas[0].source_playlist_id == "q1"
detail = src.get_playlist("q1")
assert detail.tracks[0].source_track_id == "qt1"
assert detail.tracks[0].duration_ms == 300_000
assert detail.tracks[0].image_url == "ti"
# ─── Spotify Public ─────────────────────────────────────────────────────
def test_spotify_public_adapter_invalid_url():
src = SpotifyPublicPlaylistSource()
# invalid URL → parser returns None → adapter returns None
assert src.get_playlist("not-a-spotify-url") is None
assert src.supports_listing is False
assert src.list_playlists() == []
def test_spotify_public_adapter_projects_scrape(monkeypatch):
src = SpotifyPublicPlaylistSource()
def fake_parse(url: str):
return {"type": "playlist", "id": "xyz"}
def fake_scrape(spotify_type: str, spotify_id: str):
return {
"id": spotify_id,
"type": "playlist",
"name": "Embed",
"subtitle": "owner",
"tracks": [
{
"id": "sptrk",
"name": "Song",
"artists": [{"name": "Artist"}],
"duration_ms": 100_000,
"is_explicit": False,
"track_number": 1,
},
],
"url": "https://open.spotify.com/playlist/xyz",
"url_hash": "abc123",
}
monkeypatch.setattr("core.spotify_public_scraper.parse_spotify_url", fake_parse)
monkeypatch.setattr("core.spotify_public_scraper.scrape_spotify_embed", fake_scrape)
detail = src.get_playlist("https://open.spotify.com/playlist/xyz")
assert detail is not None
assert detail.meta.source_playlist_id == "abc123"
assert detail.meta.source_url == "https://open.spotify.com/playlist/xyz"
assert detail.tracks[0].artist_name == "Artist"
assert detail.tracks[0].source_track_id == "sptrk"
# ─── YouTube ────────────────────────────────────────────────────────────
def test_youtube_adapter_projection():
def parser(url: str):
return {
"id": "yt_pl",
"name": "YT Mix",
"track_count": 1,
"url": url,
"image_url": "thumb",
"tracks": [{
"id": "vid1",
"name": "Track",
"artists": ["Channel"],
"duration_ms": 240_000,
"url": "https://youtu.be/vid1",
}],
}
src = YouTubePlaylistSource(parser)
detail = src.get_playlist("https://youtube.com/playlist?list=yt_pl")
assert detail is not None
assert detail.meta.source == "youtube"
assert detail.meta.source_url == "https://youtube.com/playlist?list=yt_pl"
assert len(detail.meta.source_playlist_id) == 12 # md5[:12]
assert detail.tracks[0].source_track_id == "vid1"
def test_youtube_adapter_failed_parse():
src = YouTubePlaylistSource(lambda url: None)
assert src.get_playlist("https://bad") is None
# ─── iTunes Link ────────────────────────────────────────────────────────
def test_itunes_link_adapter_projection():
def parser(url: str):
return {
"id": "1234",
"type": "album",
"name": "Album X",
"subtitle": "Artist",
"url": url,
"url_hash": "abcd1234",
"track_count": 1,
"image_url": "art",
"tracks": [{
"id": "555",
"name": "Song",
"artists": ["Artist"],
"album": {"name": "Album X"},
"duration_ms": 220_000,
"image_url": "art",
}],
}
src = ITunesLinkPlaylistSource(parser)
detail = src.get_playlist("https://music.apple.com/us/album/1234")
assert detail is not None
assert detail.meta.source == "itunes_link"
assert detail.meta.source_playlist_id == "abcd1234"
assert detail.tracks[0].album_name == "Album X"
assert detail.tracks[0].source_track_id == "555"
# ─── ListenBrainz ───────────────────────────────────────────────────────
class _FakeLBManager:
def __init__(self, authed: bool = True):
self.client = SimpleNamespace(is_authenticated=lambda: authed)
self._rows = {
"created_for_user": [{
"playlist_mbid": "lb-1",
"title": "Weekly Discovery",
"creator": "ListenBrainz",
"track_count": 1,
"annotation": {"note": "weekly"},
"last_updated": "2026-05-26",
}],
"user_created": [],
"collaborative": [],
}
self._tracks = {
"lb-1": [{
"track_name": "Discovery Track",
"artist_name": "MB Artist",
"album_name": "MB Album",
"duration_ms": 250_000,
"recording_mbid": "rec-1",
"release_mbid": "rel-1",
"album_cover_url": "cover",
"additional_metadata": {},
}],
}
self.refresh_called = False
def get_cached_playlists(self, playlist_type: str):
return self._rows.get(playlist_type, [])
def get_playlist_type(self, mbid: str) -> str:
for ptype, rows in self._rows.items():
if any(r["playlist_mbid"] == mbid for r in rows):
return ptype
return ""
def get_cached_tracks(self, mbid: str):
return self._tracks.get(mbid, [])
def update_all_playlists(self):
self.refresh_called = True
def test_listenbrainz_adapter_marks_needs_discovery():
manager = _FakeLBManager()
src = ListenBrainzPlaylistSource(lambda: manager)
metas = src.list_playlists()
assert len(metas) == 1
assert metas[0].source == "listenbrainz"
assert metas[0].extra["playlist_type"] == "created_for_user"
detail = src.get_playlist("lb-1")
assert detail is not None
assert detail.meta.track_count == 1
t = detail.tracks[0]
assert t.needs_discovery is True
assert t.source_track_id == "rec-1"
assert t.extra["recording_mbid"] == "rec-1"
def test_listenbrainz_adapter_refresh_calls_manager():
manager = _FakeLBManager()
src = ListenBrainzPlaylistSource(lambda: manager)
src.refresh_playlist("lb-1")
assert manager.refresh_called is True
# ─── Last.fm ────────────────────────────────────────────────────────────
class _FakeLastFMManager:
def __init__(self):
self._rows = [{
"playlist_mbid": "lfm-1",
"title": "Last.fm Radio: Seed",
"creator": "Last.fm",
"track_count": 1,
"annotation": {"seed": "track"},
"last_updated": "2026-05-26",
}]
self._tracks = [{
"track_name": "Similar",
"artist_name": "Artist",
"album_name": None,
"duration_ms": 200_000,
"recording_mbid": "lfm-rec-1",
"release_mbid": None,
"album_cover_url": None,
"additional_metadata": {},
}]
def get_cached_playlists(self, playlist_type: str):
if playlist_type == "lastfm_radio":
return self._rows
return []
def get_cached_tracks(self, mbid: str):
if mbid == "lfm-1":
return self._tracks
return []
def test_lastfm_adapter_projects_radio_rows():
src = LastFMPlaylistSource(lambda: _FakeLastFMManager())
metas = src.list_playlists()
assert len(metas) == 1
assert metas[0].source == "lastfm"
assert metas[0].owner == "Last.fm"
detail = src.get_playlist("lfm-1")
assert detail is not None
assert detail.tracks[0].needs_discovery is True
assert detail.tracks[0].source_track_id == "lfm-rec-1"
# ─── SoulSync Discovery ─────────────────────────────────────────────────
class _FakeDiscoveryManager:
def __init__(self):
self.refresh_calls = []
self._records = [SimpleNamespace(
id=42,
profile_id=1,
kind="hidden_gems",
variant="",
name="Hidden Gems",
config=None,
track_count=1,
last_generated_at="2026-05-26T00:00:00Z",
last_synced_at=None,
last_generation_source="discovery_pool",
last_generation_error=None,
is_stale=False,
)]
self._tracks = [SimpleNamespace(
track_name="Gem",
artist_name="Indie",
album_name="EP",
spotify_track_id="sp-gem",
itunes_track_id=None,
deezer_track_id=None,
album_cover_url="art",
duration_ms=180_000,
popularity=20,
track_data_json=None,
source="discovery",
primary_id=lambda: "sp-gem",
)]
def list_playlists(self, profile_id: int = 1):
return self._records
def get_playlist_tracks(self, playlist_id: int):
return self._tracks if playlist_id == 42 else []
def refresh_playlist(self, kind: str, variant: str = "", profile_id: int = 1, config_overrides=None):
self.refresh_calls.append((kind, variant, profile_id))
return self._records[0]
def test_soulsync_discovery_adapter_tracks_dont_need_discovery():
manager = _FakeDiscoveryManager()
src = SoulSyncDiscoveryPlaylistSource(lambda: manager, profile_id_getter=lambda: 1)
metas = src.list_playlists()
assert metas[0].source == "soulsync_discovery"
assert metas[0].source_playlist_id == "42"
assert metas[0].extra["kind"] == "hidden_gems"
detail = src.get_playlist("42")
assert detail is not None
t = detail.tracks[0]
assert t.needs_discovery is False
assert t.source_track_id == "sp-gem"
assert t.album_name == "EP"
assert t.extra["spotify_track_id"] == "sp-gem"
def test_soulsync_discovery_adapter_refresh_invokes_manager():
manager = _FakeDiscoveryManager()
src = SoulSyncDiscoveryPlaylistSource(lambda: manager, profile_id_getter=lambda: 1)
src.refresh_playlist("42")
assert manager.refresh_calls == [("hidden_gems", "", 1)]
# ─── Registry ───────────────────────────────────────────────────────────
def test_registry_lazy_construct_and_cache():
reg = PlaylistSourceRegistry()
constructed = []
def factory():
constructed.append(True)
return SpotifyPlaylistSource(lambda: None)
reg.register("spotify", factory)
assert constructed == [] # not built yet
first = reg.get_source("spotify")
second = reg.get_source("spotify")
assert first is second # cached
assert len(constructed) == 1
def test_registry_re_register_invalidates_instance():
reg = PlaylistSourceRegistry()
reg.register("spotify", lambda: SpotifyPlaylistSource(lambda: None))
first = reg.get_source("spotify")
reg.register("spotify", lambda: SpotifyPlaylistSource(lambda: None))
second = reg.get_source("spotify")
assert first is not second
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_default_discover_tracks_is_no_op():
"""Adapters whose tracks already carry provider IDs (Spotify,
Tidal, Qobuz, YouTube, Deezer, Spotify-public, iTunes-link,
SoulSync-Discovery) inherit the ABC default return tracks
unchanged."""
track = NormalizedTrack(
position=0,
track_name="Song",
artist_name="Artist",
source_track_id="abc",
needs_discovery=False,
)
src = SpotifyPlaylistSource(lambda: None)
out = src.discover_tracks([track])
assert out == [track]
def test_listenbrainz_discover_tracks_uses_callable():
"""When the LB adapter is wired with a discover_callable, MB
tracks get matched_data populated; ``needs_discovery`` flips to
False on matches; non-matches stay as-is."""
def fake_discover(track_dicts):
# Match the first, leave second unmatched.
return [
{
"id": "matched-1",
"name": "Matched",
"artists": ["Artist 1"],
"album": {"name": "Album"},
"duration_ms": 200_000,
"image_url": "art",
"source": "spotify",
"_provider": "spotify",
"_confidence": 0.95,
},
None,
]
src = ListenBrainzPlaylistSource(
lambda: None,
discover_callable=fake_discover,
)
tracks = [
NormalizedTrack(
position=0,
track_name="Song A",
artist_name="Artist 1",
source_track_id="mbid-1",
needs_discovery=True,
),
NormalizedTrack(
position=1,
track_name="Song B",
artist_name="Artist 2",
source_track_id="mbid-2",
needs_discovery=True,
),
]
out = src.discover_tracks(tracks)
assert len(out) == 2
assert out[0].needs_discovery is False
assert out[0].source_track_id == "matched-1"
assert out[0].extra["discovered"] is True
assert out[0].extra["provider"] == "spotify"
assert out[0].extra["confidence"] == 0.95
assert out[0].extra["matched_data"]["id"] == "matched-1"
# Unmatched stays as-is.
assert out[1].needs_discovery is True
assert out[1].source_track_id == "mbid-2"
assert "matched_data" not in (out[1].extra or {})
def test_listenbrainz_discover_tracks_no_callable_is_no_op():
"""If no ``discover_callable`` is wired, the adapter returns the
list unchanged refresh paths that haven't enabled discovery
still work."""
src = ListenBrainzPlaylistSource(lambda: None, discover_callable=None)
tracks = [
NormalizedTrack(
position=0,
track_name="T",
artist_name="A",
needs_discovery=True,
)
]
assert src.discover_tracks(tracks) == tracks
def test_lastfm_discover_tracks_shares_listenbrainz_implementation():
"""Last.fm radio tracks have the same MB-metadata shape as LB
tracks, so the adapter reuses LB's ``discover_tracks``."""
def fake_discover(track_dicts):
return [{
"id": "lfm-matched",
"name": "Match",
"artists": ["Artist"],
"album": {"name": ""},
"duration_ms": 200_000,
"image_url": "",
"source": "spotify",
"_provider": "spotify",
}]
src = LastFMPlaylistSource(lambda: None, discover_callable=fake_discover)
tracks = [
NormalizedTrack(
position=0,
track_name="T",
artist_name="A",
needs_discovery=True,
)
]
out = src.discover_tracks(tracks)
assert out[0].needs_discovery is False
assert out[0].source_track_id == "lfm-matched"
assert out[0].extra["matched_data"]["id"] == "lfm-matched"
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"

View file

@ -45,6 +45,7 @@ SPLIT_MODULES = [
"discover.js",
"enrichment.js",
"stats-automations.js",
"auto-sync.js",
"pages-extra.js",
"init.js",
]

0
tests/text/__init__.py Normal file
View file

View file

@ -0,0 +1,38 @@
"""Tests for `core.text.normalize.normalize_for_comparison`."""
from core.text.normalize import normalize_for_comparison
def test_empty_input_returns_empty_string():
assert normalize_for_comparison("") == ""
assert normalize_for_comparison(None) == "" # type: ignore[arg-type]
def test_lowercases_ascii():
assert normalize_for_comparison("Drake") == "drake"
assert normalize_for_comparison("DRAKE") == "drake"
def test_strips_surrounding_whitespace():
assert normalize_for_comparison(" Drake ") == "drake"
assert normalize_for_comparison("\tDrake\n") == "drake"
def test_folds_accents_to_ascii():
"""Diacritic-different spellings of the same artist must collapse to
one normalized key otherwise the pool would re-fetch the same
artist when the playlist and library disagree on casing/accents."""
assert normalize_for_comparison("Beyoncé") == "beyonce"
assert normalize_for_comparison("Björk") == "bjork"
assert normalize_for_comparison("Subcarpaţi") == "subcarpati"
def test_combines_lowercase_and_accent_folding():
assert normalize_for_comparison("BEYONCÉ") == "beyonce"
def test_preserves_internal_whitespace():
"""Multi-word artist names must keep their internal spacing — only
leading/trailing whitespace is stripped."""
assert normalize_for_comparison("Bon Iver") == "bon iver"
assert normalize_for_comparison("Tame Impala") == "tame impala"

View file

@ -0,0 +1,159 @@
"""Tests for the wishlist-cycle album grouping helper that drives
the per-album bundle dispatch.
Pins the bucketing contract so future changes to the dispatch flow
don't silently regress the user-visible behavior: wishlist 'albums'
cycle should emit one album-bundle search per missing album, not
one per missing track.
"""
from __future__ import annotations
from core.wishlist.album_grouping import (
WishlistAlbumGroup,
WishlistGroupingResult,
group_wishlist_tracks_by_album,
)
def _wt(track_name, artist, album_id, album_name, **extra):
"""Build a wishlist row in the shape the wishlist service returns."""
return {
'track_name': track_name,
'artist_name': artist,
'spotify_data': {
'name': track_name,
'artists': [{'name': artist}],
'album': {
'id': album_id,
'name': album_name,
**extra,
},
},
}
def test_empty_input_returns_empty_result():
res = group_wishlist_tracks_by_album([])
assert res.album_groups == []
assert res.residual_tracks == []
def test_single_album_groups_all_tracks_together():
tracks = [
_wt('Dragon Soul', 'Ryoto', 'alb1', 'Cha-La Head-Cha-La'),
_wt('Cha-La Head-Cha-La', 'Ryoto', 'alb1', 'Cha-La Head-Cha-La'),
_wt('Zenkai Power', 'Ryoto', 'alb1', 'Cha-La Head-Cha-La'),
]
res = group_wishlist_tracks_by_album(tracks)
assert len(res.album_groups) == 1
g = res.album_groups[0]
assert g.album_key == 'alb1'
assert g.album_context['name'] == 'Cha-La Head-Cha-La'
assert g.artist_context['name'] == 'Ryoto'
assert len(g.tracks) == 3
def test_multiple_albums_emit_separate_groups():
tracks = [
_wt('Song A', 'Artist 1', 'alb1', 'Album 1'),
_wt('Song B', 'Artist 1', 'alb1', 'Album 1'),
_wt('Song C', 'Artist 2', 'alb2', 'Album 2'),
]
res = group_wishlist_tracks_by_album(tracks)
assert len(res.album_groups) == 2
keys = {g.album_key for g in res.album_groups}
assert keys == {'alb1', 'alb2'}
for g in res.album_groups:
if g.album_key == 'alb1':
assert len(g.tracks) == 2
else:
assert len(g.tracks) == 1
def test_missing_album_metadata_falls_through_to_residual():
tracks = [
# No spotify_data.album at all
{'track_name': 'Orphan', 'artist_name': 'X', 'spotify_data': {'artists': [{'name': 'X'}]}},
# Empty album dict
{'track_name': 'Empty Album', 'artist_name': 'X', 'spotify_data': {'album': {}, 'artists': [{'name': 'X'}]}},
]
res = group_wishlist_tracks_by_album(tracks)
assert res.album_groups == []
assert len(res.residual_tracks) == 2
def test_missing_artist_demotes_to_residual():
"""Album-bundle search needs an artist; if we can't recover one,
skip the bundle path and let the track go through per-track."""
tracks = [{
'track_name': 'Song',
'spotify_data': {
'artists': [],
'album': {'id': 'a', 'name': 'Album'},
},
}]
res = group_wishlist_tracks_by_album(tracks)
assert res.album_groups == []
assert res.residual_tracks == tracks
def test_min_tracks_threshold_demotes_solos():
"""When ``min_tracks_per_album=2``, single-track albums fall to
residual so the user doesn't fire a bundle search for a 1-track
rip when per-track would do."""
tracks = [
_wt('Solo Track', 'Artist 1', 'alb1', 'Album 1'),
_wt('Song A', 'Artist 2', 'alb2', 'Album 2'),
_wt('Song B', 'Artist 2', 'alb2', 'Album 2'),
]
res = group_wishlist_tracks_by_album(tracks, min_tracks_per_album=2)
assert len(res.album_groups) == 1
assert res.album_groups[0].album_key == 'alb2'
assert len(res.residual_tracks) == 1
assert res.residual_tracks[0]['track_name'] == 'Solo Track'
def test_default_threshold_promotes_solo_albums():
"""Default ``min_tracks_per_album=1`` — even one missing track
triggers the album-bundle path. Matches the user's stated
preference (don't gate on track count)."""
tracks = [_wt('Solo', 'Artist 1', 'alb1', 'Album 1')]
res = group_wishlist_tracks_by_album(tracks)
assert len(res.album_groups) == 1
assert res.residual_tracks == []
def test_album_without_id_uses_name_normalized_key():
"""Some older wishlist rows are missing the album id. Group by
a name-normalized key so they still bucket together."""
tracks = [
_wt('S1', 'Artist', None, 'Same Album'),
_wt('S2', 'Artist', None, 'Same Album'),
]
# First track has explicit id=None which is filtered; the fallback
# is ``_name_<lowercase trimmed name>``. Build manually so the
# helper sees no id at all.
for t in tracks:
del t['spotify_data']['album']['id']
res = group_wishlist_tracks_by_album(tracks)
assert len(res.album_groups) == 1
assert res.album_groups[0].album_key == '_name_same album'
assert len(res.album_groups[0].tracks) == 2
def test_nested_track_data_payloads_normalized():
"""The wishlist service sometimes nests spotify_data under
track_data (JSON-string in DB re-parsed). Ensure the grouper
digs through the same shapes ``classify_wishlist_track`` does."""
tracks = [{
'track_data': {
'spotify_data': {
'artists': [{'name': 'Artist'}],
'album': {'id': 'a', 'name': 'Album'},
},
},
}]
res = group_wishlist_tracks_by_album(tracks)
assert len(res.album_groups) == 1
assert res.album_groups[0].album_key == 'a'

View file

@ -233,7 +233,111 @@ def test_process_wishlist_automatically_creates_batch_for_matching_tracks():
assert batch["analysis_total"] == 1
assert any(kwargs.get("progress") == 50 for _args, kwargs in progress_calls)
assert guard_events == ["enter", "exit"]
assert any("Starting automatic wishlist batch" in msg for msg in logger.info_messages)
# Track has no album id/name → falls to residual batch path
assert any("Starting wishlist residual batch" in msg for msg in logger.info_messages)
def test_wishlist_albums_cycle_splits_into_per_album_batches():
"""Multi-album wishlist run: each album emits its own sub-batch
with ``is_album_download=True`` + populated album/artist context.
Pinned so the album-bundle dispatch gate (which keys on those
fields) engages per album instead of falling through to per-track
on a single mixed batch."""
batch_map = {}
runtime, _service, _profiles_db, music_db, executor, _logger, _progress, _guards = _build_runtime(
tracks=[
{
"name": "Song A1",
"artists": [{"name": "Artist 1"}],
"spotify_data": {
"album": {"id": "alb1", "name": "Album One", "album_type": "album"},
"artists": [{"name": "Artist 1"}],
},
},
{
"name": "Song A2",
"artists": [{"name": "Artist 1"}],
"spotify_data": {
"album": {"id": "alb1", "name": "Album One", "album_type": "album"},
"artists": [{"name": "Artist 1"}],
},
},
{
"name": "Song B1",
"artists": [{"name": "Artist 2"}],
"spotify_data": {
"album": {"id": "alb2", "name": "Album Two", "album_type": "album"},
"artists": [{"name": "Artist 2"}],
},
},
],
cycle_value="albums",
count=3,
batch_map=batch_map,
)
process_wishlist_automatically(runtime, automation_id="auto-multi-album")
# Two album groups → two sub-batches submitted (no residual).
assert len(executor.submissions) == 2
assert len(batch_map) == 2
# Each sub-batch must carry album-bundle dispatch context.
for batch in batch_map.values():
assert batch.get("is_album_download") is True
assert batch.get("album_context", {}).get("name") in {"Album One", "Album Two"}
assert batch.get("artist_context", {}).get("name") in {"Artist 1", "Artist 2"}
submitted_track_lists = [submitted_args[2] for _fn, submitted_args, _kw in executor.submissions]
track_counts = sorted(len(tracks) for tracks in submitted_track_lists)
assert track_counts == [1, 2]
# All sub-batches of one wishlist invocation share a single
# ``wishlist_run_id`` so the completion handler can gate the
# cycle toggle on "all siblings done".
run_ids = {batch.get("wishlist_run_id") for batch in batch_map.values()}
assert len(run_ids) == 1
assert next(iter(run_ids)) # non-empty string
def test_wishlist_albums_cycle_residual_for_orphan_tracks():
"""Tracks without resolvable album metadata fall to the classic
per-track residual batch (no ``is_album_download`` flag), while
sibling tracks with valid album info still get their own
album-bundle sub-batch."""
batch_map = {}
runtime, _service, _profiles_db, music_db, executor, _logger, _progress, _guards = _build_runtime(
tracks=[
{
"name": "Real Album Track",
"artists": [{"name": "Artist 1"}],
"spotify_data": {
"album": {"id": "alb1", "name": "Album One", "album_type": "album"},
"artists": [{"name": "Artist 1"}],
},
},
{
# No album id, no album name — orphan
"name": "Orphan",
"artists": [{"name": "X"}],
"spotify_data": {"album": {"album_type": "album"}, "artists": [{"name": "X"}]},
},
],
cycle_value="albums",
count=2,
batch_map=batch_map,
)
process_wishlist_automatically(runtime, automation_id="auto-mixed")
assert len(executor.submissions) == 2 # 1 album batch + 1 residual
album_batches = [b for b in batch_map.values() if b.get("is_album_download")]
residual_batches = [b for b in batch_map.values() if not b.get("is_album_download")]
assert len(album_batches) == 1
assert len(residual_batches) == 1
assert album_batches[0]["album_context"]["name"] == "Album One"
assert residual_batches[0]["analysis_total"] == 1
def test_process_wishlist_automatically_returns_early_when_already_processing():

View file

@ -232,6 +232,80 @@ def test_start_manual_wishlist_download_batch_does_not_run_library_cleanup():
assert activity_calls == [("", "Wishlist Download Started", "2 tracks", "Now")]
def test_manual_wishlist_splits_into_per_album_sub_batches():
"""Manual wishlist run with multi-album content splits into one
sub-batch per album. Each sub-batch flips
``is_album_download=True`` + populates album/artist context so
the slskd / Prowlarr album-bundle dispatch engages.
Pinned to verify the manual path matches the auto path's
behavior the user's first real-world test hit the manual
flow, not the auto flow."""
runtime, _service, _db, executor, _logger, _activity, batch_map, master_calls = _build_runtime(
tracks=[
{
"id": "trk-a1",
"spotify_track_id": "trk-a1",
"name": "Song A1",
"artists": [{"name": "Artist 1"}],
"spotify_data": {
"album": {"id": "alb1", "name": "Album One", "album_type": "album"},
"artists": [{"name": "Artist 1"}],
},
},
{
"id": "trk-a2",
"spotify_track_id": "trk-a2",
"name": "Song A2",
"artists": [{"name": "Artist 1"}],
"spotify_data": {
"album": {"id": "alb1", "name": "Album One", "album_type": "album"},
"artists": [{"name": "Artist 1"}],
},
},
{
"id": "trk-b1",
"spotify_track_id": "trk-b1",
"name": "Song B1",
"artists": [{"name": "Artist 2"}],
"spotify_data": {
"album": {"id": "alb2", "name": "Album Two", "album_type": "album"},
"artists": [{"name": "Artist 2"}],
},
},
]
)
payload, status = processing.start_manual_wishlist_download_batch(runtime)
assert status == 200
_run_submitted_bg_job(executor)
# Two album groups → two master-worker calls.
assert len(master_calls) == 2
# First sub-batch uses the caller-allocated batch_id.
first_args, _ = master_calls[0]
assert first_args[0] == payload["batch_id"]
assert batch_map[payload["batch_id"]].get("is_album_download") is True
# Second sub-batch gets a fresh uuid; its row exists in batch_map.
second_args, _ = master_calls[1]
assert second_args[0] != payload["batch_id"]
assert second_args[0] in batch_map
assert batch_map[second_args[0]].get("is_album_download") is True
# Track counts across the two sub-batches sum to the wishlist total.
counts = sorted(len(args[2]) for args, _ in master_calls)
assert counts == [1, 2]
# Both sub-batches carry album context populated from spotify_data.
album_names = {
batch_map[args[0]]["album_context"]["name"]
for args, _ in master_calls
}
assert album_names == {"Album One", "Album Two"}
def test_bg_job_marks_batch_complete_when_wishlist_genuinely_empty():
"""If the wishlist is empty before the manual click, the bg job marks the batch complete."""
runtime, _service, _db, executor, _logger, _activity, batch_map, master_calls = _build_runtime(

View file

@ -106,6 +106,44 @@ def test_extract_spotify_track_from_modal_info_reconstructs_from_slskd_result():
assert out["album"]["name"] == "Album Three"
def test_ensure_wishlist_track_format_defaults_non_dict_album_to_album_type():
"""When ``album`` arrives as a non-dict (legacy/reconstruction path) we
must not stamp ``album_type='single'`` that lies about the origin
and routes the wishlist requeue through the single_path template
instead of album_path, dumping album tracks into the Singles tree.
Default to 'album' / total_tracks=0 (unknown) so downstream code can
fall through to the real release-type detection logic."""
track = {
"name": "Song",
"artist": "Artist One",
"album": "Album From Legacy String",
}
out = payloads.ensure_wishlist_track_format(track)
assert out["album"]["name"] == "Album From Legacy String"
assert out["album"]["album_type"] == "album"
assert out["album"]["total_tracks"] == 0
def test_extract_spotify_track_from_modal_info_slskd_reconstruction_defaults_to_album():
"""Slskd-result reconstruction is a last-resort path; defaulting to
``album_type='single'`` corrupted the requeue routing for album
batches. Same fix as ensure_wishlist_track_format: default 'album'."""
track_info = {
"slskd_result": SimpleNamespace(
title="Song Three",
artist="Artist Three",
album="Album Three",
)
}
out = payloads.extract_spotify_track_from_modal_info(track_info)
assert out["album"]["album_type"] == "album"
assert out["album"]["total_tracks"] == 0
def test_extract_wishlist_track_from_modal_info_uses_track_data_key():
track_info = {
"track_data": {

View file

@ -108,6 +108,53 @@ def test_add_cancelled_tracks_to_failed_tracks_builds_entries():
assert failed[0]["failure_reason"] == "Download cancelled"
def test_resolve_wishlist_source_type_for_batch_returns_album_for_album_batch():
assert processing.resolve_wishlist_source_type_for_batch({"is_album_download": True}) == "album"
def test_resolve_wishlist_source_type_for_batch_returns_playlist_otherwise():
assert processing.resolve_wishlist_source_type_for_batch({}) == "playlist"
assert processing.resolve_wishlist_source_type_for_batch({"is_album_download": False}) == "playlist"
def test_build_wishlist_source_context_minimal_batch_skips_album_provenance():
"""Non-album batches must not carry album_context (would mislead the
requeue logic into routing single-track sync failures through
album_path)."""
batch = {
"playlist_name": "My Mix",
"playlist_id": "pl-99",
}
context = processing.build_wishlist_source_context(batch)
assert context["playlist_name"] == "My Mix"
assert context["playlist_id"] == "pl-99"
assert context["added_from"] == "webui_modal"
assert "is_album_download" not in context
assert "album_context" not in context
assert "artist_context" not in context
def test_build_wishlist_source_context_preserves_album_context_for_album_batches():
"""Album batches must carry album_context/artist_context through to the
wishlist row so a later requeue has authoritative routing data instead
of having to reconstruct it from per-track album dicts."""
batch = {
"playlist_name": "Album: GNX",
"playlist_id": "library_redownload_abc",
"is_album_download": True,
"album_context": {"id": "alb-1", "name": "GNX", "total_tracks": 12},
"artist_context": {"id": "art-1", "name": "Kendrick Lamar"},
}
context = processing.build_wishlist_source_context(batch)
assert context["is_album_download"] is True
assert context["album_context"] == {"id": "alb-1", "name": "GNX", "total_tracks": 12}
assert context["artist_context"] == {"id": "art-1", "name": "Kendrick Lamar"}
def test_recover_uncaptured_failed_tracks_builds_entries():
batch = {"queue": ["a"]}
download_tasks = {
@ -135,6 +182,111 @@ def test_recover_uncaptured_failed_tracks_builds_entries():
assert failed[0]["failure_reason"] == "boom"
def test_finalize_auto_wishlist_completion_defers_toggle_when_siblings_active():
"""When the completing batch shares a ``wishlist_run_id`` with
siblings still in pre-terminal phases, finalize must NOT toggle
the cycle yet that only happens when the LAST sibling done.
Pinned to prevent the regression where every sub-batch's
completion fired its own cycle toggle (Phase 1c.2.1 split path)."""
db = _FakeDB()
automation_engine = _FakeAutomationEngine()
resets = []
activities = []
summary = {"tracks_added": 1, "total_failed": 1, "errors": 0}
# Two sub-batches share the same run id. The first finishes,
# the second is still 'analysis'.
download_batches = {
"batch-A": {"current_cycle": "albums", "wishlist_run_id": "run-1", "phase": "complete"},
"batch-B": {"current_cycle": "albums", "wishlist_run_id": "run-1", "phase": "analysis"},
}
processing.finalize_auto_wishlist_completion(
"batch-A",
summary,
download_batches=download_batches,
tasks_lock=_FakeLock(),
reset_processing_state=lambda: resets.append(True),
add_activity_item=lambda *args: activities.append(args),
automation_engine=automation_engine,
db_factory=lambda: db,
logger=_FakeLogger(),
)
# Activity log still fires (it's a per-batch record), but cycle
# toggle + state reset + automation emit are deferred.
assert activities == [("", "Wishlist Updated", "1 failed tracks added to wishlist", "Now")]
assert resets == [] # NOT reset yet — siblings still active
assert automation_engine.events == [] # NOT emitted yet
assert db.connection.cursor_obj.calls == [] # DB cycle-toggle NOT written
def test_finalize_auto_wishlist_completion_toggles_when_last_sibling_done():
"""When all siblings of the same run are in terminal phases (or
don't exist), the completing batch IS the last → cycle toggles
+ state resets + automation event fires."""
db = _FakeDB()
automation_engine = _FakeAutomationEngine()
resets = []
summary = {"tracks_added": 1, "total_failed": 1, "errors": 0}
download_batches = {
# Both siblings already terminal — current batch is the last.
"batch-A": {"current_cycle": "albums", "wishlist_run_id": "run-1", "phase": "complete"},
"batch-B": {"current_cycle": "albums", "wishlist_run_id": "run-1", "phase": "complete"},
"batch-C": {"current_cycle": "albums", "wishlist_run_id": "run-1", "phase": "analysis"}, # the completing one
}
processing.finalize_auto_wishlist_completion(
"batch-C",
summary,
download_batches=download_batches,
tasks_lock=_FakeLock(),
reset_processing_state=lambda: resets.append(True),
add_activity_item=lambda *_a: None,
automation_engine=automation_engine,
db_factory=lambda: db,
logger=_FakeLogger(),
)
assert resets == [True]
assert db.connection.committed is True
assert db.connection.cursor_obj.calls[0][1] == ("singles",)
assert automation_engine.events # event emitted
def test_finalize_auto_wishlist_completion_legacy_no_run_id_toggles_immediately():
"""Back-compat: a batch with NO ``wishlist_run_id`` (legacy
single-batch run from before Phase 1c.2.1) should keep firing
the toggle on its own completion regardless of any unrelated
batches in the dict."""
db = _FakeDB()
automation_engine = _FakeAutomationEngine()
resets = []
summary = {"tracks_added": 0, "total_failed": 0, "errors": 0}
download_batches = {
"batch-legacy": {"current_cycle": "albums"}, # no wishlist_run_id
# Even with another unrelated batch active, legacy should toggle.
"unrelated": {"current_cycle": "singles", "phase": "analysis"},
}
processing.finalize_auto_wishlist_completion(
"batch-legacy",
summary,
download_batches=download_batches,
tasks_lock=_FakeLock(),
reset_processing_state=lambda: resets.append(True),
add_activity_item=lambda *_a: None,
automation_engine=automation_engine,
db_factory=lambda: db,
logger=_FakeLogger(),
)
assert resets == [True]
assert db.connection.cursor_obj.calls[0][1] == ("singles",)
def test_finalize_auto_wishlist_completion_toggles_cycle_and_resets_state():
db = _FakeDB()
automation_engine = _FakeAutomationEngine()
@ -364,3 +516,4 @@ def test_finalize_auto_wishlist_completion_with_no_tracks_added_still_resets_sta
)
]
assert db.connection.committed is True

File diff suppressed because it is too large Load diff

View file

@ -837,18 +837,86 @@
</div>
</article>
<!-- Card: Tools (compact, top-right) — entry point to maintenance ops -->
<article class="dash-card dash-card--cta" data-card="tools" onclick="navigateToPage('tools')">
<!-- Card: Quick Actions launcher — asymmetric bento.
Auto-Sync hero spans both rows on the left; Tools + Automations
stack on the right. Each tile has its own signature animation. -->
<article class="dash-card dash-card--quick-actions" data-card="tools">
<header class="dash-card__head">
<h3 class="dash-card__title">Tools</h3>
<p class="dash-card__sub">Database, scanning, backups, cache, maintenance &amp; more.</p>
<h3 class="dash-card__title">Quick Actions</h3>
<p class="dash-card__sub">Three control rooms inside SoulSync.</p>
</header>
<div class="dash-card__body dash-card__body--cta">
<div class="dash-card__cta-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
</div>
<div class="dash-card__cta-label">Open Tools</div>
<svg class="dash-card__cta-arrow" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
<div class="dash-card__body qa-bento">
<button class="qa-tile qa-tile--hero qa-tile--sync" onclick="openAutoSyncScheduleModal()" aria-label="Open Auto-Sync">
<div class="qa-tile__bg" aria-hidden="true">
<div class="qa-tile__eq">
<span></span><span></span><span></span><span></span><span></span>
<span></span><span></span><span></span><span></span><span></span>
<span></span><span></span><span></span><span></span><span></span>
<span></span><span></span><span></span><span></span><span></span>
</div>
</div>
<div class="qa-tile__topline">
<span class="qa-tile__pulse" aria-hidden="true"></span>
<span class="qa-tile__kicker">Playlist pipeline</span>
</div>
<div class="qa-tile__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 15.5-6.2"/><path d="M18.5 3.5v5h-5"/><path d="M21 12a9 9 0 0 1-15.5 6.2"/><path d="M5.5 20.5v-5h5"/></svg>
</div>
<div class="qa-tile__heading">
<strong class="qa-tile__title">Auto-Sync</strong>
<p class="qa-tile__desc">Refresh, discover, sync, wishlist — running on a schedule you set.</p>
</div>
<div class="qa-tile__cta">
<span class="qa-tile__cta-label">Manage Schedule</span>
<span class="qa-tile__cta-arrow" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="13 6 19 12 13 18"/></svg>
</span>
</div>
</button>
<button class="qa-tile qa-tile--minor qa-tile--tools" onclick="navigateToPage('tools')" aria-label="Open Tools">
<div class="qa-tile__bg" aria-hidden="true">
<div class="qa-tile__gear">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</div>
</div>
<div class="qa-tile__topline">
<span class="qa-tile__kicker">Maintenance</span>
</div>
<div class="qa-tile__heading">
<strong class="qa-tile__title">Tools</strong>
<p class="qa-tile__desc">Database, scanning, repair, backups.</p>
</div>
<div class="qa-tile__cta">
<span class="qa-tile__cta-label">Open</span>
<span class="qa-tile__cta-arrow" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="13 6 19 12 13 18"/></svg>
</span>
</div>
</button>
<button class="qa-tile qa-tile--minor qa-tile--auto" onclick="navigateToPage('automations')" aria-label="Open Automations">
<div class="qa-tile__bg" aria-hidden="true">
<div class="qa-tile__flow">
<span class="qa-flow-node"></span>
<span class="qa-flow-line"></span>
<span class="qa-flow-node"></span>
<span class="qa-flow-line"></span>
<span class="qa-flow-node"></span>
</div>
</div>
<div class="qa-tile__topline">
<span class="qa-tile__kicker">Trigger → action</span>
</div>
<div class="qa-tile__heading">
<strong class="qa-tile__title">Automations</strong>
<p class="qa-tile__desc">Events, schedules, signals, then-actions.</p>
</div>
<div class="qa-tile__cta">
<span class="qa-tile__cta-label">Open</span>
<span class="qa-tile__cta-arrow" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="13 6 19 12 13 18"/></svg>
</span>
</div>
</button>
</div>
</article>
@ -916,6 +984,7 @@
server</p>
</div>
<div style="display:flex;gap:8px;align-items:center;">
<button class="sync-history-btn auto-sync-manager-btn" onclick="openAutoSyncScheduleModal()" title="Schedule mirrored playlists to refresh, discover, sync, and queue missing tracks">Auto-Sync</button>
<button class="sync-history-btn" onclick="openManualLibraryMatchTool()" title="Manually link source tracks to library tracks">Library Match</button>
<button class="sync-history-btn" onclick="openSyncHistoryModal()" title="View sync history">Sync History</button>
</div>
@ -937,15 +1006,18 @@
<button class="sync-tab-button" data-tab="spotify-public">
<span class="tab-icon spotify-icon"></span> Spotify Link
</button>
<button class="sync-tab-button" data-tab="itunes-link">
<span class="tab-icon itunes-icon"></span> iTunes Link
</button>
<button class="sync-tab-button" data-tab="tidal">
<span class="tab-icon tidal-icon"></span> Tidal
</button>
<button class="sync-tab-button" data-tab="deezer">
<span class="tab-icon deezer-icon"></span> Deezer
</button>
<button class="sync-tab-button" data-tab="qobuz">
<span class="tab-icon qobuz-icon"></span> Qobuz
</button>
<button class="sync-tab-button" data-tab="deezer">
<span class="tab-icon deezer-icon"></span> Deezer
</button>
<button class="sync-tab-button" data-tab="deezer-link">
<span class="tab-icon deezer-icon"></span> Deezer Link
</button>
@ -955,6 +1027,15 @@
<button class="sync-tab-button" data-tab="beatport">
<span class="tab-icon beatport-icon"></span> Beatport
</button>
<button class="sync-tab-button" data-tab="listenbrainz-sync">
<span class="tab-icon listenbrainz-icon"></span> ListenBrainz
</button>
<button class="sync-tab-button" data-tab="lastfm-sync">
<span class="tab-icon lastfm-icon"></span> Last.fm
</button>
<button class="sync-tab-button" data-tab="soulsync-discovery-sync">
<span class="tab-icon soulsync-discovery-icon"></span> SoulSync Discovery
</button>
<button class="sync-tab-button" data-tab="import-file">
<span class="tab-icon import-file-icon"></span> Import
</button>
@ -1046,6 +1127,19 @@
</div>
</div>
<!-- iTunes Link Tab Content -->
<div class="sync-tab-content" id="itunes-link-tab-content">
<div class="youtube-input-section">
<input type="text" id="itunes-link-url-input"
placeholder="Paste iTunes or Apple Music Album/Track URL...">
<button id="itunes-link-parse-btn">Load</button>
</div>
<div class="url-history-bar" id="itunes-link-url-history" style="display:none"></div>
<div class="playlist-scroll-container" id="itunes-link-playlist-container">
<div class="playlist-placeholder">Paste an iTunes or Apple Music album/track URL above to load tracks.</div>
</div>
</div>
<!-- Beatport Tab Content -->
<div class="sync-tab-content" id="beatport-tab-content">
<!-- Beatport Nested Tabs (hidden — Browse is the only view) -->
@ -1827,6 +1921,44 @@
</div>
</div>
<!-- SoulSync Discovery Sync Tab Content -->
<div class="sync-tab-content" id="soulsync-discovery-sync-tab-content">
<div class="playlist-header">
<h3>SoulSync Discovery Playlists</h3>
<button class="refresh-button soulsync-discovery" id="soulsync-discovery-sync-refresh-btn">🔄 Refresh</button>
</div>
<div class="playlist-scroll-container" id="soulsync-discovery-sync-playlist-container">
<div class="playlist-placeholder">Click 'Refresh' to load your personalized SoulSync Discovery playlists.</div>
</div>
</div>
<!-- Last.fm Radio Sync Tab Content -->
<div class="sync-tab-content" id="lastfm-sync-tab-content">
<div class="playlist-header">
<h3>Your Last.fm Radio Playlists</h3>
<button class="refresh-button lastfm" id="lastfm-sync-refresh-btn">🔄 Refresh</button>
</div>
<div class="playlist-scroll-container" id="lastfm-sync-playlist-container">
<div class="playlist-placeholder">Click 'Refresh' to load your Last.fm Radio playlists. Generate new ones from the Discover page.</div>
</div>
</div>
<!-- ListenBrainz Sync Tab Content (separate ID from Discover-page LB UI) -->
<div class="sync-tab-content" id="listenbrainz-sync-tab-content">
<div class="playlist-header">
<h3>Your ListenBrainz Playlists</h3>
<div class="listenbrainz-sub-tabs">
<button class="listenbrainz-sub-tab-btn active" data-lb-type="created_for_user">For You</button>
<button class="listenbrainz-sub-tab-btn" data-lb-type="user_created">My Playlists</button>
<button class="listenbrainz-sub-tab-btn" data-lb-type="collaborative">Collaborative</button>
</div>
<button class="refresh-button listenbrainz" id="listenbrainz-sync-refresh-btn">🔄 Refresh</button>
</div>
<div class="playlist-scroll-container" id="listenbrainz-sync-playlist-container">
<div class="playlist-placeholder">Click 'Refresh' to load your ListenBrainz playlists.</div>
</div>
</div>
<!-- Mirrored Playlists Tab Content -->
<div class="sync-tab-content" id="mirrored-tab-content">
<div class="playlist-header">
@ -7877,6 +8009,9 @@
<script src="{{ url_for('static', filename='downloads.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='wishlist-tools.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-services.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-listenbrainz.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-lastfm.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-soulsync-discovery.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='api-monitor.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='library.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='beatport-ui.js', v=static_v) }}"></script>
@ -7884,6 +8019,7 @@
<script src="{{ url_for('static', filename='discover.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='enrichment.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='stats-automations.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='auto-sync.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='pages-extra.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='init.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='shell-bridge.js', v=static_v) }}"></script>

1521
webui/static/auto-sync.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -3413,6 +3413,39 @@ function closeHelperSearch() {
// projects that span multiple commits before shipping. Strip the flag at
// release time and add a real `date:` line at the top of the version block.
const WHATS_NEW = {
'2.6.3': [
{ unreleased: true },
{ title: 'Library: Enhanced / Standard view toggle now sticks per browser', desc: 'the artist detail page used to revert to Standard view every time you clicked into a new artist — flipping to Enhanced was a per-click thing with no memory. now the choice is persisted in localStorage and reapplied automatically on every artist open. survives page reloads too. non-admins (who can\'t toggle Enhanced anyway) are unaffected; admins who never touched the Enhanced view stay on Standard since the saved value defaults to it.' },
{ title: 'Fix popup: manual matches survive Playlist Pipeline runs', desc: 'manually mapping a mirrored-playlist track via the Fix popup (search or MBID) saved correctly and the download went through, but the next Playlist Pipeline run kept reverting the match — the track flipped back to "Provider Changed" and you had to map it again. three things were ganging up on it: the manual-fix save always stamped `provider: \'spotify\'` even when the match came from MusicBrainz / iTunes / Deezer, so prepare-discovery saw a provider mismatch on the next run; the discovery worker also re-queued any matched_data that lacked `track_number` / `album.id` / `release_date` for re-discovery, and Fix-popup saves never carry those fields by design (search results don\'t include track_number, MBID lookups don\'t include album.id) — so manual fixes always looked "incomplete" and got overwritten; and the prepare-discovery provider check didn\'t honour the `manual_match` flag at all. all three patched — manual matches now stamp the actual source, the worker short-circuits on `manual_match` before the incomplete-data branch, and prepare-discovery treats manual fixes as cached regardless of provider drift.' },
{ title: 'Fix popup: artist + track fields no longer surface unrelated covers', desc: 'separate artist / track inputs in the Fix popup used to dump both into a bare MusicBrainz keyword query — and MB\'s scorer heavily favours title matches, so searching "Coffee Break" + "Zeds Dead" surfaced random "Coffee Break" tracks by other artists ahead of the canonical Zeds Dead one. fields-mode now uses a field-scoped Lucene query that actually anchors the artist, with the old bare query kept as a fallback for diacritic mismatches like "Bjork" vs "Björk" where strict phrase match misses. results also stable-prefer entries with known track length so the canonical 3:04 sibling sits above the 0:00 duplicate.' },
{ 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.' },
{ title: 'Discovery folded into the unified source contract', desc: 'next slice of the groundwork. each playlist source can now answer one extra question — "match these raw tracks against Spotify / iTunes" — through the same adapter interface. Spotify / Tidal / Qobuz / YouTube / Deezer / Spotify-public / iTunes-link / SoulSync-Discovery all answer trivially (their tracks already have provider IDs); ListenBrainz + Last.fm run the matching engine. mirror-refresh now calls this automatically when a source returns MB-metadata-only tracks, so when ListenBrainz becomes a Sync-page tab next commit, its mirrors land already discovered + ready to sync — no separate Discover-page round-trip needed.' },
{ title: 'ListenBrainz Sync tab', desc: 'new ListenBrainz tab on the Sync page, between Beatport and Import. lists your "For You" / "My Playlists" / "Collaborative" LB playlists in one place. clicking a card kicks off the same discovery → sync → mirror flow you already get from the Discover page (no duplicate UI behind the scenes, just a new entry point). once mirrored, LB playlists participate in Auto-Sync schedules + pipeline automations like any other source. needs ListenBrainz connected in Settings → Connections.', page: 'sync' },
{ title: 'Last.fm Radio Sync tab', desc: 'sibling to the ListenBrainz tab — lists your generated Last.fm Radio playlists alongside the rest of the Sync sources. same discovery → mirror flow under the hood, just a different entry point. new Last.fm radios are still generated from the Discover page by picking a seed track; this tab is for syncing existing ones. mirrors auto-trim when Last.fm Radio cache rotates so old radios don\'t pile up.', page: 'sync' },
{ title: 'SoulSync Discovery Sync tab', desc: 'last of the unified-tab trio. surfaces your personalized SoulSync Discovery playlists (decade mixes, hidden gems, popular picks, daily mixes, discovery shuffle, etc.) on the Sync page. clicking a card regenerates the playlist + mirrors it under a stable synthetic id, so the same mirror updates in place every Auto-Sync refresh. tracks come out already matched against Spotify / iTunes / Deezer so there\'s no discovery hop — straight to download / sync.', page: 'sync' },
{ title: 'Fix: album-bundle downloads all landing as track 1', desc: 'soulseek album-bundle downloads (and any other untagged release-staging path) were importing every track with track_number=1. the staging-file reader was using the auto-import\'s filename extractor that defaults to 1 when no NN- prefix is present in the filename — so for albums like Ryoto\'s "Cha-La Head-Cha-La" where slskd hands you bare titles, every file got "track 1" stamped on it. now the staging path uses a strict extractor that returns 0 when it can\'t see an explicit prefix, so the downstream resolver correctly falls through to the authoritative Spotify metadata and the right track numbers land in the library.' },
{ title: 'Wishlist albums-cycle: one album-bundle search per album', desc: 'auto-wishlist runs in two cycles — albums + singles. previously the albums cycle dumped every missing track from every album into one big batch and ran a separate per-track Soulseek / Prowlarr search for each (~50 searches for a typical scan). now the albums cycle splits the wishlist into per-album sub-batches at submission time, and each one engages the existing slskd / torrent / usenet album-bundle release-first flow with that album\'s context. tracks without resolvable album metadata stay on the classic per-track residual batch so nothing falls off. singles cycle is unchanged.' },
{ title: 'Wishlist cycle toggles once per run, not per sub-batch', desc: 'follow-up to the per-album split above. with N sub-batches per wishlist run, the cycle toggle (albums → singles or vice versa) was firing once per sub-batch as each one completed — so the cycle could flip mid-run and the automation completion event got emitted multiple times. each wishlist invocation now stamps every sub-batch with a shared run id; the completion handler waits until the LAST sibling finishes before toggling the cycle + emitting the run-complete event. cleaner state machine, future-proofs frontend grouping of sub-batches under one logical run.' },
{ title: 'Wishlist download modal: keeps tracking across every album', desc: 'with the per-album sub-batch split, the modal\'s polling kept hitting the original batch id only and went stale as soon as the first sub-batch finished — subsequent albums downloaded fine but the modal\'s rows stopped updating. backend now merges every sibling sub-batch\'s tasks + analysis results into the response under the original batch id (re-indexes track positions globally so the table doesn\'t collide on row keys, picks whichever bundle is currently active, aggregates phase across siblings so the moment ANY sibling reaches the task stage the modal renders task rows — earlier rule waited for the slowest sibling and left the modal looking frozen while both albums were already downloading on slskd). frontend untouched — the modal sees one unified view of the whole run.' },
],
'2.6.2': [
{ date: 'May 24, 2026 — 2.6.2 release' },
{ title: 'Fix: songs stuck in quarantine loop when picking a different candidate', desc: 'when a track failed AcoustID verification and got quarantined, opening the candidates modal and manually picking a different file would just re-quarantine it — the manual pick path ran full AcoustID verification with no bypass, so if the alternate file disagreed with AcoustID\'s stored metadata too (common for live versions, remasters, regional title differences) the file landed right back in quarantine. user got stuck in the loop. manual picks via the candidates modal now skip AcoustID for that one post-process pass (matching what the Approve button already does for restored quarantine files). integrity and bit-depth gates still run because those check the new file\'s actual condition, not its identity. closes #701.' },
{ title: 'Fix: whole-album downloads ending up "failed" with empty queues', desc: 'the Soulseek album-bundle path routes whole-album downloads through a private staging dir, then per-track workers claim the staged files. when slskd files arrived without ID3 tags, the staging cache fell back to filename stems like "Artist - Album - 03 - Title" — too noisy to clear the title-similarity threshold against the clean Spotify title, so every track went not_found and the batch ended failed even with all files on disk. staging match now pulls the trailing-title segment when a bare track-number is present between " - " delimiters, so slskd filename patterns match cleanly. closes #700.' },
{ title: 'Fix: album tracks getting requeued as singles by the wishlist', desc: 'downstream of the album-bundle fix above. failed album-batch tracks were going to the wishlist with `source_type=\'playlist\'` hardcoded, and a couple of fallback paths were stamping `album_type=\'single\'` on the stored album dict. on requeue the path builder saw single → routed to the Singles tree even though the track belonged to an album (running Reorganize would patch it because the DB still knew). album batches now carry `source_type=\'album\'`, source-context preserves `album_context` / `artist_context`, and the non-dict-album + slskd-reconstruction fallbacks default to `album` instead of lying with `single`. closes #698.' },
{ title: 'Fix: Redownload Album button on the enhanced artist view was dead', desc: 'the Redownload button on the enhanced artist-page album row was throwing a silent ReferenceError on click — no popup, no toast, no log line, button just did nothing. the underlying function was lost when script.js got split into 17 domain modules and nothing caught it for a while. restored. closes #699.' },
{ title: 'iTunes / Apple Music link import', desc: 'new iTunes Link tab on the Sync page, between Deezer Link and YouTube. paste an Apple Music album, track, or playlist URL and SoulSync pulls the tracklist, runs it through the same discovery → sync → download flow as the other link tabs. handles the new Apple Music SPA token shape — token gets scraped from the JS bundle on first use and cached for 6 hours, with a 401 retry that refetches if Apple rotates mid-session.' },
{ title: 'Auto-Sync: bulk schedule by source + custom interval columns', desc: 'each source group in the sidebar gets a Bulk button — opens a popover that lets you schedule every playlist in that group at a chosen interval in one click (1h / 2h / 4h / 8h / 12h / 16h / 24h / 48h / 72h / weekly) or "Custom interval…" for an arbitrary number of hours. also includes "Unschedule all" to clear a source\'s schedules. on the board itself, a schedule with a non-standard interval (e.g. 6h or 36h, created via Automations page or the custom prompt) now renders as its own dashed-border column instead of disappearing because it didn\'t match a hardcoded bucket.' },
{ title: 'Auto-Sync manager: filter, failure indicators, history filters + load more', desc: 'a handful of UX additions on the Playlist Auto-Sync modal. sidebar gets a "Filter playlists…" search input so you can narrow down a long mirrored-playlist list. scheduled cards on the board now show a red ! badge when the last three pipeline runs all failed (yellow ⚠ if at least one of the last few failed) so chronically broken schedules surface visually instead of getting buried in the run-history tab. run history tab title shows a red error count when there are failed runs in the loaded window. the history tab itself gains All / Errors / Completed filter pills, a "Load more" footer that pulls another 50 entries, and a "Run pipeline again" button inside the expanded detail panel so you can re-trigger a specific playlist without leaving the modal. also dropped the "Discovered: completed" pill — `tracks_discovered` was a status string, not a count, and the same data is already in the before/after stats grid above.' },
{ title: 'Auto-Sync manager redesigned to match the rest of the app', desc: 'the Auto-Sync modal got a top-to-bottom restyle so it stops feeling like it lives in a different app. modal shell now uses the standard SoulSync gradient + accent-tinted border + 20px radius, the KPI summary became inset stat tiles, the live pipeline monitor is auto-fill instead of a fixed 4-col grid, tabs are now underline-style instead of pill buttons, and the schedule board sidebar/columns use the dense dark-card pattern that Automations + Library pages already use. modal also fills more of the screen since there was a lot of unused real estate. run history cards got the same treatment — slim horizontal row matching `.automation-card` (status dot + name + flow chips + meta row), and the expanded panel uses the same stats-grid / log-section structure as the Automations run-history modal.' },
{ title: 'Fix: Auto-Sync manager hung forever on "Loading schedule..."', desc: 'a regression while wiring up the new "in library" count: the join used `COLLATE NOCASE` on `tracks.title` and `artists.name`, which can\'t use the indexes those columns have. on a 300k-track library each playlist took ~18s. with 30 mirrored playlists the modal\'s `/api/mirrored-playlists` call would never come back. switched to case-sensitive equality so SQLite uses `idx_artists_name` + `idx_tracks_title` (6ms per playlist). misses pure-case-different matches but Spotify names are canonical against library imports, so it\'s a rounding-error tradeoff for the 3000× speedup.' },
{ title: 'Fix: Auto-Sync run history `in library` always showed 0', desc: 'the SQL query was referencing `tracks.artist` — a column that hasn\'t existed since the schema went relational. `tracks` has `artist_id` (FK to `artists`), so the query threw "no such column", got swallowed by the surrounding try/except, and the count silently defaulted to 0 on every playlist. rewired the join through `artists` so the count actually reflects how many mirrored tracks already live in your library.' },
{ title: 'Auto-Sync modal opens faster (1.5s → ~280ms)', desc: 'opening the Auto-Sync manager used to spend ~1.5s in `/api/mirrored-playlists` because each mirrored playlist row triggered its own `get_mirrored_playlist_status_counts` call, and each of those opened a fresh SQLite connection with PRAGMA setup. new `get_all_mirrored_playlist_status_counts(profile_id)` does the totals / discovered / wishlisted / in-library counts across every playlist for the active profile in 4 batched `GROUP BY` queries over one connection. about 5× faster on a 30-playlist account.' },
{ title: 'Playlist sync is way faster on partial-match playlists', desc: 'syncing a playlist where most tracks weren\'t in the library used to take forever — a 30-track playlist with only 9 matches was burning 4+ minutes because every unmatched track ran the full title-variation × artist-variation SQL grid against the tracks table (~30 SQL queries per missed track). cache only covered matches, so misses re-paid the cost every sync. sync now uses a per-artist track pool that fills in lazily — only tracks that miss the sync match cache trigger a one-time fetch of their artist\'s library tracks, and later misses for the same artist reuse the in-memory list. playlists where every track is already cached pay zero pool cost (no upfront delay). benefits every sync entry point — manual, auto-sync, the playlist_pipeline automation action, and the Sync All button.' },
{ title: 'Fix: Auto-Sync "next in 8h" timezone bug', desc: 'scheduled Auto-Sync playlists were all showing "next in 8h" regardless of the interval — Every 1 hour, Every 1 day, anything. backend stores next-run as a naive UTC string and the frontend was parsing it as local time, which on Pacific time offset the displayed countdown by ~8 hours. Auto-Sync now uses the existing UTC-aware parser the rest of the Automations page already uses. as a separate correctness fix, the automation update endpoint also now blanks the stored next-run whenever the trigger type or trigger config changes, so the engine recomputes from scratch instead of preserving a leftover timestamp from the previous schedule.' },
{ title: 'Auto-Sync modal restyled to your accent color', desc: 'the Playlist Auto-Sync manager now picks up your chosen accent (the same one used everywhere else in the app) instead of the hardcoded sky-blue palette it shipped with. tabs, drop zones, scheduled-playlist chips, scrollbars, and the modal glow all follow the accent theme. drop targets also light up clearly while dragging.' },
],
'2.6.1': [
{ date: 'May 24, 2026 — 2.6.1 release' },
{ title: 'React Import page polish', desc: 'Import now runs through the React route stack with album, singles, and auto-import tabs plus the state fixes needed for reliable Vite builds.' },
@ -3506,11 +3539,22 @@ const WHATS_NEW = {
// Section shape: { title, description, features: [bullet strings],
// usage_note?: 'optional hint shown at the bottom' }
const VERSION_MODAL_SECTIONS = [
{
title: "iTunes / Apple Music Link Import",
description: "new iTunes Link tab on the Sync page. paste an Apple Music album, track, or playlist URL and SoulSync pulls the tracklist, runs it through discovery, and lets you sync or download like any other link source.",
features: [
"new iTunes Link tab on the Sync page, sitting between Deezer Link and YouTube",
"accepts album, track, and playlist URLs from music.apple.com or iTunes",
"tracklist runs through the same discovery → sync → download pipeline as Deezer Link / YouTube",
"handles the current Apple Music SPA token flow — token is scraped from the JS bundle on first use, cached for 6 hours, and refetched automatically on 401 if Apple rotates",
],
usage_note: "Sync → iTunes Link → paste URL → Load",
},
{
title: "Qobuz Playlist Sync",
description: "Qobuz joins Tidal and Deezer as a first-class playlist sync source on the Sync page. Browse your Qobuz playlists and Favorite Tracks, run them through the same discovery flow as Tidal, sync the resulting Spotify-matched tracks, and queue downloads — same multi-step pipeline you already know.",
features: [
"new Qobuz tab on the Sync page, listed between Deezer and Deezer Link",
"new Qobuz tab on the Sync page, grouped with Tidal alongside the other lossless services",
"lists your Qobuz user playlists plus a Favorite Tracks entry (same virtual-playlist treatment Tidal gets)",
"click any card to fire discovery (Spotify-preferred, your primary metadata fallback otherwise), then sync or download just like Tidal / Deezer playlists",
"uses the Qobuz auth token you already configured for downloads — no extra connection step",

View file

@ -879,6 +879,15 @@ function navigateToArtistDetail(artistId, artistName, sourceOverride = null, opt
const bulkBar = document.getElementById('enhanced-bulk-bar');
if (bulkBar) bulkBar.classList.remove('visible');
// Restore persisted view preference. Non-admins can't see / toggle the
// Enhanced control so only honour the saved choice for admins; default
// is still Standard. Wrapped in try/catch because localStorage can be
// disabled in private-browsing modes.
let _preferEnhanced = false;
try {
_preferEnhanced = localStorage.getItem(_libraryViewModeKey()) === 'enhanced';
} catch (_) { /* localStorage unavailable */ }
// Navigate to artist detail page
navigateToPage('artist-detail', {
artistId,
@ -895,6 +904,13 @@ function navigateToArtistDetail(artistId, artistName, sourceOverride = null, opt
// Load artist data
loadArtistDetailData(artistId, artistName);
// Apply persisted Enhanced view after the standard data load is kicked off.
// toggleEnhancedView() triggers its own loadEnhancedViewData() in parallel;
// the brief Standard render is hidden as soon as the toggle flips.
if (_preferEnhanced && isEnhancedAdmin()) {
toggleEnhancedView(true);
}
}
function _updateArtistDetailBackButtonLabel() {
@ -2905,6 +2921,25 @@ function toggleEnhancedView(enabled) {
const bulkBar = document.getElementById('enhanced-bulk-bar');
if (bulkBar) bulkBar.classList.remove('visible');
}
// Persist the choice so the next artist click (and the next page reload)
// honours it instead of always reverting to Standard.
try {
localStorage.setItem(_libraryViewModeKey(), enabled ? 'enhanced' : 'standard');
} catch (_) { /* localStorage unavailable */ }
}
// localStorage key for the Enhanced/Standard toggle, scoped to the active
// profile so different admin profiles can keep different defaults. Falls
// back to an unsuffixed key when no profile is loaded (matches the original
// behaviour for any pre-multi-profile saved value).
function _libraryViewModeKey() {
const pid = (typeof currentProfile === 'object' && currentProfile && currentProfile.id != null)
? currentProfile.id
: null;
return pid != null
? `soulsync-library-view-mode:${pid}`
: 'soulsync-library-view-mode';
}
async function loadEnhancedViewData(artistId) {
@ -5299,6 +5334,99 @@ function _pollRedownloadProgress(taskId, overlay) {
}, 300000);
}
async function redownloadLibraryAlbum(album, artistName, btn) {
const albumName = album.title || '';
const spotifyAlbumId = album.spotify_album_id || '';
if (!spotifyAlbumId && !albumName) {
showToast('No album ID or name available for redownload', 'warning');
return;
}
const origText = btn ? btn.innerHTML : '';
try {
if (btn) { btn.disabled = true; btn.textContent = 'Loading...'; }
let response;
if (spotifyAlbumId) {
const params = new URLSearchParams({ name: albumName, artist: artistName || '' });
response = await fetch(`/api/spotify/album/${encodeURIComponent(spotifyAlbumId)}?${params}`);
}
if (!response || !response.ok) {
const query = `${artistName || ''} ${albumName}`.trim();
const searchResp = await fetch('/api/enhanced-search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query })
});
if (!searchResp.ok) throw new Error('Album search failed');
const searchData = await searchResp.json();
const found = searchData.spotify_albums?.[0] || searchData.itunes_albums?.[0];
if (!found || !found.id) {
showToast(`Could not find "${albumName}" by ${artistName || 'unknown'}`, 'warning');
return;
}
const params = new URLSearchParams({ name: found.name || albumName, artist: found.artist || artistName || '' });
response = await fetch(`/api/spotify/album/${encodeURIComponent(found.id)}?${params}`);
}
if (!response.ok) throw new Error(`Failed to load album: ${response.status}`);
const albumData = await response.json();
if (!albumData || !albumData.tracks || albumData.tracks.length === 0) {
showToast(`No tracks found for "${albumName}"`, 'warning');
return;
}
const resolvedId = albumData.id || spotifyAlbumId || album.id;
const virtualPlaylistId = `library_redownload_${resolvedId}`;
const playlistName = `[${artistName || 'Unknown'}] ${albumData.name}`;
const enrichedTracks = albumData.tracks.map(track => ({
...track,
album: {
name: albumData.name,
id: albumData.id,
album_type: albumData.album_type || 'album',
images: albumData.images || [],
release_date: albumData.release_date,
total_tracks: albumData.total_tracks
}
}));
const enhancedArtist = artistDetailPageState.enhancedData?.artist;
const artistObject = {
id: artistDetailPageState.currentArtistId || `library_${artistName || album.id}`,
name: artistName || '',
image_url: enhancedArtist?.thumb_url || ''
};
const fullAlbumObject = {
name: albumData.name,
id: albumData.id,
album_type: albumData.album_type || 'album',
images: albumData.images || [],
image_url: albumData.images?.[0]?.url || null,
release_date: albumData.release_date,
total_tracks: albumData.total_tracks,
artists: albumData.artists || [{ name: artistName || '' }]
};
await openDownloadMissingModalForArtistAlbum(
virtualPlaylistId, playlistName, enrichedTracks, fullAlbumObject, artistObject, true
);
const albumType = fullAlbumObject.album_type || 'album';
registerArtistDownload(artistObject, fullAlbumObject, virtualPlaylistId, albumType);
} catch (error) {
console.error('Redownload album error:', error);
showToast(`Error: ${error.message}`, 'error');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = origText; }
}
}
async function deleteLibraryAlbum(albumId) {
const choice = await _showAlbumDeleteDialog();
if (!choice) return;

View file

@ -380,6 +380,10 @@ function importFileSubmit() {
// ── Mirrored Playlists ────────────────────────────────────────────────
let mirroredPlaylistsLoaded = false;
// Auto-Sync state + functions moved to webui/static/auto-sync.js; declared
// as globals there so the older mirrored-playlist render path below can
// still reach `mirroredPipelinePollers`, `runMirroredPlaylistPipeline`,
// `editMirroredSourceRef`, `getMirroredSourceRef`, and `pollMirroredPipelineStatus`.
/**
* Fire-and-forget helper: send parsed playlist data to be mirrored on the backend.
@ -444,12 +448,34 @@ async function loadMirroredPlaylists() {
function renderMirroredCard(p, container) {
const ago = timeAgo(p.updated_at || p.mirrored_at);
const hash = `mirrored_${p.id}`;
const state = youtubePlaylistStates[hash];
const pipelineState = p.pipeline_state || null;
const pipelinePhase = pipelineState && pipelineState.status === 'running' ? 'pipeline_running'
: pipelineState && pipelineState.status === 'finished' ? 'pipeline_complete'
: pipelineState && (pipelineState.status === 'error' || pipelineState.status === 'skipped') ? 'pipeline_error'
: null;
const state = youtubePlaylistStates[hash] || (pipelinePhase ? {
phase: pipelinePhase,
pipeline_status: pipelineState.status,
pipeline_progress: pipelineState.progress || 0,
pipeline_phase: pipelineState.phase || '',
pipeline_error: pipelineState.error || '',
pipeline_log: pipelineState.log || [],
pipeline_result: pipelineState.result || null,
} : null);
const phase = state ? state.phase : null;
const sourceRef = getMirroredSourceRef(p);
// Build phase indicator
let phaseHtml = '';
if (phase === 'discovering') {
if (phase === 'pipeline_running') {
const pct = state.pipeline_progress || state.progress || 0;
const label = state.pipeline_phase || state.phase_label || 'Pipeline running';
phaseHtml = `<span style="color:#38bdf8;">${_esc(label)} ${pct}%</span>`;
} else if (phase === 'pipeline_complete') {
phaseHtml = `<span style="color:#22c55e;">Pipeline complete</span>`;
} else if (phase === 'pipeline_error') {
phaseHtml = `<span style="color:#ef4444;">Pipeline error</span>`;
} else if (phase === 'discovering') {
const pct = state.discoveryProgress || state.discovery_progress || 0;
phaseHtml = `<span style="color:#a78bfa;">Discovering ${pct}%</span>`;
} else if (phase === 'discovered') {
@ -493,6 +519,8 @@ function renderMirroredCard(p, container) {
</div>
</div>
${disc > 0 ? `<button class="mirrored-card-clear" onclick="event.stopPropagation(); clearMirroredDiscovery(${p.id}, '${_escAttr(p.name)}')" title="Clear discovery data">↺</button>` : ''}
<button class="mirrored-card-pipeline" onclick="event.stopPropagation(); runMirroredPlaylistPipeline(${p.id}, '${_escAttr(p.name)}')" title="Refresh, discover, sync, and queue missing tracks">Auto-Sync</button>
<button class="mirrored-card-link" onclick="event.stopPropagation(); editMirroredSourceRef(${p.id}, '${_escAttr(p.name)}', '${_escAttr(p.source)}', '${_escAttr(sourceRef)}')" title="Edit original playlist link">🔗</button>
<button class="mirrored-card-delete" onclick="event.stopPropagation(); deleteMirroredPlaylist(${p.id}, '${_escAttr(p.name)}')" title="Delete mirror"></button>
`;
card.addEventListener('click', () => {
@ -530,6 +558,10 @@ function renderMirroredCard(p, container) {
}
});
container.appendChild(card);
if (pipelineState && pipelineState.status === 'running' && !mirroredPipelinePollers[hash]) {
pollMirroredPipelineStatus(p.id, p.name);
}
}
function updateMirroredCardPhase(urlHash, phase) {
@ -552,6 +584,17 @@ function updateMirroredCardPhase(urlHash, phase) {
// Add new phase indicator
let phaseHtml = '';
switch (phase) {
case 'pipeline_running':
const pipelineProgress = state?.pipeline_progress || 0;
const pipelinePhase = state?.pipeline_phase || 'Pipeline running';
phaseHtml = `<span style="color:#38bdf8;">${_esc(pipelinePhase)} ${pipelineProgress}%</span>`;
break;
case 'pipeline_complete':
phaseHtml = `<span style="color:#22c55e;">Pipeline complete</span>`;
break;
case 'pipeline_error':
phaseHtml = `<span style="color:#ef4444;">Pipeline error</span>`;
break;
case 'discovering':
phaseHtml = `<span style="color:#a78bfa;">Discovering...</span>`;
break;
@ -785,6 +828,7 @@ async function openMirroredPlaylistModal(playlistId) {
const tracks = data.tracks || [];
const source = data.source || 'unknown';
const sourceRef = getMirroredSourceRef(data);
const sourceIcons = { spotify: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛' };
const sourceIcon = sourceIcons[source] || '📋';
@ -827,6 +871,8 @@ async function openMirroredPlaylistModal(playlistId) {
<button class="mirrored-btn-delete" onclick="closeMirroredModal(); deleteMirroredPlaylist(${playlistId}, '${_escAttr(data.name)}')">Delete Mirror</button>
</div>
<div class="mirrored-modal-footer-right" style="display:flex;gap:10px;">
<button class="mirrored-btn-close" onclick="editMirroredSourceRef(${playlistId}, '${_escAttr(data.name)}', '${_escAttr(source)}', '${_escAttr(sourceRef)}')">Edit Source</button>
<button class="mirrored-btn-pipeline" onclick="runMirroredPlaylistPipeline(${playlistId}, '${_escAttr(data.name)}')">Auto-Sync</button>
<button class="mirrored-btn-close" onclick="closeMirroredModal()">Close</button>
<button class="mirrored-btn-discover" onclick="discoverMirroredPlaylist(${playlistId})">Discover</button>
</div>
@ -2939,7 +2985,7 @@ function _promptNotifyConfig(groupName) {
if (type === 'discord_webhook') {
fieldsDiv.innerHTML = '<input id="deploy-notify-url" type="text" placeholder="Discord Webhook URL" style="width:100%;padding:9px 12px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);border-radius:8px;color:#fff;font-size:0.88em;margin-top:6px;box-sizing:border-box;">';
} else if (type === 'telegram') {
fieldsDiv.innerHTML = '<input id="deploy-notify-token" type="text" placeholder="Bot Token" style="width:100%;padding:9px 12px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);border-radius:8px;color:#fff;font-size:0.88em;margin-top:6px;box-sizing:border-box;"><input id="deploy-notify-chat" type="text" placeholder="Chat ID" style="width:100%;padding:9px 12px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);border-radius:8px;color:#fff;font-size:0.88em;margin-top:6px;box-sizing:border-box;">';
fieldsDiv.innerHTML = '<input id="deploy-notify-token" type="text" placeholder="Bot Token" style="width:100%;padding:9px 12px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);border-radius:8px;color:#fff;font-size:0.88em;margin-top:6px;box-sizing:border-box;"><input id="deploy-notify-chat" type="text" placeholder="Chat ID" style="width:100%;padding:9px 12px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);border-radius:8px;color:#fff;font-size:0.88em;margin-top:6px;box-sizing:border-box;"><input id="deploy-notify-thread" type="text" placeholder="Thread ID" style="width:100%;padding:9px 12px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);border-radius:8px;color:#fff;font-size:0.88em;margin-top:6px;box-sizing:border-box;">';
} else if (type === 'pushbullet') {
fieldsDiv.innerHTML = '<input id="deploy-notify-token" type="text" placeholder="Access Token" style="width:100%;padding:9px 12px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);border-radius:8px;color:#fff;font-size:0.88em;margin-top:6px;box-sizing:border-box;">';
} else {
@ -2960,7 +3006,7 @@ function _promptNotifyConfig(groupName) {
if (type === 'discord_webhook') {
config = { webhook_url: (overlay.querySelector('#deploy-notify-url')?.value || '').trim() };
} else if (type === 'telegram') {
config = { bot_token: (overlay.querySelector('#deploy-notify-token')?.value || '').trim(), chat_id: (overlay.querySelector('#deploy-notify-chat')?.value || '').trim() };
config = { bot_token: (overlay.querySelector('#deploy-notify-token')?.value || '').trim(), chat_id: (overlay.querySelector('#deploy-notify-chat')?.value || '').trim(), thread_id: (overlay.querySelector('#deploy-notify-thread')?.value || '').trim() };
} else if (type === 'pushbullet') {
config = { access_token: (overlay.querySelector('#deploy-notify-token')?.value || '').trim() };
} else {
@ -4092,6 +4138,7 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
if (blockType === 'telegram') {
const botToken = _escAttr(config.bot_token || '');
const chatId = _escAttr(config.chat_id || '');
const threadId = _escAttr(config.thread_id || '');
return `<div class="config-row">
<label>Bot Token</label>
<input type="text" id="cfg-${slotKey}-bot_token" value="${botToken}" placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11">
@ -4100,6 +4147,10 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
<label>Chat ID</label>
<input type="text" id="cfg-${slotKey}-chat_id" value="${chatId}" placeholder="-1001234567890 or @channelname">
</div>
<div class="config-row">
<label>Thread ID</label>
<input type="text" id="cfg-${slotKey}-thread_id" value="${threadId}" placeholder="Optional integer">
</div>
<div class="config-row">
<label>Message</label>
<textarea id="cfg-${slotKey}-message" placeholder="Message with {variables}...">${config.message || '{name} completed with status: {status}'}</textarea>
@ -4479,6 +4530,7 @@ function _readPlacedConfig(slotKey) {
return {
bot_token: document.getElementById('cfg-' + slotKey + '-bot_token')?.value?.trim() || '',
chat_id: document.getElementById('cfg-' + slotKey + '-chat_id')?.value?.trim() || '',
thread_id: document.getElementById('cfg-' + slotKey + '-thread_id')?.value?.trim() || '',
message: document.getElementById('cfg-' + slotKey + '-message')?.value || '',
};
}

File diff suppressed because it is too large Load diff

131
webui/static/sync-lastfm.js Normal file
View file

@ -0,0 +1,131 @@
// ===================================================================
// LAST.FM RADIO SYNC TAB
// ===================================================================
// Phase 1c.2 of the Discover-to-Sync unification. Surfaces the user's
// generated Last.fm Radio playlists as a Sync-page tab so they can be
// discovered + mirrored alongside ListenBrainz, Tidal, Qobuz, etc.
//
// Last.fm Radio playlists live in the same ``listenbrainz_playlists``
// SQLite table as ListenBrainz playlists (with
// ``playlist_type='lastfm_radio'``) and run through the same
// ``openDownloadModalForListenBrainzPlaylist`` discovery flow. So this
// module is intentionally thin — list + render + click handoff.
// The refresh loop, discovery polling, sync→mirror creation, and the
// modal itself are all shared with the ListenBrainz tab.
//
// New Last.fm radios are GENERATED from the Discover page (with a
// seed track). This tab is for listing existing radios + syncing
// them to a mirror — not for generation.
let _lastfmSyncPlaylists = [];
async function loadLastfmSyncPlaylists() {
const container = document.getElementById('lastfm-sync-playlist-container');
const refreshBtn = document.getElementById('lastfm-sync-refresh-btn');
if (!container) return;
container.innerHTML = `<div class="playlist-placeholder">🔄 Loading Last.fm Radio playlists...</div>`;
if (refreshBtn) {
refreshBtn.disabled = true;
refreshBtn.textContent = '🔄 Loading...';
}
try {
const resp = await fetch('/api/discover/listenbrainz/lastfm-radio');
const data = await resp.json();
if (!data.success && data.error) {
container.innerHTML = `<div class="playlist-placeholder">❌ ${escapeHtml(data.error)}</div>`;
return;
}
_lastfmSyncPlaylists = data.playlists || [];
renderLastfmSyncPlaylists();
console.log(`📻 Last.fm Sync tab loaded: ${_lastfmSyncPlaylists.length} radios`);
} catch (err) {
container.innerHTML = `<div class="playlist-placeholder">❌ Error loading Last.fm radios: ${err.message}</div>`;
if (typeof showToast === 'function') {
showToast(`Error loading Last.fm radios: ${err.message}`, 'error');
}
} finally {
if (refreshBtn) {
refreshBtn.disabled = false;
refreshBtn.textContent = '🔄 Refresh';
}
}
}
function renderLastfmSyncPlaylists() {
const container = document.getElementById('lastfm-sync-playlist-container');
if (!container) return;
if (_lastfmSyncPlaylists.length === 0) {
container.innerHTML = `<div class="playlist-placeholder">No Last.fm Radio playlists yet. Generate one from the Discover page by picking a seed track.</div>`;
return;
}
container.innerHTML = _lastfmSyncPlaylists.map(p => {
const inner = p.playlist || p;
const mbid = (inner.identifier || '').split('/').pop() || inner.id || '';
const title = inner.title || 'Last.fm Radio';
const creator = inner.creator || 'Last.fm';
let count = 0;
if (inner.track_count) count = inner.track_count;
else if (inner.annotation && inner.annotation.track_count) count = inner.annotation.track_count;
else if (Array.isArray(inner.track) && inner.track.length > 0) count = inner.track.length;
const state = (typeof listenbrainzPlaylistStates !== 'undefined'
&& listenbrainzPlaylistStates[mbid]) || null;
const phase = state && state.phase ? state.phase : 'fresh';
const phaseText = (typeof getPhaseText === 'function')
? getPhaseText(phase) : (phase === 'fresh' ? 'Ready to discover' : phase);
const phaseColor = (typeof getPhaseColor === 'function')
? getPhaseColor(phase) : '#999';
const buttonText = (typeof getActionButtonText === 'function')
? getActionButtonText(phase) : 'Discover';
return `
<div class="youtube-playlist-card lastfm-playlist-card"
id="lastfm-sync-card-${escapeHtml(mbid)}"
data-lb-mbid="${escapeHtml(mbid)}"
data-lb-title="${escapeHtml(title)}">
<div class="playlist-card-icon">📻</div>
<div class="playlist-card-content">
<div class="playlist-card-name">${escapeHtml(title)}</div>
<div class="playlist-card-info">
<span class="playlist-card-track-count">${count} tracks</span>
<span class="playlist-card-owner">by ${escapeHtml(creator)}</span>
<span class="playlist-card-phase-text" style="color: ${phaseColor};">${phaseText}</span>
</div>
</div>
<div class="playlist-card-progress ${phase === 'fresh' ? 'hidden' : ''}"></div>
<button class="playlist-card-action-btn">${buttonText}</button>
</div>
`;
}).join('');
container.querySelectorAll('.lastfm-playlist-card').forEach(card => {
card.addEventListener('click', () => {
const mbid = card.dataset.lbMbid;
const title = card.dataset.lbTitle;
// Reuses the LB Sync-tab click handler — Last.fm radios are
// stored in the same table + matched by the same discovery
// worker, so the click flow is byte-identical.
if (typeof handleListenBrainzSyncCardClick === 'function') {
handleListenBrainzSyncCardClick(mbid, title);
}
});
});
// Reuse the shared refresh loop from sync-listenbrainz.js — it
// already iterates Last.fm cards alongside LB cards.
if (typeof _startLbSyncCardRefreshLoop === 'function') {
const tab = document.getElementById('lastfm-sync-tab-content');
if (tab && tab.classList.contains('active')) {
_startLbSyncCardRefreshLoop();
}
}
}
document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('lastfm-sync-refresh-btn');
if (btn) btn.addEventListener('click', loadLastfmSyncPlaylists);
});

View file

@ -0,0 +1,364 @@
// ===================================================================
// LISTENBRAINZ SYNC TAB
// ===================================================================
// Phase 1c.1 of the Discover-to-Sync unification. Renders the user's
// cached ListenBrainz playlists as a Sync-page tab so they participate
// in the same discovery → mirror → auto-sync pipeline as Spotify /
// Tidal / Qobuz / etc. — without forcing the user to detour through
// the Discover page.
//
// All the heavy lifting (modal, discovery state machine, sync) already
// lives in sync-services.js + discover.js. This file is just the
// Sync-page entry point: list the cached playlists, render cards,
// pre-fetch tracks on click, then hand off to
// ``openDownloadModalForListenBrainzPlaylist`` which owns the rest.
let _lbSyncCurrentType = 'created_for_user';
let _lbSyncPlaylistsByType = {}; // {type: [playlist...]} cache
async function loadListenBrainzSyncPlaylists() {
const container = document.getElementById('listenbrainz-sync-playlist-container');
const refreshBtn = document.getElementById('listenbrainz-sync-refresh-btn');
if (!container) return;
container.innerHTML = `<div class="playlist-placeholder">🔄 Loading ListenBrainz playlists...</div>`;
if (refreshBtn) {
refreshBtn.disabled = true;
refreshBtn.textContent = '🔄 Loading...';
}
// Fetch all three LB playlist categories in parallel. The Discover
// page does the same; we mirror its behavior for state-cache parity.
try {
const [createdFor, userPl, collab] = await Promise.all([
fetch('/api/discover/listenbrainz/created-for').then(r => r.json()),
fetch('/api/discover/listenbrainz/user-playlists').then(r => r.json()),
fetch('/api/discover/listenbrainz/collaborative').then(r => r.json()),
]);
// Auth-failure responses look like `{success:false, error:'...'}`.
// Surface them to the user instead of pretending the list was empty.
const anyUnauthed = !createdFor.success && (
(createdFor.error || '').toLowerCase().includes('not authenticated')
);
if (anyUnauthed) {
container.innerHTML = `<div class="playlist-placeholder">ListenBrainz not connected. Add your token in Settings → Connections to see your playlists here.</div>`;
return;
}
_lbSyncPlaylistsByType = {
created_for_user: createdFor.playlists || [],
user_created: userPl.playlists || [],
collaborative: collab.playlists || [],
};
renderListenBrainzSyncPlaylists();
console.log(
`🎧 ListenBrainz Sync tab loaded: ${_lbSyncPlaylistsByType.created_for_user.length} for-you, ` +
`${_lbSyncPlaylistsByType.user_created.length} user, ` +
`${_lbSyncPlaylistsByType.collaborative.length} collaborative`
);
} catch (err) {
container.innerHTML = `<div class="playlist-placeholder">❌ Error loading ListenBrainz playlists: ${err.message}</div>`;
if (typeof showToast === 'function') {
showToast(`Error loading ListenBrainz playlists: ${err.message}`, 'error');
}
} finally {
if (refreshBtn) {
refreshBtn.disabled = false;
refreshBtn.textContent = '🔄 Refresh';
}
}
}
function renderListenBrainzSyncPlaylists() {
const container = document.getElementById('listenbrainz-sync-playlist-container');
if (!container) return;
const playlists = _lbSyncPlaylistsByType[_lbSyncCurrentType] || [];
if (playlists.length === 0) {
const empty = {
created_for_user: 'No "For You" playlists yet. ListenBrainz publishes Weekly Exploration / Top Discoveries on its own schedule.',
user_created: 'You haven\'t created any ListenBrainz playlists yet.',
collaborative: 'No collaborative playlists.',
}[_lbSyncCurrentType] || 'No playlists.';
container.innerHTML = `<div class="playlist-placeholder">${empty}</div>`;
return;
}
container.innerHTML = playlists.map(p => {
// The Discover-page endpoints wrap each entry in JSPF shape:
// { playlist: { identifier: 'https://.../<mbid>', title, creator,
// annotation: {track_count}, track: [...] } }
// Pull out the inner playlist object + extract the mbid from the URL.
const inner = p.playlist || p;
const mbid = (inner.identifier || '').split('/').pop() || inner.id || '';
const title = inner.title || inner.name || 'ListenBrainz Playlist';
const creator = inner.creator || 'ListenBrainz';
let count = 0;
if (inner.track_count) {
count = inner.track_count;
} else if (inner.annotation && inner.annotation.track_count) {
count = inner.annotation.track_count;
} else if (Array.isArray(inner.track) && inner.track.length > 0) {
count = inner.track.length;
}
// Reuse listenbrainzPlaylistStates so the modal state survives
// tab switches (matches Discover-page behavior).
const state = (typeof listenbrainzPlaylistStates !== 'undefined'
&& listenbrainzPlaylistStates[mbid]) || null;
const phase = state && state.phase ? state.phase : 'fresh';
const phaseText = (typeof getPhaseText === 'function')
? getPhaseText(phase)
: (phase === 'fresh' ? 'Ready to discover' : phase);
const phaseColor = (typeof getPhaseColor === 'function')
? getPhaseColor(phase)
: '#999';
const buttonText = (typeof getActionButtonText === 'function')
? getActionButtonText(phase)
: 'Discover';
return `
<div class="youtube-playlist-card listenbrainz-playlist-card"
id="listenbrainz-sync-card-${escapeHtml(mbid)}"
data-lb-mbid="${escapeHtml(mbid)}"
data-lb-title="${escapeHtml(title)}">
<div class="playlist-card-icon">🎧</div>
<div class="playlist-card-content">
<div class="playlist-card-name">${escapeHtml(title)}</div>
<div class="playlist-card-info">
<span class="playlist-card-track-count">${count} tracks</span>
<span class="playlist-card-owner">by ${escapeHtml(creator)}</span>
<span class="playlist-card-phase-text" style="color: ${phaseColor};">${phaseText}</span>
</div>
</div>
<div class="playlist-card-progress ${phase === 'fresh' ? 'hidden' : ''}"></div>
<button class="playlist-card-action-btn">${buttonText}</button>
</div>
`;
}).join('');
// Wire click handlers.
container.querySelectorAll('.listenbrainz-playlist-card').forEach(card => {
card.addEventListener('click', () => {
const mbid = card.dataset.lbMbid;
const title = card.dataset.lbTitle;
handleListenBrainzSyncCardClick(mbid, title);
});
});
// If the tab is currently visible, kick the refresh loop so cards
// start showing live state immediately. ``_startLbSyncCardRefreshLoop``
// is idempotent + self-stops when the tab loses focus.
const tab = document.getElementById('listenbrainz-sync-tab-content');
if (tab && tab.classList.contains('active')) {
_startLbSyncCardRefreshLoop();
}
}
async function handleListenBrainzSyncCardClick(playlistMbid, playlistTitle) {
if (!playlistMbid) {
if (typeof showToast === 'function') showToast('Missing playlist ID', 'error');
return;
}
// The Discover-page LB flow expects ``listenbrainzTracksCache[mbid]``
// to be populated before opening the modal — it pulls tracks from
// there when constructing the discovery state. On the Sync tab the
// user may click an LB card without ever visiting Discover, so we
// fetch + cache the tracks on demand here.
try {
if (typeof showLoadingOverlay === 'function') {
showLoadingOverlay(`Loading ${playlistTitle}...`);
}
if (typeof listenbrainzTracksCache === 'undefined') {
window.listenbrainzTracksCache = {};
}
let tracks = listenbrainzTracksCache[playlistMbid];
if (!tracks || tracks.length === 0) {
const resp = await fetch(`/api/discover/listenbrainz/playlist/${encodeURIComponent(playlistMbid)}`);
if (!resp.ok) {
throw new Error(`Failed to load playlist tracks (${resp.status})`);
}
const data = await resp.json();
tracks = (data.tracks || []).map(t => ({
track_name: t.track_name || '',
artist_name: t.artist_name || '',
album_name: t.album_name || '',
duration_ms: t.duration_ms || 0,
mbid: t.recording_mbid || t.mbid || '',
release_mbid: t.release_mbid || '',
album_cover_url: t.album_cover_url || '',
}));
listenbrainzTracksCache[playlistMbid] = tracks;
}
if (!tracks || tracks.length === 0) {
throw new Error('Playlist has no tracks');
}
if (typeof hideLoadingOverlay === 'function') hideLoadingOverlay();
// Hand off to the existing Discover-page modal opener. It owns
// state init, discovery kickoff, polling, and the sync→mirror
// step. The Sync tab is just a different entry point.
if (typeof openDownloadModalForListenBrainzPlaylist === 'function') {
await openDownloadModalForListenBrainzPlaylist(playlistMbid, playlistTitle);
} else {
throw new Error('LB discovery modal not available — discover.js may be missing');
}
} catch (err) {
if (typeof hideLoadingOverlay === 'function') hideLoadingOverlay();
console.error('Error opening LB playlist from Sync tab:', err);
if (typeof showToast === 'function') {
showToast(`Could not open playlist: ${err.message}`, 'error');
}
}
}
// Live card refresh — keeps the Sync-tab cards in sync with the
// canonical ``listenbrainzPlaylistStates`` dict that the discovery /
// sync polling loops own. Tidal does this via explicit
// ``updateTidalCardPhase`` / ``updateTidalCardProgress`` calls
// sprinkled through its polling code; we get the same UX with a
// single 500ms tick that reads the shared state. The loop only runs
// while the LB tab is the active Sync tab so it's cheap.
let _lbSyncCardRefreshInterval = null;
function _refreshOneLbSyncCard(card) {
const mbid = card.dataset.lbMbid;
if (!mbid) return;
const state = (typeof listenbrainzPlaylistStates !== 'undefined')
? listenbrainzPlaylistStates[mbid] : null;
if (!state) return;
const phase = state.phase || 'fresh';
const phaseEl = card.querySelector('.playlist-card-phase-text');
if (phaseEl) {
const text = (typeof getPhaseText === 'function') ? getPhaseText(phase) : phase;
const color = (typeof getPhaseColor === 'function') ? getPhaseColor(phase) : '';
if (phaseEl.textContent !== text) phaseEl.textContent = text;
if (color) phaseEl.style.color = color;
}
const btnEl = card.querySelector('.playlist-card-action-btn');
if (btnEl) {
const btnText = (typeof getActionButtonText === 'function')
? getActionButtonText(phase) : btnEl.textContent;
if (btnEl.textContent !== btnText) btnEl.textContent = btnText;
}
// Discovery progress mirrors Tidal's per-card text:
// "♪ <total> / ✓ <matched> / ✗ <failed> / <percent>%".
// During sync, swap to the sync progress payload the LB sync poller
// writes into state.lastSyncProgress (same shape Tidal uses).
const progEl = card.querySelector('.playlist-card-progress');
if (!progEl) return;
if (phase === 'fresh') {
progEl.classList.add('hidden');
progEl.textContent = '';
return;
}
if ((phase === 'syncing' || phase === 'sync_complete') && state.lastSyncProgress) {
const sp = state.lastSyncProgress;
const matched = sp.matched_tracks || sp.spotify_matches || 0;
const total = sp.total_tracks || sp.spotify_total || 0;
const failed = (sp.failed_tracks !== undefined)
? sp.failed_tracks : Math.max(0, total - matched);
const pct = total > 0 ? Math.round((matched / total) * 100) : 0;
progEl.textContent = `${total} / ✓ ${matched} / ✗ ${failed} / ${pct}%`;
progEl.classList.remove('hidden');
return;
}
const total = state.spotify_total || state.spotifyTotal || 0;
const matched = state.spotify_matches || state.spotifyMatches || 0;
const failed = Math.max(0, total - matched);
const pct = total > 0 ? Math.round((matched / total) * 100)
: (state.discovery_progress || state.discoveryProgress || 0);
progEl.textContent = `${total} / ✓ ${matched} / ✗ ${failed} / ${pct}%`;
progEl.classList.remove('hidden');
}
function _refreshAllLbSyncCards() {
// Both LB and Last.fm-radio tabs render MB-track cards that share
// the ``listenbrainzPlaylistStates`` state machine (Last.fm radios
// are stored in the same listenbrainz_playlists table with
// ``playlist_type='lastfm_radio'``). The refresh loop iterates
// visible cards from either tab so we don't need a second loop.
document.querySelectorAll(
'#listenbrainz-sync-tab-content .listenbrainz-playlist-card, ' +
'#lastfm-sync-tab-content .lastfm-playlist-card'
).forEach(_refreshOneLbSyncCard);
}
function _isMbStyleSyncTabActive() {
const lb = document.getElementById('listenbrainz-sync-tab-content');
const lfm = document.getElementById('lastfm-sync-tab-content');
return (lb && lb.classList.contains('active'))
|| (lfm && lfm.classList.contains('active'));
}
function _startLbSyncCardRefreshLoop() {
if (_lbSyncCardRefreshInterval) return;
_lbSyncCardRefreshInterval = setInterval(() => {
if (!_isMbStyleSyncTabActive()) {
_stopLbSyncCardRefreshLoop();
return;
}
_refreshAllLbSyncCards();
}, 500);
// Initial tick so the user doesn't wait 500ms for the first update.
_refreshAllLbSyncCards();
}
function _stopLbSyncCardRefreshLoop() {
if (_lbSyncCardRefreshInterval) {
clearInterval(_lbSyncCardRefreshInterval);
_lbSyncCardRefreshInterval = null;
}
}
// Sub-tab switching (For You / My Playlists / Collaborative).
function _initListenBrainzSyncSubTabs() {
const subTabContainer = document.querySelector('#listenbrainz-sync-tab-content .listenbrainz-sub-tabs');
if (!subTabContainer) return;
subTabContainer.addEventListener('click', (e) => {
const btn = e.target.closest('.listenbrainz-sub-tab-btn');
if (!btn) return;
const newType = btn.dataset.lbType;
if (!newType || newType === _lbSyncCurrentType) return;
subTabContainer.querySelectorAll('.listenbrainz-sub-tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_lbSyncCurrentType = newType;
renderListenBrainzSyncPlaylists();
});
}
// Refresh button.
function _initListenBrainzSyncRefreshBtn() {
const btn = document.getElementById('listenbrainz-sync-refresh-btn');
if (!btn) return;
btn.addEventListener('click', async () => {
// Trigger backend refetch + re-render.
try {
await fetch('/api/discover/listenbrainz/refresh', { method: 'POST' });
} catch (e) {
// Non-fatal; we still re-load from the cache endpoints.
console.warn('LB cache refresh failed (non-fatal):', e);
}
loadListenBrainzSyncPlaylists();
});
}
// Bootstrap once when sync page DOM is ready. ``initializeSyncPage``
// runs at app boot; we hook our subtab + refresh listeners on top.
document.addEventListener('DOMContentLoaded', () => {
_initListenBrainzSyncSubTabs();
_initListenBrainzSyncRefreshBtn();
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,266 @@
// ===================================================================
// SOULSYNC DISCOVERY SYNC TAB (Phase 1c.3)
// ===================================================================
// Surfaces the user's persisted SoulSync Discovery / personalized
// playlists (decade mixes, hidden gems, popular picks, daily mixes,
// discovery shuffle, etc.) as a Sync-page tab so they participate
// in the mirrored-playlist + Auto-Sync pipeline like every other
// source.
//
// Different shape from the LB / Last.fm tabs: personalized tracks
// already carry Spotify / iTunes / Deezer IDs (matched at generation
// time from the discovery pool), so there's no MB-style "needs
// discovery" hop. Click → refresh kind → grab tracks → mirror as
// ``source='soulsync_discovery'`` with the matched_data shape
// downstream consumers already expect from auto-discovered Spotify
// mirrors.
let _soulsyncDiscoverySyncRecords = [];
async function loadSoulsyncDiscoverySyncPlaylists() {
const container = document.getElementById('soulsync-discovery-sync-playlist-container');
const refreshBtn = document.getElementById('soulsync-discovery-sync-refresh-btn');
if (!container) return;
container.innerHTML = `<div class="playlist-placeholder">🔄 Loading SoulSync Discovery playlists...</div>`;
if (refreshBtn) {
refreshBtn.disabled = true;
refreshBtn.textContent = '🔄 Loading...';
}
try {
const resp = await fetch('/api/personalized/playlists');
const data = await resp.json();
if (!data.success) {
container.innerHTML = `<div class="playlist-placeholder">❌ ${escapeHtml(data.error || 'Failed to load')}</div>`;
return;
}
_soulsyncDiscoverySyncRecords = data.playlists || [];
renderSoulsyncDiscoverySyncPlaylists();
console.log(`✨ SoulSync Discovery Sync tab loaded: ${_soulsyncDiscoverySyncRecords.length} playlists`);
} catch (err) {
container.innerHTML = `<div class="playlist-placeholder">❌ Error: ${err.message}</div>`;
if (typeof showToast === 'function') {
showToast(`Error loading SoulSync Discovery: ${err.message}`, 'error');
}
} finally {
if (refreshBtn) {
refreshBtn.disabled = false;
refreshBtn.textContent = '🔄 Refresh';
}
}
}
function renderSoulsyncDiscoverySyncPlaylists() {
const container = document.getElementById('soulsync-discovery-sync-playlist-container');
if (!container) return;
if (_soulsyncDiscoverySyncRecords.length === 0) {
container.innerHTML = `<div class="playlist-placeholder">No SoulSync Discovery playlists yet. Open the Discover page and generate a few personalized playlists first.</div>`;
return;
}
container.innerHTML = _soulsyncDiscoverySyncRecords.map(p => {
const syntheticId = _soulsyncSyntheticId(p.kind, p.variant);
const title = p.name || `${p.kind} ${p.variant || ''}`.trim();
const subtitle = p.variant ? `${p.kind} · ${p.variant}` : p.kind;
const count = p.track_count || 0;
const stale = !!p.is_stale;
const stalenessText = stale ? 'Stale — refresh to regenerate' : 'Ready';
const stalenessColor = stale ? '#facc15' : '#14b8a6';
return `
<div class="youtube-playlist-card soulsync-discovery-playlist-card"
id="soulsync-discovery-sync-card-${escapeHtml(syntheticId)}"
data-ssd-kind="${escapeHtml(p.kind)}"
data-ssd-variant="${escapeHtml(p.variant || '')}"
data-ssd-id="${escapeHtml(syntheticId)}"
data-ssd-name="${escapeHtml(title)}">
<div class="playlist-card-icon"></div>
<div class="playlist-card-content">
<div class="playlist-card-name">${escapeHtml(title)}</div>
<div class="playlist-card-info">
<span class="playlist-card-track-count">${count} tracks</span>
<span class="playlist-card-owner">${escapeHtml(subtitle)}</span>
<span class="playlist-card-phase-text" style="color: ${stalenessColor};">${stalenessText}</span>
</div>
</div>
<div class="playlist-card-progress hidden"></div>
<button class="playlist-card-action-btn">Refresh & Mirror</button>
</div>
`;
}).join('');
container.querySelectorAll('.soulsync-discovery-playlist-card').forEach(card => {
card.addEventListener('click', () => {
const kind = card.dataset.ssdKind;
const variant = card.dataset.ssdVariant;
const name = card.dataset.ssdName;
handleSoulsyncDiscoverySyncCardClick(kind, variant, name, card);
});
});
}
function _soulsyncSyntheticId(kind, variant) {
// Synthetic stable id keyed on (kind, variant) so re-refreshes UPSERT
// the same mirror row instead of duplicating. Empty variant collapses
// cleanly (e.g. hidden_gems with no variant -> "ssd_hidden_gems").
return `ssd_${kind}${variant ? `_${variant}` : ''}`;
}
async function handleSoulsyncDiscoverySyncCardClick(kind, variant, name, cardEl) {
if (!kind) {
if (typeof showToast === 'function') showToast('Missing kind', 'error');
return;
}
const btn = cardEl ? cardEl.querySelector('.playlist-card-action-btn') : null;
const progEl = cardEl ? cardEl.querySelector('.playlist-card-progress') : null;
if (btn) {
btn.disabled = true;
btn.textContent = 'Refreshing…';
}
if (progEl) progEl.classList.remove('hidden');
try {
// Trigger the kind's generator and grab fresh tracks.
const url = variant
? `/api/personalized/playlist/${encodeURIComponent(kind)}/${encodeURIComponent(variant)}/refresh`
: `/api/personalized/playlist/${encodeURIComponent(kind)}/refresh`;
const resp = await fetch(url, { method: 'POST' });
const data = await resp.json();
if (!data.success) {
throw new Error(data.error || 'Generator failed');
}
const rec = data.playlist || {};
const tracks = data.tracks || [];
const finalName = rec.name || name || `${kind} ${variant || ''}`.trim();
const syntheticId = _soulsyncSyntheticId(kind, variant);
if (tracks.length === 0) {
if (typeof showToast === 'function') {
showToast(`'${finalName}' generated 0 tracks. Try widening the playlist's config in Discover.`, 'warning');
}
}
// Project each track into the mirrorPlaylist contract. Tracks
// already carry provider IDs from the discovery pool, so the
// matched_data block is filled inline — no separate discovery
// worker pass needed.
const mirrorTracks = tracks.map(t => {
const trackId = t.spotify_track_id || t.itunes_track_id || t.deezer_track_id || '';
const provider = t.spotify_track_id ? 'spotify'
: (t.itunes_track_id ? 'itunes'
: (t.deezer_track_id ? 'deezer' : (t.source || 'unknown')));
const albumObj = { name: t.album_name || '' };
if (t.album_cover_url) {
albumObj.images = [{ url: t.album_cover_url, height: 600, width: 600 }];
}
const extra = trackId ? JSON.stringify({
discovered: true,
provider,
confidence: 1.0,
matched_data: {
id: trackId,
name: t.track_name || '',
artists: [{ name: t.artist_name || '' }],
album: albumObj,
duration_ms: t.duration_ms || 0,
image_url: t.album_cover_url || null,
source: provider,
},
}) : null;
return {
track_name: t.track_name || '',
artist_name: t.artist_name || '',
album_name: t.album_name || '',
duration_ms: t.duration_ms || 0,
image_url: t.album_cover_url || null,
source_track_id: trackId,
extra_data: extra,
};
});
// POST inline so we can capture the returned mirrored_playlists
// row id and open the detail modal afterward. ``mirrorPlaylist``
// (in stats-automations.js) is fire-and-forget and doesn't
// surface the id, which the next step needs.
const normalizedTracks = mirrorTracks.map(t => ({
track_name: t.track_name || '',
artist_name: t.artist_name || '',
album_name: t.album_name || '',
duration_ms: t.duration_ms || 0,
image_url: t.image_url || null,
source_track_id: t.source_track_id || '',
extra_data: t.extra_data || null,
}));
const mirrorResp = await fetch('/api/mirror-playlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source: 'soulsync_discovery',
source_playlist_id: syntheticId,
name: finalName,
tracks: normalizedTracks,
description: `Personalized ${kind}${variant ? ' · ' + variant : ''} — regenerates on Auto-Sync refresh.`,
owner: 'SoulSync',
image_url: '',
}),
});
const mirrorData = await mirrorResp.json();
if (!mirrorData.success) {
throw new Error(mirrorData.error || 'Mirror creation failed');
}
const mirroredId = mirrorData.playlist_id;
if (progEl) {
progEl.textContent = `${tracks.length} / ✓ ${mirrorTracks.length} / mirrored`;
}
if (btn) {
btn.disabled = false;
btn.textContent = 'Refresh & Mirror';
}
// Update the in-memory record so the card displays the new count.
const idx = _soulsyncDiscoverySyncRecords.findIndex(
r => r.kind === kind && (r.variant || '') === (variant || '')
);
if (idx >= 0) {
_soulsyncDiscoverySyncRecords[idx] = {
..._soulsyncDiscoverySyncRecords[idx],
...rec,
track_count: tracks.length,
is_stale: false,
};
}
if (typeof showToast === 'function') {
showToast(`Mirrored '${finalName}' with ${mirrorTracks.length} tracks`, 'success');
}
// Open the mirrored-playlist detail modal so the user lands on
// the tracks view + can trigger sync / download from there.
// Same flow the Mirrored tab uses when clicking a row.
if (mirroredId && typeof openMirroredPlaylistModal === 'function') {
try {
await openMirroredPlaylistModal(mirroredId);
} catch (e) {
console.warn('Could not open mirrored playlist detail:', e);
}
}
} catch (err) {
if (btn) {
btn.disabled = false;
btn.textContent = 'Refresh & Mirror';
}
if (typeof showToast === 'function') {
showToast(`Refresh failed: ${err.message}`, 'error');
}
console.error('SoulSync Discovery refresh failed:', err);
}
}
document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('soulsync-discovery-sync-refresh-btn');
if (btn) btn.addEventListener('click', loadSoulsyncDiscoverySyncPlaylists);
});

View file

@ -396,13 +396,16 @@ async function selectDiscoveryFixTrack(track) {
// For Spotify Public, backend expects the url_hash
const state = youtubePlaylistStates[identifier];
backendIdentifier = state?.spotify_public_playlist_id || identifier;
} else if (platform === 'itunes_link') {
const state = youtubePlaylistStates[identifier];
backendIdentifier = state?.itunes_link_playlist_id || identifier;
} else if (platform === 'beatport') {
// For Beatport, backend expects url_hash (same as identifier)
backendIdentifier = identifier;
}
// Mirrored playlists route through the YouTube endpoint (which already handles mirrored_ prefixes)
const apiPlatform = platform === 'mirrored' ? 'youtube' : (platform === 'spotify_public' ? 'spotify-public' : platform);
const apiPlatform = platform === 'mirrored' ? 'youtube' : (platform === 'spotify_public' ? 'spotify-public' : (platform === 'itunes_link' ? 'itunes-link' : platform));
const requestBody = {
identifier: backendIdentifier,
@ -459,6 +462,8 @@ async function selectDiscoveryFixTrack(track) {
state = youtubePlaylistStates[identifier];
} else if (platform === 'spotify_public') {
state = youtubePlaylistStates[identifier];
} else if (platform === 'itunes_link') {
state = youtubePlaylistStates[identifier];
}
// Support both camelCase and snake_case
@ -561,6 +566,17 @@ async function selectDiscoveryFixTrack(track) {
});
}
}
if (platform === 'itunes_link' && state.itunes_link_playlist_id) {
const itunesState = itunesLinkPlaylistStates?.[state.itunes_link_playlist_id];
if (itunesState) {
itunesState.spotifyMatches = state.spotifyMatches;
updateITunesLinkCardProgress(state.itunes_link_playlist_id, {
spotify_matches: state.spotifyMatches,
spotify_total: spotify_total
});
}
}
}
// Update UI - refresh the table row
@ -623,10 +639,19 @@ function updateDiscoveryModalSingleRow(platform, identifier, trackIndex) {
}
async function unmatchDiscoveryTrack(platform, identifier, trackIndex) {
const uiState = (typeof youtubePlaylistStates !== 'undefined' ? youtubePlaylistStates[identifier] : null)
|| (typeof listenbrainzPlaylistStates !== 'undefined' ? listenbrainzPlaylistStates[identifier] : null);
const backendIdentifier = platform === 'spotify_public'
? (uiState?.spotify_public_playlist_id || identifier)
: platform === 'itunes_link'
? (uiState?.itunes_link_playlist_id || identifier)
: identifier;
// Determine the correct API base for this platform
const apiBase = platform === 'tidal' ? '/api/tidal'
: platform === 'deezer' ? '/api/deezer'
: platform === 'spotify-public' ? '/api/spotify-public'
: (platform === 'spotify-public' || platform === 'spotify_public') ? '/api/spotify-public'
: (platform === 'itunes-link' || platform === 'itunes_link') ? '/api/itunes-link'
: platform === 'beatport' ? '/api/beatport'
: platform === 'listenbrainz' ? '/api/listenbrainz'
: '/api/youtube';
@ -635,7 +660,7 @@ async function unmatchDiscoveryTrack(platform, identifier, trackIndex) {
const response = await fetch(`${apiBase}/discovery/unmatch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier, track_index: trackIndex })
body: JSON.stringify({ identifier: backendIdentifier, track_index: trackIndex })
});
const data = await response.json();
if (data.success) {
@ -6530,6 +6555,7 @@ const TOOL_HELP_CONTENT = {
<ul>
<li><strong>Bot Token:</strong> Your Telegram bot token (from @BotFather)</li>
<li><strong>Chat ID:</strong> The chat/group ID to send messages to</li>
<li><strong>Thread ID:</strong> Optional only set if your group uses Telegram topics. Leave blank for the main chat.</li>
<li><strong>Message Template:</strong> Custom message with variable placeholders</li>
</ul>