Harden playlist pipeline source refresh
Centralize mirrored playlist source reference normalization so edited links and IDs are stored consistently. Preserve URL-backed refresh refs, surface missing-source refresh failures, count background sync failures in pipeline summaries, and retry guarded automation skips after a short delay instead of losing a scheduled run. Add focused coverage for source refs, mirrored playlist source updates, refresh failures, and guarded retry behavior.
This commit is contained in:
parent
a7ca7ddfad
commit
73bd2db547
12 changed files with 497 additions and 30 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import json
|
|||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
from core.playlists.source_refs import require_refresh_url
|
||||
|
||||
|
||||
def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
|
|
@ -129,7 +130,7 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[
|
|||
# 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', '')
|
||||
spotify_url = require_refresh_url(source, pl.get('description', ''), pl.get('name', ''))
|
||||
parsed = parse_spotify_url(spotify_url) if spotify_url else None
|
||||
|
||||
# If Spotify is authenticated, use the full API (auto-discovers with album art).
|
||||
|
|
@ -185,6 +186,13 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[
|
|||
# No extra_data — let preservation code keep existing discovery data.
|
||||
except Exception as e:
|
||||
deps.logger.warning(f"Spotify public playlist refresh failed for {source_id}: {e}")
|
||||
errors.append(f"{pl.get('name', '?')}: {str(e)}")
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
log_line=f'Refresh failed: "{pl.get("name", "")}" - {str(e)}',
|
||||
log_type='error',
|
||||
)
|
||||
continue
|
||||
|
||||
elif source == 'deezer':
|
||||
try:
|
||||
|
|
@ -228,7 +236,7 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[
|
|||
|
||||
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}"
|
||||
yt_url = require_refresh_url(source, pl.get('description', ''), pl.get('name', ''))
|
||||
playlist_data = deps.parse_youtube_playlist(yt_url)
|
||||
if playlist_data and playlist_data.get('tracks'):
|
||||
tracks = []
|
||||
|
|
@ -242,6 +250,15 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[
|
|||
'source_track_id': t.get('id', ''),
|
||||
})
|
||||
|
||||
if tracks is None:
|
||||
errors.append(f"{pl.get('name', '?')}: no tracks returned from source")
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
log_line=f'Refresh failed: "{pl.get("name", "")}" - no tracks returned from source',
|
||||
log_type='error',
|
||||
)
|
||||
continue
|
||||
|
||||
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 []
|
||||
|
|
@ -261,6 +278,7 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[
|
|||
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'),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(':'))
|
||||
|
|
|
|||
125
core/playlists/source_refs.py
Normal file
125
core/playlists/source_refs.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""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 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]
|
||||
|
||||
|
||||
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 _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/(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]
|
||||
|
|
@ -11828,7 +11828,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 +11939,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:
|
||||
|
|
|
|||
|
|
@ -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,6 +26,7 @@ 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
|
||||
|
|
@ -266,6 +267,24 @@ 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)
|
||||
|
||||
|
||||
# ─── sync_playlist ───────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -398,3 +417,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)
|
||||
|
|
|
|||
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"
|
||||
45
tests/playlists/test_source_refs.py
Normal file
45
tests/playlists/test_source_refs.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import pytest
|
||||
|
||||
from core.playlists.source_refs import (
|
||||
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_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")
|
||||
|
|
@ -32211,6 +32211,55 @@ def get_mirrored_playlist_endpoint(playlist_id):
|
|||
logger.error(f"Error getting mirrored playlist: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/mirrored-playlists/<int:playlist_id>/source-ref', methods=['PATCH'])
|
||||
def update_mirrored_playlist_source_ref_endpoint(playlist_id):
|
||||
"""Update the upstream source link/id for a mirrored playlist."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
source_ref = data.get('source_ref') or data.get('source_playlist_id') or data.get('url')
|
||||
|
||||
database = get_database()
|
||||
playlist = database.get_mirrored_playlist(playlist_id)
|
||||
if not playlist:
|
||||
return jsonify({"error": "Playlist not found"}), 404
|
||||
|
||||
try:
|
||||
from core.playlists.source_refs import normalize_mirrored_source_ref
|
||||
normalized = normalize_mirrored_source_ref(
|
||||
playlist.get('source'),
|
||||
source_ref,
|
||||
playlist.get('description') or '',
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
existing = [
|
||||
pl for pl in database.get_mirrored_playlists(profile_id=playlist.get('profile_id', 1))
|
||||
if (
|
||||
pl.get('source') == playlist.get('source')
|
||||
and str(pl.get('source_playlist_id')) == str(normalized.source_playlist_id)
|
||||
and int(pl.get('id')) != int(playlist_id)
|
||||
)
|
||||
]
|
||||
if existing:
|
||||
return jsonify({
|
||||
"error": f"That source is already mirrored as '{existing[0].get('name', 'another playlist')}'"
|
||||
}), 409
|
||||
|
||||
ok = database.update_mirrored_playlist_source_ref(
|
||||
playlist_id,
|
||||
normalized.source_playlist_id,
|
||||
normalized.description,
|
||||
)
|
||||
if not ok:
|
||||
return jsonify({"error": "Failed to update source reference"}), 500
|
||||
|
||||
updated = database.get_mirrored_playlist(playlist_id) or {}
|
||||
return jsonify({"success": True, "playlist": updated})
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating mirrored playlist source reference: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/mirrored-playlists/<int:playlist_id>', methods=['DELETE'])
|
||||
def delete_mirrored_playlist_endpoint(playlist_id):
|
||||
"""Delete a mirrored playlist."""
|
||||
|
|
|
|||
|
|
@ -446,6 +446,7 @@ function renderMirroredCard(p, container) {
|
|||
const hash = `mirrored_${p.id}`;
|
||||
const state = youtubePlaylistStates[hash];
|
||||
const phase = state ? state.phase : null;
|
||||
const sourceRef = getMirroredSourceRef(p);
|
||||
|
||||
// Build phase indicator
|
||||
let phaseHtml = '';
|
||||
|
|
@ -493,6 +494,7 @@ 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-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', () => {
|
||||
|
|
@ -532,6 +534,48 @@ function renderMirroredCard(p, container) {
|
|||
container.appendChild(card);
|
||||
}
|
||||
|
||||
function getMirroredSourceRef(p) {
|
||||
const desc = (p && p.description) ? String(p.description).trim() : '';
|
||||
if ((p.source === 'spotify_public' || p.source === 'youtube') && /^https?:\/\//i.test(desc)) {
|
||||
return desc;
|
||||
}
|
||||
return (p && p.source_playlist_id) ? String(p.source_playlist_id) : '';
|
||||
}
|
||||
|
||||
async function editMirroredSourceRef(playlistId, name, source, currentRef) {
|
||||
const label = (source === 'spotify_public' || source === 'youtube')
|
||||
? 'original playlist URL'
|
||||
: 'original playlist ID or URL';
|
||||
const nextRef = window.prompt(`Update ${label} for "${name}"`, currentRef || '');
|
||||
if (nextRef === null) return;
|
||||
const trimmed = nextRef.trim();
|
||||
if (!trimmed) {
|
||||
showToast('Source link or ID is required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/mirrored-playlists/${playlistId}/source-ref`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source_ref: trimmed })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) {
|
||||
throw new Error(data.error || 'Failed to update source reference');
|
||||
}
|
||||
showToast(`Updated source for ${name}`, 'success');
|
||||
loadMirroredPlaylists();
|
||||
const openModal = document.getElementById('mirrored-track-modal');
|
||||
if (openModal) {
|
||||
closeMirroredModal();
|
||||
openMirroredPlaylistModal(playlistId);
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`Error: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateMirroredCardPhase(urlHash, phase) {
|
||||
// Update the state phase (updateYouTubeCardPhase skips this for mirrored playlists due to no cardElement)
|
||||
const state = youtubePlaylistStates[urlHash];
|
||||
|
|
@ -785,6 +829,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 +872,7 @@ 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-close" onclick="closeMirroredModal()">Close</button>
|
||||
<button class="mirrored-btn-discover" onclick="discoverMirroredPlaylist(${playlistId})">Discover</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12047,7 +12047,9 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.mirrored-card-delete {
|
||||
.mirrored-card-delete,
|
||||
.mirrored-card-clear,
|
||||
.mirrored-card-link {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #555;
|
||||
|
|
@ -12065,7 +12067,9 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
opacity: 0;
|
||||
}
|
||||
|
||||
.mirrored-playlist-card:hover .mirrored-card-delete {
|
||||
.mirrored-playlist-card:hover .mirrored-card-delete,
|
||||
.mirrored-playlist-card:hover .mirrored-card-clear,
|
||||
.mirrored-playlist-card:hover .mirrored-card-link {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
|
@ -12076,28 +12080,6 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.mirrored-card-clear {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mirrored-playlist-card:hover .mirrored-card-clear {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mirrored-card-clear:hover {
|
||||
color: #a78bfa;
|
||||
background: rgba(167, 139, 250, 0.15);
|
||||
|
|
@ -12105,6 +12087,13 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.mirrored-card-link:hover {
|
||||
color: #64b5f6;
|
||||
background: rgba(100, 181, 246, 0.15);
|
||||
border-color: rgba(100, 181, 246, 0.3);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.discovery-ratio {
|
||||
font-size: 0.8em;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
|
|
|
|||
Loading…
Reference in a new issue