commit
e37acf2463
109 changed files with 16765 additions and 1836 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.5.3)'
|
||||
description: 'Version tag (e.g. 2.5.4)'
|
||||
required: true
|
||||
default: '2.5.3'
|
||||
default: '2.5.4'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from enum import Enum
|
|||
from utils.logging_config import get_logger
|
||||
from core.acoustid_client import AcoustIDClient
|
||||
from core.matching_engine import MusicMatchingEngine
|
||||
from core.matching.version_mismatch import is_acceptable_version_mismatch
|
||||
from core.musicbrainz_client import MusicBrainzClient
|
||||
|
||||
logger = get_logger("acoustid.verification")
|
||||
|
|
@ -485,12 +486,33 @@ class AcoustIDVerification:
|
|||
expected_version = _detect_title_version(expected_track_name)
|
||||
matched_version = _detect_title_version(matched_title)
|
||||
if expected_version != matched_version:
|
||||
msg = (
|
||||
f"Version mismatch: expected '{expected_track_name}' ({expected_version}) "
|
||||
f"but file is '{matched_title}' ({matched_version})"
|
||||
)
|
||||
logger.warning(f"AcoustID verification FAILED (version mismatch) - {msg}")
|
||||
return VerificationResult.FAIL, msg
|
||||
# Issue #607 (AfonsoG6): MusicBrainz often stores live
|
||||
# recordings with bare titles ("Clarity") while the
|
||||
# release entry carries the venue annotation ("Clarity
|
||||
# (Live at Blossom Music Center, ...)"). The fingerprint
|
||||
# correctly identifies the LIVE recording; only the
|
||||
# title text is bare. Helper accepts the one-sided bare
|
||||
# case when fingerprint + bare-title + artist all agree.
|
||||
# Two-sided version mismatches (live vs remix etc) stay
|
||||
# strict — those are genuinely different recordings.
|
||||
if is_acceptable_version_mismatch(
|
||||
expected_version, matched_version,
|
||||
fingerprint_score=best_score,
|
||||
title_similarity=title_sim,
|
||||
artist_similarity=artist_sim,
|
||||
):
|
||||
logger.info(
|
||||
f"AcoustID version annotation differs (expected={expected_version}, "
|
||||
f"matched={matched_version}) but fingerprint+title+artist all match — "
|
||||
f"accepting (likely MB metadata gap on a live/version-annotated recording)"
|
||||
)
|
||||
else:
|
||||
msg = (
|
||||
f"Version mismatch: expected '{expected_track_name}' ({expected_version}) "
|
||||
f"but file is '{matched_title}' ({matched_version})"
|
||||
)
|
||||
logger.warning(f"AcoustID verification FAILED (version mismatch) - {msg}")
|
||||
return VerificationResult.FAIL, msg
|
||||
|
||||
# Step 5: Decide pass/fail based on similarity
|
||||
if title_sim >= TITLE_MATCH_THRESHOLD and artist_sim >= ARTIST_MATCH_THRESHOLD:
|
||||
|
|
@ -593,12 +615,19 @@ class AcoustIDVerification:
|
|||
# downloading Mr. Morale: three tracks (Rich Interlude, Savior
|
||||
# Interlude, Savior) all received the wrong R.O.T.C audio file
|
||||
# because of this leak.
|
||||
top = recordings[0]
|
||||
top_title = top.get('title', '?') or ''
|
||||
top_artist = top.get('artist', '?') or ''
|
||||
# Use the BEST matching recording's strings here (not
|
||||
# `recordings[0]`) so the failure message reports the same
|
||||
# candidate the title/artist similarity scores came from.
|
||||
# Issue #607 (AfonsoG6) example 1: the prior code mixed
|
||||
# `recordings[0]`'s strings (which can be empty) with
|
||||
# `best_rec`'s scores, producing nonsense reasons like
|
||||
# "file identified as '' by '' (artist=100%)" when a later
|
||||
# recording in the list scored well on artist.
|
||||
display_title = matched_title or '?'
|
||||
display_artist = matched_artist or '?'
|
||||
has_non_ascii = (
|
||||
any(ord(c) > 127 for c in (expected_track_name or ''))
|
||||
or any(ord(c) > 127 for c in top_title)
|
||||
or any(ord(c) > 127 for c in display_title)
|
||||
)
|
||||
language_script_skip = (
|
||||
best_score >= 0.95
|
||||
|
|
@ -618,18 +647,16 @@ class AcoustIDVerification:
|
|||
)
|
||||
msg = (
|
||||
f"Title/artist mismatch but fingerprint confidence very high ({best_score:.2f}): "
|
||||
f"AcoustID='{top_title}' by '{top_artist}', "
|
||||
f"AcoustID='{display_title}' by '{display_artist}', "
|
||||
f"expected '{expected_track_name}' by '{expected_artist_name}' — "
|
||||
f"{reason}"
|
||||
)
|
||||
logger.info(f"AcoustID verification SKIPPED (high confidence) - {msg}")
|
||||
return VerificationResult.SKIP, msg
|
||||
|
||||
# Low fingerprint score + no metadata match — file is likely wrong
|
||||
# `top`, `top_title`, `top_artist` already resolved above for the
|
||||
# skip-eligibility check.
|
||||
# Low fingerprint score + no metadata match — file is likely wrong.
|
||||
msg = (
|
||||
f"Audio mismatch: file identified as '{top_title}' by '{top_artist}', "
|
||||
f"Audio mismatch: file identified as '{display_title}' by '{display_artist}', "
|
||||
f"expected '{expected_track_name}' by '{expected_artist_name}' "
|
||||
f"(title={title_sim:.0%}, artist={artist_sim:.0%})"
|
||||
)
|
||||
|
|
|
|||
773
core/amazon_client.py
Normal file
773
core/amazon_client.py
Normal file
|
|
@ -0,0 +1,773 @@
|
|||
"""Amazon Music metadata client backed by a T2Tunes proxy instance.
|
||||
|
||||
T2Tunes exposes the Amazon Music catalog (search, album metadata, stream info)
|
||||
through a simple REST API. This module wraps those endpoints and normalises the
|
||||
responses into the same Track / Artist / Album dataclass shape used by
|
||||
DeezerClient and iTunesClient.
|
||||
|
||||
Endpoints used:
|
||||
GET /api/status
|
||||
GET /api/amazon-music/search
|
||||
GET /api/amazon-music/metadata
|
||||
GET /api/amazon-music/media-from-asin
|
||||
|
||||
Config keys (all optional — fall back to public defaults):
|
||||
amazon.base_url T2Tunes instance URL (default: https://t2tunes.site)
|
||||
amazon.country ISO-3166 country code (default: US)
|
||||
amazon.preferred_codec Preferred audio codec (default: flac)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
|
||||
from config.settings import config_manager
|
||||
from core.api_call_tracker import api_call_tracker
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("amazon_client")
|
||||
|
||||
# Strips featuring credits like "Artist feat. X", "Artist ft. Y" so artist
|
||||
# deduplication works on the primary artist name only.
|
||||
_FEAT_RE = re.compile(r'\s+(?:feat(?:uring)?\.?|ft\.?)\s+.*', re.IGNORECASE)
|
||||
|
||||
# Strips the Explicit marker — explicit is treated as the default version.
|
||||
# Clean/Edited/Censored stay in the name so users can distinguish them.
|
||||
_EDITION_RE = re.compile(r'\s*[\[\(]explicit[\]\)]', re.IGNORECASE)
|
||||
|
||||
|
||||
def _primary_artist(name: str) -> str:
|
||||
return _FEAT_RE.sub('', name).strip()
|
||||
|
||||
|
||||
def _strip_edition(name: str) -> str:
|
||||
return _EDITION_RE.sub('', name).strip()
|
||||
|
||||
|
||||
def _unslugify(name: str) -> str:
|
||||
"""Convert a slug-form artist ID (e.g. 'kendrick_lamar') to a search name."""
|
||||
return name.replace('_', ' ')
|
||||
|
||||
|
||||
DEFAULT_BASE_URL = "https://t2tunes.site"
|
||||
DEFAULT_COUNTRY = "US"
|
||||
DEFAULT_CODEC = "flac"
|
||||
MIN_API_INTERVAL = 0.5 # seconds — T2Tunes has no published rate limit
|
||||
|
||||
_last_api_call: float = 0.0
|
||||
_api_call_lock = threading.Lock()
|
||||
|
||||
_META_CACHE_TTL = 300 # seconds
|
||||
_meta_cache: Dict[str, tuple] = {} # asin -> (fetched_at, meta_dict)
|
||||
_meta_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
class AmazonClientError(RuntimeError):
|
||||
"""Raised on unrecoverable T2Tunes API errors."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dataclasses — field layout matches DeezerClient / iTunesClient exactly
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class Track:
|
||||
id: str # Amazon track ASIN
|
||||
name: str
|
||||
artists: List[str]
|
||||
album: str
|
||||
duration_ms: int
|
||||
popularity: int
|
||||
preview_url: Optional[str] = None
|
||||
external_urls: Optional[Dict[str, str]] = None
|
||||
image_url: Optional[str] = None
|
||||
release_date: Optional[str] = None
|
||||
track_number: Optional[int] = None
|
||||
disc_number: Optional[int] = None
|
||||
album_type: Optional[str] = None
|
||||
total_tracks: Optional[int] = None
|
||||
isrc: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def from_search_hit(cls, doc: Dict[str, Any]) -> "Track":
|
||||
return cls(
|
||||
id=str(doc.get("asin") or ""),
|
||||
name=str(doc.get("title") or ""),
|
||||
artists=[str(doc.get("artistName") or "Unknown Artist")],
|
||||
album=str(doc.get("albumName") or ""),
|
||||
duration_ms=int(doc.get("duration") or 0) * 1000,
|
||||
popularity=0,
|
||||
isrc=str(doc.get("isrc") or "") or None,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_stream_info(
|
||||
cls,
|
||||
stream: "T2TunesStreamInfo",
|
||||
album_meta: Optional[Dict[str, Any]] = None,
|
||||
) -> "Track":
|
||||
album = album_meta or {}
|
||||
return cls(
|
||||
id=stream.asin,
|
||||
name=stream.title,
|
||||
artists=[stream.artist] if stream.artist else ["Unknown Artist"],
|
||||
album=stream.album,
|
||||
duration_ms=0,
|
||||
popularity=0,
|
||||
image_url=album.get("image"),
|
||||
release_date=album.get("release_date"),
|
||||
total_tracks=album.get("trackCount"),
|
||||
isrc=stream.isrc or None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Artist:
|
||||
id: str # Slugified artist name — T2Tunes exposes no artist IDs
|
||||
name: str
|
||||
popularity: int
|
||||
genres: List[str]
|
||||
followers: int
|
||||
image_url: Optional[str] = None
|
||||
external_urls: Optional[Dict[str, str]] = None
|
||||
|
||||
@classmethod
|
||||
def from_name(cls, name: str) -> "Artist":
|
||||
slug = name.lower().replace(" ", "_")
|
||||
return cls(id=slug, name=name, popularity=0, genres=[], followers=0)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Album:
|
||||
id: str # Amazon album ASIN
|
||||
name: str
|
||||
artists: List[str]
|
||||
release_date: str
|
||||
total_tracks: int
|
||||
album_type: str
|
||||
image_url: Optional[str] = None
|
||||
external_urls: Optional[Dict[str, str]] = None
|
||||
explicit: Optional[bool] = None
|
||||
|
||||
@classmethod
|
||||
def from_search_hit(cls, doc: Dict[str, Any]) -> "Album":
|
||||
return cls(
|
||||
id=str(doc.get("albumAsin") or doc.get("asin") or ""),
|
||||
name=str(doc.get("albumName") or doc.get("title") or ""),
|
||||
artists=[str(doc.get("artistName") or "Unknown Artist")],
|
||||
release_date="",
|
||||
total_tracks=0,
|
||||
album_type="album",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_metadata(cls, album_meta: Dict[str, Any], asin: str = "") -> "Album":
|
||||
return cls(
|
||||
id=str(album_meta.get("asin") or asin or ""),
|
||||
name=str(album_meta.get("title") or ""),
|
||||
artists=[str(album_meta.get("artistName") or "Unknown Artist")],
|
||||
release_date=str(album_meta.get("release_date") or album_meta.get("releaseDate") or ""),
|
||||
total_tracks=int(album_meta.get("trackCount") or 0),
|
||||
album_type="album",
|
||||
image_url=album_meta.get("image"),
|
||||
explicit=album_meta.get("explicit"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal dataclasses for raw T2Tunes response parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class T2TunesSearchItem:
|
||||
asin: str
|
||||
title: str
|
||||
artist_name: str
|
||||
item_type: str
|
||||
album_name: str = ""
|
||||
album_asin: str = ""
|
||||
duration_seconds: int = 0
|
||||
isrc: str = ""
|
||||
|
||||
@property
|
||||
def is_album(self) -> bool:
|
||||
return "album" in self.item_type.lower()
|
||||
|
||||
@property
|
||||
def is_track(self) -> bool:
|
||||
return "track" in self.item_type.lower()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class T2TunesStreamInfo:
|
||||
asin: str
|
||||
streamable: bool
|
||||
codec: str
|
||||
format: str
|
||||
sample_rate: Optional[int]
|
||||
stream_url: str
|
||||
decryption_key: Optional[str] # hex-encoded AES key; None when stream is clear
|
||||
title: str = ""
|
||||
artist: str = ""
|
||||
album: str = ""
|
||||
isrc: str = ""
|
||||
cover_url: str = ""
|
||||
track_number: Optional[int] = None
|
||||
disc_number: Optional[int] = None
|
||||
genre: str = ""
|
||||
label: str = ""
|
||||
date: str = ""
|
||||
|
||||
@property
|
||||
def has_decryption_key(self) -> bool:
|
||||
return bool(self.decryption_key)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rate-limit enforcement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _rate_limit() -> None:
|
||||
global _last_api_call
|
||||
with _api_call_lock:
|
||||
elapsed = time.monotonic() - _last_api_call
|
||||
if elapsed < MIN_API_INTERVAL:
|
||||
time.sleep(MIN_API_INTERVAL - elapsed)
|
||||
_last_api_call = time.monotonic()
|
||||
api_call_tracker.record_call("amazon")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AmazonClient:
|
||||
"""T2Tunes-backed Amazon Music metadata and stream-info client."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
country: Optional[str] = None,
|
||||
preferred_codec: Optional[str] = None,
|
||||
timeout: int = 30,
|
||||
session: Optional[Any] = None,
|
||||
) -> None:
|
||||
self.base_url = (base_url or config_manager.get("amazon.base_url", DEFAULT_BASE_URL)).rstrip("/")
|
||||
self.country = (country or config_manager.get("amazon.country", DEFAULT_COUNTRY)).upper()
|
||||
self.preferred_codec = (
|
||||
preferred_codec or config_manager.get("amazon.preferred_codec", DEFAULT_CODEC)
|
||||
).lower()
|
||||
self.timeout = timeout
|
||||
self.session: Any = session or requests.Session()
|
||||
if isinstance(self.session, requests.Session):
|
||||
self.session.headers.update({
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "SoulSync/1.0",
|
||||
"Referer": self.base_url,
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def reload_config(self) -> None:
|
||||
self.base_url = config_manager.get("amazon.base_url", DEFAULT_BASE_URL).rstrip("/")
|
||||
self.country = config_manager.get("amazon.country", DEFAULT_COUNTRY).upper()
|
||||
self.preferred_codec = config_manager.get("amazon.preferred_codec", DEFAULT_CODEC).lower()
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
"""Return True when the T2Tunes instance reports Amazon Music as up."""
|
||||
try:
|
||||
return str(self.status().get("amazonMusic", "")).lower() == "up"
|
||||
except AmazonClientError:
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Low-level API wrappers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def status(self) -> Dict[str, Any]:
|
||||
return self._get_json("/api/status")
|
||||
|
||||
def search_raw(self, query: str, *, types: str = "track,album") -> List[T2TunesSearchItem]:
|
||||
data = self._get_json(
|
||||
"/api/amazon-music/search",
|
||||
params={"query": query, "types": types, "country": self.country},
|
||||
)
|
||||
return list(self._iter_search_items(data))
|
||||
|
||||
def album_metadata(self, asin: str) -> Dict[str, Any]:
|
||||
return self._get_json(
|
||||
"/api/amazon-music/metadata",
|
||||
params={"asin": asin, "country": self.country},
|
||||
)
|
||||
|
||||
def media_from_asin(self, asin: str, codec: Optional[str] = None) -> List[T2TunesStreamInfo]:
|
||||
effective_codec = (codec or self.preferred_codec).lower()
|
||||
data = self._get_json(
|
||||
"/api/amazon-music/media-from-asin",
|
||||
params={"asin": asin, "country": self.country, "codec": effective_codec},
|
||||
)
|
||||
if isinstance(data, list):
|
||||
return [self._parse_stream_info(item) for item in data if isinstance(item, dict)]
|
||||
if isinstance(data, dict):
|
||||
return [self._parse_stream_info(data)]
|
||||
raise AmazonClientError(f"Unexpected media-from-asin response type: {type(data).__name__}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Metadata interface — mirrors DeezerClient / iTunesClient signatures
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def search_tracks(self, query: str, limit: int = 20) -> List[Track]:
|
||||
_rate_limit()
|
||||
items = self.search_raw(query, types="track,album")
|
||||
track_pairs: List[tuple] = [] # (Track, album_asin)
|
||||
seen_album_asins: List[str] = []
|
||||
for item in items:
|
||||
if not item.is_track:
|
||||
continue
|
||||
track = Track.from_search_hit({
|
||||
"asin": item.asin,
|
||||
"title": _strip_edition(item.title),
|
||||
"artistName": _primary_artist(item.artist_name),
|
||||
"albumName": _strip_edition(item.album_name),
|
||||
"albumAsin": item.album_asin,
|
||||
"duration": item.duration_seconds,
|
||||
"isrc": item.isrc,
|
||||
})
|
||||
track_pairs.append((track, item.album_asin))
|
||||
if item.album_asin and item.album_asin not in seen_album_asins:
|
||||
seen_album_asins.append(item.album_asin)
|
||||
if len(track_pairs) >= limit:
|
||||
break
|
||||
album_metas = self._fetch_album_metas(seen_album_asins[:5])
|
||||
tracks: List[Track] = []
|
||||
for track, album_asin in track_pairs:
|
||||
if album_asin and album_asin in album_metas:
|
||||
meta = album_metas[album_asin]
|
||||
track.image_url = meta.get("image")
|
||||
track.release_date = str(meta.get("release_date") or meta.get("releaseDate") or "")
|
||||
track.total_tracks = meta.get("trackCount")
|
||||
tracks.append(track)
|
||||
return tracks
|
||||
|
||||
def search_artists(self, query: str, limit: int = 20) -> List[Artist]:
|
||||
_rate_limit()
|
||||
items = self.search_raw(query, types="track,album")
|
||||
seen: Dict[str, Artist] = {}
|
||||
artist_album_asin: Dict[str, str] = {} # artist name → first album ASIN seen
|
||||
for item in items:
|
||||
name = _primary_artist(item.artist_name)
|
||||
if not name:
|
||||
continue
|
||||
if name not in seen:
|
||||
seen[name] = Artist.from_name(name)
|
||||
if name not in artist_album_asin and item.album_asin:
|
||||
artist_album_asin[name] = item.album_asin
|
||||
if len(seen) >= limit:
|
||||
break
|
||||
# T2Tunes has no artist images — use an album cover as stand-in.
|
||||
unique_asins = list({v for v in artist_album_asin.values()})[:5]
|
||||
album_metas = self._fetch_album_metas(unique_asins)
|
||||
for name, artist in seen.items():
|
||||
asin = artist_album_asin.get(name)
|
||||
if asin and asin in album_metas:
|
||||
artist.image_url = album_metas[asin].get("image")
|
||||
return list(seen.values())
|
||||
|
||||
def search_albums(self, query: str, limit: int = 20) -> List[Album]:
|
||||
_rate_limit()
|
||||
items = self.search_raw(query, types="track,album")
|
||||
album_candidates: List[tuple] = [] # (Album, asin)
|
||||
seen_keys: set = set()
|
||||
for item in items:
|
||||
if not item.is_album:
|
||||
continue
|
||||
album_asin = item.album_asin or item.asin
|
||||
raw_name = item.album_name or item.title
|
||||
display_name = _strip_edition(raw_name)
|
||||
artist = _primary_artist(item.artist_name)
|
||||
# Collapse Explicit/Clean variants: same normalised name + artist = same album
|
||||
dedup_key = (display_name.lower(), artist.lower())
|
||||
if dedup_key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(dedup_key)
|
||||
album = Album.from_search_hit({
|
||||
"albumAsin": album_asin,
|
||||
"albumName": display_name,
|
||||
"artistName": artist,
|
||||
})
|
||||
album_candidates.append((album, album_asin))
|
||||
if len(album_candidates) >= limit:
|
||||
break
|
||||
album_metas = self._fetch_album_metas([a for _, a in album_candidates[:10]])
|
||||
albums: List[Album] = []
|
||||
for album, asin in album_candidates:
|
||||
if asin in album_metas:
|
||||
meta = album_metas[asin]
|
||||
album.image_url = meta.get("image")
|
||||
album.release_date = str(meta.get("release_date") or meta.get("releaseDate") or "")
|
||||
album.total_tracks = int(meta.get("trackCount") or 0)
|
||||
albums.append(album)
|
||||
return albums
|
||||
|
||||
def get_track_details(self, asin: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return a Spotify-compatible dict for a single track ASIN."""
|
||||
_rate_limit()
|
||||
try:
|
||||
streams = self.media_from_asin(asin)
|
||||
except AmazonClientError:
|
||||
return None
|
||||
if not streams:
|
||||
return None
|
||||
s = streams[0]
|
||||
|
||||
album_data: Dict[str, Any] = {}
|
||||
try:
|
||||
meta = self.album_metadata(asin)
|
||||
albums = meta.get("albumList")
|
||||
if isinstance(albums, list) and albums and isinstance(albums[0], dict):
|
||||
album_data = albums[0]
|
||||
except AmazonClientError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"id": s.asin,
|
||||
"name": _strip_edition(s.title),
|
||||
"artists": [{"name": _primary_artist(s.artist), "id": ""}],
|
||||
"album": {
|
||||
"id": album_data.get("asin", ""),
|
||||
"name": _strip_edition(s.album),
|
||||
"images": [{"url": album_data["image"]}] if album_data.get("image") else [],
|
||||
"release_date": album_data.get("release_date") or album_data.get("releaseDate") or s.date or "",
|
||||
"total_tracks": album_data.get("trackCount", 0),
|
||||
},
|
||||
"duration_ms": 0,
|
||||
"popularity": 0,
|
||||
"external_urls": {"amazon": f"https://music.amazon.com/albums/{asin}"},
|
||||
"track_number": s.track_number,
|
||||
"disc_number": s.disc_number,
|
||||
"isrc": s.isrc,
|
||||
"is_album_track": True,
|
||||
"raw_data": {
|
||||
"codec": s.codec,
|
||||
"format": s.format,
|
||||
"sample_rate": s.sample_rate,
|
||||
"streamable": s.streamable,
|
||||
"has_decryption_key": s.has_decryption_key,
|
||||
},
|
||||
}
|
||||
|
||||
def get_album(self, asin: str, include_tracks: bool = True) -> Optional[Dict[str, Any]]:
|
||||
"""Return a Spotify-compatible album dict."""
|
||||
_rate_limit()
|
||||
try:
|
||||
meta = self.album_metadata(asin)
|
||||
except AmazonClientError:
|
||||
return None
|
||||
|
||||
albums = meta.get("albumList")
|
||||
if not isinstance(albums, list) or not albums:
|
||||
return None
|
||||
album = albums[0] if isinstance(albums[0], dict) else {}
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
"id": asin,
|
||||
"name": _strip_edition(album.get("title", "")),
|
||||
"artists": [{"name": _primary_artist(album.get("artistName", "")), "id": ""}],
|
||||
"release_date": album.get("release_date") or album.get("releaseDate") or "",
|
||||
"total_tracks": album.get("trackCount", 0),
|
||||
"album_type": "album",
|
||||
"images": [{"url": album["image"]}] if album.get("image") else [],
|
||||
"external_urls": {"amazon": f"https://music.amazon.com/albums/{asin}"},
|
||||
"label": album.get("label", ""),
|
||||
}
|
||||
if include_tracks:
|
||||
tracks_data = self.get_album_tracks(asin) or {
|
||||
"items": [], "total": 0, "limit": 50, "next": None,
|
||||
}
|
||||
result["tracks"] = tracks_data
|
||||
# Backfill release_date from stream tags when album metadata lacks it.
|
||||
if not result["release_date"]:
|
||||
items = tracks_data.get("items") or []
|
||||
for item in items:
|
||||
rd = item.get("release_date") or ""
|
||||
if rd and len(rd) >= 4:
|
||||
result["release_date"] = rd
|
||||
break
|
||||
return result
|
||||
|
||||
def get_album_tracks(self, asin: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return album tracks in Spotify pagination format."""
|
||||
_rate_limit()
|
||||
try:
|
||||
streams = self.media_from_asin(asin)
|
||||
except AmazonClientError:
|
||||
return None
|
||||
|
||||
# media_from_asin has no duration — enrich from search results which do.
|
||||
duration_map: Dict[str, int] = {} # track asin → duration_ms
|
||||
if streams:
|
||||
album_name = _strip_edition(streams[0].album)
|
||||
artist_name = _primary_artist(streams[0].artist)
|
||||
try:
|
||||
search_items = self.search_raw(
|
||||
f"{album_name} {artist_name}", types="track,album"
|
||||
)
|
||||
for item in search_items:
|
||||
if item.album_asin == asin and item.duration_seconds:
|
||||
duration_map[item.asin] = item.duration_seconds * 1000
|
||||
except Exception as exc:
|
||||
logger.debug("Duration backfill failed for ASIN %s: %s", asin, exc)
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": s.asin,
|
||||
"name": _strip_edition(s.title),
|
||||
"artists": [{"name": _primary_artist(s.artist), "id": ""}],
|
||||
"duration_ms": duration_map.get(s.asin, 0),
|
||||
"track_number": s.track_number,
|
||||
"disc_number": s.disc_number,
|
||||
"release_date": s.date or "",
|
||||
"isrc": s.isrc,
|
||||
}
|
||||
for s in streams
|
||||
]
|
||||
return {"items": items, "total": len(items), "limit": 50, "next": None}
|
||||
|
||||
def get_artist(self, artist_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return a Spotify-compatible artist dict inferred from search results."""
|
||||
_rate_limit()
|
||||
search_name = _unslugify(artist_name)
|
||||
try:
|
||||
items = self.search_raw(search_name, types="track,album")
|
||||
except AmazonClientError:
|
||||
return None
|
||||
name_lower = search_name.lower()
|
||||
match = next(
|
||||
(i for i in items if _primary_artist(i.artist_name).lower() == name_lower),
|
||||
next((i for i in items if name_lower in _primary_artist(i.artist_name).lower()), None),
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
return {
|
||||
"id": match.artist_name.lower().replace(" ", "_"),
|
||||
"name": match.artist_name,
|
||||
"genres": [],
|
||||
"popularity": 0,
|
||||
"followers": {"total": 0},
|
||||
"images": [],
|
||||
"external_urls": {},
|
||||
}
|
||||
|
||||
def get_artist_albums(
|
||||
self,
|
||||
artist_name: str,
|
||||
album_type: str = "album,single",
|
||||
limit: int = 200,
|
||||
) -> List[Album]:
|
||||
"""Return albums for an artist inferred from search results."""
|
||||
_rate_limit()
|
||||
search_name = _unslugify(artist_name)
|
||||
try:
|
||||
items = self.search_raw(f"{search_name} album", types="track,album")
|
||||
except AmazonClientError:
|
||||
return []
|
||||
album_candidates: List[tuple] = [] # (Album, asin)
|
||||
seen_asins: set = set()
|
||||
name_lower = search_name.lower()
|
||||
for item in items:
|
||||
if not item.is_album:
|
||||
continue
|
||||
if _primary_artist(item.artist_name).lower() != name_lower:
|
||||
continue
|
||||
album_asin = item.album_asin or item.asin
|
||||
if album_asin in seen_asins:
|
||||
continue
|
||||
seen_asins.add(album_asin)
|
||||
album = Album.from_search_hit({
|
||||
"albumAsin": album_asin,
|
||||
"albumName": _strip_edition(item.album_name or item.title),
|
||||
"artistName": _primary_artist(item.artist_name),
|
||||
})
|
||||
album_candidates.append((album, album_asin))
|
||||
if len(album_candidates) >= limit:
|
||||
break
|
||||
|
||||
# Fetch metadata for art, release_date, track_count, and type inference.
|
||||
# No explicit cap here — search_raw naturally bounds results to ~20 items,
|
||||
# so album_candidates is already small.
|
||||
asins_to_fetch = [a for _, a in album_candidates]
|
||||
metas = self._fetch_album_metas(asins_to_fetch)
|
||||
|
||||
albums: List[Album] = []
|
||||
for album, asin in album_candidates:
|
||||
meta = metas.get(asin, {})
|
||||
if meta:
|
||||
album.image_url = meta.get("image")
|
||||
album.release_date = str(meta.get("release_date") or meta.get("releaseDate") or "")
|
||||
total = int(meta.get("trackCount") or 0)
|
||||
album.total_tracks = total
|
||||
# T2Tunes doesn't expose release type — infer from track count.
|
||||
# 1-track releases are singles; keep default "album" otherwise.
|
||||
if total == 1:
|
||||
album.album_type = "single"
|
||||
albums.append(album)
|
||||
return albums
|
||||
|
||||
def get_track_features(self, track_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Not available from Amazon Music — returns None for compatibility."""
|
||||
return None
|
||||
|
||||
def _get_artist_image_from_albums(self, artist_id: str) -> Optional[str]:
|
||||
"""Return an album cover as artist image stand-in (T2Tunes has no artist images)."""
|
||||
search_name = _unslugify(artist_id)
|
||||
try:
|
||||
items = self.search_raw(search_name, types="track,album")
|
||||
except AmazonClientError:
|
||||
return None
|
||||
name_lower = search_name.lower()
|
||||
for item in items:
|
||||
if _primary_artist(item.artist_name).lower() != name_lower:
|
||||
continue
|
||||
asin = item.album_asin or item.asin
|
||||
if not asin:
|
||||
continue
|
||||
metas = self._fetch_album_metas([asin])
|
||||
if asin in metas and metas[asin].get("image"):
|
||||
return metas[asin]["image"]
|
||||
return None
|
||||
|
||||
# ==================== Interface Aliases (match DeezerClient method names) ====================
|
||||
get_album_metadata = get_album
|
||||
get_artist_info = get_artist
|
||||
get_artist_albums_list = get_artist_albums
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _fetch_album_metas(self, asins: List[str]) -> Dict[str, Dict[str, Any]]:
|
||||
"""Parallel-fetch album metadata for up to N ASINs. Returns {asin: albumList[0]}."""
|
||||
if not asins:
|
||||
return {}
|
||||
metas: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def _fetch(asin: str) -> None:
|
||||
now = time.monotonic()
|
||||
with _meta_cache_lock:
|
||||
entry = _meta_cache.get(asin)
|
||||
if entry and (now - entry[0]) < _META_CACHE_TTL:
|
||||
metas[asin] = entry[1]
|
||||
return
|
||||
_rate_limit()
|
||||
try:
|
||||
raw = self.album_metadata(asin)
|
||||
lst = raw.get("albumList")
|
||||
if isinstance(lst, list) and lst and isinstance(lst[0], dict):
|
||||
meta = lst[0]
|
||||
with _meta_cache_lock:
|
||||
_meta_cache[asin] = (time.monotonic(), meta)
|
||||
metas[asin] = meta
|
||||
except Exception as exc:
|
||||
logger.debug("Album metadata fetch failed for ASIN %s: %s", asin, exc)
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
with ThreadPoolExecutor(max_workers=min(len(asins), 5)) as pool:
|
||||
list(pool.map(_fetch, asins))
|
||||
return metas
|
||||
|
||||
def _get_json(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
||||
url = urljoin(f"{self.base_url}/", path.lstrip("/"))
|
||||
try:
|
||||
resp = self.session.get(url, params=params, timeout=self.timeout)
|
||||
resp.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
raise AmazonClientError(
|
||||
f"HTTP {exc.response.status_code} for {url}"
|
||||
) from exc
|
||||
except requests.RequestException as exc:
|
||||
raise AmazonClientError(f"Request failed for {url}: {exc}") from exc
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError as exc:
|
||||
preview = resp.text[:200].replace("\n", " ")
|
||||
raise AmazonClientError(f"Response not JSON for {url}: {preview!r}") from exc
|
||||
|
||||
@staticmethod
|
||||
def _iter_search_items(response: Any) -> Iterator[T2TunesSearchItem]:
|
||||
if not isinstance(response, dict):
|
||||
raise AmazonClientError(
|
||||
f"Unexpected search response type: {type(response).__name__}"
|
||||
)
|
||||
for result in response.get("results") or []:
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
for hit in result.get("hits") or []:
|
||||
if not isinstance(hit, dict):
|
||||
continue
|
||||
doc = hit.get("document")
|
||||
if not isinstance(doc, dict):
|
||||
continue
|
||||
asin = str(doc.get("asin") or "")
|
||||
if not asin:
|
||||
continue
|
||||
yield T2TunesSearchItem(
|
||||
asin=asin,
|
||||
title=str(doc.get("title") or ""),
|
||||
artist_name=str(doc.get("artistName") or ""),
|
||||
item_type=str(doc.get("__type") or ""),
|
||||
album_name=str(doc.get("albumName") or ""),
|
||||
album_asin=str(doc.get("albumAsin") or ""),
|
||||
duration_seconds=int(doc.get("duration") or 0),
|
||||
isrc=str(doc.get("isrc") or ""),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_stream_info(item: Dict[str, Any]) -> T2TunesStreamInfo:
|
||||
stream_info = item.get("streamInfo") if isinstance(item.get("streamInfo"), dict) else {}
|
||||
tags = item.get("tags") if isinstance(item.get("tags"), dict) else {}
|
||||
# T2Tunes API has a typo: "stremeable" in some responses
|
||||
streamable = item.get("streamable")
|
||||
if streamable is None:
|
||||
streamable = item.get("stremeable")
|
||||
raw_key = item.get("decryptionKey")
|
||||
decryption_key = str(raw_key) if raw_key else None
|
||||
|
||||
def _int_tag(key: str) -> Optional[int]:
|
||||
v = tags.get(key)
|
||||
try:
|
||||
return int(v) if v is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
return T2TunesStreamInfo(
|
||||
asin=str(item.get("asin") or ""),
|
||||
streamable=bool(streamable),
|
||||
codec=str(stream_info.get("codec") or ""),
|
||||
format=str(stream_info.get("format") or ""),
|
||||
sample_rate=(
|
||||
stream_info.get("sampleRate")
|
||||
if isinstance(stream_info.get("sampleRate"), int)
|
||||
else None
|
||||
),
|
||||
stream_url=str(stream_info.get("streamUrl") or ""),
|
||||
decryption_key=decryption_key,
|
||||
title=str(tags.get("title") or ""),
|
||||
artist=str(tags.get("artist") or ""),
|
||||
album=str(tags.get("album") or ""),
|
||||
isrc=str(tags.get("isrc") or ""),
|
||||
cover_url=str(item.get("coverUrl") or ""),
|
||||
track_number=_int_tag("trackNumber"),
|
||||
disc_number=_int_tag("discNumber"),
|
||||
genre=str(tags.get("genre") or ""),
|
||||
label=str(tags.get("label") or ""),
|
||||
date=str(tags.get("date") or ""),
|
||||
)
|
||||
452
core/amazon_download_client.py
Normal file
452
core/amazon_download_client.py
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
"""Amazon Music download source plugin backed by a T2Tunes proxy.
|
||||
|
||||
NOT yet wired into the app registry — validated in isolation only.
|
||||
See tests/tools/test_amazon_download_client.py.
|
||||
|
||||
Filename encoding (the DownloadSourcePlugin dispatch contract):
|
||||
"{asin}||{display_name}"
|
||||
e.g. "B09XYZ1234||Kendrick Lamar - Not Like Us"
|
||||
|
||||
Codec preference order: FLAC → Opus → EAC3.
|
||||
|
||||
Download flow (from Tubifarry reference implementation):
|
||||
1. GET stream_url → encrypted bytes on disk
|
||||
2. FFmpeg -decryption_key <hex> to decrypt in place
|
||||
3. Embed metadata tags (handled by the app's standard post-processing)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests as http_requests
|
||||
|
||||
from config.settings import config_manager
|
||||
from core.amazon_client import AmazonClient, AmazonClientError
|
||||
from core.download_plugins.base import DownloadSourcePlugin
|
||||
from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("amazon_download_client")
|
||||
|
||||
# Quality / codec helpers
|
||||
CODEC_PREFERENCE = ["flac", "opus", "eac3"]
|
||||
|
||||
_CODEC_EXTENSIONS: Dict[str, str] = {
|
||||
"flac": "flac",
|
||||
"ogg_vorbis": "ogg",
|
||||
"opus": "opus",
|
||||
"eac3": "eac3",
|
||||
"mp4": "m4a",
|
||||
"aac": "m4a",
|
||||
"mp3": "mp3",
|
||||
}
|
||||
|
||||
MIN_AUDIO_BYTES = 512 * 1024 # 512 KB — anything smaller is a broken stream
|
||||
|
||||
|
||||
def _codec_key(codec: str) -> str:
|
||||
return codec.lower().replace("-", "_").replace(" ", "_")
|
||||
|
||||
|
||||
def _file_extension(codec: str) -> str:
|
||||
return _CODEC_EXTENSIONS.get(_codec_key(codec), "bin")
|
||||
|
||||
|
||||
def _quality_label(codec: str, sample_rate: Optional[int] = None) -> str:
|
||||
ck = _codec_key(codec)
|
||||
if ck == "flac":
|
||||
if sample_rate and sample_rate > 48000:
|
||||
return "Hi-Res"
|
||||
return "Lossless"
|
||||
return "Lossy"
|
||||
|
||||
|
||||
class AmazonDownloadClient(DownloadSourcePlugin):
|
||||
"""DownloadSourcePlugin — Amazon Music via T2Tunes proxy."""
|
||||
|
||||
def __init__(self, download_path: Optional[str] = None) -> None:
|
||||
if download_path is None:
|
||||
download_path = config_manager.get("soulseek.download_path", "./downloads")
|
||||
self.download_path = Path(download_path)
|
||||
self.download_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._quality = config_manager.get("amazon_download.quality", "flac")
|
||||
self._allow_fallback = config_manager.get("amazon_download.allow_fallback", True)
|
||||
|
||||
self._client = AmazonClient(preferred_codec=self._quality)
|
||||
self.session = http_requests.Session()
|
||||
self.session.headers.update({
|
||||
"User-Agent": "SoulSync/1.0",
|
||||
"Accept": "*/*",
|
||||
})
|
||||
|
||||
self._engine: Any = None
|
||||
self.shutdown_check: Any = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DownloadSourcePlugin — lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_engine(self, engine) -> None:
|
||||
"""Engine callback — wires the central thread worker + state store."""
|
||||
self._engine = engine
|
||||
|
||||
def set_shutdown_check(self, check_callable) -> None:
|
||||
self.shutdown_check = check_callable
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
# T2Tunes has a public default instance; no credentials required.
|
||||
# Return True unconditionally so the source shows as available.
|
||||
return True
|
||||
|
||||
async def check_connection(self) -> bool:
|
||||
try:
|
||||
return self._client.is_authenticated()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DownloadSourcePlugin — search
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
timeout: Optional[int] = None,
|
||||
progress_callback: Any = None,
|
||||
) -> Tuple[List[TrackResult], List[AlbumResult]]:
|
||||
try:
|
||||
items = self._client.search_raw(query, types="track,album")
|
||||
except AmazonClientError as exc:
|
||||
logger.warning(f"Amazon search failed for {query!r}: {exc}")
|
||||
return [], []
|
||||
|
||||
track_results: List[TrackResult] = []
|
||||
album_map: Dict[str, AlbumResult] = {}
|
||||
album_order: List[str] = []
|
||||
preferred = self._client.preferred_codec
|
||||
|
||||
for item in items:
|
||||
quality = _quality_label(preferred)
|
||||
if item.is_track:
|
||||
track_results.append(TrackResult(
|
||||
username="amazon",
|
||||
filename=f"{item.asin}||{item.artist_name} - {item.title}",
|
||||
size=0,
|
||||
bitrate=None,
|
||||
duration=item.duration_seconds * 1000 if item.duration_seconds else None,
|
||||
quality=quality,
|
||||
free_upload_slots=999,
|
||||
upload_speed=999_999,
|
||||
queue_length=0,
|
||||
artist=item.artist_name,
|
||||
title=item.title,
|
||||
album=item.album_name,
|
||||
_source_metadata={
|
||||
"asin": item.asin,
|
||||
"album_asin": item.album_asin,
|
||||
"isrc": item.isrc,
|
||||
},
|
||||
))
|
||||
elif item.is_album:
|
||||
album_asin = item.album_asin or item.asin
|
||||
if album_asin not in album_map:
|
||||
placeholder = TrackResult(
|
||||
username="amazon",
|
||||
filename=f"{item.asin}||{item.artist_name} - {item.title}",
|
||||
size=0,
|
||||
bitrate=None,
|
||||
duration=None,
|
||||
quality=quality,
|
||||
free_upload_slots=999,
|
||||
upload_speed=999_999,
|
||||
queue_length=0,
|
||||
artist=item.artist_name,
|
||||
title=item.title,
|
||||
album=item.album_name,
|
||||
)
|
||||
album_map[album_asin] = AlbumResult(
|
||||
username="amazon",
|
||||
album_path=album_asin,
|
||||
album_title=item.album_name or item.title,
|
||||
artist=item.artist_name,
|
||||
track_count=0,
|
||||
total_size=0,
|
||||
tracks=[placeholder],
|
||||
dominant_quality=quality,
|
||||
)
|
||||
album_order.append(album_asin)
|
||||
|
||||
return track_results, [album_map[k] for k in album_order]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DownloadSourcePlugin — download dispatch
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def download(
|
||||
self,
|
||||
username: str,
|
||||
filename: str,
|
||||
file_size: int = 0,
|
||||
) -> Optional[str]:
|
||||
if "||" not in filename:
|
||||
logger.error(f"Invalid Amazon filename format (no '||'): {filename!r}")
|
||||
return None
|
||||
if self._engine is None:
|
||||
raise RuntimeError(
|
||||
"AmazonDownloadClient._engine is not set — "
|
||||
"client not connected to download infrastructure"
|
||||
)
|
||||
asin, display_name = filename.split("||", 1)
|
||||
asin = asin.strip()
|
||||
display_name = display_name.strip()
|
||||
return self._engine.worker.dispatch(
|
||||
source_name="amazon",
|
||||
target_id=asin,
|
||||
display_name=display_name,
|
||||
original_filename=filename,
|
||||
impl_callable=self._download_sync,
|
||||
)
|
||||
|
||||
def _download_sync(
|
||||
self,
|
||||
download_id: str,
|
||||
target_id: str,
|
||||
display_name: str,
|
||||
) -> Optional[str]:
|
||||
asin = str(target_id)
|
||||
codecs = CODEC_PREFERENCE if self._allow_fallback else [self._quality]
|
||||
for codec in codecs:
|
||||
try:
|
||||
streams = self._client.media_from_asin(asin, codec=codec)
|
||||
except AmazonClientError as exc:
|
||||
logger.warning(f"media_from_asin({asin!r}, {codec}) failed: {exc}")
|
||||
continue
|
||||
|
||||
stream = next(
|
||||
(s for s in streams if s.streamable and s.stream_url),
|
||||
None,
|
||||
)
|
||||
if not stream:
|
||||
logger.debug(f"No streamable result for {asin} at codec={codec}")
|
||||
continue
|
||||
|
||||
ext = _file_extension(stream.codec or codec)
|
||||
safe = "".join(
|
||||
ch if ch.isalnum() or ch in " -_." else "_"
|
||||
for ch in display_name
|
||||
)[:80]
|
||||
# T2Tunes always serves audio in an encrypted MP4 container.
|
||||
# The codec extension (.flac, .opus, .eac3) is only for the
|
||||
# final decrypted output.
|
||||
enc_ext = "mp4" if stream.decryption_key else ext
|
||||
enc_path = self._unique_path(self.download_path / f"{safe}.enc.{enc_ext}")
|
||||
out_path = self._unique_path(self.download_path / f"{safe}.{ext}")
|
||||
|
||||
if self._engine is not None:
|
||||
self._engine.update_record(
|
||||
"amazon", download_id, {"state": "downloading", "progress": 0.0}
|
||||
)
|
||||
try:
|
||||
downloaded = self._stream_to_file(stream.stream_url, enc_path, download_id)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Stream download failed for {asin} ({codec}): {exc}")
|
||||
enc_path.unlink(missing_ok=True)
|
||||
continue
|
||||
|
||||
if downloaded < MIN_AUDIO_BYTES:
|
||||
logger.warning(
|
||||
f"File too small ({downloaded} B) for {asin} at {codec} — trying next"
|
||||
)
|
||||
enc_path.unlink(missing_ok=True)
|
||||
continue
|
||||
|
||||
if stream.decryption_key:
|
||||
if self._engine is not None:
|
||||
self._engine.update_record(
|
||||
"amazon", download_id, {"state": "decrypting", "progress": 1.0}
|
||||
)
|
||||
try:
|
||||
self._decrypt_with_ffmpeg(enc_path, out_path, stream.decryption_key)
|
||||
enc_path.unlink(missing_ok=True)
|
||||
except Exception as exc:
|
||||
logger.error(f"Decryption failed for {asin} ({codec}): {exc}")
|
||||
enc_path.unlink(missing_ok=True)
|
||||
out_path.unlink(missing_ok=True)
|
||||
continue
|
||||
else:
|
||||
enc_path.rename(out_path)
|
||||
|
||||
final_size = out_path.stat().st_size
|
||||
logger.info(
|
||||
f"Amazon download complete ({codec}): {out_path} "
|
||||
f"({final_size / (1024 * 1024):.1f} MB)"
|
||||
)
|
||||
# Sync size == transferred so the download monitor's bytes-incomplete
|
||||
# guard doesn't block post-processing. The throttled updates in
|
||||
# _stream_to_file leave transferred < size after the last 0.5s tick;
|
||||
# other streaming clients avoid this by not tracking bytes at all
|
||||
# (size stays 0, the guard is skipped). Writing the final output size
|
||||
# here restores parity.
|
||||
if self._engine is not None:
|
||||
self._engine.update_record(
|
||||
"amazon", download_id, {'size': final_size, 'transferred': final_size}
|
||||
)
|
||||
return str(out_path)
|
||||
|
||||
logger.error(f"All codec tiers exhausted for '{display_name}' ({asin})")
|
||||
return None
|
||||
|
||||
def _decrypt_with_ffmpeg(
|
||||
self, enc_path: Path, out_path: Path, hex_key: str
|
||||
) -> None:
|
||||
"""Decrypt a T2Tunes encrypted audio file using FFmpeg -decryption_key.
|
||||
|
||||
T2Tunes uses CENC (Common Encryption) for DRM-protected tracks.
|
||||
FFmpeg supports decryption via the -decryption_key flag when the
|
||||
key is provided in hex.
|
||||
"""
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
if not ffmpeg:
|
||||
tools_dir = Path(__file__).parent.parent / "tools"
|
||||
candidate = tools_dir / ("ffmpeg.exe" if os.name == "nt" else "ffmpeg")
|
||||
if candidate.exists():
|
||||
ffmpeg = str(candidate)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"ffmpeg is required for Amazon Music decryption. "
|
||||
"Install ffmpeg and ensure it is on your PATH."
|
||||
)
|
||||
cmd = [
|
||||
ffmpeg,
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-decryption_key", hex_key,
|
||||
"-i", str(enc_path),
|
||||
"-map", "0:a:0", # extract first audio stream (FLAC/Opus/EAC3 inside MP4)
|
||||
"-c", "copy",
|
||||
str(out_path),
|
||||
]
|
||||
logger.debug(f"Decrypting {enc_path.name} → {out_path.name}")
|
||||
result = subprocess.run(cmd, capture_output=True)
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.decode("utf-8", errors="replace").strip()
|
||||
raise RuntimeError(f"FFmpeg decryption failed (exit {result.returncode}): {stderr}")
|
||||
|
||||
def _stream_to_file(self, url: str, out_path: Path, download_id: str) -> int:
|
||||
resp = self.session.get(url, stream=True, timeout=60)
|
||||
resp.raise_for_status()
|
||||
|
||||
total = int(resp.headers.get("content-length", 0))
|
||||
downloaded = 0
|
||||
last_report = time.monotonic()
|
||||
shutdown_triggered = False
|
||||
|
||||
with out_path.open("wb") as fh:
|
||||
for chunk in resp.iter_content(chunk_size=64 * 1024):
|
||||
if not chunk:
|
||||
continue
|
||||
if self.shutdown_check and self.shutdown_check():
|
||||
shutdown_triggered = True
|
||||
break
|
||||
fh.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
now = time.monotonic()
|
||||
if self._engine and now - last_report >= 0.5:
|
||||
self._engine.update_record(
|
||||
"amazon",
|
||||
download_id,
|
||||
{
|
||||
"transferred": downloaded,
|
||||
"size": total,
|
||||
"progress": downloaded / total if total else 0.0,
|
||||
},
|
||||
)
|
||||
last_report = now
|
||||
|
||||
if shutdown_triggered:
|
||||
out_path.unlink(missing_ok=True)
|
||||
raise RuntimeError("Shutdown requested mid-download")
|
||||
|
||||
return downloaded
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DownloadSourcePlugin — status interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_all_downloads(self) -> List[DownloadStatus]:
|
||||
if self._engine is None:
|
||||
return []
|
||||
return [
|
||||
self._record_to_status(record)
|
||||
for record in self._engine.iter_records_for_source('amazon')
|
||||
]
|
||||
|
||||
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
||||
if self._engine is None:
|
||||
return None
|
||||
record = self._engine.get_record('amazon', download_id)
|
||||
return self._record_to_status(record) if record is not None else None
|
||||
|
||||
async def cancel_download(
|
||||
self,
|
||||
download_id: str,
|
||||
username: Optional[str] = None,
|
||||
remove: bool = False,
|
||||
) -> bool:
|
||||
if self._engine is None:
|
||||
return False
|
||||
if self._engine.get_record('amazon', download_id) is None:
|
||||
return False
|
||||
self._engine.update_record('amazon', download_id, {'state': 'Cancelled'})
|
||||
if remove:
|
||||
self._engine.remove_record('amazon', download_id)
|
||||
return True
|
||||
|
||||
async def clear_all_completed_downloads(self) -> bool:
|
||||
if self._engine is None:
|
||||
return True
|
||||
terminal = {'Completed, Succeeded', 'Cancelled', 'Errored', 'Aborted'}
|
||||
for record in list(self._engine.iter_records_for_source('amazon')):
|
||||
if record.get('state') in terminal:
|
||||
self._engine.remove_record('amazon', record['id'])
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _unique_path(path: Path) -> Path:
|
||||
if not path.exists():
|
||||
return path
|
||||
stem, suffix = path.stem, path.suffix
|
||||
for i in range(1, 100):
|
||||
candidate = path.with_name(f"{stem} ({i}){suffix}")
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
return path.with_name(f"{stem}_{uuid.uuid4().hex[:8]}{suffix}")
|
||||
|
||||
@staticmethod
|
||||
def _record_to_status(rec: Dict[str, Any]) -> DownloadStatus:
|
||||
return DownloadStatus(
|
||||
id=str(rec.get('id', '')),
|
||||
filename=str(rec.get('filename', '')),
|
||||
username='amazon',
|
||||
state=str(rec.get('state', 'queued')),
|
||||
progress=float(rec.get('progress', 0.0)),
|
||||
size=int(rec.get('size', 0)),
|
||||
transferred=int(rec.get('transferred', 0)),
|
||||
speed=int(rec.get('speed', 0)),
|
||||
time_remaining=rec.get('time_remaining'),
|
||||
file_path=rec.get('file_path'),
|
||||
)
|
||||
566
core/amazon_worker.py
Normal file
566
core/amazon_worker.py
Normal file
|
|
@ -0,0 +1,566 @@
|
|||
import re
|
||||
import threading
|
||||
import time
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
from utils.logging_config import get_logger
|
||||
from database.music_database import MusicDatabase
|
||||
from core.amazon_client import AmazonClient
|
||||
from core.worker_utils import interruptible_sleep, set_album_api_track_count
|
||||
from core.enrichment.manual_match_honoring import honor_stored_match
|
||||
|
||||
logger = get_logger("amazon_worker")
|
||||
|
||||
|
||||
class AmazonWorker:
|
||||
"""Background worker for enriching library artists, albums, and tracks with Amazon Music metadata."""
|
||||
|
||||
def __init__(self, database: MusicDatabase):
|
||||
self.db = database
|
||||
self.client = AmazonClient()
|
||||
|
||||
self.running = False
|
||||
self.paused = False
|
||||
self.should_stop = False
|
||||
self.thread = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
self.current_item = None
|
||||
|
||||
self.stats = {
|
||||
'matched': 0,
|
||||
'not_found': 0,
|
||||
'pending': 0,
|
||||
'errors': 0,
|
||||
}
|
||||
|
||||
self.retry_days = 30
|
||||
self.name_similarity_threshold = 0.80
|
||||
|
||||
logger.info("Amazon background worker initialized")
|
||||
|
||||
def start(self):
|
||||
if self.running:
|
||||
logger.warning("Worker already running")
|
||||
return
|
||||
|
||||
self.running = True
|
||||
self.should_stop = False
|
||||
self._stop_event.clear()
|
||||
self.thread = threading.Thread(target=self._run, daemon=True)
|
||||
self.thread.start()
|
||||
logger.info("Amazon background worker started")
|
||||
|
||||
def stop(self):
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
logger.info("Stopping Amazon worker...")
|
||||
self.should_stop = True
|
||||
self.running = False
|
||||
self._stop_event.set()
|
||||
|
||||
if self.thread:
|
||||
self.thread.join(timeout=1)
|
||||
|
||||
logger.info("Amazon worker stopped")
|
||||
|
||||
def pause(self):
|
||||
if not self.running:
|
||||
logger.warning("Worker not running, cannot pause")
|
||||
return
|
||||
self.paused = True
|
||||
logger.info("Amazon worker paused")
|
||||
|
||||
def resume(self):
|
||||
if not self.running:
|
||||
logger.warning("Worker not running, start it first")
|
||||
return
|
||||
self.paused = False
|
||||
logger.info("Amazon worker resumed")
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
self.stats['pending'] = self._count_pending_items()
|
||||
progress = self._get_progress_breakdown()
|
||||
is_actually_running = self.running and (self.thread is not None and self.thread.is_alive())
|
||||
is_idle = is_actually_running and not self.paused and self.stats['pending'] == 0 and self.current_item is None
|
||||
return {
|
||||
'enabled': True,
|
||||
'running': is_actually_running and not self.paused,
|
||||
'paused': self.paused,
|
||||
'idle': is_idle,
|
||||
'current_item': self.current_item,
|
||||
'stats': self.stats.copy(),
|
||||
'progress': progress,
|
||||
}
|
||||
|
||||
def _run(self):
|
||||
logger.info("Amazon worker thread started")
|
||||
while not self.should_stop:
|
||||
try:
|
||||
if self.paused:
|
||||
interruptible_sleep(self._stop_event, 1)
|
||||
continue
|
||||
|
||||
self.current_item = None
|
||||
item = self._get_next_item()
|
||||
|
||||
if not item:
|
||||
logger.debug("No pending items, sleeping...")
|
||||
interruptible_sleep(self._stop_event, 10)
|
||||
continue
|
||||
|
||||
self.current_item = item
|
||||
item_id = item.get('id') or item.get('artist_id') or item.get('album_id')
|
||||
if item_id is None:
|
||||
logger.warning(f"Skipping {item.get('type', 'unknown')} with NULL id: {item.get('name', '?')}")
|
||||
continue
|
||||
|
||||
self._process_item(item)
|
||||
interruptible_sleep(self._stop_event, 2)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in worker loop: {e}")
|
||||
interruptible_sleep(self._stop_event, 5)
|
||||
|
||||
logger.info("Amazon worker thread finished")
|
||||
|
||||
def _get_next_item(self) -> Optional[Dict[str, Any]]:
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Priority 1: Unattempted artists
|
||||
cursor.execute("""
|
||||
SELECT id, name FROM artists
|
||||
WHERE amazon_match_status IS NULL AND id IS NOT NULL
|
||||
ORDER BY id ASC LIMIT 1
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return {'type': 'artist', 'id': row[0], 'name': row[1]}
|
||||
|
||||
# Priority 2: Unattempted albums
|
||||
cursor.execute("""
|
||||
SELECT a.id, a.title, ar.name AS artist_name, ar.amazon_id AS artist_amazon_id
|
||||
FROM albums a
|
||||
JOIN artists ar ON a.artist_id = ar.id
|
||||
WHERE a.amazon_match_status IS NULL AND a.id IS NOT NULL
|
||||
ORDER BY a.id ASC LIMIT 1
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return {'type': 'album', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_amazon_id': row[3]}
|
||||
|
||||
# Priority 3: Unattempted tracks
|
||||
cursor.execute("""
|
||||
SELECT t.id, t.title, ar.name AS artist_name, ar.amazon_id AS artist_amazon_id
|
||||
FROM tracks t
|
||||
JOIN artists ar ON t.artist_id = ar.id
|
||||
WHERE t.amazon_match_status IS NULL AND t.id IS NOT NULL
|
||||
ORDER BY t.id ASC LIMIT 1
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return {'type': 'track', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_amazon_id': row[3]}
|
||||
|
||||
not_found_cutoff = datetime.now() - timedelta(days=self.retry_days)
|
||||
|
||||
# Priority 4: Retry not_found artists
|
||||
cursor.execute("""
|
||||
SELECT id, name FROM artists
|
||||
WHERE amazon_match_status = 'not_found' AND amazon_last_attempted < ?
|
||||
ORDER BY amazon_last_attempted ASC LIMIT 1
|
||||
""", (not_found_cutoff,))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
logger.info(f"Retrying artist '{row[1]}' (last attempted before cutoff)")
|
||||
return {'type': 'artist', 'id': row[0], 'name': row[1]}
|
||||
|
||||
# Priority 5: Retry not_found albums
|
||||
cursor.execute("""
|
||||
SELECT a.id, a.title, ar.name AS artist_name, ar.amazon_id AS artist_amazon_id
|
||||
FROM albums a
|
||||
JOIN artists ar ON a.artist_id = ar.id
|
||||
WHERE a.amazon_match_status = 'not_found' AND a.amazon_last_attempted < ?
|
||||
ORDER BY a.amazon_last_attempted ASC LIMIT 1
|
||||
""", (not_found_cutoff,))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return {'type': 'album', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_amazon_id': row[3]}
|
||||
|
||||
# Priority 6: Retry not_found tracks
|
||||
cursor.execute("""
|
||||
SELECT t.id, t.title, ar.name AS artist_name, ar.amazon_id AS artist_amazon_id
|
||||
FROM tracks t
|
||||
JOIN artists ar ON t.artist_id = ar.id
|
||||
WHERE t.amazon_match_status = 'not_found' AND t.amazon_last_attempted < ?
|
||||
ORDER BY t.amazon_last_attempted ASC LIMIT 1
|
||||
""", (not_found_cutoff,))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return {'type': 'track', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_amazon_id': row[3]}
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting next item: {e}")
|
||||
return None
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _normalize_name(self, name: str) -> str:
|
||||
name = name.lower().strip()
|
||||
name = re.sub(r'\s+[-–—]\s+.*$', '', name)
|
||||
name = re.sub(r'\s*\(.*?\)\s*', ' ', name)
|
||||
name = re.sub(r'[^\w\s]', '', name)
|
||||
name = re.sub(r'\s+', ' ', name).strip()
|
||||
return name
|
||||
|
||||
def _name_matches(self, query_name: str, result_name: str) -> bool:
|
||||
norm_query = self._normalize_name(query_name)
|
||||
norm_result = self._normalize_name(result_name)
|
||||
similarity = SequenceMatcher(None, norm_query, norm_result).ratio()
|
||||
logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}")
|
||||
return similarity >= self.name_similarity_threshold
|
||||
|
||||
def _process_item(self, item: Dict[str, Any]):
|
||||
try:
|
||||
item_type = item['type']
|
||||
item_id = item['id']
|
||||
item_name = item['name']
|
||||
logger.debug(f"Processing {item_type} #{item_id}: {item_name}")
|
||||
|
||||
if item_type == 'artist':
|
||||
self._process_artist(item_id, item_name)
|
||||
elif item_type == 'album':
|
||||
self._process_album(item_id, item_name, item.get('artist', ''), item)
|
||||
elif item_type == 'track':
|
||||
self._process_track(item_id, item_name, item.get('artist', ''), item)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing {item['type']} #{item['id']}: {e}")
|
||||
self.stats['errors'] += 1
|
||||
try:
|
||||
self._mark_status(item['type'], item['id'], 'error')
|
||||
except Exception as e2:
|
||||
logger.error(f"Error updating item status: {e2}")
|
||||
|
||||
def _get_existing_id(self, entity_type: str, entity_id: int) -> Optional[str]:
|
||||
table_map = {'artist': 'artists', 'album': 'albums', 'track': 'tracks'}
|
||||
table = table_map.get(entity_type)
|
||||
if not table:
|
||||
return None
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f"SELECT amazon_id FROM {table} WHERE id = ?", (entity_id,))
|
||||
row = cursor.fetchone()
|
||||
return row[0] if row and row[0] else None
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _process_artist(self, artist_id: int, artist_name: str):
|
||||
existing_id = self._get_existing_id('artist', artist_id)
|
||||
if existing_id:
|
||||
logger.debug(f"Preserving existing Amazon ID for artist '{artist_name}': {existing_id}")
|
||||
return
|
||||
|
||||
results = self.client.search_artists(artist_name, limit=5)
|
||||
if results:
|
||||
result = results[0]
|
||||
if self._name_matches(artist_name, result.name):
|
||||
self._update_artist(artist_id, result)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched artist '{artist_name}' -> Amazon ID: {result.id}")
|
||||
else:
|
||||
self._mark_status('artist', artist_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"Name mismatch for artist '{artist_name}' (got '{result.name}')")
|
||||
else:
|
||||
self._mark_status('artist', artist_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"No match for artist '{artist_name}'")
|
||||
|
||||
def _refresh_album_via_stored_id(self, album_id, stored_id, api_data):
|
||||
self._update_album(album_id, api_data, stored_id)
|
||||
|
||||
def _refresh_track_via_stored_id(self, track_id, stored_id, api_data):
|
||||
self._update_track(track_id, api_data, stored_id)
|
||||
|
||||
def _process_album(self, album_id: int, album_name: str, artist_name: str, item: Dict[str, Any]):
|
||||
if honor_stored_match(
|
||||
db=self.db, entity_table='albums', entity_id=album_id,
|
||||
id_column='amazon_id',
|
||||
client_fetch_fn=lambda asin: self.client.get_album(asin, include_tracks=False),
|
||||
on_match_fn=self._refresh_album_via_stored_id,
|
||||
log_prefix='Amazon',
|
||||
):
|
||||
self.stats['matched'] += 1
|
||||
return
|
||||
|
||||
query = f"{artist_name} {album_name}"
|
||||
results = self.client.search_albums(query, limit=10)
|
||||
if results:
|
||||
result = results[0]
|
||||
if self._name_matches(album_name, result.name):
|
||||
full_album = None
|
||||
if result.id:
|
||||
try:
|
||||
full_album = self.client.get_album(result.id, include_tracks=False)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch full album '{album_name}' (ASIN: {result.id}): {e}")
|
||||
|
||||
if full_album is None:
|
||||
self._mark_status('album', album_id, 'error')
|
||||
self.stats['errors'] += 1
|
||||
logger.warning(f"Album '{album_name}' matched but full details unavailable, will retry")
|
||||
return
|
||||
|
||||
self._update_album(album_id, full_album, result.id)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched album '{album_name}' -> Amazon ASIN: {result.id}")
|
||||
else:
|
||||
self._mark_status('album', album_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"Name mismatch for album '{album_name}' (got '{result.name}')")
|
||||
else:
|
||||
self._mark_status('album', album_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"No match for album '{album_name}'")
|
||||
|
||||
def _process_track(self, track_id: int, track_name: str, artist_name: str, item: Dict[str, Any]):
|
||||
if honor_stored_match(
|
||||
db=self.db, entity_table='tracks', entity_id=track_id,
|
||||
id_column='amazon_id',
|
||||
client_fetch_fn=self.client.get_track_details,
|
||||
on_match_fn=self._refresh_track_via_stored_id,
|
||||
log_prefix='Amazon',
|
||||
):
|
||||
self.stats['matched'] += 1
|
||||
return
|
||||
|
||||
query = f"{artist_name} {track_name}"
|
||||
results = self.client.search_tracks(query, limit=10)
|
||||
if results:
|
||||
result = results[0]
|
||||
if self._name_matches(track_name, result.name):
|
||||
full_track = None
|
||||
if result.id:
|
||||
try:
|
||||
full_track = self.client.get_track_details(result.id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch full track '{track_name}' (ASIN: {result.id}): {e}")
|
||||
|
||||
if full_track is None:
|
||||
self._mark_status('track', track_id, 'error')
|
||||
self.stats['errors'] += 1
|
||||
logger.warning(f"Track '{track_name}' matched but full details unavailable, will retry")
|
||||
return
|
||||
|
||||
self._update_track(track_id, full_track, result.id)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched track '{track_name}' -> Amazon ASIN: {result.id}")
|
||||
else:
|
||||
self._mark_status('track', track_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"Name mismatch for track '{track_name}' (got '{result.name}')")
|
||||
else:
|
||||
self._mark_status('track', track_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"No match for track '{track_name}'")
|
||||
|
||||
def _update_artist(self, artist_id: int, result):
|
||||
"""Store Amazon metadata for an artist. ``result`` is an Artist dataclass."""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE artists SET
|
||||
amazon_id = ?,
|
||||
amazon_match_status = 'matched',
|
||||
amazon_last_attempted = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (str(result.id), artist_id))
|
||||
|
||||
# Backfill thumb_url from album cover stand-in when artist has no image
|
||||
image_url = result.image_url
|
||||
if not image_url:
|
||||
try:
|
||||
image_url = self.client._get_artist_image_from_albums(result.id)
|
||||
except Exception as exc:
|
||||
logger.debug("Artist image from albums failed for %s: %s", result.id, exc)
|
||||
if image_url:
|
||||
cursor.execute("""
|
||||
UPDATE artists SET thumb_url = ?
|
||||
WHERE id = ? AND (thumb_url IS NULL OR thumb_url = '')
|
||||
""", (image_url, artist_id))
|
||||
|
||||
conn.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating artist #{artist_id} with Amazon data: {e}")
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _update_album(self, album_id: int, full_data: Dict[str, Any], asin: str):
|
||||
"""Store Amazon metadata for an album. ``full_data`` is a get_album() dict."""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE albums SET
|
||||
amazon_id = ?,
|
||||
amazon_match_status = 'matched',
|
||||
amazon_last_attempted = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (asin, album_id))
|
||||
|
||||
# Backfill label when missing
|
||||
label = full_data.get('label')
|
||||
if label:
|
||||
cursor.execute("""
|
||||
UPDATE albums SET label = ?
|
||||
WHERE id = ? AND (label IS NULL OR label = '')
|
||||
""", (label, album_id))
|
||||
|
||||
# Backfill thumb_url
|
||||
images = full_data.get('images') or []
|
||||
thumb_url = images[0].get('url') if images else None
|
||||
if thumb_url:
|
||||
cursor.execute("""
|
||||
UPDATE albums SET thumb_url = ?
|
||||
WHERE id = ? AND (thumb_url IS NULL OR thumb_url = '')
|
||||
""", (thumb_url, album_id))
|
||||
|
||||
# Cache authoritative track count for completeness repair
|
||||
total_tracks = full_data.get('total_tracks') or (
|
||||
full_data.get('tracks', {}).get('total') if isinstance(full_data.get('tracks'), dict) else None
|
||||
)
|
||||
set_album_api_track_count(cursor, album_id, total_tracks)
|
||||
|
||||
conn.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating album #{album_id} with Amazon data: {e}")
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _update_track(self, track_id: int, full_data: Dict[str, Any], asin: str):
|
||||
"""Store Amazon metadata for a track. ``full_data`` is a get_track_details() dict."""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE tracks SET
|
||||
amazon_id = ?,
|
||||
amazon_match_status = 'matched',
|
||||
amazon_last_attempted = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (asin, track_id))
|
||||
|
||||
conn.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating track #{track_id} with Amazon data: {e}")
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _mark_status(self, entity_type: str, entity_id: int, status: str):
|
||||
table_map = {'artist': 'artists', 'album': 'albums', 'track': 'tracks'}
|
||||
table = table_map.get(entity_type)
|
||||
if not table:
|
||||
logger.error(f"Unknown entity type: {entity_type}")
|
||||
return
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f"""
|
||||
UPDATE {table} SET
|
||||
amazon_match_status = ?,
|
||||
amazon_last_attempted = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (status, entity_id))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Error marking {entity_type} #{entity_id} status: {e}")
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _count_pending_items(self) -> int:
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM artists WHERE amazon_match_status IS NULL AND id IS NOT NULL) +
|
||||
(SELECT COUNT(*) FROM albums WHERE amazon_match_status IS NULL AND id IS NOT NULL) +
|
||||
(SELECT COUNT(*) FROM tracks WHERE amazon_match_status IS NULL AND id IS NOT NULL)
|
||||
AS pending
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
return row[0] if row else 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error counting pending items: {e}")
|
||||
return 0
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def _get_progress_breakdown(self) -> Dict[str, Dict[str, int]]:
|
||||
conn = None
|
||||
try:
|
||||
conn = self.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
progress = {}
|
||||
|
||||
for table in ('artists', 'albums', 'tracks'):
|
||||
cursor.execute(f"""
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN amazon_match_status IS NOT NULL THEN 1 ELSE 0 END) AS processed
|
||||
FROM {table}
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
total, processed = row[0], row[1] or 0
|
||||
progress[table] = {
|
||||
'matched': processed,
|
||||
'total': total,
|
||||
'percent': int((processed / total * 100) if total > 0 else 0),
|
||||
}
|
||||
|
||||
return progress
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting progress breakdown: {e}")
|
||||
return {}
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
|
@ -30,6 +30,7 @@ RATE_LIMITS = {
|
|||
'tidal': 120, # MIN_API_INTERVAL=0.5s → ~120/min
|
||||
'qobuz': 60, # Variable throttle, ~60/min estimate
|
||||
'discogs': 60, # MIN_API_INTERVAL=1.0s with auth → ~60/min
|
||||
'amazon': 120, # MIN_API_INTERVAL=0.5s → ~120/min (T2Tunes proxy)
|
||||
}
|
||||
|
||||
# Display names for UI
|
||||
|
|
@ -44,12 +45,13 @@ SERVICE_LABELS = {
|
|||
'tidal': 'Tidal',
|
||||
'qobuz': 'Qobuz',
|
||||
'discogs': 'Discogs',
|
||||
'amazon': 'Amazon Music',
|
||||
}
|
||||
|
||||
# Display order
|
||||
SERVICE_ORDER = [
|
||||
'spotify', 'itunes', 'deezer', 'lastfm', 'genius',
|
||||
'musicbrainz', 'audiodb', 'tidal', 'qobuz', 'discogs',
|
||||
'musicbrainz', 'audiodb', 'tidal', 'qobuz', 'discogs', 'amazon',
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ def build_source_only_artist_detail(
|
|||
deezer_client: Optional[Any] = None,
|
||||
itunes_client: Optional[Any] = None,
|
||||
discogs_client: Optional[Any] = None,
|
||||
amazon_client: Optional[Any] = None,
|
||||
lastfm_api_key: Optional[str] = None,
|
||||
) -> Tuple[Dict[str, Any], int]:
|
||||
"""Build the artist-detail payload for a source-only artist.
|
||||
|
|
@ -84,6 +85,12 @@ def build_source_only_artist_detail(
|
|||
dc_artist = discogs_client.get_artist(artist_id)
|
||||
if dc_artist:
|
||||
source_genres = dc_artist.get("genres") or []
|
||||
elif source == "amazon" and amazon_client is not None:
|
||||
az_artist = amazon_client.get_artist(resolved_name or artist_id)
|
||||
if az_artist:
|
||||
source_genres = az_artist.get("genres") or []
|
||||
if not image_url and az_artist.get("images"):
|
||||
image_url = az_artist["images"][0].get("url")
|
||||
except Exception as e:
|
||||
logger.debug(f"Source-side artist info lookup failed for {source}:{artist_id}: {e}")
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ logger = logging.getLogger("artist_source_lookup")
|
|||
|
||||
|
||||
SOURCE_ONLY_ARTIST_SOURCES = frozenset({
|
||||
"spotify", "itunes", "deezer", "discogs", "hydrabase", "musicbrainz",
|
||||
"spotify", "itunes", "deezer", "discogs", "hydrabase", "musicbrainz", "amazon",
|
||||
})
|
||||
|
||||
|
||||
|
|
@ -36,6 +36,7 @@ SOURCE_ID_FIELD = {
|
|||
"discogs": "discogs_id",
|
||||
"hydrabase": "soul_id",
|
||||
"musicbrainz": "musicbrainz_id",
|
||||
"amazon": "amazon_id",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -20,10 +20,14 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
def get_current_profile_id() -> int:
|
||||
"""Mirror of web_server.get_current_profile_id — uses Flask g."""
|
||||
"""Mirror of web_server.get_current_profile_id — uses Flask g.
|
||||
|
||||
Catches RuntimeError too because reading `g` outside a request
|
||||
context raises that (not AttributeError) — happens when this is
|
||||
called from background threads (sync, automation, scanners)."""
|
||||
try:
|
||||
return g.profile_id
|
||||
except AttributeError:
|
||||
except (AttributeError, RuntimeError):
|
||||
return 1
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
"""Automation API + progress tracking helpers package.
|
||||
"""Automation API + progress + handlers package.
|
||||
|
||||
Lifted from web_server.py /api/automations/* routes and progress
|
||||
emitters. The action handler registration (`_register_automation_handlers`)
|
||||
stays in web_server.py because each handler closure is tightly coupled
|
||||
to other application features.
|
||||
Lifted from web_server.py:
|
||||
- `/api/automations/*` route helpers → `api.py`
|
||||
- block library used by the trigger/action UI → `blocks.py`
|
||||
- progress tracker (init / update / finish) → `progress.py`
|
||||
- cross-handler signal bus → `signals.py`
|
||||
- per-action handler functions → `handlers/` subpackage (with
|
||||
`deps.py` defining the dependency-injection surface so handlers
|
||||
stay testable in isolation)
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -146,6 +146,15 @@ ACTIONS: list[dict] = [
|
|||
{"key": "all", "type": "checkbox", "label": "Process all mirrored playlists", "default": False},
|
||||
{"key": "skip_wishlist", "type": "checkbox", "label": "Skip wishlist processing", "default": False},
|
||||
]},
|
||||
{"type": "personalized_pipeline", "label": "Personalized Playlist Pipeline", "icon": "sparkles",
|
||||
"description": "Sync personalized / discover-page playlists (Hidden Gems, Time Machine, Fresh Tape, etc.) to your media server + queue missing tracks for download.",
|
||||
"available": True,
|
||||
"config_fields": [
|
||||
{"key": "kinds", "type": "personalized_playlist_select", "label": "Playlists to sync",
|
||||
"description": "Multi-select: Hidden Gems, Discovery Shuffle, Time Machine (per decade), Genre playlists, Fresh Tape, The Archives, Seasonal Mix (per season)"},
|
||||
{"key": "refresh_first", "type": "checkbox", "label": "Refresh playlists before sync (regenerate snapshots)", "default": False},
|
||||
{"key": "skip_wishlist", "type": "checkbox", "label": "Skip wishlist processing", "default": False},
|
||||
]},
|
||||
{"type": "notify_only", "label": "Notify Only", "icon": "bell", "description": "No action — just send notification", "available": True},
|
||||
# Phase 3 actions
|
||||
{"type": "start_database_update", "label": "Update Database", "icon": "database",
|
||||
|
|
|
|||
146
core/automation/deps.py
Normal file
146
core/automation/deps.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""Dependency-injection surface for automation handlers.
|
||||
|
||||
Each handler in ``core.automation.handlers`` is a top-level pure
|
||||
function that accepts ``(config: dict, deps: AutomationDeps)`` instead
|
||||
of reaching for module-level globals in ``web_server``. The deps
|
||||
namespace bundles every callable, client, and mutable-state container
|
||||
the handlers need.
|
||||
|
||||
Construction happens once at app startup in ``web_server.py``:
|
||||
|
||||
from core.automation.deps import AutomationDeps, AutomationState
|
||||
state = AutomationState()
|
||||
deps = AutomationDeps(
|
||||
engine=automation_engine,
|
||||
state=state,
|
||||
get_database=get_database,
|
||||
spotify_client=spotify_client,
|
||||
...
|
||||
)
|
||||
register_all(deps)
|
||||
|
||||
Tests construct a fake ``AutomationDeps`` with stub callables — every
|
||||
handler is then exercisable without spinning up Flask, the DB, or
|
||||
the real media-server clients.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class AutomationState:
|
||||
"""Mutable flags shared across handler invocations.
|
||||
|
||||
Pre-refactor each was a ``global`` or ``nonlocal`` variable inside
|
||||
the registration closure. Lifted here so handlers + their guards
|
||||
can read/write a single object instead of importing globals.
|
||||
|
||||
All mutations should hold ``lock``; the helper methods below do
|
||||
so for the common get/set patterns.
|
||||
"""
|
||||
|
||||
scan_library_automation_id: Optional[str] = None
|
||||
db_update_automation_id: Optional[str] = None
|
||||
pipeline_running: bool = False
|
||||
lock: threading.Lock = field(default_factory=threading.Lock)
|
||||
|
||||
def is_scan_library_active(self) -> bool:
|
||||
with self.lock:
|
||||
return self.scan_library_automation_id is not None
|
||||
|
||||
def is_pipeline_running(self) -> bool:
|
||||
with self.lock:
|
||||
return self.pipeline_running
|
||||
|
||||
def set_scan_library_id(self, automation_id: Optional[str]) -> None:
|
||||
with self.lock:
|
||||
self.scan_library_automation_id = automation_id
|
||||
|
||||
def set_pipeline_running(self, value: bool) -> None:
|
||||
with self.lock:
|
||||
self.pipeline_running = value
|
||||
|
||||
|
||||
@dataclass
|
||||
class AutomationDeps:
|
||||
"""Bundle of every callable + client an automation handler may need.
|
||||
|
||||
Add fields as new handlers are extracted. Every field is required
|
||||
at construction (no defaults) so a missing dep fails loudly at
|
||||
startup, not silently mid-handler.
|
||||
"""
|
||||
|
||||
# --- Engine + shared state ---
|
||||
engine: Any # AutomationEngine instance
|
||||
state: AutomationState
|
||||
config_manager: Any # config.settings.ConfigManager singleton
|
||||
update_progress: Callable[..., None] # _update_automation_progress
|
||||
logger: Any # module-level logger from utils.logging_config
|
||||
|
||||
# --- Service clients (each may be None depending on user config) ---
|
||||
get_database: Callable[[], Any] # late-binding so tests don't need DB
|
||||
spotify_client: Any
|
||||
tidal_client: Any
|
||||
web_scan_manager: Any
|
||||
|
||||
# --- Background-task entry points ---
|
||||
process_wishlist_automatically: Callable[..., Any]
|
||||
process_watchlist_scan_automatically: Callable[..., Any]
|
||||
is_wishlist_actually_processing: Callable[[], bool]
|
||||
is_watchlist_actually_scanning: Callable[[], bool]
|
||||
get_watchlist_scan_state: Callable[[], dict] # accessor returns the live mutable dict
|
||||
|
||||
# --- Playlist pipeline entry points ---
|
||||
run_playlist_discovery_worker: Callable[..., Any]
|
||||
run_sync_task: Callable[..., Any]
|
||||
load_sync_status_file: Callable[[], dict]
|
||||
get_deezer_client: Callable[[], Any]
|
||||
parse_youtube_playlist: Callable[[str], Any]
|
||||
get_sync_states: Callable[[], dict] # accessor returns the live dict shared with the sync UI
|
||||
|
||||
# --- Database update + quality scanner (shared state + executors) ---
|
||||
set_db_update_automation_id: Callable[[Optional[str]], None] # syncs the legacy `_db_update_automation_id` global so the live DB-update progress callbacks (which still read the global directly) keep firing for the active automation
|
||||
get_db_update_state: Callable[[], dict]
|
||||
db_update_lock: Any # threading.Lock
|
||||
db_update_executor: Any # ThreadPoolExecutor
|
||||
run_db_update_task: Callable[..., Any]
|
||||
run_deep_scan_task: Callable[..., Any]
|
||||
get_duplicate_cleaner_state: Callable[[], dict]
|
||||
duplicate_cleaner_lock: Any
|
||||
duplicate_cleaner_executor: Any
|
||||
run_duplicate_cleaner: Callable[..., Any]
|
||||
get_quality_scanner_state: Callable[[], dict]
|
||||
quality_scanner_lock: Any
|
||||
quality_scanner_executor: Any
|
||||
run_quality_scanner: Callable[..., Any]
|
||||
|
||||
# --- Download orchestrator + queue accessors ---
|
||||
download_orchestrator: Any
|
||||
run_async: Callable[..., Any]
|
||||
tasks_lock: Any
|
||||
get_download_batches: Callable[[], dict]
|
||||
get_download_tasks: Callable[[], dict]
|
||||
sweep_empty_download_directories: Callable[[], int]
|
||||
get_staging_path: Callable[[], str]
|
||||
|
||||
# --- Maintenance helpers ---
|
||||
docker_resolve_path: Callable[[str], str]
|
||||
get_current_profile_id: Callable[[], int]
|
||||
get_watchlist_scanner: Callable[[Any], Any]
|
||||
get_app: Callable[[], Any] # Flask app for test_client (beatport refresh)
|
||||
get_beatport_data_cache: Callable[[], dict]
|
||||
|
||||
# --- Progress + history callbacks (used by register_all to wire
|
||||
# the engine's progress callback hooks). ---
|
||||
init_automation_progress: Callable[..., Any]
|
||||
record_progress_history: Callable[..., Any]
|
||||
|
||||
# --- Personalized playlist pipeline ---
|
||||
# Lazy builder so the pipeline handler can construct a fresh
|
||||
# PersonalizedPlaylistManager per run (cheap accessors inside,
|
||||
# no caching needed yet).
|
||||
build_personalized_manager: Callable[[], Any]
|
||||
64
core/automation/handlers/__init__.py
Normal file
64
core/automation/handlers/__init__.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""Per-action automation handlers.
|
||||
|
||||
Each module in this subpackage exposes one top-level handler function
|
||||
(or a small cluster of related handlers) of the form::
|
||||
|
||||
def auto_<action_name>(config: dict, deps: AutomationDeps) -> dict
|
||||
|
||||
The ``register_all`` helper in :mod:`registration` wires every handler
|
||||
to the engine in one place. ``web_server.py`` calls
|
||||
``register_all(deps)`` once at startup.
|
||||
"""
|
||||
|
||||
from core.automation.handlers.process_wishlist import auto_process_wishlist
|
||||
from core.automation.handlers.scan_watchlist import auto_scan_watchlist
|
||||
from core.automation.handlers.scan_library import auto_scan_library
|
||||
from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored
|
||||
from core.automation.handlers.sync_playlist import auto_sync_playlist
|
||||
from core.automation.handlers.discover_playlist import auto_discover_playlist
|
||||
from core.automation.handlers.playlist_pipeline import auto_playlist_pipeline
|
||||
from core.automation.handlers.personalized_pipeline import auto_personalized_pipeline
|
||||
from core.automation.handlers.database_update import auto_start_database_update, auto_deep_scan_library
|
||||
from core.automation.handlers.duplicate_cleaner import auto_run_duplicate_cleaner
|
||||
from core.automation.handlers.quality_scanner import auto_start_quality_scan
|
||||
from core.automation.handlers.maintenance import (
|
||||
auto_clear_quarantine,
|
||||
auto_cleanup_wishlist,
|
||||
auto_update_discovery_pool,
|
||||
auto_backup_database,
|
||||
auto_refresh_beatport_cache,
|
||||
)
|
||||
from core.automation.handlers.download_cleanup import (
|
||||
auto_clean_search_history,
|
||||
auto_clean_completed_downloads,
|
||||
auto_full_cleanup,
|
||||
)
|
||||
from core.automation.handlers.run_script import auto_run_script
|
||||
from core.automation.handlers.search_and_download import auto_search_and_download
|
||||
from core.automation.handlers.registration import register_all
|
||||
|
||||
__all__ = [
|
||||
'auto_process_wishlist',
|
||||
'auto_scan_watchlist',
|
||||
'auto_scan_library',
|
||||
'auto_refresh_mirrored',
|
||||
'auto_sync_playlist',
|
||||
'auto_discover_playlist',
|
||||
'auto_playlist_pipeline',
|
||||
'auto_personalized_pipeline',
|
||||
'auto_start_database_update',
|
||||
'auto_deep_scan_library',
|
||||
'auto_run_duplicate_cleaner',
|
||||
'auto_start_quality_scan',
|
||||
'auto_clear_quarantine',
|
||||
'auto_cleanup_wishlist',
|
||||
'auto_update_discovery_pool',
|
||||
'auto_backup_database',
|
||||
'auto_refresh_beatport_cache',
|
||||
'auto_clean_search_history',
|
||||
'auto_clean_completed_downloads',
|
||||
'auto_full_cleanup',
|
||||
'auto_run_script',
|
||||
'auto_search_and_download',
|
||||
'register_all',
|
||||
]
|
||||
201
core/automation/handlers/_pipeline_shared.py
Normal file
201
core/automation/handlers/_pipeline_shared.py
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
"""Shared helpers between mirrored + personalized playlist pipelines.
|
||||
|
||||
Both pipelines end in the same shape:
|
||||
1. SYNC each playlist to the active media server.
|
||||
2. WISHLIST: trigger the wishlist processor for missing tracks.
|
||||
|
||||
The differing prefix (mirrored = REFRESH external sources + DISCOVER
|
||||
metadata; personalized = SNAPSHOT manager-backed playlists) is owned
|
||||
by each pipeline. This module owns the SYNC + WISHLIST tail so both
|
||||
pipelines stay consistent + DRY.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
# Per-playlist sync poll cap (mirrored side already used this).
|
||||
_SYNC_PER_PLAYLIST_TIMEOUT_SECONDS = 600
|
||||
# Sync-status final-state markers.
|
||||
_SYNC_TERMINAL_STATUSES = ('finished', 'complete', 'error', 'failed')
|
||||
|
||||
|
||||
def run_sync_and_wishlist(
|
||||
deps: AutomationDeps,
|
||||
automation_id: Optional[str],
|
||||
playlists: List[Dict[str, Any]],
|
||||
*,
|
||||
sync_one_fn: Callable[[Dict[str, Any]], Dict[str, Any]],
|
||||
sync_id_for_fn: Callable[[Dict[str, Any]], str],
|
||||
skip_wishlist: bool = False,
|
||||
progress_start: int = 56,
|
||||
progress_end: int = 85,
|
||||
sync_phase_label: str = 'Phase: Syncing to server...',
|
||||
sync_phase_start_log: str = 'Sync',
|
||||
wishlist_phase_label: str = 'Phase: Processing wishlist...',
|
||||
wishlist_phase_start_log: str = 'Wishlist',
|
||||
) -> Dict[str, int]:
|
||||
"""Run the SYNC + WISHLIST tail of a playlist pipeline.
|
||||
|
||||
The caller supplies:
|
||||
- ``playlists``: list of playlist payload dicts. Each must have at
|
||||
least a ``name`` key (used in progress logs). The shape beyond
|
||||
``name`` is opaque to the helper — ``sync_one_fn`` receives the
|
||||
payload and returns a sync_result dict.
|
||||
- ``sync_one_fn(payload) -> sync_result``: launches sync for one
|
||||
playlist. Result dict must carry ``status`` ∈ ``('started',
|
||||
'skipped', 'error')`` and may carry ``reason``.
|
||||
- ``sync_id_for_fn(payload) -> str``: returns the sync-state key
|
||||
the helper polls on (so we can wait for the background sync
|
||||
thread to complete + read the matched_tracks count).
|
||||
|
||||
Returns ``{'synced': int, 'skipped': int, 'errors': int,
|
||||
'wishlist_queued': int}`` so the caller can stitch it into its
|
||||
final status.
|
||||
"""
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=progress_start,
|
||||
phase=sync_phase_label,
|
||||
log_line=sync_phase_start_log,
|
||||
log_type='info',
|
||||
)
|
||||
|
||||
total_synced = 0
|
||||
total_skipped = 0
|
||||
sync_errors = 0
|
||||
sync_states = deps.get_sync_states()
|
||||
n_playlists = max(1, len(playlists))
|
||||
progress_span = max(1, progress_end - progress_start - 1)
|
||||
|
||||
for pl_idx, pl in enumerate(playlists):
|
||||
pl_name = pl.get('name', '')
|
||||
sync_result = sync_one_fn(pl)
|
||||
sync_status = sync_result.get('status', '')
|
||||
|
||||
if sync_status == 'started':
|
||||
sync_id = sync_id_for_fn(pl)
|
||||
sync_poll_start = time.time()
|
||||
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):
|
||||
break
|
||||
time.sleep(2)
|
||||
elapsed = int(time.time() - sync_poll_start)
|
||||
sub_progress = progress_start + 1 + ((pl_idx + 1) / n_playlists) * progress_span
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=min(int(sub_progress), progress_end - 1),
|
||||
phase=f'{sync_phase_label.rstrip(".")} — "{pl_name}" ({elapsed}s)',
|
||||
)
|
||||
|
||||
ss = sync_states.get(sync_id, {})
|
||||
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
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Synced "{pl_name}": {matched} tracks matched',
|
||||
log_type='success',
|
||||
)
|
||||
|
||||
elif sync_status == 'skipped':
|
||||
total_skipped += 1
|
||||
reason = sync_result.get('reason', 'unchanged')
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Skipped "{pl_name}": {reason}',
|
||||
log_type='skip',
|
||||
)
|
||||
elif sync_status == 'error':
|
||||
sync_errors += 1
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Sync error "{pl_name}": {sync_result.get("reason", "unknown")}',
|
||||
log_type='error',
|
||||
)
|
||||
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=progress_end,
|
||||
phase=f'{sync_phase_label.rstrip(".")} complete',
|
||||
log_line=f'Sync done: {total_synced} matched, {total_skipped} skipped, {sync_errors} errors',
|
||||
log_type='success' if sync_errors == 0 else 'warning',
|
||||
)
|
||||
|
||||
wishlist_queued = run_wishlist_phase(
|
||||
deps, automation_id,
|
||||
skip=skip_wishlist,
|
||||
progress_pct=progress_end + 1,
|
||||
wishlist_phase_label=wishlist_phase_label,
|
||||
wishlist_phase_start_log=wishlist_phase_start_log,
|
||||
)
|
||||
|
||||
return {
|
||||
'synced': total_synced,
|
||||
'skipped': total_skipped,
|
||||
'errors': sync_errors,
|
||||
'wishlist_queued': wishlist_queued,
|
||||
}
|
||||
|
||||
|
||||
def run_wishlist_phase(
|
||||
deps: AutomationDeps,
|
||||
automation_id: Optional[str],
|
||||
*,
|
||||
skip: bool,
|
||||
progress_pct: int,
|
||||
wishlist_phase_label: str = 'Phase: Processing wishlist...',
|
||||
wishlist_phase_start_log: str = 'Wishlist',
|
||||
) -> int:
|
||||
"""Trigger the wishlist processor unless skipped or already running.
|
||||
|
||||
Returns 1 when the processor was triggered, 0 otherwise. Errors are
|
||||
logged but never raised — wishlist failure should not abort the
|
||||
pipeline."""
|
||||
if skip:
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=progress_pct,
|
||||
log_line=f'{wishlist_phase_start_log}: skipped (disabled)',
|
||||
log_type='skip',
|
||||
)
|
||||
return 0
|
||||
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=progress_pct,
|
||||
phase=wishlist_phase_label,
|
||||
log_line=wishlist_phase_start_log,
|
||||
log_type='info',
|
||||
)
|
||||
|
||||
try:
|
||||
if not deps.is_wishlist_actually_processing():
|
||||
deps.process_wishlist_automatically(automation_id=None)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line='Wishlist processing triggered',
|
||||
log_type='success',
|
||||
)
|
||||
return 1
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line='Wishlist already running — skipped',
|
||||
log_type='skip',
|
||||
)
|
||||
return 0
|
||||
except Exception as e: # noqa: BLE001 — wishlist failure must never abort pipeline
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Wishlist error: {e}',
|
||||
log_type='warning',
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
__all__ = ['run_sync_and_wishlist', 'run_wishlist_phase']
|
||||
136
core/automation/handlers/database_update.py
Normal file
136
core/automation/handlers/database_update.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"""Automation handlers: ``start_database_update`` and
|
||||
``deep_scan_library`` actions.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers`` (the
|
||||
``_auto_start_database_update`` and ``_auto_deep_scan_library``
|
||||
closures). Both share the same ``db_update_state`` / executor / lock
|
||||
infrastructure -- the only difference is which task they submit
|
||||
(``run_db_update_task`` vs ``run_deep_scan_task``).
|
||||
|
||||
Pattern: pre-set state to running, submit task to executor, then
|
||||
poll the state dict until it transitions away from ``running``.
|
||||
Stall-detection emits a warning every 10 minutes when progress
|
||||
hasn't budged. 2-hour outer timeout caps the worst case.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
_TIMEOUT_SECONDS = 7200 # 2 hours — covers the worst large-library case
|
||||
_STALL_WARNING_SECONDS = 600 # 10 minutes without progress = stall
|
||||
_POLL_INTERVAL_SECONDS = 3
|
||||
_INITIAL_DELAY_SECONDS = 1
|
||||
|
||||
|
||||
def auto_start_database_update(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Run a full or incremental DB update via ``run_db_update_task``."""
|
||||
return _run_with_progress(
|
||||
config, deps,
|
||||
task=deps.run_db_update_task,
|
||||
task_args=(config.get('full_refresh', False), deps.config_manager.get_active_media_server()),
|
||||
initial_phase='Initializing...',
|
||||
stall_label='Database update',
|
||||
finished_extras=lambda: {'full_refresh': str(config.get('full_refresh', False))},
|
||||
timeout_label='Database update timed out after 2 hours',
|
||||
)
|
||||
|
||||
|
||||
def auto_deep_scan_library(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Run a deep library scan via ``run_deep_scan_task``."""
|
||||
return _run_with_progress(
|
||||
config, deps,
|
||||
task=deps.run_deep_scan_task,
|
||||
task_args=(deps.config_manager.get_active_media_server(),),
|
||||
initial_phase='Deep scan: Initializing...',
|
||||
stall_label='Deep scan',
|
||||
finished_extras=lambda: {},
|
||||
timeout_label='Deep scan timed out after 2 hours',
|
||||
)
|
||||
|
||||
|
||||
def _run_with_progress(
|
||||
config: Dict[str, Any],
|
||||
deps: AutomationDeps,
|
||||
*,
|
||||
task,
|
||||
task_args: tuple,
|
||||
initial_phase: str,
|
||||
stall_label: str,
|
||||
finished_extras,
|
||||
timeout_label: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Shared poll-and-wait body for both DB-update handlers."""
|
||||
automation_id = config.get('_automation_id')
|
||||
state = deps.get_db_update_state()
|
||||
if state.get('status') == 'running':
|
||||
return {'status': 'skipped', 'reason': 'Database update already running'}
|
||||
deps.state.db_update_automation_id = automation_id
|
||||
# Sync legacy module global so the DB-update progress callbacks
|
||||
# (still living in web_server.py) emit against this automation.
|
||||
deps.set_db_update_automation_id(automation_id)
|
||||
|
||||
with deps.db_update_lock:
|
||||
state.update({
|
||||
'status': 'running', 'phase': initial_phase,
|
||||
'progress': 0, 'current_item': '', 'processed': 0, 'total': 0,
|
||||
'error_message': '',
|
||||
})
|
||||
deps.db_update_executor.submit(task, *task_args)
|
||||
|
||||
# Monitor progress (callbacks handle card updates, we just block until done).
|
||||
time.sleep(_INITIAL_DELAY_SECONDS)
|
||||
poll_start = time.time()
|
||||
last_progress_time = time.time()
|
||||
last_progress_val = 0
|
||||
while time.time() - poll_start < _TIMEOUT_SECONDS:
|
||||
time.sleep(_POLL_INTERVAL_SECONDS)
|
||||
with deps.db_update_lock:
|
||||
current_status = state.get('status', 'idle')
|
||||
current_progress = state.get('progress', 0)
|
||||
if current_status != 'running':
|
||||
break
|
||||
# Stall detection — if no progress change in 10 minutes, warn.
|
||||
if current_progress != last_progress_val:
|
||||
last_progress_val = current_progress
|
||||
last_progress_time = time.time()
|
||||
elif time.time() - last_progress_time > _STALL_WARNING_SECONDS:
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'{stall_label} appears stalled — waiting...',
|
||||
log_type='warning',
|
||||
)
|
||||
last_progress_time = time.time() # Reset so warning repeats every 10 min.
|
||||
else:
|
||||
# 2-hour timeout reached.
|
||||
deps.update_progress(
|
||||
automation_id, status='error',
|
||||
phase='Timed out', log_line=timeout_label, log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'reason': 'Timed out', '_manages_own_progress': True}
|
||||
|
||||
# Finished/error callback already updated the card — return matching status.
|
||||
with deps.db_update_lock:
|
||||
final_status = state.get('status', 'unknown')
|
||||
if final_status == 'error':
|
||||
return {
|
||||
'status': 'error',
|
||||
'reason': state.get('error_message', 'Unknown error'),
|
||||
'_manages_own_progress': True,
|
||||
}
|
||||
with deps.db_update_lock:
|
||||
stats = {
|
||||
'status': 'completed', '_manages_own_progress': True,
|
||||
'artists': state.get('total', 0),
|
||||
'albums': state.get('total_albums', 0),
|
||||
'tracks': state.get('total_tracks', 0),
|
||||
'removed_artists': state.get('removed_artists', 0),
|
||||
'removed_albums': state.get('removed_albums', 0),
|
||||
'removed_tracks': state.get('removed_tracks', 0),
|
||||
}
|
||||
stats.update(finished_extras())
|
||||
return stats
|
||||
48
core/automation/handlers/discover_playlist.py
Normal file
48
core/automation/handlers/discover_playlist.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Automation handler: ``discover_playlist`` action.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers`` (the
|
||||
``_auto_discover_playlist`` closure). Kicks off background discovery
|
||||
of official Spotify / iTunes metadata for mirrored playlist tracks.
|
||||
The worker runs in a daemon thread and emits its own progress; this
|
||||
handler returns immediately after launching it (``_manages_own_progress``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
def auto_discover_playlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Discover official Spotify/iTunes metadata for mirrored
|
||||
playlist tracks. Runs the worker in a background thread."""
|
||||
db = deps.get_database()
|
||||
playlist_id = config.get('playlist_id')
|
||||
discover_all = config.get('all', False)
|
||||
|
||||
if discover_all:
|
||||
playlists = db.get_mirrored_playlists()
|
||||
elif playlist_id:
|
||||
p = db.get_mirrored_playlist(int(playlist_id))
|
||||
playlists = [p] if p else []
|
||||
else:
|
||||
return {'status': 'error', 'reason': 'No playlist specified'}
|
||||
|
||||
if not playlists:
|
||||
return {'status': 'error', 'reason': 'No playlists found'}
|
||||
|
||||
threading.Thread(
|
||||
target=deps.run_playlist_discovery_worker,
|
||||
args=(playlists, config.get('_automation_id')),
|
||||
daemon=True,
|
||||
name='auto-discover-playlist',
|
||||
).start()
|
||||
names = ', '.join(p['name'] for p in playlists[:3])
|
||||
return {
|
||||
'status': 'started',
|
||||
'playlist_count': str(len(playlists)),
|
||||
'playlists': names,
|
||||
'_manages_own_progress': True,
|
||||
}
|
||||
267
core/automation/handlers/download_cleanup.py
Normal file
267
core/automation/handlers/download_cleanup.py
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
"""Automation handlers: download-queue cleanup actions.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers``:
|
||||
- ``clean_search_history`` → :func:`auto_clean_search_history`
|
||||
- ``clean_completed_downloads`` → :func:`auto_clean_completed_downloads`
|
||||
- ``full_cleanup`` → :func:`auto_full_cleanup`
|
||||
|
||||
All three share the download-orchestrator + tasks_lock /
|
||||
download_batches / download_tasks accessors. ``full_cleanup`` is a
|
||||
multi-step orchestration that pulls in quarantine purge + staging
|
||||
sweep on top of the queue cleanup -- kept as one big handler since
|
||||
its phases share state-detection logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil as _shutil
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
# ─── clean_search_history ────────────────────────────────────────────
|
||||
|
||||
|
||||
def auto_clean_search_history(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Remove old searches from Soulseek when configured."""
|
||||
automation_id = config.get('_automation_id')
|
||||
# Skip if soulseek is not the active download source or in hybrid order.
|
||||
dl_mode = deps.config_manager.get('download_source.mode', 'hybrid')
|
||||
hybrid_order = deps.config_manager.get(
|
||||
'download_source.hybrid_order', ['hifi', 'youtube', 'soulseek'],
|
||||
)
|
||||
soulseek_active = (
|
||||
dl_mode == 'soulseek'
|
||||
or (dl_mode == 'hybrid' and 'soulseek' in hybrid_order)
|
||||
)
|
||||
# Reach the underlying SoulseekClient via the orchestrator's
|
||||
# generic accessor.
|
||||
slskd = deps.download_orchestrator.client('soulseek') if deps.download_orchestrator else None
|
||||
if not soulseek_active or not slskd or not slskd.base_url:
|
||||
deps.update_progress(automation_id, log_line='Soulseek not active — skipped', log_type='skip')
|
||||
return {'status': 'skipped'}
|
||||
if not deps.config_manager.get('soulseek.auto_clear_searches', True):
|
||||
deps.update_progress(
|
||||
automation_id, log_line='Auto-clear disabled in settings', log_type='skip',
|
||||
)
|
||||
return {'status': 'skipped'}
|
||||
try:
|
||||
success = deps.run_async(deps.download_orchestrator.maintain_search_history_with_buffer(
|
||||
keep_searches=50, trigger_threshold=200,
|
||||
))
|
||||
if success:
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line='Search history maintenance completed',
|
||||
log_type='success',
|
||||
)
|
||||
return {'status': 'completed'}
|
||||
else:
|
||||
deps.update_progress(automation_id, log_line='No cleanup needed', log_type='skip')
|
||||
return {'status': 'completed'}
|
||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise
|
||||
return {'status': 'error', 'error': str(e)}
|
||||
|
||||
|
||||
# ─── clean_completed_downloads ───────────────────────────────────────
|
||||
|
||||
|
||||
def auto_clean_completed_downloads(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Clear completed downloads + sweep empty download directories.
|
||||
Skips when active batches or post-processing is in flight."""
|
||||
automation_id = config.get('_automation_id')
|
||||
try:
|
||||
has_active_batches = False
|
||||
has_post_processing = False
|
||||
with deps.tasks_lock:
|
||||
batches = deps.get_download_batches()
|
||||
for batch_data in batches.values():
|
||||
if batch_data.get('phase') not in ['complete', 'error', 'cancelled', None]:
|
||||
has_active_batches = True
|
||||
break
|
||||
if not has_active_batches:
|
||||
tasks = deps.get_download_tasks()
|
||||
for task_data in tasks.values():
|
||||
if task_data.get('status') == 'post_processing':
|
||||
has_post_processing = True
|
||||
break
|
||||
|
||||
if has_active_batches:
|
||||
deps.update_progress(
|
||||
automation_id, log_line='Skipped — downloads active', log_type='skip',
|
||||
)
|
||||
return {'status': 'completed'}
|
||||
|
||||
deps.run_async(deps.download_orchestrator.clear_all_completed_downloads())
|
||||
if not has_post_processing:
|
||||
deps.sweep_empty_download_directories()
|
||||
deps.update_progress(
|
||||
automation_id, log_line='Download cleanup completed', log_type='success',
|
||||
)
|
||||
return {'status': 'completed'}
|
||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise
|
||||
return {'status': 'error', 'reason': str(e)}
|
||||
|
||||
|
||||
# ─── full_cleanup ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def auto_full_cleanup(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Run all cleanup tasks: quarantine purge → download queue clear
|
||||
→ empty-dir sweep → staging sweep → search history."""
|
||||
automation_id = config.get('_automation_id')
|
||||
steps = []
|
||||
|
||||
# --- 1. Clear quarantine ---
|
||||
deps.update_progress(automation_id, phase='Clearing quarantine...', progress=0)
|
||||
quarantine_path = os.path.join(
|
||||
deps.docker_resolve_path(deps.config_manager.get('soulseek.download_path', './downloads')),
|
||||
'ss_quarantine',
|
||||
)
|
||||
q_removed = 0
|
||||
if os.path.exists(quarantine_path):
|
||||
for f in os.listdir(quarantine_path):
|
||||
fp = os.path.join(quarantine_path, f)
|
||||
try:
|
||||
if os.path.isfile(fp):
|
||||
os.remove(fp)
|
||||
q_removed += 1
|
||||
elif os.path.isdir(fp):
|
||||
_shutil.rmtree(fp)
|
||||
q_removed += 1
|
||||
except Exception as e: # noqa: BLE001 — best-effort purge
|
||||
deps.logger.debug("quarantine entry purge failed: %s", e)
|
||||
steps.append(f'Quarantine: removed {q_removed} items')
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Quarantine: removed {q_removed} items',
|
||||
log_type='success' if q_removed else 'info',
|
||||
)
|
||||
|
||||
# --- 2. Clear completed/errored/cancelled downloads from Soulseek queue ---
|
||||
deps.update_progress(automation_id, phase='Clearing download queue...', progress=20)
|
||||
has_active_batches = False
|
||||
has_post_processing = False
|
||||
with deps.tasks_lock:
|
||||
batches = deps.get_download_batches()
|
||||
for batch_data in batches.values():
|
||||
if batch_data.get('phase') not in ['complete', 'error', 'cancelled', None]:
|
||||
has_active_batches = True
|
||||
break
|
||||
if not has_active_batches:
|
||||
tasks = deps.get_download_tasks()
|
||||
for task_data in tasks.values():
|
||||
if task_data.get('status') == 'post_processing':
|
||||
has_post_processing = True
|
||||
break
|
||||
if has_active_batches:
|
||||
steps.append('Download queue: skipped (active batches)')
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line='Download queue: skipped (active batches)',
|
||||
log_type='skip',
|
||||
)
|
||||
else:
|
||||
try:
|
||||
deps.run_async(deps.download_orchestrator.clear_all_completed_downloads())
|
||||
steps.append('Download queue: cleared')
|
||||
deps.update_progress(
|
||||
automation_id, log_line='Download queue: cleared', log_type='success',
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — per-step best-effort
|
||||
steps.append(f'Download queue: error ({e})')
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Download queue: error ({e})',
|
||||
log_type='error',
|
||||
)
|
||||
|
||||
# --- 3. Sweep empty download directories ---
|
||||
deps.update_progress(automation_id, phase='Sweeping empty directories...', progress=40)
|
||||
if has_active_batches or has_post_processing:
|
||||
reason = 'active batches' if has_active_batches else 'post-processing active'
|
||||
steps.append(f'Empty directories: skipped ({reason})')
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Empty directories: skipped ({reason})',
|
||||
log_type='skip',
|
||||
)
|
||||
else:
|
||||
dirs_removed = deps.sweep_empty_download_directories()
|
||||
steps.append(f'Empty directories: removed {dirs_removed}')
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Empty directories: removed {dirs_removed}',
|
||||
log_type='success' if dirs_removed else 'info',
|
||||
)
|
||||
|
||||
# --- 4. Sweep empty staging directories ---
|
||||
deps.update_progress(automation_id, phase='Sweeping import folder...', progress=60)
|
||||
staging_path = deps.get_staging_path()
|
||||
s_removed = 0
|
||||
if os.path.isdir(staging_path):
|
||||
for dirpath, _dirnames, _filenames in os.walk(staging_path, topdown=False):
|
||||
if os.path.normpath(dirpath) == os.path.normpath(staging_path):
|
||||
continue
|
||||
try:
|
||||
entries = os.listdir(dirpath)
|
||||
except OSError:
|
||||
continue
|
||||
visible = [e for e in entries if not e.startswith('.')]
|
||||
if not visible:
|
||||
for hidden in entries:
|
||||
try:
|
||||
os.remove(os.path.join(dirpath, hidden))
|
||||
except Exception as e: # noqa: BLE001 — best-effort
|
||||
deps.logger.debug("hidden file cleanup failed: %s", e)
|
||||
try:
|
||||
os.rmdir(dirpath)
|
||||
s_removed += 1
|
||||
except OSError:
|
||||
pass
|
||||
steps.append(f'Staging: removed {s_removed} empty directories')
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Staging: removed {s_removed} empty directories',
|
||||
log_type='success' if s_removed else 'info',
|
||||
)
|
||||
|
||||
# --- 5. Clean search history (if enabled) ---
|
||||
deps.update_progress(automation_id, phase='Cleaning search history...', progress=80)
|
||||
try:
|
||||
if not deps.config_manager.get('soulseek.auto_clear_searches', True):
|
||||
steps.append('Search cleanup: disabled in settings')
|
||||
deps.update_progress(
|
||||
automation_id, log_line='Search cleanup: disabled in settings', log_type='skip',
|
||||
)
|
||||
else:
|
||||
deps.run_async(deps.download_orchestrator.maintain_search_history_with_buffer(
|
||||
keep_searches=50, trigger_threshold=200,
|
||||
))
|
||||
steps.append('Search history: cleaned')
|
||||
deps.update_progress(
|
||||
automation_id, log_line='Search history: cleaned', log_type='success',
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — per-step best-effort
|
||||
steps.append(f'Search history: error ({e})')
|
||||
deps.update_progress(
|
||||
automation_id, log_line=f'Search history: error ({e})', log_type='error',
|
||||
)
|
||||
|
||||
total_removed = q_removed + s_removed
|
||||
deps.update_progress(
|
||||
automation_id, status='finished', progress=100,
|
||||
phase='Complete',
|
||||
log_line=f'Full cleanup complete — {total_removed} items removed',
|
||||
log_type='success',
|
||||
)
|
||||
return {
|
||||
'status': 'completed',
|
||||
'quarantine_removed': str(q_removed),
|
||||
'staging_removed': str(s_removed),
|
||||
'total_removed': str(total_removed),
|
||||
'steps': steps,
|
||||
'_manages_own_progress': True,
|
||||
}
|
||||
87
core/automation/handlers/duplicate_cleaner.py
Normal file
87
core/automation/handlers/duplicate_cleaner.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Automation handler: ``run_duplicate_cleaner`` action.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers`` (the
|
||||
``_auto_run_duplicate_cleaner`` closure). Submits the duplicate
|
||||
cleaner to its executor, then polls the shared state dict until
|
||||
the worker transitions away from ``running``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
_TIMEOUT_SECONDS = 7200 # 2 hours
|
||||
_POLL_INTERVAL_SECONDS = 3
|
||||
_INITIAL_DELAY_SECONDS = 1
|
||||
|
||||
|
||||
def auto_run_duplicate_cleaner(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Kick off the duplicate cleaner and report final stats."""
|
||||
automation_id = config.get('_automation_id')
|
||||
state = deps.get_duplicate_cleaner_state()
|
||||
if state.get('status') == 'running':
|
||||
return {'status': 'skipped', 'reason': 'Duplicate cleaner already running'}
|
||||
|
||||
# Pre-set status before submit so the polling loop doesn't see a
|
||||
# stale 'finished' from a previous run.
|
||||
with deps.duplicate_cleaner_lock:
|
||||
state['status'] = 'running'
|
||||
deps.duplicate_cleaner_executor.submit(deps.run_duplicate_cleaner)
|
||||
deps.update_progress(automation_id, log_line='Duplicate cleaner started', log_type='info')
|
||||
|
||||
# Monitor progress (max 2 hours).
|
||||
time.sleep(_INITIAL_DELAY_SECONDS)
|
||||
poll_start = time.time()
|
||||
while time.time() - poll_start < _TIMEOUT_SECONDS:
|
||||
time.sleep(_POLL_INTERVAL_SECONDS)
|
||||
current_status = state.get('status', 'idle')
|
||||
if current_status not in ('running',):
|
||||
break
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
phase=state.get('phase', 'Scanning...'),
|
||||
progress=state.get('progress', 0),
|
||||
processed=state.get('files_scanned', 0),
|
||||
total=state.get('total_files', 0),
|
||||
)
|
||||
else:
|
||||
# 2-hour timeout reached.
|
||||
deps.update_progress(
|
||||
automation_id, status='error',
|
||||
phase='Timed out',
|
||||
log_line='Duplicate cleaner timed out after 2 hours',
|
||||
log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'reason': 'Timed out', '_manages_own_progress': True}
|
||||
|
||||
# Check actual exit status (could be 'finished' or 'error').
|
||||
final_status = state.get('status', 'idle')
|
||||
if final_status == 'error':
|
||||
err = state.get('error_message', 'Unknown error')
|
||||
deps.update_progress(
|
||||
automation_id, status='error', progress=100,
|
||||
phase='Error', log_line=err, log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'reason': err, '_manages_own_progress': True}
|
||||
|
||||
dupes = state.get('duplicates_found', 0)
|
||||
removed = state.get('deleted', 0)
|
||||
space_freed = state.get('space_freed', 0)
|
||||
scanned = state.get('files_scanned', 0)
|
||||
deps.update_progress(
|
||||
automation_id, status='finished', progress=100,
|
||||
phase='Complete',
|
||||
log_line=f'Found {dupes} duplicates, removed {removed} files',
|
||||
log_type='success',
|
||||
)
|
||||
return {
|
||||
'status': 'completed', '_manages_own_progress': True,
|
||||
'files_scanned': scanned,
|
||||
'duplicates_found': dupes,
|
||||
'files_deleted': removed,
|
||||
'space_freed_mb': round(space_freed / (1024 * 1024), 1),
|
||||
}
|
||||
213
core/automation/handlers/maintenance.py
Normal file
213
core/automation/handlers/maintenance.py
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
"""Automation handlers: short maintenance actions.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers``:
|
||||
- ``clear_quarantine`` → :func:`auto_clear_quarantine`
|
||||
- ``cleanup_wishlist`` → :func:`auto_cleanup_wishlist`
|
||||
- ``update_discovery_pool`` → :func:`auto_update_discovery_pool`
|
||||
- ``backup_database`` → :func:`auto_backup_database`
|
||||
- ``refresh_beatport_cache`` → :func:`auto_refresh_beatport_cache`
|
||||
|
||||
Each is a thin wrapper around an existing service / helper. Grouped
|
||||
in one module because every body is short and they share no state
|
||||
between them — splitting into per-handler files would just add
|
||||
import noise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob as _glob
|
||||
import os
|
||||
import shutil as _shutil
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
# ─── clear_quarantine ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def auto_clear_quarantine(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Purge every file/folder under the configured ss_quarantine path."""
|
||||
automation_id = config.get('_automation_id')
|
||||
quarantine_path = os.path.join(
|
||||
deps.docker_resolve_path(deps.config_manager.get('soulseek.download_path', './downloads')),
|
||||
'ss_quarantine',
|
||||
)
|
||||
if not os.path.exists(quarantine_path):
|
||||
deps.update_progress(automation_id, log_line='No quarantine folder found', log_type='info')
|
||||
return {'status': 'completed', 'removed': '0'}
|
||||
removed = 0
|
||||
for f in os.listdir(quarantine_path):
|
||||
fp = os.path.join(quarantine_path, f)
|
||||
try:
|
||||
if os.path.isfile(fp):
|
||||
os.remove(fp)
|
||||
removed += 1
|
||||
elif os.path.isdir(fp):
|
||||
_shutil.rmtree(fp)
|
||||
removed += 1
|
||||
except Exception as e: # noqa: BLE001 — best-effort purge
|
||||
deps.logger.debug("quarantine entry purge failed: %s", e)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Removed {removed} quarantined items',
|
||||
log_type='success' if removed > 0 else 'info',
|
||||
)
|
||||
return {'status': 'completed', 'removed': str(removed)}
|
||||
|
||||
|
||||
# ─── cleanup_wishlist ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def auto_cleanup_wishlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Drop duplicate entries from the wishlist for the active profile."""
|
||||
automation_id = config.get('_automation_id')
|
||||
db = deps.get_database()
|
||||
removed = db.remove_wishlist_duplicates(deps.get_current_profile_id())
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Removed {removed or 0} duplicate wishlist entries',
|
||||
log_type='success' if removed else 'info',
|
||||
)
|
||||
return {'status': 'completed', 'removed': str(removed or 0)}
|
||||
|
||||
|
||||
# ─── update_discovery_pool ───────────────────────────────────────────
|
||||
|
||||
|
||||
def auto_update_discovery_pool(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Run an incremental refresh of the discovery pool via the
|
||||
watchlist scanner."""
|
||||
automation_id = config.get('_automation_id')
|
||||
try:
|
||||
scanner = deps.get_watchlist_scanner(deps.spotify_client)
|
||||
deps.update_progress(automation_id, log_line='Updating discovery pool...', log_type='info')
|
||||
scanner.update_discovery_pool_incremental(deps.get_current_profile_id())
|
||||
deps.update_progress(
|
||||
automation_id, status='finished', progress=100,
|
||||
phase='Complete', log_line='Discovery pool updated', log_type='success',
|
||||
)
|
||||
return {'status': 'completed', '_manages_own_progress': True}
|
||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise
|
||||
deps.update_progress(
|
||||
automation_id, status='error',
|
||||
phase='Error', log_line=str(e), log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'reason': str(e), '_manages_own_progress': True}
|
||||
|
||||
|
||||
# ─── backup_database ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
_MAX_BACKUPS = 5
|
||||
|
||||
|
||||
def auto_backup_database(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Create a hot SQLite backup, then prune old backups so only the
|
||||
newest ``_MAX_BACKUPS`` remain."""
|
||||
automation_id = config.get('_automation_id')
|
||||
db_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')
|
||||
if not os.path.exists(db_path):
|
||||
return {'status': 'error', 'reason': 'Database file not found'}
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_path = f"{db_path}.backup_{timestamp}"
|
||||
# Use SQLite backup API for a safe hot-copy of an active database.
|
||||
src = sqlite3.connect(db_path)
|
||||
dst = sqlite3.connect(backup_path)
|
||||
src.backup(dst)
|
||||
dst.close()
|
||||
src.close()
|
||||
size_mb = round(os.path.getsize(backup_path) / (1024 * 1024), 1)
|
||||
|
||||
# Rolling cleanup — keep only the newest N backups.
|
||||
existing = sorted(_glob.glob(f"{db_path}.backup_*"), key=os.path.getmtime)
|
||||
while len(existing) > _MAX_BACKUPS:
|
||||
try:
|
||||
os.remove(existing.pop(0))
|
||||
except Exception as e: # noqa: BLE001 — best-effort cleanup
|
||||
deps.logger.debug("rolling backup cleanup failed: %s", e)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Backup created: {size_mb}MB ({os.path.basename(backup_path)})',
|
||||
log_type='success',
|
||||
)
|
||||
return {'status': 'completed', 'backup_path': backup_path, 'size_mb': str(size_mb)}
|
||||
|
||||
|
||||
# ─── refresh_beatport_cache ──────────────────────────────────────────
|
||||
|
||||
|
||||
_BEATPORT_SECTIONS = (
|
||||
('hero_tracks', '/api/beatport/hero-tracks', 'Hero Tracks'),
|
||||
('new_releases', '/api/beatport/new-releases', 'New Releases'),
|
||||
('featured_charts', '/api/beatport/featured-charts', 'Featured Charts'),
|
||||
('dj_charts', '/api/beatport/dj-charts', 'DJ Charts'),
|
||||
('top_10_lists', '/api/beatport/homepage/top-10-lists', 'Top 10 Lists'),
|
||||
('top_10_releases', '/api/beatport/homepage/top-10-releases-cards', 'Top 10 Releases'),
|
||||
('hype_picks', '/api/beatport/hype-picks', 'Hype Picks'),
|
||||
)
|
||||
|
||||
|
||||
def auto_refresh_beatport_cache(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Refresh Beatport homepage cache by calling each endpoint internally
|
||||
via Flask's ``test_client``. Invalidates the homepage cache first
|
||||
so endpoints re-scrape rather than returning stale data."""
|
||||
automation_id = config.get('_automation_id')
|
||||
cache = deps.get_beatport_data_cache()
|
||||
# Invalidate all homepage cache timestamps so endpoints re-scrape.
|
||||
with cache['cache_lock']:
|
||||
for key in cache['homepage']:
|
||||
cache['homepage'][key]['timestamp'] = 0
|
||||
cache['homepage'][key]['data'] = None
|
||||
|
||||
refreshed = 0
|
||||
errors = []
|
||||
app = deps.get_app()
|
||||
with app.test_client() as client:
|
||||
for idx, (_, endpoint, label) in enumerate(_BEATPORT_SECTIONS):
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=(idx / len(_BEATPORT_SECTIONS)) * 100,
|
||||
phase=f'Scraping: {label}',
|
||||
current_item=label,
|
||||
)
|
||||
try:
|
||||
resp = client.get(endpoint)
|
||||
if resp.status_code == 200:
|
||||
refreshed += 1
|
||||
deps.update_progress(
|
||||
automation_id, log_line=f'{label}: cached', log_type='success',
|
||||
)
|
||||
else:
|
||||
errors.append(label)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'{label}: HTTP {resp.status_code}',
|
||||
log_type='error',
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — per-section best-effort
|
||||
errors.append(label)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'{label}: {str(e)}',
|
||||
log_type='error',
|
||||
)
|
||||
if idx < len(_BEATPORT_SECTIONS) - 1:
|
||||
time.sleep(2)
|
||||
|
||||
deps.update_progress(
|
||||
automation_id, status='finished', progress=100,
|
||||
phase='Complete',
|
||||
log_line=f'Refreshed {refreshed}/{len(_BEATPORT_SECTIONS)} sections',
|
||||
log_type='success',
|
||||
)
|
||||
return {
|
||||
'status': 'completed',
|
||||
'refreshed': str(refreshed),
|
||||
'errors': str(len(errors)),
|
||||
'_manages_own_progress': True,
|
||||
}
|
||||
356
core/automation/handlers/personalized_pipeline.py
Normal file
356
core/automation/handlers/personalized_pipeline.py
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
"""Personalized Playlist Pipeline automation handler.
|
||||
|
||||
Sibling to ``auto_playlist_pipeline`` (mirrored). Where the mirrored
|
||||
pipeline runs REFRESH external sources → DISCOVER metadata → SYNC →
|
||||
WISHLIST, the personalized pipeline is simpler:
|
||||
|
||||
SNAPSHOT → SYNC → WISHLIST
|
||||
|
||||
SNAPSHOT reads the persisted track list from
|
||||
``PersonalizedPlaylistManager``. When ``refresh_first=True`` (config),
|
||||
each playlist is refreshed BEFORE syncing — useful when the user
|
||||
wants the cron to capture a fresh-each-run view (e.g. "give me a new
|
||||
Hidden Gems set every night"). Default is to sync the existing
|
||||
snapshot, on the assumption the user / a separate cron has already
|
||||
refreshed when they wanted to.
|
||||
|
||||
Config schema:
|
||||
{
|
||||
'kinds': [
|
||||
{'kind': 'hidden_gems'},
|
||||
{'kind': 'time_machine', 'variant': '1980s'},
|
||||
{'kind': 'seasonal_mix', 'variant': 'halloween'},
|
||||
...
|
||||
],
|
||||
'refresh_first': bool, # default false
|
||||
'skip_wishlist': bool, # default false
|
||||
}
|
||||
|
||||
Each kind dict has at minimum ``kind``; ``variant`` is required for
|
||||
kinds that need it (time_machine, genre_playlist, daily_mix,
|
||||
seasonal_mix). Singleton kinds (hidden_gems, discovery_shuffle,
|
||||
popular_picks, fresh_tape, archives) ignore variant.
|
||||
|
||||
Pipeline-running flag (``deps.state.pipeline_running``) is shared
|
||||
with the mirrored pipeline so the two can't overlap. (One sync
|
||||
queue, one wishlist worker — overlapping triggers would step on
|
||||
each other.)"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
from core.automation.handlers._pipeline_shared import run_sync_and_wishlist
|
||||
|
||||
|
||||
# Sync state key prefix so personalized syncs don't collide with
|
||||
# mirrored ones (`auto_mirror_<id>`).
|
||||
_SYNC_ID_PREFIX = 'auto_personalized'
|
||||
|
||||
|
||||
def auto_personalized_pipeline(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Run SNAPSHOT → SYNC → WISHLIST for selected personalized playlists."""
|
||||
deps.state.set_pipeline_running(True)
|
||||
automation_id = config.get('_automation_id')
|
||||
pipeline_start = time.time()
|
||||
|
||||
try:
|
||||
kinds_config = config.get('kinds') or []
|
||||
if not isinstance(kinds_config, list) or not kinds_config:
|
||||
deps.state.set_pipeline_running(False)
|
||||
return {
|
||||
'status': 'error',
|
||||
'error': 'No personalized playlist kinds selected',
|
||||
}
|
||||
|
||||
refresh_first = bool(config.get('refresh_first', False))
|
||||
skip_wishlist = bool(config.get('skip_wishlist', False))
|
||||
|
||||
manager = deps.build_personalized_manager()
|
||||
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=2,
|
||||
phase=f'Personalized pipeline: {len(kinds_config)} playlist(s)',
|
||||
log_line=f'Starting pipeline for {len(kinds_config)} playlist(s)',
|
||||
log_type='info',
|
||||
)
|
||||
|
||||
# ── PHASE 1: SNAPSHOT (optionally refresh) ──────────────────
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=3,
|
||||
phase='Phase 1/2: Loading snapshots...' if not refresh_first
|
||||
else 'Phase 1/2: Refreshing snapshots...',
|
||||
log_line='Phase 1: Snapshot' + (' (with refresh)' if refresh_first else ''),
|
||||
log_type='info',
|
||||
)
|
||||
|
||||
profile_id = deps.get_current_profile_id()
|
||||
playload_payloads = _build_payloads_for_kinds(
|
||||
deps, manager, kinds_config, profile_id,
|
||||
automation_id=automation_id,
|
||||
refresh_first=refresh_first,
|
||||
)
|
||||
|
||||
if not playload_payloads:
|
||||
deps.state.set_pipeline_running(False)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
status='finished', progress=100,
|
||||
phase='No playlists to sync',
|
||||
log_line='No personalized playlists had tracks to sync',
|
||||
log_type='warning',
|
||||
)
|
||||
return {
|
||||
'status': 'completed',
|
||||
'_manages_own_progress': True,
|
||||
'playlists_synced': '0',
|
||||
'tracks_synced': '0',
|
||||
'duration_seconds': str(int(time.time() - pipeline_start)),
|
||||
}
|
||||
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=50,
|
||||
phase='Phase 1/2: Snapshot complete',
|
||||
log_line=f'Phase 1 done: {len(playload_payloads)} playlist(s) ready to sync',
|
||||
log_type='success',
|
||||
)
|
||||
|
||||
# ── PHASE 2: SYNC + WISHLIST (shared helper) ────────────────
|
||||
sync_summary = run_sync_and_wishlist(
|
||||
deps,
|
||||
automation_id,
|
||||
playload_payloads,
|
||||
sync_one_fn=lambda pl: _sync_personalized_playlist(deps, pl),
|
||||
sync_id_for_fn=lambda pl: pl['sync_id'],
|
||||
skip_wishlist=skip_wishlist,
|
||||
progress_start=51,
|
||||
progress_end=90,
|
||||
sync_phase_label='Phase 2/2: Syncing to server...',
|
||||
sync_phase_start_log='Phase 2: Sync',
|
||||
wishlist_phase_label='Phase 2/2: Processing wishlist...',
|
||||
wishlist_phase_start_log='Wishlist',
|
||||
)
|
||||
|
||||
# ── COMPLETE ────────────────────────────────────────────────
|
||||
duration = int(time.time() - pipeline_start)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
status='finished', progress=100,
|
||||
phase='Pipeline complete',
|
||||
log_line=f'Personalized 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_synced': str(len(playload_payloads)),
|
||||
'tracks_synced': str(sync_summary['synced']),
|
||||
'sync_skipped': str(sync_summary['skipped']),
|
||||
'wishlist_queued': str(sync_summary['wishlist_queued']),
|
||||
'duration_seconds': str(duration),
|
||||
}
|
||||
|
||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise into engine
|
||||
deps.state.set_pipeline_running(False)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
status='error', progress=100,
|
||||
phase='Pipeline error',
|
||||
log_line=f'Personalized pipeline failed: {e}',
|
||||
log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'error': str(e), '_manages_own_progress': True}
|
||||
|
||||
|
||||
def _build_payloads_for_kinds(
|
||||
deps: AutomationDeps,
|
||||
manager: Any,
|
||||
kinds_config: List[Dict[str, Any]],
|
||||
profile_id: int,
|
||||
*,
|
||||
automation_id: Optional[str],
|
||||
refresh_first: bool,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Resolve each requested kind+variant into a sync-payload dict.
|
||||
|
||||
Each payload has: ``{'name', 'kind', 'variant', 'tracks_json',
|
||||
'image_url', 'sync_id'}``. Playlists with no tracks (e.g. a
|
||||
seasonal mix that hasn't been populated yet) are omitted from
|
||||
the result so the sync loop doesn't waste time on empty pushes.
|
||||
"""
|
||||
payloads: List[Dict[str, Any]] = []
|
||||
for entry in kinds_config:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
kind = entry.get('kind')
|
||||
variant = entry.get('variant') or ''
|
||||
if not kind:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Refresh when ANY of:
|
||||
# - explicit user flag (cron use case: regenerate each run)
|
||||
# - snapshot marked stale by upstream data refresher
|
||||
# - playlist was never generated yet (auto-created by
|
||||
# ensure_playlist; track_count=0, last_generated_at=NULL).
|
||||
# Without this branch, a first-run pipeline reads the
|
||||
# empty snapshot and silently skips — user picks a kind,
|
||||
# hits run, gets "No tracks to sync" with no clue why.
|
||||
if refresh_first:
|
||||
record = manager.refresh_playlist(kind, variant, profile_id)
|
||||
else:
|
||||
existing = manager.ensure_playlist(kind, variant, profile_id)
|
||||
needs_first_gen = existing.last_generated_at is None
|
||||
if existing.is_stale or needs_first_gen:
|
||||
record = manager.refresh_playlist(kind, variant, profile_id)
|
||||
else:
|
||||
record = existing
|
||||
except Exception as exc: # noqa: BLE001 — log + continue with next kind
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Skipping {kind}{("/" + variant) if variant else ""}: {exc}',
|
||||
log_type='warning',
|
||||
)
|
||||
continue
|
||||
|
||||
tracks = manager.get_playlist_tracks(record.id)
|
||||
if not tracks:
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'No tracks in {record.name} — skipping sync',
|
||||
log_type='skip',
|
||||
)
|
||||
continue
|
||||
|
||||
tracks_json = [_track_to_sync_shape(t) for t in tracks]
|
||||
payloads.append({
|
||||
'name': record.name,
|
||||
'kind': record.kind,
|
||||
'variant': record.variant,
|
||||
'tracks_json': tracks_json,
|
||||
'image_url': '', # personalized playlists don't have a cover image yet
|
||||
'sync_id': f'{_SYNC_ID_PREFIX}_{record.kind}_{record.variant or "_"}',
|
||||
})
|
||||
return payloads
|
||||
|
||||
|
||||
def _track_to_sync_shape(track: Any) -> Dict[str, Any]:
|
||||
"""Convert a personalized.types.Track into the dict shape
|
||||
`_run_sync_task` expects. Mirrors what the mirrored pipeline
|
||||
builds from extra_data.matched_data, preserving enriched metadata
|
||||
from personalized snapshots when available."""
|
||||
primary_id = track.spotify_track_id or track.itunes_track_id or track.deezer_track_id or ''
|
||||
rich_data = _coerce_track_data_json(getattr(track, 'track_data_json', None))
|
||||
|
||||
if not rich_data:
|
||||
album = {'name': track.album_name or ''}
|
||||
cover_url = getattr(track, 'album_cover_url', None)
|
||||
if cover_url:
|
||||
album['images'] = [{'url': cover_url}]
|
||||
return {
|
||||
'name': track.track_name,
|
||||
'artists': [{'name': track.artist_name}],
|
||||
'album': album,
|
||||
'duration_ms': int(track.duration_ms or 0),
|
||||
'id': primary_id,
|
||||
}
|
||||
|
||||
payload = dict(rich_data)
|
||||
cover_url = (
|
||||
getattr(track, 'album_cover_url', None)
|
||||
or payload.get('album_cover_url')
|
||||
or payload.get('image_url')
|
||||
)
|
||||
payload['id'] = payload.get('id') or primary_id
|
||||
payload['name'] = payload.get('name') or track.track_name
|
||||
payload['artists'] = _normalize_artists(payload.get('artists'), track.artist_name)
|
||||
payload['album'] = _normalize_album(payload.get('album'), track, cover_url=cover_url)
|
||||
payload['duration_ms'] = int(payload.get('duration_ms') or track.duration_ms or 0)
|
||||
if 'popularity' not in payload and getattr(track, 'popularity', None) is not None:
|
||||
payload['popularity'] = int(track.popularity or 0)
|
||||
|
||||
if cover_url and not payload.get('image_url'):
|
||||
payload['image_url'] = cover_url
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def _coerce_track_data_json(value: Any) -> Dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str) and value.strip():
|
||||
try:
|
||||
loaded = json.loads(value)
|
||||
except (TypeError, ValueError):
|
||||
return {}
|
||||
return loaded if isinstance(loaded, dict) else {}
|
||||
return {}
|
||||
|
||||
|
||||
def _normalize_artists(artists: Any, fallback_artist: str) -> List[Dict[str, Any]]:
|
||||
if not artists:
|
||||
return [{'name': fallback_artist or 'Unknown Artist'}]
|
||||
if isinstance(artists, list):
|
||||
normalized = []
|
||||
for artist in artists:
|
||||
if isinstance(artist, dict):
|
||||
normalized.append(artist if artist.get('name') else {'name': str(artist)})
|
||||
elif isinstance(artist, str):
|
||||
normalized.append({'name': artist})
|
||||
else:
|
||||
normalized.append({'name': str(artist)})
|
||||
return normalized or [{'name': fallback_artist or 'Unknown Artist'}]
|
||||
if isinstance(artists, dict):
|
||||
return [artists if artists.get('name') else {'name': str(artists)}]
|
||||
return [{'name': str(artists)}]
|
||||
|
||||
|
||||
def _normalize_album(album: Any, track: Any, cover_url: Optional[str] = None) -> Dict[str, Any]:
|
||||
if isinstance(album, dict):
|
||||
normalized = dict(album)
|
||||
else:
|
||||
normalized = {'name': str(album) if album else (track.album_name or '')}
|
||||
|
||||
normalized['name'] = normalized.get('name') or track.album_name or ''
|
||||
images = normalized.get('images')
|
||||
if not isinstance(images, list):
|
||||
images = []
|
||||
if cover_url and not images:
|
||||
images = [{'url': cover_url}]
|
||||
if images:
|
||||
normalized['images'] = images
|
||||
return normalized
|
||||
|
||||
|
||||
def _sync_personalized_playlist(deps: AutomationDeps, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Launch a personalized playlist sync via _run_sync_task on a
|
||||
daemon thread + return immediately with status='started'.
|
||||
|
||||
Mirrors the mirrored ``auto_sync_playlist`` return contract so the
|
||||
shared helper can poll on ``sync_states[sync_id]`` and aggregate
|
||||
results identically."""
|
||||
sync_id = payload['sync_id']
|
||||
name = payload['name']
|
||||
tracks_json = payload['tracks_json']
|
||||
profile_id = deps.get_current_profile_id()
|
||||
|
||||
threading.Thread(
|
||||
target=deps.run_sync_task,
|
||||
args=(sync_id, name, tracks_json, None, profile_id, payload.get('image_url', '')),
|
||||
daemon=True,
|
||||
name=f'auto-personalized-{sync_id}',
|
||||
).start()
|
||||
return {
|
||||
'status': 'started',
|
||||
'playlist_name': name,
|
||||
'_manages_own_progress': True,
|
||||
}
|
||||
227
core/automation/handlers/playlist_pipeline.py
Normal file
227
core/automation/handlers/playlist_pipeline.py
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
"""Automation handler: ``playlist_pipeline`` action.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
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}
|
||||
27
core/automation/handlers/process_wishlist.py
Normal file
27
core/automation/handlers/process_wishlist.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""Automation handler: ``process_wishlist`` action.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers`` (the
|
||||
``_auto_process_wishlist`` closure). Wishlist processing is async —
|
||||
the helper submits a batch to an executor and returns immediately;
|
||||
per-track stats arrive later via batch-completion callbacks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
def auto_process_wishlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Kick off the wishlist processor for an automation trigger.
|
||||
|
||||
Returns immediately after submission; the wishlist worker emits
|
||||
per-batch progress via its own callbacks. We only report
|
||||
``status: completed`` to mark the trigger fired successfully.
|
||||
"""
|
||||
try:
|
||||
deps.process_wishlist_automatically(automation_id=config.get('_automation_id'))
|
||||
return {'status': 'completed'}
|
||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise into the engine
|
||||
return {'status': 'error', 'error': str(e)}
|
||||
89
core/automation/handlers/progress_callbacks.py
Normal file
89
core/automation/handlers/progress_callbacks.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""Progress + history callbacks the automation engine invokes around
|
||||
each handler run.
|
||||
|
||||
Lifted from the closures at the bottom of
|
||||
``web_server._register_automation_handlers``:
|
||||
- ``_progress_init`` → :func:`progress_init`
|
||||
- ``_progress_finish`` → :func:`progress_finish`
|
||||
- ``_record_automation_history`` → :func:`record_history`
|
||||
- ``_on_library_scan_completed`` → :func:`on_library_scan_completed`
|
||||
|
||||
The engine accepts four callables via
|
||||
``register_progress_callbacks(init, finish, update, history)``;
|
||||
``registration.register_all`` wires these here. The
|
||||
``library_scan_completed`` callback is registered separately on the
|
||||
``web_scan_manager`` (when one is available) -- see
|
||||
``register_library_scan_completed_emitter``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
def progress_init(aid: Any, name: str, action_type: str, deps: AutomationDeps) -> None:
|
||||
"""Initialize per-automation progress state when the engine starts
|
||||
a handler. Thin wrapper so the engine receives a closure that
|
||||
delegates into the live progress tracker."""
|
||||
deps.init_automation_progress(aid, name, action_type)
|
||||
|
||||
|
||||
def progress_finish(aid: Any, result: Dict[str, Any], deps: AutomationDeps) -> None:
|
||||
"""Emit the final progress update when a handler returns.
|
||||
|
||||
Skipped for handlers that manage their own progress lifecycle
|
||||
(they call ``update_progress(status='finished')`` themselves and
|
||||
set ``_manages_own_progress: True`` in the returned dict).
|
||||
Otherwise translates the handler's status into a finished/error
|
||||
progress emit with a status-appropriate phase + log line.
|
||||
"""
|
||||
if result.get('_manages_own_progress'):
|
||||
return
|
||||
result_status = result.get('status', '')
|
||||
status = 'error' if result_status == 'error' else 'finished'
|
||||
msg = result.get('error', result.get('reason', result_status or 'done'))
|
||||
deps.update_progress(
|
||||
aid,
|
||||
status=status,
|
||||
progress=100,
|
||||
phase='Error' if status == 'error' else 'Complete',
|
||||
log_line=msg,
|
||||
log_type='error' if status == 'error' else 'success',
|
||||
)
|
||||
|
||||
|
||||
def record_history(aid: Any, result: Dict[str, Any], deps: AutomationDeps) -> None:
|
||||
"""Capture progress state into run history before the engine's
|
||||
cleanup pass clears it. Thin wrapper so the engine sees a stable
|
||||
callable."""
|
||||
deps.record_progress_history(aid, result, deps.get_database())
|
||||
|
||||
|
||||
def on_library_scan_completed(deps: AutomationDeps) -> None:
|
||||
"""Emit the ``library_scan_completed`` automation event with the
|
||||
active media-server type. Replaces the hard-coded
|
||||
``scan_completion_callback → trigger_automatic_database_update``
|
||||
chain so any automation can listen for scan completion as a
|
||||
trigger."""
|
||||
if not deps.engine:
|
||||
return
|
||||
server_type = (
|
||||
getattr(deps.web_scan_manager, '_current_server_type', None)
|
||||
or 'unknown'
|
||||
)
|
||||
deps.engine.emit('library_scan_completed', {
|
||||
'server_type': server_type,
|
||||
})
|
||||
|
||||
|
||||
def register_library_scan_completed_emitter(deps: AutomationDeps) -> None:
|
||||
"""Wire :func:`on_library_scan_completed` to the
|
||||
``web_scan_manager``'s scan-completion callback list. No-op when
|
||||
no scan manager is configured (e.g. headless / test contexts)."""
|
||||
if not deps.web_scan_manager:
|
||||
return
|
||||
deps.web_scan_manager.add_scan_completion_callback(
|
||||
lambda: on_library_scan_completed(deps),
|
||||
)
|
||||
83
core/automation/handlers/quality_scanner.py
Normal file
83
core/automation/handlers/quality_scanner.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Automation handler: ``start_quality_scan`` action.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers`` (the
|
||||
``_auto_start_quality_scan`` closure). Submits the quality scanner
|
||||
to its executor with the configured scope (default: ``watchlist``)
|
||||
then polls the shared state dict.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
_TIMEOUT_SECONDS = 7200 # 2 hours
|
||||
_POLL_INTERVAL_SECONDS = 3
|
||||
_INITIAL_DELAY_SECONDS = 1
|
||||
|
||||
|
||||
def auto_start_quality_scan(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
automation_id = config.get('_automation_id')
|
||||
state = deps.get_quality_scanner_state()
|
||||
if state.get('status') == 'running':
|
||||
return {'status': 'skipped', 'reason': 'Quality scan already running'}
|
||||
|
||||
scope = config.get('scope', 'watchlist')
|
||||
# Pre-set status before submit so the polling loop doesn't see a
|
||||
# stale 'finished' from a previous run.
|
||||
with deps.quality_scanner_lock:
|
||||
state['status'] = 'running'
|
||||
deps.quality_scanner_executor.submit(deps.run_quality_scanner, scope, deps.get_current_profile_id())
|
||||
deps.update_progress(
|
||||
automation_id, log_line=f'Quality scan started (scope: {scope})', log_type='info',
|
||||
)
|
||||
|
||||
# Monitor progress (max 2 hours).
|
||||
time.sleep(_INITIAL_DELAY_SECONDS)
|
||||
poll_start = time.time()
|
||||
while time.time() - poll_start < _TIMEOUT_SECONDS:
|
||||
time.sleep(_POLL_INTERVAL_SECONDS)
|
||||
current_status = state.get('status', 'idle')
|
||||
if current_status not in ('running',):
|
||||
break
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
phase=state.get('phase', 'Scanning...'),
|
||||
progress=state.get('progress', 0),
|
||||
processed=state.get('processed', 0),
|
||||
total=state.get('total', 0),
|
||||
)
|
||||
else:
|
||||
deps.update_progress(
|
||||
automation_id, status='error',
|
||||
phase='Timed out', log_line='Quality scan timed out after 2 hours',
|
||||
log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'reason': 'Timed out', '_manages_own_progress': True}
|
||||
|
||||
final_status = state.get('status', 'idle')
|
||||
if final_status == 'error':
|
||||
err = state.get('error_message', 'Unknown error')
|
||||
deps.update_progress(
|
||||
automation_id, status='error', progress=100,
|
||||
phase='Error', log_line=err, log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'reason': err, '_manages_own_progress': True}
|
||||
|
||||
issues = state.get('low_quality', 0)
|
||||
deps.update_progress(
|
||||
automation_id, status='finished', progress=100,
|
||||
phase='Complete',
|
||||
log_line=f'Quality scan complete — {issues} issues found',
|
||||
log_type='success',
|
||||
)
|
||||
return {
|
||||
'status': 'completed', 'scope': scope, '_manages_own_progress': True,
|
||||
'tracks_scanned': state.get('processed', 0),
|
||||
'quality_met': state.get('quality_met', 0),
|
||||
'low_quality': issues,
|
||||
'matched': state.get('matched', 0),
|
||||
}
|
||||
308
core/automation/handlers/refresh_mirrored.py
Normal file
308
core/automation/handlers/refresh_mirrored.py
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
"""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.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Refresh mirrored playlist(s) from source.
|
||||
|
||||
Returns ``{'status': 'completed', 'refreshed': '<int>',
|
||||
'errors': '<int>'}`` on success (counts stringified to match the
|
||||
automation engine's stat-rendering convention).
|
||||
"""
|
||||
db = deps.get_database()
|
||||
playlist_id = config.get('playlist_id')
|
||||
refresh_all = config.get('all', False)
|
||||
auto_id = config.get('_automation_id')
|
||||
|
||||
if refresh_all:
|
||||
playlists = db.get_mirrored_playlists()
|
||||
elif playlist_id:
|
||||
p = db.get_mirrored_playlist(int(playlist_id))
|
||||
playlists = [p] if p else []
|
||||
else:
|
||||
return {'status': 'error', 'reason': 'No playlist specified'}
|
||||
|
||||
# Filter out sources that can't be refreshed (no external API).
|
||||
playlists = [pl for pl in playlists if pl.get('source', '') not in ('file', 'beatport')]
|
||||
|
||||
refreshed = 0
|
||||
errors = []
|
||||
for idx, pl in enumerate(playlists):
|
||||
try:
|
||||
source = pl.get('source', '')
|
||||
source_id = pl.get('source_playlist_id', '')
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
progress=(idx / max(1, len(playlists))) * 100,
|
||||
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'),
|
||||
)
|
||||
refreshed += 1
|
||||
|
||||
# Emit playlist_changed if tracks actually changed.
|
||||
if old_ids != new_ids:
|
||||
added_count = len(new_ids - old_ids)
|
||||
removed_count = len(old_ids - new_ids)
|
||||
deps.logger.info(
|
||||
f"[AUTOMATION] Playlist changed: '{pl.get('name', '')}' — "
|
||||
f"{added_count} added, {removed_count} removed (old={len(old_ids)}, new={len(new_ids)})"
|
||||
)
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
log_line=f'"{pl.get("name", "")}" — {added_count} added, {removed_count} removed',
|
||||
log_type='success',
|
||||
)
|
||||
try:
|
||||
if deps.engine:
|
||||
deps.engine.emit('playlist_changed', {
|
||||
'playlist_name': pl.get('name', ''),
|
||||
'playlist_id': str(pl.get('id', '')),
|
||||
'old_count': str(len(old_ids)),
|
||||
'new_count': str(len(new_ids)),
|
||||
'added': str(added_count),
|
||||
'removed': str(removed_count),
|
||||
})
|
||||
except Exception as e:
|
||||
deps.logger.debug("playlist_synced automation emit failed: %s", e)
|
||||
else:
|
||||
deps.logger.warning(f"[AUTOMATION] No changes: '{pl.get('name', '')}' (tracks={len(old_ids)})")
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
log_line=f'No changes: "{pl.get("name", "")}"',
|
||||
log_type='skip',
|
||||
)
|
||||
except Exception as e:
|
||||
errors.append(f"{pl.get('name', '?')}: {str(e)}")
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
log_line=f'Error: {pl.get("name", "?")} — {str(e)}',
|
||||
log_type='error',
|
||||
)
|
||||
return {'status': 'completed', 'refreshed': str(refreshed), 'errors': str(len(errors))}
|
||||
184
core/automation/handlers/registration.py
Normal file
184
core/automation/handlers/registration.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
"""One-stop registration of every extracted automation handler.
|
||||
|
||||
``web_server`` builds the deps once at startup and calls
|
||||
:func:`register_all` here. Each new handler module gets one line in
|
||||
this file when it lands.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
from core.automation.handlers.process_wishlist import auto_process_wishlist
|
||||
from core.automation.handlers.scan_watchlist import auto_scan_watchlist
|
||||
from core.automation.handlers.scan_library import auto_scan_library
|
||||
from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored
|
||||
from core.automation.handlers.sync_playlist import auto_sync_playlist
|
||||
from core.automation.handlers.discover_playlist import auto_discover_playlist
|
||||
from core.automation.handlers.playlist_pipeline import auto_playlist_pipeline
|
||||
from core.automation.handlers.personalized_pipeline import auto_personalized_pipeline
|
||||
from core.automation.handlers.database_update import (
|
||||
auto_start_database_update, auto_deep_scan_library,
|
||||
)
|
||||
from core.automation.handlers.duplicate_cleaner import auto_run_duplicate_cleaner
|
||||
from core.automation.handlers.quality_scanner import auto_start_quality_scan
|
||||
from core.automation.handlers.maintenance import (
|
||||
auto_clear_quarantine,
|
||||
auto_cleanup_wishlist,
|
||||
auto_update_discovery_pool,
|
||||
auto_backup_database,
|
||||
auto_refresh_beatport_cache,
|
||||
)
|
||||
from core.automation.handlers.download_cleanup import (
|
||||
auto_clean_search_history,
|
||||
auto_clean_completed_downloads,
|
||||
auto_full_cleanup,
|
||||
)
|
||||
from core.automation.handlers.run_script import auto_run_script
|
||||
from core.automation.handlers.search_and_download import auto_search_and_download
|
||||
from core.automation.handlers.progress_callbacks import (
|
||||
progress_init,
|
||||
progress_finish,
|
||||
record_history,
|
||||
register_library_scan_completed_emitter,
|
||||
)
|
||||
|
||||
|
||||
def register_all(deps: AutomationDeps) -> None:
|
||||
"""Wire every extracted handler to the engine.
|
||||
|
||||
Each ``register_action_handler`` call binds the action name (the
|
||||
string the trigger uses to look up its action) to a thin lambda
|
||||
that injects ``deps`` and forwards the engine-supplied config.
|
||||
Guards stay alongside their handler so duplicate-run prevention
|
||||
behaves identically to the pre-extraction code.
|
||||
"""
|
||||
engine = deps.engine
|
||||
|
||||
# Self-guards prevent duplicate runs of the SAME operation, but
|
||||
# different operations can run concurrently — wishlist downloads
|
||||
# use bandwidth, watchlist scans use API calls, library scans use
|
||||
# media-server CPU. Different resources, no contention.
|
||||
engine.register_action_handler(
|
||||
'process_wishlist',
|
||||
lambda config: auto_process_wishlist(config, deps),
|
||||
guard_fn=deps.is_wishlist_actually_processing,
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'scan_watchlist',
|
||||
lambda config: auto_scan_watchlist(config, deps),
|
||||
guard_fn=deps.is_watchlist_actually_scanning,
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'scan_library',
|
||||
lambda config: auto_scan_library(config, deps),
|
||||
deps.state.is_scan_library_active,
|
||||
)
|
||||
|
||||
# Playlist lifecycle handlers. The pipeline composes refresh +
|
||||
# sync + discover (it imports them directly), so all four ship
|
||||
# together. The pipeline guard prevents an in-flight pipeline
|
||||
# from being re-triggered mid-run.
|
||||
engine.register_action_handler(
|
||||
'refresh_mirrored',
|
||||
lambda config: auto_refresh_mirrored(config, deps),
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'sync_playlist',
|
||||
lambda config: auto_sync_playlist(config, deps),
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'discover_playlist',
|
||||
lambda config: auto_discover_playlist(config, deps),
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'playlist_pipeline',
|
||||
lambda config: auto_playlist_pipeline(config, deps),
|
||||
deps.state.is_pipeline_running,
|
||||
)
|
||||
# Personalized pipeline shares the pipeline_running flag with the
|
||||
# mirrored pipeline so the two can't overlap (single sync queue,
|
||||
# single wishlist worker).
|
||||
engine.register_action_handler(
|
||||
'personalized_pipeline',
|
||||
lambda config: auto_personalized_pipeline(config, deps),
|
||||
deps.state.is_pipeline_running,
|
||||
)
|
||||
|
||||
# Database update + deep scan share the db_update_state guard —
|
||||
# only one operation can mutate that state at a time.
|
||||
engine.register_action_handler(
|
||||
'start_database_update',
|
||||
lambda config: auto_start_database_update(config, deps),
|
||||
lambda: deps.get_db_update_state().get('status') == 'running',
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'deep_scan_library',
|
||||
lambda config: auto_deep_scan_library(config, deps),
|
||||
lambda: deps.get_db_update_state().get('status') == 'running',
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'run_duplicate_cleaner',
|
||||
lambda config: auto_run_duplicate_cleaner(config, deps),
|
||||
lambda: deps.get_duplicate_cleaner_state().get('status') == 'running',
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'clear_quarantine',
|
||||
lambda config: auto_clear_quarantine(config, deps),
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'cleanup_wishlist',
|
||||
lambda config: auto_cleanup_wishlist(config, deps),
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'update_discovery_pool',
|
||||
lambda config: auto_update_discovery_pool(config, deps),
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'start_quality_scan',
|
||||
lambda config: auto_start_quality_scan(config, deps),
|
||||
lambda: deps.get_quality_scanner_state().get('status') == 'running',
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'backup_database',
|
||||
lambda config: auto_backup_database(config, deps),
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'refresh_beatport_cache',
|
||||
lambda config: auto_refresh_beatport_cache(config, deps),
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'clean_search_history',
|
||||
lambda config: auto_clean_search_history(config, deps),
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'clean_completed_downloads',
|
||||
lambda config: auto_clean_completed_downloads(config, deps),
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'full_cleanup',
|
||||
lambda config: auto_full_cleanup(config, deps),
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'run_script',
|
||||
lambda config: auto_run_script(config, deps),
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'search_and_download',
|
||||
lambda config: auto_search_and_download(config, deps),
|
||||
)
|
||||
|
||||
# Progress + history callbacks: the engine invokes these around
|
||||
# each handler run. Lift the closures from
|
||||
# `web_server._register_automation_handlers` into thin lambdas
|
||||
# that delegate into the extracted top-level functions.
|
||||
engine.register_progress_callbacks(
|
||||
lambda aid, name, action_type: progress_init(aid, name, action_type, deps),
|
||||
lambda aid, result: progress_finish(aid, result, deps),
|
||||
deps.update_progress,
|
||||
lambda aid, result: record_history(aid, result, deps),
|
||||
)
|
||||
|
||||
# `library_scan_completed` event: when the media-server scan
|
||||
# manager finishes a scan, emit the event so any automation can
|
||||
# trigger off it. No-op when no scan manager is configured.
|
||||
register_library_scan_completed_emitter(deps)
|
||||
103
core/automation/handlers/run_script.py
Normal file
103
core/automation/handlers/run_script.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""Automation handler: ``run_script`` action.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers`` (the
|
||||
``_auto_run_script`` closure). Runs a user-provided shell or Python
|
||||
script from the configured scripts directory with bounded timeout +
|
||||
captured stdout/stderr. Path-traversal guard ensures users can't
|
||||
escape the scripts directory.
|
||||
|
||||
Environment variables exposed to the script:
|
||||
- ``SOULSYNC_EVENT``: triggering event type (when fired by an event)
|
||||
- ``SOULSYNC_AUTOMATION``: automation name
|
||||
- ``SOULSYNC_SCRIPTS_DIR``: absolute path to the scripts dir
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess as _sp
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
_MAX_TIMEOUT_SECONDS = 300 # Hard cap on user-supplied timeout config.
|
||||
|
||||
|
||||
def auto_run_script(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
script_name = config.get('script_name', '')
|
||||
timeout = min(int(config.get('timeout', 60)), _MAX_TIMEOUT_SECONDS)
|
||||
automation_id = config.get('_automation_id')
|
||||
|
||||
if not script_name:
|
||||
return {'status': 'error', 'error': 'No script selected'}
|
||||
|
||||
scripts_dir = deps.docker_resolve_path(deps.config_manager.get('scripts.path', './scripts'))
|
||||
if not scripts_dir or not os.path.isdir(scripts_dir):
|
||||
os.makedirs(scripts_dir, exist_ok=True)
|
||||
return {
|
||||
'status': 'error',
|
||||
'error': 'Scripts directory is empty. Add scripts to the scripts/ folder.',
|
||||
}
|
||||
|
||||
script_path = os.path.join(scripts_dir, script_name)
|
||||
script_path = os.path.realpath(script_path)
|
||||
|
||||
# Security: block path traversal — script must resolve under
|
||||
# the scripts dir, no symlinks/.. tricks allowed out.
|
||||
if not script_path.startswith(os.path.realpath(scripts_dir)):
|
||||
return {'status': 'error', 'error': 'Script path traversal blocked'}
|
||||
|
||||
if not os.path.isfile(script_path):
|
||||
return {'status': 'error', 'error': f'Script not found: {script_name}'}
|
||||
|
||||
deps.update_progress(automation_id, phase=f'Running {script_name}...', progress=10)
|
||||
|
||||
# Build environment with SoulSync context.
|
||||
env = os.environ.copy()
|
||||
event_data = config.get('_event_data') or {}
|
||||
env['SOULSYNC_EVENT'] = str(event_data.get('type', ''))
|
||||
env['SOULSYNC_AUTOMATION'] = config.get('_automation_name', '')
|
||||
env['SOULSYNC_SCRIPTS_DIR'] = scripts_dir
|
||||
|
||||
try:
|
||||
# Determine how to run the script.
|
||||
if script_path.endswith('.py'):
|
||||
cmd = ['python', script_path]
|
||||
elif script_path.endswith('.sh'):
|
||||
cmd = ['bash', script_path]
|
||||
else:
|
||||
cmd = [script_path]
|
||||
|
||||
result = _sp.run(
|
||||
cmd,
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
cwd=scripts_dir, env=env,
|
||||
)
|
||||
|
||||
deps.update_progress(automation_id, phase='Script completed', progress=100)
|
||||
|
||||
stdout = result.stdout[:2000] if result.stdout else ''
|
||||
stderr = result.stderr[:1000] if result.stderr else ''
|
||||
|
||||
if result.returncode == 0:
|
||||
deps.logger.info(f"Script '{script_name}' completed (exit 0)")
|
||||
else:
|
||||
deps.logger.warning(f"Script '{script_name}' exited with code {result.returncode}")
|
||||
|
||||
return {
|
||||
'status': 'completed' if result.returncode == 0 else 'error',
|
||||
'exit_code': str(result.returncode),
|
||||
'stdout': stdout,
|
||||
'stderr': stderr,
|
||||
'script': script_name,
|
||||
}
|
||||
except _sp.TimeoutExpired:
|
||||
deps.update_progress(automation_id, phase='Script timed out', progress=100)
|
||||
return {
|
||||
'status': 'error',
|
||||
'error': f'Script timed out after {timeout}s',
|
||||
'script': script_name,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise
|
||||
return {'status': 'error', 'error': str(e), 'script': script_name}
|
||||
158
core/automation/handlers/scan_library.py
Normal file
158
core/automation/handlers/scan_library.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
"""Automation handler: ``scan_library`` action.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers`` (the
|
||||
``_auto_scan_library`` closure). The handler triggers a media-server
|
||||
scan via ``web_scan_manager``, then polls the manager's status until
|
||||
the scan completes (or a 30-minute timeout fires). Progress phases
|
||||
are emitted via :func:`AutomationDeps.update_progress` so the
|
||||
trigger card stays current throughout the run.
|
||||
|
||||
The handler manages its own progress reporting (it sets
|
||||
``_manages_own_progress: True`` in the result) so the engine doesn't
|
||||
overwrite the live phase string with a generic 'completed' label.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
# Outer poll cap — covers extreme worst case (long Plex scans on
|
||||
# huge libraries). Past this point we surface a clear timeout error
|
||||
# so users notice rather than letting the trigger hang forever.
|
||||
_SCAN_TIMEOUT_SECONDS = 1800
|
||||
|
||||
# Per-phase poll intervals.
|
||||
_POLL_SCHEDULED_SECONDS = 2
|
||||
_POLL_SCANNING_SECONDS = 5
|
||||
_POLL_UNKNOWN_SECONDS = 2
|
||||
|
||||
# Progress percentage waypoints.
|
||||
_PROGRESS_SCHEDULED_MAX = 14
|
||||
_PROGRESS_SCAN_START = 15
|
||||
_PROGRESS_SCAN_MAX = 95
|
||||
|
||||
|
||||
def auto_scan_library(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Run a media-server library scan and stream progress to the
|
||||
trigger card.
|
||||
|
||||
Returns one of:
|
||||
- ``{'status': 'completed', '_manages_own_progress': True, ...}``
|
||||
- ``{'status': 'skipped', 'reason': 'Scan already being tracked'}``
|
||||
- ``{'status': 'error', 'reason': '...', '_manages_own_progress': True}``
|
||||
"""
|
||||
automation_id = config.get('_automation_id')
|
||||
|
||||
if not deps.web_scan_manager:
|
||||
return {'status': 'error', 'reason': 'Scan manager not available'}
|
||||
|
||||
# If another automation is already tracking the scan, just forward
|
||||
# the request — the original tracker keeps emitting progress.
|
||||
if deps.state.is_scan_library_active():
|
||||
deps.web_scan_manager.request_scan('Automation trigger (additional batch)')
|
||||
return {'status': 'skipped', 'reason': 'Scan already being tracked'}
|
||||
|
||||
deps.state.set_scan_library_id(automation_id)
|
||||
|
||||
try:
|
||||
result = deps.web_scan_manager.request_scan('Automation trigger')
|
||||
scan_status_val = result.get('status', 'unknown')
|
||||
|
||||
if scan_status_val == 'queued':
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line='Scan already in progress — waiting for completion',
|
||||
log_type='info',
|
||||
)
|
||||
else:
|
||||
delay = result.get('delay_seconds', 60)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Scan scheduled (debounce: {delay}s)',
|
||||
log_type='info',
|
||||
)
|
||||
|
||||
# Unified polling loop — handles debounce → scanning → idle.
|
||||
poll_start = time.time()
|
||||
scan_started = (scan_status_val == 'queued')
|
||||
while time.time() - poll_start < _SCAN_TIMEOUT_SECONDS:
|
||||
status = deps.web_scan_manager.get_scan_status()
|
||||
st = status.get('status')
|
||||
|
||||
if st == 'idle':
|
||||
break # Scan completed (or finished before we polled)
|
||||
|
||||
if st == 'scheduled':
|
||||
elapsed = int(time.time() - poll_start)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
phase=f'Waiting for scan to start... ({elapsed}s)',
|
||||
progress=min(int(elapsed / 60 * 10), _PROGRESS_SCHEDULED_MAX),
|
||||
)
|
||||
time.sleep(_POLL_SCHEDULED_SECONDS)
|
||||
continue
|
||||
|
||||
if st == 'scanning':
|
||||
if not scan_started:
|
||||
scan_started = True
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=_PROGRESS_SCAN_START,
|
||||
log_line='Scan triggered on media server',
|
||||
log_type='success',
|
||||
)
|
||||
elapsed = status.get('elapsed_seconds', 0)
|
||||
max_time = status.get('max_time_seconds', 300)
|
||||
pct = min(_PROGRESS_SCAN_START + int(elapsed / max_time * 80), _PROGRESS_SCAN_MAX)
|
||||
mins, secs = divmod(elapsed, 60)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
phase=f'Library scan in progress... ({mins}m {secs}s)',
|
||||
progress=pct,
|
||||
)
|
||||
time.sleep(_POLL_SCANNING_SECONDS)
|
||||
continue
|
||||
|
||||
time.sleep(_POLL_UNKNOWN_SECONDS)
|
||||
else:
|
||||
# 30-min timeout reached
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
status='error',
|
||||
phase='Timed out',
|
||||
log_line='Library scan timed out after 30 minutes',
|
||||
log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'reason': 'Timed out', '_manages_own_progress': True}
|
||||
|
||||
elapsed = round(time.time() - poll_start, 1)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
status='finished',
|
||||
progress=100,
|
||||
phase='Complete',
|
||||
log_line='Library scan completed',
|
||||
log_type='success',
|
||||
)
|
||||
return {
|
||||
'status': 'completed',
|
||||
'_manages_own_progress': True,
|
||||
'scan_duration_seconds': elapsed,
|
||||
}
|
||||
|
||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise into the engine
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
status='error',
|
||||
phase='Error',
|
||||
log_line=str(e),
|
||||
log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'error': str(e), '_manages_own_progress': True}
|
||||
|
||||
finally:
|
||||
deps.state.set_scan_library_id(None)
|
||||
46
core/automation/handlers/scan_watchlist.py
Normal file
46
core/automation/handlers/scan_watchlist.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"""Automation handler: ``scan_watchlist`` action.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers`` (the
|
||||
``_auto_scan_watchlist`` closure). The watchlist scanner returns
|
||||
summary stats for the trigger card only when a fresh scan actually
|
||||
ran — detected by snapshotting ``id(state_dict)`` before/after, since
|
||||
the live processor reassigns the dict on each new scan.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
def auto_scan_watchlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Run a watchlist scan when the automation triggers.
|
||||
|
||||
Pre-scan we capture ``id(watchlist_scan_state)`` so we can tell
|
||||
afterwards whether the worker ran (and reassigned the state dict)
|
||||
or short-circuited (kept the same dict). Only fresh scans report
|
||||
summary stats — repeat triggers without an intervening run return
|
||||
a bare ``completed``.
|
||||
"""
|
||||
try:
|
||||
pre_state = deps.get_watchlist_scan_state()
|
||||
pre_state_id = id(pre_state)
|
||||
deps.process_watchlist_scan_automatically(
|
||||
automation_id=config.get('_automation_id'),
|
||||
profile_id=config.get('_profile_id'),
|
||||
)
|
||||
post_state = deps.get_watchlist_scan_state()
|
||||
# Fresh scan = state dict was reassigned mid-run.
|
||||
if id(post_state) != pre_state_id:
|
||||
summary = post_state.get('summary', {}) if isinstance(post_state, dict) else {}
|
||||
return {
|
||||
'status': 'completed',
|
||||
'artists_scanned': summary.get('total_artists', 0),
|
||||
'successful_scans': summary.get('successful_scans', 0),
|
||||
'new_tracks_found': summary.get('new_tracks_found', 0),
|
||||
'tracks_added_to_wishlist': summary.get('tracks_added_to_wishlist', 0),
|
||||
}
|
||||
return {'status': 'completed'}
|
||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise into the engine
|
||||
return {'status': 'error', 'error': str(e)}
|
||||
57
core/automation/handlers/search_and_download.py
Normal file
57
core/automation/handlers/search_and_download.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"""Automation handler: ``search_and_download`` action.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers`` (the
|
||||
``_auto_search_and_download`` closure). Searches for a track by
|
||||
name/artist string and dispatches the best match through the
|
||||
download orchestrator. Query can come from the trigger config
|
||||
(direct value) or from event data (e.g. webhook payload).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
def auto_search_and_download(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
automation_id = config.get('_automation_id')
|
||||
query = config.get('query', '').strip()
|
||||
# Event-triggered: pull query from event data (e.g. webhook_received).
|
||||
if not query:
|
||||
event_data = config.get('_event_data', {})
|
||||
query = (event_data.get('query', '') or '').strip()
|
||||
if not query:
|
||||
if automation_id:
|
||||
deps.update_progress(
|
||||
automation_id, log_line='No search query provided', log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'error': 'No search query provided'}
|
||||
try:
|
||||
if automation_id:
|
||||
deps.update_progress(
|
||||
automation_id, phase='Searching',
|
||||
log_line=f'Searching: {query}', log_type='info',
|
||||
)
|
||||
result = deps.run_async(deps.download_orchestrator.search_and_download_best(query))
|
||||
if result:
|
||||
if automation_id:
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Download started for: {query}',
|
||||
log_type='success',
|
||||
)
|
||||
return {'status': 'completed', 'query': query, 'download_id': result}
|
||||
if automation_id:
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'No match found for: {query}',
|
||||
log_type='warning',
|
||||
)
|
||||
return {'status': 'not_found', 'query': query, 'error': 'No match found'}
|
||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise
|
||||
if automation_id:
|
||||
deps.update_progress(
|
||||
automation_id, log_line=f'Error: {e}', log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'query': query, 'error': str(e)}
|
||||
195
core/automation/handlers/sync_playlist.py
Normal file
195
core/automation/handlers/sync_playlist.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
"""Automation handler: ``sync_playlist`` action.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers`` (the
|
||||
``_auto_sync_playlist`` closure). Syncs a mirrored playlist to the
|
||||
configured media server, using discovered metadata when available
|
||||
and skipping undiscovered tracks. When triggered on a schedule with
|
||||
no track changes since the last sync, short-circuits with
|
||||
``status: skipped`` (saves Plex / Jellyfin / Navidrome from
|
||||
needless rewrites)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
def auto_sync_playlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Sync a mirrored playlist to the active media server.
|
||||
|
||||
Behavior:
|
||||
- Tracks with discovered metadata (extra_data.discovered + matched_data)
|
||||
are routed via the official metadata.
|
||||
- Tracks with a Spotify hint (real Spotify ID from the embed
|
||||
scraper) are included so they can still hit Soulseek + the
|
||||
wishlist.
|
||||
- Tracks with neither are counted as ``skipped_tracks``.
|
||||
- Empty result → ``status: skipped`` with the skipped count.
|
||||
- Same track set as last sync (matched_tracks unchanged) →
|
||||
``status: skipped`` (no-op).
|
||||
- Otherwise spawns a daemon thread running ``run_sync_task`` and
|
||||
returns ``status: started`` with ``_manages_own_progress: True``.
|
||||
"""
|
||||
auto_id = config.get('_automation_id')
|
||||
playlist_id = config.get('playlist_id')
|
||||
if not playlist_id:
|
||||
return {'status': 'error', 'reason': 'No playlist specified'}
|
||||
|
||||
db = deps.get_database()
|
||||
pl = db.get_mirrored_playlist(int(playlist_id))
|
||||
if not pl:
|
||||
return {'status': 'error', 'reason': 'Playlist not found'}
|
||||
|
||||
tracks = db.get_mirrored_playlist_tracks(int(playlist_id))
|
||||
if not tracks:
|
||||
return {'status': 'error', 'reason': 'No tracks in playlist'}
|
||||
|
||||
# Convert mirrored tracks to format expected by run_sync_task.
|
||||
# Use discovered metadata when available, fall back to Spotify
|
||||
# hint or raw playlist fields when not.
|
||||
tracks_json = []
|
||||
skipped_count = 0
|
||||
|
||||
for t in tracks:
|
||||
# Parse extra_data for discovery info.
|
||||
extra = {}
|
||||
if t.get('extra_data'):
|
||||
try:
|
||||
extra = json.loads(t['extra_data']) if isinstance(t['extra_data'], str) else t['extra_data']
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
if extra.get('discovered') and extra.get('matched_data'):
|
||||
# Use official discovered metadata.
|
||||
md = extra['matched_data']
|
||||
album_raw = md.get('album', '')
|
||||
album_obj = album_raw if isinstance(album_raw, dict) else {'name': album_raw or ''}
|
||||
_track_entry = {
|
||||
'name': md.get('name', ''),
|
||||
'artists': md.get('artists', [{'name': t.get('artist_name', '')}]),
|
||||
'album': album_obj,
|
||||
'duration_ms': md.get('duration_ms', 0),
|
||||
'id': md.get('id', ''),
|
||||
}
|
||||
if md.get('track_number'):
|
||||
_track_entry['track_number'] = md['track_number']
|
||||
if md.get('disc_number'):
|
||||
_track_entry['disc_number'] = md['disc_number']
|
||||
tracks_json.append(_track_entry)
|
||||
else:
|
||||
# NOT discovered — try to include using available metadata so
|
||||
# the track can still be searched on Soulseek and added to
|
||||
# wishlist. Without this, failed discovery blocks the entire
|
||||
# download pipeline.
|
||||
#
|
||||
# Priority: spotify_hint (has real Spotify ID from embed
|
||||
# scraper) > raw playlist fields (only if source_track_id
|
||||
# is valid).
|
||||
hint = extra.get('spotify_hint', {})
|
||||
# Build album object with cover art from the mirrored playlist track.
|
||||
track_image = (t.get('image_url') or '').strip()
|
||||
album_obj = {
|
||||
'name': (t.get('album_name') or '').strip(),
|
||||
'images': [{'url': track_image, 'height': 300, 'width': 300}] if track_image else [],
|
||||
}
|
||||
|
||||
if hint.get('id') and hint.get('name'):
|
||||
# spotify_hint has proper Spotify track ID + metadata from embed scraper.
|
||||
hint_artists = hint.get('artists', [])
|
||||
if hint_artists and isinstance(hint_artists[0], str):
|
||||
hint_artists = [{'name': a} for a in hint_artists]
|
||||
elif hint_artists and isinstance(hint_artists[0], dict):
|
||||
pass # Already in correct format
|
||||
else:
|
||||
hint_artists = [{'name': t.get('artist_name', '')}]
|
||||
tracks_json.append({
|
||||
'name': hint['name'],
|
||||
'artists': hint_artists,
|
||||
'album': album_obj,
|
||||
'duration_ms': t.get('duration_ms', 0),
|
||||
'id': hint['id'],
|
||||
})
|
||||
elif t.get('source_track_id') and (t.get('track_name') or '').strip():
|
||||
# Has a valid source ID and track name — usable for wishlist.
|
||||
tracks_json.append({
|
||||
'name': t['track_name'].strip(),
|
||||
'artists': [{'name': (t.get('artist_name') or '').strip() or 'Unknown Artist'}],
|
||||
'album': album_obj,
|
||||
'duration_ms': t.get('duration_ms', 0),
|
||||
'id': t['source_track_id'],
|
||||
})
|
||||
else:
|
||||
skipped_count += 1 # No usable ID or name — truly can't process.
|
||||
|
||||
if not tracks_json:
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
log_line=f'No discovered tracks — {skipped_count} need discovery first',
|
||||
log_type='skip',
|
||||
)
|
||||
return {
|
||||
'status': 'skipped',
|
||||
'reason': f'No discovered tracks to sync ({skipped_count} tracks need discovery first)',
|
||||
'skipped_tracks': str(skipped_count),
|
||||
}
|
||||
|
||||
# Preflight: hash the track list and compare against last sync.
|
||||
# Skip if the exact same set of tracks was already synced and
|
||||
# everything matched (no-op preserves Plex / Jellyfin / Navidrome
|
||||
# from needless rewrites).
|
||||
track_ids_str = ','.join(sorted(t.get('id', '') for t in tracks_json))
|
||||
tracks_hash = hashlib.md5(track_ids_str.encode()).hexdigest()
|
||||
|
||||
sync_id_key = f"auto_mirror_{playlist_id}"
|
||||
try:
|
||||
sync_statuses = deps.load_sync_status_file()
|
||||
last_status = sync_statuses.get(sync_id_key, {})
|
||||
last_hash = last_status.get('tracks_hash', '')
|
||||
last_matched = last_status.get('matched_tracks', -1)
|
||||
|
||||
if last_hash == tracks_hash and last_matched >= len(tracks_json):
|
||||
# Exact same tracks, all matched last time — nothing to do.
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
log_line=f'All {len(tracks_json)} tracks unchanged since last sync — skipping',
|
||||
log_type='skip',
|
||||
)
|
||||
return {
|
||||
'status': 'skipped',
|
||||
'reason': f'All {len(tracks_json)} tracks unchanged since last sync',
|
||||
}
|
||||
except Exception as e:
|
||||
deps.logger.debug("mirror sync last-status read: %s", e)
|
||||
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
progress=50,
|
||||
phase=f'Syncing "{pl["name"]}"',
|
||||
log_line=f'{len(tracks_json)} discovered, {skipped_count} skipped',
|
||||
log_type='info',
|
||||
)
|
||||
|
||||
sync_id = f"auto_mirror_{playlist_id}"
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
progress=90,
|
||||
log_line=f'Starting sync: {len(tracks_json)} tracks',
|
||||
log_type='success',
|
||||
)
|
||||
threading.Thread(
|
||||
target=deps.run_sync_task,
|
||||
args=(sync_id, pl['name'], tracks_json, auto_id, 1, pl.get('image_url', '')),
|
||||
daemon=True,
|
||||
name=f'auto-sync-{playlist_id}',
|
||||
).start()
|
||||
return {
|
||||
'status': 'started',
|
||||
'playlist_name': pl['name'],
|
||||
'discovered_tracks': str(len(tracks_json)),
|
||||
'skipped_tracks': str(skipped_count),
|
||||
'_manages_own_progress': True,
|
||||
}
|
||||
|
|
@ -17,10 +17,14 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
def get_current_profile_id() -> int:
|
||||
"""Mirror of web_server.get_current_profile_id — uses Flask g."""
|
||||
"""Mirror of web_server.get_current_profile_id — uses Flask g.
|
||||
|
||||
Catches RuntimeError too because reading `g` outside a request
|
||||
context raises that (not AttributeError) — happens when this is
|
||||
called from background threads (sync, automation, scanners)."""
|
||||
try:
|
||||
return g.profile_id
|
||||
except AttributeError:
|
||||
except (AttributeError, RuntimeError):
|
||||
return 1
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -397,9 +397,12 @@ def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, p
|
|||
}
|
||||
logger.info(f"Sync finished for {playlist_id} - state updated")
|
||||
|
||||
# Set playlist poster image if available (Plex, Jellyfin, Emby)
|
||||
# Set playlist poster image if available (Plex, Jellyfin, Emby).
|
||||
# Don't log the URL itself — it may carry an auth token (Plex
|
||||
# X-Plex-Token / Jellyfin X-Emby-Token / Subsonic auth) that we
|
||||
# don't want persisted to app.log.
|
||||
_synced = getattr(result, 'synced_tracks', 0)
|
||||
logger.info(f"[PLAYLIST IMAGE] image_url={playlist_image_url!r}, synced_tracks={_synced}")
|
||||
logger.info(f"[PLAYLIST IMAGE] has_image={bool(playlist_image_url)}, synced_tracks={_synced}")
|
||||
if playlist_image_url and _synced > 0:
|
||||
try:
|
||||
active_server = deps.config_manager.get_active_media_server()
|
||||
|
|
|
|||
|
|
@ -104,6 +104,15 @@ class DownloadOrchestrator:
|
|||
deezer_dl.reconnect(deezer_arl)
|
||||
deezer_dl._quality = config_manager.get('deezer_download.quality', 'flac')
|
||||
|
||||
# Reload Amazon quality preference (T2Tunes needs no reconnect — public proxy)
|
||||
amazon = self.client('amazon')
|
||||
if amazon:
|
||||
quality = config_manager.get('amazon_download.quality', 'flac')
|
||||
amazon._quality = quality
|
||||
amazon._allow_fallback = config_manager.get('amazon_download.allow_fallback', True)
|
||||
if hasattr(amazon, '_client') and amazon._client:
|
||||
amazon._client.preferred_codec = quality
|
||||
|
||||
# Reload download path for all clients that cache it.
|
||||
# Soulseek owns the path config and is reloaded above; every
|
||||
# other source mirrors that path so files all land in one
|
||||
|
|
@ -319,7 +328,7 @@ class DownloadOrchestrator:
|
|||
return None
|
||||
|
||||
# 2. Filter and validate results
|
||||
_streaming_sources = ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud')
|
||||
_streaming_sources = ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud', 'amazon')
|
||||
is_streaming = tracks[0].username in _streaming_sources if tracks else False
|
||||
|
||||
if is_streaming and expected_track:
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ from core.download_plugins.base import DownloadSourcePlugin
|
|||
# than the legacy module-top imports here. Importing everything at
|
||||
# registry-load time pins the bindings the same way the legacy
|
||||
# orchestrator did.
|
||||
from core.amazon_download_client import AmazonDownloadClient
|
||||
from core.deezer_download_client import DeezerDownloadClient
|
||||
from core.hifi_client import HiFiClient
|
||||
from core.lidarr_download_client import LidarrDownloadClient
|
||||
|
|
@ -176,6 +177,7 @@ def build_default_registry() -> DownloadPluginRegistry:
|
|||
"""
|
||||
registry = DownloadPluginRegistry()
|
||||
|
||||
registry.register(PluginSpec(name='amazon', factory=AmazonDownloadClient, display_name='Amazon Music'))
|
||||
registry.register(PluginSpec(name='soulseek', factory=SoulseekClient, display_name='Soulseek'))
|
||||
registry.register(PluginSpec(name='youtube', factory=YouTubeClient, display_name='YouTube'))
|
||||
registry.register(PluginSpec(name='tidal', factory=TidalDownloadClient, display_name='Tidal'))
|
||||
|
|
|
|||
|
|
@ -235,7 +235,7 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
|
|||
|
||||
# 1. Try track_info (from frontend, has album track data)
|
||||
tn = track_info.get('track_number', 0) if isinstance(track_info, dict) else 0
|
||||
dn = track_info.get('disc_number', 1) if isinstance(track_info, dict) else 1
|
||||
dn = (track_info.get('disc_number') or 1) if isinstance(track_info, dict) else 1
|
||||
if tn and tn > 0:
|
||||
enhanced_payload['track_number'] = tn
|
||||
enhanced_payload['disc_number'] = dn
|
||||
|
|
@ -255,7 +255,7 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
|
|||
detailed_track = deps.spotify_client.get_track_details(track.id)
|
||||
if detailed_track and detailed_track.get('track_number'):
|
||||
enhanced_payload['track_number'] = detailed_track['track_number']
|
||||
enhanced_payload['disc_number'] = detailed_track.get('disc_number', 1)
|
||||
enhanced_payload['disc_number'] = detailed_track.get('disc_number') or 1
|
||||
got_track_number = True
|
||||
logger.info(f"[Context] Added track_number from API: {detailed_track['track_number']}, disc_number: {enhanced_payload['disc_number']}")
|
||||
|
||||
|
|
|
|||
|
|
@ -483,7 +483,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
# Use ALL tracks (tracks_json), not just missing ones, to correctly detect multi-disc
|
||||
# even when only one disc has missing tracks
|
||||
if batch_is_album and batch_album_context:
|
||||
total_discs = max((t.get('disc_number', 1) for t in tracks_json), default=1)
|
||||
total_discs = max((t.get('disc_number') or 1 for t in tracks_json), default=1)
|
||||
batch_album_context['total_discs'] = total_discs
|
||||
if total_discs > 1:
|
||||
logger.info(f"[Multi-Disc] Detected {total_discs} discs for album '{batch_album_context.get('name')}'")
|
||||
|
|
@ -507,7 +507,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
# Fallback album key: use album name when ID is missing (e.g. mirrored playlist tracks)
|
||||
if not album_id and isinstance(album_val, dict) and album_val.get('name'):
|
||||
album_id = f"_name_{album_val['name'].lower().strip()}"
|
||||
disc_num = sp_data.get('disc_number', t.get('disc_number', 1))
|
||||
disc_num = sp_data.get('disc_number') or t.get('disc_number') or 1
|
||||
if album_id:
|
||||
wishlist_album_disc_counts[album_id] = max(
|
||||
wishlist_album_disc_counts.get(album_id, 1), disc_num
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ class StatusDeps:
|
|||
# Streaming sources the engine fallback applies to. Soulseek goes through
|
||||
# slskd's live_transfers path and must NOT hit the engine fallback.
|
||||
_STREAMING_SOURCE_NAMES = frozenset((
|
||||
'youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud',
|
||||
'youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud', 'amazon',
|
||||
))
|
||||
|
||||
# Keep these in sync with the engine plugins' state strings.
|
||||
|
|
|
|||
|
|
@ -294,7 +294,7 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
|
|||
source_clients = {
|
||||
name: orch.client(name)
|
||||
for name in ('soulseek', 'youtube', 'tidal', 'qobuz',
|
||||
'hifi', 'deezer_dl', 'lidarr', 'soundcloud')
|
||||
'hifi', 'deezer_dl', 'lidarr', 'soundcloud', 'amazon')
|
||||
}
|
||||
|
||||
# The orchestrator tried sources in order but stopped at the first with results.
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ def get_valid_candidates(results, spotify_track, query):
|
|||
|
||||
# Streaming sources (YouTube, Tidal, Qobuz, HiFi, Deezer, SoundCloud) return structured API results
|
||||
# with proper artist/title metadata — score using the same matching engine as Soulseek
|
||||
_streaming_sources = ("youtube", "tidal", "qobuz", "hifi", "deezer_dl", "soundcloud")
|
||||
_streaming_sources = ("youtube", "tidal", "qobuz", "hifi", "deezer_dl", "soundcloud", "amazon")
|
||||
if results[0].username in _streaming_sources:
|
||||
source_label = results[0].username.replace('_dl', '').title()
|
||||
expected_artists = spotify_track.artists if spotify_track else []
|
||||
|
|
|
|||
|
|
@ -130,6 +130,16 @@ def list_quarantine_entries(quarantine_dir: str) -> List[Dict[str, Any]]:
|
|||
except OSError:
|
||||
size_bytes = 0
|
||||
|
||||
# Issue #608 follow-up (AfonsoG6): surface the source username
|
||||
# + filename that was originally downloaded, so the user can see
|
||||
# at a glance which uploader the bad file came from. Lives
|
||||
# under `context.original_search_result` when full context is
|
||||
# persisted; absent on legacy thin sidecars.
|
||||
ctx = sidecar.get("context") if isinstance(sidecar.get("context"), dict) else {}
|
||||
osr = ctx.get("original_search_result") if isinstance(ctx.get("original_search_result"), dict) else {}
|
||||
source_username = osr.get("username", "") if isinstance(osr, dict) else ""
|
||||
source_filename = osr.get("filename", "") if isinstance(osr, dict) else ""
|
||||
|
||||
entries.append(
|
||||
{
|
||||
"id": entry_id,
|
||||
|
|
@ -142,6 +152,8 @@ def list_quarantine_entries(quarantine_dir: str) -> List[Dict[str, Any]]:
|
|||
"size_bytes": size_bytes,
|
||||
"has_full_context": isinstance(sidecar.get("context"), dict),
|
||||
"trigger": sidecar.get("trigger", "unknown"),
|
||||
"source_username": source_username,
|
||||
"source_filename": source_filename,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -235,7 +235,7 @@ def record_library_history_download(context: Dict[str, Any]) -> None:
|
|||
source_track_id = search_result.get("track_id", "") or search_result.get("id", "") or ti.get("id", "")
|
||||
source_track_title = search_result.get("title", "") or search_result.get("name", "")
|
||||
source_artist = search_result.get("artist", "")
|
||||
if source_filename and "||" in source_filename and username in ("tidal", "youtube", "qobuz", "hifi", "deezer_dl", "lidarr", "soundcloud"):
|
||||
if source_filename and "||" in source_filename and username in ("tidal", "youtube", "qobuz", "hifi", "deezer_dl", "lidarr", "soundcloud", "amazon"):
|
||||
stream_id = source_filename.split("||")[0]
|
||||
if stream_id and not source_track_id:
|
||||
source_track_id = stream_id
|
||||
|
|
|
|||
327
core/library/reorganize_tag_source.py
Normal file
327
core/library/reorganize_tag_source.py
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
"""Build reorganize-planning metadata from a file's embedded tags
|
||||
instead of from a live metadata-source API call.
|
||||
|
||||
Issue #592 (tacobell444): when a library has been carefully enriched
|
||||
+ tagged, doing a fresh API lookup at reorganize time can introduce
|
||||
inconsistencies (provider naming drift, version-mismatches, missing
|
||||
album-level metadata for niche releases). The user's own embedded
|
||||
tags are usually the most stable source of truth for an enriched
|
||||
library — and using them costs zero API calls.
|
||||
|
||||
This module is the pure tag-to-context adapter. It turns the dict
|
||||
that ``core.library.file_tags.read_embedded_tags`` returns into the
|
||||
``api_album`` / ``api_track`` shapes that
|
||||
``library_reorganize._build_post_process_context`` already consumes.
|
||||
That keeps the downstream pipeline path-builder, post-process
|
||||
helpers, AcoustID, etc.) completely unchanged: tag-mode just produces
|
||||
the same input shape via a different upstream route.
|
||||
|
||||
Pure helpers — no IO inside the extractors so every shape is
|
||||
test-pinnable. The wrapper :func:`read_album_track_from_file` does
|
||||
the file IO via ``read_embedded_tags`` and then routes through the
|
||||
extractors.
|
||||
|
||||
Returns ``None`` (extractors) / ``(None, None, reason)`` (wrapper)
|
||||
when the embedded tags are missing fields essential for reorganize
|
||||
(track title, album name, or track artist). The plan layer surfaces
|
||||
that as an unmatched item with a clear reason — same UX as when the
|
||||
metadata-API call returns no candidate. No silent degradation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
# Tokens we accept as valid `releasetype` / `albumtype` values.
|
||||
# Mirrors the canonical set the rest of the metadata pipeline uses
|
||||
# (`core/metadata/album_tracks.py:_normalize_album_type`).
|
||||
_VALID_ALBUM_TYPES = frozenset({'album', 'single', 'ep', 'compilation'})
|
||||
|
||||
|
||||
# Match a 4-digit year anywhere in a date-like string ("2020",
|
||||
# "2020-01-15", "2020/01/15", "Jan 5, 2020", etc.).
|
||||
_YEAR_RE = re.compile(r'(\d{4})')
|
||||
|
||||
|
||||
# Separators we split a single artist field on to recover a list.
|
||||
# Mirrors the same separator set ``core/metadata/artist_resolution.py``
|
||||
# uses when normalizing soulseek matched-download artist strings.
|
||||
_ARTIST_SPLIT_RE = re.compile(
|
||||
r'\s*(?:,|;|/|&| feat\. | feat | ft\. | ft | featuring | x | with )\s*',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _stringify(value: Any) -> str:
|
||||
"""Coerce an embedded-tag value into a clean string."""
|
||||
if value is None:
|
||||
return ''
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _parse_int_first(value: Any) -> Optional[int]:
|
||||
"""Parse a track/disc number that may arrive as ``"5"``, ``"5/12"``,
|
||||
``5``, ``5.0`` or even ``"05"``. Returns the leading integer, or
|
||||
``None`` when no integer is recoverable.
|
||||
|
||||
Defensive against the trailing-``/N`` shape ID3 stores: ``TRCK =
|
||||
"5/12"`` means "track 5 of 12", and we want ``5``."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int,)):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
return int(value)
|
||||
s = _stringify(value)
|
||||
if not s:
|
||||
return None
|
||||
head = s.split('/', 1)[0].strip()
|
||||
try:
|
||||
return int(head)
|
||||
except (TypeError, ValueError):
|
||||
try:
|
||||
return int(float(head))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_int_total(value: Any) -> Optional[int]:
|
||||
"""Parse the trailing ``N`` of an ID3-style ``"5/12"`` value, or
|
||||
return the parsed value when it's a plain integer string."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
s = _stringify(value)
|
||||
if not s:
|
||||
return None
|
||||
if '/' in s:
|
||||
tail = s.split('/', 1)[1].strip()
|
||||
try:
|
||||
return int(tail)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
try:
|
||||
return int(s)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_year(value: Any) -> str:
|
||||
"""Extract a 4-digit year from a date-like field. Returns '' when
|
||||
no year is extractable. Reorganize templates only use the year
|
||||
portion of release dates, so we don't need to preserve the full
|
||||
date string."""
|
||||
s = _stringify(value)
|
||||
if not s:
|
||||
return ''
|
||||
m = _YEAR_RE.search(s)
|
||||
return m.group(1) if m else ''
|
||||
|
||||
|
||||
def _normalize_album_type(value: Any) -> str:
|
||||
"""Lowercase + validate the ``releasetype`` tag against the canonical
|
||||
token set. Returns '' for unknown values so the downstream path
|
||||
builder falls back to its default."""
|
||||
s = _stringify(value).lower()
|
||||
if s in _VALID_ALBUM_TYPES:
|
||||
return s
|
||||
return ''
|
||||
|
||||
|
||||
def _split_artists(value: Any) -> List[str]:
|
||||
"""Split an artist-string field into a list. Handles common
|
||||
separators (``,``, ``;``, ``/``, ``&``, ``feat``, ``ft``, ``x``,
|
||||
``with``). Strips whitespace, drops empties, dedupes (case-
|
||||
insensitive) while preserving order."""
|
||||
s = _stringify(value)
|
||||
if not s:
|
||||
return []
|
||||
parts = _ARTIST_SPLIT_RE.split(s)
|
||||
seen: set = set()
|
||||
out: List[str] = []
|
||||
for p in parts:
|
||||
cleaned = p.strip()
|
||||
if not cleaned:
|
||||
continue
|
||||
key = cleaned.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(cleaned)
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_track_artists(tags: Dict[str, Any]) -> List[str]:
|
||||
"""Resolve the per-track artist list from embedded tags. Prefers a
|
||||
multi-value ``artists`` tag (TXXX:Artists / Vorbis ``artists``)
|
||||
over splitting the single-string ``artist`` tag, which is exactly
|
||||
the precedence the post-download enrichment uses."""
|
||||
artists_value = tags.get('artists')
|
||||
if artists_value:
|
||||
# Multi-value tag readers may already have joined with ', '.
|
||||
# Re-split to recover the list.
|
||||
parts = _split_artists(artists_value)
|
||||
if parts:
|
||||
return parts
|
||||
return _split_artists(tags.get('artist') or '')
|
||||
|
||||
|
||||
def extract_track_meta_from_tags(tags: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Build an ``api_track``-shaped dict from embedded tags.
|
||||
|
||||
Returns ``None`` if essential fields are missing (title or
|
||||
artist). Caller surfaces that as an unmatched plan item.
|
||||
|
||||
Output shape matches what ``library_reorganize._build_post_process_context``
|
||||
consumes (``name`` / ``track_number`` / ``disc_number`` /
|
||||
``artists`` / ``duration_ms`` / ``id``)."""
|
||||
if not isinstance(tags, dict) or not tags:
|
||||
return None
|
||||
|
||||
title = _stringify(tags.get('title'))
|
||||
if not title:
|
||||
return None
|
||||
|
||||
artists = _resolve_track_artists(tags)
|
||||
if not artists:
|
||||
return None
|
||||
|
||||
track_number = _parse_int_first(tags.get('tracknumber')) or 1
|
||||
disc_number = _parse_int_first(tags.get('discnumber')) or 1
|
||||
|
||||
return {
|
||||
'name': title,
|
||||
'title': title, # belt-and-braces — both keys are read downstream
|
||||
'track_number': track_number,
|
||||
'disc_number': disc_number,
|
||||
'artists': [{'name': a} for a in artists],
|
||||
'duration_ms': 0, # not derivable from tags alone; set later from `duration`
|
||||
'id': '', # tag-mode has no source ID; reorganize doesn't need one
|
||||
'uri': '',
|
||||
}
|
||||
|
||||
|
||||
def extract_album_meta_from_tags(tags: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Build an ``api_album``-shaped dict from embedded tags.
|
||||
|
||||
Falls back to empty / zero values when fields are missing — the
|
||||
path builder accepts those and uses its own defaults. The album
|
||||
name is the only field we can't fall back on; if missing the
|
||||
caller should treat the track as unmatched (handled by
|
||||
:func:`read_album_track_from_file`)."""
|
||||
if not isinstance(tags, dict):
|
||||
tags = {}
|
||||
|
||||
album_name = _stringify(tags.get('album'))
|
||||
album_artist = _stringify(tags.get('albumartist') or tags.get('album_artist'))
|
||||
release_date = _normalize_year(tags.get('date') or tags.get('year') or tags.get('originaldate'))
|
||||
total_tracks = (
|
||||
_parse_int_total(tags.get('totaltracks'))
|
||||
or _parse_int_total(tags.get('tracktotal'))
|
||||
or _parse_int_total(tags.get('tracknumber')) # may be "5/12"
|
||||
or 0
|
||||
)
|
||||
album_type = _normalize_album_type(tags.get('releasetype'))
|
||||
|
||||
# `total_discs` only comes from explicit total signals: a
|
||||
# `totaldiscs` tag, or the trailing `/N` of an ID3-style
|
||||
# `discnumber = "1/2"`. A bare `discnumber = "1"` carries no total
|
||||
# and must NOT be treated as one (else single-disc albums would
|
||||
# claim total=1 and the path builder would still skip the
|
||||
# subfolder, but partial-album cases would underreport).
|
||||
total_discs = _parse_int_total(tags.get('totaldiscs')) or 0
|
||||
discnumber_raw = _stringify(tags.get('discnumber'))
|
||||
if '/' in discnumber_raw:
|
||||
explicit_total = _parse_int_total(discnumber_raw)
|
||||
if explicit_total:
|
||||
total_discs = max(total_discs, explicit_total)
|
||||
|
||||
return {
|
||||
'id': '',
|
||||
'album_id': '',
|
||||
'name': album_name,
|
||||
'title': album_name,
|
||||
'release_date': release_date,
|
||||
'total_tracks': total_tracks,
|
||||
'total_discs': total_discs,
|
||||
'image_url': '',
|
||||
'images': [],
|
||||
'album_artist': album_artist,
|
||||
'album_type': album_type,
|
||||
}
|
||||
|
||||
|
||||
def read_album_track_from_file(
|
||||
file_path: str,
|
||||
*,
|
||||
read_embedded_tags_fn=None,
|
||||
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]], Optional[str]]:
|
||||
"""Read embedded tags from ``file_path`` and produce
|
||||
``(album_meta, track_meta, error_reason)``.
|
||||
|
||||
Returns ``(None, None, reason)`` when the file can't be opened,
|
||||
has no recognisable tags, or is missing essential fields (title
|
||||
or artist). The reason string is human-readable and suitable for
|
||||
surfacing directly in the reorganize preview/error UI.
|
||||
|
||||
Args:
|
||||
file_path: Resolved on-disk path to the audio file.
|
||||
read_embedded_tags_fn: Optional override for the tag reader,
|
||||
used by tests to avoid real mutagen IO. Defaults to
|
||||
``core.library.file_tags.read_embedded_tags``."""
|
||||
if not file_path or not isinstance(file_path, str):
|
||||
return None, None, 'No file path on track row.'
|
||||
|
||||
if read_embedded_tags_fn is None:
|
||||
from core.library.file_tags import read_embedded_tags as _real_reader
|
||||
read_embedded_tags_fn = _real_reader
|
||||
|
||||
result = read_embedded_tags_fn(file_path)
|
||||
if not isinstance(result, dict) or not result.get('available'):
|
||||
reason = (result or {}).get('reason') if isinstance(result, dict) else None
|
||||
return None, None, reason or 'Could not read embedded tags from file.'
|
||||
|
||||
tags = result.get('tags') or {}
|
||||
track_meta = extract_track_meta_from_tags(tags)
|
||||
if track_meta is None:
|
||||
return None, None, 'Embedded tags missing required title or artist.'
|
||||
|
||||
album_meta = extract_album_meta_from_tags(tags)
|
||||
if not album_meta.get('name'):
|
||||
return None, None, 'Embedded tags missing album name.'
|
||||
|
||||
# Promote duration from the file-info block onto the track meta
|
||||
# so the path builder has a non-zero value if a downstream
|
||||
# consumer wants it.
|
||||
duration_seconds = result.get('duration') or 0
|
||||
try:
|
||||
track_meta['duration_ms'] = int(float(duration_seconds) * 1000)
|
||||
except (TypeError, ValueError):
|
||||
track_meta['duration_ms'] = 0
|
||||
|
||||
return album_meta, track_meta, None
|
||||
|
||||
|
||||
def normalize_resolved_path(file_path: Optional[str]) -> Optional[str]:
|
||||
"""Defensive wrapper: returns the input only when it points at a
|
||||
real file. Saves the caller from another ``os.path.exists`` check
|
||||
in already-noisy code paths."""
|
||||
if not file_path:
|
||||
return None
|
||||
try:
|
||||
if not os.path.exists(file_path):
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
return file_path
|
||||
|
||||
|
||||
__all__ = [
|
||||
'extract_track_meta_from_tags',
|
||||
'extract_album_meta_from_tags',
|
||||
'read_album_track_from_file',
|
||||
'normalize_resolved_path',
|
||||
]
|
||||
|
|
@ -19,6 +19,7 @@ tidal_enrichment_worker = None
|
|||
qobuz_enrichment_worker = None
|
||||
discogs_worker = None
|
||||
audiodb_worker = None
|
||||
amazon_worker = None
|
||||
|
||||
|
||||
def init(
|
||||
|
|
@ -31,11 +32,12 @@ def init(
|
|||
qobuz_worker=None,
|
||||
discogs_worker_obj=None,
|
||||
audiodb_worker_obj=None,
|
||||
amazon_worker_obj=None,
|
||||
):
|
||||
"""Bind enrichment worker handles so the lifted bodies can use them."""
|
||||
global spotify_enrichment_worker, itunes_enrichment_worker, mb_worker
|
||||
global lastfm_worker, genius_worker, tidal_enrichment_worker
|
||||
global qobuz_enrichment_worker, discogs_worker, audiodb_worker
|
||||
global qobuz_enrichment_worker, discogs_worker, audiodb_worker, amazon_worker
|
||||
spotify_enrichment_worker = spotify_worker
|
||||
itunes_enrichment_worker = itunes_worker
|
||||
mb_worker = musicbrainz_worker
|
||||
|
|
@ -45,6 +47,7 @@ def init(
|
|||
qobuz_enrichment_worker = qobuz_worker
|
||||
discogs_worker = discogs_worker_obj
|
||||
audiodb_worker = audiodb_worker_obj
|
||||
amazon_worker = amazon_worker_obj
|
||||
|
||||
|
||||
def _detect_provider(items, client):
|
||||
|
|
@ -293,4 +296,22 @@ def _search_service(service, entity_type, query):
|
|||
'image': None, 'extra': f"{result.get('strArtist', '')} · {result.get('strAlbum', '')}"}]
|
||||
return []
|
||||
|
||||
elif service == 'amazon':
|
||||
if not amazon_worker or not amazon_worker.client:
|
||||
raise ValueError("Amazon worker not initialized")
|
||||
client = amazon_worker.client
|
||||
if entity_type == 'artist':
|
||||
items = client.search_artists(query, limit=8)
|
||||
return [{'id': str(a.id), 'name': a.name, 'image': a.image_url,
|
||||
'extra': ', '.join(a.genres[:3]) if a.genres else ''} for a in items]
|
||||
elif entity_type == 'album':
|
||||
items = client.search_albums(query, limit=8)
|
||||
return [{'id': str(a.id), 'name': a.name, 'image': a.image_url,
|
||||
'extra': f"{', '.join(a.artists)} · {a.release_date or ''}"} for a in items]
|
||||
elif entity_type == 'track':
|
||||
items = client.search_tracks(query, limit=8)
|
||||
return [{'id': str(t.id), 'name': t.name, 'image': t.image_url,
|
||||
'extra': f"{', '.join(t.artists)} · {t.album or ''}"} for t in items]
|
||||
return []
|
||||
|
||||
return []
|
||||
|
|
|
|||
|
|
@ -549,17 +549,139 @@ def load_album_and_tracks(db, album_id):
|
|||
pass
|
||||
|
||||
|
||||
def _plan_from_tags(
|
||||
album_data: dict,
|
||||
tracks: List[dict],
|
||||
resolve_file_path_fn: Optional[Callable[[Optional[str]], Optional[str]]],
|
||||
) -> dict:
|
||||
"""Tag-mode planner: build per-track ``api_track`` shapes from each
|
||||
file's own embedded metadata instead of a live source API call.
|
||||
|
||||
Per-track behavior:
|
||||
- File missing on disk → unmatched with reason.
|
||||
- Tags missing essentials (title / artist / album) → unmatched
|
||||
with reason.
|
||||
- Otherwise matched with the per-file extracted ``api_track`` and
|
||||
a per-file ``api_album``. The plan stores the FIRST matched
|
||||
track's album dict on the top-level ``api_album`` field for
|
||||
backward compatibility with downstream callers; downstream
|
||||
consumers that need the per-track album shape read it off
|
||||
``items[i]['api_album']``.
|
||||
|
||||
Returns the same status / source / api_album / total_discs / items
|
||||
shape as :func:`plan_album_reorganize`. ``source`` is the literal
|
||||
string ``'tags'`` so callers can distinguish from API sources."""
|
||||
if resolve_file_path_fn is None:
|
||||
# Without the file-path resolver we can't read anything off
|
||||
# disk. Return an unmatched plan so callers surface a clear
|
||||
# error instead of silently returning empty.
|
||||
reason = 'Tag-mode reorganize requires the file path resolver.'
|
||||
return {
|
||||
'status': 'no_source_id', 'source': None, 'api_album': None,
|
||||
'total_discs': 1,
|
||||
'items': [{
|
||||
'track': t, 'api_track': None, 'matched': False,
|
||||
'reason': reason,
|
||||
} for t in tracks],
|
||||
}
|
||||
|
||||
from core.library.reorganize_tag_source import read_album_track_from_file
|
||||
|
||||
items: List[dict] = []
|
||||
first_album_meta: Optional[dict] = None
|
||||
max_disc = 1
|
||||
|
||||
for track in tracks:
|
||||
db_path = track.get('file_path')
|
||||
resolved = resolve_file_path_fn(db_path) if db_path else None
|
||||
if not resolved:
|
||||
items.append({
|
||||
'track': track, 'api_track': None, 'api_album': None,
|
||||
'matched': False,
|
||||
'reason': 'File no longer exists on disk for this track.',
|
||||
})
|
||||
continue
|
||||
|
||||
album_meta, track_meta, err = read_album_track_from_file(resolved)
|
||||
if err is not None or track_meta is None or album_meta is None:
|
||||
items.append({
|
||||
'track': track, 'api_track': None, 'api_album': None,
|
||||
'matched': False,
|
||||
'reason': err or 'Could not extract metadata from embedded tags.',
|
||||
})
|
||||
continue
|
||||
|
||||
if first_album_meta is None:
|
||||
first_album_meta = album_meta
|
||||
try:
|
||||
disc = int(track_meta.get('disc_number') or 1)
|
||||
except (TypeError, ValueError):
|
||||
disc = 1
|
||||
if disc > max_disc:
|
||||
max_disc = disc
|
||||
# Respect an explicit `totaldiscs` tag (or "1/2" disc-number
|
||||
# form) so a partial-album reorganize (only disc 1 present
|
||||
# locally) still routes into `Disc 1/` when the file's tags
|
||||
# know there are 2 discs total.
|
||||
try:
|
||||
tagged_total = int(album_meta.get('total_discs') or 0)
|
||||
except (TypeError, ValueError):
|
||||
tagged_total = 0
|
||||
if tagged_total > max_disc:
|
||||
max_disc = tagged_total
|
||||
|
||||
items.append({
|
||||
'track': track,
|
||||
'api_track': track_meta,
|
||||
'api_album': album_meta,
|
||||
'matched': True,
|
||||
'reason': None,
|
||||
})
|
||||
|
||||
if not any(it['matched'] for it in items):
|
||||
return {
|
||||
'status': 'no_source_id',
|
||||
'source': 'tags',
|
||||
'api_album': None,
|
||||
'total_discs': 1,
|
||||
'items': items,
|
||||
}
|
||||
|
||||
return {
|
||||
'status': 'planned',
|
||||
'source': 'tags',
|
||||
'api_album': first_album_meta or {},
|
||||
'total_discs': max_disc,
|
||||
'items': items,
|
||||
}
|
||||
|
||||
|
||||
def plan_album_reorganize(
|
||||
album_data: dict,
|
||||
tracks: List[dict],
|
||||
primary_source: Optional[str] = None,
|
||||
strict_source: bool = False,
|
||||
metadata_source: str = 'api',
|
||||
resolve_file_path_fn: Optional[Callable[[Optional[str]], Optional[str]]] = None,
|
||||
) -> dict:
|
||||
"""Compute the per-track plan for an album reorganize without doing
|
||||
any file IO. Both the actual reorganize orchestrator and the preview
|
||||
endpoint share this so the preview is guaranteed to match what would
|
||||
happen on apply.
|
||||
|
||||
``metadata_source``:
|
||||
- ``'api'`` (default): query the configured metadata source(s)
|
||||
for the canonical tracklist (existing behavior). Issues an
|
||||
API call.
|
||||
- ``'tags'``: read each file's embedded tags as the source of
|
||||
truth (issue #592). Zero API calls; trusts the user's
|
||||
enriched library.
|
||||
|
||||
When ``metadata_source='tags'``, ``resolve_file_path_fn`` MUST be
|
||||
provided (the planner needs to read the actual files). The
|
||||
``primary_source`` and ``strict_source`` params are ignored in
|
||||
tag mode.
|
||||
|
||||
Returns:
|
||||
``{'status': 'planned' | 'no_source_id' | 'no_tracks',
|
||||
'source': str | None,
|
||||
|
|
@ -581,6 +703,9 @@ def plan_album_reorganize(
|
|||
'total_discs': 1, 'items': [],
|
||||
}
|
||||
|
||||
if metadata_source == 'tags':
|
||||
return _plan_from_tags(album_data, tracks, resolve_file_path_fn)
|
||||
|
||||
if primary_source is None:
|
||||
try:
|
||||
primary_source = get_primary_source()
|
||||
|
|
@ -720,6 +845,7 @@ def preview_album_reorganize(
|
|||
build_final_path_fn: Callable,
|
||||
primary_source: Optional[str] = None,
|
||||
strict_source: bool = False,
|
||||
metadata_source: str = 'api',
|
||||
) -> dict:
|
||||
"""Compute the planned destination paths for a reorganize WITHOUT
|
||||
moving any files. The preview UI uses this to show users what the
|
||||
|
|
@ -775,6 +901,8 @@ def preview_album_reorganize(
|
|||
plan = plan_album_reorganize(
|
||||
album_data, tracks,
|
||||
primary_source=primary_source, strict_source=strict_source,
|
||||
metadata_source=metadata_source,
|
||||
resolve_file_path_fn=resolve_file_path_fn,
|
||||
)
|
||||
artist_name = album_data.get('artist_name') or 'Unknown Artist'
|
||||
album_title = album_data.get('title') or 'Unknown Album'
|
||||
|
|
@ -836,8 +964,12 @@ def preview_album_reorganize(
|
|||
item['disc_number'] = int(api_track.get('disc_number') or 1)
|
||||
# Build the same context the orchestrator builds so the path
|
||||
# builder produces the same destination it would on apply.
|
||||
# Tag-mode plan items carry per-item album metadata; fall back
|
||||
# to the shared api_album in API mode (where every plan item
|
||||
# shares the same one).
|
||||
per_item_album = plan_item.get('api_album') or api_album
|
||||
context = _build_post_process_context(
|
||||
api_album, api_track, artist_name, album_title, total_discs
|
||||
per_item_album, api_track, artist_name, album_title, total_discs
|
||||
)
|
||||
# `_build_final_path_for_track` switches between ALBUM and SINGLE
|
||||
# modes based on `album_info.get('is_album')` — must be passed,
|
||||
|
|
@ -1034,13 +1166,18 @@ def _stage_track(ctx: _RunContext, track_id, title, resolved_src) -> Optional[st
|
|||
return staging_file
|
||||
|
||||
|
||||
def _run_post_process_for_track(ctx: _RunContext, track_id, title, api_track, staging_file) -> Optional[str]:
|
||||
def _run_post_process_for_track(ctx: _RunContext, track_id, title, api_track, staging_file, *, per_item_api_album=None) -> Optional[str]:
|
||||
"""Build the per-track context, hand it to post-processing, and
|
||||
return the final on-disk path it produced. Returns None on any
|
||||
failure (exception, AcoustID rejection, internal skip); the caller
|
||||
leaves the original file alone."""
|
||||
leaves the original file alone.
|
||||
|
||||
``per_item_api_album`` overrides ``ctx.api_album`` for this track —
|
||||
used in tag-mode reorganize where each file may carry its own
|
||||
embedded album metadata."""
|
||||
api_album = per_item_api_album if per_item_api_album else ctx.api_album
|
||||
context = _build_post_process_context(
|
||||
ctx.api_album, api_track, ctx.artist_name, ctx.album_title, ctx.total_discs
|
||||
api_album, api_track, ctx.artist_name, ctx.album_title, ctx.total_discs
|
||||
)
|
||||
context_key = f"reorganize_{ctx.album_id}_{track_id}_{uuid.uuid4().hex[:8]}"
|
||||
try:
|
||||
|
|
@ -1138,7 +1275,10 @@ def _process_one_track(ctx: _RunContext, plan_item: dict) -> None:
|
|||
if staging_file is None:
|
||||
return
|
||||
|
||||
new_path = _run_post_process_for_track(ctx, track_id, title, plan_item['api_track'], staging_file)
|
||||
new_path = _run_post_process_for_track(
|
||||
ctx, track_id, title, plan_item['api_track'], staging_file,
|
||||
per_item_api_album=plan_item.get('api_album'),
|
||||
)
|
||||
if new_path is None:
|
||||
return
|
||||
|
||||
|
|
@ -1180,6 +1320,7 @@ def reorganize_album(
|
|||
primary_source: Optional[str] = None,
|
||||
strict_source: bool = False,
|
||||
stop_check: Optional[Callable[[], bool]] = None,
|
||||
metadata_source: str = 'api',
|
||||
) -> dict:
|
||||
"""Run a single album through the post-processing pipeline.
|
||||
|
||||
|
|
@ -1257,10 +1398,19 @@ def reorganize_album(
|
|||
plan = plan_album_reorganize(
|
||||
album_data, tracks,
|
||||
primary_source=primary_source, strict_source=strict_source,
|
||||
metadata_source=metadata_source,
|
||||
resolve_file_path_fn=resolve_file_path_fn,
|
||||
)
|
||||
if plan['status'] == 'no_source_id':
|
||||
summary['status'] = 'no_source_id'
|
||||
if _is_unknown_artist(album_data.get('artist_name')) or _looks_like_album_id_title(album_data.get('title')):
|
||||
summary['source'] = plan.get('source') # 'tags' or None
|
||||
if plan.get('source') == 'tags':
|
||||
err_text = (
|
||||
f"No tracks of '{album_data.get('title', '?')}' have readable "
|
||||
"embedded tags (missing title / artist / album, or file unreadable). "
|
||||
"Switch back to API mode or fix the embedded tags first."
|
||||
)
|
||||
elif _is_unknown_artist(album_data.get('artist_name')) or _looks_like_album_id_title(album_data.get('title')):
|
||||
err_text = (
|
||||
f"Album '{album_data.get('title', '?')}' has placeholder metadata "
|
||||
"(Unknown Artist or numeric title) — run the 'Fix Unknown Artists' "
|
||||
|
|
|
|||
115
core/matching/version_mismatch.py
Normal file
115
core/matching/version_mismatch.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Decide when an AcoustID version-annotation mismatch should still
|
||||
pass verification.
|
||||
|
||||
Issue #607 (AfonsoG6): live recordings were quarantining as
|
||||
"Version mismatch: expected '... (Live at Venue)' (live) but file is
|
||||
'Song' (original)" because MusicBrainz often stores the recording
|
||||
entity with a bare title — the venue / live annotation lives on the
|
||||
release entity, not the recording. The audio fingerprint correctly
|
||||
identifies the live recording (live audio has its own distinct
|
||||
fingerprint), but the title-text comparison flagged it as the wrong
|
||||
version.
|
||||
|
||||
Strict version mismatching stays for genuinely-different recordings
|
||||
(instrumental vs vocal, remix vs original, acoustic vs studio) —
|
||||
those have distinct fingerprints AND MB always annotates them in the
|
||||
recording title. We only loosen for the **live** direction
|
||||
specifically, since that's the asymmetry users actually hit:
|
||||
|
||||
- LIVE: MB often stores live recordings with bare titles. The
|
||||
fingerprint correctly identifies the live recording even when the
|
||||
recording's text title lacks a "(Live at ...)" annotation.
|
||||
- INSTRUMENTAL / REMIX / ACOUSTIC / DEMO / etc: MB always carries
|
||||
the version marker in the recording title. If AcoustID returns an
|
||||
instrumental for a vocal file query (or vice versa), it's a real
|
||||
wrong-recording match — the fingerprint matched the instrumental
|
||||
audio, which IS the wrong file.
|
||||
|
||||
Two-sided version mismatches stay strict — "live" vs "remix" really
|
||||
are different recordings even if MB titled them similarly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
_BARE_VERSION = 'original'
|
||||
|
||||
# Versions where a one-sided annotation difference is plausibly
|
||||
# explained by MB metadata gaps rather than a different recording.
|
||||
# Live recordings are the primary case; venue annotations live on the
|
||||
# release-track entity, not the recording entity, so the recording can
|
||||
# be bare-titled even though the audio is genuinely live.
|
||||
_LIVE_AWARE_VERSIONS = frozenset({'live'})
|
||||
|
||||
|
||||
def is_acceptable_version_mismatch(
|
||||
expected_version: str,
|
||||
matched_version: str,
|
||||
*,
|
||||
fingerprint_score: float,
|
||||
title_similarity: float,
|
||||
artist_similarity: float,
|
||||
score_threshold: float = 0.85,
|
||||
title_threshold: float = 0.70,
|
||||
artist_threshold: float = 0.60,
|
||||
) -> bool:
|
||||
"""Return True when an expected-vs-matched version annotation
|
||||
difference is likely a MusicBrainz metadata gap rather than a
|
||||
genuinely different recording.
|
||||
|
||||
Conditions (all must hold):
|
||||
|
||||
1. The mismatch is **one-sided AND involves a live-aware version**
|
||||
— exactly one side is ``'live'`` and the other is bare
|
||||
``'original'``. Other version markers (instrumental, remix,
|
||||
acoustic, etc) carry distinct fingerprints AND are always
|
||||
annotated in MB's recording title — we don't loosen for them.
|
||||
2. Fingerprint score ``>= score_threshold`` (default 0.85). The
|
||||
AcoustID fingerprint is high-confidence — we trust it.
|
||||
3. Bare title similarity ``>= title_threshold`` (default 0.70).
|
||||
After the version annotation is stripped, the underlying titles
|
||||
agree.
|
||||
4. Artist similarity ``>= artist_threshold`` (default 0.60). Same
|
||||
artist credit.
|
||||
|
||||
When ``expected_version == matched_version`` returns ``True``
|
||||
trivially — no mismatch to decide.
|
||||
|
||||
Examples that ACCEPT (return True):
|
||||
|
||||
- expected=``'live'``, matched=``'original'``, fp=0.95, title=0.95,
|
||||
artist=1.0 — typical live-recording MB-bare-title case (issue
|
||||
#607 example 2).
|
||||
- expected=``'original'``, matched=``'live'`` — same case in the
|
||||
other direction.
|
||||
|
||||
Examples that REJECT (return False):
|
||||
|
||||
- expected=``'instrumental'``, matched=``'original'`` — fingerprint
|
||||
matched the instrumental recording; if user asked for vocal, the
|
||||
file is genuinely wrong. Stays strict regardless of confidence.
|
||||
- expected=``'remix'``, matched=``'original'`` — same logic.
|
||||
- expected=``'live'``, matched=``'remix'`` — both versioned,
|
||||
both different. Real mismatch.
|
||||
- expected=``'live'``, matched=``'original'``, fp=0.50 — one-sided
|
||||
live but low confidence. Fall through to FAIL.
|
||||
- expected=``'live'``, matched=``'original'``, fp=0.95 but title
|
||||
sim 0.30 — bare titles don't agree. Different song.
|
||||
"""
|
||||
if expected_version == matched_version:
|
||||
return True
|
||||
|
||||
one_sided_live = (
|
||||
(expected_version in _LIVE_AWARE_VERSIONS and matched_version == _BARE_VERSION)
|
||||
or (expected_version == _BARE_VERSION and matched_version in _LIVE_AWARE_VERSIONS)
|
||||
)
|
||||
if not one_sided_live:
|
||||
return False
|
||||
|
||||
return (
|
||||
fingerprint_score >= score_threshold
|
||||
and title_similarity >= title_threshold
|
||||
and artist_similarity >= artist_threshold
|
||||
)
|
||||
|
||||
|
||||
__all__ = ['is_acceptable_version_mismatch']
|
||||
|
|
@ -28,6 +28,54 @@ __all__ = [
|
|||
logger = _create_logger("metadata.artwork")
|
||||
|
||||
|
||||
# Query-string keys whose values must be masked when a media-server
|
||||
# URL ends up in a log line. Plex uses X-Plex-Token, Jellyfin uses
|
||||
# X-Emby-Token / api_key, Navidrome's Subsonic auth uses t (token) +
|
||||
# s (salt) + p (password fallback). Logs end up persisted to disk —
|
||||
# leaking any of these gives full read access to the user's library.
|
||||
_REDACT_QUERY_KEYS = (
|
||||
'x-plex-token', 'x-emby-token', 'api_key', 'apikey',
|
||||
't', 's', 'p', 'token', 'password',
|
||||
)
|
||||
_REDACT_KEYS_ALT = '|'.join(re.escape(k) for k in _REDACT_QUERY_KEYS)
|
||||
# Plain form: `?key=value` or `&key=value`. Anchored on `?` / `&` (or
|
||||
# string start) so short keys like `t` only match at parameter
|
||||
# boundaries — not as a substring of `format=Jpg`.
|
||||
_REDACT_QUERY_RE = re.compile(
|
||||
r'(?i)(?P<lead>^|[?&])(?P<key>' + _REDACT_KEYS_ALT + r')=(?P<val>[^&\s]+)'
|
||||
)
|
||||
# URL-encoded form: `%3Fkey%3Dvalue` or `%26key%3Dvalue`. The image
|
||||
# proxy wraps the original URL via `?url=<encoded>`, so the auth
|
||||
# params end up encoded inside another URL. Without this second pass
|
||||
# the encoded form survives plain redaction and ships to logs intact.
|
||||
_REDACT_QUERY_RE_ENCODED = re.compile(
|
||||
r'(?i)(?P<lead>%3F|%26)(?P<key>' + _REDACT_KEYS_ALT + r')%3D(?P<val>[^%&\s]+?)(?=%26|&|\s|$)'
|
||||
)
|
||||
|
||||
|
||||
def _redact_url_secrets(url: str | None) -> str:
|
||||
"""Mask sensitive query parameters in a URL so the result is safe
|
||||
to log. Handles both the plain form (``?token=abc``) and the URL-
|
||||
encoded form (``%3Ftoken%3Dabc``) — the latter shows up when an
|
||||
auth-bearing URL is wrapped inside another URL's query string
|
||||
(e.g. our `/api/image-proxy?url=<encoded-plex-url>` flow).
|
||||
|
||||
Returns ``''`` for None/empty input. Idempotent (safe to call on
|
||||
already-redacted strings)."""
|
||||
if not url:
|
||||
return ''
|
||||
out = str(url)
|
||||
out = _REDACT_QUERY_RE.sub(
|
||||
lambda m: f"{m.group('lead')}{m.group('key')}=***REDACTED***",
|
||||
out,
|
||||
)
|
||||
out = _REDACT_QUERY_RE_ENCODED.sub(
|
||||
lambda m: f"{m.group('lead')}{m.group('key')}%3D***REDACTED***",
|
||||
out,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def normalize_image_url(thumb_url: str | None) -> str | None:
|
||||
"""Convert media-server image URLs into browser-safe URLs."""
|
||||
if not thumb_url:
|
||||
|
|
@ -62,7 +110,6 @@ def normalize_image_url(thumb_url: str | None) -> str | None:
|
|||
plex_config = cfg.get_plex_config()
|
||||
plex_base_url = plex_config.get('base_url', '')
|
||||
plex_token = plex_config.get('token', '')
|
||||
logger.info("Plex config - base_url: %s, token: %s...", plex_base_url, plex_token[:10])
|
||||
|
||||
if plex_base_url and plex_token:
|
||||
# Extract the path from URL
|
||||
|
|
@ -76,18 +123,13 @@ def normalize_image_url(thumb_url: str | None) -> str | None:
|
|||
|
||||
# Construct proper Plex URL with token
|
||||
fixed_url = f"{plex_base_url.rstrip('/')}{path}?X-Plex-Token={plex_token}"
|
||||
logger.info("Fixed URL: %s", fixed_url)
|
||||
logger.debug("Fixed URL: %s", _redact_url_secrets(fixed_url))
|
||||
return _browser_safe_image_url(fixed_url)
|
||||
|
||||
elif active_server == 'jellyfin':
|
||||
jellyfin_config = cfg.get_jellyfin_config()
|
||||
jellyfin_base_url = jellyfin_config.get('base_url', '')
|
||||
jellyfin_token = jellyfin_config.get('api_key', '')
|
||||
logger.info(
|
||||
"Jellyfin config - base_url: %s, token: %s...",
|
||||
jellyfin_base_url,
|
||||
jellyfin_token[:10] if jellyfin_token else 'None',
|
||||
)
|
||||
|
||||
if jellyfin_base_url:
|
||||
# Extract the path from URL
|
||||
|
|
@ -105,7 +147,7 @@ def normalize_image_url(thumb_url: str | None) -> str | None:
|
|||
fixed_url = f"{jellyfin_base_url.rstrip('/')}{path}{separator}X-Emby-Token={jellyfin_token}"
|
||||
else:
|
||||
fixed_url = f"{jellyfin_base_url.rstrip('/')}{path}"
|
||||
logger.info("Fixed URL: %s", fixed_url)
|
||||
logger.debug("Fixed URL: %s", _redact_url_secrets(fixed_url))
|
||||
return _browser_safe_image_url(fixed_url)
|
||||
|
||||
elif active_server == 'navidrome':
|
||||
|
|
@ -113,7 +155,6 @@ def normalize_image_url(thumb_url: str | None) -> str | None:
|
|||
navidrome_base_url = navidrome_config.get('base_url', '')
|
||||
navidrome_username = navidrome_config.get('username', '')
|
||||
navidrome_password = navidrome_config.get('password', '')
|
||||
logger.info("Navidrome config - base_url: %s, username: %s", navidrome_base_url, navidrome_username)
|
||||
|
||||
if navidrome_base_url and navidrome_username and navidrome_password:
|
||||
# Extract the path from URL
|
||||
|
|
@ -137,7 +178,7 @@ def normalize_image_url(thumb_url: str | None) -> str | None:
|
|||
|
||||
# Construct proper Navidrome Subsonic URL
|
||||
fixed_url = f"{navidrome_base_url.rstrip('/')}{path}{separator}{auth_params}"
|
||||
logger.info("Fixed URL: %s", fixed_url)
|
||||
logger.debug("Fixed URL: %s", _redact_url_secrets(fixed_url))
|
||||
return _browser_safe_image_url(fixed_url)
|
||||
|
||||
logger.warning("No configuration found for %s or unsupported server type", active_server)
|
||||
|
|
@ -146,7 +187,7 @@ def normalize_image_url(thumb_url: str | None) -> str | None:
|
|||
return _browser_safe_image_url(thumb_url)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Error fixing image URL '%s': %s", thumb_url, exc)
|
||||
logger.error("Error fixing image URL '%s': %s", _redact_url_secrets(thumb_url), exc)
|
||||
return _browser_safe_image_url(thumb_url)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -140,6 +140,14 @@ def _get_discogs_factory(client_factory: Optional[MetadataClientFactory]) -> Met
|
|||
return DiscogsClient
|
||||
|
||||
|
||||
def _get_amazon_factory(client_factory: Optional[MetadataClientFactory]) -> MetadataClientFactory:
|
||||
if client_factory is not None:
|
||||
return client_factory
|
||||
from core.amazon_client import AmazonClient
|
||||
|
||||
return AmazonClient
|
||||
|
||||
|
||||
def get_spotify_client(client_factory: Optional[MetadataClientFactory] = None):
|
||||
"""Get shared Spotify client.
|
||||
|
||||
|
|
@ -260,6 +268,18 @@ def get_discogs_client(
|
|||
return client
|
||||
|
||||
|
||||
def get_amazon_client(client_factory: Optional[MetadataClientFactory] = None):
|
||||
"""Get cached Amazon Music client."""
|
||||
cache_key = "amazon"
|
||||
factory = _get_amazon_factory(client_factory)
|
||||
with _client_cache_lock:
|
||||
client = _client_cache.get(cache_key)
|
||||
if client is None:
|
||||
client = factory()
|
||||
_client_cache[cache_key] = client
|
||||
return client
|
||||
|
||||
|
||||
def is_hydrabase_enabled() -> bool:
|
||||
"""Return True when Hydrabase is connected and app-enabled."""
|
||||
try:
|
||||
|
|
@ -331,6 +351,7 @@ def get_primary_client(
|
|||
itunes_client_factory: Optional[MetadataClientFactory] = None,
|
||||
deezer_client_factory: Optional[MetadataClientFactory] = None,
|
||||
discogs_client_factory: Optional[MetadataClientFactory] = None,
|
||||
amazon_client_factory: Optional[MetadataClientFactory] = None,
|
||||
):
|
||||
"""Return client for configured primary source."""
|
||||
return get_client_for_source(
|
||||
|
|
@ -339,6 +360,7 @@ def get_primary_client(
|
|||
itunes_client_factory=itunes_client_factory,
|
||||
deezer_client_factory=deezer_client_factory,
|
||||
discogs_client_factory=discogs_client_factory,
|
||||
amazon_client_factory=amazon_client_factory,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -348,6 +370,7 @@ def get_primary_source_status(
|
|||
itunes_client_factory: Optional[MetadataClientFactory] = None,
|
||||
deezer_client_factory: Optional[MetadataClientFactory] = None,
|
||||
discogs_client_factory: Optional[MetadataClientFactory] = None,
|
||||
amazon_client_factory: Optional[MetadataClientFactory] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return a generic status snapshot for the active primary metadata source."""
|
||||
source = _get_config_value("metadata.fallback_source", "deezer") or "deezer"
|
||||
|
|
@ -361,6 +384,7 @@ def get_primary_source_status(
|
|||
itunes_client_factory=itunes_client_factory,
|
||||
deezer_client_factory=deezer_client_factory,
|
||||
discogs_client_factory=discogs_client_factory,
|
||||
amazon_client_factory=amazon_client_factory,
|
||||
)
|
||||
if source == "spotify":
|
||||
connected = bool(client and client.is_spotify_authenticated())
|
||||
|
|
@ -387,6 +411,7 @@ def get_client_for_source(
|
|||
itunes_client_factory: Optional[MetadataClientFactory] = None,
|
||||
deezer_client_factory: Optional[MetadataClientFactory] = None,
|
||||
discogs_client_factory: Optional[MetadataClientFactory] = None,
|
||||
amazon_client_factory: Optional[MetadataClientFactory] = None,
|
||||
):
|
||||
"""Return exact client for a source, or None if unavailable."""
|
||||
if source == "spotify":
|
||||
|
|
@ -410,4 +435,7 @@ def get_client_for_source(
|
|||
if source == "itunes":
|
||||
return get_itunes_client(client_factory=itunes_client_factory)
|
||||
|
||||
if source == "amazon":
|
||||
return get_amazon_client(client_factory=amazon_client_factory)
|
||||
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ from core.metadata.registry import (
|
|||
clear_cached_metadata_client,
|
||||
clear_cached_metadata_clients,
|
||||
clear_cached_profile_spotify_client,
|
||||
get_amazon_client,
|
||||
get_client_for_source,
|
||||
get_deezer_client,
|
||||
get_discogs_client,
|
||||
|
|
@ -75,6 +76,7 @@ except Exception: # pragma: no cover - optional dependency fallback
|
|||
|
||||
__all__ = [
|
||||
"METADATA_SOURCE_PRIORITY",
|
||||
"get_amazon_client",
|
||||
"MetadataCache",
|
||||
"MetadataLookupOptions",
|
||||
"MetadataProvider",
|
||||
|
|
|
|||
24
core/personalized/__init__.py
Normal file
24
core/personalized/__init__.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""Standardized personalized-playlist subsystem.
|
||||
|
||||
Replaces the patchwork of `PersonalizedPlaylistsService` (computed-on-
|
||||
demand views, no persistence) + `discovery_curated_playlists` (ID-only
|
||||
storage) + `curated_seasonal_playlists` (full storage) with a single
|
||||
unified abstraction:
|
||||
|
||||
- ``manager.PersonalizedPlaylistManager`` — owns the storage layer +
|
||||
generator dispatch + refresh lifecycle.
|
||||
- ``specs.PlaylistKindSpec`` — one spec per playlist KIND
|
||||
(``hidden_gems``, ``time_machine``, ``seasonal_mix``, etc.) with
|
||||
generator function, default config, variant resolver, and display-
|
||||
name template.
|
||||
- ``types.Track`` / ``types.PlaylistConfig`` — shared dataclasses.
|
||||
|
||||
The legacy ``PersonalizedPlaylistsService`` keeps its existing
|
||||
generator implementations — they're called BY the manager rather than
|
||||
duplicated. This means:
|
||||
- The improved diversity logic / popularity thresholds / blacklist
|
||||
filtering all stays.
|
||||
- New behavior layered on top: persistence, refresh-on-demand,
|
||||
per-playlist user-tweakable config, staleness windows, listening-
|
||||
history cross-reference, seeded randomization.
|
||||
"""
|
||||
163
core/personalized/api.py
Normal file
163
core/personalized/api.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"""HTTP endpoint handlers for the personalized-playlists subsystem.
|
||||
|
||||
Wired into the Flask app from web_server.py. Each handler is a thin
|
||||
wrapper that:
|
||||
1. Pulls profile id + manager from request context.
|
||||
2. Calls one PersonalizedPlaylistManager method.
|
||||
3. Returns a JSON-serializable shape.
|
||||
|
||||
Live routes (registered against the main Flask app):
|
||||
- GET /api/personalized/playlists — list
|
||||
- GET /api/personalized/kinds — registry
|
||||
- GET /api/personalized/playlist/<kind> — singleton
|
||||
- GET /api/personalized/playlist/<kind>/<variant> — variant
|
||||
- POST /api/personalized/playlist/<kind>/refresh — singleton
|
||||
- POST /api/personalized/playlist/<kind>/<variant>/refresh — variant
|
||||
- PUT /api/personalized/playlist/<kind>/config — singleton
|
||||
- PUT /api/personalized/playlist/<kind>/<variant>/config — variant
|
||||
|
||||
The handlers themselves are pure functions returning Python dicts so
|
||||
they're testable without spinning up Flask. The wiring step in
|
||||
web_server.py wraps them in `jsonify` + URL routing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.personalized.manager import PersonalizedPlaylistManager
|
||||
from core.personalized.specs import PlaylistKindRegistry, get_registry
|
||||
from core.personalized.types import PlaylistRecord, Track
|
||||
|
||||
|
||||
def _record_to_dict(record: PlaylistRecord) -> Dict[str, Any]:
|
||||
return {
|
||||
'id': record.id,
|
||||
'profile_id': record.profile_id,
|
||||
'kind': record.kind,
|
||||
'variant': record.variant,
|
||||
'name': record.name,
|
||||
'config': record.config.to_json_dict(),
|
||||
'track_count': record.track_count,
|
||||
'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_to_dict(track: Track) -> Dict[str, Any]:
|
||||
return {
|
||||
'spotify_track_id': track.spotify_track_id,
|
||||
'itunes_track_id': track.itunes_track_id,
|
||||
'deezer_track_id': track.deezer_track_id,
|
||||
'track_name': track.track_name,
|
||||
'artist_name': track.artist_name,
|
||||
'album_name': track.album_name,
|
||||
'album_cover_url': track.album_cover_url,
|
||||
'duration_ms': track.duration_ms,
|
||||
'popularity': track.popularity,
|
||||
'track_data_json': track.track_data_json,
|
||||
'source': track.source,
|
||||
}
|
||||
|
||||
|
||||
def list_kinds(
|
||||
registry: Optional[PlaylistKindRegistry] = None,
|
||||
manager: Optional[PersonalizedPlaylistManager] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return every registered playlist kind with metadata.
|
||||
|
||||
UI uses this to render the "available playlists" picker. Each
|
||||
kind reports whether it requires a variant; when a manager is
|
||||
supplied AND the kind has a variant_resolver, the resolved
|
||||
variant list is also included so the UI can render variant
|
||||
checkboxes without a second round-trip per kind."""
|
||||
reg = registry or get_registry()
|
||||
out = []
|
||||
for spec in reg.all():
|
||||
entry = {
|
||||
'kind': spec.kind,
|
||||
'name_template': spec.name_template,
|
||||
'description': spec.description,
|
||||
'requires_variant': spec.requires_variant,
|
||||
'tags': list(spec.tags),
|
||||
'default_config': spec.default_config.to_json_dict(),
|
||||
'variants': [],
|
||||
}
|
||||
if manager is not None and spec.variant_resolver is not None:
|
||||
try:
|
||||
entry['variants'] = list(spec.variant_resolver(manager.deps) or [])
|
||||
except Exception:
|
||||
entry['variants'] = []
|
||||
out.append(entry)
|
||||
return {'success': True, 'kinds': out}
|
||||
|
||||
|
||||
def list_playlists(manager: PersonalizedPlaylistManager, profile_id: int) -> Dict[str, Any]:
|
||||
"""List every persisted playlist for a profile."""
|
||||
records = manager.list_playlists(profile_id)
|
||||
return {
|
||||
'success': True,
|
||||
'playlists': [_record_to_dict(r) for r in records],
|
||||
}
|
||||
|
||||
|
||||
def get_playlist_with_tracks(
|
||||
manager: PersonalizedPlaylistManager,
|
||||
kind: str,
|
||||
variant: str,
|
||||
profile_id: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Get the playlist row + its current track snapshot. Auto-creates
|
||||
the row from default config if it doesn't exist (so the UI's first-
|
||||
paint of an unseen kind works without a separate ensure call)."""
|
||||
record = manager.ensure_playlist(kind, variant, profile_id)
|
||||
tracks = manager.get_playlist_tracks(record.id)
|
||||
return {
|
||||
'success': True,
|
||||
'playlist': _record_to_dict(record),
|
||||
'tracks': [_track_to_dict(t) for t in tracks],
|
||||
}
|
||||
|
||||
|
||||
def refresh_playlist(
|
||||
manager: PersonalizedPlaylistManager,
|
||||
kind: str,
|
||||
variant: str,
|
||||
profile_id: int,
|
||||
config_overrides: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run the kind's generator and persist the snapshot. Returns the
|
||||
fresh row + tracks."""
|
||||
record = manager.refresh_playlist(kind, variant, profile_id, config_overrides=config_overrides)
|
||||
tracks = manager.get_playlist_tracks(record.id)
|
||||
return {
|
||||
'success': True,
|
||||
'playlist': _record_to_dict(record),
|
||||
'tracks': [_track_to_dict(t) for t in tracks],
|
||||
}
|
||||
|
||||
|
||||
def update_config(
|
||||
manager: PersonalizedPlaylistManager,
|
||||
kind: str,
|
||||
variant: str,
|
||||
profile_id: int,
|
||||
overrides: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Patch the playlist's config with the provided fields."""
|
||||
record = manager.update_config(kind, variant, profile_id, overrides)
|
||||
return {
|
||||
'success': True,
|
||||
'playlist': _record_to_dict(record),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
'list_kinds',
|
||||
'list_playlists',
|
||||
'get_playlist_with_tracks',
|
||||
'refresh_playlist',
|
||||
'update_config',
|
||||
]
|
||||
27
core/personalized/generators/__init__.py
Normal file
27
core/personalized/generators/__init__.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""Per-kind generators for the personalized-playlists subsystem.
|
||||
|
||||
Each module in this subpackage:
|
||||
1. Defines a generator function ``generate(deps, variant, config)``
|
||||
that returns a ``List[Track]``.
|
||||
2. Calls ``get_registry().register(spec)`` at import time so the
|
||||
manager auto-discovers it.
|
||||
|
||||
The legacy ``core.personalized_playlists.PersonalizedPlaylistsService``
|
||||
keeps its existing implementations — the wrappers in this package
|
||||
just adapt the call surface (`PlaylistConfig` → method kwargs) and
|
||||
coerce results into ``Track`` instances.
|
||||
|
||||
To register every generator, import this package — `from
|
||||
core.personalized import generators` — typically done once at
|
||||
application startup."""
|
||||
|
||||
# Importing each module triggers its registration side-effect.
|
||||
from core.personalized.generators import hidden_gems # noqa: F401
|
||||
from core.personalized.generators import discovery_shuffle # noqa: F401
|
||||
from core.personalized.generators import popular_picks # noqa: F401
|
||||
from core.personalized.generators import time_machine # noqa: F401
|
||||
from core.personalized.generators import genre_playlist # noqa: F401
|
||||
from core.personalized.generators import daily_mix # noqa: F401
|
||||
from core.personalized.generators import fresh_tape # noqa: F401
|
||||
from core.personalized.generators import archives # noqa: F401
|
||||
from core.personalized.generators import seasonal_mix # noqa: F401
|
||||
37
core/personalized/generators/_common.py
Normal file
37
core/personalized/generators/_common.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"""Shared helpers for personalized-playlist generators.
|
||||
|
||||
Each per-kind generator module is small + mechanical — it pulls the
|
||||
legacy ``PersonalizedPlaylistsService`` instance off the deps object
|
||||
and calls the matching method, then coerces results. This module
|
||||
holds the bits every generator reuses so we don't repeat them
|
||||
five times."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List
|
||||
|
||||
from core.personalized.types import Track
|
||||
|
||||
|
||||
def get_service(deps: Any):
|
||||
"""Pull the ``PersonalizedPlaylistsService`` instance from deps.
|
||||
|
||||
Generators access the service via ``deps.service``. Tests can
|
||||
pass a fake deps namespace with a ``service`` attribute that
|
||||
returns a stub. Raises a clear error if the dep isn't wired."""
|
||||
service = getattr(deps, 'service', None) or (deps.get('service') if isinstance(deps, dict) else None)
|
||||
if service is None:
|
||||
raise RuntimeError(
|
||||
"Personalized generator deps missing `service` "
|
||||
"(PersonalizedPlaylistsService instance). Wire it during "
|
||||
"PersonalizedPlaylistManager construction."
|
||||
)
|
||||
return service
|
||||
|
||||
|
||||
def coerce_tracks(rows: List[dict]) -> List[Track]:
|
||||
"""Convert legacy generator output (list of dicts) into Track
|
||||
instances. Tolerates None / non-list inputs by returning []."""
|
||||
if not rows:
|
||||
return []
|
||||
return [Track.from_dict(row) for row in rows if isinstance(row, dict)]
|
||||
35
core/personalized/generators/archives.py
Normal file
35
core/personalized/generators/archives.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""The Archives (Spotify Discover Weekly) generator.
|
||||
|
||||
Same shape as Fresh Tape — read curated track-id list from
|
||||
``discovery_curated_playlists`` under ``discovery_weekly_<source>``
|
||||
(fallback ``discovery_weekly``), hydrate via discovery pool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List
|
||||
|
||||
from core.personalized.generators.fresh_tape import _hydrate_curated
|
||||
from core.personalized.specs import PlaylistKindSpec, get_registry
|
||||
from core.personalized.types import PlaylistConfig, Track
|
||||
|
||||
|
||||
KIND = 'archives'
|
||||
|
||||
|
||||
def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]:
|
||||
return _hydrate_curated(deps, 'discovery_weekly', config)
|
||||
|
||||
|
||||
SPEC = PlaylistKindSpec(
|
||||
kind=KIND,
|
||||
name_template='The Archives',
|
||||
description='Your Spotify Discover Weekly — curated discovery picks.',
|
||||
default_config=PlaylistConfig(limit=50, max_per_album=5, max_per_artist=10),
|
||||
generator=generate,
|
||||
requires_variant=False,
|
||||
tags=['curated', 'spotify'],
|
||||
)
|
||||
|
||||
|
||||
if get_registry().get(KIND) is None:
|
||||
get_registry().register(SPEC)
|
||||
83
core/personalized/generators/daily_mix.py
Normal file
83
core/personalized/generators/daily_mix.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Daily Mix generator — top library genre → discovery picks.
|
||||
|
||||
Variant = rank position as a string ('1' / '2' / '3' / '4'). Each
|
||||
mix tracks the user's Nth top library genre and returns discovery
|
||||
picks within it. Top genres recompute at refresh time, so as the
|
||||
library evolves a mix's underlying genre can shift -- the playlist
|
||||
metadata records which genre was used at the most recent refresh
|
||||
so the UI can label the mix accurately.
|
||||
|
||||
Note: previously this kind ambitiously promised 50% library + 50%
|
||||
discovery. The library half was a stub (`tracks` table has no
|
||||
source IDs to sync), so the new generator is discovery-only.
|
||||
A future enhancement can backfill source IDs into library rows
|
||||
and re-add the hybrid behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List
|
||||
|
||||
from core.personalized.generators._common import coerce_tracks, get_service
|
||||
from core.personalized.specs import PlaylistKindSpec, get_registry
|
||||
from core.personalized.types import PlaylistConfig, Track
|
||||
|
||||
|
||||
KIND = 'daily_mix'
|
||||
|
||||
# Default rank set — UI surfaces 4 daily mixes by default.
|
||||
_DEFAULT_RANKS = ('1', '2', '3', '4')
|
||||
|
||||
# Number of top library genres to consider when ranking.
|
||||
_MAX_TOP_GENRES = 8
|
||||
|
||||
|
||||
def _resolve_genre_for_rank(service, rank: int) -> str:
|
||||
"""Look up the user's Nth-ranked top library genre. Returns the
|
||||
genre key or '' when no genre at that rank.
|
||||
|
||||
Calls ``service.get_top_genres_from_library(limit=...)`` and
|
||||
indexes the resulting (genre, count) tuples by 0-based rank.
|
||||
"""
|
||||
top = service.get_top_genres_from_library(limit=_MAX_TOP_GENRES) or []
|
||||
if rank < 1 or rank > len(top):
|
||||
return ''
|
||||
pair = top[rank - 1]
|
||||
if not pair:
|
||||
return ''
|
||||
# `top` is List[Tuple[str, int]] per service signature.
|
||||
return pair[0] if isinstance(pair, (tuple, list)) else str(pair)
|
||||
|
||||
|
||||
def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]:
|
||||
service = get_service(deps)
|
||||
try:
|
||||
rank = int(variant)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Daily Mix variant {variant!r} must be a rank int") from exc
|
||||
genre = _resolve_genre_for_rank(service, rank)
|
||||
if not genre:
|
||||
# User's library doesn't have enough genres for this rank.
|
||||
return []
|
||||
rows = service.get_genre_playlist(genre=genre, limit=config.limit)
|
||||
return coerce_tracks(rows)
|
||||
|
||||
|
||||
def variant_resolver(deps: Any) -> List[str]:
|
||||
"""Return the standard rank set."""
|
||||
return list(_DEFAULT_RANKS)
|
||||
|
||||
|
||||
SPEC = PlaylistKindSpec(
|
||||
kind=KIND,
|
||||
name_template='Daily Mix {variant}',
|
||||
description='Personalized mix based on your top library genres. One mix per top genre rank.',
|
||||
default_config=PlaylistConfig(limit=50, max_per_album=2, max_per_artist=3),
|
||||
generator=generate,
|
||||
variant_resolver=variant_resolver,
|
||||
requires_variant=True,
|
||||
tags=['discovery', 'personalized'],
|
||||
)
|
||||
|
||||
|
||||
if get_registry().get(KIND) is None:
|
||||
get_registry().register(SPEC)
|
||||
33
core/personalized/generators/discovery_shuffle.py
Normal file
33
core/personalized/generators/discovery_shuffle.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""Discovery Shuffle generator — pure-random discovery pool exploration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List
|
||||
|
||||
from core.personalized.generators._common import coerce_tracks, get_service
|
||||
from core.personalized.specs import PlaylistKindSpec, get_registry
|
||||
from core.personalized.types import PlaylistConfig, Track
|
||||
|
||||
|
||||
KIND = 'discovery_shuffle'
|
||||
|
||||
|
||||
def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]:
|
||||
service = get_service(deps)
|
||||
rows = service.get_discovery_shuffle(limit=config.limit)
|
||||
return coerce_tracks(rows)
|
||||
|
||||
|
||||
SPEC = PlaylistKindSpec(
|
||||
kind=KIND,
|
||||
name_template='Discovery Shuffle',
|
||||
description='Pure random shuffle from the discovery pool — different every refresh.',
|
||||
default_config=PlaylistConfig(limit=50, max_per_album=2, max_per_artist=2),
|
||||
generator=generate,
|
||||
requires_variant=False,
|
||||
tags=['discovery'],
|
||||
)
|
||||
|
||||
|
||||
if get_registry().get(KIND) is None:
|
||||
get_registry().register(SPEC)
|
||||
119
core/personalized/generators/fresh_tape.py
Normal file
119
core/personalized/generators/fresh_tape.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""Fresh Tape (Spotify Release Radar) generator.
|
||||
|
||||
Reads the curated track-id list cached in ``discovery_curated_playlists``
|
||||
under ``release_radar_<source>`` (with fallback to ``release_radar``)
|
||||
and hydrates each ID against the discovery pool to produce full Track
|
||||
records. The Spotify enrichment worker is responsible for keeping the
|
||||
curated list fresh — this generator is just a read-and-hydrate path."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, List
|
||||
|
||||
from core.personalized.generators._common import coerce_tracks
|
||||
from core.personalized.specs import PlaylistKindSpec, get_registry
|
||||
from core.personalized.types import PlaylistConfig, Track
|
||||
|
||||
|
||||
KIND = 'fresh_tape'
|
||||
|
||||
|
||||
def _hydrate_curated(deps: Any, curated_type_prefix: str, config: PlaylistConfig) -> List[Track]:
|
||||
"""Shared body for Fresh Tape + Archives — pulls the cached IDs
|
||||
from discovery_curated_playlists and hydrates them via the live
|
||||
discovery pool. Returns a Track list trimmed to ``config.limit``."""
|
||||
# Allow tests to inject a fake db / service; production flow gets
|
||||
# them from the manager's deps.
|
||||
db = getattr(deps, 'database', None) or (deps.get('database') if isinstance(deps, dict) else None)
|
||||
if db is None:
|
||||
raise RuntimeError("Curated-playlist generator deps missing `database`")
|
||||
|
||||
profile_id = _resolve_profile_id(deps)
|
||||
active_source = _resolve_active_source(deps)
|
||||
|
||||
# Try source-specific then generic, mirrors web_server endpoint behavior.
|
||||
curated_ids = (
|
||||
db.get_curated_playlist(f'{curated_type_prefix}_{active_source}', profile_id=profile_id)
|
||||
or db.get_curated_playlist(curated_type_prefix, profile_id=profile_id)
|
||||
or []
|
||||
)
|
||||
if not curated_ids:
|
||||
return []
|
||||
|
||||
pool_rows = db.get_discovery_pool_tracks(
|
||||
limit=5000, new_releases_only=False,
|
||||
source=active_source, profile_id=profile_id,
|
||||
)
|
||||
by_id = {}
|
||||
for t in pool_rows:
|
||||
if active_source == 'spotify' and getattr(t, 'spotify_track_id', None):
|
||||
by_id[t.spotify_track_id] = t
|
||||
elif active_source == 'deezer' and getattr(t, 'deezer_track_id', None):
|
||||
by_id[t.deezer_track_id] = t
|
||||
elif active_source == 'itunes' and getattr(t, 'itunes_track_id', None):
|
||||
by_id[t.itunes_track_id] = t
|
||||
|
||||
tracks: List[Track] = []
|
||||
for tid in curated_ids:
|
||||
candidate = by_id.get(tid)
|
||||
if candidate is None:
|
||||
continue
|
||||
# The pool track is a row-like object; coerce to dict for
|
||||
# Track.from_dict's existing tolerance.
|
||||
td = getattr(candidate, 'track_data_json', None)
|
||||
if isinstance(td, str):
|
||||
try:
|
||||
td = json.loads(td)
|
||||
except (ValueError, TypeError):
|
||||
td = None
|
||||
track_dict = {
|
||||
'spotify_track_id': getattr(candidate, 'spotify_track_id', None),
|
||||
'itunes_track_id': getattr(candidate, 'itunes_track_id', None),
|
||||
'deezer_track_id': getattr(candidate, 'deezer_track_id', None),
|
||||
'track_name': getattr(candidate, 'track_name', ''),
|
||||
'artist_name': getattr(candidate, 'artist_name', ''),
|
||||
'album_name': getattr(candidate, 'album_name', ''),
|
||||
'album_cover_url': getattr(candidate, 'album_cover_url', None),
|
||||
'duration_ms': getattr(candidate, 'duration_ms', 0),
|
||||
'popularity': getattr(candidate, 'popularity', 0),
|
||||
'track_data_json': td,
|
||||
'source': getattr(candidate, 'source', active_source),
|
||||
}
|
||||
tracks.append(Track.from_dict(track_dict))
|
||||
if len(tracks) >= config.limit:
|
||||
break
|
||||
return tracks
|
||||
|
||||
|
||||
def _resolve_profile_id(deps: Any) -> int:
|
||||
fn = getattr(deps, 'get_current_profile_id', None) or (
|
||||
deps.get('get_current_profile_id') if isinstance(deps, dict) else None
|
||||
)
|
||||
return fn() if callable(fn) else 1
|
||||
|
||||
|
||||
def _resolve_active_source(deps: Any) -> str:
|
||||
fn = getattr(deps, 'get_active_discovery_source', None) or (
|
||||
deps.get('get_active_discovery_source') if isinstance(deps, dict) else None
|
||||
)
|
||||
return fn() if callable(fn) else 'spotify'
|
||||
|
||||
|
||||
def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]:
|
||||
return _hydrate_curated(deps, 'release_radar', config)
|
||||
|
||||
|
||||
SPEC = PlaylistKindSpec(
|
||||
kind=KIND,
|
||||
name_template='Fresh Tape',
|
||||
description='Your Spotify Release Radar — new releases from artists you follow.',
|
||||
default_config=PlaylistConfig(limit=50, max_per_album=5, max_per_artist=10),
|
||||
generator=generate,
|
||||
requires_variant=False,
|
||||
tags=['curated', 'spotify'],
|
||||
)
|
||||
|
||||
|
||||
if get_registry().get(KIND) is None:
|
||||
get_registry().register(SPEC)
|
||||
78
core/personalized/generators/genre_playlist.py
Normal file
78
core/personalized/generators/genre_playlist.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""Genre Playlist generator — discovery picks within one genre.
|
||||
|
||||
Variant = either a parent-genre key from
|
||||
``PersonalizedPlaylistsService.GENRE_MAPPING`` (e.g. ``'rock'``,
|
||||
``'electronic_dance'``) or a specific child-genre keyword (e.g.
|
||||
``'house'``). Stored variant is always normalized to lowercase
|
||||
underscore-separated form so the UI and storage agree.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List
|
||||
|
||||
from core.personalized.generators._common import coerce_tracks, get_service
|
||||
from core.personalized.specs import PlaylistKindSpec, get_registry
|
||||
from core.personalized.types import PlaylistConfig, Track
|
||||
|
||||
|
||||
KIND = 'genre_playlist'
|
||||
|
||||
|
||||
def _normalize_variant_to_genre_key(variant: str, service) -> str:
|
||||
"""Resolve a variant string back into the genre identifier the
|
||||
legacy service expects.
|
||||
|
||||
Service accepts both parent-genre KEYS from GENRE_MAPPING (e.g.
|
||||
'Electronic/Dance', 'Hip Hop/Rap') and free-form keywords.
|
||||
The URL-safe variant we store is the parent key with `/` replaced
|
||||
by `_` and lowercased — e.g. 'electronic_dance'. This helper
|
||||
inverts that mapping."""
|
||||
if not variant:
|
||||
raise ValueError('Genre playlist requires a variant')
|
||||
|
||||
# Build a once-computed lookup of normalized → original parent key.
|
||||
mapping = getattr(service, 'GENRE_MAPPING', {})
|
||||
for parent_key in mapping.keys():
|
||||
normalized = parent_key.lower().replace('/', '_').replace(' ', '_')
|
||||
if normalized == variant.lower():
|
||||
return parent_key
|
||||
|
||||
# Fall through: treat the variant as a free-form keyword (the
|
||||
# legacy service handles partial matching for those).
|
||||
return variant
|
||||
|
||||
|
||||
def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]:
|
||||
service = get_service(deps)
|
||||
genre_key = _normalize_variant_to_genre_key(variant, service)
|
||||
rows = service.get_genre_playlist(genre=genre_key, limit=config.limit)
|
||||
return coerce_tracks(rows)
|
||||
|
||||
|
||||
def variant_resolver(deps: Any) -> List[str]:
|
||||
"""Return the URL-safe variant for every parent genre defined on
|
||||
the service. Specific (free-form) genre variants aren't enumerated
|
||||
— they're created on demand when the user requests a custom one."""
|
||||
service = get_service(deps)
|
||||
mapping = getattr(service, 'GENRE_MAPPING', {})
|
||||
return [
|
||||
parent.lower().replace('/', '_').replace(' ', '_')
|
||||
for parent in mapping.keys()
|
||||
]
|
||||
|
||||
|
||||
SPEC = PlaylistKindSpec(
|
||||
kind=KIND,
|
||||
name_template='Genre — {variant}',
|
||||
description='Discovery picks within one genre. Supports parent-genre families + free-form genre keywords.',
|
||||
default_config=PlaylistConfig(limit=50, max_per_album=3, max_per_artist=5),
|
||||
generator=generate,
|
||||
variant_resolver=variant_resolver,
|
||||
requires_variant=True,
|
||||
tags=['genre'],
|
||||
)
|
||||
|
||||
|
||||
if get_registry().get(KIND) is None:
|
||||
get_registry().register(SPEC)
|
||||
42
core/personalized/generators/hidden_gems.py
Normal file
42
core/personalized/generators/hidden_gems.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""Hidden Gems generator — low-popularity tracks from discovery pool.
|
||||
|
||||
Wraps ``PersonalizedPlaylistsService.get_hidden_gems`` so the
|
||||
existing source-aware popularity threshold + diversity filter
|
||||
behavior is preserved verbatim. The user-tweakable knobs that
|
||||
arrive via ``PlaylistConfig`` (limit) flow through; future config
|
||||
options (popularity_max override, exclude_recent_days) get layered
|
||||
on the wrapper without changing the legacy implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List
|
||||
|
||||
from core.personalized.generators._common import coerce_tracks, get_service
|
||||
from core.personalized.specs import PlaylistKindSpec, get_registry
|
||||
from core.personalized.types import PlaylistConfig, Track
|
||||
|
||||
|
||||
KIND = 'hidden_gems'
|
||||
|
||||
|
||||
def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]:
|
||||
service = get_service(deps)
|
||||
rows = service.get_hidden_gems(limit=config.limit)
|
||||
return coerce_tracks(rows)
|
||||
|
||||
|
||||
SPEC = PlaylistKindSpec(
|
||||
kind=KIND,
|
||||
name_template='Hidden Gems',
|
||||
description='Low-popularity discovery picks — underground / indie tracks you probably haven\'t heard.',
|
||||
default_config=PlaylistConfig(limit=50, max_per_album=2, max_per_artist=3),
|
||||
generator=generate,
|
||||
requires_variant=False,
|
||||
tags=['discovery'],
|
||||
)
|
||||
|
||||
|
||||
# Register at import time so the manager auto-discovers this kind.
|
||||
# Re-import (e.g. test reloads) is tolerated: only register if absent.
|
||||
if get_registry().get(KIND) is None:
|
||||
get_registry().register(SPEC)
|
||||
33
core/personalized/generators/popular_picks.py
Normal file
33
core/personalized/generators/popular_picks.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""Popular Picks generator — high-popularity discovery pool picks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List
|
||||
|
||||
from core.personalized.generators._common import coerce_tracks, get_service
|
||||
from core.personalized.specs import PlaylistKindSpec, get_registry
|
||||
from core.personalized.types import PlaylistConfig, Track
|
||||
|
||||
|
||||
KIND = 'popular_picks'
|
||||
|
||||
|
||||
def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]:
|
||||
service = get_service(deps)
|
||||
rows = service.get_popular_picks(limit=config.limit)
|
||||
return coerce_tracks(rows)
|
||||
|
||||
|
||||
SPEC = PlaylistKindSpec(
|
||||
kind=KIND,
|
||||
name_template='Popular Picks',
|
||||
description='High-popularity tracks from the discovery pool — what most people are listening to.',
|
||||
default_config=PlaylistConfig(limit=50, max_per_album=2, max_per_artist=3),
|
||||
generator=generate,
|
||||
requires_variant=False,
|
||||
tags=['discovery'],
|
||||
)
|
||||
|
||||
|
||||
if get_registry().get(KIND) is None:
|
||||
get_registry().register(SPEC)
|
||||
137
core/personalized/generators/seasonal_mix.py
Normal file
137
core/personalized/generators/seasonal_mix.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
"""Seasonal Mix generator (variant = season key).
|
||||
|
||||
Variant = season key from ``SEASONAL_CONFIG`` (``'halloween'`` /
|
||||
``'christmas'`` / ``'valentines'`` / ``'summer'`` / ``'spring'`` /
|
||||
``'autumn'``). One playlist per season — user picks which seasons
|
||||
to enable; idle seasons can stay un-refreshed until their active
|
||||
period.
|
||||
|
||||
Reads curated track IDs from ``curated_seasonal_playlists`` (via
|
||||
``SeasonalDiscoveryService.get_curated_seasonal_playlist``) and
|
||||
hydrates them against ``seasonal_tracks`` (which carries full
|
||||
metadata including ``track_data_json`` for sync-ready downstream
|
||||
use)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, List
|
||||
|
||||
from core.personalized.specs import PlaylistKindSpec, get_registry
|
||||
from core.personalized.types import PlaylistConfig, Track
|
||||
|
||||
|
||||
KIND = 'seasonal_mix'
|
||||
|
||||
|
||||
def _resolve_seasonal_service(deps: Any):
|
||||
"""Pull the SeasonalDiscoveryService instance from deps."""
|
||||
svc = getattr(deps, 'seasonal_service', None) or (
|
||||
deps.get('seasonal_service') if isinstance(deps, dict) else None
|
||||
)
|
||||
if svc is None:
|
||||
raise RuntimeError(
|
||||
"Seasonal mix generator deps missing `seasonal_service` "
|
||||
"(SeasonalDiscoveryService instance)."
|
||||
)
|
||||
return svc
|
||||
|
||||
|
||||
def _resolve_database(deps: Any):
|
||||
db = getattr(deps, 'database', None) or (
|
||||
deps.get('database') if isinstance(deps, dict) else None
|
||||
)
|
||||
if db is None:
|
||||
raise RuntimeError("Seasonal mix generator deps missing `database`")
|
||||
return db
|
||||
|
||||
|
||||
def _resolve_active_source(deps: Any) -> str:
|
||||
fn = getattr(deps, 'get_active_discovery_source', None) or (
|
||||
deps.get('get_active_discovery_source') if isinstance(deps, dict) else None
|
||||
)
|
||||
return fn() if callable(fn) else 'spotify'
|
||||
|
||||
|
||||
def _hydrate_seasonal_tracks(db, season_key: str, source: str, track_ids: List[str]) -> List[Track]:
|
||||
"""Look up the seasonal_tracks rows for the given IDs."""
|
||||
if not track_ids:
|
||||
return []
|
||||
placeholders = ','.join('?' * len(track_ids))
|
||||
with db._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT spotify_track_id, track_name, artist_name, album_name,
|
||||
album_cover_url, duration_ms, popularity, track_data_json
|
||||
FROM seasonal_tracks
|
||||
WHERE season_key = ? AND source = ?
|
||||
AND spotify_track_id IN ({placeholders})
|
||||
""",
|
||||
(season_key, source, *track_ids),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
by_id = {}
|
||||
for r in rows:
|
||||
if hasattr(r, 'keys'):
|
||||
r = dict(r)
|
||||
else:
|
||||
r = dict(zip(
|
||||
('spotify_track_id', 'track_name', 'artist_name', 'album_name',
|
||||
'album_cover_url', 'duration_ms', 'popularity', 'track_data_json'),
|
||||
r,
|
||||
strict=False,
|
||||
))
|
||||
td = r.get('track_data_json')
|
||||
if isinstance(td, str):
|
||||
try:
|
||||
td = json.loads(td)
|
||||
except (ValueError, TypeError):
|
||||
td = None
|
||||
r['track_data_json'] = td
|
||||
r['source'] = source
|
||||
by_id[r['spotify_track_id']] = r
|
||||
|
||||
# Preserve curated order.
|
||||
return [
|
||||
Track.from_dict(by_id[tid])
|
||||
for tid in track_ids
|
||||
if tid in by_id
|
||||
]
|
||||
|
||||
|
||||
def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]:
|
||||
if not variant:
|
||||
raise ValueError('Seasonal Mix requires a season variant')
|
||||
seasonal_service = _resolve_seasonal_service(deps)
|
||||
db = _resolve_database(deps)
|
||||
source = _resolve_active_source(deps)
|
||||
track_ids = seasonal_service.get_curated_seasonal_playlist(variant, source=source) or []
|
||||
tracks = _hydrate_seasonal_tracks(db, variant, source, track_ids)
|
||||
return tracks[:config.limit]
|
||||
|
||||
|
||||
def variant_resolver(deps: Any) -> List[str]:
|
||||
"""Return every season key from SEASONAL_CONFIG."""
|
||||
try:
|
||||
from core.seasonal_discovery import SEASONAL_CONFIG
|
||||
except Exception:
|
||||
return []
|
||||
return list(SEASONAL_CONFIG.keys())
|
||||
|
||||
|
||||
SPEC = PlaylistKindSpec(
|
||||
kind=KIND,
|
||||
name_template='Seasonal — {variant}',
|
||||
description='Holiday / season-themed picks. One playlist per season; user enables which to track.',
|
||||
default_config=PlaylistConfig(limit=50, max_per_album=2, max_per_artist=3),
|
||||
generator=generate,
|
||||
variant_resolver=variant_resolver,
|
||||
requires_variant=True,
|
||||
tags=['curated', 'seasonal'],
|
||||
)
|
||||
|
||||
|
||||
if get_registry().get(KIND) is None:
|
||||
get_registry().register(SPEC)
|
||||
66
core/personalized/generators/time_machine.py
Normal file
66
core/personalized/generators/time_machine.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""Time Machine generator — by-decade discovery picks.
|
||||
|
||||
Variant = decade label like ``'1980s'`` / ``'1990s'`` / ``'2000s'``.
|
||||
The variant resolver returns the standard decade set; users see one
|
||||
playlist per decade (each independently configurable / refreshable).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List
|
||||
|
||||
from core.personalized.generators._common import coerce_tracks, get_service
|
||||
from core.personalized.specs import PlaylistKindSpec, get_registry
|
||||
from core.personalized.types import PlaylistConfig, Track
|
||||
|
||||
|
||||
KIND = 'time_machine'
|
||||
|
||||
|
||||
# Standard decades the UI exposes. Adjust here when adding eras.
|
||||
_DEFAULT_DECADES = ('1960s', '1970s', '1980s', '1990s', '2000s', '2010s', '2020s')
|
||||
|
||||
|
||||
def _decade_to_year(variant: str) -> int:
|
||||
"""'1980s' -> 1980. Tolerates ' 1980 ', '1980'.
|
||||
|
||||
Raises ValueError for anything that doesn't look like a decade
|
||||
label so the manager surfaces a clear error instead of generating
|
||||
garbage."""
|
||||
cleaned = (variant or '').strip().rstrip('sS')
|
||||
try:
|
||||
year = int(cleaned)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Time Machine variant {variant!r} not a decade label") from exc
|
||||
if year < 1900 or year > 2100:
|
||||
raise ValueError(f"Time Machine variant {variant!r} out of range")
|
||||
return year
|
||||
|
||||
|
||||
def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]:
|
||||
service = get_service(deps)
|
||||
decade_year = _decade_to_year(variant)
|
||||
rows = service.get_decade_playlist(decade=decade_year, limit=config.limit)
|
||||
return coerce_tracks(rows)
|
||||
|
||||
|
||||
def variant_resolver(deps: Any) -> List[str]:
|
||||
"""Return the standard decade set. Future enhancement: filter to
|
||||
decades that actually have data in the discovery pool."""
|
||||
return list(_DEFAULT_DECADES)
|
||||
|
||||
|
||||
SPEC = PlaylistKindSpec(
|
||||
kind=KIND,
|
||||
name_template='Time Machine — {variant}',
|
||||
description='Tracks from a specific decade. One playlist per decade.',
|
||||
default_config=PlaylistConfig(limit=100, max_per_album=3, max_per_artist=5),
|
||||
generator=generate,
|
||||
variant_resolver=variant_resolver,
|
||||
requires_variant=True,
|
||||
tags=['time'],
|
||||
)
|
||||
|
||||
|
||||
if get_registry().get(KIND) is None:
|
||||
get_registry().register(SPEC)
|
||||
478
core/personalized/manager.py
Normal file
478
core/personalized/manager.py
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
"""Storage layer + lifecycle for personalized playlists.
|
||||
|
||||
The manager is the ONLY place that touches the
|
||||
``personalized_playlists`` / ``personalized_playlist_tracks`` /
|
||||
``personalized_track_history`` tables. Generators (in
|
||||
``core/personalized/generators/``) produce track lists; the manager
|
||||
persists, refreshes, and serves them.
|
||||
|
||||
Key invariants:
|
||||
|
||||
- ``(profile_id, kind, variant)`` uniquely identifies a playlist.
|
||||
Variant '' (empty string) means singleton — used for kinds like
|
||||
``hidden_gems`` that don't have multiple instances per profile.
|
||||
- A playlist row is auto-created on first access (``ensure_playlist``)
|
||||
using the kind's default config.
|
||||
- Track lists are atomically replaced on refresh — never partial-
|
||||
mutated. Either the new snapshot lands fully or the old one
|
||||
remains.
|
||||
- Refresh failures get logged on the row
|
||||
(``last_generation_error``) so the UI can surface them without
|
||||
losing the previous good snapshot.
|
||||
- Staleness history is append-only and queried by the
|
||||
``exclude_recent_days`` config option (handled by individual
|
||||
generators when they want to honor it).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
from core.personalized.specs import PlaylistKindRegistry, get_registry
|
||||
from core.personalized.types import PlaylistConfig, PlaylistRecord, Track
|
||||
|
||||
logger = get_logger("personalized.manager")
|
||||
|
||||
|
||||
class PersonalizedPlaylistManager:
|
||||
"""Owns persistence + refresh lifecycle for personalized playlists."""
|
||||
|
||||
def __init__(self, database, deps: Any, registry: Optional[PlaylistKindRegistry] = None):
|
||||
"""
|
||||
Args:
|
||||
database: MusicDatabase singleton (exposes ``_get_connection``).
|
||||
deps: Opaque object passed through to each generator
|
||||
callable. Whatever a generator needs (the legacy
|
||||
``PersonalizedPlaylistsService`` instance, the
|
||||
``config_manager``, a metadata client) goes in here.
|
||||
Manager doesn't inspect it.
|
||||
registry: optional PlaylistKindRegistry override (for tests).
|
||||
"""
|
||||
self.database = database
|
||||
self.deps = deps
|
||||
self.registry = registry or get_registry()
|
||||
|
||||
# ─── playlist row lifecycle ──────────────────────────────────────
|
||||
|
||||
def ensure_playlist(self, kind: str, variant: str = '', profile_id: int = 1) -> PlaylistRecord:
|
||||
"""Return the playlist row for ``(profile, kind, variant)``,
|
||||
creating it from the kind's default config if it doesn't exist."""
|
||||
spec = self.registry.get(kind)
|
||||
if spec is None:
|
||||
raise ValueError(f"Unknown playlist kind: {kind!r}")
|
||||
if spec.requires_variant and not variant:
|
||||
raise ValueError(f"Kind {kind!r} requires a variant")
|
||||
|
||||
existing = self._fetch_playlist_row(kind, variant, profile_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
# Insert new row using the kind's defaults.
|
||||
config = spec.default_config
|
||||
name = spec.display_name(variant)
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO personalized_playlists
|
||||
(profile_id, kind, variant, name, config_json,
|
||||
track_count, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
""",
|
||||
(profile_id, kind, variant, name, json.dumps(config.to_json_dict())),
|
||||
)
|
||||
conn.commit()
|
||||
return self._fetch_playlist_row(kind, variant, profile_id) # type: ignore[return-value]
|
||||
|
||||
def get_playlist(self, kind: str, variant: str = '', profile_id: int = 1) -> Optional[PlaylistRecord]:
|
||||
"""Return the playlist row if it exists. Does NOT auto-create."""
|
||||
return self._fetch_playlist_row(kind, variant, profile_id)
|
||||
|
||||
def list_playlists(self, profile_id: int = 1) -> List[PlaylistRecord]:
|
||||
"""List every persisted playlist for a profile, newest-first."""
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, profile_id, kind, variant, name, config_json,
|
||||
track_count, last_generated_at, last_synced_at,
|
||||
last_generation_source, last_generation_error,
|
||||
is_stale
|
||||
FROM personalized_playlists
|
||||
WHERE profile_id = ?
|
||||
ORDER BY COALESCE(last_generated_at, created_at) DESC
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
return [self._row_to_record(r) for r in rows]
|
||||
|
||||
def update_config(self, kind: str, variant: str, profile_id: int, overrides: Dict[str, Any]) -> PlaylistRecord:
|
||||
"""Patch the per-playlist config with the provided overrides."""
|
||||
record = self.ensure_playlist(kind, variant, profile_id)
|
||||
merged = record.config.merged(overrides)
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE personalized_playlists
|
||||
SET config_json = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(json.dumps(merged.to_json_dict()), record.id),
|
||||
)
|
||||
conn.commit()
|
||||
return self._fetch_playlist_row(kind, variant, profile_id) # type: ignore[return-value]
|
||||
|
||||
# ─── refresh / generation ─────────────────────────────────────────
|
||||
|
||||
def refresh_playlist(
|
||||
self,
|
||||
kind: str,
|
||||
variant: str = '',
|
||||
profile_id: int = 1,
|
||||
config_overrides: Optional[Dict[str, Any]] = None,
|
||||
) -> PlaylistRecord:
|
||||
"""Run the kind's generator and persist the result as the
|
||||
playlist's current snapshot.
|
||||
|
||||
Atomic: track list is replaced in a single transaction. On
|
||||
generator exception, the previous snapshot is preserved and
|
||||
the error is recorded on the row.
|
||||
|
||||
Args:
|
||||
kind: registered kind identifier.
|
||||
variant: e.g. '1980s' for time machine, '' for singletons.
|
||||
profile_id: profile to refresh under.
|
||||
config_overrides: optional per-call config tweaks merged on
|
||||
top of the stored config (e.g. UI lets the user "preview
|
||||
with limit=100" without persisting that change).
|
||||
|
||||
Returns:
|
||||
Updated PlaylistRecord with fresh ``track_count`` /
|
||||
``last_generated_at`` (or ``last_generation_error`` on
|
||||
failure).
|
||||
"""
|
||||
spec = self.registry.get(kind)
|
||||
if spec is None:
|
||||
raise ValueError(f"Unknown playlist kind: {kind!r}")
|
||||
record = self.ensure_playlist(kind, variant, profile_id)
|
||||
|
||||
config = record.config
|
||||
if config_overrides:
|
||||
config = config.merged(config_overrides)
|
||||
|
||||
try:
|
||||
tracks = spec.generator(self.deps, variant, config)
|
||||
except Exception as exc: # noqa: BLE001 — record + re-raise after persisting
|
||||
logger.exception("Generator failed for kind=%s variant=%s: %s", kind, variant, exc)
|
||||
self._record_generation_failure(record.id, str(exc))
|
||||
return self._fetch_playlist_row(kind, variant, profile_id) # type: ignore[return-value]
|
||||
|
||||
# Quality post-filters — applied uniformly to every kind so
|
||||
# generators stay focused on selection logic, not staleness
|
||||
# bookkeeping. Filters are config-driven; defaults preserve
|
||||
# the pre-overhaul behavior (no filtering).
|
||||
tracks = self._apply_quality_filters(tracks, kind, profile_id, config)
|
||||
|
||||
return self._persist_snapshot(record.id, kind, profile_id, tracks)
|
||||
|
||||
def _apply_quality_filters(
|
||||
self,
|
||||
tracks: List[Track],
|
||||
kind: str,
|
||||
profile_id: int,
|
||||
config: PlaylistConfig,
|
||||
) -> List[Track]:
|
||||
"""Apply manager-level quality filters to a generator's output.
|
||||
|
||||
Currently:
|
||||
- **Staleness window** (`config.exclude_recent_days > 0`): drops
|
||||
any track whose primary id was served by this `kind` for this
|
||||
`profile_id` in the last N days. Prevents the same track
|
||||
from showing up across consecutive refreshes — e.g. a daily
|
||||
Discovery Shuffle that shouldn't replay yesterday's picks.
|
||||
Tracks without a primary id pass through unchanged (nothing
|
||||
to dedupe on).
|
||||
|
||||
Returns a new list (never mutates input). When no filter
|
||||
applies, returns ``tracks`` unchanged."""
|
||||
if config.exclude_recent_days <= 0 or not tracks:
|
||||
return tracks
|
||||
|
||||
recent_set = set(self.recent_track_ids(profile_id, kind, config.exclude_recent_days))
|
||||
if not recent_set:
|
||||
return tracks
|
||||
|
||||
return [t for t in tracks if not t.primary_id() or t.primary_id() not in recent_set]
|
||||
|
||||
# ─── track read ──────────────────────────────────────────────────
|
||||
|
||||
def get_playlist_tracks(self, playlist_id: int) -> List[Track]:
|
||||
"""Return the persisted track list for a playlist row."""
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT spotify_track_id, itunes_track_id, deezer_track_id,
|
||||
track_name, artist_name, album_name, album_cover_url,
|
||||
duration_ms, popularity, track_data_json
|
||||
FROM personalized_playlist_tracks
|
||||
WHERE playlist_id = ?
|
||||
ORDER BY position ASC
|
||||
""",
|
||||
(playlist_id,),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
return [self._row_to_track(r) for r in rows]
|
||||
|
||||
# ─── snapshot freshness vs source data ───────────────────────────
|
||||
|
||||
def mark_kinds_stale(self, kinds: List[str], profile_id: Optional[int] = None) -> int:
|
||||
"""Flag every playlist row matching one of ``kinds`` as stale.
|
||||
|
||||
Called by upstream data refreshers (watchlist scan finishing
|
||||
/ Spotify enrichment worker re-pulling Release Radar / etc)
|
||||
so pipelines auto-regenerate snapshots before the next sync
|
||||
instead of pushing stale data to the media server.
|
||||
|
||||
Returns the number of rows touched. When ``profile_id`` is
|
||||
None, flags rows across every profile.
|
||||
"""
|
||||
if not kinds:
|
||||
return 0
|
||||
placeholders = ','.join('?' * len(kinds))
|
||||
sql = f"UPDATE personalized_playlists SET is_stale = 1, updated_at = CURRENT_TIMESTAMP WHERE kind IN ({placeholders})"
|
||||
params: List[Any] = list(kinds)
|
||||
if profile_id is not None:
|
||||
sql += " AND profile_id = ?"
|
||||
params.append(profile_id)
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(sql, params)
|
||||
count = cursor.rowcount
|
||||
conn.commit()
|
||||
return count
|
||||
|
||||
# ─── staleness history ───────────────────────────────────────────
|
||||
|
||||
def recent_track_ids(self, profile_id: int, kind: str, days: int) -> List[str]:
|
||||
"""Return track IDs served by ``kind`` for ``profile_id`` in
|
||||
the last ``days`` days. Generators query this when honoring
|
||||
the ``exclude_recent_days`` config knob."""
|
||||
if days <= 0:
|
||||
return []
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT DISTINCT track_id
|
||||
FROM personalized_track_history
|
||||
WHERE profile_id = ?
|
||||
AND kind = ?
|
||||
AND served_at >= datetime('now', ?)
|
||||
""",
|
||||
(profile_id, kind, f'-{int(days)} days'),
|
||||
)
|
||||
return [r[0] for r in cursor.fetchall() if r[0]]
|
||||
|
||||
# ─── internal helpers ────────────────────────────────────────────
|
||||
|
||||
def _persist_snapshot(self, playlist_id: int, kind: str, profile_id: int, tracks: List[Track]) -> PlaylistRecord:
|
||||
"""Atomic replace of a playlist's track list + history append."""
|
||||
now = datetime.now(timezone.utc).isoformat(timespec='seconds')
|
||||
primary_source = next(
|
||||
(t.source for t in tracks if t.source), None,
|
||||
)
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute("BEGIN")
|
||||
cursor.execute(
|
||||
"DELETE FROM personalized_playlist_tracks WHERE playlist_id = ?",
|
||||
(playlist_id,),
|
||||
)
|
||||
for position, track in enumerate(tracks):
|
||||
td = track.track_data_json
|
||||
if td is not None and not isinstance(td, str):
|
||||
td = json.dumps(td)
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO personalized_playlist_tracks
|
||||
(playlist_id, position,
|
||||
spotify_track_id, itunes_track_id, deezer_track_id,
|
||||
track_name, artist_name, album_name, album_cover_url,
|
||||
duration_ms, popularity, track_data_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
playlist_id, position,
|
||||
track.spotify_track_id, track.itunes_track_id, track.deezer_track_id,
|
||||
track.track_name, track.artist_name, track.album_name, track.album_cover_url,
|
||||
int(track.duration_ms or 0), int(track.popularity or 0), td,
|
||||
),
|
||||
)
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE personalized_playlists
|
||||
SET track_count = ?, last_generated_at = CURRENT_TIMESTAMP,
|
||||
last_generation_source = ?, last_generation_error = NULL,
|
||||
is_stale = 0,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(len(tracks), primary_source, playlist_id),
|
||||
)
|
||||
# History append — only for tracks with a primary ID,
|
||||
# used by exclude_recent_days filter on next refresh.
|
||||
for track in tracks:
|
||||
tid = track.primary_id()
|
||||
if not tid:
|
||||
continue
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO personalized_track_history
|
||||
(profile_id, kind, track_id, served_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
""",
|
||||
(profile_id, kind, tid),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
# Re-read the row so the returned record carries the fresh
|
||||
# last_generated_at timestamp.
|
||||
record = self._fetch_playlist_row_by_id(playlist_id)
|
||||
if record is None:
|
||||
raise RuntimeError(f"playlist row {playlist_id} disappeared mid-refresh")
|
||||
return record
|
||||
|
||||
def _record_generation_failure(self, playlist_id: int, error_message: str) -> None:
|
||||
"""Stamp the error on the row WITHOUT touching tracks."""
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE personalized_playlists
|
||||
SET last_generation_error = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(error_message[:500], playlist_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def _fetch_playlist_row(self, kind: str, variant: str, profile_id: int) -> Optional[PlaylistRecord]:
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, profile_id, kind, variant, name, config_json,
|
||||
track_count, last_generated_at, last_synced_at,
|
||||
last_generation_source, last_generation_error,
|
||||
is_stale
|
||||
FROM personalized_playlists
|
||||
WHERE profile_id = ? AND kind = ? AND variant = ?
|
||||
""",
|
||||
(profile_id, kind, variant),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
return self._row_to_record(row) if row else None
|
||||
|
||||
def _fetch_playlist_row_by_id(self, playlist_id: int) -> Optional[PlaylistRecord]:
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, profile_id, kind, variant, name, config_json,
|
||||
track_count, last_generated_at, last_synced_at,
|
||||
last_generation_source, last_generation_error,
|
||||
is_stale
|
||||
FROM personalized_playlists
|
||||
WHERE id = ?
|
||||
""",
|
||||
(playlist_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
return self._row_to_record(row) if row else None
|
||||
|
||||
@staticmethod
|
||||
def _row_to_record(row: Any) -> PlaylistRecord:
|
||||
# Tolerate sqlite3.Row + plain tuples.
|
||||
if hasattr(row, 'keys'):
|
||||
row = dict(row)
|
||||
return PlaylistRecord(
|
||||
id=row['id'], profile_id=row['profile_id'],
|
||||
kind=row['kind'], variant=row['variant'] or '',
|
||||
name=row['name'],
|
||||
config=PlaylistConfig.from_json_dict(_safe_json_loads(row['config_json'])),
|
||||
track_count=row['track_count'] or 0,
|
||||
last_generated_at=row.get('last_generated_at'),
|
||||
last_synced_at=row.get('last_synced_at'),
|
||||
last_generation_source=row.get('last_generation_source'),
|
||||
last_generation_error=row.get('last_generation_error'),
|
||||
is_stale=bool(row.get('is_stale') or 0),
|
||||
)
|
||||
# Tuple form: positional access matches SELECT order above.
|
||||
return PlaylistRecord(
|
||||
id=row[0], profile_id=row[1],
|
||||
kind=row[2], variant=row[3] or '',
|
||||
name=row[4],
|
||||
config=PlaylistConfig.from_json_dict(_safe_json_loads(row[5])),
|
||||
track_count=row[6] or 0,
|
||||
last_generated_at=row[7],
|
||||
last_synced_at=row[8],
|
||||
last_generation_source=row[9],
|
||||
last_generation_error=row[10],
|
||||
is_stale=bool(row[11] or 0) if len(row) > 11 else False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _row_to_track(row: Any) -> Track:
|
||||
if hasattr(row, 'keys'):
|
||||
row = dict(row)
|
||||
return Track(
|
||||
spotify_track_id=row.get('spotify_track_id'),
|
||||
itunes_track_id=row.get('itunes_track_id'),
|
||||
deezer_track_id=row.get('deezer_track_id'),
|
||||
track_name=row.get('track_name', ''),
|
||||
artist_name=row.get('artist_name', ''),
|
||||
album_name=row.get('album_name') or '',
|
||||
album_cover_url=row.get('album_cover_url'),
|
||||
duration_ms=int(row.get('duration_ms') or 0),
|
||||
popularity=int(row.get('popularity') or 0),
|
||||
track_data_json=_safe_json_loads(row.get('track_data_json')),
|
||||
)
|
||||
return Track(
|
||||
spotify_track_id=row[0], itunes_track_id=row[1], deezer_track_id=row[2],
|
||||
track_name=row[3], artist_name=row[4], album_name=row[5] or '',
|
||||
album_cover_url=row[6], duration_ms=int(row[7] or 0),
|
||||
popularity=int(row[8] or 0),
|
||||
track_data_json=_safe_json_loads(row[9]),
|
||||
)
|
||||
|
||||
|
||||
def _safe_json_loads(value: Any) -> Any:
|
||||
"""Tolerant JSON parse — returns None / dict / passes through
|
||||
non-string values. Avoids exceptions on bad rows so the manager
|
||||
never breaks on a single corrupt record."""
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if not value.strip():
|
||||
return None
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
__all__ = ['PersonalizedPlaylistManager']
|
||||
121
core/personalized/specs.py
Normal file
121
core/personalized/specs.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""Per-kind specifications for the personalized-playlist subsystem.
|
||||
|
||||
A ``PlaylistKindSpec`` declares everything the manager needs to know
|
||||
about one playlist type:
|
||||
|
||||
- The kind identifier (stable string used in URLs / configs / DB).
|
||||
- Human-readable display name template (with optional ``{variant}``
|
||||
substitution).
|
||||
- Whether the kind supports / requires variants and what valid
|
||||
variants look like.
|
||||
- The default user-tweakable config for this kind.
|
||||
- A generator callable that produces a fresh track list given
|
||||
``(deps, variant, config)``.
|
||||
|
||||
Generators live in ``core/personalized/generators/`` (added in
|
||||
later commits as each kind is migrated). For commit 1 the registry
|
||||
ships empty — schema + manager land first; generators arrive
|
||||
incrementally with their per-kind tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from core.personalized.types import PlaylistConfig, Track
|
||||
|
||||
|
||||
# Generator callable signature.
|
||||
# Args:
|
||||
# deps: opaque object the generator may need (DB, service handle,
|
||||
# config_manager). Each generator declares what it pulls.
|
||||
# variant: e.g. '1980s' for time machine, 'halloween' for seasonal.
|
||||
# Empty string '' for singleton kinds.
|
||||
# config: PlaylistConfig with the user's per-playlist overrides
|
||||
# merged onto the kind's defaults.
|
||||
# Returns:
|
||||
# List[Track] — the fresh snapshot to persist as the playlist's
|
||||
# current track list.
|
||||
GeneratorFn = Callable[[Any, str, PlaylistConfig], List[Track]]
|
||||
|
||||
|
||||
# Variant resolver: returns the list of currently-valid variant
|
||||
# identifiers for a kind that supports multiple instances. Used by
|
||||
# the manager when auto-creating playlist rows for newly available
|
||||
# variants (e.g. a new decade, a new season). Singletons return [''].
|
||||
VariantResolver = Callable[[Any], List[str]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaylistKindSpec:
|
||||
"""Declaration of one playlist kind.
|
||||
|
||||
See module docstring for the contract.
|
||||
"""
|
||||
|
||||
kind: str
|
||||
name_template: str # e.g. 'Time Machine — {variant}', 'Hidden Gems'
|
||||
description: str
|
||||
default_config: PlaylistConfig
|
||||
generator: GeneratorFn
|
||||
variant_resolver: Optional[VariantResolver] = None
|
||||
requires_variant: bool = False
|
||||
# Tags for UI grouping ('curated' / 'discovery' / 'time' / 'genre').
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
def display_name(self, variant: str) -> str:
|
||||
"""Render the human-readable playlist name for a given variant."""
|
||||
if not variant:
|
||||
return self.name_template.replace('{variant}', '').strip(' —-')
|
||||
return self.name_template.format(variant=variant)
|
||||
|
||||
|
||||
class PlaylistKindRegistry:
|
||||
"""Module-level registry of every kind the manager knows about.
|
||||
|
||||
Populated at import time as each generator module is loaded. The
|
||||
manager queries the registry at runtime to dispatch refresh
|
||||
requests, list available kinds for the UI, and resolve variants.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._kinds: Dict[str, PlaylistKindSpec] = {}
|
||||
|
||||
def register(self, spec: PlaylistKindSpec) -> None:
|
||||
if spec.kind in self._kinds:
|
||||
raise ValueError(f"Kind {spec.kind!r} already registered")
|
||||
self._kinds[spec.kind] = spec
|
||||
|
||||
def get(self, kind: str) -> Optional[PlaylistKindSpec]:
|
||||
return self._kinds.get(kind)
|
||||
|
||||
def all(self) -> List[PlaylistKindSpec]:
|
||||
return list(self._kinds.values())
|
||||
|
||||
def kinds(self) -> List[str]:
|
||||
return list(self._kinds.keys())
|
||||
|
||||
def reset_for_tests(self) -> None:
|
||||
"""Drop every registration. Tests only — production runs
|
||||
register at module import and never reset."""
|
||||
self._kinds.clear()
|
||||
|
||||
|
||||
# Module-level singleton. Generators register against this on import.
|
||||
_registry = PlaylistKindRegistry()
|
||||
|
||||
|
||||
def get_registry() -> PlaylistKindRegistry:
|
||||
"""Public accessor for the module-level registry. Tests can reset
|
||||
via ``get_registry().reset_for_tests()``."""
|
||||
return _registry
|
||||
|
||||
|
||||
__all__ = [
|
||||
'PlaylistKindSpec',
|
||||
'PlaylistKindRegistry',
|
||||
'GeneratorFn',
|
||||
'VariantResolver',
|
||||
'get_registry',
|
||||
]
|
||||
181
core/personalized/types.py
Normal file
181
core/personalized/types.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"""Shared dataclasses for the personalized-playlist subsystem.
|
||||
|
||||
These are pure data containers — no business logic, no IO. The
|
||||
manager + specs + generators all speak in these types so the seam
|
||||
between them stays mechanical.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class Track:
|
||||
"""A track in a personalized playlist.
|
||||
|
||||
Mirrors the shape returned by
|
||||
``PersonalizedPlaylistsService._build_track_dict`` so the legacy
|
||||
generators can be wrapped without translating fields. Always at
|
||||
least one of the source IDs is populated; ``track_data_json`` is
|
||||
the full enriched track object when available (used by sync /
|
||||
download paths that need richer metadata than just the ID)."""
|
||||
|
||||
track_name: str
|
||||
artist_name: str
|
||||
album_name: str = ''
|
||||
spotify_track_id: Optional[str] = None
|
||||
itunes_track_id: Optional[str] = None
|
||||
deezer_track_id: Optional[str] = None
|
||||
album_cover_url: Optional[str] = None
|
||||
duration_ms: int = 0
|
||||
popularity: int = 0
|
||||
track_data_json: Optional[Any] = None # dict OR JSON string OR None
|
||||
source: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> 'Track':
|
||||
"""Coerce a legacy generator's output dict into a Track.
|
||||
|
||||
Tolerates the ``_artist_genres_raw`` / ``_release_date`` extra-
|
||||
column passthroughs by ignoring them — this dataclass only
|
||||
carries the storage-layer fields."""
|
||||
return cls(
|
||||
track_name=d.get('track_name', 'Unknown'),
|
||||
artist_name=d.get('artist_name', 'Unknown'),
|
||||
album_name=d.get('album_name', '') or '',
|
||||
spotify_track_id=d.get('spotify_track_id'),
|
||||
itunes_track_id=d.get('itunes_track_id'),
|
||||
deezer_track_id=d.get('deezer_track_id'),
|
||||
album_cover_url=d.get('album_cover_url'),
|
||||
duration_ms=int(d.get('duration_ms') or 0),
|
||||
popularity=int(d.get('popularity') or 0),
|
||||
track_data_json=d.get('track_data_json'),
|
||||
source=d.get('source'),
|
||||
)
|
||||
|
||||
def primary_id(self) -> Optional[str]:
|
||||
"""Return the first non-empty source ID. Used as the staleness-
|
||||
history key + the dedupe key when persisting tracks."""
|
||||
return (
|
||||
self.spotify_track_id
|
||||
or self.itunes_track_id
|
||||
or self.deezer_track_id
|
||||
or None
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaylistConfig:
|
||||
"""User-tweakable knobs per playlist instance.
|
||||
|
||||
Stored as JSON in `personalized_playlists.config_json`. Defaults
|
||||
come from the kind's spec; user overrides override per-playlist.
|
||||
|
||||
Fields:
|
||||
- limit: target number of tracks
|
||||
- max_per_album / max_per_artist: diversity caps
|
||||
- popularity_min / popularity_max: filter bounds (None = ignore)
|
||||
- exclude_recent_days: avoid tracks served by this kind in the
|
||||
last N days (0 = no exclusion)
|
||||
- recency_days: only include tracks released in the last N days
|
||||
(None = all-time)
|
||||
- seed: optional deterministic seed for randomization (None =
|
||||
use system random; same seed + same pool = same output)
|
||||
- extra: free-form per-kind extension dict (e.g. seasonal mix
|
||||
stores ``selected_seasons``, time machine stores
|
||||
``selected_decades``, genre stores ``selected_genres``).
|
||||
"""
|
||||
|
||||
limit: int = 50
|
||||
max_per_album: int = 2
|
||||
max_per_artist: int = 3
|
||||
popularity_min: Optional[int] = None
|
||||
popularity_max: Optional[int] = None
|
||||
exclude_recent_days: int = 0
|
||||
recency_days: Optional[int] = None
|
||||
seed: Optional[int] = None
|
||||
extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_json_dict(self) -> Dict[str, Any]:
|
||||
"""Serialise to a JSON-safe dict for storage."""
|
||||
return {
|
||||
'limit': self.limit,
|
||||
'max_per_album': self.max_per_album,
|
||||
'max_per_artist': self.max_per_artist,
|
||||
'popularity_min': self.popularity_min,
|
||||
'popularity_max': self.popularity_max,
|
||||
'exclude_recent_days': self.exclude_recent_days,
|
||||
'recency_days': self.recency_days,
|
||||
'seed': self.seed,
|
||||
'extra': dict(self.extra),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_json_dict(cls, d: Optional[Dict[str, Any]]) -> 'PlaylistConfig':
|
||||
"""Reconstruct from a stored JSON dict. Missing fields fall
|
||||
back to defaults so old rows + new code stay compatible."""
|
||||
if not isinstance(d, dict):
|
||||
return cls()
|
||||
return cls(
|
||||
limit=int(d.get('limit', 50)),
|
||||
max_per_album=int(d.get('max_per_album', 2)),
|
||||
max_per_artist=int(d.get('max_per_artist', 3)),
|
||||
popularity_min=d.get('popularity_min'),
|
||||
popularity_max=d.get('popularity_max'),
|
||||
exclude_recent_days=int(d.get('exclude_recent_days', 0)),
|
||||
recency_days=d.get('recency_days'),
|
||||
seed=d.get('seed'),
|
||||
extra=dict(d.get('extra') or {}),
|
||||
)
|
||||
|
||||
def merged(self, overrides: Dict[str, Any]) -> 'PlaylistConfig':
|
||||
"""Return a new PlaylistConfig with `overrides` merged in.
|
||||
|
||||
Used when a user PATCHes their per-playlist config — apply
|
||||
only the fields they sent, leave the rest at their stored
|
||||
values."""
|
||||
base = self.to_json_dict()
|
||||
for key, value in (overrides or {}).items():
|
||||
if key == 'extra' and isinstance(value, dict):
|
||||
base['extra'] = {**base.get('extra', {}), **value}
|
||||
elif key in base:
|
||||
base[key] = value
|
||||
return PlaylistConfig.from_json_dict(base)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaylistRecord:
|
||||
"""One row of `personalized_playlists` plus its track count.
|
||||
|
||||
The live track list is fetched separately via
|
||||
``PersonalizedPlaylistManager.get_playlist_tracks(playlist_id)``
|
||||
so list / detail responses can stay cheap when the caller only
|
||||
needs metadata.
|
||||
|
||||
``is_stale`` flips to True when the underlying source data
|
||||
changes (e.g. watchlist scan updates the discovery pool) and is
|
||||
cleared on the next successful refresh. Pipelines auto-refresh
|
||||
stale snapshots before syncing so the server playlist always
|
||||
reflects the latest source data."""
|
||||
|
||||
id: int
|
||||
profile_id: int
|
||||
kind: str
|
||||
variant: str
|
||||
name: str
|
||||
config: PlaylistConfig
|
||||
track_count: int
|
||||
last_generated_at: Optional[str]
|
||||
last_synced_at: Optional[str]
|
||||
last_generation_source: Optional[str]
|
||||
last_generation_error: Optional[str]
|
||||
is_stale: bool = False
|
||||
|
||||
|
||||
__all__ = [
|
||||
'Track',
|
||||
'PlaylistConfig',
|
||||
'PlaylistRecord',
|
||||
]
|
||||
|
|
@ -779,19 +779,21 @@ class PersonalizedPlaylistsService:
|
|||
}
|
||||
|
||||
def _get_library_tracks_by_category(self, category: str, limit: int) -> List[Dict]:
|
||||
"""
|
||||
Get tracks from library matching genre or artist
|
||||
"""Library tracks intentionally excluded from daily mixes.
|
||||
|
||||
NOTE: This requires library tracks to have Spotify metadata which may not be available.
|
||||
Returns empty list if schema incompatible.
|
||||
"""
|
||||
try:
|
||||
logger.warning("Library tracks by category requires Spotify-linked library - returning empty")
|
||||
return []
|
||||
The legacy ambition was 50% library + 50% discovery, but the
|
||||
``tracks`` table doesn't carry source IDs (no
|
||||
``spotify_track_id`` / ``itunes_track_id`` / ``deezer_track_id``
|
||||
column) — so library rows can't flow through the same
|
||||
sync / wishlist pipeline that discovery tracks do. Returning
|
||||
them here would produce un-syncable, un-downloadable phantom
|
||||
entries.
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting library tracks by category: {e}")
|
||||
return []
|
||||
Returns ``[]`` so callers compose with ``_get_discovery_tracks_by_category``
|
||||
for a discovery-only mix. A future PR can backfill source IDs
|
||||
into library rows and lift this restriction.
|
||||
"""
|
||||
return []
|
||||
|
||||
def _get_discovery_tracks_by_category(self, category: str, limit: int) -> List[Dict]:
|
||||
"""Get tracks from discovery pool matching genre or artist"""
|
||||
|
|
|
|||
|
|
@ -66,6 +66,10 @@ class QueueItem:
|
|||
artist_name: str # captured at enqueue time for UI display
|
||||
source: Optional[str] # the user's per-modal pick (None = auto)
|
||||
enqueued_at: float
|
||||
# 'api' (default) = query metadata source per album_data IDs.
|
||||
# 'tags' = read each file's embedded tags as the source
|
||||
# of truth (issue #592). Zero API calls.
|
||||
metadata_source: str = 'api'
|
||||
status: str = 'queued' # queued | running | done | failed | cancelled
|
||||
started_at: Optional[float] = None
|
||||
finished_at: Optional[float] = None
|
||||
|
|
@ -91,6 +95,7 @@ class QueueItem:
|
|||
'artist_id': self.artist_id,
|
||||
'artist_name': self.artist_name,
|
||||
'source': self.source,
|
||||
'metadata_source': self.metadata_source,
|
||||
'enqueued_at': self.enqueued_at,
|
||||
'started_at': self.started_at,
|
||||
'finished_at': self.finished_at,
|
||||
|
|
@ -155,6 +160,7 @@ class ReorganizeQueue:
|
|||
artist_id: Optional[str],
|
||||
artist_name: str,
|
||||
source: Optional[str] = None,
|
||||
metadata_source: str = 'api',
|
||||
) -> dict:
|
||||
"""Add an album to the queue. Returns a result dict:
|
||||
|
||||
|
|
@ -183,6 +189,7 @@ class ReorganizeQueue:
|
|||
artist_name=artist_name,
|
||||
source=source,
|
||||
enqueued_at=time.time(),
|
||||
metadata_source=metadata_source or 'api',
|
||||
)
|
||||
self._items.append(item)
|
||||
position = sum(1 for i in self._items if i.status == 'queued')
|
||||
|
|
@ -190,7 +197,8 @@ class ReorganizeQueue:
|
|||
self._cond.notify_all()
|
||||
logger.info(
|
||||
f"[Queue] Enqueued '{album_title}' (album_id={album_id}, "
|
||||
f"queue_id={item.queue_id}, position={position}, source={source or 'auto'})"
|
||||
f"queue_id={item.queue_id}, position={position}, "
|
||||
f"source={source or 'auto'}, metadata={item.metadata_source})"
|
||||
)
|
||||
return {
|
||||
'queued': True,
|
||||
|
|
@ -240,6 +248,7 @@ class ReorganizeQueue:
|
|||
artist_name=raw.get('artist_name') or 'Unknown Artist',
|
||||
source=raw.get('source'),
|
||||
enqueued_at=time.time(),
|
||||
metadata_source=raw.get('metadata_source') or 'api',
|
||||
)
|
||||
self._items.append(item)
|
||||
enqueued += 1
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ def build_runner(
|
|||
primary_source=item.source,
|
||||
strict_source=bool(item.source),
|
||||
stop_check=is_shutting_down_fn,
|
||||
metadata_source=getattr(item, 'metadata_source', 'api') or 'api',
|
||||
)
|
||||
|
||||
return runner
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ from . import sources
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
VALID_SOURCES = (
|
||||
'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz',
|
||||
'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz', 'amazon',
|
||||
)
|
||||
|
||||
VALID_STREAM_SOURCES = VALID_SOURCES + ('youtube_videos',)
|
||||
|
|
@ -88,6 +88,13 @@ def resolve_client(source_name: str, deps: SearchDeps) -> tuple[Any, bool]:
|
|||
except Exception as e:
|
||||
logger.warning(f"MusicBrainz search client init failed: {e}")
|
||||
return None, False
|
||||
if source_name == 'amazon':
|
||||
try:
|
||||
from core.metadata.registry import get_amazon_client
|
||||
return get_amazon_client(), True
|
||||
except Exception as e:
|
||||
logger.warning(f"Amazon Music client init failed: {e}")
|
||||
return None, False
|
||||
return None, False
|
||||
|
||||
|
||||
|
|
@ -183,6 +190,8 @@ def _alternate_sources(primary_source: str, deps: SearchDeps) -> list[str]:
|
|||
alts.append('discogs')
|
||||
if primary_source != 'hydrabase' and hydrabase_available:
|
||||
alts.append('hydrabase')
|
||||
if primary_source != 'amazon':
|
||||
alts.append('amazon') # always available (T2Tunes, no auth)
|
||||
alts.append('youtube_videos') # always available (yt-dlp, no auth)
|
||||
alts.append('musicbrainz') # always available (public API)
|
||||
return alts
|
||||
|
|
|
|||
|
|
@ -24,6 +24,19 @@ from core.wishlist_service import get_wishlist_service
|
|||
from core.matching_engine import MusicMatchingEngine
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
|
||||
def _mark_personalized_kinds_stale(database, kinds, profile_id=1):
|
||||
"""Module-level helper so the inline call sites stay tiny.
|
||||
|
||||
Constructs a PersonalizedPlaylistManager with the minimal deps
|
||||
needed for `mark_kinds_stale` (database access only — no generator
|
||||
dispatch required) and flips the is_stale flag for matching rows.
|
||||
Best-effort: any exception is swallowed by the caller's try/except
|
||||
since stale-flagging is non-critical for the scan itself."""
|
||||
from core.personalized.manager import PersonalizedPlaylistManager
|
||||
mgr = PersonalizedPlaylistManager(database, deps=None)
|
||||
return mgr.mark_kinds_stale(list(kinds), profile_id=profile_id)
|
||||
|
||||
logger = get_logger("watchlist_scanner")
|
||||
|
||||
# Rate limiting constants for watchlist operations
|
||||
|
|
@ -2980,6 +2993,22 @@ class WatchlistScanner:
|
|||
self.database.update_discovery_pool_timestamp(track_count=final_count, profile_id=profile_id)
|
||||
logger.info(f"Discovery pool now contains {final_count} total tracks (built over time)")
|
||||
|
||||
# Mark every personalized-playlist kind that draws from the
|
||||
# discovery pool as stale so the playlist pipeline auto-
|
||||
# regenerates snapshots on its next run. Without this the
|
||||
# server playlists stay frozen even though the source pool
|
||||
# just got fresh tracks. Best-effort — pool refresh succeeds
|
||||
# even if the manager isn't wired (no personalized tables).
|
||||
try:
|
||||
_mark_personalized_kinds_stale(
|
||||
self.database,
|
||||
kinds=['hidden_gems', 'discovery_shuffle', 'popular_picks',
|
||||
'time_machine', 'genre_playlist', 'daily_mix'],
|
||||
profile_id=profile_id,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — never abort scan for staleness flag
|
||||
logger.debug("Failed to mark personalized kinds stale: %s", e)
|
||||
|
||||
# Cache recent albums for discovery page
|
||||
logger.info("Caching recent albums for discovery page...")
|
||||
if progress_callback:
|
||||
|
|
@ -3660,6 +3689,14 @@ class WatchlistScanner:
|
|||
playlist_key = f'release_radar_{source}'
|
||||
self.database.save_curated_playlist(playlist_key, release_radar_tracks, profile_id=profile_id)
|
||||
logger.info(f"Release Radar ({source}) curated: {len(release_radar_tracks)} tracks")
|
||||
# Flag personalized Fresh Tape snapshot as stale so the
|
||||
# pipeline auto-regenerates it on the next run.
|
||||
try:
|
||||
_mark_personalized_kinds_stale(
|
||||
self.database, kinds=['fresh_tape'], profile_id=profile_id,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("Fresh Tape stale-flag failed: %s", e)
|
||||
|
||||
# 2. Curate Discovery Weekly - 50 tracks from discovery pool
|
||||
logger.info(f"Curating Discovery Weekly for {source}...")
|
||||
|
|
@ -3735,6 +3772,12 @@ class WatchlistScanner:
|
|||
playlist_key = f'discovery_weekly_{source}'
|
||||
self.database.save_curated_playlist(playlist_key, discovery_weekly_tracks, profile_id=profile_id)
|
||||
logger.info(f"Discovery Weekly ({source}) curated: {len(discovery_weekly_tracks)} tracks")
|
||||
try:
|
||||
_mark_personalized_kinds_stale(
|
||||
self.database, kinds=['archives'], profile_id=profile_id,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("Archives stale-flag failed: %s", e)
|
||||
|
||||
# 3. "Because You Listen To" — personalized sections based on top played artists
|
||||
if profile['has_data']:
|
||||
|
|
|
|||
|
|
@ -407,6 +407,9 @@ class MusicDatabase:
|
|||
# Add Discogs enrichment columns (migration)
|
||||
self._add_discogs_columns(cursor)
|
||||
|
||||
# Add Amazon artist ID column (migration)
|
||||
self._add_amazon_columns(cursor)
|
||||
|
||||
# Backfill match_status for rows that already have an external ID but
|
||||
# NULL status. Prevents enrichment workers from re-processing these
|
||||
# rows forever. Must run AFTER all *_match_status columns have been
|
||||
|
|
@ -752,13 +755,21 @@ class MusicDatabase:
|
|||
)
|
||||
""")
|
||||
|
||||
# Personalized-playlists subsystem schema (Group A + Group B
|
||||
# unified storage). Idempotent — safe on every startup.
|
||||
try:
|
||||
from database.personalized_schema import ensure_personalized_schema
|
||||
ensure_personalized_schema(conn)
|
||||
except Exception as ps_err:
|
||||
logger.error(f"Personalized-playlist schema init failed: {ps_err}")
|
||||
|
||||
conn.commit()
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing database: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def _add_mirrored_playlist_explored_column(self, cursor):
|
||||
"""Add explored_at column to mirrored_playlists to persist explore badge."""
|
||||
try:
|
||||
|
|
@ -1623,6 +1634,10 @@ class MusicDatabase:
|
|||
cursor.execute("ALTER TABLE watchlist_artists ADD COLUMN discogs_artist_id TEXT")
|
||||
logger.info("Added discogs_artist_id column to watchlist_artists table for cross-provider support")
|
||||
|
||||
if 'amazon_artist_id' not in columns:
|
||||
cursor.execute("ALTER TABLE watchlist_artists ADD COLUMN amazon_artist_id TEXT")
|
||||
logger.info("Added amazon_artist_id column to watchlist_artists table for Amazon Music support")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding itunes_artist_id column to watchlist_artists: {e}")
|
||||
# Don't raise - this is a migration, database can still function
|
||||
|
|
@ -2027,6 +2042,55 @@ class MusicDatabase:
|
|||
except Exception as e:
|
||||
logger.error(f"Error adding Discogs columns: {e}")
|
||||
|
||||
def _add_amazon_columns(self, cursor):
|
||||
"""Add Amazon enrichment tracking columns to artists, albums, and tracks."""
|
||||
try:
|
||||
# --- Artists ---
|
||||
cursor.execute("PRAGMA table_info(artists)")
|
||||
artists_columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if 'amazon_id' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN amazon_id TEXT")
|
||||
if 'amazon_match_status' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN amazon_match_status TEXT")
|
||||
if 'amazon_last_attempted' not in artists_columns:
|
||||
cursor.execute("ALTER TABLE artists ADD COLUMN amazon_last_attempted TIMESTAMP")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_amazon_id ON artists (amazon_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_amazon_status ON artists (amazon_match_status)")
|
||||
|
||||
# --- Albums ---
|
||||
cursor.execute("PRAGMA table_info(albums)")
|
||||
albums_columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if 'amazon_id' not in albums_columns:
|
||||
cursor.execute("ALTER TABLE albums ADD COLUMN amazon_id TEXT")
|
||||
if 'amazon_match_status' not in albums_columns:
|
||||
cursor.execute("ALTER TABLE albums ADD COLUMN amazon_match_status TEXT")
|
||||
if 'amazon_last_attempted' not in albums_columns:
|
||||
cursor.execute("ALTER TABLE albums ADD COLUMN amazon_last_attempted TIMESTAMP")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_amazon_id ON albums (amazon_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_amazon_status ON albums (amazon_match_status)")
|
||||
|
||||
# --- Tracks ---
|
||||
cursor.execute("PRAGMA table_info(tracks)")
|
||||
tracks_columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if 'amazon_id' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN amazon_id TEXT")
|
||||
if 'amazon_match_status' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN amazon_match_status TEXT")
|
||||
if 'amazon_last_attempted' not in tracks_columns:
|
||||
cursor.execute("ALTER TABLE tracks ADD COLUMN amazon_last_attempted TIMESTAMP")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_amazon_id ON tracks (amazon_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_amazon_status ON tracks (amazon_match_status)")
|
||||
|
||||
logger.info("Amazon columns added/verified successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding Amazon columns: {e}")
|
||||
|
||||
def _backfill_match_status_for_existing_ids(self, cursor):
|
||||
"""Set `<provider>_match_status = 'matched'` for rows that already have a
|
||||
populated external ID but NULL match_status.
|
||||
|
|
@ -8895,6 +8959,7 @@ class MusicDatabase:
|
|||
a.tidal_id,
|
||||
a.qobuz_id,
|
||||
a.soul_id,
|
||||
a.amazon_id,
|
||||
a.server_source
|
||||
FROM artists a
|
||||
WHERE {where_clause}
|
||||
|
|
@ -8994,6 +9059,7 @@ class MusicDatabase:
|
|||
'tidal_id': row['tidal_id'],
|
||||
'qobuz_id': row['qobuz_id'],
|
||||
'soul_id': row['soul_id'],
|
||||
'amazon_id': row['amazon_id'],
|
||||
'album_count': counts_map.get(row['id'], (0, 0))[0],
|
||||
'track_count': counts_map.get(row['id'], (0, 0))[1],
|
||||
'is_watched': bool(is_watched)
|
||||
|
|
@ -9052,7 +9118,7 @@ class MusicDatabase:
|
|||
id, name, thumb_url, genres, server_source,
|
||||
musicbrainz_id, deezer_id, audiodb_id, discogs_id,
|
||||
spotify_artist_id, itunes_artist_id, lastfm_url, genius_url,
|
||||
tidal_id, qobuz_id, soul_id,
|
||||
tidal_id, qobuz_id, soul_id, amazon_id,
|
||||
lastfm_listeners, lastfm_playcount, lastfm_tags, lastfm_bio
|
||||
FROM artists
|
||||
WHERE id = ?
|
||||
|
|
@ -9210,6 +9276,7 @@ class MusicDatabase:
|
|||
'tidal_id': artist_row['tidal_id'],
|
||||
'qobuz_id': artist_row['qobuz_id'],
|
||||
'soul_id': artist_row['soul_id'],
|
||||
'amazon_id': artist_row['amazon_id'],
|
||||
'lastfm_listeners': artist_row['lastfm_listeners'],
|
||||
'lastfm_playcount': artist_row['lastfm_playcount'],
|
||||
'lastfm_tags': artist_row['lastfm_tags'],
|
||||
|
|
|
|||
159
database/personalized_schema.py
Normal file
159
database/personalized_schema.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""Schema + migration helpers for the personalized-playlists subsystem.
|
||||
|
||||
Pre-existing state (this PR replaces over time):
|
||||
- Group A (Fresh Tape / The Archives / Seasonal Mix) lives in
|
||||
`discovery_curated_playlists` (track_ids only) and
|
||||
`curated_seasonal_playlists` (track_ids + seasonal_tracks join).
|
||||
Read paths exist; refresh paths are tied to specific workers.
|
||||
- Group B (Hidden Gems / Discovery Shuffle / Time Machine / Popular
|
||||
Picks / Genre / Daily Mixes) is computed on-demand by
|
||||
`PersonalizedPlaylistsService` — no persistence, every call reruns
|
||||
the generator with `ORDER BY RANDOM()` so the result rotates.
|
||||
|
||||
Post-overhaul (this module's responsibility):
|
||||
- ALL personalized playlists land in a unified storage layer with a
|
||||
stable (profile_id, kind, variant) identity, JSON config per
|
||||
playlist (limit, diversity caps, popularity / recency filters,
|
||||
exclude-recent-days, randomization seed), and a persistent track
|
||||
list that only mutates when the playlist is explicitly refreshed.
|
||||
|
||||
Tables created here:
|
||||
|
||||
- ``personalized_playlists`` — one row per (profile, kind, variant).
|
||||
Variants disambiguate kinds with multiple instances:
|
||||
* ``time_machine``: variant = ``'1980s'`` / ``'1990s'`` / ...
|
||||
* ``seasonal_mix``: variant = ``'halloween'`` / ``'christmas'`` / ...
|
||||
* ``genre_playlist``: variant = ``'rock'`` / ``'electronic_dance'`` / ...
|
||||
* ``daily_mix``: variant = ``'1'`` / ``'2'`` / ``'3'`` / ``'4'``
|
||||
* Singletons (``hidden_gems``, ``discovery_shuffle``,
|
||||
``popular_picks``, ``fresh_tape``, ``archives``): variant = ``''``.
|
||||
Variant '' (empty) is used instead of NULL so the UNIQUE
|
||||
constraint behaves predictably (NULL doesn't collide with NULL in
|
||||
SQLite UNIQUE indexes — would let multiple singleton rows
|
||||
coexist).
|
||||
|
||||
- ``personalized_playlist_tracks`` — current snapshot per playlist.
|
||||
Cleared + repopulated on refresh; never partial-mutates.
|
||||
|
||||
- ``personalized_track_history`` — append-only log of which tracks
|
||||
were served by which (profile, kind) over time. Powers the
|
||||
``exclude_recent_days`` config option so generators can avoid
|
||||
recommending the same track twice in N days.
|
||||
|
||||
The schema is created idempotently — `ensure_personalized_schema`
|
||||
runs CREATE TABLE IF NOT EXISTS at startup, so existing installs
|
||||
upgrade silently."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("database.personalized_schema")
|
||||
|
||||
|
||||
PERSONALIZED_PLAYLISTS_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS personalized_playlists (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
profile_id INTEGER NOT NULL DEFAULT 1,
|
||||
kind TEXT NOT NULL,
|
||||
variant TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
track_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_generated_at TIMESTAMP,
|
||||
last_synced_at TIMESTAMP,
|
||||
last_generation_source TEXT,
|
||||
last_generation_error TEXT,
|
||||
is_stale INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (profile_id, kind, variant)
|
||||
)
|
||||
"""
|
||||
|
||||
# Migration for installs that created the table before is_stale existed.
|
||||
PERSONALIZED_PLAYLISTS_STALE_MIGRATION = """
|
||||
ALTER TABLE personalized_playlists ADD COLUMN is_stale INTEGER NOT NULL DEFAULT 0
|
||||
"""
|
||||
|
||||
PERSONALIZED_PLAYLIST_TRACKS_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS personalized_playlist_tracks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
playlist_id INTEGER NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
spotify_track_id TEXT,
|
||||
itunes_track_id TEXT,
|
||||
deezer_track_id TEXT,
|
||||
track_name TEXT,
|
||||
artist_name TEXT,
|
||||
album_name TEXT,
|
||||
album_cover_url TEXT,
|
||||
duration_ms INTEGER,
|
||||
popularity INTEGER,
|
||||
track_data_json TEXT,
|
||||
FOREIGN KEY (playlist_id) REFERENCES personalized_playlists(id) ON DELETE CASCADE,
|
||||
UNIQUE (playlist_id, position)
|
||||
)
|
||||
"""
|
||||
|
||||
PERSONALIZED_PLAYLIST_TRACKS_INDEX = """
|
||||
CREATE INDEX IF NOT EXISTS idx_personalized_tracks_playlist
|
||||
ON personalized_playlist_tracks(playlist_id)
|
||||
"""
|
||||
|
||||
PERSONALIZED_TRACK_HISTORY_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS personalized_track_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
profile_id INTEGER NOT NULL DEFAULT 1,
|
||||
kind TEXT NOT NULL,
|
||||
track_id TEXT NOT NULL,
|
||||
served_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
|
||||
PERSONALIZED_TRACK_HISTORY_INDEX = """
|
||||
CREATE INDEX IF NOT EXISTS idx_personalized_history_lookup
|
||||
ON personalized_track_history(profile_id, kind, track_id, served_at)
|
||||
"""
|
||||
|
||||
|
||||
def ensure_personalized_schema(connection: Any) -> None:
|
||||
"""Create the personalized-playlist tables + indexes if missing.
|
||||
|
||||
Idempotent. Safe to call on every app startup. Caller is responsible
|
||||
for committing the connection (we leave that to the caller so this
|
||||
composes with other schema-init steps in one transaction).
|
||||
"""
|
||||
cursor = connection.cursor()
|
||||
cursor.execute(PERSONALIZED_PLAYLISTS_DDL)
|
||||
cursor.execute(PERSONALIZED_PLAYLIST_TRACKS_DDL)
|
||||
cursor.execute(PERSONALIZED_PLAYLIST_TRACKS_INDEX)
|
||||
cursor.execute(PERSONALIZED_TRACK_HISTORY_DDL)
|
||||
cursor.execute(PERSONALIZED_TRACK_HISTORY_INDEX)
|
||||
|
||||
# Add is_stale column on installs that created the table before
|
||||
# this column existed. SQLite has no `ADD COLUMN IF NOT EXISTS` so
|
||||
# we probe with PRAGMA + tolerate the OperationalError that fires
|
||||
# when the column is already there.
|
||||
cursor.execute("PRAGMA table_info(personalized_playlists)")
|
||||
cols = {row[1] for row in cursor.fetchall()}
|
||||
if 'is_stale' not in cols:
|
||||
try:
|
||||
cursor.execute(PERSONALIZED_PLAYLISTS_STALE_MIGRATION)
|
||||
logger.info("Added is_stale column to personalized_playlists")
|
||||
except Exception as e:
|
||||
logger.debug("is_stale column migration: %s", e)
|
||||
|
||||
logger.debug("Personalized-playlist schema ensured")
|
||||
|
||||
|
||||
__all__ = [
|
||||
'ensure_personalized_schema',
|
||||
'PERSONALIZED_PLAYLISTS_DDL',
|
||||
'PERSONALIZED_PLAYLIST_TRACKS_DDL',
|
||||
'PERSONALIZED_PLAYLIST_TRACKS_INDEX',
|
||||
'PERSONALIZED_TRACK_HISTORY_DDL',
|
||||
'PERSONALIZED_TRACK_HISTORY_INDEX',
|
||||
]
|
||||
|
|
@ -34,7 +34,8 @@ def _shape_check(items, allowed_types):
|
|||
|
||||
_FIELD_TYPES = {
|
||||
'number', 'select', 'time', 'multi_select', 'checkbox', 'text',
|
||||
'mirrored_playlist_select', 'signal_input', 'script_select',
|
||||
'mirrored_playlist_select', 'personalized_playlist_select',
|
||||
'signal_input', 'script_select',
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
391
tests/automation/test_handler_registration.py
Normal file
391
tests/automation/test_handler_registration.py
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
"""Engine-boundary tests for the automation handler registration.
|
||||
|
||||
Per-handler boundary tests in the sibling test files prove each
|
||||
handler's body works in isolation. These tests prove the
|
||||
**registration layer** wires every handler to the right action name,
|
||||
attaches the right guard, and registers the four progress callbacks
|
||||
in the slots the engine expects.
|
||||
|
||||
The kettui standard for refactor PRs: don't ship a "behavior
|
||||
preserved" claim that's only validated at the function boundary.
|
||||
Wire the seam — engine + register_all + deps — and exercise it.
|
||||
|
||||
These tests use a minimal recording engine that captures every
|
||||
``register_action_handler`` / ``register_progress_callbacks`` call,
|
||||
plus a no-op ``add_scan_completion_callback`` on a fake scan
|
||||
manager. No real AutomationEngine, no real DB, no real Flask app.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from core.automation.deps import AutomationDeps, AutomationState
|
||||
from core.automation.handlers import register_all
|
||||
|
||||
|
||||
# Every action name `register_all` is expected to register. Drift
|
||||
# (rename / new handler / removed handler) fails this test
|
||||
# immediately so refactor PRs can't quietly drop a handler.
|
||||
EXPECTED_ACTION_NAMES = frozenset({
|
||||
'process_wishlist',
|
||||
'scan_watchlist',
|
||||
'scan_library',
|
||||
'refresh_mirrored',
|
||||
'sync_playlist',
|
||||
'discover_playlist',
|
||||
'playlist_pipeline',
|
||||
'personalized_pipeline',
|
||||
'start_database_update',
|
||||
'deep_scan_library',
|
||||
'run_duplicate_cleaner',
|
||||
'clear_quarantine',
|
||||
'cleanup_wishlist',
|
||||
'update_discovery_pool',
|
||||
'start_quality_scan',
|
||||
'backup_database',
|
||||
'refresh_beatport_cache',
|
||||
'clean_search_history',
|
||||
'clean_completed_downloads',
|
||||
'full_cleanup',
|
||||
'run_script',
|
||||
'search_and_download',
|
||||
})
|
||||
|
||||
# Action names that MUST register a guard (duplicate-run prevention).
|
||||
EXPECTED_GUARDED_ACTIONS = frozenset({
|
||||
'process_wishlist',
|
||||
'scan_watchlist',
|
||||
'scan_library',
|
||||
'playlist_pipeline',
|
||||
'personalized_pipeline',
|
||||
'start_database_update',
|
||||
'deep_scan_library',
|
||||
'run_duplicate_cleaner',
|
||||
'start_quality_scan',
|
||||
})
|
||||
|
||||
|
||||
class _RecordingEngine:
|
||||
"""Minimal AutomationEngine stand-in. Captures everything
|
||||
register_all does so tests can assert on it."""
|
||||
|
||||
def __init__(self):
|
||||
self.action_handlers: Dict[str, Dict[str, Any]] = {}
|
||||
self.progress_callbacks: Tuple = ()
|
||||
self.emits: List[Tuple[str, dict]] = []
|
||||
|
||||
def register_action_handler(self, action_type, handler_fn, guard_fn=None):
|
||||
self.action_handlers[action_type] = {'handler': handler_fn, 'guard': guard_fn}
|
||||
|
||||
def register_progress_callbacks(self, init_fn, finish_fn, update_fn=None, history_fn=None):
|
||||
self.progress_callbacks = (init_fn, finish_fn, update_fn, history_fn)
|
||||
|
||||
def emit(self, event_name, payload):
|
||||
self.emits.append((event_name, payload))
|
||||
|
||||
|
||||
class _RecordingScanMgr:
|
||||
_current_server_type = 'plex'
|
||||
|
||||
def __init__(self):
|
||||
self.callbacks: List = []
|
||||
|
||||
def add_scan_completion_callback(self, cb):
|
||||
self.callbacks.append(cb)
|
||||
|
||||
|
||||
def _build_deps(engine, scan_mgr=None) -> AutomationDeps:
|
||||
class _Logger:
|
||||
def debug(self, *a, **k): pass
|
||||
def info(self, *a, **k): pass
|
||||
def warning(self, *a, **k): pass
|
||||
def error(self, *a, **k): pass
|
||||
|
||||
class _Cfg:
|
||||
def get(self, key, default=None): return default
|
||||
def get_active_media_server(self): return 'plex'
|
||||
|
||||
return AutomationDeps(
|
||||
engine=engine,
|
||||
state=AutomationState(),
|
||||
config_manager=_Cfg(),
|
||||
update_progress=lambda *a, **k: None,
|
||||
logger=_Logger(),
|
||||
get_database=lambda: object(),
|
||||
spotify_client=None,
|
||||
tidal_client=None,
|
||||
web_scan_manager=scan_mgr,
|
||||
process_wishlist_automatically=lambda **k: None,
|
||||
process_watchlist_scan_automatically=lambda **k: None,
|
||||
is_wishlist_actually_processing=lambda: False,
|
||||
is_watchlist_actually_scanning=lambda: False,
|
||||
get_watchlist_scan_state=lambda: {},
|
||||
run_playlist_discovery_worker=lambda *a, **k: None,
|
||||
run_sync_task=lambda *a, **k: None,
|
||||
load_sync_status_file=lambda: {},
|
||||
get_deezer_client=lambda: None,
|
||||
parse_youtube_playlist=lambda url: None,
|
||||
get_sync_states=lambda: {},
|
||||
set_db_update_automation_id=lambda v: None,
|
||||
get_db_update_state=lambda: {},
|
||||
db_update_lock=threading.Lock(),
|
||||
db_update_executor=None,
|
||||
run_db_update_task=lambda *a, **k: None,
|
||||
run_deep_scan_task=lambda *a, **k: None,
|
||||
get_duplicate_cleaner_state=lambda: {},
|
||||
duplicate_cleaner_lock=threading.Lock(),
|
||||
duplicate_cleaner_executor=None,
|
||||
run_duplicate_cleaner=lambda: None,
|
||||
get_quality_scanner_state=lambda: {},
|
||||
quality_scanner_lock=threading.Lock(),
|
||||
quality_scanner_executor=None,
|
||||
run_quality_scanner=lambda *a, **k: None,
|
||||
download_orchestrator=None,
|
||||
run_async=lambda coro: None,
|
||||
tasks_lock=threading.Lock(),
|
||||
get_download_batches=lambda: {},
|
||||
get_download_tasks=lambda: {},
|
||||
sweep_empty_download_directories=lambda: 0,
|
||||
get_staging_path=lambda: '/staging',
|
||||
docker_resolve_path=lambda p: p,
|
||||
get_current_profile_id=lambda: 1,
|
||||
get_watchlist_scanner=lambda spc: None,
|
||||
get_app=lambda: None,
|
||||
get_beatport_data_cache=lambda: {'cache_lock': threading.Lock(), 'homepage': {}},
|
||||
init_automation_progress=lambda *a, **k: None,
|
||||
record_progress_history=lambda *a, **k: None,
|
||||
build_personalized_manager=lambda: None,
|
||||
)
|
||||
|
||||
|
||||
# ─── action handler registration ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestActionHandlerRegistration:
|
||||
def test_every_expected_action_name_registered(self):
|
||||
engine = _RecordingEngine()
|
||||
register_all(_build_deps(engine))
|
||||
registered = set(engine.action_handlers.keys())
|
||||
missing = EXPECTED_ACTION_NAMES - registered
|
||||
extra = registered - EXPECTED_ACTION_NAMES
|
||||
assert not missing, f"register_all dropped: {missing}"
|
||||
assert not extra, f"register_all added unexpected: {extra}"
|
||||
|
||||
def test_guarded_actions_have_a_guard(self):
|
||||
engine = _RecordingEngine()
|
||||
register_all(_build_deps(engine))
|
||||
for name in EXPECTED_GUARDED_ACTIONS:
|
||||
assert engine.action_handlers[name]['guard'] is not None, (
|
||||
f"action {name!r} expected to register a guard but didn't"
|
||||
)
|
||||
|
||||
def test_unguarded_actions_have_no_guard(self):
|
||||
engine = _RecordingEngine()
|
||||
register_all(_build_deps(engine))
|
||||
unguarded = EXPECTED_ACTION_NAMES - EXPECTED_GUARDED_ACTIONS
|
||||
for name in unguarded:
|
||||
assert engine.action_handlers[name]['guard'] is None, (
|
||||
f"action {name!r} unexpectedly registered a guard"
|
||||
)
|
||||
|
||||
def test_every_handler_callable(self):
|
||||
# Every registered handler must be callable with a config dict.
|
||||
engine = _RecordingEngine()
|
||||
register_all(_build_deps(engine))
|
||||
for name, entry in engine.action_handlers.items():
|
||||
handler = entry['handler']
|
||||
assert callable(handler), f"{name} handler is not callable"
|
||||
|
||||
def test_every_guard_callable_returns_bool(self):
|
||||
engine = _RecordingEngine()
|
||||
register_all(_build_deps(engine))
|
||||
for name, entry in engine.action_handlers.items():
|
||||
guard = entry['guard']
|
||||
if guard is None:
|
||||
continue
|
||||
value = guard()
|
||||
assert isinstance(value, bool), (
|
||||
f"{name} guard returned non-bool: {type(value).__name__}"
|
||||
)
|
||||
|
||||
|
||||
# ─── progress callback registration ──────────────────────────────────
|
||||
|
||||
|
||||
class TestProgressCallbackRegistration:
|
||||
def test_all_four_callbacks_registered(self):
|
||||
engine = _RecordingEngine()
|
||||
register_all(_build_deps(engine))
|
||||
init_fn, finish_fn, update_fn, history_fn = engine.progress_callbacks
|
||||
assert callable(init_fn)
|
||||
assert callable(finish_fn)
|
||||
assert callable(update_fn)
|
||||
assert callable(history_fn)
|
||||
|
||||
def test_progress_init_callback_invocable(self):
|
||||
# Engine signature: init_fn(aid, name, action_type)
|
||||
engine = _RecordingEngine()
|
||||
captured: List[Tuple] = []
|
||||
deps = _build_deps(engine)
|
||||
deps = AutomationDeps(**{
|
||||
**{f.name: getattr(deps, f.name) for f in deps.__dataclass_fields__.values()},
|
||||
'init_automation_progress': lambda aid, name, at: captured.append((aid, name, at)),
|
||||
})
|
||||
register_all(deps)
|
||||
init_fn, _, _, _ = engine.progress_callbacks
|
||||
init_fn('auto-1', 'My Auto', 'wishlist')
|
||||
assert captured == [('auto-1', 'My Auto', 'wishlist')]
|
||||
|
||||
def test_progress_finish_callback_invocable(self):
|
||||
# Engine signature: finish_fn(aid, result)
|
||||
engine = _RecordingEngine()
|
||||
captured: List[Tuple] = []
|
||||
deps = _build_deps(engine)
|
||||
deps = AutomationDeps(**{
|
||||
**{f.name: getattr(deps, f.name) for f in deps.__dataclass_fields__.values()},
|
||||
'update_progress': lambda aid, **kw: captured.append((aid, kw)),
|
||||
})
|
||||
register_all(deps)
|
||||
_, finish_fn, _, _ = engine.progress_callbacks
|
||||
# Non-_manages_own_progress result triggers update_progress emit.
|
||||
finish_fn('auto-1', {'status': 'completed'})
|
||||
assert len(captured) == 1
|
||||
assert captured[0][0] == 'auto-1'
|
||||
assert captured[0][1]['status'] == 'finished'
|
||||
|
||||
def test_progress_finish_skips_self_managed(self):
|
||||
engine = _RecordingEngine()
|
||||
captured: List[Tuple] = []
|
||||
deps = _build_deps(engine)
|
||||
deps = AutomationDeps(**{
|
||||
**{f.name: getattr(deps, f.name) for f in deps.__dataclass_fields__.values()},
|
||||
'update_progress': lambda aid, **kw: captured.append((aid, kw)),
|
||||
})
|
||||
register_all(deps)
|
||||
_, finish_fn, _, _ = engine.progress_callbacks
|
||||
finish_fn('auto-1', {'_manages_own_progress': True, 'status': 'completed'})
|
||||
assert captured == []
|
||||
|
||||
def test_history_callback_invocable_with_db(self):
|
||||
# Engine signature: history_fn(aid, result)
|
||||
engine = _RecordingEngine()
|
||||
captured: List[Tuple] = []
|
||||
db_obj = object()
|
||||
deps = _build_deps(engine)
|
||||
deps = AutomationDeps(**{
|
||||
**{f.name: getattr(deps, f.name) for f in deps.__dataclass_fields__.values()},
|
||||
'get_database': lambda: db_obj,
|
||||
'record_progress_history': lambda aid, result, db: captured.append((aid, result, db)),
|
||||
})
|
||||
register_all(deps)
|
||||
_, _, _, history_fn = engine.progress_callbacks
|
||||
history_fn('auto-1', {'status': 'completed'})
|
||||
assert captured == [('auto-1', {'status': 'completed'}, db_obj)]
|
||||
|
||||
|
||||
# ─── library_scan_completed wiring ───────────────────────────────────
|
||||
|
||||
|
||||
class TestLibraryScanCompletedEmitter:
|
||||
def test_no_scan_manager_safe(self):
|
||||
# Should not raise when scan manager is absent (test/headless mode).
|
||||
engine = _RecordingEngine()
|
||||
register_all(_build_deps(engine, scan_mgr=None))
|
||||
# No callbacks captured (no scan manager to register against),
|
||||
# but engine still has all the handlers.
|
||||
assert engine.action_handlers
|
||||
|
||||
def test_scan_completion_callback_registered(self):
|
||||
engine = _RecordingEngine()
|
||||
scan_mgr = _RecordingScanMgr()
|
||||
register_all(_build_deps(engine, scan_mgr=scan_mgr))
|
||||
assert len(scan_mgr.callbacks) == 1
|
||||
# Invoking the callback should fire engine.emit('library_scan_completed', ...).
|
||||
scan_mgr.callbacks[0]()
|
||||
assert engine.emits == [('library_scan_completed', {'server_type': 'plex'})]
|
||||
|
||||
|
||||
# ─── handler invocation through the engine boundary ─────────────────
|
||||
|
||||
|
||||
class TestHandlerInvocation:
|
||||
"""For each registered handler, exercise the lambda the engine
|
||||
would call. Verifies the deps closure is captured correctly + the
|
||||
handler returns a result dict.
|
||||
|
||||
Forces every long-running handler down its guard short-circuit
|
||||
path by pre-setting the relevant `*_state` dicts to ``running``
|
||||
(database_update, deep_scan, duplicate_cleaner, quality_scanner)
|
||||
or by pre-occupying the state flags (scan_library, playlist_pipeline).
|
||||
Other handlers either return error early (no playlist specified,
|
||||
no scan manager, etc) or run cleanly against the no-op stub deps.
|
||||
"""
|
||||
|
||||
def test_every_handler_returns_dict(self):
|
||||
engine = _RecordingEngine()
|
||||
# Pre-set state dicts so guarded handlers skip-return cleanly.
|
||||
running_state = {'status': 'running'}
|
||||
active_batches = {'b1': {'phase': 'downloading'}}
|
||||
|
||||
# Stub DB that satisfies the handlers reaching for it on
|
||||
# short paths. cleanup_wishlist calls remove_wishlist_duplicates.
|
||||
# update_discovery_pool's exception path swallows missing
|
||||
# scanner. Other handlers either short-circuit or use stub
|
||||
# callables.
|
||||
class _StubDB:
|
||||
def remove_wishlist_duplicates(self, profile_id): return 0
|
||||
def get_mirrored_playlists(self): return []
|
||||
def get_mirrored_playlist(self, _id): return None
|
||||
def get_mirrored_playlist_tracks(self, _id): return []
|
||||
|
||||
# Stub Flask app so refresh_beatport_cache's test_client call
|
||||
# doesn't crash. Use a minimal context manager.
|
||||
class _FakeResponse:
|
||||
status_code = 200
|
||||
class _FakeClient:
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *a): return False
|
||||
def get(self, _path): return _FakeResponse()
|
||||
class _StubApp:
|
||||
def test_client(self): return _FakeClient()
|
||||
|
||||
deps = _build_deps(engine)
|
||||
deps = AutomationDeps(**{
|
||||
**{f.name: getattr(deps, f.name) for f in deps.__dataclass_fields__.values()},
|
||||
'get_db_update_state': lambda: running_state,
|
||||
'get_duplicate_cleaner_state': lambda: running_state,
|
||||
'get_quality_scanner_state': lambda: running_state,
|
||||
'get_download_batches': lambda: active_batches, # forces clean_completed_downloads to skip
|
||||
'get_database': lambda: _StubDB(),
|
||||
'get_app': lambda: _StubApp(),
|
||||
})
|
||||
# Pre-set state flags too so scan_library + playlist_pipeline guards fire.
|
||||
deps.state.scan_library_automation_id = 'someone-else'
|
||||
deps.state.pipeline_running = True
|
||||
|
||||
# Patch time.sleep across handler modules so refresh_beatport_cache
|
||||
# (which sleeps 2s between sections) doesn't extend the test.
|
||||
import core.automation.handlers.maintenance as maint_mod
|
||||
original_sleep = maint_mod.time.sleep
|
||||
maint_mod.time.sleep = lambda _: None
|
||||
|
||||
register_all(deps)
|
||||
|
||||
try:
|
||||
for name, entry in engine.action_handlers.items():
|
||||
handler = entry['handler']
|
||||
# Minimal config: trigger the natural error/skip paths
|
||||
# for handlers that need a playlist_id, query, script_name etc.
|
||||
result = handler({'_automation_id': 'test', 'all': False})
|
||||
assert isinstance(result, dict), (
|
||||
f"handler {name!r} returned {type(result).__name__}, expected dict"
|
||||
)
|
||||
assert 'status' in result, (
|
||||
f"handler {name!r} returned dict without 'status': {list(result.keys())}"
|
||||
)
|
||||
finally:
|
||||
maint_mod.time.sleep = original_sleep
|
||||
393
tests/automation/test_handlers_maintenance.py
Normal file
393
tests/automation/test_handlers_maintenance.py
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
"""Boundary tests for the maintenance + misc automation handlers
|
||||
(database_update / deep_scan_library / duplicate_cleaner /
|
||||
quality_scanner / clear_quarantine / cleanup_wishlist /
|
||||
update_discovery_pool / backup_database / refresh_beatport_cache /
|
||||
clean_search_history / clean_completed_downloads / full_cleanup /
|
||||
run_script / search_and_download).
|
||||
|
||||
Each handler is tested as a pure function via stub deps. The bodies
|
||||
are mechanical lifts of the closures that used to live in
|
||||
``web_server._register_automation_handlers`` — these tests pin the
|
||||
seam (deps wiring, exception swallow contract, return shapes,
|
||||
guard short-circuits) so future drift fails here, not at runtime
|
||||
against real executors / clients."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
|
||||
from core.automation.deps import AutomationDeps, AutomationState
|
||||
from core.automation.handlers.database_update import (
|
||||
auto_start_database_update, auto_deep_scan_library,
|
||||
)
|
||||
from core.automation.handlers.duplicate_cleaner import auto_run_duplicate_cleaner
|
||||
from core.automation.handlers.quality_scanner import auto_start_quality_scan
|
||||
from core.automation.handlers.maintenance import (
|
||||
auto_clear_quarantine, auto_cleanup_wishlist,
|
||||
auto_update_discovery_pool, auto_backup_database,
|
||||
)
|
||||
from core.automation.handlers.download_cleanup import (
|
||||
auto_clean_search_history, auto_clean_completed_downloads,
|
||||
)
|
||||
from core.automation.handlers.run_script import auto_run_script
|
||||
from core.automation.handlers.search_and_download import auto_search_and_download
|
||||
|
||||
|
||||
class _StubLogger:
|
||||
def debug(self, *a, **k): pass
|
||||
def info(self, *a, **k): pass
|
||||
def warning(self, *a, **k): pass
|
||||
def error(self, *a, **k): pass
|
||||
|
||||
|
||||
class _StubConfig:
|
||||
def __init__(self, values=None):
|
||||
self._values = values or {}
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._values.get(key, default)
|
||||
|
||||
def get_active_media_server(self):
|
||||
return 'plex'
|
||||
|
||||
|
||||
def _build_deps(**overrides) -> AutomationDeps:
|
||||
defaults = dict(
|
||||
engine=object(),
|
||||
state=AutomationState(),
|
||||
config_manager=_StubConfig(),
|
||||
update_progress=lambda *a, **k: None,
|
||||
logger=_StubLogger(),
|
||||
get_database=lambda: object(),
|
||||
spotify_client=None,
|
||||
tidal_client=None,
|
||||
web_scan_manager=None,
|
||||
process_wishlist_automatically=lambda **k: None,
|
||||
process_watchlist_scan_automatically=lambda **k: None,
|
||||
is_wishlist_actually_processing=lambda: False,
|
||||
is_watchlist_actually_scanning=lambda: False,
|
||||
get_watchlist_scan_state=lambda: {},
|
||||
run_playlist_discovery_worker=lambda *a, **k: None,
|
||||
run_sync_task=lambda *a, **k: None,
|
||||
load_sync_status_file=lambda: {},
|
||||
get_deezer_client=lambda: None,
|
||||
parse_youtube_playlist=lambda url: None,
|
||||
get_sync_states=lambda: {},
|
||||
set_db_update_automation_id=lambda v: None,
|
||||
get_db_update_state=lambda: {},
|
||||
db_update_lock=threading.Lock(),
|
||||
db_update_executor=None,
|
||||
run_db_update_task=lambda *a, **k: None,
|
||||
run_deep_scan_task=lambda *a, **k: None,
|
||||
get_duplicate_cleaner_state=lambda: {},
|
||||
duplicate_cleaner_lock=threading.Lock(),
|
||||
duplicate_cleaner_executor=None,
|
||||
run_duplicate_cleaner=lambda: None,
|
||||
get_quality_scanner_state=lambda: {},
|
||||
quality_scanner_lock=threading.Lock(),
|
||||
quality_scanner_executor=None,
|
||||
run_quality_scanner=lambda *a, **k: None,
|
||||
download_orchestrator=None,
|
||||
run_async=lambda coro: None,
|
||||
tasks_lock=threading.Lock(),
|
||||
get_download_batches=lambda: {},
|
||||
get_download_tasks=lambda: {},
|
||||
sweep_empty_download_directories=lambda: 0,
|
||||
get_staging_path=lambda: '/staging',
|
||||
docker_resolve_path=lambda p: p,
|
||||
get_current_profile_id=lambda: 1,
|
||||
get_watchlist_scanner=lambda spc: None,
|
||||
get_app=lambda: None,
|
||||
get_beatport_data_cache=lambda: {'cache_lock': threading.Lock(), 'homepage': {}},
|
||||
init_automation_progress=lambda *a, **k: None,
|
||||
record_progress_history=lambda *a, **k: None,
|
||||
build_personalized_manager=lambda: None,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return AutomationDeps(**defaults) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ─── database_update / deep_scan ──────────────────────────────────────
|
||||
|
||||
|
||||
class _StubExecutor:
|
||||
def __init__(self):
|
||||
self.submits: List[tuple] = []
|
||||
|
||||
def submit(self, fn, *args, **kwargs):
|
||||
self.submits.append((fn, args, kwargs))
|
||||
|
||||
|
||||
class TestDatabaseUpdate:
|
||||
def test_already_running_returns_skipped(self):
|
||||
state = {'status': 'running'}
|
||||
deps = _build_deps(get_db_update_state=lambda: state)
|
||||
result = auto_start_database_update({}, deps)
|
||||
assert result == {'status': 'skipped', 'reason': 'Database update already running'}
|
||||
|
||||
def test_set_db_update_automation_id_called(self):
|
||||
# Handler must propagate the automation id through the deps
|
||||
# setter so the legacy global stays in sync.
|
||||
captured: List[Any] = []
|
||||
state = {'status': 'idle'}
|
||||
# Make the polling loop terminate immediately by flipping
|
||||
# the status as soon as it's set to 'running'.
|
||||
executor = _StubExecutor()
|
||||
|
||||
def fake_task(*_a, **_k):
|
||||
state['status'] = 'finished'
|
||||
|
||||
executor.submit = lambda fn, *a, **k: fake_task()
|
||||
deps = _build_deps(
|
||||
get_db_update_state=lambda: state,
|
||||
db_update_executor=executor,
|
||||
set_db_update_automation_id=lambda v: captured.append(v),
|
||||
run_db_update_task=fake_task,
|
||||
)
|
||||
# Replace time.sleep so we don't actually wait
|
||||
import core.automation.handlers.database_update as module
|
||||
original = module.time.sleep
|
||||
module.time.sleep = lambda _: None
|
||||
try:
|
||||
result = auto_start_database_update({'_automation_id': 'auto-1'}, deps)
|
||||
finally:
|
||||
module.time.sleep = original
|
||||
assert captured == ['auto-1']
|
||||
assert result['status'] == 'completed'
|
||||
|
||||
|
||||
class TestDeepScan:
|
||||
def test_already_running_returns_skipped(self):
|
||||
state = {'status': 'running'}
|
||||
deps = _build_deps(get_db_update_state=lambda: state)
|
||||
result = auto_deep_scan_library({}, deps)
|
||||
assert result == {'status': 'skipped', 'reason': 'Database update already running'}
|
||||
|
||||
|
||||
# ─── duplicate_cleaner ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDuplicateCleaner:
|
||||
def test_already_running_returns_skipped(self):
|
||||
state = {'status': 'running'}
|
||||
deps = _build_deps(get_duplicate_cleaner_state=lambda: state)
|
||||
result = auto_run_duplicate_cleaner({}, deps)
|
||||
assert result == {'status': 'skipped', 'reason': 'Duplicate cleaner already running'}
|
||||
|
||||
|
||||
# ─── quality_scanner ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestQualityScanner:
|
||||
def test_already_running_returns_skipped(self):
|
||||
state = {'status': 'running'}
|
||||
deps = _build_deps(get_quality_scanner_state=lambda: state)
|
||||
result = auto_start_quality_scan({}, deps)
|
||||
assert result == {'status': 'skipped', 'reason': 'Quality scan already running'}
|
||||
|
||||
|
||||
# ─── clear_quarantine ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestClearQuarantine:
|
||||
def test_no_quarantine_folder_returns_zero(self, tmp_path):
|
||||
# Point at a non-existent path.
|
||||
deps = _build_deps(
|
||||
config_manager=_StubConfig({'soulseek.download_path': str(tmp_path / 'nonexistent')}),
|
||||
docker_resolve_path=lambda p: p,
|
||||
)
|
||||
result = auto_clear_quarantine({}, deps)
|
||||
assert result == {'status': 'completed', 'removed': '0'}
|
||||
|
||||
def test_clears_files_from_quarantine(self, tmp_path):
|
||||
download_path = tmp_path
|
||||
quarantine_path = download_path / 'ss_quarantine'
|
||||
quarantine_path.mkdir()
|
||||
(quarantine_path / 'a.flac').write_bytes(b'')
|
||||
(quarantine_path / 'b.flac').write_bytes(b'')
|
||||
deps = _build_deps(
|
||||
config_manager=_StubConfig({'soulseek.download_path': str(download_path)}),
|
||||
docker_resolve_path=lambda p: p,
|
||||
)
|
||||
result = auto_clear_quarantine({}, deps)
|
||||
assert result == {'status': 'completed', 'removed': '2'}
|
||||
|
||||
|
||||
# ─── cleanup_wishlist ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCleanupWishlist:
|
||||
def test_returns_count_from_db(self):
|
||||
class _DB:
|
||||
def remove_wishlist_duplicates(self, profile_id):
|
||||
assert profile_id == 1
|
||||
return 7
|
||||
|
||||
deps = _build_deps(get_database=lambda: _DB())
|
||||
result = auto_cleanup_wishlist({}, deps)
|
||||
assert result == {'status': 'completed', 'removed': '7'}
|
||||
|
||||
def test_returns_zero_when_db_returns_falsey(self):
|
||||
class _DB:
|
||||
def remove_wishlist_duplicates(self, profile_id):
|
||||
return None
|
||||
|
||||
deps = _build_deps(get_database=lambda: _DB())
|
||||
result = auto_cleanup_wishlist({}, deps)
|
||||
assert result == {'status': 'completed', 'removed': '0'}
|
||||
|
||||
|
||||
# ─── update_discovery_pool ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestUpdateDiscoveryPool:
|
||||
def test_success(self):
|
||||
called: List[Any] = []
|
||||
|
||||
class _Scanner:
|
||||
def update_discovery_pool_incremental(self, profile_id):
|
||||
called.append(profile_id)
|
||||
|
||||
deps = _build_deps(
|
||||
get_watchlist_scanner=lambda spc: _Scanner(),
|
||||
get_current_profile_id=lambda: 7,
|
||||
)
|
||||
result = auto_update_discovery_pool({}, deps)
|
||||
assert result['status'] == 'completed'
|
||||
assert result['_manages_own_progress'] is True
|
||||
assert called == [7]
|
||||
|
||||
def test_exception_swallowed(self):
|
||||
def boom(_):
|
||||
raise RuntimeError('scanner down')
|
||||
|
||||
deps = _build_deps(get_watchlist_scanner=boom)
|
||||
result = auto_update_discovery_pool({}, deps)
|
||||
assert result['status'] == 'error'
|
||||
assert 'scanner down' in result['reason']
|
||||
|
||||
|
||||
# ─── backup_database ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBackupDatabase:
|
||||
def test_missing_database_returns_error(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv('DATABASE_PATH', str(tmp_path / 'no.db'))
|
||||
deps = _build_deps()
|
||||
result = auto_backup_database({}, deps)
|
||||
assert result == {'status': 'error', 'reason': 'Database file not found'}
|
||||
|
||||
|
||||
# ─── clean_search_history ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCleanSearchHistory:
|
||||
def test_soulseek_inactive_skips(self):
|
||||
deps = _build_deps(
|
||||
config_manager=_StubConfig({'download_source.mode': 'tidal'}),
|
||||
)
|
||||
result = auto_clean_search_history({}, deps)
|
||||
assert result == {'status': 'skipped'}
|
||||
|
||||
def test_no_orchestrator_skips(self):
|
||||
deps = _build_deps(
|
||||
config_manager=_StubConfig({'download_source.mode': 'soulseek'}),
|
||||
download_orchestrator=None,
|
||||
)
|
||||
result = auto_clean_search_history({}, deps)
|
||||
assert result == {'status': 'skipped'}
|
||||
|
||||
|
||||
# ─── clean_completed_downloads ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestCleanCompletedDownloads:
|
||||
def test_active_batches_skip(self):
|
||||
# Active batch present → handler returns 'completed' without doing anything.
|
||||
deps = _build_deps(
|
||||
get_download_batches=lambda: {'b1': {'phase': 'downloading'}},
|
||||
get_download_tasks=lambda: {},
|
||||
)
|
||||
result = auto_clean_completed_downloads({}, deps)
|
||||
assert result == {'status': 'completed'}
|
||||
|
||||
|
||||
# ─── run_script ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRunScript:
|
||||
def test_no_script_name_returns_error(self):
|
||||
deps = _build_deps()
|
||||
result = auto_run_script({}, deps)
|
||||
assert result == {'status': 'error', 'error': 'No script selected'}
|
||||
|
||||
def test_path_traversal_blocked(self, tmp_path):
|
||||
scripts_dir = tmp_path / 'scripts'
|
||||
scripts_dir.mkdir()
|
||||
# Place a script OUTSIDE the scripts dir + try to reach it
|
||||
# via ../ traversal.
|
||||
evil = tmp_path / 'evil.sh'
|
||||
evil.write_text('#!/bin/bash\necho evil')
|
||||
deps = _build_deps(
|
||||
config_manager=_StubConfig({'scripts.path': str(scripts_dir)}),
|
||||
docker_resolve_path=lambda p: p,
|
||||
)
|
||||
result = auto_run_script({'script_name': '../evil.sh'}, deps)
|
||||
assert result['status'] == 'error'
|
||||
assert 'path traversal' in result['error'].lower()
|
||||
|
||||
def test_missing_script_returns_error(self, tmp_path):
|
||||
scripts_dir = tmp_path / 'scripts'
|
||||
scripts_dir.mkdir()
|
||||
deps = _build_deps(
|
||||
config_manager=_StubConfig({'scripts.path': str(scripts_dir)}),
|
||||
)
|
||||
result = auto_run_script({'script_name': 'no_such_script.sh'}, deps)
|
||||
assert result['status'] == 'error'
|
||||
assert 'not found' in result['error'].lower()
|
||||
|
||||
|
||||
# ─── search_and_download ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSearchAndDownload:
|
||||
def test_no_query_returns_error(self):
|
||||
deps = _build_deps()
|
||||
result = auto_search_and_download({}, deps)
|
||||
assert result == {'status': 'error', 'error': 'No search query provided'}
|
||||
|
||||
def test_query_from_event_data_used(self):
|
||||
captured_queries: List[str] = []
|
||||
|
||||
class _Orchestrator:
|
||||
async def search_and_download_best(self, q):
|
||||
captured_queries.append(q)
|
||||
return 'dl-id-123'
|
||||
|
||||
deps = _build_deps(
|
||||
download_orchestrator=_Orchestrator(),
|
||||
run_async=lambda coro: 'dl-id-123',
|
||||
)
|
||||
result = auto_search_and_download(
|
||||
{'_event_data': {'query': 'Adele Hello'}}, deps,
|
||||
)
|
||||
assert result['status'] == 'completed'
|
||||
assert result['query'] == 'Adele Hello'
|
||||
assert result['download_id'] == 'dl-id-123'
|
||||
|
||||
def test_no_match_returns_not_found(self):
|
||||
class _Orchestrator:
|
||||
async def search_and_download_best(self, q):
|
||||
return None
|
||||
|
||||
deps = _build_deps(
|
||||
download_orchestrator=_Orchestrator(),
|
||||
run_async=lambda coro: None,
|
||||
)
|
||||
result = auto_search_and_download({'query': 'xyz'}, deps)
|
||||
assert result['status'] == 'not_found'
|
||||
assert result['query'] == 'xyz'
|
||||
529
tests/automation/test_handlers_personalized_pipeline.py
Normal file
529
tests/automation/test_handlers_personalized_pipeline.py
Normal file
|
|
@ -0,0 +1,529 @@
|
|||
"""Boundary tests for the personalized playlist pipeline handler.
|
||||
|
||||
Pin every shape: empty kinds error, refresh_first behaviour, snapshot
|
||||
load + sync dispatch, missing-tracks skip, exception swallowing,
|
||||
pipeline_running flag cleanup, sync payload shape passed to
|
||||
_run_sync_task."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, List
|
||||
|
||||
import pytest
|
||||
|
||||
from core.automation.deps import AutomationDeps, AutomationState
|
||||
from core.automation.handlers.personalized_pipeline import (
|
||||
auto_personalized_pipeline,
|
||||
_build_payloads_for_kinds,
|
||||
_track_to_sync_shape,
|
||||
_sync_personalized_playlist,
|
||||
)
|
||||
|
||||
|
||||
class _StubLogger:
|
||||
def debug(self, *a, **k): pass
|
||||
def info(self, *a, **k): pass
|
||||
def warning(self, *a, **k): pass
|
||||
def error(self, *a, **k): pass
|
||||
|
||||
|
||||
def _build_deps(**overrides) -> AutomationDeps:
|
||||
defaults = dict(
|
||||
engine=object(),
|
||||
state=AutomationState(),
|
||||
config_manager=object(),
|
||||
update_progress=lambda *a, **k: None,
|
||||
logger=_StubLogger(),
|
||||
get_database=lambda: object(),
|
||||
spotify_client=None,
|
||||
tidal_client=None,
|
||||
web_scan_manager=None,
|
||||
process_wishlist_automatically=lambda **k: None,
|
||||
process_watchlist_scan_automatically=lambda **k: None,
|
||||
is_wishlist_actually_processing=lambda: False,
|
||||
is_watchlist_actually_scanning=lambda: False,
|
||||
get_watchlist_scan_state=lambda: {},
|
||||
run_playlist_discovery_worker=lambda *a, **k: None,
|
||||
run_sync_task=lambda *a, **k: None,
|
||||
load_sync_status_file=lambda: {},
|
||||
get_deezer_client=lambda: None,
|
||||
parse_youtube_playlist=lambda url: None,
|
||||
get_sync_states=lambda: {},
|
||||
set_db_update_automation_id=lambda v: None,
|
||||
get_db_update_state=lambda: {},
|
||||
db_update_lock=threading.Lock(),
|
||||
db_update_executor=None,
|
||||
run_db_update_task=lambda *a, **k: None,
|
||||
run_deep_scan_task=lambda *a, **k: None,
|
||||
get_duplicate_cleaner_state=lambda: {},
|
||||
duplicate_cleaner_lock=threading.Lock(),
|
||||
duplicate_cleaner_executor=None,
|
||||
run_duplicate_cleaner=lambda: None,
|
||||
get_quality_scanner_state=lambda: {},
|
||||
quality_scanner_lock=threading.Lock(),
|
||||
quality_scanner_executor=None,
|
||||
run_quality_scanner=lambda *a, **k: None,
|
||||
download_orchestrator=None,
|
||||
run_async=lambda coro: None,
|
||||
tasks_lock=threading.Lock(),
|
||||
get_download_batches=lambda: {},
|
||||
get_download_tasks=lambda: {},
|
||||
sweep_empty_download_directories=lambda: 0,
|
||||
get_staging_path=lambda: '/staging',
|
||||
docker_resolve_path=lambda p: p,
|
||||
get_current_profile_id=lambda: 1,
|
||||
get_watchlist_scanner=lambda spc: None,
|
||||
get_app=lambda: None,
|
||||
get_beatport_data_cache=lambda: {'cache_lock': threading.Lock(), 'homepage': {}},
|
||||
init_automation_progress=lambda *a, **k: None,
|
||||
record_progress_history=lambda *a, **k: None,
|
||||
build_personalized_manager=lambda: None,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return AutomationDeps(**defaults) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ─── Track shape converter ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTrackToSyncShape:
|
||||
def test_basic_shape(self):
|
||||
track = SimpleNamespace(
|
||||
track_name='Song', artist_name='Artist', album_name='Album',
|
||||
spotify_track_id='sp-1', itunes_track_id=None, deezer_track_id=None,
|
||||
duration_ms=200000,
|
||||
)
|
||||
out = _track_to_sync_shape(track)
|
||||
assert out == {
|
||||
'name': 'Song',
|
||||
'artists': [{'name': 'Artist'}],
|
||||
'album': {'name': 'Album'},
|
||||
'duration_ms': 200000,
|
||||
'id': 'sp-1',
|
||||
}
|
||||
|
||||
def test_falls_back_through_source_ids(self):
|
||||
t1 = SimpleNamespace(track_name='', artist_name='', album_name='',
|
||||
spotify_track_id=None, itunes_track_id='it-1',
|
||||
deezer_track_id=None, duration_ms=0)
|
||||
assert _track_to_sync_shape(t1)['id'] == 'it-1'
|
||||
|
||||
t2 = SimpleNamespace(track_name='', artist_name='', album_name='',
|
||||
spotify_track_id=None, itunes_track_id=None,
|
||||
deezer_track_id='dz-1', duration_ms=0)
|
||||
assert _track_to_sync_shape(t2)['id'] == 'dz-1'
|
||||
|
||||
def test_no_id_returns_empty_string(self):
|
||||
t = SimpleNamespace(track_name='X', artist_name='Y', album_name='Z',
|
||||
spotify_track_id=None, itunes_track_id=None,
|
||||
deezer_track_id=None, duration_ms=0)
|
||||
assert _track_to_sync_shape(t)['id'] == ''
|
||||
|
||||
def test_preserves_enriched_track_data_for_wishlist_metadata(self):
|
||||
track = SimpleNamespace(
|
||||
track_name='Bare Name', artist_name='Bare Artist', album_name='Bare Album',
|
||||
spotify_track_id='sp-rich', itunes_track_id=None, deezer_track_id=None,
|
||||
album_cover_url=None, duration_ms=200000, popularity=33,
|
||||
track_data_json={
|
||||
'id': 'sp-rich',
|
||||
'name': 'Rich Name',
|
||||
'artists': [{'name': 'Rich Artist', 'id': 'artist-1'}],
|
||||
'album': {
|
||||
'id': 'album-1',
|
||||
'name': 'Rich Album',
|
||||
'images': [{'url': 'https://example.test/cover.jpg'}],
|
||||
},
|
||||
'duration_ms': 201000,
|
||||
'preview_url': 'https://example.test/preview.mp3',
|
||||
},
|
||||
)
|
||||
|
||||
out = _track_to_sync_shape(track)
|
||||
|
||||
assert out['name'] == 'Rich Name'
|
||||
assert out['artists'][0] == {'name': 'Rich Artist', 'id': 'artist-1'}
|
||||
assert out['album']['id'] == 'album-1'
|
||||
assert out['album']['images'][0]['url'] == 'https://example.test/cover.jpg'
|
||||
assert out['preview_url'] == 'https://example.test/preview.mp3'
|
||||
|
||||
def test_album_cover_url_fills_album_images_when_no_rich_blob(self):
|
||||
track = SimpleNamespace(
|
||||
track_name='Song', artist_name='Artist', album_name='Album',
|
||||
spotify_track_id='sp-1', itunes_track_id=None, deezer_track_id=None,
|
||||
album_cover_url='https://example.test/fallback.jpg',
|
||||
duration_ms=200000, track_data_json=None,
|
||||
)
|
||||
|
||||
out = _track_to_sync_shape(track)
|
||||
|
||||
assert out['album'] == {
|
||||
'name': 'Album',
|
||||
'images': [{'url': 'https://example.test/fallback.jpg'}],
|
||||
}
|
||||
|
||||
|
||||
# ─── Empty / config validation ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestEmptyConfig:
|
||||
def test_no_kinds_returns_error_and_clears_flag(self):
|
||||
deps = _build_deps()
|
||||
deps.state.set_pipeline_running(True) # simulate already-running
|
||||
result = auto_personalized_pipeline({}, deps)
|
||||
assert result['status'] == 'error'
|
||||
assert 'No personalized playlist' in result['error']
|
||||
assert deps.state.pipeline_running is False
|
||||
|
||||
def test_empty_kinds_list_returns_error(self):
|
||||
deps = _build_deps()
|
||||
result = auto_personalized_pipeline({'kinds': []}, deps)
|
||||
assert result['status'] == 'error'
|
||||
assert deps.state.pipeline_running is False
|
||||
|
||||
def test_non_list_kinds_returns_error(self):
|
||||
deps = _build_deps()
|
||||
result = auto_personalized_pipeline({'kinds': 'not_a_list'}, deps)
|
||||
assert result['status'] == 'error'
|
||||
|
||||
|
||||
# ─── Payload building ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class _StubManagerNoTracks:
|
||||
def ensure_playlist(self, kind, variant, profile_id):
|
||||
# last_generated_at non-None so pipeline treats the snapshot as
|
||||
# already-generated-but-empty (rather than first-run-needs-gen).
|
||||
return SimpleNamespace(
|
||||
id=1, name=f'{kind}-{variant}', kind=kind, variant=variant,
|
||||
is_stale=False, last_generated_at='2026-05-15T20:00:00',
|
||||
)
|
||||
|
||||
def refresh_playlist(self, kind, variant, profile_id):
|
||||
return self.ensure_playlist(kind, variant, profile_id)
|
||||
|
||||
def get_playlist_tracks(self, playlist_id):
|
||||
return []
|
||||
|
||||
|
||||
class _StubManagerWithTracks:
|
||||
def __init__(self, tracks_per_kind=None):
|
||||
self.tracks_per_kind = tracks_per_kind or {}
|
||||
self.refresh_calls: List[tuple] = []
|
||||
self.ensure_calls: List[tuple] = []
|
||||
|
||||
def ensure_playlist(self, kind, variant, profile_id):
|
||||
self.ensure_calls.append((kind, variant, profile_id))
|
||||
return SimpleNamespace(
|
||||
id=hash((kind, variant)) % 10000,
|
||||
name=f'{kind}-{variant or "S"}', kind=kind, variant=variant,
|
||||
is_stale=False, last_generated_at='2026-05-15T20:00:00',
|
||||
)
|
||||
|
||||
def refresh_playlist(self, kind, variant, profile_id):
|
||||
self.refresh_calls.append((kind, variant, profile_id))
|
||||
# Mirror real manager: refresh returns a record without invoking
|
||||
# the public ensure_playlist API path again.
|
||||
return SimpleNamespace(
|
||||
id=hash((kind, variant)) % 10000,
|
||||
name=f'{kind}-{variant or "S"}', kind=kind, variant=variant,
|
||||
is_stale=False, last_generated_at='2026-05-15T20:00:00',
|
||||
)
|
||||
|
||||
def get_playlist_tracks(self, playlist_id):
|
||||
# Return all tracks regardless of id — tests scope to one playlist at a time.
|
||||
for tracks in self.tracks_per_kind.values():
|
||||
if tracks:
|
||||
return [SimpleNamespace(
|
||||
track_name=t['name'], artist_name=t.get('artist', 'A'),
|
||||
album_name=t.get('album', 'Al'),
|
||||
spotify_track_id=t.get('id'),
|
||||
itunes_track_id=None, deezer_track_id=None,
|
||||
duration_ms=200000,
|
||||
) for t in tracks]
|
||||
return []
|
||||
|
||||
|
||||
class TestPayloadBuilding:
|
||||
def test_skips_kinds_with_no_tracks(self):
|
||||
deps = _build_deps()
|
||||
manager = _StubManagerNoTracks()
|
||||
payloads = _build_payloads_for_kinds(
|
||||
deps, manager,
|
||||
[{'kind': 'hidden_gems'}, {'kind': 'discovery_shuffle'}],
|
||||
profile_id=1, automation_id=None, refresh_first=False,
|
||||
)
|
||||
assert payloads == []
|
||||
|
||||
def test_skips_invalid_entries(self):
|
||||
deps = _build_deps()
|
||||
manager = _StubManagerNoTracks()
|
||||
payloads = _build_payloads_for_kinds(
|
||||
deps, manager,
|
||||
['not-a-dict', {}, {'variant': 'no-kind'}], # all invalid
|
||||
profile_id=1, automation_id=None, refresh_first=False,
|
||||
)
|
||||
assert payloads == []
|
||||
|
||||
def test_refresh_first_calls_refresh(self):
|
||||
deps = _build_deps()
|
||||
manager = _StubManagerWithTracks(
|
||||
tracks_per_kind={'hidden_gems': [{'name': 'T', 'id': 'sp-1'}]},
|
||||
)
|
||||
_build_payloads_for_kinds(
|
||||
deps, manager,
|
||||
[{'kind': 'hidden_gems'}],
|
||||
profile_id=1, automation_id=None, refresh_first=True,
|
||||
)
|
||||
assert manager.refresh_calls == [('hidden_gems', '', 1)]
|
||||
assert manager.ensure_calls == []
|
||||
|
||||
def test_no_refresh_calls_ensure(self):
|
||||
deps = _build_deps()
|
||||
manager = _StubManagerWithTracks(
|
||||
tracks_per_kind={'hidden_gems': [{'name': 'T', 'id': 'sp-1'}]},
|
||||
)
|
||||
_build_payloads_for_kinds(
|
||||
deps, manager,
|
||||
[{'kind': 'hidden_gems'}],
|
||||
profile_id=1, automation_id=None, refresh_first=False,
|
||||
)
|
||||
assert manager.ensure_calls == [('hidden_gems', '', 1)]
|
||||
assert manager.refresh_calls == []
|
||||
|
||||
def test_payload_shape(self):
|
||||
deps = _build_deps()
|
||||
manager = _StubManagerWithTracks(
|
||||
tracks_per_kind={'hidden_gems': [
|
||||
{'name': 'Track1', 'id': 'sp-1'},
|
||||
{'name': 'Track2', 'id': 'sp-2'},
|
||||
]},
|
||||
)
|
||||
payloads = _build_payloads_for_kinds(
|
||||
deps, manager,
|
||||
[{'kind': 'hidden_gems'}],
|
||||
profile_id=1, automation_id=None, refresh_first=False,
|
||||
)
|
||||
assert len(payloads) == 1
|
||||
p = payloads[0]
|
||||
assert p['kind'] == 'hidden_gems'
|
||||
assert p['variant'] == ''
|
||||
assert p['name'] == 'hidden_gems-S'
|
||||
assert p['sync_id'].startswith('auto_personalized_hidden_gems_')
|
||||
assert len(p['tracks_json']) == 2
|
||||
assert p['tracks_json'][0]['id'] == 'sp-1'
|
||||
|
||||
def test_stale_snapshot_auto_refreshes_even_without_refresh_first(self):
|
||||
"""When the manager reports is_stale=True, the pipeline refreshes
|
||||
regardless of the refresh_first config flag — the source data
|
||||
(discovery_pool / curated lists) changed, so the snapshot must
|
||||
be regenerated before syncing or we'd push stale data."""
|
||||
deps = _build_deps()
|
||||
# Stub manager whose ensure_playlist returns a stale record.
|
||||
# refresh_playlist should still get called.
|
||||
refresh_called = []
|
||||
|
||||
class _StaleMgr:
|
||||
def ensure_playlist(self, kind, variant, profile_id):
|
||||
return SimpleNamespace(
|
||||
id=1, name=kind, kind=kind, variant=variant, is_stale=True,
|
||||
last_generated_at='2026-05-15T20:00:00',
|
||||
)
|
||||
def refresh_playlist(self, kind, variant, profile_id):
|
||||
refresh_called.append((kind, variant))
|
||||
return SimpleNamespace(
|
||||
id=1, name=kind, kind=kind, variant=variant, is_stale=False,
|
||||
last_generated_at='2026-05-15T20:00:00',
|
||||
)
|
||||
def get_playlist_tracks(self, _id):
|
||||
return [SimpleNamespace(
|
||||
track_name='Refreshed', artist_name='A', album_name='Al',
|
||||
spotify_track_id='sp-fresh', itunes_track_id=None,
|
||||
deezer_track_id=None, duration_ms=200000,
|
||||
)]
|
||||
|
||||
payloads = _build_payloads_for_kinds(
|
||||
deps, _StaleMgr(),
|
||||
[{'kind': 'hidden_gems'}],
|
||||
profile_id=1, automation_id=None, refresh_first=False,
|
||||
)
|
||||
assert refresh_called == [('hidden_gems', '')]
|
||||
assert len(payloads) == 1
|
||||
assert payloads[0]['tracks_json'][0]['name'] == 'Refreshed'
|
||||
|
||||
def test_non_stale_snapshot_skips_refresh(self):
|
||||
"""When the snapshot is fresh AND refresh_first is False, just
|
||||
read the existing tracks without re-running the generator."""
|
||||
deps = _build_deps()
|
||||
refresh_called = []
|
||||
|
||||
class _FreshMgr:
|
||||
def ensure_playlist(self, kind, variant, profile_id):
|
||||
return SimpleNamespace(
|
||||
id=1, name=kind, kind=kind, variant=variant, is_stale=False,
|
||||
last_generated_at='2026-05-15T20:00:00',
|
||||
)
|
||||
def refresh_playlist(self, *_a, **_k):
|
||||
refresh_called.append('called')
|
||||
return SimpleNamespace(
|
||||
id=1, name='x', kind='x', variant='', is_stale=False,
|
||||
last_generated_at='2026-05-15T20:00:00',
|
||||
)
|
||||
def get_playlist_tracks(self, _id):
|
||||
return [SimpleNamespace(
|
||||
track_name='Cached', artist_name='A', album_name='Al',
|
||||
spotify_track_id='sp-1', itunes_track_id=None,
|
||||
deezer_track_id=None, duration_ms=200000,
|
||||
)]
|
||||
|
||||
_build_payloads_for_kinds(
|
||||
deps, _FreshMgr(),
|
||||
[{'kind': 'hidden_gems'}],
|
||||
profile_id=1, automation_id=None, refresh_first=False,
|
||||
)
|
||||
assert refresh_called == []
|
||||
|
||||
def test_never_generated_snapshot_triggers_first_refresh(self):
|
||||
"""First-run case: pipeline picks a brand-new kind, ensure_playlist
|
||||
auto-creates the row with track_count=0 and last_generated_at=None.
|
||||
Without this branch the pipeline would read the empty snapshot and
|
||||
silently skip — user picked a kind and got nothing. With the branch,
|
||||
last_generated_at=None forces a refresh so the generator actually runs."""
|
||||
deps = _build_deps()
|
||||
refresh_called = []
|
||||
|
||||
class _NeverGenMgr:
|
||||
def ensure_playlist(self, kind, variant, profile_id):
|
||||
return SimpleNamespace(
|
||||
id=1, name=kind, kind=kind, variant=variant,
|
||||
is_stale=False, last_generated_at=None,
|
||||
)
|
||||
def refresh_playlist(self, kind, variant, profile_id):
|
||||
refresh_called.append((kind, variant))
|
||||
return SimpleNamespace(
|
||||
id=1, name=kind, kind=kind, variant=variant,
|
||||
is_stale=False, last_generated_at='2026-05-15T20:00:00',
|
||||
)
|
||||
def get_playlist_tracks(self, _id):
|
||||
return [SimpleNamespace(
|
||||
track_name='Generated', artist_name='A', album_name='Al',
|
||||
spotify_track_id='sp-new', itunes_track_id=None,
|
||||
deezer_track_id=None, duration_ms=200000,
|
||||
)]
|
||||
|
||||
payloads = _build_payloads_for_kinds(
|
||||
deps, _NeverGenMgr(),
|
||||
[{'kind': 'fresh_tape'}],
|
||||
profile_id=1, automation_id=None, refresh_first=False,
|
||||
)
|
||||
assert refresh_called == [('fresh_tape', '')]
|
||||
assert len(payloads) == 1
|
||||
assert payloads[0]['tracks_json'][0]['name'] == 'Generated'
|
||||
|
||||
def test_manager_exception_swallowed_continues_to_next(self):
|
||||
deps = _build_deps()
|
||||
|
||||
class _ExplodingMgr:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
def ensure_playlist(self, kind, variant, profile_id):
|
||||
self.calls.append(kind)
|
||||
if kind == 'broken':
|
||||
raise RuntimeError('manager boom')
|
||||
return SimpleNamespace(
|
||||
id=1, name=kind, kind=kind, variant=variant, is_stale=False,
|
||||
last_generated_at='2026-05-15T20:00:00',
|
||||
)
|
||||
def get_playlist_tracks(self, _id):
|
||||
return []
|
||||
|
||||
mgr = _ExplodingMgr()
|
||||
# broken raises, hidden_gems proceeds (just no tracks).
|
||||
payloads = _build_payloads_for_kinds(
|
||||
deps, mgr,
|
||||
[{'kind': 'broken'}, {'kind': 'hidden_gems'}],
|
||||
profile_id=1, automation_id=None, refresh_first=False,
|
||||
)
|
||||
assert mgr.calls == ['broken', 'hidden_gems']
|
||||
assert payloads == [] # neither produced tracks
|
||||
|
||||
|
||||
# ─── Sync launch ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSyncLaunch:
|
||||
def test_sync_one_playlist_starts_thread(self):
|
||||
captured: List[tuple] = []
|
||||
|
||||
def fake_run_sync_task(*args):
|
||||
captured.append(args)
|
||||
|
||||
deps = _build_deps(
|
||||
run_sync_task=fake_run_sync_task,
|
||||
get_current_profile_id=lambda: 7,
|
||||
)
|
||||
payload = {
|
||||
'sync_id': 'auto_personalized_hidden_gems_',
|
||||
'name': 'Hidden Gems',
|
||||
'tracks_json': [{'name': 'X', 'id': 'sp-1'}],
|
||||
'image_url': '',
|
||||
}
|
||||
result = _sync_personalized_playlist(deps, payload)
|
||||
assert result['status'] == 'started'
|
||||
# Wait for thread to invoke fake_run_sync_task.
|
||||
for _ in range(100):
|
||||
if captured:
|
||||
break
|
||||
import time
|
||||
time.sleep(0.01)
|
||||
assert len(captured) == 1
|
||||
# Args: (sync_id, name, tracks_json, automation_id, profile_id, image_url)
|
||||
assert captured[0][0] == 'auto_personalized_hidden_gems_'
|
||||
assert captured[0][1] == 'Hidden Gems'
|
||||
assert captured[0][3] is None # automation_id muted
|
||||
assert captured[0][4] == 7 # profile_id
|
||||
|
||||
|
||||
# ─── Full pipeline (with stubbed manager + sync states) ─────────────
|
||||
|
||||
|
||||
class TestPipelineHappyPath:
|
||||
def test_pipeline_completes_with_synced_count(self):
|
||||
# Stub manager returns one playlist with 2 tracks.
|
||||
manager = _StubManagerWithTracks(
|
||||
tracks_per_kind={'hidden_gems': [
|
||||
{'name': 'A', 'id': 'sp-1'},
|
||||
{'name': 'B', 'id': 'sp-2'},
|
||||
]},
|
||||
)
|
||||
|
||||
# sync_states populated as if the sync background task finished.
|
||||
sync_states_storage = {}
|
||||
|
||||
def fake_run_sync(sync_id, name, tracks, aid, pid, img):
|
||||
sync_states_storage[sync_id] = {
|
||||
'status': 'finished',
|
||||
'result': {'matched_tracks': 2},
|
||||
}
|
||||
|
||||
deps = _build_deps(
|
||||
build_personalized_manager=lambda: manager,
|
||||
run_sync_task=fake_run_sync,
|
||||
get_sync_states=lambda: sync_states_storage,
|
||||
)
|
||||
# Patch time.sleep in shared helper so test doesn't take 2s per iter.
|
||||
import core.automation.handlers._pipeline_shared as shared
|
||||
orig = shared.time.sleep
|
||||
shared.time.sleep = lambda _: None
|
||||
try:
|
||||
result = auto_personalized_pipeline(
|
||||
{'_automation_id': 'auto-1', 'kinds': [{'kind': 'hidden_gems'}]},
|
||||
deps,
|
||||
)
|
||||
finally:
|
||||
shared.time.sleep = orig
|
||||
assert result['status'] == 'completed'
|
||||
assert result['_manages_own_progress'] is True
|
||||
# Pipeline-running flag cleaned up.
|
||||
assert deps.state.pipeline_running is False
|
||||
400
tests/automation/test_handlers_playlist.py
Normal file
400
tests/automation/test_handlers_playlist.py
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
"""Boundary tests for the playlist-lifecycle automation handlers
|
||||
(``refresh_mirrored``, ``sync_playlist``, ``discover_playlist``,
|
||||
``playlist_pipeline``).
|
||||
|
||||
The handlers themselves are mechanical lifts of the closures that
|
||||
used to live in ``web_server._register_automation_handlers`` — these
|
||||
tests pin the seam so the wiring stays correct (deps are read from
|
||||
the deps object, not module-level globals; cross-handler calls in
|
||||
the pipeline still compose; failure paths still return clear status
|
||||
shapes).
|
||||
|
||||
Source-specific branches inside ``refresh_mirrored`` (Spotify auth
|
||||
+ public-embed fallback, Deezer / Tidal / YouTube) are validated
|
||||
end-to-end via fake clients in ``deps`` rather than per-source
|
||||
because they're a verbatim lift — drift would show up here as a
|
||||
behavior change."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from core.automation.deps import AutomationDeps, AutomationState
|
||||
from core.automation.handlers.discover_playlist import auto_discover_playlist
|
||||
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
|
||||
|
||||
|
||||
# ─── shared scaffolding ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class _StubLogger:
|
||||
def __init__(self):
|
||||
self.messages: List[tuple] = []
|
||||
|
||||
def debug(self, *a, **k): self.messages.append(('debug', a))
|
||||
def info(self, *a, **k): self.messages.append(('info', a))
|
||||
def warning(self, *a, **k): self.messages.append(('warning', a))
|
||||
def error(self, *a, **k): self.messages.append(('error', a))
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StubDB:
|
||||
"""Fake MusicDatabase — minimal surface used by the playlist handlers."""
|
||||
|
||||
playlists: List[dict] = field(default_factory=list)
|
||||
playlist_tracks: Dict[int, List[dict]] = field(default_factory=dict)
|
||||
extra_data_maps: Dict[int, Dict[str, str]] = field(default_factory=dict)
|
||||
mirror_calls: List[dict] = field(default_factory=list)
|
||||
|
||||
def get_mirrored_playlists(self) -> list:
|
||||
return list(self.playlists)
|
||||
|
||||
def get_mirrored_playlist(self, playlist_id: int) -> Optional[dict]:
|
||||
for p in self.playlists:
|
||||
if int(p.get('id', -1)) == int(playlist_id):
|
||||
return p
|
||||
return None
|
||||
|
||||
def get_mirrored_playlist_tracks(self, playlist_id: int) -> list:
|
||||
return list(self.playlist_tracks.get(int(playlist_id), []))
|
||||
|
||||
def get_mirrored_tracks_extra_data_map(self, playlist_id: int) -> dict:
|
||||
return dict(self.extra_data_maps.get(int(playlist_id), {}))
|
||||
|
||||
def mirror_playlist(self, **kwargs) -> None:
|
||||
self.mirror_calls.append(kwargs)
|
||||
|
||||
|
||||
def _build_deps(**overrides) -> AutomationDeps:
|
||||
defaults = dict(
|
||||
engine=object(),
|
||||
state=AutomationState(),
|
||||
config_manager=object(),
|
||||
update_progress=lambda *a, **k: None,
|
||||
logger=_StubLogger(),
|
||||
get_database=lambda: _StubDB(),
|
||||
spotify_client=None,
|
||||
tidal_client=None,
|
||||
web_scan_manager=None,
|
||||
process_wishlist_automatically=lambda **k: None,
|
||||
process_watchlist_scan_automatically=lambda **k: None,
|
||||
is_wishlist_actually_processing=lambda: False,
|
||||
is_watchlist_actually_scanning=lambda: False,
|
||||
get_watchlist_scan_state=lambda: {},
|
||||
run_playlist_discovery_worker=lambda *a, **k: None,
|
||||
run_sync_task=lambda *a, **k: None,
|
||||
load_sync_status_file=lambda: {},
|
||||
get_deezer_client=lambda: None,
|
||||
parse_youtube_playlist=lambda url: None,
|
||||
get_sync_states=lambda: {},
|
||||
set_db_update_automation_id=lambda v: None,
|
||||
get_db_update_state=lambda: {},
|
||||
db_update_lock=threading.Lock(),
|
||||
db_update_executor=None,
|
||||
run_db_update_task=lambda *a, **k: None,
|
||||
run_deep_scan_task=lambda *a, **k: None,
|
||||
get_duplicate_cleaner_state=lambda: {},
|
||||
duplicate_cleaner_lock=threading.Lock(),
|
||||
duplicate_cleaner_executor=None,
|
||||
run_duplicate_cleaner=lambda: None,
|
||||
get_quality_scanner_state=lambda: {},
|
||||
quality_scanner_lock=threading.Lock(),
|
||||
quality_scanner_executor=None,
|
||||
run_quality_scanner=lambda *a, **k: None,
|
||||
download_orchestrator=None,
|
||||
run_async=lambda coro: None,
|
||||
tasks_lock=threading.Lock(),
|
||||
get_download_batches=lambda: {},
|
||||
get_download_tasks=lambda: {},
|
||||
sweep_empty_download_directories=lambda: 0,
|
||||
get_staging_path=lambda: '/staging',
|
||||
docker_resolve_path=lambda p: p,
|
||||
get_current_profile_id=lambda: 1,
|
||||
get_watchlist_scanner=lambda spc: None,
|
||||
get_app=lambda: None,
|
||||
get_beatport_data_cache=lambda: {'cache_lock': threading.Lock(), 'homepage': {}},
|
||||
init_automation_progress=lambda *a, **k: None,
|
||||
record_progress_history=lambda *a, **k: None,
|
||||
build_personalized_manager=lambda: None,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return AutomationDeps(**defaults) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ─── discover_playlist ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDiscoverPlaylist:
|
||||
def test_no_playlist_id_returns_error(self):
|
||||
deps = _build_deps()
|
||||
result = auto_discover_playlist({}, deps)
|
||||
assert result == {'status': 'error', 'reason': 'No playlist specified'}
|
||||
|
||||
def test_specific_playlist_id_starts_worker(self):
|
||||
db = _StubDB(playlists=[{'id': 42, 'name': 'Test Playlist'}])
|
||||
called: List[Any] = []
|
||||
deps = _build_deps(
|
||||
get_database=lambda: db,
|
||||
run_playlist_discovery_worker=lambda *a, **k: called.append((a, k)),
|
||||
)
|
||||
result = auto_discover_playlist({'playlist_id': '42', '_automation_id': 'auto-1'}, deps)
|
||||
assert result['status'] == 'started'
|
||||
assert result['_manages_own_progress'] is True
|
||||
assert result['playlist_count'] == '1'
|
||||
# Worker spawned on a thread; give it a moment.
|
||||
for _ in range(50):
|
||||
if called:
|
||||
break
|
||||
import time
|
||||
time.sleep(0.01)
|
||||
assert len(called) == 1
|
||||
|
||||
def test_all_playlists_includes_every_one(self):
|
||||
db = _StubDB(playlists=[
|
||||
{'id': 1, 'name': 'A'}, {'id': 2, 'name': 'B'}, {'id': 3, 'name': 'C'},
|
||||
])
|
||||
deps = _build_deps(get_database=lambda: db)
|
||||
result = auto_discover_playlist({'all': True}, deps)
|
||||
assert result['playlist_count'] == '3'
|
||||
assert 'A' in result['playlists']
|
||||
assert 'B' in result['playlists']
|
||||
assert 'C' in result['playlists']
|
||||
|
||||
def test_no_playlists_in_db_returns_error(self):
|
||||
deps = _build_deps(get_database=lambda: _StubDB(playlists=[]))
|
||||
result = auto_discover_playlist({'all': True}, deps)
|
||||
assert result == {'status': 'error', 'reason': 'No playlists found'}
|
||||
|
||||
|
||||
# ─── refresh_mirrored ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StubSpotifyTrack:
|
||||
id: str
|
||||
name: str
|
||||
artists: list
|
||||
album: str
|
||||
duration_ms: int
|
||||
image_url: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StubSpotifyPlaylist:
|
||||
tracks: list
|
||||
|
||||
|
||||
class _StubSpotifyClient:
|
||||
def __init__(self, playlist):
|
||||
self._playlist = playlist
|
||||
self._authenticated = True
|
||||
|
||||
def is_spotify_authenticated(self) -> bool:
|
||||
return self._authenticated
|
||||
|
||||
def get_playlist_by_id(self, _source_id):
|
||||
return self._playlist
|
||||
|
||||
|
||||
class TestRefreshMirrored:
|
||||
def test_no_playlist_specified_returns_error(self):
|
||||
deps = _build_deps()
|
||||
result = auto_refresh_mirrored({}, deps)
|
||||
assert result == {'status': 'error', 'reason': 'No playlist specified'}
|
||||
|
||||
def test_filters_unrefreshable_sources(self):
|
||||
# Sources 'file' and 'beatport' have no API to refresh from.
|
||||
db = _StubDB(playlists=[
|
||||
{'id': 1, 'name': 'F', 'source': 'file', 'source_playlist_id': '1'},
|
||||
{'id': 2, 'name': 'B', 'source': 'beatport', 'source_playlist_id': '2'},
|
||||
])
|
||||
deps = _build_deps(get_database=lambda: db)
|
||||
result = auto_refresh_mirrored({'all': True}, deps)
|
||||
assert result['status'] == 'completed'
|
||||
assert result['refreshed'] == '0'
|
||||
assert db.mirror_calls == [] # nothing got pushed to DB
|
||||
|
||||
def test_spotify_refresh_writes_to_db(self):
|
||||
track = _StubSpotifyTrack(
|
||||
id='track123', name='Hello', artists=['Adele'],
|
||||
album='25', duration_ms=295000,
|
||||
)
|
||||
playlist = _StubSpotifyPlaylist(tracks=[track])
|
||||
spotify = _StubSpotifyClient(playlist)
|
||||
db = _StubDB(playlists=[
|
||||
{'id': 5, 'name': 'My Spot', 'source': 'spotify',
|
||||
'source_playlist_id': 'spot-id', 'profile_id': 1},
|
||||
])
|
||||
deps = _build_deps(get_database=lambda: db, spotify_client=spotify)
|
||||
result = auto_refresh_mirrored({'playlist_id': '5'}, deps)
|
||||
assert result['status'] == 'completed'
|
||||
assert result['refreshed'] == '1'
|
||||
assert len(db.mirror_calls) == 1
|
||||
call = db.mirror_calls[0]
|
||||
assert call['source'] == 'spotify'
|
||||
assert call['source_playlist_id'] == 'spot-id'
|
||||
assert call['name'] == 'My Spot'
|
||||
assert len(call['tracks']) == 1
|
||||
# Spotify-source tracks should be auto-marked discovered.
|
||||
extra = json.loads(call['tracks'][0]['extra_data'])
|
||||
assert extra['discovered'] is True
|
||||
assert extra['provider'] == 'spotify'
|
||||
assert extra['matched_data']['id'] == 'track123'
|
||||
|
||||
def test_per_playlist_exception_collected_into_errors(self):
|
||||
# Force an exception by making the DB blow up on mirror_playlist.
|
||||
class _ExplodingDB(_StubDB):
|
||||
def mirror_playlist(self, **kwargs):
|
||||
raise RuntimeError('db disk full')
|
||||
|
||||
track = _StubSpotifyTrack(id='t', name='t', artists=['a'], album='a', duration_ms=0)
|
||||
spotify = _StubSpotifyClient(_StubSpotifyPlaylist(tracks=[track]))
|
||||
db = _ExplodingDB(playlists=[
|
||||
{'id': 1, 'name': 'X', 'source': 'spotify', 'source_playlist_id': 'spot'},
|
||||
])
|
||||
deps = _build_deps(get_database=lambda: db, spotify_client=spotify)
|
||||
result = auto_refresh_mirrored({'all': True}, deps)
|
||||
# Error captured, status still 'completed' (handler returns counts).
|
||||
assert result['status'] == 'completed'
|
||||
assert result['errors'] == '1'
|
||||
assert result['refreshed'] == '0'
|
||||
|
||||
|
||||
# ─── sync_playlist ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSyncPlaylist:
|
||||
def test_no_playlist_id_returns_error(self):
|
||||
deps = _build_deps()
|
||||
result = auto_sync_playlist({}, deps)
|
||||
assert result == {'status': 'error', 'reason': 'No playlist specified'}
|
||||
|
||||
def test_playlist_not_found_returns_error(self):
|
||||
deps = _build_deps(get_database=lambda: _StubDB(playlists=[]))
|
||||
result = auto_sync_playlist({'playlist_id': '99'}, deps)
|
||||
assert result == {'status': 'error', 'reason': 'Playlist not found'}
|
||||
|
||||
def test_no_tracks_returns_error(self):
|
||||
db = _StubDB(playlists=[{'id': 1, 'name': 'P'}], playlist_tracks={1: []})
|
||||
deps = _build_deps(get_database=lambda: db)
|
||||
result = auto_sync_playlist({'playlist_id': '1'}, deps)
|
||||
assert result == {'status': 'error', 'reason': 'No tracks in playlist'}
|
||||
|
||||
def test_no_discovered_tracks_skips(self):
|
||||
# All tracks lack discovery + spotify_hint + valid IDs.
|
||||
db = _StubDB(
|
||||
playlists=[{'id': 1, 'name': 'P'}],
|
||||
playlist_tracks={1: [{}, {}]}, # empty tracks → nothing usable
|
||||
)
|
||||
deps = _build_deps(get_database=lambda: db)
|
||||
result = auto_sync_playlist({'playlist_id': '1'}, deps)
|
||||
assert result['status'] == 'skipped'
|
||||
assert 'No discovered tracks' in result['reason']
|
||||
assert result['skipped_tracks'] == '2'
|
||||
|
||||
def test_discovered_track_starts_sync_thread(self):
|
||||
discovered_track = {
|
||||
'extra_data': json.dumps({
|
||||
'discovered': True,
|
||||
'matched_data': {
|
||||
'id': 'spot-1', 'name': 'Track', 'artists': [{'name': 'X'}],
|
||||
'album': {'name': 'Album'}, 'duration_ms': 200000,
|
||||
},
|
||||
}),
|
||||
'artist_name': 'X',
|
||||
}
|
||||
db = _StubDB(
|
||||
playlists=[{'id': 1, 'name': 'P'}],
|
||||
playlist_tracks={1: [discovered_track]},
|
||||
)
|
||||
sync_calls: List[tuple] = []
|
||||
deps = _build_deps(
|
||||
get_database=lambda: db,
|
||||
run_sync_task=lambda *a, **k: sync_calls.append((a, k)),
|
||||
)
|
||||
result = auto_sync_playlist({'playlist_id': '1'}, deps)
|
||||
assert result['status'] == 'started'
|
||||
assert result['_manages_own_progress'] is True
|
||||
assert result['discovered_tracks'] == '1'
|
||||
# Wait for thread to fire run_sync_task
|
||||
for _ in range(50):
|
||||
if sync_calls:
|
||||
break
|
||||
import time
|
||||
time.sleep(0.01)
|
||||
assert len(sync_calls) == 1
|
||||
|
||||
def test_unchanged_since_last_sync_returns_skipped(self):
|
||||
discovered_track = {
|
||||
'extra_data': json.dumps({
|
||||
'discovered': True,
|
||||
'matched_data': {
|
||||
'id': 'spot-1', 'name': 'T', 'artists': [{'name': 'X'}],
|
||||
'album': {'name': 'A'}, 'duration_ms': 0,
|
||||
},
|
||||
}),
|
||||
'artist_name': 'X',
|
||||
}
|
||||
db = _StubDB(
|
||||
playlists=[{'id': 1, 'name': 'P'}],
|
||||
playlist_tracks={1: [discovered_track]},
|
||||
)
|
||||
|
||||
# Pre-populate the sync-status file with the EXPECTED hash so the
|
||||
# preflight short-circuit fires.
|
||||
import hashlib
|
||||
expected_hash = hashlib.md5('spot-1'.encode()).hexdigest()
|
||||
sync_statuses = {
|
||||
'auto_mirror_1': {'tracks_hash': expected_hash, 'matched_tracks': 1}
|
||||
}
|
||||
|
||||
deps = _build_deps(
|
||||
get_database=lambda: db,
|
||||
load_sync_status_file=lambda: sync_statuses,
|
||||
)
|
||||
result = auto_sync_playlist({'playlist_id': '1'}, deps)
|
||||
assert result['status'] == 'skipped'
|
||||
assert 'unchanged' in result['reason']
|
||||
|
||||
|
||||
# ─── playlist_pipeline ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPlaylistPipeline:
|
||||
def test_no_playlist_specified_returns_error(self):
|
||||
deps = _build_deps()
|
||||
result = auto_playlist_pipeline({}, deps)
|
||||
assert result == {'status': 'error', 'error': 'No playlist specified'}
|
||||
# Pipeline-running flag MUST be cleared on error so the guard
|
||||
# doesn't block subsequent triggers.
|
||||
assert deps.state.pipeline_running is False
|
||||
|
||||
def test_no_refreshable_playlists_clears_running_flag(self):
|
||||
db = _StubDB(playlists=[
|
||||
{'id': 1, 'name': 'F', 'source': 'file'},
|
||||
{'id': 2, 'name': 'B', 'source': 'beatport'},
|
||||
])
|
||||
deps = _build_deps(get_database=lambda: db)
|
||||
result = auto_playlist_pipeline({'all': True}, deps)
|
||||
assert result == {'status': 'error', 'error': 'No refreshable playlists found'}
|
||||
assert deps.state.pipeline_running is False
|
||||
|
||||
def test_pipeline_clears_running_on_unhandled_exception(self):
|
||||
# Force the database accessor to blow up after the early checks.
|
||||
class _ExplodingDB(_StubDB):
|
||||
def get_mirrored_playlists(self):
|
||||
raise RuntimeError('db down')
|
||||
|
||||
db = _ExplodingDB(playlists=[])
|
||||
deps = _build_deps(get_database=lambda: db)
|
||||
result = auto_playlist_pipeline({'all': True}, deps)
|
||||
assert result['status'] == 'error'
|
||||
assert result['_manages_own_progress'] is True
|
||||
assert deps.state.pipeline_running is False
|
||||
329
tests/automation/test_handlers_simple.py
Normal file
329
tests/automation/test_handlers_simple.py
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
"""Boundary tests for the simple extracted automation handlers
|
||||
(``process_wishlist``, ``scan_watchlist``, ``scan_library``).
|
||||
|
||||
Each handler is tested as a pure function: real ``AutomationDeps``
|
||||
constructed with stub callables, no Flask, no DB, no media-server
|
||||
clients. The tests exercise the success path, the guard paths
|
||||
(handler short-circuits when another instance is running), the
|
||||
exception-swallowing contract (handlers must NEVER raise into the
|
||||
engine), and the mutable-state machinery for handlers that own a
|
||||
flag in ``AutomationState``.
|
||||
|
||||
Pre-extraction these closures lived inside
|
||||
``web_server._register_automation_handlers`` and were essentially
|
||||
un-testable — every test would have needed to spin up the whole
|
||||
Flask app and stub a dozen module-level globals."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, List, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from core.automation.deps import AutomationDeps, AutomationState
|
||||
from core.automation.handlers.process_wishlist import auto_process_wishlist
|
||||
from core.automation.handlers.scan_watchlist import auto_scan_watchlist
|
||||
from core.automation.handlers.scan_library import auto_scan_library
|
||||
|
||||
|
||||
# ─── shared test scaffolding ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_deps(**overrides: Any) -> AutomationDeps:
|
||||
"""Return a default `AutomationDeps` with no-op callables. Tests
|
||||
pass ``overrides`` to install behaviour on the specific deps they
|
||||
care about."""
|
||||
|
||||
class _StubLogger:
|
||||
def debug(self, *_a, **_k): pass
|
||||
def info(self, *_a, **_k): pass
|
||||
def warning(self, *_a, **_k): pass
|
||||
def error(self, *_a, **_k): pass
|
||||
|
||||
defaults = dict(
|
||||
engine=object(),
|
||||
state=AutomationState(),
|
||||
config_manager=object(),
|
||||
update_progress=lambda *a, **k: None,
|
||||
logger=_StubLogger(),
|
||||
get_database=lambda: object(),
|
||||
spotify_client=None,
|
||||
tidal_client=None,
|
||||
web_scan_manager=None,
|
||||
process_wishlist_automatically=lambda **k: None,
|
||||
process_watchlist_scan_automatically=lambda **k: None,
|
||||
is_wishlist_actually_processing=lambda: False,
|
||||
is_watchlist_actually_scanning=lambda: False,
|
||||
get_watchlist_scan_state=lambda: {},
|
||||
run_playlist_discovery_worker=lambda *a, **k: None,
|
||||
run_sync_task=lambda *a, **k: None,
|
||||
load_sync_status_file=lambda: {},
|
||||
get_deezer_client=lambda: None,
|
||||
parse_youtube_playlist=lambda url: None,
|
||||
get_sync_states=lambda: {},
|
||||
set_db_update_automation_id=lambda v: None,
|
||||
get_db_update_state=lambda: {},
|
||||
db_update_lock=threading.Lock(),
|
||||
db_update_executor=None,
|
||||
run_db_update_task=lambda *a, **k: None,
|
||||
run_deep_scan_task=lambda *a, **k: None,
|
||||
get_duplicate_cleaner_state=lambda: {},
|
||||
duplicate_cleaner_lock=threading.Lock(),
|
||||
duplicate_cleaner_executor=None,
|
||||
run_duplicate_cleaner=lambda: None,
|
||||
get_quality_scanner_state=lambda: {},
|
||||
quality_scanner_lock=threading.Lock(),
|
||||
quality_scanner_executor=None,
|
||||
run_quality_scanner=lambda *a, **k: None,
|
||||
download_orchestrator=None,
|
||||
run_async=lambda coro: None,
|
||||
tasks_lock=threading.Lock(),
|
||||
get_download_batches=lambda: {},
|
||||
get_download_tasks=lambda: {},
|
||||
sweep_empty_download_directories=lambda: 0,
|
||||
get_staging_path=lambda: '/staging',
|
||||
docker_resolve_path=lambda p: p,
|
||||
get_current_profile_id=lambda: 1,
|
||||
get_watchlist_scanner=lambda spc: None,
|
||||
get_app=lambda: None,
|
||||
get_beatport_data_cache=lambda: {'cache_lock': threading.Lock(), 'homepage': {}},
|
||||
init_automation_progress=lambda *a, **k: None,
|
||||
record_progress_history=lambda *a, **k: None,
|
||||
build_personalized_manager=lambda: None,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return AutomationDeps(**defaults) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ─── process_wishlist ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestProcessWishlist:
|
||||
def test_success_returns_completed_status(self):
|
||||
called: List[Any] = []
|
||||
|
||||
def stub(automation_id=None):
|
||||
called.append(automation_id)
|
||||
|
||||
deps = _build_deps(process_wishlist_automatically=stub)
|
||||
result = auto_process_wishlist({'_automation_id': 'auto-1'}, deps)
|
||||
assert result == {'status': 'completed'}
|
||||
assert called == ['auto-1']
|
||||
|
||||
def test_passes_none_when_no_automation_id(self):
|
||||
called: List[Any] = []
|
||||
|
||||
def stub(automation_id=None):
|
||||
called.append(automation_id)
|
||||
|
||||
deps = _build_deps(process_wishlist_automatically=stub)
|
||||
result = auto_process_wishlist({}, deps)
|
||||
assert result == {'status': 'completed'}
|
||||
assert called == [None]
|
||||
|
||||
def test_handler_swallows_exceptions(self):
|
||||
def stub(**_kwargs):
|
||||
raise RuntimeError('boom')
|
||||
|
||||
deps = _build_deps(process_wishlist_automatically=stub)
|
||||
result = auto_process_wishlist({'_automation_id': 'a'}, deps)
|
||||
assert result == {'status': 'error', 'error': 'boom'}
|
||||
|
||||
|
||||
# ─── scan_watchlist ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestScanWatchlist:
|
||||
def test_fresh_scan_reports_summary_stats(self):
|
||||
# Worker reassigns the state dict mid-run — handler detects
|
||||
# via id() change and reports stats.
|
||||
states = [
|
||||
{'summary': {}},
|
||||
{'summary': {
|
||||
'total_artists': 5,
|
||||
'successful_scans': 4,
|
||||
'new_tracks_found': 12,
|
||||
'tracks_added_to_wishlist': 8,
|
||||
}},
|
||||
]
|
||||
idx = {'i': 0}
|
||||
|
||||
def get_state():
|
||||
return states[idx['i']]
|
||||
|
||||
def stub(**_kwargs):
|
||||
idx['i'] = 1 # simulate the worker swapping the dict
|
||||
|
||||
deps = _build_deps(
|
||||
process_watchlist_scan_automatically=stub,
|
||||
get_watchlist_scan_state=get_state,
|
||||
)
|
||||
result = auto_scan_watchlist({}, deps)
|
||||
assert result == {
|
||||
'status': 'completed',
|
||||
'artists_scanned': 5,
|
||||
'successful_scans': 4,
|
||||
'new_tracks_found': 12,
|
||||
'tracks_added_to_wishlist': 8,
|
||||
}
|
||||
|
||||
def test_no_fresh_scan_returns_bare_completed(self):
|
||||
# Same dict identity before and after = no fresh scan ran.
|
||||
same_dict = {'summary': {'total_artists': 999}}
|
||||
deps = _build_deps(
|
||||
process_watchlist_scan_automatically=lambda **_k: None,
|
||||
get_watchlist_scan_state=lambda: same_dict,
|
||||
)
|
||||
result = auto_scan_watchlist({}, deps)
|
||||
assert result == {'status': 'completed'}
|
||||
|
||||
def test_handler_swallows_exceptions(self):
|
||||
def stub(**_kwargs):
|
||||
raise ValueError('no scanner')
|
||||
|
||||
deps = _build_deps(process_watchlist_scan_automatically=stub)
|
||||
result = auto_scan_watchlist({}, deps)
|
||||
assert result == {'status': 'error', 'error': 'no scanner'}
|
||||
|
||||
|
||||
# ─── scan_library ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StubScanManager:
|
||||
"""Minimal fake of ``web_scan_manager`` — records calls + lets
|
||||
tests script its responses."""
|
||||
|
||||
request_responses: List[dict] = field(default_factory=list)
|
||||
status_responses: List[dict] = field(default_factory=list)
|
||||
request_calls: List[str] = field(default_factory=list)
|
||||
|
||||
def request_scan(self, label: str) -> dict:
|
||||
self.request_calls.append(label)
|
||||
return self.request_responses.pop(0) if self.request_responses else {'status': 'queued'}
|
||||
|
||||
def get_scan_status(self) -> dict:
|
||||
return self.status_responses.pop(0) if self.status_responses else {'status': 'idle'}
|
||||
|
||||
|
||||
class TestScanLibrary:
|
||||
def test_no_scan_manager_returns_error(self):
|
||||
deps = _build_deps(web_scan_manager=None)
|
||||
result = auto_scan_library({'_automation_id': 'a'}, deps)
|
||||
assert result == {'status': 'error', 'reason': 'Scan manager not available'}
|
||||
|
||||
def test_already_tracked_returns_skipped(self):
|
||||
# Pre-set the state flag — handler should short-circuit.
|
||||
state = AutomationState()
|
||||
state.scan_library_automation_id = 'someone-else'
|
||||
scanner = _StubScanManager(request_responses=[{'status': 'queued'}])
|
||||
deps = _build_deps(state=state, web_scan_manager=scanner)
|
||||
result = auto_scan_library({'_automation_id': 'a'}, deps)
|
||||
assert result == {'status': 'skipped', 'reason': 'Scan already being tracked'}
|
||||
assert scanner.request_calls == ['Automation trigger (additional batch)']
|
||||
|
||||
def test_scan_completes_normally(self):
|
||||
# request_scan returns scheduled; first poll = scheduled;
|
||||
# second poll = scanning; third poll = idle.
|
||||
scanner = _StubScanManager(
|
||||
request_responses=[{'status': 'scheduled', 'delay_seconds': 5}],
|
||||
status_responses=[
|
||||
{'status': 'scheduled'},
|
||||
{'status': 'scanning', 'elapsed_seconds': 10, 'max_time_seconds': 100},
|
||||
{'status': 'idle'},
|
||||
],
|
||||
)
|
||||
progress: List[dict] = []
|
||||
|
||||
def stub_progress(automation_id, **kwargs):
|
||||
progress.append({'aid': automation_id, **kwargs})
|
||||
|
||||
deps = _build_deps(
|
||||
web_scan_manager=scanner,
|
||||
update_progress=stub_progress,
|
||||
)
|
||||
# Patch time.sleep so the test runs instantly.
|
||||
import core.automation.handlers.scan_library as module
|
||||
original = module.time.sleep
|
||||
module.time.sleep = lambda _: None
|
||||
try:
|
||||
result = auto_scan_library({'_automation_id': 'auto-1'}, deps)
|
||||
finally:
|
||||
module.time.sleep = original
|
||||
|
||||
assert result['status'] == 'completed'
|
||||
assert result.get('_manages_own_progress') is True
|
||||
# State flag cleaned up after run
|
||||
assert deps.state.scan_library_automation_id is None
|
||||
# Progress phases emitted: scheduled, scan-start, scanning, completed
|
||||
statuses = [p.get('status') for p in progress]
|
||||
assert 'finished' in statuses
|
||||
|
||||
def test_state_cleanup_on_exception(self):
|
||||
class ExplodingScanner:
|
||||
def request_scan(self, _):
|
||||
raise RuntimeError('boom')
|
||||
|
||||
def get_scan_status(self):
|
||||
return {'status': 'idle'}
|
||||
|
||||
progress: List[dict] = []
|
||||
deps = _build_deps(
|
||||
web_scan_manager=ExplodingScanner(),
|
||||
update_progress=lambda aid, **kw: progress.append({'aid': aid, **kw}),
|
||||
)
|
||||
result = auto_scan_library({'_automation_id': 'auto-x'}, deps)
|
||||
assert result['status'] == 'error'
|
||||
assert result['_manages_own_progress'] is True
|
||||
# State flag still cleaned up
|
||||
assert deps.state.scan_library_automation_id is None
|
||||
# Error progress emitted
|
||||
assert any(p.get('status') == 'error' for p in progress)
|
||||
|
||||
|
||||
# ─── AutomationState ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAutomationState:
|
||||
def test_default_state(self):
|
||||
s = AutomationState()
|
||||
assert s.scan_library_automation_id is None
|
||||
assert s.db_update_automation_id is None
|
||||
assert s.pipeline_running is False
|
||||
assert s.is_scan_library_active() is False
|
||||
assert s.is_pipeline_running() is False
|
||||
|
||||
def test_set_scan_library_id(self):
|
||||
s = AutomationState()
|
||||
s.set_scan_library_id('auto-1')
|
||||
assert s.scan_library_automation_id == 'auto-1'
|
||||
assert s.is_scan_library_active() is True
|
||||
s.set_scan_library_id(None)
|
||||
assert s.is_scan_library_active() is False
|
||||
|
||||
def test_set_pipeline_running(self):
|
||||
s = AutomationState()
|
||||
s.set_pipeline_running(True)
|
||||
assert s.is_pipeline_running() is True
|
||||
s.set_pipeline_running(False)
|
||||
assert s.is_pipeline_running() is False
|
||||
|
||||
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.
|
||||
s = AutomationState()
|
||||
|
||||
def worker():
|
||||
for _ in range(100):
|
||||
s.set_pipeline_running(True)
|
||||
s.set_pipeline_running(False)
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(4)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
assert s.pipeline_running is False
|
||||
244
tests/automation/test_progress_callbacks.py
Normal file
244
tests/automation/test_progress_callbacks.py
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
"""Boundary tests for the progress + history callbacks extracted
|
||||
from ``web_server._register_automation_handlers``.
|
||||
|
||||
The callbacks are wired by the engine via ``register_progress_callbacks``;
|
||||
each test invokes the extracted top-level function with stub deps
|
||||
and verifies the right downstream call fires."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from core.automation.deps import AutomationDeps, AutomationState
|
||||
from core.automation.handlers.progress_callbacks import (
|
||||
progress_init,
|
||||
progress_finish,
|
||||
record_history,
|
||||
on_library_scan_completed,
|
||||
register_library_scan_completed_emitter,
|
||||
)
|
||||
|
||||
|
||||
def _build_deps(**overrides) -> AutomationDeps:
|
||||
class _StubLogger:
|
||||
def debug(self, *a, **k): pass
|
||||
def info(self, *a, **k): pass
|
||||
def warning(self, *a, **k): pass
|
||||
def error(self, *a, **k): pass
|
||||
|
||||
defaults = dict(
|
||||
engine=object(),
|
||||
state=AutomationState(),
|
||||
config_manager=object(),
|
||||
update_progress=lambda *a, **k: None,
|
||||
logger=_StubLogger(),
|
||||
get_database=lambda: object(),
|
||||
spotify_client=None,
|
||||
tidal_client=None,
|
||||
web_scan_manager=None,
|
||||
process_wishlist_automatically=lambda **k: None,
|
||||
process_watchlist_scan_automatically=lambda **k: None,
|
||||
is_wishlist_actually_processing=lambda: False,
|
||||
is_watchlist_actually_scanning=lambda: False,
|
||||
get_watchlist_scan_state=lambda: {},
|
||||
run_playlist_discovery_worker=lambda *a, **k: None,
|
||||
run_sync_task=lambda *a, **k: None,
|
||||
load_sync_status_file=lambda: {},
|
||||
get_deezer_client=lambda: None,
|
||||
parse_youtube_playlist=lambda url: None,
|
||||
get_sync_states=lambda: {},
|
||||
set_db_update_automation_id=lambda v: None,
|
||||
get_db_update_state=lambda: {},
|
||||
db_update_lock=threading.Lock(),
|
||||
db_update_executor=None,
|
||||
run_db_update_task=lambda *a, **k: None,
|
||||
run_deep_scan_task=lambda *a, **k: None,
|
||||
get_duplicate_cleaner_state=lambda: {},
|
||||
duplicate_cleaner_lock=threading.Lock(),
|
||||
duplicate_cleaner_executor=None,
|
||||
run_duplicate_cleaner=lambda: None,
|
||||
get_quality_scanner_state=lambda: {},
|
||||
quality_scanner_lock=threading.Lock(),
|
||||
quality_scanner_executor=None,
|
||||
run_quality_scanner=lambda *a, **k: None,
|
||||
download_orchestrator=None,
|
||||
run_async=lambda coro: None,
|
||||
tasks_lock=threading.Lock(),
|
||||
get_download_batches=lambda: {},
|
||||
get_download_tasks=lambda: {},
|
||||
sweep_empty_download_directories=lambda: 0,
|
||||
get_staging_path=lambda: '/staging',
|
||||
docker_resolve_path=lambda p: p,
|
||||
get_current_profile_id=lambda: 1,
|
||||
get_watchlist_scanner=lambda spc: None,
|
||||
get_app=lambda: None,
|
||||
get_beatport_data_cache=lambda: {'cache_lock': threading.Lock(), 'homepage': {}},
|
||||
init_automation_progress=lambda *a, **k: None,
|
||||
record_progress_history=lambda *a, **k: None,
|
||||
build_personalized_manager=lambda: None,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return AutomationDeps(**defaults) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ─── progress_init ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestProgressInit:
|
||||
def test_forwards_to_init_automation_progress(self):
|
||||
captured: List[Tuple] = []
|
||||
|
||||
def fake(aid, name, action_type):
|
||||
captured.append((aid, name, action_type))
|
||||
|
||||
deps = _build_deps(init_automation_progress=fake)
|
||||
progress_init('auto-1', 'My Auto', 'wishlist', deps)
|
||||
assert captured == [('auto-1', 'My Auto', 'wishlist')]
|
||||
|
||||
|
||||
# ─── progress_finish ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestProgressFinish:
|
||||
def test_skips_when_handler_manages_own_progress(self):
|
||||
# Handler set the flag — engine callback must NOT emit a
|
||||
# second 'finished' over the top of the handler's own.
|
||||
calls: List[Dict] = []
|
||||
deps = _build_deps(update_progress=lambda *a, **k: calls.append({'a': a, 'k': k}))
|
||||
progress_finish('auto-1', {'_manages_own_progress': True, 'status': 'completed'}, deps)
|
||||
assert calls == []
|
||||
|
||||
def test_completed_emits_finished_status(self):
|
||||
calls: List[Dict] = []
|
||||
deps = _build_deps(update_progress=lambda aid, **kw: calls.append({'aid': aid, **kw}))
|
||||
progress_finish('auto-1', {'status': 'completed'}, deps)
|
||||
assert len(calls) == 1
|
||||
assert calls[0]['aid'] == 'auto-1'
|
||||
assert calls[0]['status'] == 'finished'
|
||||
assert calls[0]['progress'] == 100
|
||||
assert calls[0]['phase'] == 'Complete'
|
||||
assert calls[0]['log_type'] == 'success'
|
||||
|
||||
def test_error_status_emits_error_phase(self):
|
||||
calls: List[Dict] = []
|
||||
deps = _build_deps(update_progress=lambda aid, **kw: calls.append({'aid': aid, **kw}))
|
||||
progress_finish('auto-1', {'status': 'error', 'error': 'boom'}, deps)
|
||||
assert calls[0]['status'] == 'error'
|
||||
assert calls[0]['phase'] == 'Error'
|
||||
assert calls[0]['log_line'] == 'boom'
|
||||
assert calls[0]['log_type'] == 'error'
|
||||
|
||||
def test_msg_falls_back_through_keys(self):
|
||||
# error -> reason -> status -> 'done'
|
||||
calls: List[Dict] = []
|
||||
deps = _build_deps(update_progress=lambda aid, **kw: calls.append({'aid': aid, **kw}))
|
||||
progress_finish('auto-1', {'status': 'completed', 'reason': 'all good'}, deps)
|
||||
assert calls[0]['log_line'] == 'all good'
|
||||
|
||||
def test_msg_default_done(self):
|
||||
calls: List[Dict] = []
|
||||
deps = _build_deps(update_progress=lambda aid, **kw: calls.append({'aid': aid, **kw}))
|
||||
progress_finish('auto-1', {}, deps)
|
||||
assert calls[0]['log_line'] == 'done'
|
||||
|
||||
|
||||
# ─── record_history ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRecordHistory:
|
||||
def test_passes_db_to_recorder(self):
|
||||
captured: List[Tuple] = []
|
||||
db_obj = object()
|
||||
deps = _build_deps(
|
||||
get_database=lambda: db_obj,
|
||||
record_progress_history=lambda aid, result, db: captured.append((aid, result, db)),
|
||||
)
|
||||
record_history('auto-1', {'status': 'completed'}, deps)
|
||||
assert captured == [('auto-1', {'status': 'completed'}, db_obj)]
|
||||
|
||||
|
||||
# ─── on_library_scan_completed ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestOnLibraryScanCompleted:
|
||||
def test_no_engine_skips(self):
|
||||
deps = _build_deps(engine=None)
|
||||
# Should not raise.
|
||||
on_library_scan_completed(deps)
|
||||
|
||||
def test_emits_event_with_server_type(self):
|
||||
emits: List[Tuple] = []
|
||||
|
||||
class _Engine:
|
||||
def emit(self, name, payload):
|
||||
emits.append((name, payload))
|
||||
|
||||
class _ScanMgr:
|
||||
_current_server_type = 'plex'
|
||||
|
||||
deps = _build_deps(engine=_Engine(), web_scan_manager=_ScanMgr())
|
||||
on_library_scan_completed(deps)
|
||||
assert emits == [('library_scan_completed', {'server_type': 'plex'})]
|
||||
|
||||
def test_unknown_server_type_when_attr_missing(self):
|
||||
emits: List[Tuple] = []
|
||||
|
||||
class _Engine:
|
||||
def emit(self, name, payload):
|
||||
emits.append((name, payload))
|
||||
|
||||
deps = _build_deps(engine=_Engine(), web_scan_manager=object())
|
||||
on_library_scan_completed(deps)
|
||||
assert emits[0][1] == {'server_type': 'unknown'}
|
||||
|
||||
|
||||
# ─── register_library_scan_completed_emitter ─────────────────────────
|
||||
|
||||
|
||||
class TestRegisterEmitter:
|
||||
def test_no_scan_manager_noop(self):
|
||||
# No web_scan_manager → no callback registered, no error.
|
||||
deps = _build_deps(web_scan_manager=None)
|
||||
register_library_scan_completed_emitter(deps)
|
||||
|
||||
def test_registers_callback_with_scan_manager(self):
|
||||
callbacks: List = []
|
||||
|
||||
class _ScanMgr:
|
||||
_current_server_type = 'plex'
|
||||
def add_scan_completion_callback(self, cb):
|
||||
callbacks.append(cb)
|
||||
|
||||
deps = _build_deps(web_scan_manager=_ScanMgr())
|
||||
register_library_scan_completed_emitter(deps)
|
||||
assert len(callbacks) == 1
|
||||
# The registered callback must invoke without args (web_scan_manager
|
||||
# calls completion callbacks with no params).
|
||||
# Verify it does fire on_library_scan_completed when invoked.
|
||||
emits: List = []
|
||||
|
||||
class _Engine:
|
||||
def emit(self, name, payload):
|
||||
emits.append((name, payload))
|
||||
|
||||
deps2 = _build_deps(engine=_Engine(), web_scan_manager=_ScanMgr())
|
||||
register_library_scan_completed_emitter(deps2)
|
||||
# The lambda captured deps2; we need to grab the registered
|
||||
# callback to invoke it. Re-register and capture.
|
||||
captured = []
|
||||
class _Mgr2:
|
||||
_current_server_type = 'jellyfin'
|
||||
def add_scan_completion_callback(self, cb):
|
||||
captured.append(cb)
|
||||
deps3 = _build_deps(engine=_Engine(), web_scan_manager=_Mgr2())
|
||||
emits3 = []
|
||||
deps3 = _build_deps(
|
||||
engine=type('E', (), {'emit': lambda self, n, p: emits3.append((n, p))})(),
|
||||
web_scan_manager=_Mgr2(),
|
||||
)
|
||||
register_library_scan_completed_emitter(deps3)
|
||||
captured[0]() # invoke the registered callback
|
||||
assert emits3 == [('library_scan_completed', {'server_type': 'jellyfin'})]
|
||||
231
tests/matching/test_version_mismatch.py
Normal file
231
tests/matching/test_version_mismatch.py
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
"""Boundary tests for ``core.matching.version_mismatch``.
|
||||
|
||||
Pin every shape the version-mismatch escape valve has to handle so
|
||||
future drift fails here instead of at runtime against a real download:
|
||||
one-sided bare cases (the live-recording MB-metadata-gap that issue
|
||||
#607 reported), two-sided real mismatches (live vs remix — keep
|
||||
strict), high vs low fingerprint score gates, title/artist threshold
|
||||
gates, defensive paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from core.matching.version_mismatch import is_acceptable_version_mismatch
|
||||
|
||||
|
||||
class TestEqualVersions:
|
||||
def test_same_version_trivially_accepted(self):
|
||||
# Equal version strings — no mismatch to decide. True.
|
||||
assert is_acceptable_version_mismatch(
|
||||
'live', 'live',
|
||||
fingerprint_score=0.0,
|
||||
title_similarity=0.0,
|
||||
artist_similarity=0.0,
|
||||
) is True
|
||||
|
||||
def test_both_original_accepted(self):
|
||||
assert is_acceptable_version_mismatch(
|
||||
'original', 'original',
|
||||
fingerprint_score=0.0,
|
||||
title_similarity=0.0,
|
||||
artist_similarity=0.0,
|
||||
) is True
|
||||
|
||||
|
||||
class TestOneSidedBareMismatch:
|
||||
"""Issue #607 example 2: expected has annotation, AcoustID's MB
|
||||
record is bare. Accept when fingerprint + bare titles + artist
|
||||
all line up."""
|
||||
|
||||
def test_live_vs_original_high_confidence_accepted(self):
|
||||
# Reporter's exact case: "Clarity (Live at ...)" vs "Clarity"
|
||||
assert is_acceptable_version_mismatch(
|
||||
'live', 'original',
|
||||
fingerprint_score=0.95,
|
||||
title_similarity=0.95,
|
||||
artist_similarity=1.0,
|
||||
) is True
|
||||
|
||||
def test_original_vs_live_high_confidence_accepted(self):
|
||||
# Same case in the other direction.
|
||||
assert is_acceptable_version_mismatch(
|
||||
'original', 'live',
|
||||
fingerprint_score=0.95,
|
||||
title_similarity=0.95,
|
||||
artist_similarity=1.0,
|
||||
) is True
|
||||
|
||||
def test_live_at_thresholds_accepted(self):
|
||||
# Exactly at the thresholds for the live-aware case.
|
||||
assert is_acceptable_version_mismatch(
|
||||
'live', 'original',
|
||||
fingerprint_score=0.85,
|
||||
title_similarity=0.70,
|
||||
artist_similarity=0.60,
|
||||
) is True
|
||||
|
||||
|
||||
class TestNonLiveOneSidedMismatchStaysStrict:
|
||||
"""Other version markers (instrumental / remix / acoustic / etc)
|
||||
have distinct fingerprints AND MB always annotates them in the
|
||||
recording title. When AcoustID returns one of these for a bare
|
||||
expected (or vice versa), the file genuinely IS that version —
|
||||
the user asked for the wrong cut. Reject regardless of how high
|
||||
the supporting scores are.
|
||||
|
||||
This narrowness is what keeps the existing
|
||||
test_acoustid_version_mismatch suite passing — instrumental
|
||||
vs vocal etc. stays a real mismatch."""
|
||||
|
||||
def test_remix_vs_original_rejected_at_high_confidence(self):
|
||||
assert is_acceptable_version_mismatch(
|
||||
'remix', 'original',
|
||||
fingerprint_score=0.99,
|
||||
title_similarity=0.99,
|
||||
artist_similarity=0.99,
|
||||
) is False
|
||||
|
||||
def test_instrumental_vs_original_rejected_at_high_confidence(self):
|
||||
# The exact case test_acoustid_version_mismatch.py:
|
||||
# test_instrumental_returned_for_vocal_request_fails pins —
|
||||
# vocal asked, instrumental returned, must FAIL.
|
||||
assert is_acceptable_version_mismatch(
|
||||
'instrumental', 'original',
|
||||
fingerprint_score=0.99,
|
||||
title_similarity=0.99,
|
||||
artist_similarity=0.99,
|
||||
) is False
|
||||
|
||||
def test_original_vs_instrumental_rejected_at_high_confidence(self):
|
||||
# Reverse direction: caller asked for vocal, file is
|
||||
# instrumental.
|
||||
assert is_acceptable_version_mismatch(
|
||||
'original', 'instrumental',
|
||||
fingerprint_score=0.99,
|
||||
title_similarity=0.99,
|
||||
artist_similarity=0.99,
|
||||
) is False
|
||||
|
||||
def test_acoustic_vs_original_rejected_at_high_confidence(self):
|
||||
assert is_acceptable_version_mismatch(
|
||||
'acoustic', 'original',
|
||||
fingerprint_score=0.99,
|
||||
title_similarity=0.99,
|
||||
artist_similarity=0.99,
|
||||
) is False
|
||||
|
||||
def test_demo_vs_original_rejected(self):
|
||||
assert is_acceptable_version_mismatch(
|
||||
'demo', 'original',
|
||||
fingerprint_score=0.99,
|
||||
title_similarity=0.99,
|
||||
artist_similarity=0.99,
|
||||
) is False
|
||||
|
||||
|
||||
class TestTwoSidedMismatchStaysStrict:
|
||||
"""Both sides have version markers but they disagree. Real
|
||||
different-recording mismatch — must reject regardless of how
|
||||
high the other scores are."""
|
||||
|
||||
def test_live_vs_remix_rejected_even_at_max(self):
|
||||
assert is_acceptable_version_mismatch(
|
||||
'live', 'remix',
|
||||
fingerprint_score=1.0,
|
||||
title_similarity=1.0,
|
||||
artist_similarity=1.0,
|
||||
) is False
|
||||
|
||||
def test_acoustic_vs_instrumental_rejected(self):
|
||||
assert is_acceptable_version_mismatch(
|
||||
'acoustic', 'instrumental',
|
||||
fingerprint_score=0.99,
|
||||
title_similarity=0.99,
|
||||
artist_similarity=0.99,
|
||||
) is False
|
||||
|
||||
def test_live_vs_acoustic_rejected(self):
|
||||
assert is_acceptable_version_mismatch(
|
||||
'live', 'acoustic',
|
||||
fingerprint_score=0.95,
|
||||
title_similarity=0.90,
|
||||
artist_similarity=1.0,
|
||||
) is False
|
||||
|
||||
|
||||
class TestThresholdGates:
|
||||
"""One-sided + bare but one of the supporting signals is too weak.
|
||||
Reject — fall through to FAIL."""
|
||||
|
||||
def test_low_fingerprint_score_rejected(self):
|
||||
# Fingerprint score below threshold. Don't trust it enough.
|
||||
assert is_acceptable_version_mismatch(
|
||||
'live', 'original',
|
||||
fingerprint_score=0.50,
|
||||
title_similarity=0.95,
|
||||
artist_similarity=1.0,
|
||||
) is False
|
||||
|
||||
def test_low_title_similarity_rejected(self):
|
||||
# Bare titles disagree → different songs, not just MB metadata gap.
|
||||
assert is_acceptable_version_mismatch(
|
||||
'live', 'original',
|
||||
fingerprint_score=0.95,
|
||||
title_similarity=0.30,
|
||||
artist_similarity=1.0,
|
||||
) is False
|
||||
|
||||
def test_low_artist_similarity_rejected(self):
|
||||
# Wrong artist — definitely not the same recording.
|
||||
assert is_acceptable_version_mismatch(
|
||||
'live', 'original',
|
||||
fingerprint_score=0.95,
|
||||
title_similarity=0.95,
|
||||
artist_similarity=0.20,
|
||||
) is False
|
||||
|
||||
def test_just_below_score_threshold_rejected(self):
|
||||
assert is_acceptable_version_mismatch(
|
||||
'live', 'original',
|
||||
fingerprint_score=0.849, # default threshold 0.85
|
||||
title_similarity=0.95,
|
||||
artist_similarity=1.0,
|
||||
) is False
|
||||
|
||||
def test_just_below_title_threshold_rejected(self):
|
||||
assert is_acceptable_version_mismatch(
|
||||
'live', 'original',
|
||||
fingerprint_score=0.95,
|
||||
title_similarity=0.699, # default threshold 0.70
|
||||
artist_similarity=1.0,
|
||||
) is False
|
||||
|
||||
def test_just_below_artist_threshold_rejected(self):
|
||||
assert is_acceptable_version_mismatch(
|
||||
'live', 'original',
|
||||
fingerprint_score=0.95,
|
||||
title_similarity=0.95,
|
||||
artist_similarity=0.599, # default threshold 0.60
|
||||
) is False
|
||||
|
||||
|
||||
class TestCustomThresholds:
|
||||
def test_custom_score_threshold_accepts_when_loosened(self):
|
||||
assert is_acceptable_version_mismatch(
|
||||
'live', 'original',
|
||||
fingerprint_score=0.70,
|
||||
title_similarity=0.95,
|
||||
artist_similarity=1.0,
|
||||
score_threshold=0.65,
|
||||
) is True
|
||||
|
||||
def test_custom_score_threshold_rejects_when_tightened(self):
|
||||
assert is_acceptable_version_mismatch(
|
||||
'live', 'original',
|
||||
fingerprint_score=0.90,
|
||||
title_similarity=0.95,
|
||||
artist_similarity=1.0,
|
||||
score_threshold=0.95,
|
||||
) is False
|
||||
|
|
@ -31,6 +31,7 @@ EXPECTED_SOURCE_ID_FIELD = {
|
|||
"discogs": "discogs_id",
|
||||
"hydrabase": "soul_id",
|
||||
"musicbrainz": "musicbrainz_id",
|
||||
"amazon": "amazon_id",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ def _import_plugin_classes():
|
|||
from core.deezer_download_client import DeezerDownloadClient
|
||||
from core.lidarr_download_client import LidarrDownloadClient
|
||||
from core.soundcloud_client import SoundcloudClient
|
||||
from core.amazon_download_client import AmazonDownloadClient
|
||||
|
||||
return {
|
||||
'soulseek': SoulseekClient,
|
||||
|
|
@ -69,10 +70,11 @@ def _import_plugin_classes():
|
|||
'deezer': DeezerDownloadClient,
|
||||
'lidarr': LidarrDownloadClient,
|
||||
'soundcloud': SoundcloudClient,
|
||||
'amazon': AmazonDownloadClient,
|
||||
}
|
||||
|
||||
|
||||
def test_default_registry_registers_all_eight_sources():
|
||||
def test_default_registry_registers_all_sources():
|
||||
"""Smoke check that the foundation registry knows about every
|
||||
source the orchestrator historically dispatched to. If someone
|
||||
drops a registration here, every other test in this module would
|
||||
|
|
@ -82,7 +84,7 @@ def test_default_registry_registers_all_eight_sources():
|
|||
registry = build_default_registry()
|
||||
expected = {
|
||||
'soulseek', 'youtube', 'tidal', 'qobuz',
|
||||
'hifi', 'deezer', 'lidarr', 'soundcloud',
|
||||
'hifi', 'deezer', 'lidarr', 'soundcloud', 'amazon',
|
||||
}
|
||||
assert set(registry.names()) == expected
|
||||
|
||||
|
|
@ -102,7 +104,7 @@ def test_deezer_dl_alias_is_registered_against_deezer_spec():
|
|||
|
||||
@pytest.mark.parametrize('plugin_name', [
|
||||
'soulseek', 'youtube', 'tidal', 'qobuz',
|
||||
'hifi', 'deezer', 'lidarr', 'soundcloud',
|
||||
'hifi', 'deezer', 'lidarr', 'soundcloud', 'amazon',
|
||||
])
|
||||
def test_plugin_class_has_all_required_methods(plugin_name):
|
||||
"""Every registered plugin class exposes every protocol method
|
||||
|
|
@ -122,7 +124,7 @@ def test_plugin_class_has_all_required_methods(plugin_name):
|
|||
|
||||
@pytest.mark.parametrize('plugin_name', [
|
||||
'soulseek', 'youtube', 'tidal', 'qobuz',
|
||||
'hifi', 'deezer', 'lidarr', 'soundcloud',
|
||||
'hifi', 'deezer', 'lidarr', 'soundcloud', 'amazon',
|
||||
])
|
||||
def test_plugin_class_async_methods_are_coroutines(plugin_name):
|
||||
"""Methods declared async in the protocol must be async on every
|
||||
|
|
|
|||
181
tests/test_personalized_api.py
Normal file
181
tests/test_personalized_api.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"""Boundary tests for `core.personalized.api` handler functions.
|
||||
|
||||
These are pure-function dispatchers — they take a manager + ids,
|
||||
return a JSON-serializable dict. No Flask required, no real DB.
|
||||
The Flask wiring in `web_server.py` adds `jsonify` + URL routing
|
||||
on top.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, List
|
||||
|
||||
import pytest
|
||||
|
||||
from core.personalized import api as _api
|
||||
from core.personalized.manager import PersonalizedPlaylistManager
|
||||
from core.personalized.specs import PlaylistKindRegistry, PlaylistKindSpec
|
||||
from core.personalized.types import PlaylistConfig, Track
|
||||
from database.personalized_schema import ensure_personalized_schema
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
|
||||
def _get_connection(self):
|
||||
c = sqlite3.connect(self.path)
|
||||
c.row_factory = sqlite3.Row
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
p = str(tmp_path / 't.db')
|
||||
conn = sqlite3.connect(p)
|
||||
ensure_personalized_schema(conn)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return _FakeDB(p)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry():
|
||||
return PlaylistKindRegistry()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(db, registry):
|
||||
return PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
|
||||
|
||||
def _register(reg, kind='hidden_gems', requires_variant=False, generator=None):
|
||||
spec = PlaylistKindSpec(
|
||||
kind=kind, name_template=kind.replace('_', ' ').title(),
|
||||
description=f'Description for {kind}',
|
||||
default_config=PlaylistConfig(limit=20),
|
||||
generator=generator or (lambda *a, **k: []),
|
||||
requires_variant=requires_variant,
|
||||
tags=['test'],
|
||||
)
|
||||
reg.register(spec)
|
||||
return spec
|
||||
|
||||
|
||||
# ─── list_kinds ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListKinds:
|
||||
def test_lists_every_registered_kind(self, registry):
|
||||
_register(registry, kind='hidden_gems')
|
||||
_register(registry, kind='time_machine', requires_variant=True)
|
||||
out = _api.list_kinds(registry)
|
||||
assert out['success'] is True
|
||||
kinds = {k['kind'] for k in out['kinds']}
|
||||
assert kinds == {'hidden_gems', 'time_machine'}
|
||||
|
||||
def test_kind_metadata_shape(self, registry):
|
||||
_register(registry, kind='hidden_gems')
|
||||
out = _api.list_kinds(registry)
|
||||
kind = out['kinds'][0]
|
||||
assert kind['kind'] == 'hidden_gems'
|
||||
assert kind['requires_variant'] is False
|
||||
assert kind['tags'] == ['test']
|
||||
assert kind['default_config']['limit'] == 20
|
||||
|
||||
def test_empty_registry(self):
|
||||
out = _api.list_kinds(PlaylistKindRegistry())
|
||||
assert out == {'success': True, 'kinds': []}
|
||||
|
||||
|
||||
# ─── list_playlists ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListPlaylists:
|
||||
def test_returns_empty_when_no_playlists(self, manager, registry):
|
||||
_register(registry)
|
||||
out = _api.list_playlists(manager, profile_id=1)
|
||||
assert out == {'success': True, 'playlists': []}
|
||||
|
||||
def test_serializes_playlist_record(self, manager, registry):
|
||||
_register(registry)
|
||||
manager.ensure_playlist('hidden_gems', '', 1)
|
||||
out = _api.list_playlists(manager, profile_id=1)
|
||||
assert out['success'] is True
|
||||
assert len(out['playlists']) == 1
|
||||
pl = out['playlists'][0]
|
||||
assert pl['kind'] == 'hidden_gems'
|
||||
assert pl['variant'] == ''
|
||||
assert pl['name'] == 'Hidden Gems'
|
||||
assert pl['track_count'] == 0
|
||||
assert pl['config']['limit'] == 20
|
||||
|
||||
|
||||
# ─── get_playlist_with_tracks ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetPlaylistWithTracks:
|
||||
def test_auto_creates_on_first_get(self, manager, registry):
|
||||
_register(registry)
|
||||
out = _api.get_playlist_with_tracks(manager, 'hidden_gems', '', 1)
|
||||
assert out['success'] is True
|
||||
assert out['playlist']['kind'] == 'hidden_gems'
|
||||
assert out['tracks'] == []
|
||||
|
||||
def test_returns_persisted_tracks(self, manager, registry):
|
||||
gen_calls = []
|
||||
|
||||
def gen(deps, variant, config):
|
||||
gen_calls.append(1)
|
||||
return [Track(track_name='X', artist_name='Y', spotify_track_id='sp-1')]
|
||||
|
||||
_register(registry, generator=gen)
|
||||
manager.refresh_playlist('hidden_gems', '', 1)
|
||||
out = _api.get_playlist_with_tracks(manager, 'hidden_gems', '', 1)
|
||||
assert len(out['tracks']) == 1
|
||||
assert out['tracks'][0]['track_name'] == 'X'
|
||||
assert out['tracks'][0]['spotify_track_id'] == 'sp-1'
|
||||
|
||||
def test_unknown_kind_raises_value_error(self, manager):
|
||||
with pytest.raises(ValueError):
|
||||
_api.get_playlist_with_tracks(manager, 'nope', '', 1)
|
||||
|
||||
|
||||
# ─── refresh_playlist ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRefreshPlaylist:
|
||||
def test_refresh_runs_generator_and_returns_tracks(self, manager, registry):
|
||||
_register(registry, generator=lambda *a, **k: [
|
||||
Track(track_name='T1', artist_name='A', spotify_track_id='1'),
|
||||
Track(track_name='T2', artist_name='B', spotify_track_id='2'),
|
||||
])
|
||||
out = _api.refresh_playlist(manager, 'hidden_gems', '', 1)
|
||||
assert out['success'] is True
|
||||
assert len(out['tracks']) == 2
|
||||
assert out['playlist']['track_count'] == 2
|
||||
assert out['playlist']['last_generated_at'] is not None
|
||||
|
||||
def test_config_overrides_passed_through(self, manager, registry):
|
||||
captured = {}
|
||||
|
||||
def gen(deps, variant, config):
|
||||
captured['limit'] = config.limit
|
||||
return []
|
||||
|
||||
_register(registry, generator=gen)
|
||||
_api.refresh_playlist(manager, 'hidden_gems', '', 1, config_overrides={'limit': 99})
|
||||
assert captured['limit'] == 99
|
||||
|
||||
|
||||
# ─── update_config ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestUpdateConfig:
|
||||
def test_patches_config(self, manager, registry):
|
||||
_register(registry)
|
||||
out = _api.update_config(manager, 'hidden_gems', '', 1, {'limit': 75})
|
||||
assert out['success'] is True
|
||||
assert out['playlist']['config']['limit'] == 75
|
||||
316
tests/test_personalized_generators_curated.py
Normal file
316
tests/test_personalized_generators_curated.py
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
"""Boundary tests for the curated / hybrid personalized generators
|
||||
(`daily_mix`, `fresh_tape`, `archives`, `seasonal_mix`)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, List
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.personalized.generators import archives as _arch_mod
|
||||
from core.personalized.generators import daily_mix as _dm_mod
|
||||
from core.personalized.generators import fresh_tape as _ft_mod
|
||||
from core.personalized.generators import seasonal_mix as _sm_mod
|
||||
from core.personalized.specs import get_registry
|
||||
from core.personalized.types import PlaylistConfig
|
||||
|
||||
|
||||
# ─── daily_mix ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _DailyMixService:
|
||||
"""Stub PersonalizedPlaylistsService for daily_mix tests."""
|
||||
|
||||
GENRE_MAPPING = {}
|
||||
|
||||
def __init__(self, top_genres=None, genre_tracks=None):
|
||||
self._top = top_genres or []
|
||||
self._tracks = genre_tracks or {}
|
||||
self.calls: List[dict] = []
|
||||
|
||||
def get_top_genres_from_library(self, limit):
|
||||
self.calls.append({'method': 'get_top_genres_from_library', 'limit': limit})
|
||||
return self._top
|
||||
|
||||
def get_genre_playlist(self, genre, limit, **kw):
|
||||
self.calls.append({'method': 'get_genre_playlist', 'genre': genre, 'limit': limit})
|
||||
return self._tracks.get(genre, [])
|
||||
|
||||
|
||||
class TestDailyMix:
|
||||
def test_registered(self):
|
||||
spec = get_registry().get('daily_mix')
|
||||
assert spec is not None
|
||||
assert spec.requires_variant is True
|
||||
|
||||
def test_variant_resolver_returns_ranks(self):
|
||||
spec = get_registry().get('daily_mix')
|
||||
ranks = spec.variant_resolver(SimpleNamespace(service=_DailyMixService()))
|
||||
assert ranks == ['1', '2', '3', '4']
|
||||
|
||||
def test_resolves_rank_to_top_genre(self):
|
||||
svc = _DailyMixService(
|
||||
top_genres=[('Rock', 100), ('Pop', 80), ('Jazz', 30)],
|
||||
genre_tracks={'Rock': [{'track_name': 'R', 'artist_name': 'A'}]},
|
||||
)
|
||||
out = _dm_mod.generate(SimpleNamespace(service=svc), '1', PlaylistConfig(limit=10))
|
||||
assert len(out) == 1
|
||||
assert out[0].track_name == 'R'
|
||||
# Service called for top-genre lookup + genre playlist.
|
||||
assert {c['method'] for c in svc.calls} == {
|
||||
'get_top_genres_from_library', 'get_genre_playlist',
|
||||
}
|
||||
|
||||
def test_rank_beyond_top_returns_empty(self):
|
||||
svc = _DailyMixService(top_genres=[('Rock', 100)]) # only 1 top genre
|
||||
out = _dm_mod.generate(SimpleNamespace(service=svc), '4', PlaylistConfig())
|
||||
assert out == []
|
||||
|
||||
def test_invalid_variant_raises(self):
|
||||
deps = SimpleNamespace(service=_DailyMixService())
|
||||
with pytest.raises(ValueError, match='must be a rank int'):
|
||||
_dm_mod.generate(deps, 'abc', PlaylistConfig())
|
||||
|
||||
|
||||
# ─── fresh_tape / archives shared shape ─────────────────────────────
|
||||
|
||||
|
||||
class _StubPoolTrack:
|
||||
def __init__(self, sid, name='T', artist='A', source='spotify'):
|
||||
self.spotify_track_id = sid
|
||||
self.itunes_track_id = None
|
||||
self.deezer_track_id = None
|
||||
self.track_name = name
|
||||
self.artist_name = artist
|
||||
self.album_name = 'Album'
|
||||
self.album_cover_url = None
|
||||
self.duration_ms = 200000
|
||||
self.popularity = 50
|
||||
self.track_data_json = None
|
||||
self.source = source
|
||||
|
||||
|
||||
class _CuratedDB:
|
||||
def __init__(self, curated_ids=None, pool_tracks=None):
|
||||
self.curated_ids = curated_ids or []
|
||||
self.pool_tracks = pool_tracks or []
|
||||
self.requested_keys: List[str] = []
|
||||
|
||||
def get_curated_playlist(self, key, profile_id=1):
|
||||
self.requested_keys.append(key)
|
||||
return list(self.curated_ids)
|
||||
|
||||
def get_discovery_pool_tracks(self, **kwargs):
|
||||
return list(self.pool_tracks)
|
||||
|
||||
|
||||
def _curated_deps(db):
|
||||
return SimpleNamespace(
|
||||
database=db,
|
||||
get_current_profile_id=lambda: 1,
|
||||
get_active_discovery_source=lambda: 'spotify',
|
||||
)
|
||||
|
||||
|
||||
class TestFreshTape:
|
||||
def test_registered(self):
|
||||
spec = get_registry().get('fresh_tape')
|
||||
assert spec is not None
|
||||
assert spec.requires_variant is False
|
||||
assert spec.display_name('') == 'Fresh Tape'
|
||||
|
||||
def test_returns_empty_when_no_curated_ids(self):
|
||||
db = _CuratedDB(curated_ids=[])
|
||||
out = _ft_mod.generate(_curated_deps(db), '', PlaylistConfig())
|
||||
assert out == []
|
||||
|
||||
def test_hydrates_curated_ids_from_pool(self):
|
||||
db = _CuratedDB(
|
||||
curated_ids=['sp-1', 'sp-2', 'sp-missing'],
|
||||
pool_tracks=[
|
||||
_StubPoolTrack('sp-1', name='Song1', artist='Artist1'),
|
||||
_StubPoolTrack('sp-2', name='Song2', artist='Artist2'),
|
||||
],
|
||||
)
|
||||
out = _ft_mod.generate(_curated_deps(db), '', PlaylistConfig())
|
||||
# Missing IDs silently skipped; order preserved.
|
||||
assert [t.track_name for t in out] == ['Song1', 'Song2']
|
||||
|
||||
def test_tries_source_specific_then_fallback_key(self):
|
||||
# First lookup (source-specific) returns []; second (generic) returns IDs.
|
||||
class _DB:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.responses = {
|
||||
'release_radar_spotify': [],
|
||||
'release_radar': ['sp-1'],
|
||||
}
|
||||
|
||||
def get_curated_playlist(self, key, profile_id=1):
|
||||
self.calls.append(key)
|
||||
return self.responses.get(key, [])
|
||||
|
||||
def get_discovery_pool_tracks(self, **kw):
|
||||
return [_StubPoolTrack('sp-1', name='Hit')]
|
||||
|
||||
db = _DB()
|
||||
out = _ft_mod.generate(_curated_deps(db), '', PlaylistConfig())
|
||||
assert db.calls == ['release_radar_spotify', 'release_radar']
|
||||
assert len(out) == 1
|
||||
|
||||
def test_respects_limit(self):
|
||||
db = _CuratedDB(
|
||||
curated_ids=[f'sp-{i}' for i in range(20)],
|
||||
pool_tracks=[_StubPoolTrack(f'sp-{i}', name=f'T{i}') for i in range(20)],
|
||||
)
|
||||
out = _ft_mod.generate(_curated_deps(db), '', PlaylistConfig(limit=5))
|
||||
assert len(out) == 5
|
||||
|
||||
def test_missing_database_dep_raises(self):
|
||||
with pytest.raises(RuntimeError, match='missing `database`'):
|
||||
_ft_mod.generate(SimpleNamespace(), '', PlaylistConfig())
|
||||
|
||||
|
||||
class TestArchives:
|
||||
def test_registered(self):
|
||||
spec = get_registry().get('archives')
|
||||
assert spec is not None
|
||||
assert spec.display_name('') == 'The Archives'
|
||||
|
||||
def test_uses_discovery_weekly_curated_key(self):
|
||||
db = _CuratedDB(
|
||||
curated_ids=['sp-1'],
|
||||
pool_tracks=[_StubPoolTrack('sp-1', name='Discover')],
|
||||
)
|
||||
_arch_mod.generate(_curated_deps(db), '', PlaylistConfig())
|
||||
# Source-specific request fires first; fallback only fires
|
||||
# when source-specific returns empty. Stub returns IDs on
|
||||
# every call, so only the first key gets queried.
|
||||
assert db.requested_keys[0] == 'discovery_weekly_spotify'
|
||||
|
||||
|
||||
# ─── seasonal_mix ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _SeasonalService:
|
||||
def __init__(self, track_ids):
|
||||
self.track_ids = track_ids
|
||||
|
||||
def get_curated_seasonal_playlist(self, season_key, source=None):
|
||||
return list(self.track_ids)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seasonal_db(tmp_path):
|
||||
"""Real sqlite DB with seasonal_tracks rows for hydration."""
|
||||
p = str(tmp_path / 'seasonal.db')
|
||||
conn = sqlite3.connect(p)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
CREATE TABLE seasonal_tracks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
season_key TEXT, source TEXT,
|
||||
spotify_track_id TEXT, track_name TEXT, artist_name TEXT,
|
||||
album_name TEXT, album_cover_url TEXT,
|
||||
duration_ms INTEGER, popularity INTEGER, track_data_json TEXT
|
||||
)
|
||||
""")
|
||||
seed = [
|
||||
('halloween', 'spotify', 'sp-1', 'Spooky', 'Ghost Band', 'Album1', None, 200000, 80, '{"id":"sp-1"}'),
|
||||
('halloween', 'spotify', 'sp-2', 'Haunted', 'Ghost Band', 'Album2', None, 210000, 70, None),
|
||||
('halloween', 'spotify', 'sp-extra', 'Extra', 'Other', 'Album3', None, 200000, 60, None),
|
||||
]
|
||||
cursor.executemany("""
|
||||
INSERT INTO seasonal_tracks
|
||||
(season_key, source, spotify_track_id, track_name, artist_name,
|
||||
album_name, album_cover_url, duration_ms, popularity, track_data_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", seed)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
class _DB:
|
||||
def __init__(self, path): self.path = path
|
||||
def _get_connection(self):
|
||||
c = sqlite3.connect(self.path)
|
||||
c.row_factory = sqlite3.Row
|
||||
return c
|
||||
return _DB(p)
|
||||
|
||||
|
||||
class TestSeasonalMix:
|
||||
def test_registered(self):
|
||||
spec = get_registry().get('seasonal_mix')
|
||||
assert spec is not None
|
||||
assert spec.requires_variant is True
|
||||
|
||||
def test_variant_resolver_returns_seasons(self):
|
||||
spec = get_registry().get('seasonal_mix')
|
||||
seasons = spec.variant_resolver(None)
|
||||
assert 'halloween' in seasons
|
||||
assert 'christmas' in seasons
|
||||
|
||||
def test_no_variant_raises(self, seasonal_db):
|
||||
deps = SimpleNamespace(
|
||||
seasonal_service=_SeasonalService(['sp-1']),
|
||||
database=seasonal_db,
|
||||
get_active_discovery_source=lambda: 'spotify',
|
||||
)
|
||||
with pytest.raises(ValueError, match='requires a season variant'):
|
||||
_sm_mod.generate(deps, '', PlaylistConfig())
|
||||
|
||||
def test_hydrates_curated_ids_in_order(self, seasonal_db):
|
||||
deps = SimpleNamespace(
|
||||
seasonal_service=_SeasonalService(['sp-2', 'sp-1']),
|
||||
database=seasonal_db,
|
||||
get_active_discovery_source=lambda: 'spotify',
|
||||
)
|
||||
out = _sm_mod.generate(deps, 'halloween', PlaylistConfig())
|
||||
assert [t.track_name for t in out] == ['Haunted', 'Spooky']
|
||||
|
||||
def test_missing_track_id_silently_skipped(self, seasonal_db):
|
||||
deps = SimpleNamespace(
|
||||
seasonal_service=_SeasonalService(['sp-1', 'sp-not-in-db']),
|
||||
database=seasonal_db,
|
||||
get_active_discovery_source=lambda: 'spotify',
|
||||
)
|
||||
out = _sm_mod.generate(deps, 'halloween', PlaylistConfig())
|
||||
assert len(out) == 1
|
||||
assert out[0].track_name == 'Spooky'
|
||||
|
||||
def test_track_data_json_round_trips(self, seasonal_db):
|
||||
deps = SimpleNamespace(
|
||||
seasonal_service=_SeasonalService(['sp-1']),
|
||||
database=seasonal_db,
|
||||
get_active_discovery_source=lambda: 'spotify',
|
||||
)
|
||||
out = _sm_mod.generate(deps, 'halloween', PlaylistConfig())
|
||||
# sp-1 had JSON; sp-2 had None.
|
||||
assert out[0].track_data_json == {'id': 'sp-1'}
|
||||
|
||||
def test_empty_curated_returns_empty(self, seasonal_db):
|
||||
deps = SimpleNamespace(
|
||||
seasonal_service=_SeasonalService([]),
|
||||
database=seasonal_db,
|
||||
get_active_discovery_source=lambda: 'spotify',
|
||||
)
|
||||
out = _sm_mod.generate(deps, 'halloween', PlaylistConfig())
|
||||
assert out == []
|
||||
|
||||
def test_respects_limit(self, seasonal_db):
|
||||
deps = SimpleNamespace(
|
||||
seasonal_service=_SeasonalService(['sp-1', 'sp-2', 'sp-extra']),
|
||||
database=seasonal_db,
|
||||
get_active_discovery_source=lambda: 'spotify',
|
||||
)
|
||||
out = _sm_mod.generate(deps, 'halloween', PlaylistConfig(limit=2))
|
||||
assert len(out) == 2
|
||||
|
||||
def test_missing_seasonal_service_raises(self):
|
||||
deps = SimpleNamespace(database=object())
|
||||
with pytest.raises(RuntimeError, match='missing `seasonal_service`'):
|
||||
_sm_mod.generate(deps, 'halloween', PlaylistConfig())
|
||||
147
tests/test_personalized_generators_singletons.py
Normal file
147
tests/test_personalized_generators_singletons.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"""Boundary tests for the singleton-kind personalized generators
|
||||
(`hidden_gems`, `discovery_shuffle`, `popular_picks`).
|
||||
|
||||
Each generator wraps the legacy
|
||||
``PersonalizedPlaylistsService`` method 1:1, so the tests pin:
|
||||
- registration side-effect at import
|
||||
- generator forwards `config.limit` correctly
|
||||
- empty / None / non-dict service output → []
|
||||
- tracks coerced through `Track.from_dict`
|
||||
- missing service in deps raises a clear error"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, List
|
||||
|
||||
import pytest
|
||||
|
||||
# Importing each generator triggers registration as a side-effect.
|
||||
from core.personalized.generators import discovery_shuffle as _ds_mod
|
||||
from core.personalized.generators import hidden_gems as _hg_mod
|
||||
from core.personalized.generators import popular_picks as _pp_mod
|
||||
from core.personalized.specs import get_registry
|
||||
from core.personalized.types import PlaylistConfig
|
||||
|
||||
|
||||
class _StubService:
|
||||
"""Records every call so tests can assert on dispatched limits."""
|
||||
|
||||
def __init__(self, return_value=None):
|
||||
self.calls: List[dict] = []
|
||||
self.return_value = return_value if return_value is not None else []
|
||||
|
||||
def get_hidden_gems(self, limit):
|
||||
self.calls.append({'method': 'get_hidden_gems', 'limit': limit})
|
||||
return self.return_value
|
||||
|
||||
def get_discovery_shuffle(self, limit):
|
||||
self.calls.append({'method': 'get_discovery_shuffle', 'limit': limit})
|
||||
return self.return_value
|
||||
|
||||
def get_popular_picks(self, limit):
|
||||
self.calls.append({'method': 'get_popular_picks', 'limit': limit})
|
||||
return self.return_value
|
||||
|
||||
|
||||
def _deps(svc):
|
||||
return SimpleNamespace(service=svc)
|
||||
|
||||
|
||||
# ─── registration ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRegistration:
|
||||
def test_hidden_gems_registered(self):
|
||||
spec = get_registry().get('hidden_gems')
|
||||
assert spec is not None
|
||||
assert spec.kind == 'hidden_gems'
|
||||
assert spec.requires_variant is False
|
||||
assert spec.default_config.limit == 50
|
||||
|
||||
def test_discovery_shuffle_registered(self):
|
||||
spec = get_registry().get('discovery_shuffle')
|
||||
assert spec is not None
|
||||
assert spec.requires_variant is False
|
||||
|
||||
def test_popular_picks_registered(self):
|
||||
spec = get_registry().get('popular_picks')
|
||||
assert spec is not None
|
||||
assert spec.requires_variant is False
|
||||
|
||||
def test_display_names(self):
|
||||
assert get_registry().get('hidden_gems').display_name('') == 'Hidden Gems'
|
||||
assert get_registry().get('discovery_shuffle').display_name('') == 'Discovery Shuffle'
|
||||
assert get_registry().get('popular_picks').display_name('') == 'Popular Picks'
|
||||
|
||||
|
||||
# ─── generator dispatch ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHiddenGemsGenerator:
|
||||
def test_forwards_limit(self):
|
||||
svc = _StubService()
|
||||
_hg_mod.generate(_deps(svc), '', PlaylistConfig(limit=75))
|
||||
assert svc.calls == [{'method': 'get_hidden_gems', 'limit': 75}]
|
||||
|
||||
def test_uses_default_limit_when_config_default(self):
|
||||
svc = _StubService()
|
||||
_hg_mod.generate(_deps(svc), '', PlaylistConfig())
|
||||
assert svc.calls[0]['limit'] == 50
|
||||
|
||||
def test_coerces_tracks(self):
|
||||
svc = _StubService(return_value=[
|
||||
{'track_name': 'A', 'artist_name': 'X', 'spotify_track_id': 'sp-1'},
|
||||
{'track_name': 'B', 'artist_name': 'Y', 'spotify_track_id': 'sp-2'},
|
||||
])
|
||||
out = _hg_mod.generate(_deps(svc), '', PlaylistConfig())
|
||||
assert len(out) == 2
|
||||
assert out[0].track_name == 'A'
|
||||
assert out[0].spotify_track_id == 'sp-1'
|
||||
|
||||
def test_empty_service_output_returns_empty_list(self):
|
||||
svc = _StubService(return_value=[])
|
||||
out = _hg_mod.generate(_deps(svc), '', PlaylistConfig())
|
||||
assert out == []
|
||||
|
||||
def test_none_service_output_returns_empty_list(self):
|
||||
svc = _StubService(return_value=None)
|
||||
out = _hg_mod.generate(_deps(svc), '', PlaylistConfig())
|
||||
assert out == []
|
||||
|
||||
|
||||
class TestDiscoveryShuffleGenerator:
|
||||
def test_forwards_limit(self):
|
||||
svc = _StubService()
|
||||
_ds_mod.generate(_deps(svc), '', PlaylistConfig(limit=42))
|
||||
assert svc.calls == [{'method': 'get_discovery_shuffle', 'limit': 42}]
|
||||
|
||||
def test_coerces_tracks(self):
|
||||
svc = _StubService(return_value=[{'track_name': 'Z', 'artist_name': 'Q'}])
|
||||
out = _ds_mod.generate(_deps(svc), '', PlaylistConfig())
|
||||
assert out[0].track_name == 'Z'
|
||||
|
||||
|
||||
class TestPopularPicksGenerator:
|
||||
def test_forwards_limit(self):
|
||||
svc = _StubService()
|
||||
_pp_mod.generate(_deps(svc), '', PlaylistConfig(limit=10))
|
||||
assert svc.calls == [{'method': 'get_popular_picks', 'limit': 10}]
|
||||
|
||||
|
||||
# ─── deps validation ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDepsValidation:
|
||||
def test_missing_service_raises(self):
|
||||
# No `service` attribute on deps.
|
||||
deps = SimpleNamespace()
|
||||
with pytest.raises(RuntimeError, match='missing `service`'):
|
||||
_hg_mod.generate(deps, '', PlaylistConfig())
|
||||
|
||||
def test_dict_form_deps_accepted(self):
|
||||
# generators._common.get_service tolerates dict deps too.
|
||||
svc = _StubService()
|
||||
out = _hg_mod.generate({'service': svc}, '', PlaylistConfig())
|
||||
assert isinstance(out, list)
|
||||
assert svc.calls
|
||||
131
tests/test_personalized_generators_variants.py
Normal file
131
tests/test_personalized_generators_variants.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""Boundary tests for variant-bearing personalized generators
|
||||
(`time_machine` per decade, `genre_playlist` per genre).
|
||||
|
||||
Each generator coerces a URL-safe variant string into the form the
|
||||
legacy service expects, then forwards. Tests pin the variant
|
||||
parsing + service dispatch + variant_resolver listing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
|
||||
from core.personalized.generators import genre_playlist as _gp_mod
|
||||
from core.personalized.generators import time_machine as _tm_mod
|
||||
from core.personalized.specs import get_registry
|
||||
from core.personalized.types import PlaylistConfig
|
||||
|
||||
|
||||
class _StubService:
|
||||
GENRE_MAPPING = {
|
||||
'Electronic/Dance': ['house', 'techno'],
|
||||
'Hip Hop/Rap': ['hip hop', 'rap'],
|
||||
'Rock': ['rock', 'punk'],
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.calls: List[dict] = []
|
||||
|
||||
def get_decade_playlist(self, decade, limit, **kw):
|
||||
self.calls.append({'method': 'get_decade_playlist', 'decade': decade, 'limit': limit})
|
||||
return [{'track_name': f'D{decade}', 'artist_name': 'A'}]
|
||||
|
||||
def get_genre_playlist(self, genre, limit, **kw):
|
||||
self.calls.append({'method': 'get_genre_playlist', 'genre': genre, 'limit': limit})
|
||||
return [{'track_name': f'G{genre}', 'artist_name': 'A'}]
|
||||
|
||||
|
||||
def _deps():
|
||||
return SimpleNamespace(service=_StubService())
|
||||
|
||||
|
||||
# ─── time_machine ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTimeMachine:
|
||||
def test_registered(self):
|
||||
spec = get_registry().get('time_machine')
|
||||
assert spec is not None
|
||||
assert spec.requires_variant is True
|
||||
assert spec.variant_resolver is not None
|
||||
|
||||
def test_variant_resolver_returns_decades(self):
|
||||
spec = get_registry().get('time_machine')
|
||||
decades = spec.variant_resolver(_deps())
|
||||
assert '1980s' in decades
|
||||
assert '2020s' in decades
|
||||
# All decades should be 4-digit + 's'
|
||||
for d in decades:
|
||||
assert d.endswith('s')
|
||||
assert d[:-1].isdigit()
|
||||
|
||||
def test_decade_label_to_year(self):
|
||||
deps = _deps()
|
||||
_tm_mod.generate(deps, '1980s', PlaylistConfig(limit=20))
|
||||
assert deps.service.calls == [
|
||||
{'method': 'get_decade_playlist', 'decade': 1980, 'limit': 20}
|
||||
]
|
||||
|
||||
def test_invalid_variant_raises(self):
|
||||
deps = _deps()
|
||||
with pytest.raises(ValueError, match='not a decade label'):
|
||||
_tm_mod.generate(deps, 'banana', PlaylistConfig())
|
||||
|
||||
def test_out_of_range_year_raises(self):
|
||||
deps = _deps()
|
||||
with pytest.raises(ValueError, match='out of range'):
|
||||
_tm_mod.generate(deps, '1500s', PlaylistConfig())
|
||||
|
||||
def test_tolerates_no_s_suffix(self):
|
||||
deps = _deps()
|
||||
_tm_mod.generate(deps, '1990', PlaylistConfig())
|
||||
assert deps.service.calls[0]['decade'] == 1990
|
||||
|
||||
def test_default_limit_is_100(self):
|
||||
spec = get_registry().get('time_machine')
|
||||
assert spec.default_config.limit == 100
|
||||
|
||||
def test_display_name_with_variant(self):
|
||||
spec = get_registry().get('time_machine')
|
||||
assert spec.display_name('1980s') == 'Time Machine — 1980s'
|
||||
|
||||
|
||||
# ─── genre_playlist ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGenrePlaylist:
|
||||
def test_registered(self):
|
||||
spec = get_registry().get('genre_playlist')
|
||||
assert spec is not None
|
||||
assert spec.requires_variant is True
|
||||
|
||||
def test_variant_resolver_normalizes_parent_keys(self):
|
||||
spec = get_registry().get('genre_playlist')
|
||||
variants = spec.variant_resolver(_deps())
|
||||
# 'Electronic/Dance' → 'electronic_dance' (slash → underscore + lowercase)
|
||||
assert 'electronic_dance' in variants
|
||||
assert 'hip_hop_rap' in variants
|
||||
assert 'rock' in variants
|
||||
|
||||
def test_normalized_variant_resolves_to_parent_key(self):
|
||||
deps = _deps()
|
||||
_gp_mod.generate(deps, 'electronic_dance', PlaylistConfig())
|
||||
# Service receives ORIGINAL parent key.
|
||||
assert deps.service.calls[0]['genre'] == 'Electronic/Dance'
|
||||
|
||||
def test_unknown_variant_passed_through_as_freeform(self):
|
||||
# Service handles partial-matching for free-form keywords.
|
||||
deps = _deps()
|
||||
_gp_mod.generate(deps, 'shoegaze', PlaylistConfig())
|
||||
assert deps.service.calls[0]['genre'] == 'shoegaze'
|
||||
|
||||
def test_empty_variant_raises(self):
|
||||
deps = _deps()
|
||||
with pytest.raises(ValueError, match='requires a variant'):
|
||||
_gp_mod.generate(deps, '', PlaylistConfig())
|
||||
|
||||
def test_display_name(self):
|
||||
spec = get_registry().get('genre_playlist')
|
||||
assert spec.display_name('electronic_dance') == 'Genre — electronic_dance'
|
||||
532
tests/test_personalized_manager.py
Normal file
532
tests/test_personalized_manager.py
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
"""Boundary tests for the personalized-playlists foundation
|
||||
(``core.personalized.types`` + ``core.personalized.specs`` +
|
||||
``core.personalized.manager``).
|
||||
|
||||
Pin every shape the storage layer + lifecycle has to handle so the
|
||||
generators that arrive in subsequent commits can rely on a stable
|
||||
contract: ensure_playlist auto-creates from default config, refresh
|
||||
atomically replaces the snapshot + appends history, generator
|
||||
exceptions don't lose the previous good snapshot, config patches
|
||||
preserve unsent fields, recent_track_ids honors the day window,
|
||||
list_playlists orders newest-first."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from typing import Any, List
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.personalized.manager import PersonalizedPlaylistManager
|
||||
from core.personalized.specs import PlaylistKindRegistry, PlaylistKindSpec
|
||||
from core.personalized.types import PlaylistConfig, Track
|
||||
from database.personalized_schema import ensure_personalized_schema
|
||||
|
||||
|
||||
# ─── shared fixtures ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Minimal MusicDatabase stand-in — gives the manager a real
|
||||
sqlite connection so the manager exercises actual SQL."""
|
||||
|
||||
def __init__(self, path: str):
|
||||
self.path = path
|
||||
|
||||
def _get_connection(self):
|
||||
conn = sqlite3.connect(self.path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_path(tmp_path):
|
||||
p = str(tmp_path / 'test.db')
|
||||
conn = sqlite3.connect(p)
|
||||
ensure_personalized_schema(conn)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(db_path):
|
||||
return _FakeDB(db_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry():
|
||||
r = PlaylistKindRegistry()
|
||||
return r
|
||||
|
||||
|
||||
def _make_track(name='T1', artist='A1', sid='spot-1', source='spotify') -> Track:
|
||||
return Track(
|
||||
track_name=name, artist_name=artist, album_name='Album',
|
||||
spotify_track_id=sid, source=source,
|
||||
duration_ms=200000, popularity=50,
|
||||
)
|
||||
|
||||
|
||||
# ─── PlaylistConfig ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPlaylistConfig:
|
||||
def test_default_values(self):
|
||||
c = PlaylistConfig()
|
||||
assert c.limit == 50
|
||||
assert c.max_per_album == 2
|
||||
assert c.max_per_artist == 3
|
||||
assert c.popularity_min is None
|
||||
assert c.popularity_max is None
|
||||
assert c.exclude_recent_days == 0
|
||||
assert c.recency_days is None
|
||||
assert c.seed is None
|
||||
assert c.extra == {}
|
||||
|
||||
def test_round_trip_through_json_dict(self):
|
||||
c = PlaylistConfig(
|
||||
limit=100, max_per_album=5, max_per_artist=10,
|
||||
popularity_min=20, popularity_max=80,
|
||||
exclude_recent_days=14, recency_days=180,
|
||||
seed=42, extra={'selected_seasons': ['halloween', 'christmas']},
|
||||
)
|
||||
d = c.to_json_dict()
|
||||
c2 = PlaylistConfig.from_json_dict(d)
|
||||
assert c2 == c
|
||||
|
||||
def test_from_json_dict_handles_none(self):
|
||||
c = PlaylistConfig.from_json_dict(None)
|
||||
assert c == PlaylistConfig()
|
||||
|
||||
def test_from_json_dict_handles_non_dict(self):
|
||||
c = PlaylistConfig.from_json_dict('garbage') # type: ignore
|
||||
assert c == PlaylistConfig()
|
||||
|
||||
def test_from_json_dict_missing_fields_use_defaults(self):
|
||||
c = PlaylistConfig.from_json_dict({'limit': 75})
|
||||
assert c.limit == 75
|
||||
assert c.max_per_album == 2 # default
|
||||
|
||||
def test_merged_overrides_only_named_fields(self):
|
||||
base = PlaylistConfig(limit=50, popularity_min=20)
|
||||
out = base.merged({'limit': 100})
|
||||
assert out.limit == 100
|
||||
assert out.popularity_min == 20 # untouched
|
||||
|
||||
def test_merged_extra_dict_is_deep_merged(self):
|
||||
base = PlaylistConfig(extra={'a': 1, 'b': 2})
|
||||
out = base.merged({'extra': {'b': 99, 'c': 3}})
|
||||
assert out.extra == {'a': 1, 'b': 99, 'c': 3}
|
||||
|
||||
def test_merged_ignores_unknown_keys(self):
|
||||
base = PlaylistConfig()
|
||||
out = base.merged({'unknown_field': 'foo'})
|
||||
assert out == base
|
||||
|
||||
|
||||
# ─── Track ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTrack:
|
||||
def test_from_dict_legacy_shape(self):
|
||||
d = {
|
||||
'track_name': 'Song', 'artist_name': 'Band',
|
||||
'album_name': 'Album', 'spotify_track_id': 'spot-1',
|
||||
'duration_ms': 200000, 'popularity': 60,
|
||||
'_artist_genres_raw': '["rock"]', # ignored extra
|
||||
}
|
||||
t = Track.from_dict(d)
|
||||
assert t.track_name == 'Song'
|
||||
assert t.spotify_track_id == 'spot-1'
|
||||
assert t.duration_ms == 200000
|
||||
|
||||
def test_primary_id_prefers_spotify(self):
|
||||
t = Track(
|
||||
track_name='', artist_name='',
|
||||
spotify_track_id='spot', itunes_track_id='itu', deezer_track_id='dee',
|
||||
)
|
||||
assert t.primary_id() == 'spot'
|
||||
|
||||
def test_primary_id_falls_back_through_sources(self):
|
||||
t = Track(track_name='', artist_name='', itunes_track_id='itu')
|
||||
assert t.primary_id() == 'itu'
|
||||
t2 = Track(track_name='', artist_name='', deezer_track_id='dee')
|
||||
assert t2.primary_id() == 'dee'
|
||||
|
||||
def test_primary_id_none_when_no_sources(self):
|
||||
t = Track(track_name='', artist_name='')
|
||||
assert t.primary_id() is None
|
||||
|
||||
|
||||
# ─── PlaylistKindRegistry ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
def test_register_and_get(self, registry):
|
||||
spec = PlaylistKindSpec(
|
||||
kind='hidden_gems', name_template='Hidden Gems',
|
||||
description='', default_config=PlaylistConfig(),
|
||||
generator=lambda *a, **k: [],
|
||||
)
|
||||
registry.register(spec)
|
||||
assert registry.get('hidden_gems') is spec
|
||||
assert registry.get('nonexistent') is None
|
||||
|
||||
def test_duplicate_registration_raises(self, registry):
|
||||
spec = PlaylistKindSpec(
|
||||
kind='x', name_template='X', description='',
|
||||
default_config=PlaylistConfig(), generator=lambda *a, **k: [],
|
||||
)
|
||||
registry.register(spec)
|
||||
with pytest.raises(ValueError, match='already registered'):
|
||||
registry.register(spec)
|
||||
|
||||
def test_display_name_singleton(self):
|
||||
spec = PlaylistKindSpec(
|
||||
kind='x', name_template='Hidden Gems', description='',
|
||||
default_config=PlaylistConfig(), generator=lambda *a, **k: [],
|
||||
)
|
||||
assert spec.display_name('') == 'Hidden Gems'
|
||||
|
||||
def test_display_name_with_variant(self):
|
||||
spec = PlaylistKindSpec(
|
||||
kind='x', name_template='Time Machine — {variant}',
|
||||
description='', default_config=PlaylistConfig(),
|
||||
generator=lambda *a, **k: [],
|
||||
)
|
||||
assert spec.display_name('1980s') == 'Time Machine — 1980s'
|
||||
|
||||
def test_kinds_listing(self, registry):
|
||||
for k in ('a', 'b', 'c'):
|
||||
registry.register(PlaylistKindSpec(
|
||||
kind=k, name_template=k, description='',
|
||||
default_config=PlaylistConfig(), generator=lambda *a, **k: [],
|
||||
))
|
||||
assert set(registry.kinds()) == {'a', 'b', 'c'}
|
||||
|
||||
|
||||
# ─── PersonalizedPlaylistManager ─────────────────────────────────────
|
||||
|
||||
|
||||
def _register_simple_kind(registry, generator, kind='hidden_gems', requires_variant=False):
|
||||
spec = PlaylistKindSpec(
|
||||
kind=kind, name_template=kind.replace('_', ' ').title(),
|
||||
description='', default_config=PlaylistConfig(limit=10),
|
||||
generator=generator, requires_variant=requires_variant,
|
||||
)
|
||||
registry.register(spec)
|
||||
return spec
|
||||
|
||||
|
||||
class TestEnsurePlaylist:
|
||||
def test_creates_row_with_default_config(self, db, registry):
|
||||
_register_simple_kind(registry, lambda *a, **k: [])
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
record = mgr.ensure_playlist('hidden_gems', '', 1)
|
||||
assert record.id > 0
|
||||
assert record.kind == 'hidden_gems'
|
||||
assert record.variant == ''
|
||||
assert record.profile_id == 1
|
||||
assert record.config.limit == 10 # from default
|
||||
assert record.track_count == 0
|
||||
assert record.last_generated_at is None
|
||||
|
||||
def test_returns_same_row_on_second_call(self, db, registry):
|
||||
_register_simple_kind(registry, lambda *a, **k: [])
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
r1 = mgr.ensure_playlist('hidden_gems', '', 1)
|
||||
r2 = mgr.ensure_playlist('hidden_gems', '', 1)
|
||||
assert r1.id == r2.id
|
||||
|
||||
def test_variant_creates_separate_row(self, db, registry):
|
||||
_register_simple_kind(
|
||||
registry, lambda *a, **k: [], kind='time_machine', requires_variant=True,
|
||||
)
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
r1 = mgr.ensure_playlist('time_machine', '1980s', 1)
|
||||
r2 = mgr.ensure_playlist('time_machine', '1990s', 1)
|
||||
assert r1.id != r2.id
|
||||
|
||||
def test_unknown_kind_raises(self, db, registry):
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
with pytest.raises(ValueError, match='Unknown playlist kind'):
|
||||
mgr.ensure_playlist('does_not_exist', '', 1)
|
||||
|
||||
def test_required_variant_missing_raises(self, db, registry):
|
||||
_register_simple_kind(
|
||||
registry, lambda *a, **k: [], kind='time_machine', requires_variant=True,
|
||||
)
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
with pytest.raises(ValueError, match='requires a variant'):
|
||||
mgr.ensure_playlist('time_machine', '', 1)
|
||||
|
||||
|
||||
class TestRefreshPlaylist:
|
||||
def test_refresh_persists_tracks(self, db, registry):
|
||||
tracks = [_make_track('S1', 'A1', 'sp1'), _make_track('S2', 'A1', 'sp2')]
|
||||
_register_simple_kind(registry, lambda deps, variant, config: tracks)
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
record = mgr.refresh_playlist('hidden_gems', '', 1)
|
||||
assert record.track_count == 2
|
||||
assert record.last_generated_at is not None
|
||||
assert record.last_generation_error is None
|
||||
|
||||
persisted = mgr.get_playlist_tracks(record.id)
|
||||
assert len(persisted) == 2
|
||||
assert persisted[0].track_name == 'S1'
|
||||
assert persisted[1].track_name == 'S2'
|
||||
|
||||
def test_refresh_replaces_previous_snapshot_atomically(self, db, registry):
|
||||
run = {'tracks': [_make_track('first')]}
|
||||
|
||||
def gen(deps, variant, config):
|
||||
return run['tracks']
|
||||
|
||||
_register_simple_kind(registry, gen)
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
r1 = mgr.refresh_playlist('hidden_gems', '', 1)
|
||||
assert r1.track_count == 1
|
||||
|
||||
run['tracks'] = [_make_track('A'), _make_track('B'), _make_track('C')]
|
||||
r2 = mgr.refresh_playlist('hidden_gems', '', 1)
|
||||
assert r2.id == r1.id
|
||||
assert r2.track_count == 3
|
||||
|
||||
persisted = mgr.get_playlist_tracks(r2.id)
|
||||
assert [t.track_name for t in persisted] == ['A', 'B', 'C']
|
||||
|
||||
def test_generator_exception_preserves_previous_snapshot(self, db, registry):
|
||||
run = {'mode': 'success'}
|
||||
|
||||
def gen(deps, variant, config):
|
||||
if run['mode'] == 'fail':
|
||||
raise RuntimeError('generator boom')
|
||||
return [_make_track('first')]
|
||||
|
||||
_register_simple_kind(registry, gen)
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
r1 = mgr.refresh_playlist('hidden_gems', '', 1)
|
||||
assert r1.track_count == 1
|
||||
|
||||
run['mode'] = 'fail'
|
||||
r2 = mgr.refresh_playlist('hidden_gems', '', 1)
|
||||
# Previous snapshot preserved.
|
||||
assert r2.track_count == 1
|
||||
# Error stamped on row.
|
||||
assert r2.last_generation_error is not None
|
||||
assert 'generator boom' in r2.last_generation_error
|
||||
# Tracks still queryable.
|
||||
persisted = mgr.get_playlist_tracks(r2.id)
|
||||
assert len(persisted) == 1
|
||||
|
||||
def test_config_overrides_passed_to_generator(self, db, registry):
|
||||
captured = {}
|
||||
|
||||
def gen(deps, variant, config):
|
||||
captured['limit'] = config.limit
|
||||
return []
|
||||
|
||||
_register_simple_kind(registry, gen)
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
mgr.refresh_playlist('hidden_gems', '', 1, config_overrides={'limit': 200})
|
||||
assert captured['limit'] == 200
|
||||
|
||||
def test_refresh_records_source_from_first_track(self, db, registry):
|
||||
tracks = [_make_track(source='spotify'), _make_track(source='deezer')]
|
||||
_register_simple_kind(registry, lambda *a, **k: tracks)
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
record = mgr.refresh_playlist('hidden_gems', '', 1)
|
||||
assert record.last_generation_source == 'spotify'
|
||||
|
||||
def test_track_data_json_round_trips(self, db, registry):
|
||||
nested = {'id': 'spot-1', 'name': 'Foo', 'artists': [{'name': 'Bar'}]}
|
||||
track = Track(
|
||||
track_name='Foo', artist_name='Bar',
|
||||
spotify_track_id='spot-1', track_data_json=nested,
|
||||
)
|
||||
_register_simple_kind(registry, lambda *a, **k: [track])
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
record = mgr.refresh_playlist('hidden_gems', '', 1)
|
||||
persisted = mgr.get_playlist_tracks(record.id)
|
||||
assert persisted[0].track_data_json == nested
|
||||
|
||||
|
||||
class TestUpdateConfig:
|
||||
def test_patch_merges_with_stored(self, db, registry):
|
||||
_register_simple_kind(registry, lambda *a, **k: [])
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
mgr.ensure_playlist('hidden_gems', '', 1)
|
||||
record = mgr.update_config('hidden_gems', '', 1, {'limit': 75})
|
||||
assert record.config.limit == 75
|
||||
# Other fields kept.
|
||||
assert record.config.max_per_album == 2
|
||||
|
||||
def test_patch_extra_dict_deep_merges(self, db, registry):
|
||||
_register_simple_kind(registry, lambda *a, **k: [])
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
mgr.ensure_playlist('hidden_gems', '', 1)
|
||||
mgr.update_config('hidden_gems', '', 1, {'extra': {'a': 1}})
|
||||
record = mgr.update_config('hidden_gems', '', 1, {'extra': {'b': 2}})
|
||||
assert record.config.extra == {'a': 1, 'b': 2}
|
||||
|
||||
|
||||
class TestListPlaylists:
|
||||
def test_lists_all_playlists_for_profile(self, db, registry):
|
||||
_register_simple_kind(registry, lambda *a, **k: [], kind='hidden_gems')
|
||||
_register_simple_kind(registry, lambda *a, **k: [], kind='popular_picks')
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
mgr.ensure_playlist('hidden_gems', '', 1)
|
||||
mgr.ensure_playlist('popular_picks', '', 1)
|
||||
records = mgr.list_playlists(1)
|
||||
kinds = {r.kind for r in records}
|
||||
assert kinds == {'hidden_gems', 'popular_picks'}
|
||||
|
||||
def test_does_not_list_other_profiles(self, db, registry):
|
||||
_register_simple_kind(registry, lambda *a, **k: [])
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
mgr.ensure_playlist('hidden_gems', '', 1)
|
||||
mgr.ensure_playlist('hidden_gems', '', 2)
|
||||
assert len(mgr.list_playlists(1)) == 1
|
||||
assert len(mgr.list_playlists(2)) == 1
|
||||
|
||||
|
||||
class TestStalenessFilter:
|
||||
"""`config.exclude_recent_days > 0` drops tracks served by this
|
||||
kind for this profile in the last N days."""
|
||||
|
||||
def test_zero_days_means_no_filter(self, db, registry):
|
||||
# Default config has exclude_recent_days=0; everything passes.
|
||||
tracks = [_make_track(sid='spot-1'), _make_track(sid='spot-2')]
|
||||
run = {'tracks': tracks}
|
||||
_register_simple_kind(registry, lambda *a, **k: run['tracks'])
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
r1 = mgr.refresh_playlist('hidden_gems', '', 1)
|
||||
# Refresh again with same tracks — no filter, all should persist.
|
||||
r2 = mgr.refresh_playlist('hidden_gems', '', 1)
|
||||
assert r2.track_count == 2
|
||||
|
||||
def test_positive_days_filters_recently_served(self, db, registry):
|
||||
run = {'tracks': [_make_track(sid='spot-1'), _make_track(sid='spot-2')]}
|
||||
_register_simple_kind(registry, lambda *a, **k: run['tracks'])
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
r1 = mgr.refresh_playlist('hidden_gems', '', 1)
|
||||
assert r1.track_count == 2
|
||||
# Update config to exclude tracks served in last 7 days.
|
||||
mgr.update_config('hidden_gems', '', 1, {'exclude_recent_days': 7})
|
||||
# Same generator output now → all tracks just got served, all filtered out.
|
||||
r2 = mgr.refresh_playlist('hidden_gems', '', 1)
|
||||
assert r2.track_count == 0
|
||||
|
||||
def test_filter_preserves_non_recent_tracks(self, db, registry):
|
||||
run = {'tracks': [_make_track(sid='spot-1')]}
|
||||
_register_simple_kind(registry, lambda *a, **k: run['tracks'])
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
r1 = mgr.refresh_playlist('hidden_gems', '', 1)
|
||||
mgr.update_config('hidden_gems', '', 1, {'exclude_recent_days': 7})
|
||||
# New generator output with a NEW id — should pass.
|
||||
run['tracks'] = [_make_track(sid='spot-1'), _make_track(sid='spot-NEW')]
|
||||
r2 = mgr.refresh_playlist('hidden_gems', '', 1)
|
||||
# spot-1 was just served, dropped. spot-NEW is fresh, kept.
|
||||
assert r2.track_count == 1
|
||||
persisted = mgr.get_playlist_tracks(r2.id)
|
||||
assert persisted[0].spotify_track_id == 'spot-NEW'
|
||||
|
||||
def test_tracks_without_primary_id_pass_through(self, db, registry):
|
||||
# Track with no source IDs — primary_id() is None — staleness
|
||||
# filter has nothing to dedupe on, so the track passes.
|
||||
track_no_id = Track(track_name='X', artist_name='Y', source='spotify')
|
||||
run = {'tracks': [track_no_id]}
|
||||
_register_simple_kind(registry, lambda *a, **k: run['tracks'])
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
mgr.refresh_playlist('hidden_gems', '', 1)
|
||||
mgr.update_config('hidden_gems', '', 1, {'exclude_recent_days': 7})
|
||||
r2 = mgr.refresh_playlist('hidden_gems', '', 1)
|
||||
# Track is kept because there's no id to match against history.
|
||||
assert r2.track_count == 1
|
||||
|
||||
|
||||
class TestStaleFlag:
|
||||
"""`is_stale` flips when upstream data changes; refresh clears it."""
|
||||
|
||||
def test_default_is_false(self, db, registry):
|
||||
_register_simple_kind(registry, lambda *a, **k: [])
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
record = mgr.ensure_playlist('hidden_gems', '', 1)
|
||||
assert record.is_stale is False
|
||||
|
||||
def test_mark_kinds_stale_flips_flag(self, db, registry):
|
||||
_register_simple_kind(registry, lambda *a, **k: [], kind='hidden_gems')
|
||||
_register_simple_kind(registry, lambda *a, **k: [], kind='discovery_shuffle')
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
mgr.ensure_playlist('hidden_gems', '', 1)
|
||||
mgr.ensure_playlist('discovery_shuffle', '', 1)
|
||||
n = mgr.mark_kinds_stale(['hidden_gems', 'discovery_shuffle'])
|
||||
assert n == 2
|
||||
assert mgr.ensure_playlist('hidden_gems', '', 1).is_stale is True
|
||||
assert mgr.ensure_playlist('discovery_shuffle', '', 1).is_stale is True
|
||||
|
||||
def test_mark_kinds_stale_only_matching_kinds(self, db, registry):
|
||||
_register_simple_kind(registry, lambda *a, **k: [], kind='hidden_gems')
|
||||
_register_simple_kind(registry, lambda *a, **k: [], kind='popular_picks')
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
mgr.ensure_playlist('hidden_gems', '', 1)
|
||||
mgr.ensure_playlist('popular_picks', '', 1)
|
||||
mgr.mark_kinds_stale(['hidden_gems'])
|
||||
assert mgr.ensure_playlist('hidden_gems', '', 1).is_stale is True
|
||||
assert mgr.ensure_playlist('popular_picks', '', 1).is_stale is False
|
||||
|
||||
def test_mark_kinds_stale_scopes_to_profile(self, db, registry):
|
||||
_register_simple_kind(registry, lambda *a, **k: [])
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
mgr.ensure_playlist('hidden_gems', '', 1)
|
||||
mgr.ensure_playlist('hidden_gems', '', 2)
|
||||
mgr.mark_kinds_stale(['hidden_gems'], profile_id=1)
|
||||
assert mgr.ensure_playlist('hidden_gems', '', 1).is_stale is True
|
||||
assert mgr.ensure_playlist('hidden_gems', '', 2).is_stale is False
|
||||
|
||||
def test_refresh_clears_stale_flag(self, db, registry):
|
||||
_register_simple_kind(registry, lambda *a, **k: [_make_track()])
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
mgr.ensure_playlist('hidden_gems', '', 1)
|
||||
mgr.mark_kinds_stale(['hidden_gems'])
|
||||
assert mgr.ensure_playlist('hidden_gems', '', 1).is_stale is True
|
||||
record = mgr.refresh_playlist('hidden_gems', '', 1)
|
||||
assert record.is_stale is False
|
||||
|
||||
def test_mark_kinds_stale_empty_list_noop(self, db, registry):
|
||||
_register_simple_kind(registry, lambda *a, **k: [])
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
mgr.ensure_playlist('hidden_gems', '', 1)
|
||||
n = mgr.mark_kinds_stale([])
|
||||
assert n == 0
|
||||
|
||||
|
||||
class TestStalenessHistory:
|
||||
def test_recent_track_ids_returns_zero_when_days_zero(self, db, registry):
|
||||
_register_simple_kind(registry, lambda *a, **k: [_make_track(sid='spot-1')])
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
mgr.refresh_playlist('hidden_gems', '', 1)
|
||||
assert mgr.recent_track_ids(1, 'hidden_gems', 0) == []
|
||||
|
||||
def test_recent_track_ids_after_refresh(self, db, registry):
|
||||
_register_simple_kind(
|
||||
registry,
|
||||
lambda *a, **k: [_make_track(sid='spot-1'), _make_track(sid='spot-2')],
|
||||
)
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
mgr.refresh_playlist('hidden_gems', '', 1)
|
||||
recent = mgr.recent_track_ids(1, 'hidden_gems', 7)
|
||||
assert set(recent) == {'spot-1', 'spot-2'}
|
||||
|
||||
def test_recent_track_ids_scoped_to_kind(self, db, registry):
|
||||
_register_simple_kind(registry, lambda *a, **k: [_make_track(sid='gem-1')], kind='hidden_gems')
|
||||
_register_simple_kind(registry, lambda *a, **k: [_make_track(sid='pop-1')], kind='popular_picks')
|
||||
mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry)
|
||||
mgr.refresh_playlist('hidden_gems', '', 1)
|
||||
mgr.refresh_playlist('popular_picks', '', 1)
|
||||
assert mgr.recent_track_ids(1, 'hidden_gems', 7) == ['gem-1']
|
||||
assert mgr.recent_track_ids(1, 'popular_picks', 7) == ['pop-1']
|
||||
602
tests/test_reorganize_tag_source.py
Normal file
602
tests/test_reorganize_tag_source.py
Normal file
|
|
@ -0,0 +1,602 @@
|
|||
"""Boundary tests for ``core.library.reorganize_tag_source``.
|
||||
|
||||
Pin every shape the embedded-tag → reorganize-context adapter has to
|
||||
handle so future drift fails here instead of at runtime against a
|
||||
real library: empty / missing essentials, multi-value vs single-string
|
||||
artist tags, ID3-style ``"5/12"`` track-number values, year
|
||||
normalization across date shapes, releasetype validation, multi-disc
|
||||
parsing, defensive paths against bad input.
|
||||
|
||||
The wrapper :func:`read_album_track_from_file` is tested against a
|
||||
fake ``read_embedded_tags_fn`` so no real mutagen IO happens here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from typing import Any, Dict
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── stubs (other tests rely on these too — keep the shape consistent) ──
|
||||
if 'utils.logging_config' not in sys.modules:
|
||||
utils_mod = types.ModuleType('utils')
|
||||
logging_mod = types.ModuleType('utils.logging_config')
|
||||
logging_mod.get_logger = lambda name: type('L', (), {
|
||||
'debug': lambda *a, **k: None,
|
||||
'info': lambda *a, **k: None,
|
||||
'warning': lambda *a, **k: None,
|
||||
'error': lambda *a, **k: None,
|
||||
})()
|
||||
sys.modules['utils'] = utils_mod
|
||||
sys.modules['utils.logging_config'] = logging_mod
|
||||
|
||||
|
||||
from core.library.reorganize_tag_source import (
|
||||
extract_album_meta_from_tags,
|
||||
extract_track_meta_from_tags,
|
||||
read_album_track_from_file,
|
||||
normalize_resolved_path,
|
||||
)
|
||||
|
||||
|
||||
# ─── extract_track_meta_from_tags ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestExtractTrackMeta:
|
||||
def test_full_set_returns_canonical_shape(self):
|
||||
out = extract_track_meta_from_tags({
|
||||
'title': 'HUMBLE.',
|
||||
'artist': 'Kendrick Lamar',
|
||||
'tracknumber': '4',
|
||||
'discnumber': '1',
|
||||
})
|
||||
assert out is not None
|
||||
assert out['name'] == 'HUMBLE.'
|
||||
assert out['title'] == 'HUMBLE.'
|
||||
assert out['track_number'] == 4
|
||||
assert out['disc_number'] == 1
|
||||
assert out['artists'] == [{'name': 'Kendrick Lamar'}]
|
||||
assert out['duration_ms'] == 0
|
||||
assert out['id'] == ''
|
||||
assert out['uri'] == ''
|
||||
|
||||
def test_missing_title_returns_none(self):
|
||||
assert extract_track_meta_from_tags({'artist': 'foo'}) is None
|
||||
assert extract_track_meta_from_tags({'title': '', 'artist': 'foo'}) is None
|
||||
assert extract_track_meta_from_tags({'title': ' ', 'artist': 'foo'}) is None
|
||||
|
||||
def test_missing_artist_returns_none(self):
|
||||
assert extract_track_meta_from_tags({'title': 'Song'}) is None
|
||||
assert extract_track_meta_from_tags({'title': 'Song', 'artist': ''}) is None
|
||||
|
||||
def test_multi_value_artists_field_takes_precedence(self):
|
||||
out = extract_track_meta_from_tags({
|
||||
'title': 'Collab',
|
||||
'artist': 'Foo Bar',
|
||||
'artists': 'Foo, Bar, Baz', # multi-value tag joined by reader
|
||||
})
|
||||
assert out is not None
|
||||
assert out['artists'] == [{'name': 'Foo'}, {'name': 'Bar'}, {'name': 'Baz'}]
|
||||
|
||||
def test_artist_string_split_on_known_separators(self):
|
||||
for sep_input, expected in [
|
||||
('Foo, Bar', ['Foo', 'Bar']),
|
||||
('Foo & Bar', ['Foo', 'Bar']),
|
||||
('Foo feat. Bar', ['Foo', 'Bar']),
|
||||
('Foo ft Bar', ['Foo', 'Bar']),
|
||||
('Foo featuring Bar', ['Foo', 'Bar']),
|
||||
('Foo / Bar', ['Foo', 'Bar']),
|
||||
('Foo; Bar', ['Foo', 'Bar']),
|
||||
('Foo x Bar', ['Foo', 'Bar']),
|
||||
('Foo with Bar', ['Foo', 'Bar']),
|
||||
]:
|
||||
out = extract_track_meta_from_tags({'title': 't', 'artist': sep_input})
|
||||
assert out is not None
|
||||
names = [a['name'] for a in out['artists']]
|
||||
assert names == expected, f"failed for {sep_input!r}: got {names}"
|
||||
|
||||
def test_artist_dedup_case_insensitive(self):
|
||||
out = extract_track_meta_from_tags({
|
||||
'title': 't',
|
||||
'artists': 'Foo, foo, FOO, Bar',
|
||||
})
|
||||
assert out is not None
|
||||
names = [a['name'] for a in out['artists']]
|
||||
assert names == ['Foo', 'Bar']
|
||||
|
||||
def test_id3_track_total_shape(self):
|
||||
# ID3 stores TRCK as "5/12" — caller must use the head only.
|
||||
out = extract_track_meta_from_tags({
|
||||
'title': 't', 'artist': 'a',
|
||||
'tracknumber': '5/12',
|
||||
})
|
||||
assert out['track_number'] == 5
|
||||
|
||||
def test_disc_total_shape(self):
|
||||
out = extract_track_meta_from_tags({
|
||||
'title': 't', 'artist': 'a',
|
||||
'discnumber': '2/2',
|
||||
})
|
||||
assert out['disc_number'] == 2
|
||||
|
||||
def test_track_number_default_to_one(self):
|
||||
out = extract_track_meta_from_tags({
|
||||
'title': 't', 'artist': 'a',
|
||||
})
|
||||
assert out['track_number'] == 1
|
||||
assert out['disc_number'] == 1
|
||||
|
||||
def test_track_number_zero_or_negative_falls_back_to_one(self):
|
||||
out = extract_track_meta_from_tags({
|
||||
'title': 't', 'artist': 'a', 'tracknumber': '0',
|
||||
})
|
||||
assert out['track_number'] == 1 # or-default of 0 → 1
|
||||
|
||||
def test_track_number_unparseable_falls_back_to_one(self):
|
||||
out = extract_track_meta_from_tags({
|
||||
'title': 't', 'artist': 'a',
|
||||
'tracknumber': 'side-a-2',
|
||||
})
|
||||
assert out['track_number'] == 1
|
||||
|
||||
def test_int_track_number(self):
|
||||
out = extract_track_meta_from_tags({
|
||||
'title': 't', 'artist': 'a',
|
||||
'tracknumber': 5,
|
||||
'discnumber': 2,
|
||||
})
|
||||
assert out['track_number'] == 5
|
||||
assert out['disc_number'] == 2
|
||||
|
||||
def test_float_track_number_truncated(self):
|
||||
out = extract_track_meta_from_tags({
|
||||
'title': 't', 'artist': 'a',
|
||||
'tracknumber': 5.7,
|
||||
})
|
||||
assert out['track_number'] == 5
|
||||
|
||||
def test_zero_padded_track_number(self):
|
||||
out = extract_track_meta_from_tags({
|
||||
'title': 't', 'artist': 'a',
|
||||
'tracknumber': '03',
|
||||
})
|
||||
assert out['track_number'] == 3
|
||||
|
||||
def test_non_dict_input(self):
|
||||
assert extract_track_meta_from_tags(None) is None
|
||||
assert extract_track_meta_from_tags([]) is None
|
||||
assert extract_track_meta_from_tags('') is None
|
||||
|
||||
def test_empty_dict(self):
|
||||
assert extract_track_meta_from_tags({}) is None
|
||||
|
||||
|
||||
# ─── extract_album_meta_from_tags ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestExtractAlbumMeta:
|
||||
def test_full_set(self):
|
||||
out = extract_album_meta_from_tags({
|
||||
'album': 'DAMN.',
|
||||
'albumartist': 'Kendrick Lamar',
|
||||
'date': '2017-04-14',
|
||||
'totaltracks': '14',
|
||||
'releasetype': 'Album',
|
||||
})
|
||||
assert out['name'] == 'DAMN.'
|
||||
assert out['title'] == 'DAMN.'
|
||||
assert out['album_artist'] == 'Kendrick Lamar'
|
||||
assert out['release_date'] == '2017'
|
||||
assert out['total_tracks'] == 14
|
||||
assert out['album_type'] == 'album'
|
||||
assert out['image_url'] == ''
|
||||
assert out['id'] == ''
|
||||
|
||||
def test_year_normalization_from_full_date(self):
|
||||
for date_input, expected_year in [
|
||||
('2020-01-15', '2020'),
|
||||
('2020', '2020'),
|
||||
('2020-01', '2020'),
|
||||
('Jan 5, 2020', '2020'),
|
||||
('1999/12/31', '1999'),
|
||||
]:
|
||||
out = extract_album_meta_from_tags({'album': 'a', 'date': date_input})
|
||||
assert out['release_date'] == expected_year, f"date={date_input!r}"
|
||||
|
||||
def test_year_falls_back_to_year_field(self):
|
||||
out = extract_album_meta_from_tags({'album': 'a', 'year': '2018'})
|
||||
assert out['release_date'] == '2018'
|
||||
|
||||
def test_year_falls_back_to_originaldate(self):
|
||||
out = extract_album_meta_from_tags({'album': 'a', 'originaldate': '2010'})
|
||||
assert out['release_date'] == '2010'
|
||||
|
||||
def test_year_missing_returns_empty(self):
|
||||
out = extract_album_meta_from_tags({'album': 'a'})
|
||||
assert out['release_date'] == ''
|
||||
|
||||
def test_totaltracks_from_id3_shape(self):
|
||||
# ID3 may store track_number as "5/12" — use the trailing 12.
|
||||
out = extract_album_meta_from_tags({
|
||||
'album': 'a', 'tracknumber': '5/12',
|
||||
})
|
||||
assert out['total_tracks'] == 12
|
||||
|
||||
def test_totaltracks_explicit_field_wins(self):
|
||||
out = extract_album_meta_from_tags({
|
||||
'album': 'a', 'totaltracks': '14', 'tracknumber': '5/12',
|
||||
})
|
||||
assert out['total_tracks'] == 14
|
||||
|
||||
def test_totaltracks_tracktotal_alias(self):
|
||||
out = extract_album_meta_from_tags({'album': 'a', 'tracktotal': '8'})
|
||||
assert out['total_tracks'] == 8
|
||||
|
||||
def test_releasetype_canonical(self):
|
||||
for input_val, expected in [
|
||||
('album', 'album'), ('Album', 'album'),
|
||||
('single', 'single'), ('Single', 'single'),
|
||||
('ep', 'ep'), ('EP', 'ep'),
|
||||
('compilation', 'compilation'),
|
||||
('soundtrack', ''), # not in canonical set
|
||||
('mixtape', ''),
|
||||
('', ''),
|
||||
]:
|
||||
out = extract_album_meta_from_tags({
|
||||
'album': 'a', 'releasetype': input_val,
|
||||
})
|
||||
assert out['album_type'] == expected, f"releasetype={input_val!r}"
|
||||
|
||||
def test_total_discs_explicit_field(self):
|
||||
out = extract_album_meta_from_tags({
|
||||
'album': 'a', 'totaldiscs': '2',
|
||||
})
|
||||
assert out['total_discs'] == 2
|
||||
|
||||
def test_total_discs_from_id3_disc_form(self):
|
||||
out = extract_album_meta_from_tags({
|
||||
'album': 'a', 'discnumber': '1/2',
|
||||
})
|
||||
assert out['total_discs'] == 2
|
||||
|
||||
def test_total_discs_explicit_wins_over_disc_form(self):
|
||||
# When both present, take the larger (defensive against drift).
|
||||
out = extract_album_meta_from_tags({
|
||||
'album': 'a', 'discnumber': '1/2', 'totaldiscs': '3',
|
||||
})
|
||||
assert out['total_discs'] == 3
|
||||
|
||||
def test_total_discs_missing_zero(self):
|
||||
out = extract_album_meta_from_tags({
|
||||
'album': 'a', 'discnumber': '1',
|
||||
})
|
||||
assert out['total_discs'] == 0 # caller defaults via max() with disc count
|
||||
|
||||
def test_album_artist_underscore_alias(self):
|
||||
out = extract_album_meta_from_tags({
|
||||
'album': 'a', 'album_artist': 'Foo',
|
||||
})
|
||||
assert out['album_artist'] == 'Foo'
|
||||
|
||||
def test_missing_album_returns_empty_name(self):
|
||||
out = extract_album_meta_from_tags({})
|
||||
assert out['name'] == ''
|
||||
# All other fields should still be present (zero/empty), so the
|
||||
# caller's downstream consumer doesn't KeyError.
|
||||
assert 'release_date' in out
|
||||
assert 'total_tracks' in out
|
||||
|
||||
def test_non_dict_input_safe(self):
|
||||
out = extract_album_meta_from_tags(None) # type: ignore
|
||||
assert out['name'] == ''
|
||||
|
||||
|
||||
# ─── read_album_track_from_file ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestReadAlbumTrackFromFile:
|
||||
def test_unavailable_result_returns_reason(self):
|
||||
def fake_reader(_p):
|
||||
return {'available': False, 'reason': 'No file.'}
|
||||
|
||||
a, t, err = read_album_track_from_file('fake', read_embedded_tags_fn=fake_reader)
|
||||
assert a is None and t is None
|
||||
assert err == 'No file.'
|
||||
|
||||
def test_unavailable_no_reason_falls_back(self):
|
||||
def fake_reader(_p):
|
||||
return {'available': False}
|
||||
|
||||
_, _, err = read_album_track_from_file('fake', read_embedded_tags_fn=fake_reader)
|
||||
assert 'Could not read embedded tags' in (err or '')
|
||||
|
||||
def test_non_dict_result_safe(self):
|
||||
def fake_reader(_p):
|
||||
return None
|
||||
|
||||
a, t, err = read_album_track_from_file('fake', read_embedded_tags_fn=fake_reader)
|
||||
assert a is None and t is None
|
||||
assert err
|
||||
|
||||
def test_essentials_missing_track_returns_reason(self):
|
||||
# Title missing → unmatched.
|
||||
def fake_reader(_p):
|
||||
return {'available': True, 'tags': {'artist': 'a', 'album': 'b'}}
|
||||
|
||||
a, t, err = read_album_track_from_file('fake', read_embedded_tags_fn=fake_reader)
|
||||
assert a is None and t is None
|
||||
assert 'title' in (err or '').lower()
|
||||
|
||||
def test_essentials_missing_album_returns_reason(self):
|
||||
# Album missing → unmatched even if track meta extracted.
|
||||
def fake_reader(_p):
|
||||
return {'available': True, 'tags': {'title': 't', 'artist': 'a'}}
|
||||
|
||||
a, t, err = read_album_track_from_file('fake', read_embedded_tags_fn=fake_reader)
|
||||
assert a is None and t is None
|
||||
assert 'album' in (err or '').lower()
|
||||
|
||||
def test_full_extraction(self):
|
||||
def fake_reader(_p):
|
||||
return {
|
||||
'available': True,
|
||||
'duration': 234.5,
|
||||
'tags': {
|
||||
'title': 'HUMBLE.',
|
||||
'artist': 'Kendrick Lamar',
|
||||
'album': 'DAMN.',
|
||||
'albumartist': 'Kendrick Lamar',
|
||||
'tracknumber': '4/14',
|
||||
'discnumber': '1/1',
|
||||
'date': '2017-04-14',
|
||||
'releasetype': 'Album',
|
||||
},
|
||||
}
|
||||
|
||||
album, track, err = read_album_track_from_file(
|
||||
'/fake.flac', read_embedded_tags_fn=fake_reader,
|
||||
)
|
||||
assert err is None
|
||||
assert track is not None and album is not None
|
||||
assert track['name'] == 'HUMBLE.'
|
||||
assert track['track_number'] == 4
|
||||
assert track['disc_number'] == 1
|
||||
assert track['duration_ms'] == 234500
|
||||
assert album['name'] == 'DAMN.'
|
||||
assert album['release_date'] == '2017'
|
||||
assert album['total_tracks'] == 14
|
||||
assert album['album_type'] == 'album'
|
||||
|
||||
def test_duration_zero_when_missing(self):
|
||||
def fake_reader(_p):
|
||||
return {
|
||||
'available': True,
|
||||
'tags': {
|
||||
'title': 't', 'artist': 'a', 'album': 'b',
|
||||
},
|
||||
}
|
||||
|
||||
_, track, err = read_album_track_from_file('fake', read_embedded_tags_fn=fake_reader)
|
||||
assert err is None
|
||||
assert track['duration_ms'] == 0
|
||||
|
||||
def test_duration_unparseable_zero(self):
|
||||
def fake_reader(_p):
|
||||
return {
|
||||
'available': True,
|
||||
'duration': 'banana',
|
||||
'tags': {'title': 't', 'artist': 'a', 'album': 'b'},
|
||||
}
|
||||
|
||||
_, track, err = read_album_track_from_file('fake', read_embedded_tags_fn=fake_reader)
|
||||
assert err is None
|
||||
assert track['duration_ms'] == 0
|
||||
|
||||
def test_empty_path(self):
|
||||
a, t, err = read_album_track_from_file('')
|
||||
assert a is None and t is None
|
||||
assert err
|
||||
|
||||
def test_non_string_path(self):
|
||||
a, t, err = read_album_track_from_file(None) # type: ignore
|
||||
assert a is None and t is None
|
||||
assert err
|
||||
|
||||
|
||||
# ─── normalize_resolved_path ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNormalizeResolvedPath:
|
||||
def test_returns_path_when_exists(self, tmp_path):
|
||||
f = tmp_path / 'x.flac'
|
||||
f.write_bytes(b'')
|
||||
assert normalize_resolved_path(str(f)) == str(f)
|
||||
|
||||
def test_none_when_missing(self, tmp_path):
|
||||
assert normalize_resolved_path(str(tmp_path / 'no.flac')) is None
|
||||
|
||||
def test_empty_input_safe(self):
|
||||
assert normalize_resolved_path('') is None
|
||||
assert normalize_resolved_path(None) is None
|
||||
|
||||
|
||||
# ─── plan_album_reorganize (tag-mode integration) ────────────────────
|
||||
#
|
||||
# Pin the wiring between the planner branch and the tag-source helper
|
||||
# so the additive-and-optional contract holds: API mode unchanged,
|
||||
# tag mode produces matched plan items shaped like API mode (so
|
||||
# downstream post-process treats them identically).
|
||||
|
||||
|
||||
def _stub_metadata_service(monkeypatch):
|
||||
"""Inject a minimal `core.metadata_service` so `library_reorganize`
|
||||
imports cleanly even in the test process where the real metadata
|
||||
clients aren't wired."""
|
||||
if 'core' not in sys.modules:
|
||||
sys.modules['core'] = types.ModuleType('core')
|
||||
if 'core.metadata_service' in sys.modules:
|
||||
return
|
||||
fake = types.ModuleType('core.metadata_service')
|
||||
fake.get_album_for_source = lambda *a, **k: {}
|
||||
fake.get_album_tracks_for_source = lambda *a, **k: []
|
||||
fake.get_client_for_source = lambda *a, **k: None
|
||||
fake.get_primary_source = lambda: 'deezer'
|
||||
fake.get_source_priority = lambda primary=None: ['deezer', 'spotify', 'itunes']
|
||||
sys.modules['core.metadata_service'] = fake
|
||||
|
||||
|
||||
class TestPlannerTagModeIntegration:
|
||||
def test_tag_mode_planner_matches_every_track_with_good_tags(self, monkeypatch):
|
||||
_stub_metadata_service(monkeypatch)
|
||||
from core import library_reorganize as lr
|
||||
|
||||
per_path = {
|
||||
'/a/track1.flac': {
|
||||
'available': True, 'duration': 200,
|
||||
'tags': {
|
||||
'title': 'Song A', 'artist': 'Foo', 'album': 'AlbumX',
|
||||
'tracknumber': '1/3', 'discnumber': '1/1',
|
||||
'date': '2020', 'releasetype': 'Album',
|
||||
},
|
||||
},
|
||||
'/a/track2.flac': {
|
||||
'available': True, 'duration': 230,
|
||||
'tags': {
|
||||
'title': 'Song B', 'artist': 'Foo', 'album': 'AlbumX',
|
||||
'tracknumber': '2/3', 'discnumber': '1/1',
|
||||
'date': '2020', 'releasetype': 'Album',
|
||||
},
|
||||
},
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
'core.library.file_tags.read_embedded_tags',
|
||||
lambda p: per_path.get(p, {'available': False, 'reason': 'missing'}),
|
||||
)
|
||||
plan = lr.plan_album_reorganize(
|
||||
album_data={'artist_name': 'Foo', 'title': 'AlbumX'},
|
||||
tracks=[
|
||||
{'id': 't1', 'title': 'Song A', 'track_number': 1, 'file_path': '/a/track1.flac'},
|
||||
{'id': 't2', 'title': 'Song B', 'track_number': 2, 'file_path': '/a/track2.flac'},
|
||||
],
|
||||
metadata_source='tags',
|
||||
resolve_file_path_fn=lambda p: p,
|
||||
)
|
||||
assert plan['status'] == 'planned'
|
||||
assert plan['source'] == 'tags'
|
||||
assert plan['total_discs'] == 1
|
||||
assert len(plan['items']) == 2
|
||||
for it in plan['items']:
|
||||
assert it['matched'] is True
|
||||
assert it['api_track']['name'] in ('Song A', 'Song B')
|
||||
assert it['api_album']['name'] == 'AlbumX'
|
||||
assert it['api_album']['album_type'] == 'album'
|
||||
|
||||
def test_tag_mode_partial_disc_uses_tagged_total_discs(self, monkeypatch):
|
||||
# User has only disc 2 of a 2-disc album; tags say so.
|
||||
# max_disc must reflect tagged total so path builder still
|
||||
# routes into the multi-disc subfolder.
|
||||
_stub_metadata_service(monkeypatch)
|
||||
from core import library_reorganize as lr
|
||||
|
||||
monkeypatch.setattr(
|
||||
'core.library.file_tags.read_embedded_tags',
|
||||
lambda p: {
|
||||
'available': True,
|
||||
'tags': {
|
||||
'title': 'Song A', 'artist': 'Foo', 'album': 'Y',
|
||||
'tracknumber': '1/8', 'discnumber': '2/2',
|
||||
'totaldiscs': '2',
|
||||
},
|
||||
},
|
||||
)
|
||||
plan = lr.plan_album_reorganize(
|
||||
album_data={'artist_name': 'Foo', 'title': 'Y'},
|
||||
tracks=[{'id': 't1', 'title': 'Song A', 'track_number': 1, 'file_path': '/a.flac'}],
|
||||
metadata_source='tags',
|
||||
resolve_file_path_fn=lambda p: p,
|
||||
)
|
||||
assert plan['status'] == 'planned'
|
||||
assert plan['total_discs'] == 2
|
||||
|
||||
def test_tag_mode_file_missing_unmatched_with_reason(self, monkeypatch):
|
||||
_stub_metadata_service(monkeypatch)
|
||||
from core import library_reorganize as lr
|
||||
|
||||
plan = lr.plan_album_reorganize(
|
||||
album_data={'artist_name': 'Foo', 'title': 'X'},
|
||||
tracks=[{'id': 't1', 'title': 'Song', 'track_number': 1, 'file_path': '/missing.flac'}],
|
||||
metadata_source='tags',
|
||||
resolve_file_path_fn=lambda p: None, # always missing
|
||||
)
|
||||
# All tracks unmatched → no_source_id status, source='tags'.
|
||||
assert plan['status'] == 'no_source_id'
|
||||
assert plan['source'] == 'tags'
|
||||
assert plan['items'][0]['matched'] is False
|
||||
assert 'no longer exists' in plan['items'][0]['reason'].lower()
|
||||
|
||||
def test_tag_mode_some_match_some_unreadable(self, monkeypatch):
|
||||
_stub_metadata_service(monkeypatch)
|
||||
from core import library_reorganize as lr
|
||||
|
||||
per_path = {
|
||||
'/good.flac': {
|
||||
'available': True,
|
||||
'tags': {'title': 'Good', 'artist': 'A', 'album': 'X', 'tracknumber': '1/2'},
|
||||
},
|
||||
'/bad.flac': {'available': False, 'reason': 'unreadable'},
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
'core.library.file_tags.read_embedded_tags',
|
||||
lambda p: per_path.get(p, {'available': False, 'reason': 'missing'}),
|
||||
)
|
||||
plan = lr.plan_album_reorganize(
|
||||
album_data={'artist_name': 'A', 'title': 'X'},
|
||||
tracks=[
|
||||
{'id': 'g', 'title': 'Good', 'track_number': 1, 'file_path': '/good.flac'},
|
||||
{'id': 'b', 'title': 'Bad', 'track_number': 2, 'file_path': '/bad.flac'},
|
||||
],
|
||||
metadata_source='tags',
|
||||
resolve_file_path_fn=lambda p: p,
|
||||
)
|
||||
assert plan['status'] == 'planned'
|
||||
assert plan['source'] == 'tags'
|
||||
matched = [it for it in plan['items'] if it['matched']]
|
||||
unmatched = [it for it in plan['items'] if not it['matched']]
|
||||
assert len(matched) == 1
|
||||
assert len(unmatched) == 1
|
||||
assert unmatched[0]['reason'] == 'unreadable'
|
||||
|
||||
def test_tag_mode_without_resolver_returns_no_source_id(self, monkeypatch):
|
||||
# Defensive: caller forgot to pass resolve_file_path_fn.
|
||||
_stub_metadata_service(monkeypatch)
|
||||
from core import library_reorganize as lr
|
||||
|
||||
plan = lr.plan_album_reorganize(
|
||||
album_data={'artist_name': 'A', 'title': 'X'},
|
||||
tracks=[{'id': 't1', 'title': 'Song', 'track_number': 1, 'file_path': '/a.flac'}],
|
||||
metadata_source='tags',
|
||||
resolve_file_path_fn=None,
|
||||
)
|
||||
assert plan['status'] == 'no_source_id'
|
||||
assert plan['items'][0]['matched'] is False
|
||||
assert 'requires the file path resolver' in plan['items'][0]['reason']
|
||||
|
||||
def test_api_mode_unchanged_default(self, monkeypatch):
|
||||
# Regression guard: omitting metadata_source preserves the API
|
||||
# path — calls _resolve_source which calls our stubbed
|
||||
# metadata_service. Should land in 'no_source_id' since stubs
|
||||
# return empty.
|
||||
_stub_metadata_service(monkeypatch)
|
||||
from core import library_reorganize as lr
|
||||
|
||||
plan = lr.plan_album_reorganize(
|
||||
album_data={'artist_name': 'Foo', 'title': 'Bar'},
|
||||
tracks=[{'id': 't1', 'title': 'Song', 'track_number': 1, 'file_path': '/a.flac'}],
|
||||
)
|
||||
# No metadata_source param → defaults to 'api' → empty stubs
|
||||
# produce no_source_id.
|
||||
assert plan['status'] == 'no_source_id'
|
||||
assert plan['source'] is None # never reached the tags branch
|
||||
0
tests/tools/__init__.py
Normal file
0
tests/tools/__init__.py
Normal file
1024
tests/tools/test_amazon_client.py
Normal file
1024
tests/tools/test_amazon_client.py
Normal file
File diff suppressed because it is too large
Load diff
847
tests/tools/test_amazon_download_client.py
Normal file
847
tests/tools/test_amazon_download_client.py
Normal file
|
|
@ -0,0 +1,847 @@
|
|||
"""Unit tests for core/amazon_download_client.py.
|
||||
|
||||
All network I/O and subprocess calls are mocked.
|
||||
No real T2Tunes instance or ffmpeg binary required.
|
||||
|
||||
Run from project root:
|
||||
python -m pytest tests/tools/test_amazon_download_client.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from core.amazon_client import AmazonClientError, T2TunesStreamInfo
|
||||
from core.amazon_download_client import (
|
||||
AmazonDownloadClient,
|
||||
MIN_AUDIO_BYTES,
|
||||
_codec_key,
|
||||
_file_extension,
|
||||
_quality_label,
|
||||
)
|
||||
from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers / fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def _stream_info(
|
||||
*,
|
||||
asin: str = "B09XYZ1234",
|
||||
streamable: bool = True,
|
||||
codec: str = "FLAC",
|
||||
sample_rate: int = 44100,
|
||||
stream_url: str = "https://cdn.example.com/track.enc.flac",
|
||||
decryption_key: Optional[str] = "deadbeef1234",
|
||||
) -> T2TunesStreamInfo:
|
||||
return T2TunesStreamInfo(
|
||||
asin=asin,
|
||||
streamable=streamable,
|
||||
codec=codec,
|
||||
format=codec,
|
||||
sample_rate=sample_rate,
|
||||
stream_url=stream_url,
|
||||
decryption_key=decryption_key,
|
||||
title="Not Like Us",
|
||||
artist="Kendrick Lamar",
|
||||
album="GNX",
|
||||
isrc="USRC12345678",
|
||||
)
|
||||
|
||||
|
||||
def _search_items(n_tracks: int = 2, n_albums: int = 1):
|
||||
from core.amazon_client import T2TunesSearchItem
|
||||
items = [
|
||||
T2TunesSearchItem(
|
||||
asin=f"B0TRACK{i}",
|
||||
title=f"Track {i}",
|
||||
artist_name="Kendrick Lamar",
|
||||
item_type="MusicTrack",
|
||||
album_name="GNX",
|
||||
album_asin="B0ALBUM1",
|
||||
duration_seconds=200 + i * 10,
|
||||
isrc=f"USRC{i:08d}",
|
||||
)
|
||||
for i in range(n_tracks)
|
||||
]
|
||||
items += [
|
||||
T2TunesSearchItem(
|
||||
asin=f"B0ALBUM{j}",
|
||||
title=f"Album {j}",
|
||||
artist_name="Kendrick Lamar",
|
||||
item_type="MusicAlbum",
|
||||
album_name=f"Album {j}",
|
||||
album_asin=f"B0ALBUM{j}",
|
||||
duration_seconds=0,
|
||||
)
|
||||
for j in range(n_albums)
|
||||
]
|
||||
return items
|
||||
|
||||
|
||||
def _make_client(tmp_path: Path) -> AmazonDownloadClient:
|
||||
with patch("core.amazon_download_client.config_manager") as cfg:
|
||||
cfg.get.return_value = str(tmp_path)
|
||||
with patch("core.amazon_client.config_manager") as cfg2:
|
||||
cfg2.get.return_value = ""
|
||||
client = AmazonDownloadClient(download_path=str(tmp_path))
|
||||
return client
|
||||
|
||||
|
||||
def _fake_chunked_response(data: bytes, status_code: int = 200) -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.ok = status_code < 400
|
||||
resp.headers = {"content-length": str(len(data))}
|
||||
|
||||
chunk_size = 4096
|
||||
chunks = [data[i : i + chunk_size] for i in range(0, len(data), chunk_size)] or [b""]
|
||||
|
||||
def iter_content(chunk_size=None):
|
||||
yield from chunks
|
||||
|
||||
resp.iter_content = iter_content
|
||||
if status_code >= 400:
|
||||
from requests import HTTPError
|
||||
resp.raise_for_status.side_effect = HTTPError(response=resp)
|
||||
else:
|
||||
resp.raise_for_status.return_value = None
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Codec / quality helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCodecHelpers:
|
||||
def test_codec_key_lowercases(self):
|
||||
assert _codec_key("FLAC") == "flac"
|
||||
assert _codec_key("OGG-Vorbis") == "ogg_vorbis"
|
||||
assert _codec_key("EAC3") == "eac3"
|
||||
|
||||
def test_file_extension_known_codecs(self):
|
||||
assert _file_extension("FLAC") == "flac"
|
||||
assert _file_extension("ogg_vorbis") == "ogg"
|
||||
assert _file_extension("opus") == "opus"
|
||||
assert _file_extension("eac3") == "eac3"
|
||||
assert _file_extension("mp4") == "m4a"
|
||||
assert _file_extension("aac") == "m4a"
|
||||
assert _file_extension("mp3") == "mp3"
|
||||
|
||||
def test_file_extension_unknown_falls_back(self):
|
||||
assert _file_extension("wtf_codec") == "bin"
|
||||
|
||||
def test_quality_label_flac_lossless(self):
|
||||
assert _quality_label("flac", 44100) == "Lossless"
|
||||
assert _quality_label("FLAC", 48000) == "Lossless"
|
||||
|
||||
def test_quality_label_flac_hires(self):
|
||||
assert _quality_label("flac", 96000) == "Hi-Res"
|
||||
assert _quality_label("flac", 192000) == "Hi-Res"
|
||||
|
||||
def test_quality_label_lossy(self):
|
||||
assert _quality_label("opus") == "Lossy"
|
||||
assert _quality_label("eac3") == "Lossy"
|
||||
assert _quality_label("mp3") == "Lossy"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_configured / check_connection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIsConfigured:
|
||||
def test_always_true(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
assert client.is_configured() is True
|
||||
|
||||
|
||||
class TestCheckConnection:
|
||||
def test_true_when_client_authenticated(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
client._client = MagicMock()
|
||||
client._client.is_authenticated.return_value = True
|
||||
assert run(client.check_connection()) is True
|
||||
|
||||
def test_false_when_client_down(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
client._client = MagicMock()
|
||||
client._client.is_authenticated.return_value = False
|
||||
assert run(client.check_connection()) is False
|
||||
|
||||
def test_false_on_exception(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
client._client = MagicMock()
|
||||
client._client.is_authenticated.side_effect = Exception("timeout")
|
||||
assert run(client.check_connection()) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# search()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSearch:
|
||||
def test_returns_track_results(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
client._client = MagicMock()
|
||||
client._client.search_raw.return_value = _search_items(n_tracks=2, n_albums=0)
|
||||
client._client.preferred_codec = "flac"
|
||||
|
||||
tracks, albums = run(client.search("Kendrick Lamar"))
|
||||
|
||||
assert len(tracks) == 2
|
||||
assert len(albums) == 0
|
||||
assert all(isinstance(t, TrackResult) for t in tracks)
|
||||
|
||||
def test_track_fields(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
client._client = MagicMock()
|
||||
client._client.search_raw.return_value = _search_items(n_tracks=1, n_albums=0)
|
||||
client._client.preferred_codec = "flac"
|
||||
|
||||
tracks, _ = run(client.search("Not Like Us"))
|
||||
t = tracks[0]
|
||||
|
||||
assert t.username == "amazon"
|
||||
assert "B0TRACK0" in t.filename
|
||||
assert "||" in t.filename
|
||||
assert t.artist == "Kendrick Lamar"
|
||||
assert t.title == "Track 0"
|
||||
assert t.album == "GNX"
|
||||
assert t.quality == "Lossless"
|
||||
assert t.duration == 200_000
|
||||
|
||||
def test_track_source_metadata(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
client._client = MagicMock()
|
||||
client._client.search_raw.return_value = _search_items(n_tracks=1, n_albums=0)
|
||||
client._client.preferred_codec = "flac"
|
||||
|
||||
tracks, _ = run(client.search("test"))
|
||||
meta = tracks[0]._source_metadata
|
||||
|
||||
assert meta["asin"] == "B0TRACK0"
|
||||
assert meta["album_asin"] == "B0ALBUM1"
|
||||
assert "isrc" in meta
|
||||
|
||||
def test_returns_album_results(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
client._client = MagicMock()
|
||||
client._client.search_raw.return_value = _search_items(n_tracks=0, n_albums=2)
|
||||
client._client.preferred_codec = "flac"
|
||||
|
||||
_, albums = run(client.search("GNX"))
|
||||
|
||||
assert len(albums) == 2
|
||||
assert all(isinstance(a, AlbumResult) for a in albums)
|
||||
|
||||
def test_album_deduplication(self, tmp_path):
|
||||
from core.amazon_client import T2TunesSearchItem
|
||||
client = _make_client(tmp_path)
|
||||
client._client = MagicMock()
|
||||
client._client.preferred_codec = "flac"
|
||||
# Two hits with same album_asin
|
||||
dup = T2TunesSearchItem(
|
||||
asin="B0ALBUM0",
|
||||
title="GNX",
|
||||
artist_name="Kendrick Lamar",
|
||||
item_type="MusicAlbum",
|
||||
album_asin="B0ALBUM0",
|
||||
)
|
||||
client._client.search_raw.return_value = [dup, dup]
|
||||
|
||||
_, albums = run(client.search("GNX"))
|
||||
assert len(albums) == 1
|
||||
|
||||
def test_returns_empty_on_error(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
client._client = MagicMock()
|
||||
client._client.search_raw.side_effect = AmazonClientError("fail")
|
||||
|
||||
tracks, albums = run(client.search("anything"))
|
||||
assert tracks == []
|
||||
assert albums == []
|
||||
|
||||
def test_soulseek_compat_fields(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
client._client = MagicMock()
|
||||
client._client.search_raw.return_value = _search_items(n_tracks=1, n_albums=0)
|
||||
client._client.preferred_codec = "flac"
|
||||
|
||||
tracks, _ = run(client.search("test"))
|
||||
t = tracks[0]
|
||||
|
||||
assert t.free_upload_slots == 999
|
||||
assert t.upload_speed == 999_999
|
||||
assert t.queue_length == 0
|
||||
assert t.size == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _unique_path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUniquePath:
|
||||
def test_returns_original_when_no_conflict(self, tmp_path):
|
||||
p = tmp_path / "track.flac"
|
||||
result = AmazonDownloadClient._unique_path(p)
|
||||
assert result == p
|
||||
|
||||
def test_appends_counter_on_conflict(self, tmp_path):
|
||||
p = tmp_path / "track.flac"
|
||||
p.touch()
|
||||
result = AmazonDownloadClient._unique_path(p)
|
||||
assert result != p
|
||||
assert "(1)" in result.name
|
||||
|
||||
def test_increments_counter(self, tmp_path):
|
||||
p = tmp_path / "track.flac"
|
||||
p.touch()
|
||||
(tmp_path / "track (1).flac").touch()
|
||||
result = AmazonDownloadClient._unique_path(p)
|
||||
assert "(2)" in result.name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _record_to_status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRecordToStatus:
|
||||
def test_fields_mapped(self):
|
||||
rec = {
|
||||
"id": "dl-001",
|
||||
"filename": "B1||Artist - Title",
|
||||
"state": "downloading",
|
||||
"progress": 0.5,
|
||||
"size": 10_000_000,
|
||||
"transferred": 5_000_000,
|
||||
"speed": 1_000_000,
|
||||
"time_remaining": 5,
|
||||
"file_path": "/tmp/track.flac",
|
||||
}
|
||||
status = AmazonDownloadClient._record_to_status(rec)
|
||||
|
||||
assert status.id == "dl-001"
|
||||
assert status.filename == "B1||Artist - Title"
|
||||
assert status.username == "amazon"
|
||||
assert status.state == "downloading"
|
||||
assert status.progress == 0.5
|
||||
assert status.size == 10_000_000
|
||||
assert status.transferred == 5_000_000
|
||||
assert status.speed == 1_000_000
|
||||
assert status.time_remaining == 5
|
||||
assert status.file_path == "/tmp/track.flac"
|
||||
|
||||
def test_defaults_for_missing_fields(self):
|
||||
status = AmazonDownloadClient._record_to_status({})
|
||||
assert status.id == ""
|
||||
assert status.state == "queued"
|
||||
assert status.progress == 0.0
|
||||
assert status.size == 0
|
||||
assert status.transferred == 0
|
||||
assert status.speed == 0
|
||||
assert status.time_remaining is None
|
||||
assert status.file_path is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _stream_to_file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStreamToFile:
|
||||
def test_writes_file_and_returns_size(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
data = b"X" * (MIN_AUDIO_BYTES + 1024)
|
||||
client.session = MagicMock()
|
||||
client.session.get.return_value = _fake_chunked_response(data)
|
||||
|
||||
out = tmp_path / "output.flac"
|
||||
downloaded = client._stream_to_file("https://example.com/t.flac", out, "dl-001")
|
||||
|
||||
assert downloaded == len(data)
|
||||
assert out.exists()
|
||||
assert out.read_bytes() == data
|
||||
|
||||
def test_raises_on_http_error(self, tmp_path):
|
||||
from requests import HTTPError
|
||||
client = _make_client(tmp_path)
|
||||
client.session = MagicMock()
|
||||
client.session.get.return_value = _fake_chunked_response(b"", status_code=403)
|
||||
|
||||
out = tmp_path / "output.flac"
|
||||
with pytest.raises(HTTPError):
|
||||
client._stream_to_file("https://example.com/t.flac", out, "dl-001")
|
||||
|
||||
def test_respects_shutdown_check(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
data = b"X" * (MIN_AUDIO_BYTES + 1024)
|
||||
client.session = MagicMock()
|
||||
client.session.get.return_value = _fake_chunked_response(data)
|
||||
client.shutdown_check = lambda: True # trigger immediately
|
||||
|
||||
out = tmp_path / "output.flac"
|
||||
with pytest.raises(RuntimeError, match="Shutdown"):
|
||||
client._stream_to_file("https://example.com/t.flac", out, "dl-001")
|
||||
assert not out.exists()
|
||||
|
||||
def test_updates_engine_progress(self, tmp_path):
|
||||
import itertools
|
||||
client = _make_client(tmp_path)
|
||||
data = b"X" * (MIN_AUDIO_BYTES + 1024)
|
||||
client.session = MagicMock()
|
||||
client.session.get.return_value = _fake_chunked_response(data)
|
||||
engine = MagicMock()
|
||||
client._engine = engine
|
||||
|
||||
out = tmp_path / "output.flac"
|
||||
counter = itertools.count(0.0, 1.0)
|
||||
with patch("core.amazon_download_client.time") as mock_time:
|
||||
mock_time.monotonic.side_effect = lambda: next(counter)
|
||||
client._stream_to_file("https://example.com/t.flac", out, "dl-001")
|
||||
|
||||
assert engine.update_record.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _decrypt_with_ffmpeg
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDecryptWithFfmpeg:
|
||||
def test_calls_ffmpeg_with_key(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
enc = tmp_path / "track.enc.flac"
|
||||
enc.write_bytes(b"encrypted")
|
||||
out = tmp_path / "track.flac"
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/ffmpeg"):
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr=b"")
|
||||
client._decrypt_with_ffmpeg(enc, out, "deadbeef1234")
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "ffmpeg" in cmd[0]
|
||||
assert "-decryption_key" in cmd
|
||||
assert "deadbeef1234" in cmd
|
||||
assert str(enc) in cmd
|
||||
assert str(out) in cmd
|
||||
|
||||
def test_raises_on_ffmpeg_failure(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
enc = tmp_path / "track.enc.flac"
|
||||
enc.write_bytes(b"bad")
|
||||
out = tmp_path / "track.flac"
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/ffmpeg"):
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1, stderr=b"Invalid data found"
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="FFmpeg decryption failed"):
|
||||
client._decrypt_with_ffmpeg(enc, out, "deadbeef1234")
|
||||
|
||||
def test_raises_when_ffmpeg_missing(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
enc = tmp_path / "track.enc.flac"
|
||||
out = tmp_path / "track.flac"
|
||||
|
||||
with patch("shutil.which", return_value=None):
|
||||
# Ensure tools/ffmpeg.exe also absent
|
||||
with pytest.raises(RuntimeError, match="ffmpeg is required"):
|
||||
client._decrypt_with_ffmpeg(enc, out, "deadbeef1234")
|
||||
|
||||
def test_uses_tools_ffmpeg_when_not_on_path(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
enc = tmp_path / "track.enc.flac"
|
||||
enc.write_bytes(b"enc")
|
||||
out = tmp_path / "track.flac"
|
||||
|
||||
fake_ffmpeg = tmp_path / "ffmpeg.exe"
|
||||
fake_ffmpeg.touch()
|
||||
|
||||
with patch("shutil.which", return_value=None):
|
||||
with patch(
|
||||
"core.amazon_download_client.Path.__file__",
|
||||
create=True,
|
||||
):
|
||||
import os as _os
|
||||
is_nt = _os.name == "nt"
|
||||
ffmpeg_name = "ffmpeg.exe" if is_nt else "ffmpeg"
|
||||
tools_dir = ROOT / "tools"
|
||||
tools_ffmpeg = tools_dir / ffmpeg_name
|
||||
if tools_ffmpeg.exists():
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr=b"")
|
||||
client._decrypt_with_ffmpeg(enc, out, "aabbcc")
|
||||
assert str(tools_ffmpeg) in mock_run.call_args[0][0][0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _download_sync — integration of stream + decrypt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDownloadSync:
|
||||
def _setup(self, tmp_path: Path, decryption_key: Optional[str] = "deadbeef"):
|
||||
client = _make_client(tmp_path)
|
||||
stream = _stream_info(decryption_key=decryption_key)
|
||||
client._client = MagicMock()
|
||||
client._client.media_from_asin.return_value = [stream]
|
||||
client._client.preferred_codec = "flac"
|
||||
|
||||
audio_data = b"A" * (MIN_AUDIO_BYTES + 1024)
|
||||
client.session = MagicMock()
|
||||
client.session.get.return_value = _fake_chunked_response(audio_data)
|
||||
return client, audio_data
|
||||
|
||||
def test_returns_output_path_on_success(self, tmp_path):
|
||||
client, audio_data = self._setup(tmp_path)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/ffmpeg"):
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr=b"")
|
||||
# simulate ffmpeg writing output file
|
||||
def _ffmpeg_side_effect(cmd, capture_output=False):
|
||||
# Write dummy decrypted data
|
||||
out_path = Path(cmd[-1])
|
||||
out_path.write_bytes(audio_data)
|
||||
return MagicMock(returncode=0, stderr=b"")
|
||||
|
||||
mock_run.side_effect = _ffmpeg_side_effect
|
||||
result = client._download_sync("dl-001", "B09XYZ1234", "Kendrick Lamar - Not Like Us")
|
||||
|
||||
assert result is not None
|
||||
assert Path(result).exists()
|
||||
assert Path(result).suffix == ".flac"
|
||||
|
||||
def test_tries_next_codec_when_stream_unavailable(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
# FLAC: no streamable results; Opus: success
|
||||
flac_stream = _stream_info(streamable=False, codec="FLAC")
|
||||
opus_stream = _stream_info(streamable=True, codec="OPUS",
|
||||
stream_url="https://cdn.example.com/t.opus",
|
||||
decryption_key=None)
|
||||
client._client = MagicMock()
|
||||
client._client.preferred_codec = "flac"
|
||||
|
||||
def _media(asin, codec):
|
||||
if codec == "flac":
|
||||
return [flac_stream]
|
||||
if codec == "opus":
|
||||
return [opus_stream]
|
||||
return []
|
||||
|
||||
client._client.media_from_asin.side_effect = _media
|
||||
|
||||
audio_data = b"B" * (MIN_AUDIO_BYTES + 1024)
|
||||
client.session = MagicMock()
|
||||
client.session.get.return_value = _fake_chunked_response(audio_data)
|
||||
|
||||
result = client._download_sync("dl-001", "B09XYZ1234", "Kendrick - Track")
|
||||
|
||||
assert result is not None
|
||||
assert ".opus" in result
|
||||
|
||||
def test_returns_none_when_file_too_small(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
client._client = MagicMock()
|
||||
client._client.preferred_codec = "flac"
|
||||
client._client.media_from_asin.return_value = [
|
||||
_stream_info(decryption_key=None)
|
||||
]
|
||||
tiny_data = b"X" * 100 # way below MIN_AUDIO_BYTES
|
||||
client.session = MagicMock()
|
||||
client.session.get.return_value = _fake_chunked_response(tiny_data)
|
||||
|
||||
result = client._download_sync("dl-001", "B09XYZ1234", "Artist - Title")
|
||||
assert result is None
|
||||
|
||||
def test_tries_next_codec_when_media_fails(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
client._client = MagicMock()
|
||||
client._client.preferred_codec = "flac"
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def _media(asin, codec):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
raise AmazonClientError("quota exceeded")
|
||||
stream = _stream_info(codec="OPUS", decryption_key=None)
|
||||
return [stream]
|
||||
|
||||
client._client.media_from_asin.side_effect = _media
|
||||
|
||||
audio_data = b"C" * (MIN_AUDIO_BYTES + 1024)
|
||||
client.session = MagicMock()
|
||||
client.session.get.return_value = _fake_chunked_response(audio_data)
|
||||
|
||||
result = client._download_sync("dl-001", "B09XYZ1234", "Artist - Track")
|
||||
assert result is not None
|
||||
|
||||
def test_decryption_failure_tries_next_codec(self, tmp_path):
|
||||
client, audio_data = self._setup(tmp_path, decryption_key="badkey")
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/ffmpeg"):
|
||||
with patch("subprocess.run") as mock_run:
|
||||
# First codec (flac) decryption fails; no more codecs succeed
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr=b"decrypt error")
|
||||
result = client._download_sync("dl-001", "B09XYZ1234", "Artist - Track")
|
||||
|
||||
# All codecs should fail since we return the same bad result for all
|
||||
assert result is None
|
||||
|
||||
def test_updates_engine_state(self, tmp_path):
|
||||
client, audio_data = self._setup(tmp_path, decryption_key=None)
|
||||
engine = MagicMock()
|
||||
client._engine = engine
|
||||
|
||||
result = client._download_sync("dl-001", "B09XYZ1234", "Artist - Track")
|
||||
|
||||
update_calls = engine.update_record.call_args_list
|
||||
states = [c[0][2].get("state") for c in update_calls if "state" in c[0][2]]
|
||||
assert "downloading" in states
|
||||
|
||||
def test_final_size_update_syncs_transferred_to_size(self, tmp_path):
|
||||
"""size == transferred after success so monitor bytes-incomplete guard doesn't block."""
|
||||
client, audio_data = self._setup(tmp_path, decryption_key=None)
|
||||
engine = MagicMock()
|
||||
client._engine = engine
|
||||
|
||||
result = client._download_sync("dl-001", "B09XYZ1234", "Artist - Track")
|
||||
|
||||
assert result is not None
|
||||
# Last update must set size == transferred (both equal the output file size)
|
||||
final_calls = [
|
||||
c[0][2] for c in engine.update_record.call_args_list
|
||||
if 'size' in c[0][2] and 'transferred' in c[0][2]
|
||||
and c[0][2]['size'] == c[0][2]['transferred']
|
||||
and c[0][2]['size'] > 0
|
||||
]
|
||||
assert final_calls, "Expected a final engine update with size == transferred > 0"
|
||||
|
||||
def test_clear_stream_skips_ffmpeg(self, tmp_path):
|
||||
client, audio_data = self._setup(tmp_path, decryption_key=None)
|
||||
|
||||
with patch("subprocess.run") as mock_run:
|
||||
result = client._download_sync("dl-001", "B09XYZ1234", "Artist - Track")
|
||||
|
||||
mock_run.assert_not_called()
|
||||
assert result is not None
|
||||
|
||||
def test_safe_filename_sanitisation(self, tmp_path):
|
||||
client, audio_data = self._setup(tmp_path, decryption_key=None)
|
||||
result = client._download_sync(
|
||||
"dl-001", "B09XYZ1234", "Björk / Sigur Rós: Hvarf<>Heim"
|
||||
)
|
||||
assert result is not None
|
||||
# Path must not contain illegal filesystem chars
|
||||
path = Path(result)
|
||||
assert "/" not in path.name
|
||||
assert "<" not in path.name
|
||||
assert ">" not in path.name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# download() — async dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDownloadDispatch:
|
||||
def test_raises_without_engine(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
with pytest.raises(RuntimeError, match="_engine"):
|
||||
run(client.download("amazon", "B09XYZ1234||Artist - Title"))
|
||||
|
||||
def test_returns_none_on_bad_filename(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
client._engine = MagicMock()
|
||||
result = run(client.download("amazon", "no-pipe-delimiter-here"))
|
||||
assert result is None
|
||||
|
||||
def test_dispatches_to_engine_worker(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
engine = MagicMock()
|
||||
engine.worker.dispatch.return_value = "dl-abc123"
|
||||
client._engine = engine
|
||||
|
||||
result = run(client.download("amazon", "B09XYZ1234||Kendrick Lamar - Not Like Us"))
|
||||
|
||||
assert result == "dl-abc123"
|
||||
dispatch_call = engine.worker.dispatch.call_args
|
||||
assert dispatch_call[1]["source_name"] == "amazon"
|
||||
assert dispatch_call[1]["target_id"] == "B09XYZ1234"
|
||||
assert dispatch_call[1]["display_name"] == "Kendrick Lamar - Not Like Us"
|
||||
assert dispatch_call[1]["impl_callable"] == client._download_sync
|
||||
|
||||
def test_strips_whitespace_from_asin_and_name(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
engine = MagicMock()
|
||||
engine.worker.dispatch.return_value = "dl-xyz"
|
||||
client._engine = engine
|
||||
|
||||
run(client.download("amazon", " B09XYZ1234 || Artist - Title "))
|
||||
|
||||
call = engine.worker.dispatch.call_args
|
||||
assert call[1]["target_id"] == "B09XYZ1234"
|
||||
assert call[1]["display_name"] == "Artist - Title"
|
||||
|
||||
def test_set_engine_wires_engine(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
assert client._engine is None
|
||||
engine = MagicMock()
|
||||
client.set_engine(engine)
|
||||
assert client._engine is engine
|
||||
|
||||
def test_set_shutdown_check_wires_callback(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
assert client.shutdown_check is None
|
||||
check = lambda: False
|
||||
client.set_shutdown_check(check)
|
||||
assert client.shutdown_check is check
|
||||
|
||||
def test_set_engine_allows_download_dispatch(self, tmp_path):
|
||||
"""set_engine() must unblock download() — the live failure mode."""
|
||||
client = _make_client(tmp_path)
|
||||
engine = MagicMock()
|
||||
engine.worker.dispatch.return_value = "dl-wired"
|
||||
client.set_engine(engine)
|
||||
result = run(client.download("amazon", "B09XYZ1234||Artist - Title"))
|
||||
assert result == "dl-wired"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status interface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStatusInterface:
|
||||
def test_get_all_downloads_empty_without_engine(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
result = run(client.get_all_downloads())
|
||||
assert result == []
|
||||
|
||||
def test_get_all_downloads_converts_records(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
engine = MagicMock()
|
||||
engine.iter_records_for_source.return_value = iter([{
|
||||
"id": "dl-001",
|
||||
"filename": "B1||A - T",
|
||||
"state": "complete",
|
||||
"progress": 1.0,
|
||||
"size": 5_000_000,
|
||||
"transferred": 5_000_000,
|
||||
"speed": 0,
|
||||
}])
|
||||
client._engine = engine
|
||||
|
||||
statuses = run(client.get_all_downloads())
|
||||
assert len(statuses) == 1
|
||||
assert statuses[0].id == "dl-001"
|
||||
assert statuses[0].state == "complete"
|
||||
engine.iter_records_for_source.assert_called_once_with('amazon')
|
||||
|
||||
def test_get_download_status_returns_none_without_engine(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
assert run(client.get_download_status("dl-001")) is None
|
||||
|
||||
def test_get_download_status_hit(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
engine = MagicMock()
|
||||
engine.get_record.return_value = {
|
||||
"id": "dl-001",
|
||||
"filename": "B1||A - T",
|
||||
"state": "downloading",
|
||||
"progress": 0.7,
|
||||
"size": 10_000_000,
|
||||
"transferred": 7_000_000,
|
||||
"speed": 500_000,
|
||||
}
|
||||
client._engine = engine
|
||||
|
||||
status = run(client.get_download_status("dl-001"))
|
||||
assert status is not None
|
||||
assert status.id == "dl-001"
|
||||
assert status.state == "downloading"
|
||||
assert status.progress == 0.7
|
||||
engine.get_record.assert_called_once_with('amazon', 'dl-001')
|
||||
|
||||
def test_get_download_status_miss(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
engine = MagicMock()
|
||||
engine.get_record.return_value = None
|
||||
client._engine = engine
|
||||
|
||||
assert run(client.get_download_status("nonexistent")) is None
|
||||
|
||||
def test_cancel_returns_false_without_engine(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
assert run(client.cancel_download("dl-001")) is False
|
||||
|
||||
def test_cancel_delegates_to_engine(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
engine = MagicMock()
|
||||
engine.get_record.return_value = {'id': 'dl-001', 'state': 'downloading'}
|
||||
client._engine = engine
|
||||
|
||||
result = run(client.cancel_download("dl-001", remove=True))
|
||||
assert result is True
|
||||
engine.update_record.assert_called_once_with('amazon', 'dl-001', {'state': 'Cancelled'})
|
||||
engine.remove_record.assert_called_once_with('amazon', 'dl-001')
|
||||
|
||||
def test_cancel_returns_false_when_record_not_found(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
engine = MagicMock()
|
||||
engine.get_record.return_value = None
|
||||
client._engine = engine
|
||||
|
||||
result = run(client.cancel_download("nonexistent"))
|
||||
assert result is False
|
||||
engine.update_record.assert_not_called()
|
||||
|
||||
def test_clear_completed_returns_true_without_engine(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
assert run(client.clear_all_completed_downloads()) is True
|
||||
|
||||
def test_clear_completed_removes_terminal_records(self, tmp_path):
|
||||
client = _make_client(tmp_path)
|
||||
engine = MagicMock()
|
||||
engine.iter_records_for_source.return_value = iter([
|
||||
{'id': 'dl-001', 'state': 'Completed, Succeeded'},
|
||||
{'id': 'dl-002', 'state': 'InProgress, Downloading'},
|
||||
{'id': 'dl-003', 'state': 'Errored'},
|
||||
])
|
||||
client._engine = engine
|
||||
|
||||
result = run(client.clear_all_completed_downloads())
|
||||
assert result is True
|
||||
# Only terminal records removed
|
||||
removed_ids = [c[0][1] for c in engine.remove_record.call_args_list]
|
||||
assert 'dl-001' in removed_ids
|
||||
assert 'dl-003' in removed_ids
|
||||
assert 'dl-002' not in removed_ids
|
||||
|
||||
def test_status_methods_no_records(self, tmp_path):
|
||||
"""Engine with no Amazon records returns empty/None gracefully."""
|
||||
client = _make_client(tmp_path)
|
||||
engine = MagicMock()
|
||||
engine.iter_records_for_source.return_value = iter([])
|
||||
engine.get_record.return_value = None
|
||||
client._engine = engine
|
||||
|
||||
assert run(client.get_all_downloads()) == []
|
||||
assert run(client.get_download_status("dl-001")) is None
|
||||
assert run(client.cancel_download("dl-001")) is False
|
||||
assert run(client.clear_all_completed_downloads()) is True
|
||||
125
tests/tools/test_t2tunes_probe.py
Normal file
125
tests/tools/test_t2tunes_probe.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "tools"))
|
||||
|
||||
from t2tunes_probe import T2TunesClient, T2TunesError # noqa: E402
|
||||
|
||||
|
||||
class _Response:
|
||||
def __init__(self, payload=None, *, status_code=200, text="", headers=None, url="https://example.test/x"):
|
||||
self._payload = payload
|
||||
self.status_code = status_code
|
||||
self.text = text
|
||||
self.headers = headers or {}
|
||||
self.url = url
|
||||
self.ok = 200 <= status_code < 400
|
||||
|
||||
def json(self):
|
||||
if isinstance(self._payload, Exception):
|
||||
raise self._payload
|
||||
return self._payload
|
||||
|
||||
def raise_for_status(self):
|
||||
if not self.ok:
|
||||
raise RuntimeError(f"{self.status_code} error")
|
||||
|
||||
|
||||
class T2TunesProbeTests(unittest.TestCase):
|
||||
def test_status_uses_api_status_endpoint(self):
|
||||
session = Mock()
|
||||
session.get.return_value = _Response({"amazonMusic": "up"})
|
||||
client = T2TunesClient("https://t2.example", session=session)
|
||||
|
||||
self.assertTrue(client.amazon_music_is_up())
|
||||
session.get.assert_called_once()
|
||||
self.assertEqual(session.get.call_args.args[0], "https://t2.example/api/status")
|
||||
|
||||
def test_search_flattens_nested_hits(self):
|
||||
session = Mock()
|
||||
session.get.return_value = _Response({
|
||||
"results": [{
|
||||
"hits": [{
|
||||
"document": {
|
||||
"__type": "Track",
|
||||
"asin": "B001",
|
||||
"title": "Song",
|
||||
"artistName": "Artist",
|
||||
"albumName": "Album",
|
||||
"duration": 123,
|
||||
"isrc": "USABC123",
|
||||
}
|
||||
}]
|
||||
}]
|
||||
})
|
||||
client = T2TunesClient("https://t2.example", session=session)
|
||||
|
||||
items = client.search("artist song")
|
||||
|
||||
self.assertEqual(len(items), 1)
|
||||
self.assertEqual(items[0].asin, "B001")
|
||||
self.assertTrue(items[0].is_track)
|
||||
self.assertEqual(items[0].duration_seconds, 123)
|
||||
self.assertEqual(session.get.call_args.kwargs["params"]["types"], "track,album")
|
||||
|
||||
def test_media_handles_streamable_typo_and_decryption_flag(self):
|
||||
session = Mock()
|
||||
session.get.return_value = _Response([{
|
||||
"asin": "B001",
|
||||
"stremeable": True,
|
||||
"decryptionKey": "abc123",
|
||||
"tags": {
|
||||
"title": "Song",
|
||||
"artist": "Artist",
|
||||
"album": "Album",
|
||||
"isrc": "USABC123",
|
||||
},
|
||||
"streamInfo": {
|
||||
"format": "flac",
|
||||
"codec": "flac",
|
||||
"sampleRate": 48000,
|
||||
"streamUrl": "https://cdn.example/song.flac",
|
||||
},
|
||||
}])
|
||||
client = T2TunesClient("https://t2.example", session=session)
|
||||
|
||||
streams = client.media_from_asin("B001")
|
||||
|
||||
self.assertEqual(len(streams), 1)
|
||||
self.assertTrue(streams[0].streamable)
|
||||
self.assertTrue(streams[0].has_decryption_key)
|
||||
self.assertEqual(streams[0].stream_url, "https://cdn.example/song.flac")
|
||||
|
||||
def test_non_json_response_raises_probe_error(self):
|
||||
session = Mock()
|
||||
session.get.return_value = _Response(ValueError("not json"), text="Service Unavailable")
|
||||
client = T2TunesClient("https://t2.example", session=session)
|
||||
|
||||
with self.assertRaises(T2TunesError):
|
||||
client.status()
|
||||
|
||||
def test_probe_stream_falls_back_to_range_get_when_head_is_blocked(self):
|
||||
session = Mock()
|
||||
session.head.return_value = _Response(status_code=405)
|
||||
session.get.return_value = _Response(
|
||||
status_code=206,
|
||||
headers={"content-type": "audio/flac", "content-length": "1"},
|
||||
url="https://cdn.example/song.flac",
|
||||
)
|
||||
client = T2TunesClient("https://t2.example", session=session)
|
||||
|
||||
result = client.probe_stream("https://cdn.example/song.flac")
|
||||
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(result["method"], "GET range")
|
||||
self.assertEqual(result["content_type"], "audio/flac")
|
||||
self.assertEqual(session.get.call_args.kwargs["headers"], {"Range": "bytes=0-0"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
199
tools/t2tunes_media_plan.py
Normal file
199
tools/t2tunes_media_plan.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
"""Build a T2Tunes media plan for a track without downloading media.
|
||||
|
||||
This runner calls ``tools.t2tunes_probe.T2TunesClient`` and validates the
|
||||
parts of a future download source that are safe to exercise live:
|
||||
|
||||
- search resolution
|
||||
- album metadata lookup
|
||||
- cover URL discovery + HEAD probe
|
||||
- per-codec media lookup
|
||||
- stream URL HEAD/range probe
|
||||
|
||||
It writes a JSON plan that a developer can inspect while building an
|
||||
integration. It intentionally does not download or decrypt protected media.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
TOOL_DIR = Path(__file__).resolve().parent
|
||||
if str(TOOL_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(TOOL_DIR))
|
||||
|
||||
from t2tunes_probe import T2TunesClient, T2TunesSearchItem # noqa: E402
|
||||
|
||||
|
||||
def _slug(value: str) -> str:
|
||||
cleaned = "".join(ch.lower() if ch.isalnum() else "-" for ch in value)
|
||||
parts = [part for part in cleaned.split("-") if part]
|
||||
return "-".join(parts[:12]) or "t2tunes"
|
||||
|
||||
|
||||
def _item_score(item: T2TunesSearchItem, query: str, explicit: Optional[bool]) -> int:
|
||||
text = f"{item.artist_name} {item.title} {item.album_name}".lower()
|
||||
score = 0
|
||||
for token in query.lower().split():
|
||||
if token in text:
|
||||
score += 10
|
||||
if item.is_track:
|
||||
score += 20
|
||||
if explicit is True and "explicit" in text:
|
||||
score += 50
|
||||
if explicit is False and "clean" in text:
|
||||
score += 50
|
||||
if explicit is True and "clean" in text:
|
||||
score -= 40
|
||||
if explicit is False and "explicit" in text:
|
||||
score -= 40
|
||||
return score
|
||||
|
||||
|
||||
def _choose_result(
|
||||
items: Iterable[T2TunesSearchItem],
|
||||
*,
|
||||
query: str,
|
||||
asin: Optional[str],
|
||||
explicit: Optional[bool],
|
||||
) -> Optional[T2TunesSearchItem]:
|
||||
items = list(items)
|
||||
if asin:
|
||||
return next((item for item in items if item.asin == asin or item.album_asin == asin), None)
|
||||
if not items:
|
||||
return None
|
||||
return max(items, key=lambda item: _item_score(item, query, explicit))
|
||||
|
||||
|
||||
def _first_album(metadata: Dict[str, Any]) -> Dict[str, Any]:
|
||||
albums = metadata.get("albumList")
|
||||
if isinstance(albums, list) and albums:
|
||||
album = albums[0]
|
||||
return album if isinstance(album, dict) else {}
|
||||
return {}
|
||||
|
||||
|
||||
def _cover_url_from_metadata(metadata: Dict[str, Any]) -> str:
|
||||
album = _first_album(metadata)
|
||||
image = album.get("image")
|
||||
return image if isinstance(image, str) else ""
|
||||
|
||||
|
||||
def _head_url(client: T2TunesClient, url: str) -> Dict[str, Any]:
|
||||
if not url:
|
||||
return {"ok": False, "reason": "missing url"}
|
||||
return client.probe_stream(url)
|
||||
|
||||
|
||||
def build_plan(
|
||||
*,
|
||||
query: str,
|
||||
base_url: str,
|
||||
country: str,
|
||||
codecs: List[str],
|
||||
asin: Optional[str],
|
||||
explicit: Optional[bool],
|
||||
timeout: int,
|
||||
probe_urls: bool,
|
||||
) -> Dict[str, Any]:
|
||||
client = T2TunesClient(base_url, country=country, timeout=timeout)
|
||||
status = client.status()
|
||||
search_items = client.search(query)
|
||||
selected = _choose_result(search_items, query=query, asin=asin, explicit=explicit)
|
||||
|
||||
plan: Dict[str, Any] = {
|
||||
"base_url": base_url,
|
||||
"country": country,
|
||||
"query": query,
|
||||
"status": status,
|
||||
"result_count": len(search_items),
|
||||
"selected": selected.__dict__ if selected else None,
|
||||
"album_metadata": None,
|
||||
"cover": None,
|
||||
"formats": [],
|
||||
"notes": [
|
||||
"This plan validates API behavior only.",
|
||||
"It intentionally does not download or decrypt protected media.",
|
||||
],
|
||||
}
|
||||
|
||||
if not selected:
|
||||
return plan
|
||||
|
||||
album_asin = selected.album_asin or selected.asin
|
||||
metadata = client.album_metadata(album_asin)
|
||||
cover_url = _cover_url_from_metadata(metadata)
|
||||
plan["album_metadata"] = {
|
||||
"asin": album_asin,
|
||||
"title": _first_album(metadata).get("title"),
|
||||
"track_count": _first_album(metadata).get("trackCount"),
|
||||
"image": cover_url,
|
||||
"label": _first_album(metadata).get("label"),
|
||||
}
|
||||
plan["cover"] = {
|
||||
"url": cover_url,
|
||||
"probe": _head_url(client, cover_url) if probe_urls else None,
|
||||
}
|
||||
|
||||
for codec in codecs:
|
||||
format_client = T2TunesClient(base_url, country=country, codec=codec, timeout=timeout)
|
||||
streams = format_client.media_from_asin(selected.asin)
|
||||
entries = []
|
||||
for stream in streams:
|
||||
entry = stream.__dict__.copy()
|
||||
entry["stream_probe"] = _head_url(format_client, stream.stream_url) if probe_urls else None
|
||||
entries.append(entry)
|
||||
plan["formats"].append({
|
||||
"requested_codec": codec,
|
||||
"stream_count": len(entries),
|
||||
"streams": entries,
|
||||
})
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Create a safe T2Tunes media plan for a track.")
|
||||
parser.add_argument("query", help="Search query, e.g. 'Kendrick Lamar Not Like Us'")
|
||||
parser.add_argument("--base-url", default="https://t2tunes.site")
|
||||
parser.add_argument("--country", default="US")
|
||||
parser.add_argument("--codec", action="append", dest="codecs", choices=("flac", "opus", "eac3"))
|
||||
parser.add_argument("--asin", help="Prefer a specific track or album ASIN from search results")
|
||||
group = parser.add_mutually_exclusive_group()
|
||||
group.add_argument("--explicit", action="store_true", help="Prefer explicit search results")
|
||||
group.add_argument("--clean", action="store_true", help="Prefer clean search results")
|
||||
parser.add_argument("--timeout", type=int, default=30)
|
||||
parser.add_argument("--no-probe", action="store_true", help="Do not HEAD-probe cover/stream URLs")
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
help="Optional JSON output path. Defaults to ./testTEST/<query>-plan.json",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
explicit = True if args.explicit else False if args.clean else None
|
||||
plan = build_plan(
|
||||
query=args.query,
|
||||
base_url=args.base_url,
|
||||
country=args.country,
|
||||
codecs=args.codecs or ["flac", "opus", "eac3"],
|
||||
asin=args.asin,
|
||||
explicit=explicit,
|
||||
timeout=args.timeout,
|
||||
probe_urls=not args.no_probe,
|
||||
)
|
||||
|
||||
text = json.dumps(plan, indent=2, sort_keys=True)
|
||||
output_path = Path(args.output) if args.output else Path.cwd() / "testTEST" / f"{_slug(args.query)}-plan.json"
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(text + "\n", encoding="utf-8")
|
||||
plan["_written_to"] = str(output_path)
|
||||
text = json.dumps(plan, indent=2, sort_keys=True)
|
||||
print(text)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
369
tools/t2tunes_probe.py
Normal file
369
tools/t2tunes_probe.py
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
"""Standalone T2Tunes/TripleTriple API probe.
|
||||
|
||||
This is intentionally not wired into SoulSync's download source registry.
|
||||
It validates the API surface Tubifarry targets:
|
||||
|
||||
/api/status
|
||||
/api/amazon-music/search
|
||||
/api/amazon-music/metadata
|
||||
/api/amazon-music/media-from-asin
|
||||
|
||||
The probe can inspect returned stream metadata and optionally issue a HEAD
|
||||
request against the stream URL. It does not download or decrypt audio.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode, urljoin
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
DEFAULT_BASE_URL = "https://t2tunes.site"
|
||||
DEFAULT_TIMEOUT_SECONDS = 30
|
||||
|
||||
|
||||
class T2TunesError(RuntimeError):
|
||||
"""Raised when the T2Tunes API returns an invalid or failed response."""
|
||||
|
||||
|
||||
class _HttpResponse:
|
||||
def __init__(self, *, body: bytes, status_code: int, headers: Dict[str, str], url: str) -> None:
|
||||
self.body = body
|
||||
self.status_code = status_code
|
||||
self.headers = headers
|
||||
self.url = url
|
||||
self.ok = 200 <= status_code < 400
|
||||
self.text = body.decode("utf-8", errors="replace")
|
||||
|
||||
def json(self) -> Any:
|
||||
return json.loads(self.text)
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
if not self.ok:
|
||||
raise T2TunesError(f"HTTP {self.status_code} for {self.url}")
|
||||
|
||||
|
||||
class _UrllibSession:
|
||||
def get(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
timeout: int = DEFAULT_TIMEOUT_SECONDS,
|
||||
allow_redirects: bool = True, # kept for test/session compatibility
|
||||
stream: bool = False, # kept for test/session compatibility
|
||||
) -> _HttpResponse:
|
||||
del allow_redirects, stream
|
||||
if params:
|
||||
separator = "&" if "?" in url else "?"
|
||||
url = f"{url}{separator}{urlencode(params)}"
|
||||
return self._request("GET", url, headers=headers, timeout=timeout)
|
||||
|
||||
def head(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
timeout: int = DEFAULT_TIMEOUT_SECONDS,
|
||||
allow_redirects: bool = True, # kept for test/session compatibility
|
||||
) -> _HttpResponse:
|
||||
del allow_redirects
|
||||
return self._request("HEAD", url, headers=headers, timeout=timeout)
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
timeout: int = DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> _HttpResponse:
|
||||
request = Request(url, headers=headers or {}, method=method)
|
||||
try:
|
||||
with urlopen(request, timeout=timeout) as response:
|
||||
return _HttpResponse(
|
||||
body=response.read(),
|
||||
status_code=response.status,
|
||||
headers={k.lower(): v for k, v in response.headers.items()},
|
||||
url=response.url,
|
||||
)
|
||||
except HTTPError as exc:
|
||||
body = exc.read() if hasattr(exc, "read") else b""
|
||||
return _HttpResponse(
|
||||
body=body,
|
||||
status_code=exc.code,
|
||||
headers={k.lower(): v for k, v in exc.headers.items()} if exc.headers else {},
|
||||
url=exc.url,
|
||||
)
|
||||
except URLError as exc:
|
||||
raise T2TunesError(f"Network error for {url}: {exc}") from exc
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class T2TunesSearchItem:
|
||||
asin: str
|
||||
title: str
|
||||
artist_name: str
|
||||
item_type: str
|
||||
album_name: str = ""
|
||||
album_asin: str = ""
|
||||
duration_seconds: int = 0
|
||||
isrc: str = ""
|
||||
|
||||
@property
|
||||
def is_album(self) -> bool:
|
||||
return "album" in self.item_type.lower()
|
||||
|
||||
@property
|
||||
def is_track(self) -> bool:
|
||||
return "track" in self.item_type.lower()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class T2TunesStreamInfo:
|
||||
asin: str
|
||||
streamable: bool
|
||||
codec: str
|
||||
format: str
|
||||
sample_rate: Optional[int]
|
||||
stream_url: str
|
||||
has_decryption_key: bool
|
||||
title: str = ""
|
||||
artist: str = ""
|
||||
album: str = ""
|
||||
isrc: str = ""
|
||||
|
||||
|
||||
class T2TunesClient:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
*,
|
||||
country: str = "US",
|
||||
codec: str = "flac",
|
||||
timeout: int = DEFAULT_TIMEOUT_SECONDS,
|
||||
session: Optional[Any] = None,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.country = country.upper()
|
||||
self.codec = codec.lower()
|
||||
self.timeout = timeout
|
||||
self.session = session or _UrllibSession()
|
||||
|
||||
def status(self) -> Dict[str, Any]:
|
||||
return self._get_json("/api/status")
|
||||
|
||||
def amazon_music_is_up(self) -> bool:
|
||||
status = self.status()
|
||||
return str(status.get("amazonMusic", "")).lower() == "up"
|
||||
|
||||
def search(self, query: str, *, types: str = "track,album") -> List[T2TunesSearchItem]:
|
||||
data = self._get_json(
|
||||
"/api/amazon-music/search",
|
||||
params={
|
||||
"query": query,
|
||||
"types": types,
|
||||
"country": self.country,
|
||||
},
|
||||
)
|
||||
return list(_iter_search_items(data))
|
||||
|
||||
def album_metadata(self, asin: str) -> Dict[str, Any]:
|
||||
return self._get_json(
|
||||
"/api/amazon-music/metadata",
|
||||
params={
|
||||
"asin": asin,
|
||||
"country": self.country,
|
||||
},
|
||||
)
|
||||
|
||||
def media_from_asin(self, asin: str) -> List[T2TunesStreamInfo]:
|
||||
data = self._get_json(
|
||||
"/api/amazon-music/media-from-asin",
|
||||
params={
|
||||
"asin": asin,
|
||||
"country": self.country,
|
||||
"codec": self.codec,
|
||||
},
|
||||
)
|
||||
if isinstance(data, list):
|
||||
return [_media_response_to_stream_info(item) for item in data if isinstance(item, dict)]
|
||||
if isinstance(data, dict):
|
||||
return [_media_response_to_stream_info(data)]
|
||||
raise T2TunesError(f"Unexpected media response type: {type(data).__name__}")
|
||||
|
||||
def probe_stream(self, stream_url: str) -> Dict[str, Any]:
|
||||
"""Probe a stream URL without downloading audio bytes."""
|
||||
try:
|
||||
response = self.session.head(stream_url, timeout=self.timeout, allow_redirects=True)
|
||||
method = "HEAD"
|
||||
if response.status_code in (405, 403):
|
||||
response = self.session.get(
|
||||
stream_url,
|
||||
timeout=self.timeout,
|
||||
allow_redirects=True,
|
||||
stream=True,
|
||||
headers={"Range": "bytes=0-0"},
|
||||
)
|
||||
method = "GET range"
|
||||
return {
|
||||
"ok": response.ok,
|
||||
"method": method,
|
||||
"status_code": response.status_code,
|
||||
"content_type": response.headers.get("content-type", ""),
|
||||
"content_length": response.headers.get("content-length", ""),
|
||||
"accept_ranges": response.headers.get("accept-ranges", ""),
|
||||
"final_url": response.url,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
def _get_json(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
||||
url = urljoin(f"{self.base_url}/", path.lstrip("/"))
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Referer": self.base_url,
|
||||
"User-Agent": "SoulSync-T2Tunes-Probe/0.1",
|
||||
}
|
||||
try:
|
||||
response = self.session.get(url, params=params, headers=headers, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
except Exception as exc:
|
||||
raise T2TunesError(f"Request failed for {url}: {exc}") from exc
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
preview = response.text[:200].replace("\n", " ")
|
||||
raise T2TunesError(f"Response was not JSON for {url}: {preview!r}") from exc
|
||||
|
||||
|
||||
def _iter_search_items(response: Any) -> Iterable[T2TunesSearchItem]:
|
||||
if not isinstance(response, dict):
|
||||
raise T2TunesError(f"Unexpected search response type: {type(response).__name__}")
|
||||
for result in response.get("results") or []:
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
for hit in result.get("hits") or []:
|
||||
if not isinstance(hit, dict):
|
||||
continue
|
||||
document = hit.get("document")
|
||||
if not isinstance(document, dict):
|
||||
continue
|
||||
asin = str(document.get("asin") or "")
|
||||
if not asin:
|
||||
continue
|
||||
yield T2TunesSearchItem(
|
||||
asin=asin,
|
||||
title=str(document.get("title") or ""),
|
||||
artist_name=str(document.get("artistName") or ""),
|
||||
item_type=str(document.get("__type") or ""),
|
||||
album_name=str(document.get("albumName") or ""),
|
||||
album_asin=str(document.get("albumAsin") or ""),
|
||||
duration_seconds=int(document.get("duration") or 0),
|
||||
isrc=str(document.get("isrc") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _media_response_to_stream_info(item: Dict[str, Any]) -> T2TunesStreamInfo:
|
||||
stream_info = item.get("streamInfo") if isinstance(item.get("streamInfo"), dict) else {}
|
||||
tags = item.get("tags") if isinstance(item.get("tags"), dict) else {}
|
||||
return T2TunesStreamInfo(
|
||||
asin=str(item.get("asin") or ""),
|
||||
streamable=bool(item.get("streamable") if item.get("streamable") is not None else item.get("stremeable")),
|
||||
codec=str(stream_info.get("codec") or ""),
|
||||
format=str(stream_info.get("format") or ""),
|
||||
sample_rate=stream_info.get("sampleRate") if isinstance(stream_info.get("sampleRate"), int) else None,
|
||||
stream_url=str(stream_info.get("streamUrl") or ""),
|
||||
has_decryption_key=bool(item.get("decryptionKey")),
|
||||
title=str(tags.get("title") or ""),
|
||||
artist=str(tags.get("artist") or ""),
|
||||
album=str(tags.get("album") or ""),
|
||||
isrc=str(tags.get("isrc") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _print_json(data: Any) -> None:
|
||||
print(json.dumps(data, indent=2, sort_keys=True, default=lambda o: getattr(o, "__dict__", str(o))))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Probe a T2Tunes/TripleTriple API instance.")
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
parser.add_argument("--country", default="US")
|
||||
parser.add_argument("--codec", default="flac", choices=("flac", "opus", "eac3"))
|
||||
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_SECONDS)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
sub.add_parser("status")
|
||||
|
||||
search_parser = sub.add_parser("search")
|
||||
search_parser.add_argument("query")
|
||||
search_parser.add_argument("--types", default="track,album")
|
||||
|
||||
metadata_parser = sub.add_parser("metadata")
|
||||
metadata_parser.add_argument("asin")
|
||||
|
||||
media_parser = sub.add_parser("media")
|
||||
media_parser.add_argument("asin")
|
||||
media_parser.add_argument("--probe-stream", action="store_true")
|
||||
|
||||
smoke_parser = sub.add_parser("smoke")
|
||||
smoke_parser.add_argument("query")
|
||||
smoke_parser.add_argument("--probe-stream", action="store_true")
|
||||
|
||||
args = parser.parse_args()
|
||||
client = T2TunesClient(args.base_url, country=args.country, codec=args.codec, timeout=args.timeout)
|
||||
|
||||
if args.command == "status":
|
||||
_print_json(client.status())
|
||||
return 0
|
||||
|
||||
if args.command == "search":
|
||||
_print_json([item.__dict__ for item in client.search(args.query, types=args.types)])
|
||||
return 0
|
||||
|
||||
if args.command == "metadata":
|
||||
_print_json(client.album_metadata(args.asin))
|
||||
return 0
|
||||
|
||||
if args.command == "media":
|
||||
streams = client.media_from_asin(args.asin)
|
||||
payload = [stream.__dict__ for stream in streams]
|
||||
if args.probe_stream:
|
||||
for index, stream in enumerate(streams):
|
||||
payload[index]["stream_probe"] = client.probe_stream(stream.stream_url) if stream.stream_url else {"ok": False}
|
||||
_print_json(payload)
|
||||
return 0
|
||||
|
||||
if args.command == "smoke":
|
||||
status = client.status()
|
||||
search_items = client.search(args.query)
|
||||
first = next((item for item in search_items if item.is_track or item.is_album), None)
|
||||
media = client.media_from_asin(first.asin) if first else []
|
||||
payload = {
|
||||
"status": status,
|
||||
"amazon_music_up": str(status.get("amazonMusic", "")).lower() == "up",
|
||||
"result_count": len(search_items),
|
||||
"first_result": first.__dict__ if first else None,
|
||||
"media": [stream.__dict__ for stream in media[:3]],
|
||||
}
|
||||
if args.probe_stream and media and media[0].stream_url:
|
||||
payload["first_stream_probe"] = client.probe_stream(media[0].stream_url)
|
||||
_print_json(payload)
|
||||
return 0
|
||||
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
1867
web_server.py
1867
web_server.py
File diff suppressed because it is too large
Load diff
497
webui/index.html
497
webui/index.html
|
|
@ -541,6 +541,29 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Amazon Music Enrichment Status Icon -->
|
||||
<div class="amazon-enrich-button-container">
|
||||
<button class="amazon-enrich-button" id="amazon-enrich-button" title="Amazon Music Library Enrichment"
|
||||
onclick="toggleAmazonEnrichment()">
|
||||
<img src="/static/amazon.svg"
|
||||
alt="Amazon Music" class="amazon-enrich-logo">
|
||||
<div class="amazon-enrich-spinner"></div>
|
||||
</button>
|
||||
<div class="amazon-enrich-tooltip" id="amazon-enrich-tooltip">
|
||||
<div class="amazon-enrich-tooltip-content">
|
||||
<div class="amazon-enrich-tooltip-header">Amazon Music Enrichment</div>
|
||||
<div class="amazon-enrich-tooltip-body" id="amazon-enrich-tooltip-body">
|
||||
<div class="tooltip-status">Status: <span
|
||||
id="amazon-enrich-tooltip-status">Idle</span>
|
||||
</div>
|
||||
<div class="tooltip-current" id="amazon-enrich-tooltip-current">No active matches
|
||||
</div>
|
||||
<div class="tooltip-progress" id="amazon-enrich-tooltip-progress">Progress: 0 / 0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hydrabase P2P Mirror Status Icon -->
|
||||
<div class="hydrabase-button-container" id="hydrabase-button-container" style="display: none;">
|
||||
<button class="hydrabase-button" id="hydrabase-button" title="Hydrabase P2P Mirror">
|
||||
|
|
@ -619,222 +642,265 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-section">
|
||||
<h3 class="section-title">Service Status</h3>
|
||||
<div class="service-status-grid">
|
||||
<div class="service-card" id="metadata-source-service-card" data-status-ready="false">
|
||||
<div class="service-card-header">
|
||||
<span class="service-card-title" id="metadata-source-title">Metadata Source</span>
|
||||
<span class="service-card-indicator disconnected"
|
||||
id="metadata-source-status-indicator">●</span>
|
||||
</div>
|
||||
<p class="service-card-status-text" id="metadata-source-status-text">Disconnected</p>
|
||||
<p class="service-card-response-time" id="metadata-source-response-time">Response: --</p>
|
||||
<div class="service-card-footer">
|
||||
<button class="service-card-button"
|
||||
onclick="testDashboardConnection(getActiveMetadataSource())">Test Connection</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-card" id="media-server-service-card" data-status-ready="false">
|
||||
<div class="service-card-header">
|
||||
<span class="service-card-title" id="media-server-service-name">Media Server</span>
|
||||
<span class="service-card-indicator disconnected"
|
||||
id="media-server-status-indicator">●</span>
|
||||
</div>
|
||||
<p class="service-card-status-text" id="media-server-status-text">Disconnected</p>
|
||||
<p class="service-card-response-time" id="media-server-response-time">Response: --</p>
|
||||
<div class="service-card-footer">
|
||||
<button class="service-card-button" onclick="testDashboardConnection('server')">Test
|
||||
Connection</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-card" id="soulseek-service-card" data-status-ready="false">
|
||||
<div class="service-card-header">
|
||||
<span class="service-card-title" id="download-source-title">Download Source</span>
|
||||
<span class="service-card-indicator disconnected"
|
||||
id="soulseek-status-indicator">●</span>
|
||||
</div>
|
||||
<p class="service-card-status-text" id="soulseek-status-text">Disconnected</p>
|
||||
<p class="service-card-response-time" id="soulseek-response-time">Response: --</p>
|
||||
<div class="service-card-footer">
|
||||
<button class="service-card-button"
|
||||
onclick="testDashboardConnection('soulseek')">Test Connection</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="enrichment-section" id="enrichment-pills-section" style="display:none">
|
||||
<div class="enrichment-section-header">
|
||||
<span class="enrichment-section-label">Enrichment Services</span>
|
||||
</div>
|
||||
<div class="enrichment-status-grid" id="enrichment-status-grid">
|
||||
<!-- Legacy pills — hidden when rate monitor active -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
Bento dashboard — bento-style mixed-size cards. Each card has a
|
||||
bold title + short subtitle + body region + optional actions.
|
||||
Layout flows in a 3-column grid; cards span 1/2/3 columns to
|
||||
create visual hierarchy. All existing IDs + child classes
|
||||
preserved so JS that updates the dashboard keeps working.
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
<div class="dash-grid">
|
||||
|
||||
<!-- API Rate Monitor Section (replaces enrichment pills with richer cards) -->
|
||||
<div class="dashboard-section" id="rate-monitor-section">
|
||||
<h3 class="section-title">Enrichment Services</h3>
|
||||
<div class="rate-monitor-grid" id="rate-monitor-grid">
|
||||
<!-- Populated dynamically by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Library Status Card -->
|
||||
<div class="dashboard-section">
|
||||
<div class="library-status-card" id="library-status-card">
|
||||
<!-- Animated background accent -->
|
||||
<div class="library-status-glow"></div>
|
||||
|
||||
<div class="library-status-header">
|
||||
<div class="library-status-icon" id="library-status-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="9" y1="7" x2="16" y2="7"/><line x1="9" y1="11" x2="14" y2="11"/></svg>
|
||||
</div>
|
||||
<div class="library-status-info">
|
||||
<h4 class="library-status-title" id="library-status-title">Library</h4>
|
||||
<p class="library-status-subtitle" id="library-status-subtitle">Checking status...</p>
|
||||
</div>
|
||||
<div class="library-status-actions" id="library-status-actions">
|
||||
<button class="library-status-btn" id="library-status-scan-btn" style="display: none;" onclick="dashboardLibraryScan(false)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
<span id="library-status-scan-label">Refresh</span>
|
||||
</button>
|
||||
<button class="library-status-btn library-status-btn-secondary" id="library-status-deep-btn" style="display: none;" onclick="dashboardLibraryDeepScan()">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
|
||||
Deep Scan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="library-status-stats" id="library-status-stats" style="display: none;">
|
||||
<div class="library-status-stat">
|
||||
<div class="library-status-stat-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
<!-- Card: Service Status (wide, top-left) -->
|
||||
<article class="dash-card" data-card="services">
|
||||
<header class="dash-card__head">
|
||||
<h3 class="dash-card__title">Service Status</h3>
|
||||
<p class="dash-card__sub">Connection health for every service SoulSync uses.</p>
|
||||
</header>
|
||||
<div class="dash-card__body">
|
||||
<div class="service-status-grid">
|
||||
<div class="service-card" id="metadata-source-service-card" data-status-ready="false">
|
||||
<div class="service-card-header">
|
||||
<span class="service-card-title" id="metadata-source-title">Metadata Source</span>
|
||||
<span class="service-card-indicator disconnected" id="metadata-source-status-indicator">●</span>
|
||||
</div>
|
||||
<p class="service-card-status-text" id="metadata-source-status-text">Disconnected</p>
|
||||
<p class="service-card-response-time" id="metadata-source-response-time">Response: --</p>
|
||||
<div class="service-card-footer">
|
||||
<button class="service-card-button" onclick="testDashboardConnection(getActiveMetadataSource())">Test</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="library-status-stat-text">
|
||||
<span class="library-status-stat-value" id="library-status-artists">0</span>
|
||||
<span class="library-status-stat-label">Artists</span>
|
||||
<div class="service-card" id="media-server-service-card" data-status-ready="false">
|
||||
<div class="service-card-header">
|
||||
<span class="service-card-title" id="media-server-service-name">Media Server</span>
|
||||
<span class="service-card-indicator disconnected" id="media-server-status-indicator">●</span>
|
||||
</div>
|
||||
<p class="service-card-status-text" id="media-server-status-text">Disconnected</p>
|
||||
<p class="service-card-response-time" id="media-server-response-time">Response: --</p>
|
||||
<div class="service-card-footer">
|
||||
<button class="service-card-button" onclick="testDashboardConnection('server')">Test</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-card" id="soulseek-service-card" data-status-ready="false">
|
||||
<div class="service-card-header">
|
||||
<span class="service-card-title" id="download-source-title">Download Source</span>
|
||||
<span class="service-card-indicator disconnected" id="soulseek-status-indicator">●</span>
|
||||
</div>
|
||||
<p class="service-card-status-text" id="soulseek-status-text">Disconnected</p>
|
||||
<p class="service-card-response-time" id="soulseek-response-time">Response: --</p>
|
||||
<div class="service-card-footer">
|
||||
<button class="service-card-button" onclick="testDashboardConnection('soulseek')">Test</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="library-status-stat">
|
||||
<div class="library-status-stat-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||
<div class="enrichment-section" id="enrichment-pills-section" style="display:none">
|
||||
<div class="enrichment-section-header">
|
||||
<span class="enrichment-section-label">Enrichment Services</span>
|
||||
</div>
|
||||
<div class="library-status-stat-text">
|
||||
<span class="library-status-stat-value" id="library-status-albums">0</span>
|
||||
<span class="library-status-stat-label">Albums</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="library-status-stat">
|
||||
<div class="library-status-stat-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
</div>
|
||||
<div class="library-status-stat-text">
|
||||
<span class="library-status-stat-value" id="library-status-tracks">0</span>
|
||||
<span class="library-status-stat-label">Tracks</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="library-status-stat">
|
||||
<div class="library-status-stat-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
|
||||
</div>
|
||||
<div class="library-status-stat-text">
|
||||
<span class="library-status-stat-value" id="library-status-size">--</span>
|
||||
<span class="library-status-stat-label">DB Size</span>
|
||||
<div class="enrichment-status-grid" id="enrichment-status-grid">
|
||||
<!-- Legacy pills — hidden when rate monitor active -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="library-status-progress" id="library-status-progress" style="display: none;">
|
||||
<div class="library-status-phase" id="library-status-phase">Scanning...</div>
|
||||
<div class="library-status-bar">
|
||||
<div class="library-status-bar-fill" id="library-status-bar-fill" style="width: 0%;"></div>
|
||||
<!-- Card: System Stats (row 1, between Services and Library) -->
|
||||
<article class="dash-card" data-card="stats">
|
||||
<header class="dash-card__head">
|
||||
<h3 class="dash-card__title">System Stats</h3>
|
||||
<p class="dash-card__sub">Live performance metrics.</p>
|
||||
</header>
|
||||
<div class="dash-card__body">
|
||||
<div class="stats-grid-dashboard">
|
||||
<div class="stat-card-dashboard" id="active-downloads-card">
|
||||
<p class="stat-card-title">Active Downloads</p>
|
||||
<p class="stat-card-value">0</p>
|
||||
<p class="stat-card-subtitle">Currently downloading</p>
|
||||
</div>
|
||||
<div class="stat-card-dashboard" id="finished-downloads-card">
|
||||
<p class="stat-card-title">Finished Downloads</p>
|
||||
<p class="stat-card-value">0</p>
|
||||
<p class="stat-card-subtitle">Completed this session</p>
|
||||
</div>
|
||||
<div class="stat-card-dashboard" id="download-speed-card">
|
||||
<p class="stat-card-title">Download Speed</p>
|
||||
<p class="stat-card-value">0 KB/s</p>
|
||||
<p class="stat-card-subtitle">Combined speed</p>
|
||||
</div>
|
||||
<div class="stat-card-dashboard" id="active-syncs-card">
|
||||
<p class="stat-card-title">Active Syncs</p>
|
||||
<p class="stat-card-value">0</p>
|
||||
<p class="stat-card-subtitle">Playlists syncing</p>
|
||||
</div>
|
||||
<div class="stat-card-dashboard" id="uptime-card">
|
||||
<p class="stat-card-title">System Uptime</p>
|
||||
<p class="stat-card-value">0m</p>
|
||||
<p class="stat-card-subtitle">Application runtime</p>
|
||||
</div>
|
||||
<div class="stat-card-dashboard" id="memory-card">
|
||||
<p class="stat-card-title">Memory Usage</p>
|
||||
<p class="stat-card-value">--</p>
|
||||
<p class="stat-card-subtitle">Current usage</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="library-status-progress-detail" id="library-status-progress-detail">0 / 0</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="library-status-message" id="library-status-message" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Card: Library (row 1) -->
|
||||
<article class="dash-card" data-card="library">
|
||||
<header class="dash-card__head">
|
||||
<h3 class="dash-card__title">Library</h3>
|
||||
<p class="dash-card__sub">Your collection at a glance.</p>
|
||||
</header>
|
||||
<div class="dash-card__body">
|
||||
<div class="library-status-card" id="library-status-card">
|
||||
<div class="library-status-glow"></div>
|
||||
<div class="library-status-header">
|
||||
<div class="library-status-icon" id="library-status-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="9" y1="7" x2="16" y2="7"/><line x1="9" y1="11" x2="14" y2="11"/></svg>
|
||||
</div>
|
||||
<div class="library-status-info">
|
||||
<h4 class="library-status-title" id="library-status-title">Library</h4>
|
||||
<p class="library-status-subtitle" id="library-status-subtitle">Checking status...</p>
|
||||
</div>
|
||||
<div class="library-status-actions" id="library-status-actions">
|
||||
<button class="library-status-btn" id="library-status-scan-btn" style="display: none;" onclick="dashboardLibraryScan(false)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
<span id="library-status-scan-label">Refresh</span>
|
||||
</button>
|
||||
<button class="library-status-btn library-status-btn-secondary" id="library-status-deep-btn" style="display: none;" onclick="dashboardLibraryDeepScan()">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
|
||||
Deep Scan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="library-status-stats" id="library-status-stats" style="display: none;">
|
||||
<div class="library-status-stat">
|
||||
<div class="library-status-stat-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
</div>
|
||||
<div class="library-status-stat-text">
|
||||
<span class="library-status-stat-value" id="library-status-artists">0</span>
|
||||
<span class="library-status-stat-label">Artists</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="library-status-stat">
|
||||
<div class="library-status-stat-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||
</div>
|
||||
<div class="library-status-stat-text">
|
||||
<span class="library-status-stat-value" id="library-status-albums">0</span>
|
||||
<span class="library-status-stat-label">Albums</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="library-status-stat">
|
||||
<div class="library-status-stat-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
</div>
|
||||
<div class="library-status-stat-text">
|
||||
<span class="library-status-stat-value" id="library-status-tracks">0</span>
|
||||
<span class="library-status-stat-label">Tracks</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="library-status-stat">
|
||||
<div class="library-status-stat-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
|
||||
</div>
|
||||
<div class="library-status-stat-text">
|
||||
<span class="library-status-stat-value" id="library-status-size">--</span>
|
||||
<span class="library-status-stat-label">DB Size</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="library-status-progress" id="library-status-progress" style="display: none;">
|
||||
<div class="library-status-phase" id="library-status-phase">Scanning...</div>
|
||||
<div class="library-status-bar">
|
||||
<div class="library-status-bar-fill" id="library-status-bar-fill" style="width: 0%;"></div>
|
||||
</div>
|
||||
<div class="library-status-progress-detail" id="library-status-progress-detail">0 / 0</div>
|
||||
</div>
|
||||
<div class="library-status-message" id="library-status-message" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Recent Syncs Section -->
|
||||
<div class="dashboard-section">
|
||||
<h3 class="section-title">Recent Syncs</h3>
|
||||
<div class="sync-history-cards" id="sync-history-cards">
|
||||
<!-- Dynamically populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- Card: Recent Syncs (row 2) -->
|
||||
<article class="dash-card" data-card="syncs">
|
||||
<header class="dash-card__head">
|
||||
<h3 class="dash-card__title">Recent Syncs</h3>
|
||||
<p class="dash-card__sub">Playlists you've synced.</p>
|
||||
</header>
|
||||
<div class="dash-card__body">
|
||||
<div class="sync-history-cards" id="sync-history-cards">
|
||||
<!-- Dynamically populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="dashboard-section">
|
||||
<h3 class="section-title">System Statistics</h3>
|
||||
<div class="stats-grid-dashboard">
|
||||
<div class="stat-card-dashboard" id="active-downloads-card">
|
||||
<p class="stat-card-title">Active Downloads</p>
|
||||
<p class="stat-card-value">0</p>
|
||||
<p class="stat-card-subtitle">Currently downloading</p>
|
||||
<!-- Card: Tools (compact, top-right) — entry point to maintenance ops -->
|
||||
<article class="dash-card dash-card--cta" data-card="tools" onclick="navigateToPage('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>
|
||||
</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>
|
||||
<div class="stat-card-dashboard" id="finished-downloads-card">
|
||||
<p class="stat-card-title">Finished Downloads</p>
|
||||
<p class="stat-card-value">0</p>
|
||||
<p class="stat-card-subtitle">Completed this session</p>
|
||||
</div>
|
||||
<div class="stat-card-dashboard" id="download-speed-card">
|
||||
<p class="stat-card-title">Download Speed</p>
|
||||
<p class="stat-card-value">0 KB/s</p>
|
||||
<p class="stat-card-subtitle">Combined speed</p>
|
||||
</div>
|
||||
<div class="stat-card-dashboard" id="active-syncs-card">
|
||||
<p class="stat-card-title">Active Syncs</p>
|
||||
<p class="stat-card-value">0</p>
|
||||
<p class="stat-card-subtitle">Playlists syncing</p>
|
||||
</div>
|
||||
<div class="stat-card-dashboard" id="uptime-card">
|
||||
<p class="stat-card-title">System Uptime</p>
|
||||
<p class="stat-card-value">0m</p>
|
||||
<p class="stat-card-subtitle">Application runtime</p>
|
||||
</div>
|
||||
<div class="stat-card-dashboard" id="memory-card">
|
||||
<p class="stat-card-title">Memory Usage</p>
|
||||
<p class="stat-card-value">--</p>
|
||||
<p class="stat-card-subtitle">Current usage</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="dashboard-section" id="dashboard-active-downloads-section" style="display: none;">
|
||||
<h3 class="section-title">Active Downloads</h3>
|
||||
<div id="dashboard-downloads-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-section">
|
||||
<h3 class="section-title">Tools & Operations</h3>
|
||||
<div class="dashboard-tools-link" onclick="navigateToPage('tools')">
|
||||
<div class="dashboard-tools-link-content">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.5)" stroke-width="2" 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>
|
||||
<!-- Card: Recent Activity (row 2, after Tools) -->
|
||||
<article class="dash-card" data-card="activity">
|
||||
<header class="dash-card__head dash-card__head--withaction">
|
||||
<div>
|
||||
<span class="dashboard-tools-link-title">Database, scanning, backups, cache, maintenance & more</span>
|
||||
<h3 class="dash-card__title">Recent Activity</h3>
|
||||
<p class="dash-card__sub">What just happened.</p>
|
||||
</div>
|
||||
<button class="dash-card__head-btn" onclick="openLibraryHistoryModal()" title="View full library history">Download History</button>
|
||||
</header>
|
||||
<div class="dash-card__body">
|
||||
<div class="activity-feed-container" id="dashboard-activity-feed">
|
||||
<div class="activity-item">
|
||||
<span class="activity-icon">📊</span>
|
||||
<div class="activity-text-content">
|
||||
<p class="activity-title">System Started</p>
|
||||
<p class="activity-subtitle">Dashboard initialized successfully</p>
|
||||
</div>
|
||||
<p class="activity-time">Now</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
|
||||
<div class="dashboard-section">
|
||||
<div class="section-title-row">
|
||||
<h3 class="section-title">Recent Activity</h3>
|
||||
<button class="library-history-btn" onclick="openLibraryHistoryModal()" title="View full library history">Download History</button>
|
||||
</div>
|
||||
<div class="activity-feed-container" id="dashboard-activity-feed">
|
||||
<div class="activity-item">
|
||||
<span class="activity-icon">📊</span>
|
||||
<div class="activity-text-content">
|
||||
<p class="activity-title">System Started</p>
|
||||
<p class="activity-subtitle">Dashboard initialized successfully</p>
|
||||
</div>
|
||||
<p class="activity-time">Now</p>
|
||||
<!-- Card: Active Downloads (full-width, shows when downloads in flight) -->
|
||||
<article class="dash-card dash-card--full" id="dashboard-active-downloads-section" style="display: none;" data-card="active-downloads">
|
||||
<header class="dash-card__head">
|
||||
<h3 class="dash-card__title">Active Downloads</h3>
|
||||
<p class="dash-card__sub">In-flight transfers from your sources.</p>
|
||||
</header>
|
||||
<div class="dash-card__body">
|
||||
<div id="dashboard-downloads-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Card: Enrichment Detail (compact, paired with System Stats on the left) -->
|
||||
<article class="dash-card dash-card--full" id="rate-monitor-section" data-card="enrichment">
|
||||
<header class="dash-card__head">
|
||||
<h3 class="dash-card__title">Enrichment Services</h3>
|
||||
<p class="dash-card__sub">API rate monitoring across providers.</p>
|
||||
</header>
|
||||
<div class="dash-card__body">
|
||||
<div class="rate-monitor-grid" id="rate-monitor-grid">
|
||||
<!-- Populated dynamically by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
|
||||
<!-- Card: Recent Syncs (full-width) -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -880,12 +946,7 @@
|
|||
<button class="sync-tab-button" data-tab="youtube">
|
||||
<span class="tab-icon youtube-icon"></span> YouTube
|
||||
</button>
|
||||
<!-- Beatport tab hidden: Beatport added Cloudflare Turnstile to all
|
||||
public pages and locked their official OAuth API behind partner
|
||||
registration. No working server-side scrape or auth path right now.
|
||||
Tab content + backend endpoints are kept intact for fast revival
|
||||
once a workaround is found. -->
|
||||
<button class="sync-tab-button" data-tab="beatport" style="display: none;">
|
||||
<button class="sync-tab-button" data-tab="beatport">
|
||||
<span class="tab-icon beatport-icon"></span> Beatport
|
||||
</button>
|
||||
<button class="sync-tab-button" data-tab="import-file">
|
||||
|
|
@ -4256,6 +4317,7 @@
|
|||
<option value="qobuz">Qobuz Only</option>
|
||||
<option value="hifi">HiFi Only (Free Lossless)</option>
|
||||
<option value="deezer_dl">Deezer Only</option>
|
||||
<option value="amazon">Amazon Music Only</option>
|
||||
<option value="lidarr">Lidarr Only</option>
|
||||
<option value="soundcloud">SoundCloud Only</option>
|
||||
<option value="hybrid">Hybrid (Primary + Fallback)</option>
|
||||
|
|
@ -4578,6 +4640,39 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Amazon Music Download Settings (shown only when amazon mode is selected) -->
|
||||
<div id="amazon-download-settings-container" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label>Amazon Music Quality:</label>
|
||||
<select id="amazon-quality" class="form-select">
|
||||
<option value="flac">FLAC Lossless (24-bit/48kHz Hi-Res)</option>
|
||||
<option value="opus">Opus (320kbps)</option>
|
||||
<option value="eac3">EAC3 Dolby Atmos (768kbps 5.1)</option>
|
||||
</select>
|
||||
<div class="setting-help-text">
|
||||
Preferred codec tier. FLAC is 24-bit/48kHz Hi-Res — no subscription required.
|
||||
Downloads via T2Tunes proxy.
|
||||
</div>
|
||||
<label class="checkbox-inline" style="margin-top: 8px;">
|
||||
<input type="checkbox" id="amazon-allow-fallback" checked>
|
||||
Allow quality fallback
|
||||
</label>
|
||||
<div class="setting-help-text">
|
||||
Fall back to the next codec tier if the preferred one is unavailable.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Connection:</label>
|
||||
<div class="form-actions" style="margin-top: 4px;">
|
||||
<button class="test-button" onclick="testAmazonConnection()">Test Connection</button>
|
||||
<span id="amazon-connection-status" class="setting-help-text" style="margin-left: 8px;"></span>
|
||||
</div>
|
||||
<div class="setting-help-text">
|
||||
No account required — T2Tunes is a public Amazon Music proxy.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SoundCloud Download Settings -->
|
||||
<div id="soundcloud-download-settings-container" style="display: none;">
|
||||
<div class="form-group">
|
||||
|
|
|
|||
5
webui/static/amazon.svg
Normal file
5
webui/static/amazon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="black">
|
||||
<text x="12" y="13" text-anchor="middle" font-family="Arial,sans-serif" font-size="11" font-weight="bold" fill="black">a</text>
|
||||
<path d="M4.5 17 Q12 21 19.5 17" stroke="black" stroke-width="1.5" fill="none" stroke-linecap="round"/>
|
||||
<path d="M17.2 15 L19.5 17 L17.2 19" fill="none" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 451 B |
|
|
@ -4,17 +4,19 @@
|
|||
const _rateMonitorState = {};
|
||||
const _RATE_GAUGE_SERVICES = [
|
||||
'spotify', 'itunes', 'deezer', 'lastfm', 'genius',
|
||||
'musicbrainz', 'audiodb', 'tidal', 'qobuz', 'discogs',
|
||||
'musicbrainz', 'audiodb', 'tidal', 'qobuz', 'discogs', 'amazon',
|
||||
];
|
||||
const _RATE_GAUGE_LABELS = {
|
||||
spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer',
|
||||
lastfm: 'Last.fm', genius: 'Genius', musicbrainz: 'MusicBrainz',
|
||||
audiodb: 'AudioDB', tidal: 'Tidal', qobuz: 'Qobuz', discogs: 'Discogs',
|
||||
amazon: 'Amazon Music',
|
||||
};
|
||||
const _RATE_GAUGE_COLORS = {
|
||||
spotify: '#1DB954', itunes: '#FC3C44', deezer: '#A238FF',
|
||||
lastfm: '#D51007', genius: '#FFFF64', musicbrainz: '#BA478F',
|
||||
audiodb: '#00BCD4', tidal: '#00FFFF', qobuz: '#FF6B35', discogs: '#D4A574',
|
||||
amazon: '#FF9900',
|
||||
};
|
||||
|
||||
// SVG constants — 240° arc, gap at bottom
|
||||
|
|
@ -614,6 +616,26 @@ async function fetchAndUpdateActivityFeed() {
|
|||
// Cache last feed signature to avoid unnecessary DOM rebuilds (prevents blink)
|
||||
let _lastActivityFeedSig = '';
|
||||
|
||||
// Activity items carry `timestamp` (Unix epoch seconds) — `activity.time`
|
||||
// is a human label like "Now" that doesn't parse as a date. Use the
|
||||
// epoch for relative-time formatting; fall back to the label only
|
||||
// when no timestamp is present (legacy items, future shapes).
|
||||
function _activityTimeAgo(activity) {
|
||||
const ts = activity && activity.timestamp;
|
||||
if (typeof ts !== 'number' || !isFinite(ts)) {
|
||||
return (activity && activity.time) || '';
|
||||
}
|
||||
const diffMs = Date.now() - ts * 1000;
|
||||
if (diffMs < 60000) return 'Just now';
|
||||
const mins = Math.floor(diffMs / 60000);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
return `${Math.floor(days / 30)}mo ago`;
|
||||
}
|
||||
|
||||
function updateActivityFeed(activities) {
|
||||
const feedContainer = document.getElementById('dashboard-activity-feed');
|
||||
if (!feedContainer) return;
|
||||
|
|
@ -644,7 +666,7 @@ function updateActivityFeed(activities) {
|
|||
// Just update timestamps without rebuilding DOM
|
||||
const timeEls = feedContainer.querySelectorAll('.activity-time');
|
||||
items.forEach((activity, i) => {
|
||||
if (timeEls[i]) timeEls[i].textContent = timeAgo(activity.time);
|
||||
if (timeEls[i]) timeEls[i].textContent = _activityTimeAgo(activity);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -660,7 +682,7 @@ function updateActivityFeed(activities) {
|
|||
<p class="activity-title">${escapeHtml(activity.title)}</p>
|
||||
<p class="activity-subtitle">${escapeHtml(activity.subtitle)}</p>
|
||||
</div>
|
||||
<p class="activity-time">${timeAgo(activity.time)}</p>
|
||||
<p class="activity-time">${_activityTimeAgo(activity)}</p>
|
||||
`;
|
||||
feedContainer.appendChild(activityElement);
|
||||
|
||||
|
|
@ -952,7 +974,8 @@ async function initializeWatchlistPage() {
|
|||
if (artist.itunes_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-itunes">iTunes</span>');
|
||||
if (artist.deezer_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-deezer">Deezer</span>');
|
||||
if (artist.discogs_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-discogs">Discogs</span>');
|
||||
const artistPrimaryId = artist.spotify_artist_id || artist.itunes_artist_id || artist.deezer_artist_id || artist.discogs_artist_id;
|
||||
if (artist.amazon_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-amazon">Amazon</span>');
|
||||
const artistPrimaryId = artist.spotify_artist_id || artist.itunes_artist_id || artist.deezer_artist_id || artist.discogs_artist_id || artist.amazon_artist_id;
|
||||
return `
|
||||
<div class="watchlist-artist-card"
|
||||
data-artist-name="${artist.artist_name.toLowerCase().replace(/"/g, '"')}"
|
||||
|
|
@ -1744,7 +1767,8 @@ async function showWatchlistModal() {
|
|||
if (artist.itunes_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-itunes">iTunes</span>');
|
||||
if (artist.deezer_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-deezer">Deezer</span>');
|
||||
if (artist.discogs_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-discogs">Discogs</span>');
|
||||
const artistPrimaryId = artist.spotify_artist_id || artist.itunes_artist_id || artist.deezer_artist_id || artist.discogs_artist_id;
|
||||
if (artist.amazon_artist_id) sourceBadges.push('<span class="watchlist-source-badge watchlist-source-amazon">Amazon</span>');
|
||||
const artistPrimaryId = artist.spotify_artist_id || artist.itunes_artist_id || artist.deezer_artist_id || artist.discogs_artist_id || artist.amazon_artist_id;
|
||||
return `
|
||||
<div class="watchlist-artist-card"
|
||||
data-artist-name="${artist.artist_name.toLowerCase().replace(/"/g, '"')}"
|
||||
|
|
@ -1900,7 +1924,7 @@ function closeWatchlistModal() {
|
|||
* Populate the linked provider section in the watchlist config modal.
|
||||
* Shows which Spotify/iTunes/Deezer artist is linked and allows changing it.
|
||||
*/
|
||||
function _populateLinkedProviderSection(artistId, artistName, spotifyId, itunesId, artistInfo, deezerId, discogsId) {
|
||||
function _populateLinkedProviderSection(artistId, artistName, spotifyId, itunesId, artistInfo, deezerId, discogsId, amazonId) {
|
||||
const section = document.getElementById('watchlist-linked-provider-section');
|
||||
const content = document.getElementById('watchlist-linked-provider-content');
|
||||
if (!section || !content) return;
|
||||
|
|
@ -1912,6 +1936,7 @@ function _populateLinkedProviderSection(artistId, artistName, spotifyId, itunesI
|
|||
{ key: 'itunes', label: 'Apple Music', icon: '🔴', id: itunesId || '', color: '#fc3c44' },
|
||||
{ key: 'deezer', label: 'Deezer', icon: '🟣', id: deezerId || '', color: '#a238ff' },
|
||||
{ key: 'discogs', label: 'Discogs', icon: '🟤', id: discogsId || '', color: '#b08968' },
|
||||
{ key: 'amazon', label: 'Amazon Music', icon: '🟠', id: amazonId || '', color: '#FF9900' },
|
||||
];
|
||||
|
||||
let html = '<div class="wl-linked-sources">';
|
||||
|
|
@ -1955,7 +1980,7 @@ function _populateLinkedProviderSection(artistId, artistName, spotifyId, itunesI
|
|||
function _openSourceSearch(sourceKey, artistId, artistName) {
|
||||
const panel = document.getElementById('wl-linked-search-panel');
|
||||
if (!panel) return;
|
||||
const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs' };
|
||||
const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', amazon: 'Amazon Music' };
|
||||
document.getElementById('wl-linked-search-title').textContent = `Search ${labels[sourceKey] || sourceKey}`;
|
||||
const input = document.getElementById('wl-linked-search-input');
|
||||
input.value = artistName;
|
||||
|
|
@ -2083,10 +2108,10 @@ async function openWatchlistArtistConfigModal(artistId, artistName) {
|
|||
return;
|
||||
}
|
||||
|
||||
const { config, artist, spotify_artist_id, itunes_artist_id, deezer_artist_id, discogs_artist_id, watchlist_name } = data;
|
||||
const { config, artist, spotify_artist_id, itunes_artist_id, deezer_artist_id, discogs_artist_id, amazon_artist_id, watchlist_name } = data;
|
||||
|
||||
// Populate linked provider section (use DB watchlist_name for mismatch comparison)
|
||||
_populateLinkedProviderSection(artistId, watchlist_name || artistName, spotify_artist_id, itunes_artist_id, artist, deezer_artist_id, discogs_artist_id);
|
||||
_populateLinkedProviderSection(artistId, watchlist_name || artistName, spotify_artist_id, itunes_artist_id, artist, deezer_artist_id, discogs_artist_id, amazon_artist_id);
|
||||
|
||||
// Check if global override is active
|
||||
let globalOverrideActive = false;
|
||||
|
|
|
|||
|
|
@ -446,6 +446,7 @@ function initializeWebSocket() {
|
|||
socket.on('enrichment:genius-enrichment', (data) => updateGeniusEnrichmentStatusFromData(data));
|
||||
socket.on('enrichment:tidal-enrichment', (data) => updateTidalEnrichmentStatusFromData(data));
|
||||
socket.on('enrichment:qobuz-enrichment', (data) => updateQobuzEnrichmentStatusFromData(data));
|
||||
socket.on('enrichment:amazon-enrichment', (data) => updateAmazonEnrichmentStatusFromData(data));
|
||||
socket.on('enrichment:hydrabase', (data) => updateHydrabaseStatusFromData(data));
|
||||
socket.on('enrichment:repair', (data) => updateRepairStatusFromData(data));
|
||||
socket.on('enrichment:soulid', (data) => updateSoulIDStatusFromData(data));
|
||||
|
|
@ -675,6 +676,7 @@ const GENIUS_LOGO_URL = 'https://images.genius.com/8ed669cadd956443e29c70361ec4f
|
|||
const TIDAL_LOGO_URL = 'https://www.svgrepo.com/show/519734/tidal.svg';
|
||||
const QOBUZ_LOGO_URL = 'https://www.svgrepo.com/show/504778/qobuz.svg';
|
||||
const DISCOGS_LOGO_URL = 'https://www.svgrepo.com/show/305957/discogs.svg';
|
||||
const AMAZON_LOGO_URL = '/static/amazon.svg';
|
||||
function getAudioDBLogoURL() { const el = document.querySelector('img.audiodb-logo'); return el ? el.src : null; }
|
||||
|
||||
// --- Wishlist Modal Persistence State Management ---
|
||||
|
|
|
|||
|
|
@ -1205,6 +1205,112 @@ if (document.readyState === 'loading') {
|
|||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// AMAZON MUSIC ENRICHMENT WORKER
|
||||
// ===================================================================
|
||||
|
||||
async function updateAmazonEnrichmentStatus() {
|
||||
if (socketConnected) return;
|
||||
if (document.hidden) return;
|
||||
try {
|
||||
const response = await fetch('/api/enrichment/amazon/status');
|
||||
if (!response.ok) { console.warn('Amazon enrichment status endpoint unavailable'); return; }
|
||||
const data = await response.json();
|
||||
updateAmazonEnrichmentStatusFromData(data);
|
||||
} catch (error) {
|
||||
console.error('Error updating Amazon enrichment status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateAmazonEnrichmentStatusFromData(data) {
|
||||
const button = document.getElementById('amazon-enrich-button');
|
||||
if (!button) return;
|
||||
|
||||
button.classList.remove('active', 'paused', 'complete');
|
||||
if (data.paused) {
|
||||
button.classList.add('paused');
|
||||
} else if (data.idle) {
|
||||
button.classList.add('complete');
|
||||
} else if (data.running && !data.paused) {
|
||||
button.classList.add('active');
|
||||
}
|
||||
|
||||
const tooltipStatus = document.getElementById('amazon-enrich-tooltip-status');
|
||||
const tooltipCurrent = document.getElementById('amazon-enrich-tooltip-current');
|
||||
const tooltipProgress = document.getElementById('amazon-enrich-tooltip-progress');
|
||||
|
||||
if (tooltipStatus) {
|
||||
if (data.paused) { tooltipStatus.textContent = data.yield_reason === 'downloads' ? 'Yielding for downloads' : 'Paused'; }
|
||||
else if (data.idle) { tooltipStatus.textContent = 'Complete'; }
|
||||
else if (data.running) { tooltipStatus.textContent = 'Running'; }
|
||||
else { tooltipStatus.textContent = 'Idle'; }
|
||||
}
|
||||
|
||||
if (tooltipCurrent) {
|
||||
if (data.idle) {
|
||||
tooltipCurrent.textContent = 'All items processed';
|
||||
} else if (data.current_item && data.current_item.name) {
|
||||
tooltipCurrent.textContent = `Now: ${data.current_item.name}`;
|
||||
} else {
|
||||
tooltipCurrent.textContent = 'No active matches';
|
||||
}
|
||||
}
|
||||
|
||||
if (data.progress && tooltipProgress) {
|
||||
const artists = data.progress.artists || {};
|
||||
const albums = data.progress.albums || {};
|
||||
const tracks = data.progress.tracks || {};
|
||||
const currentType = data.current_item?.type;
|
||||
let progressText = '';
|
||||
const artistsComplete = artists.matched >= artists.total;
|
||||
const albumsComplete = albums.matched >= albums.total;
|
||||
if (currentType === 'artist' || (!artistsComplete && !currentType)) {
|
||||
progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`;
|
||||
} else if (currentType === 'album' || (!albumsComplete && !currentType)) {
|
||||
progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`;
|
||||
} else {
|
||||
progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`;
|
||||
}
|
||||
tooltipProgress.textContent = progressText;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAmazonEnrichment() {
|
||||
try {
|
||||
const button = document.getElementById('amazon-enrich-button');
|
||||
if (!button) return;
|
||||
const isRunning = button.classList.contains('active');
|
||||
const endpoint = isRunning ? '/api/enrichment/amazon/pause' : '/api/enrichment/amazon/resume';
|
||||
const response = await fetch(endpoint, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Amazon enrichment`);
|
||||
}
|
||||
await updateAmazonEnrichmentStatus();
|
||||
console.log(`Amazon enrichment ${isRunning ? 'paused' : 'resumed'}`);
|
||||
} catch (error) {
|
||||
console.error('Error toggling Amazon enrichment:', error);
|
||||
showToast(`Error: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const button = document.getElementById('amazon-enrich-button');
|
||||
if (button) {
|
||||
button.addEventListener('click', toggleAmazonEnrichment);
|
||||
updateAmazonEnrichmentStatus();
|
||||
setInterval(updateAmazonEnrichmentStatus, 2000);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const button = document.getElementById('amazon-enrich-button');
|
||||
if (button) {
|
||||
button.addEventListener('click', toggleAmazonEnrichment);
|
||||
updateAmazonEnrichmentStatus();
|
||||
setInterval(updateAmazonEnrichmentStatus, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// HYDRABASE P2P MIRROR WORKER
|
||||
// ===================================================================
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue