commit
b470eae0b1
93 changed files with 16476 additions and 857 deletions
4
.github/workflows/docker-publish.yml
vendored
4
.github/workflows/docker-publish.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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`` URL→ID resolution, Deezer / Tidal / YouTube)
|
||||
remain identical to the pre-extraction closure — this is a
|
||||
mechanical lift, not a redesign.
|
||||
- filtering sources that can't be refreshed (``file``, ``beatport``),
|
||||
- extracting upstream URLs from the stored ``description`` for URL-
|
||||
backed sources (``spotify_public``, ``youtube``),
|
||||
- the Spotify-public → authenticated-Spotify fallback (uses the
|
||||
``spotify`` adapter when the user is signed in so the mirror keeps
|
||||
album art),
|
||||
- the Tidal-not-authenticated skip log type (vs error),
|
||||
- preserving existing per-track ``extra_data`` on tracks that survive
|
||||
the refresh, and
|
||||
- emitting the ``playlist_changed`` automation event when the track
|
||||
set actually shifts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
from core.playlists.source_refs import require_refresh_url
|
||||
from core.playlists.sources import PlaylistDetail, to_mirror_track_dict
|
||||
from core.playlists.sources.base import (
|
||||
SOURCE_SPOTIFY,
|
||||
SOURCE_SPOTIFY_PUBLIC,
|
||||
SOURCE_TIDAL,
|
||||
SOURCE_YOUTUBE,
|
||||
)
|
||||
|
||||
|
||||
# Sources that store the upstream URL in ``description`` (because their
|
||||
# ``source_playlist_id`` is a deterministic hash, not the native ID).
|
||||
# The refresh path has to recover the URL before calling the adapter.
|
||||
_URL_BACKED_SOURCES = {SOURCE_SPOTIFY_PUBLIC, SOURCE_YOUTUBE}
|
||||
|
||||
|
||||
def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
|
|
@ -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-public→authed
|
||||
fallback, Tidal not-authed skip) live here; everything else
|
||||
delegates to the adapter."""
|
||||
registry = deps.playlist_source_registry
|
||||
if registry is None:
|
||||
return None
|
||||
|
||||
# URL-backed sources: pull the upstream URL out of `description`.
|
||||
playlist_input = source_id
|
||||
if source in _URL_BACKED_SOURCES:
|
||||
# ``require_refresh_url`` raises ValueError on missing URL.
|
||||
# The outer try/except in the loop catches it and reports as
|
||||
# an error — matching the pre-extraction behavior.
|
||||
playlist_input = require_refresh_url(
|
||||
source, pl.get('description', ''), pl.get('name', '')
|
||||
)
|
||||
|
||||
# Spotify-public refresh: prefer the authenticated Spotify API
|
||||
# when the user is signed in. Better album art, matches the
|
||||
# pre-extraction handler. Falls through to the public scraper on
|
||||
# auth failure or non-playlist URL types (e.g. album URLs).
|
||||
if source == SOURCE_SPOTIFY_PUBLIC:
|
||||
detail = _try_spotify_authed_for_public(playlist_input, deps)
|
||||
if detail is not None:
|
||||
return detail
|
||||
|
||||
# Tidal not-authed: soft-skip with a 'skip' log line, not an error.
|
||||
if source == SOURCE_TIDAL:
|
||||
tidal_source = registry.get_source(SOURCE_TIDAL)
|
||||
if tidal_source is None or not tidal_source.is_authenticated():
|
||||
deps.logger.warning(
|
||||
f"Tidal not authenticated — skipping refresh for '{pl.get('name', '')}'"
|
||||
)
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
log_line=f'Skipped "{pl.get("name", "")}" — Tidal not authenticated',
|
||||
log_type='skip',
|
||||
)
|
||||
raise _SkipPlaylist
|
||||
|
||||
adapter = registry.get_source(source)
|
||||
if adapter is None:
|
||||
return None
|
||||
try:
|
||||
return adapter.refresh_playlist(playlist_input)
|
||||
except Exception as exc:
|
||||
deps.logger.warning(
|
||||
f"{source} playlist refresh failed for {playlist_input}: {exc}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _try_spotify_authed_for_public(
|
||||
spotify_url: str, deps: AutomationDeps
|
||||
) -> Optional[PlaylistDetail]:
|
||||
"""Best-effort: use the authenticated Spotify adapter on a public URL.
|
||||
|
||||
Returns ``None`` to signal "fall through to the public-scraper
|
||||
adapter" — never raises. Only applies to ``playlist``-type URLs;
|
||||
album URLs fall through unconditionally."""
|
||||
if not spotify_url:
|
||||
return None
|
||||
spotify_client = deps.spotify_client
|
||||
if spotify_client is None or not spotify_client.is_spotify_authenticated():
|
||||
return None
|
||||
try:
|
||||
from core.spotify_public_scraper import parse_spotify_url
|
||||
parsed = parse_spotify_url(spotify_url)
|
||||
except Exception:
|
||||
return None
|
||||
if not parsed or parsed.get('type') != 'playlist':
|
||||
return None
|
||||
adapter = deps.playlist_source_registry.get_source(SOURCE_SPOTIFY)
|
||||
if adapter is None:
|
||||
return None
|
||||
try:
|
||||
return adapter.refresh_playlist(parsed['id'])
|
||||
except Exception as exc:
|
||||
deps.logger.debug(f"Spotify authed fallback for public mirror failed: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def _commit_refresh(
|
||||
pl: Dict[str, Any],
|
||||
source: str,
|
||||
source_id: str,
|
||||
tracks: List[Dict[str, Any]],
|
||||
db: Any,
|
||||
deps: AutomationDeps,
|
||||
auto_id: Optional[str],
|
||||
) -> int:
|
||||
"""Persist the refreshed track list + emit playlist_changed when delta.
|
||||
|
||||
Returns 1 when a refresh successfully landed, 0 otherwise. The
|
||||
caller is responsible for incrementing the running tally."""
|
||||
old_tracks = db.get_mirrored_playlist_tracks(pl['id']) if pl.get('id') else []
|
||||
old_ids = {t.get('source_track_id') for t in old_tracks if t.get('source_track_id')}
|
||||
new_ids = {t.get('source_track_id') for t in tracks if t.get('source_track_id')}
|
||||
|
||||
# Preserve existing extra_data (matched_data + discovery state)
|
||||
# for tracks that still exist in the refreshed snapshot, unless
|
||||
# the adapter already provided fresh extra_data for that track.
|
||||
old_extra_map = (
|
||||
db.get_mirrored_tracks_extra_data_map(pl['id']) if pl.get('id') else {}
|
||||
)
|
||||
for t in tracks:
|
||||
sid = t.get('source_track_id', '')
|
||||
if sid and sid in old_extra_map and 'extra_data' not in t:
|
||||
t['extra_data'] = old_extra_map[sid]
|
||||
|
||||
db.mirror_playlist(
|
||||
source=source,
|
||||
source_playlist_id=source_id,
|
||||
name=pl['name'],
|
||||
tracks=tracks,
|
||||
profile_id=pl.get('profile_id', 1),
|
||||
description=pl.get('description'),
|
||||
owner=pl.get('owner'),
|
||||
image_url=pl.get('image_url'),
|
||||
)
|
||||
|
||||
if old_ids != new_ids:
|
||||
added = len(new_ids - old_ids)
|
||||
removed = len(old_ids - new_ids)
|
||||
deps.logger.info(
|
||||
f"[AUTOMATION] Playlist changed: '{pl.get('name', '')}' — "
|
||||
f"{added} added, {removed} removed (old={len(old_ids)}, new={len(new_ids)})"
|
||||
)
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
log_line=f'"{pl.get("name", "")}" — {added} added, {removed} removed',
|
||||
log_type='success',
|
||||
)
|
||||
try:
|
||||
if deps.engine:
|
||||
deps.engine.emit('playlist_changed', {
|
||||
'playlist_name': pl.get('name', ''),
|
||||
'playlist_id': str(pl.get('id', '')),
|
||||
'old_count': str(len(old_ids)),
|
||||
'new_count': str(len(new_ids)),
|
||||
'added': str(added),
|
||||
'removed': str(removed),
|
||||
})
|
||||
except Exception as e:
|
||||
deps.logger.debug("playlist_synced automation emit failed: %s", e)
|
||||
else:
|
||||
deps.logger.warning(
|
||||
f"[AUTOMATION] No changes: '{pl.get('name', '')}' (tracks={len(old_ids)})"
|
||||
)
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
log_line=f'No changes: "{pl.get("name", "")}"',
|
||||
log_type='skip',
|
||||
)
|
||||
|
||||
return 1
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
70
core/discovery/manual_match.py
Normal file
70
core/discovery/manual_match.py
Normal 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
134
core/discovery/matching.py
Normal 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]
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
198
core/downloads/wishlist_aggregator.py
Normal file
198
core/downloads/wishlist_aggregator.py
Normal 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']
|
||||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
125
core/playlists/lb_series.py
Normal 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
371
core/playlists/pipeline.py
Normal 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',
|
||||
)
|
||||
156
core/playlists/source_refs.py
Normal file
156
core/playlists/source_refs.py
Normal 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]
|
||||
34
core/playlists/sources/__init__.py
Normal file
34
core/playlists/sources/__init__.py
Normal 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",
|
||||
]
|
||||
244
core/playlists/sources/base.py
Normal file
244
core/playlists/sources/base.py
Normal 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
|
||||
104
core/playlists/sources/bootstrap.py
Normal file
104
core/playlists/sources/bootstrap.py
Normal 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
|
||||
108
core/playlists/sources/deezer.py
Normal file
108
core/playlists/sources/deezer.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"""Deezer playlist source adapter.
|
||||
|
||||
Wraps ``core.deezer_client.DeezerClient.get_playlist``. Deezer's public
|
||||
API needs no auth, so ``is_authenticated`` always returns True. Listing
|
||||
the *user's* playlists requires OAuth — surfaced via the underlying
|
||||
``is_user_authenticated`` flag — but the get-by-id flow works on any
|
||||
public playlist regardless.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from core.playlists.sources.base import (
|
||||
NormalizedTrack,
|
||||
PlaylistDetail,
|
||||
PlaylistMeta,
|
||||
PlaylistSource,
|
||||
SOURCE_DEEZER,
|
||||
)
|
||||
|
||||
|
||||
class DeezerPlaylistSource(PlaylistSource):
|
||||
name = SOURCE_DEEZER
|
||||
supports_listing = True # user playlists need OAuth; falls back to []
|
||||
supports_refresh = True
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self, client_getter: Callable[[], Any]):
|
||||
self._client_getter = client_getter
|
||||
|
||||
def _client(self):
|
||||
return self._client_getter()
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
client = self._client()
|
||||
if client is None:
|
||||
return False
|
||||
# Deezer's `is_authenticated` is True even with no OAuth token —
|
||||
# the public API works without one. Use that as our liveness signal.
|
||||
return bool(client.is_authenticated())
|
||||
|
||||
def list_playlists(self) -> List[PlaylistMeta]:
|
||||
client = self._client()
|
||||
if client is None:
|
||||
return []
|
||||
# User playlists need OAuth; `get_user_playlists` returns [] when
|
||||
# the stub-interface variant is in use. Honor whatever the client
|
||||
# actually returns.
|
||||
try:
|
||||
playlists = client.get_user_playlists() or []
|
||||
except Exception:
|
||||
return []
|
||||
return [self._meta_from_playlist(p) for p in playlists]
|
||||
|
||||
def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
|
||||
client = self._client()
|
||||
if client is None:
|
||||
return None
|
||||
data = client.get_playlist(playlist_id)
|
||||
if not data:
|
||||
return None
|
||||
meta = self._meta_from_dict(data)
|
||||
tracks_raw = data.get("tracks") or []
|
||||
tracks = [self._track_from_dict(t, idx) for idx, t in enumerate(tracks_raw)]
|
||||
meta.track_count = len(tracks)
|
||||
return PlaylistDetail(meta=meta, tracks=tracks)
|
||||
|
||||
def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
|
||||
return self.get_playlist(playlist_id)
|
||||
|
||||
# ---- projection helpers ------------------------------------------------
|
||||
|
||||
def _meta_from_playlist(self, playlist: Any) -> PlaylistMeta:
|
||||
"""Project a ``DeezerClient.Playlist`` dataclass into PlaylistMeta."""
|
||||
return PlaylistMeta(
|
||||
source=self.name,
|
||||
source_playlist_id=str(getattr(playlist, "id", "")),
|
||||
name=getattr(playlist, "name", "Deezer Playlist"),
|
||||
description=getattr(playlist, "description", None),
|
||||
track_count=int(getattr(playlist, "total_tracks", 0) or 0),
|
||||
owner=getattr(playlist, "owner", None),
|
||||
)
|
||||
|
||||
def _meta_from_dict(self, p: Dict[str, Any]) -> PlaylistMeta:
|
||||
return PlaylistMeta(
|
||||
source=self.name,
|
||||
source_playlist_id=str(p.get("id", "")),
|
||||
name=p.get("name", "Deezer Playlist"),
|
||||
description=p.get("description") or None,
|
||||
owner=p.get("owner") or None,
|
||||
image_url=p.get("image_url") or None,
|
||||
track_count=int(p.get("track_count", 0) or 0),
|
||||
)
|
||||
|
||||
def _track_from_dict(self, t: Dict[str, Any], position: int) -> NormalizedTrack:
|
||||
artists = t.get("artists") or []
|
||||
artist_name = artists[0] if artists else "Unknown Artist"
|
||||
return NormalizedTrack(
|
||||
position=position,
|
||||
track_name=t.get("name", "Unknown Track"),
|
||||
artist_name=artist_name,
|
||||
album_name=t.get("album") or None,
|
||||
duration_ms=int(t.get("duration_ms", 0) or 0),
|
||||
source_track_id=str(t.get("id", "")),
|
||||
needs_discovery=False,
|
||||
extra={"track_number": t.get("track_number")},
|
||||
)
|
||||
109
core/playlists/sources/itunes_link.py
Normal file
109
core/playlists/sources/itunes_link.py
Normal 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"),
|
||||
},
|
||||
)
|
||||
134
core/playlists/sources/lastfm.py
Normal file
134
core/playlists/sources/lastfm.py
Normal 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"),
|
||||
},
|
||||
)
|
||||
308
core/playlists/sources/listenbrainz.py
Normal file
308
core/playlists/sources/listenbrainz.py
Normal 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"),
|
||||
},
|
||||
)
|
||||
94
core/playlists/sources/qobuz.py
Normal file
94
core/playlists/sources/qobuz.py
Normal 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",
|
||||
}},
|
||||
)
|
||||
77
core/playlists/sources/registry.py
Normal file
77
core/playlists/sources/registry.py
Normal 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
|
||||
149
core/playlists/sources/soulsync_discovery.py
Normal file
149
core/playlists/sources/soulsync_discovery.py
Normal 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,
|
||||
},
|
||||
)
|
||||
137
core/playlists/sources/spotify.py
Normal file
137
core/playlists/sources/spotify.py
Normal 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,
|
||||
)
|
||||
114
core/playlists/sources/spotify_public.py
Normal file
114
core/playlists/sources/spotify_public.py
Normal 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,
|
||||
)
|
||||
103
core/playlists/sources/tidal.py
Normal file
103
core/playlists/sources/tidal.py
Normal 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),
|
||||
},
|
||||
)
|
||||
87
core/playlists/sources/youtube.py
Normal file
87
core/playlists/sources/youtube.py
Normal 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
0
core/text/__init__.py
Normal file
41
core/text/normalize.py
Normal file
41
core/text/normalize.py
Normal 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()
|
||||
201
core/wishlist/album_grouping.py
Normal file
201
core/wishlist/album_grouping.py
Normal 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',
|
||||
]
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
87
tests/database/test_album_thumb_preservation.py
Normal file
87
tests/database/test_album_thumb_preservation.py
Normal 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"
|
||||
127
tests/database/test_get_artist_tracks_indexed.py
Normal file
127
tests/database/test_get_artist_tracks_indexed.py
Normal 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'
|
||||
62
tests/database/test_mirrored_playlists.py
Normal file
62
tests/database/test_mirrored_playlists.py
Normal 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"
|
||||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
106
tests/discovery/test_manual_match.py
Normal file
106
tests/discovery/test_manual_match.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
192
tests/downloads/test_wishlist_aggregator.py
Normal file
192
tests/downloads/test_wishlist_aggregator.py
Normal 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)'
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
82
tests/playlists/test_source_refs.py
Normal file
82
tests/playlists/test_source_refs.py
Normal 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"
|
||||
394
tests/static/test_auto_sync.mjs
Normal file
394
tests/static/test_auto_sync.mjs
Normal 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({}), '');
|
||||
});
|
||||
});
|
||||
43
tests/sync/test_artist_name_extraction.py
Normal file
43
tests/sync/test_artist_name_extraction.py
Normal 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('') == ''
|
||||
208
tests/sync/test_sync_candidate_pool.py
Normal file
208
tests/sync/test_sync_candidate_pool.py
Normal 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',
|
||||
)
|
||||
72
tests/test_auto_sync_js.py
Normal file
72
tests/test_auto_sync_js.py
Normal 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,
|
||||
)
|
||||
89
tests/test_lb_series_detect.py
Normal file
89
tests/test_lb_series_detect.py
Normal 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
|
||||
45
tests/test_library_tag_payload.py
Normal file
45
tests/test_library_tag_payload.py
Normal 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'
|
||||
837
tests/test_playlist_sources_adapters.py
Normal file
837
tests/test_playlist_sources_adapters.py
Normal 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"
|
||||
|
|
@ -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
0
tests/text/__init__.py
Normal file
38
tests/text/test_normalize.py
Normal file
38
tests/text/test_normalize.py
Normal 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"
|
||||
159
tests/wishlist/test_album_grouping.py
Normal file
159
tests/wishlist/test_album_grouping.py
Normal 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'
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
1353
web_server.py
1353
web_server.py
File diff suppressed because it is too large
Load diff
162
webui/index.html
162
webui/index.html
|
|
@ -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 & 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
1521
webui/static/auto-sync.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
131
webui/static/sync-lastfm.js
Normal 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);
|
||||
});
|
||||
364
webui/static/sync-listenbrainz.js
Normal file
364
webui/static/sync-listenbrainz.js
Normal 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
266
webui/static/sync-soulsync-discovery.js
Normal file
266
webui/static/sync-soulsync-discovery.js
Normal 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);
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue