Expose mirrored playlist source-ref health
Return normalized source_ref metadata from mirrored playlist APIs so the UI no longer has to infer editable refresh links from description fields. Accept Spotify embed URLs during source-ref repair and add coverage for source-ref health reporting.
This commit is contained in:
parent
73bd2db547
commit
547e499121
4 changed files with 83 additions and 2 deletions
|
|
@ -13,7 +13,7 @@ from __future__ import annotations
|
||||||
import hashlib
|
import hashlib
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Mapping, Optional
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -26,6 +26,14 @@ class MirroredSourceRef:
|
||||||
description: Optional[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(
|
def normalize_mirrored_source_ref(
|
||||||
source: str,
|
source: str,
|
||||||
source_ref: str,
|
source_ref: str,
|
||||||
|
|
@ -75,6 +83,29 @@ def require_refresh_url(source: str, description: str, playlist_name: str = "")
|
||||||
return description
|
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:
|
def _canonical_spotify_url(source_ref: str) -> str:
|
||||||
parsed = _parse_spotify_ref(source_ref)
|
parsed = _parse_spotify_ref(source_ref)
|
||||||
if parsed:
|
if parsed:
|
||||||
|
|
@ -94,7 +125,7 @@ def _parse_spotify_ref(source_ref: str) -> Optional[dict]:
|
||||||
return {"type": uri_match.group(1), "id": uri_match.group(2)}
|
return {"type": uri_match.group(1), "id": uri_match.group(2)}
|
||||||
|
|
||||||
url_match = re.search(
|
url_match = re.search(
|
||||||
r"https?://open\.spotify\.com/(playlist|album)/([A-Za-z0-9]+)",
|
r"https?://open\.spotify\.com/(?:embed/)?(playlist|album)/([A-Za-z0-9]+)",
|
||||||
source_ref,
|
source_ref,
|
||||||
)
|
)
|
||||||
if url_match:
|
if url_match:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.playlists.source_refs import (
|
from core.playlists.source_refs import (
|
||||||
|
describe_mirrored_source_ref,
|
||||||
normalize_mirrored_source_ref,
|
normalize_mirrored_source_ref,
|
||||||
require_refresh_url,
|
require_refresh_url,
|
||||||
)
|
)
|
||||||
|
|
@ -23,6 +24,16 @@ def test_spotify_public_raw_id_defaults_to_playlist_url():
|
||||||
assert out.description == "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M"
|
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():
|
def test_youtube_url_stores_hash_and_canonical_url():
|
||||||
out = normalize_mirrored_source_ref(
|
out = normalize_mirrored_source_ref(
|
||||||
"youtube",
|
"youtube",
|
||||||
|
|
@ -43,3 +54,29 @@ def test_direct_id_sources_preserve_existing_description():
|
||||||
def test_hash_backed_refresh_requires_url():
|
def test_hash_backed_refresh_requires_url():
|
||||||
with pytest.raises(ValueError, match="missing its original source URL"):
|
with pytest.raises(ValueError, match="missing its original source URL"):
|
||||||
require_refresh_url("spotify_public", "", "Release Radar")
|
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"
|
||||||
|
|
|
||||||
|
|
@ -32183,6 +32183,7 @@ def mirror_playlist_endpoint():
|
||||||
def get_mirrored_playlists_endpoint():
|
def get_mirrored_playlists_endpoint():
|
||||||
"""List all mirrored playlists for the active profile."""
|
"""List all mirrored playlists for the active profile."""
|
||||||
try:
|
try:
|
||||||
|
from core.playlists.source_refs import describe_mirrored_source_ref
|
||||||
database = get_database()
|
database = get_database()
|
||||||
profile_id = get_current_profile_id()
|
profile_id = get_current_profile_id()
|
||||||
playlists = database.get_mirrored_playlists(profile_id=profile_id)
|
playlists = database.get_mirrored_playlists(profile_id=profile_id)
|
||||||
|
|
@ -32192,6 +32193,11 @@ def get_mirrored_playlists_endpoint():
|
||||||
pl['total_count'] = counts['total']
|
pl['total_count'] = counts['total']
|
||||||
pl['wishlisted_count'] = counts['wishlisted']
|
pl['wishlisted_count'] = counts['wishlisted']
|
||||||
pl['in_library_count'] = counts['in_library']
|
pl['in_library_count'] = counts['in_library']
|
||||||
|
source_ref = describe_mirrored_source_ref(pl)
|
||||||
|
pl['source_ref'] = source_ref.source_ref
|
||||||
|
pl['source_ref_kind'] = source_ref.source_ref_kind
|
||||||
|
pl['source_ref_status'] = source_ref.source_ref_status
|
||||||
|
pl['source_ref_error'] = source_ref.source_ref_error
|
||||||
return jsonify(playlists)
|
return jsonify(playlists)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting mirrored playlists: {e}")
|
logger.error(f"Error getting mirrored playlists: {e}")
|
||||||
|
|
@ -32201,10 +32207,16 @@ def get_mirrored_playlists_endpoint():
|
||||||
def get_mirrored_playlist_endpoint(playlist_id):
|
def get_mirrored_playlist_endpoint(playlist_id):
|
||||||
"""Get a mirrored playlist with its tracks."""
|
"""Get a mirrored playlist with its tracks."""
|
||||||
try:
|
try:
|
||||||
|
from core.playlists.source_refs import describe_mirrored_source_ref
|
||||||
database = get_database()
|
database = get_database()
|
||||||
playlist = database.get_mirrored_playlist(playlist_id)
|
playlist = database.get_mirrored_playlist(playlist_id)
|
||||||
if not playlist:
|
if not playlist:
|
||||||
return jsonify({"error": "Playlist not found"}), 404
|
return jsonify({"error": "Playlist not found"}), 404
|
||||||
|
source_ref = describe_mirrored_source_ref(playlist)
|
||||||
|
playlist['source_ref'] = source_ref.source_ref
|
||||||
|
playlist['source_ref_kind'] = source_ref.source_ref_kind
|
||||||
|
playlist['source_ref_status'] = source_ref.source_ref_status
|
||||||
|
playlist['source_ref_error'] = source_ref.source_ref_error
|
||||||
playlist['tracks'] = database.get_mirrored_playlist_tracks(playlist_id)
|
playlist['tracks'] = database.get_mirrored_playlist_tracks(playlist_id)
|
||||||
return jsonify(playlist)
|
return jsonify(playlist)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -535,6 +535,7 @@ function renderMirroredCard(p, container) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMirroredSourceRef(p) {
|
function getMirroredSourceRef(p) {
|
||||||
|
if (p && p.source_ref) return String(p.source_ref);
|
||||||
const desc = (p && p.description) ? String(p.description).trim() : '';
|
const desc = (p && p.description) ? String(p.description).trim() : '';
|
||||||
if ((p.source === 'spotify_public' || p.source === 'youtube') && /^https?:\/\//i.test(desc)) {
|
if ((p.source === 'spotify_public' || p.source === 'youtube') && /^https?:\/\//i.test(desc)) {
|
||||||
return desc;
|
return desc;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue