commit
f7167619e1
253 changed files with 54966 additions and 26389 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. 1.6, 1.7)'
|
||||
description: 'Version tag (e.g. 2.4.1)'
|
||||
required: true
|
||||
default: '2.3'
|
||||
default: '2.4.1'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Download management endpoints — list, cancel active downloads.
|
|||
from flask import request, current_app
|
||||
from .auth import require_api_key
|
||||
from .helpers import api_success, api_error
|
||||
from core.runtime_state import download_tasks, tasks_lock
|
||||
|
||||
|
||||
def _serialize_download(task_id, task):
|
||||
|
|
@ -53,8 +54,6 @@ def register_routes(bp):
|
|||
descending so newest/in-flight tasks appear first.
|
||||
"""
|
||||
try:
|
||||
from web_server import download_tasks, tasks_lock
|
||||
|
||||
# Parse pagination params
|
||||
try:
|
||||
limit = int(request.args.get("limit", 100))
|
||||
|
|
|
|||
|
|
@ -287,12 +287,12 @@ def serialize_watchlist_artist(obj, fields: Optional[Set[str]] = None) -> dict:
|
|||
def serialize_wishlist_track(obj, fields: Optional[Set[str]] = None) -> dict:
|
||||
"""Standardized wishlist track serialization."""
|
||||
d = _to_dict(obj)
|
||||
spotify_data = d.get("spotify_data", {})
|
||||
if isinstance(spotify_data, str):
|
||||
track_data = d.get("track_data", d.get("spotify_data", {}))
|
||||
if isinstance(track_data, str):
|
||||
try:
|
||||
spotify_data = json.loads(spotify_data)
|
||||
track_data = json.loads(track_data)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
spotify_data = {}
|
||||
track_data = {}
|
||||
|
||||
source_info = d.get("source_info")
|
||||
if isinstance(source_info, str):
|
||||
|
|
@ -303,17 +303,23 @@ def serialize_wishlist_track(obj, fields: Optional[Set[str]] = None) -> dict:
|
|||
|
||||
result = {
|
||||
"id": d.get("id"),
|
||||
"track_id": d.get("track_id") or d.get("spotify_track_id") or d.get("id"),
|
||||
"spotify_track_id": d.get("spotify_track_id"),
|
||||
"track_name": spotify_data.get("name", "Unknown") if isinstance(spotify_data, dict) else "Unknown",
|
||||
"track_name": (
|
||||
track_data.get("name", "Unknown") if isinstance(track_data, dict) else d.get("track_name", "Unknown")
|
||||
),
|
||||
"artist_name": ", ".join(
|
||||
a.get("name", "") for a in spotify_data.get("artists", [])
|
||||
) if isinstance(spotify_data, dict) and isinstance(spotify_data.get("artists"), list) else "",
|
||||
a.get("name", "") if isinstance(a, dict) else str(a)
|
||||
for a in track_data.get("artists", [])
|
||||
) if isinstance(track_data, dict) and isinstance(track_data.get("artists"), list) else "",
|
||||
"album_name": (
|
||||
spotify_data.get("album", {}).get("name")
|
||||
if isinstance(spotify_data, dict) and isinstance(spotify_data.get("album"), dict)
|
||||
track_data.get("album", {}).get("name")
|
||||
if isinstance(track_data, dict) and isinstance(track_data.get("album"), dict)
|
||||
else None
|
||||
),
|
||||
"spotify_data": spotify_data,
|
||||
"track_data": track_data,
|
||||
"spotify_data": track_data,
|
||||
"provider": track_data.get("provider") if isinstance(track_data, dict) else d.get("provider"),
|
||||
"failure_reason": d.get("failure_reason"),
|
||||
"retry_count": d.get("retry_count", 0),
|
||||
"last_attempted": _isoformat(d.get("last_attempted")),
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ def register_routes(bp):
|
|||
def system_activity():
|
||||
"""Recent activity feed."""
|
||||
try:
|
||||
from web_server import activity_feed
|
||||
from core.runtime_state import activity_feed
|
||||
items = list(activity_feed) if activity_feed else []
|
||||
return api_success({"activities": items})
|
||||
except Exception as e:
|
||||
|
|
@ -74,7 +74,7 @@ def register_routes(bp):
|
|||
# Active download count
|
||||
download_count = 0
|
||||
try:
|
||||
from web_server import download_tasks, tasks_lock
|
||||
from core.runtime_state import download_tasks, tasks_lock
|
||||
with tasks_lock:
|
||||
download_count = sum(
|
||||
1 for t in download_tasks.values()
|
||||
|
|
|
|||
|
|
@ -45,16 +45,16 @@ def register_routes(bp):
|
|||
def add_to_wishlist():
|
||||
"""Add a track to the wishlist.
|
||||
|
||||
Body: {"spotify_track_data": {...}, "failure_reason": "...", "source_type": "..."}
|
||||
Body: {"track_data": {...}, "failure_reason": "...", "source_type": "..."}
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
track_data = body.get("spotify_track_data")
|
||||
track_data = body.get("track_data") or body.get("spotify_track_data")
|
||||
reason = body.get("failure_reason", "Added via API")
|
||||
source_type = body.get("source_type", "api")
|
||||
profile_id = parse_profile_id(request)
|
||||
|
||||
if not track_data:
|
||||
return api_error("BAD_REQUEST", "Missing 'spotify_track_data' in body.", 400)
|
||||
return api_error("BAD_REQUEST", "Missing 'track_data' in body.", 400)
|
||||
|
||||
try:
|
||||
from database.music_database import get_database
|
||||
|
|
@ -74,7 +74,7 @@ def register_routes(bp):
|
|||
@bp.route("/wishlist/<track_id>", methods=["DELETE"])
|
||||
@require_api_key
|
||||
def remove_from_wishlist(track_id):
|
||||
"""Remove a track from the wishlist by its Spotify track ID."""
|
||||
"""Remove a track from the wishlist by its track ID."""
|
||||
profile_id = parse_profile_id(request)
|
||||
try:
|
||||
from database.music_database import get_database
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import copy
|
|||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Dict, Any, Optional
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from pathlib import Path
|
||||
|
|
@ -220,7 +221,7 @@ class ConfigManager:
|
|||
"""Re-save config to encrypt any plaintext sensitive values still in the DB."""
|
||||
try:
|
||||
# Read raw DB content to check if any sensitive value is still plaintext
|
||||
conn = sqlite3.connect(str(self.database_path))
|
||||
conn = self._connect_db()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT value FROM metadata WHERE key = 'app_config'")
|
||||
row = cursor.fetchone()
|
||||
|
|
@ -257,6 +258,19 @@ class ConfigManager:
|
|||
except Exception as e:
|
||||
logger.warning(f"Could not migrate encryption: {e}")
|
||||
|
||||
def _connect_db(self) -> sqlite3.Connection:
|
||||
"""Open a configured SQLite connection for the config DB.
|
||||
|
||||
Centralizes pragma setup so every connection gets WAL mode,
|
||||
a 30s busy timeout, and synchronous=NORMAL (the safe pairing
|
||||
with WAL that avoids unnecessary fsyncs on slow disks).
|
||||
"""
|
||||
conn = sqlite3.connect(str(self.database_path), timeout=30.0)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=30000")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
return conn
|
||||
|
||||
def _ensure_database_exists(self):
|
||||
"""Ensure database file and metadata table exist"""
|
||||
try:
|
||||
|
|
@ -264,8 +278,7 @@ class ConfigManager:
|
|||
self.database_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Connect to database (creates file if it doesn't exist)
|
||||
conn = sqlite3.connect(str(self.database_path), timeout=30.0)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn = self._connect_db()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create metadata table if it doesn't exist
|
||||
|
|
@ -287,8 +300,7 @@ class ConfigManager:
|
|||
try:
|
||||
self._ensure_database_exists()
|
||||
|
||||
conn = sqlite3.connect(str(self.database_path), timeout=30.0)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn = self._connect_db()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT value FROM metadata WHERE key = 'app_config'")
|
||||
row = cursor.fetchone()
|
||||
|
|
@ -314,8 +326,7 @@ class ConfigManager:
|
|||
conn = None
|
||||
try:
|
||||
self._ensure_database_exists()
|
||||
conn = sqlite3.connect(str(self.database_path), timeout=30.0)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn = self._connect_db()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT value FROM metadata WHERE key = 'log_level'")
|
||||
row = cursor.fetchone()
|
||||
|
|
@ -362,7 +373,13 @@ class ConfigManager:
|
|||
return config_data
|
||||
|
||||
def _save_to_database(self, config_data: Dict[str, Any]) -> bool:
|
||||
"""Save configuration to database, encrypting sensitive values."""
|
||||
"""Save configuration to database, encrypting sensitive values.
|
||||
|
||||
Returns ``True`` on success. Transient ``database is locked``
|
||||
failures are logged at DEBUG so the caller's retry loop owns the
|
||||
user-visible error message — otherwise every retry would spam
|
||||
ERROR-level logs even when the next attempt succeeds.
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
self._ensure_database_exists()
|
||||
|
|
@ -370,9 +387,7 @@ class ConfigManager:
|
|||
# Encrypt sensitive values before writing (original dict is untouched)
|
||||
encrypted_data = self._encrypt_sensitive(config_data)
|
||||
|
||||
# Use longer timeout (30s) to handle contention from enrichment workers
|
||||
conn = sqlite3.connect(str(self.database_path), timeout=30.0)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn = self._connect_db()
|
||||
cursor = conn.cursor()
|
||||
|
||||
config_json = json.dumps(encrypted_data, indent=2)
|
||||
|
|
@ -384,6 +399,16 @@ class ConfigManager:
|
|||
conn.commit()
|
||||
return True
|
||||
|
||||
except sqlite3.OperationalError as e:
|
||||
# SQLite raises OperationalError("database is locked") when the
|
||||
# busy_timeout expires while another writer holds the lock.
|
||||
# Log at DEBUG so the caller can decide whether the final
|
||||
# outcome warrants an ERROR-level message.
|
||||
if "locked" in str(e).lower():
|
||||
logger.debug(f"Config DB locked, will retry: {e}")
|
||||
else:
|
||||
logger.error(f"Could not save config to database: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Could not save config to database: {e}")
|
||||
return False
|
||||
|
|
@ -607,23 +632,36 @@ class ConfigManager:
|
|||
self.config_data = self._apply_log_level_overrides(config_data)
|
||||
|
||||
def _save_config(self):
|
||||
"""Save configuration to database with retry on lock."""
|
||||
success = self._save_to_database(self.config_data)
|
||||
"""Save configuration to database with exponential-backoff retry on lock.
|
||||
|
||||
if not success:
|
||||
# Retry once after a brief wait (handles transient lock contention)
|
||||
import time
|
||||
time.sleep(1)
|
||||
success = self._save_to_database(self.config_data)
|
||||
Spread retries over ~7 seconds so a long-held writer (enrichment
|
||||
worker batch insert, library scan commit, etc.) on a slow disk
|
||||
has time to release the lock before we fall back to the JSON
|
||||
file. The single 1s retry that used to live here gave up too
|
||||
early on HDD-backed Docker volumes.
|
||||
"""
|
||||
# Cumulative delay across attempts: 0.2 + 0.5 + 1.0 + 2.0 + 4.0 = 7.7s
|
||||
# plus the 30s busy_timeout that already runs inside each attempt.
|
||||
retry_delays = [0.2, 0.5, 1.0, 2.0, 4.0]
|
||||
if self._save_to_database(self.config_data):
|
||||
return
|
||||
|
||||
if not success:
|
||||
# Fallback: Try to save to config.json if database fails
|
||||
logger.warning("Database save failed - attempting file fallback")
|
||||
for delay in retry_delays:
|
||||
time.sleep(delay)
|
||||
if self._save_to_database(self.config_data):
|
||||
return
|
||||
|
||||
# All retries exhausted — fall back to config.json so the user
|
||||
# doesn't lose their settings, then log a single error.
|
||||
logger.error(
|
||||
f"Config DB save failed after {len(retry_delays) + 1} attempts (database is locked) — "
|
||||
"falling back to config.json"
|
||||
)
|
||||
try:
|
||||
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.config_path, 'w') as f:
|
||||
json.dump(self.config_data, f, indent=2)
|
||||
logger.info("Configuration saved to config.json as fallback")
|
||||
logger.warning("Configuration saved to config.json as fallback")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save configuration: {e}")
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ Used by ``/api/artist-detail/<id>`` when the URL is called with a ``source``
|
|||
query parameter and the library DB lookup misses. Enriches the response with
|
||||
whatever metadata we can pull on demand:
|
||||
|
||||
* Image URL (via ``metadata_service.get_artist_image_url``)
|
||||
* Image URL (via ``core.metadata.artist_image.get_artist_image_url``)
|
||||
* Source-specific artist info — genres + follower count from the named
|
||||
source's ``get_artist`` / ``get_artist_info`` helper
|
||||
* Last.fm bio + listeners + playcount + URL (by artist name)
|
||||
|
|
@ -27,6 +27,9 @@ import logging
|
|||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from core.artist_source_lookup import SOURCE_ID_FIELD
|
||||
from core.metadata import artist_image as metadata_artist_image
|
||||
from core.metadata import discography as metadata_discography
|
||||
from core.metadata.lookup import MetadataLookupOptions
|
||||
|
||||
logger = logging.getLogger("artist_source_detail")
|
||||
|
||||
|
|
@ -48,21 +51,12 @@ def build_source_only_artist_detail(
|
|||
``jsonify`` or equivalent. Status is 200 on success, 404 when the
|
||||
source's discography lookup returned no releases.
|
||||
"""
|
||||
# Deferred import — keeps the top-level module importable in test rigs
|
||||
# that stub out only what they need (same pattern `_find_library_artist`
|
||||
# uses).
|
||||
from core.metadata_service import (
|
||||
MetadataLookupOptions,
|
||||
get_artist_detail_discography,
|
||||
get_artist_image_url,
|
||||
)
|
||||
|
||||
resolved_name = (artist_name or artist_id or "").strip()
|
||||
|
||||
# 1. Image URL via the same helper /api/artist/<id>/image uses.
|
||||
image_url: Optional[str] = None
|
||||
try:
|
||||
image_url = get_artist_image_url(artist_id, source_override=source)
|
||||
image_url = metadata_artist_image.get_artist_image_url(artist_id, source_override=source)
|
||||
except Exception as e:
|
||||
logger.debug(f"Artist image lookup failed for {source}:{artist_id}: {e}")
|
||||
|
||||
|
|
@ -124,7 +118,7 @@ def build_source_only_artist_detail(
|
|||
# 4. Discography from the specified source. Skip variant dedup so the
|
||||
# page shows every release the source returns — matches the inline
|
||||
# Artists-page behaviour that this view was modelled after.
|
||||
discography_result = get_artist_detail_discography(
|
||||
discography_result = metadata_discography.get_artist_detail_discography(
|
||||
artist_id,
|
||||
artist_name=resolved_name or artist_id,
|
||||
options=MetadataLookupOptions(
|
||||
|
|
|
|||
0
core/artists/__init__.py
Normal file
0
core/artists/__init__.py
Normal file
313
core/artists/liked_match.py
Normal file
313
core/artists/liked_match.py
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
"""Liked-artist multi-source matching — lifted from web_server.py.
|
||||
|
||||
Both function bodies are byte-identical to the originals. The
|
||||
``spotify_client`` proxy + ``_get_*_client`` shims let the bodies resolve
|
||||
their original names without any modification.
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
|
||||
from config.settings import config_manager
|
||||
from core.metadata.registry import (
|
||||
get_deezer_client,
|
||||
get_discogs_client,
|
||||
get_itunes_client,
|
||||
get_spotify_client,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_itunes_client():
|
||||
"""Mirror of web_server._get_itunes_client — delegates to registry."""
|
||||
return get_itunes_client()
|
||||
|
||||
|
||||
def _get_deezer_client():
|
||||
"""Mirror of web_server._get_deezer_client — delegates to registry."""
|
||||
return get_deezer_client()
|
||||
|
||||
|
||||
def _get_discogs_client(token=None):
|
||||
"""Mirror of web_server._get_discogs_client — delegates to registry."""
|
||||
return get_discogs_client(token)
|
||||
|
||||
|
||||
class _SpotifyClientProxy:
|
||||
"""Resolves the global Spotify client lazily so a Spotify re-auth that
|
||||
rebinds the cached client in core.metadata.registry is visible to the
|
||||
lifted bodies."""
|
||||
|
||||
def __getattr__(self, name):
|
||||
client = get_spotify_client()
|
||||
if client is None:
|
||||
raise AttributeError(name)
|
||||
return getattr(client, name)
|
||||
|
||||
def __bool__(self):
|
||||
return get_spotify_client() is not None
|
||||
|
||||
|
||||
spotify_client = _SpotifyClientProxy()
|
||||
|
||||
|
||||
def _match_liked_artists_to_all_sources(database, profile_id: int):
|
||||
"""Match pending liked artists to ALL metadata sources (Spotify, iTunes, Deezer, Discogs).
|
||||
Uses the same matching pattern as the watchlist scanner: DB-first, then API search
|
||||
with fuzzy name matching. Stores all resolved IDs so source switching works instantly."""
|
||||
pending = database.get_liked_artists_pending_match(profile_id, limit=200)
|
||||
if not pending:
|
||||
return
|
||||
|
||||
# Source → column mapping
|
||||
source_cols = {
|
||||
'spotify': 'spotify_artist_id',
|
||||
'itunes': 'itunes_artist_id',
|
||||
'deezer': 'deezer_artist_id',
|
||||
'discogs': 'discogs_artist_id',
|
||||
}
|
||||
id_cols = list(source_cols.values())
|
||||
|
||||
# Reject known placeholder images and local server paths
|
||||
_placeholder_hashes = {'2a96cbd8b46e442fc41c2b86b821562f'}
|
||||
def _valid_image(url):
|
||||
if not url or not url.strip():
|
||||
return None
|
||||
if any(ph in url for ph in _placeholder_hashes):
|
||||
return None
|
||||
# Reject local media server paths (Plex/Jellyfin) — not loadable in browser
|
||||
if url.startswith('/') or url.startswith('\\'):
|
||||
return None
|
||||
if not url.startswith('http'):
|
||||
return None
|
||||
return url
|
||||
|
||||
# Build search clients for each source
|
||||
from core.deezer_client import DeezerClient
|
||||
search_clients = {}
|
||||
if spotify_client and spotify_client.is_spotify_authenticated():
|
||||
search_clients['spotify'] = spotify_client
|
||||
try:
|
||||
search_clients['itunes'] = _get_itunes_client()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
search_clients['deezer'] = _get_deezer_client()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
dc = _get_discogs_client()
|
||||
# Only use Discogs if token is configured
|
||||
from config.settings import config_manager as _cm
|
||||
if _cm.get('discogs.token', ''):
|
||||
search_clients['discogs'] = dc
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Reuse watchlist scanner's fuzzy matching logic
|
||||
from core.watchlist_scanner import WatchlistScanner
|
||||
_normalize = WatchlistScanner._normalize_artist_name
|
||||
|
||||
def _best_match(results, artist_name):
|
||||
"""Pick best match from search results using name similarity (same as watchlist scanner)."""
|
||||
if not results:
|
||||
return None
|
||||
# Exact normalized match
|
||||
for r in results:
|
||||
if _normalize(r.name) == _normalize(artist_name):
|
||||
return r
|
||||
# Fuzzy scoring
|
||||
best = None
|
||||
best_sim = 0
|
||||
for r in results:
|
||||
# Simple normalized comparison
|
||||
n1 = _normalize(artist_name)
|
||||
n2 = _normalize(r.name)
|
||||
if n1 == n2:
|
||||
return r
|
||||
# Levenshtein-style similarity
|
||||
max_len = max(len(n1), len(n2))
|
||||
if max_len == 0:
|
||||
continue
|
||||
distance = sum(1 for a, b in zip(n1, n2, strict=False) if a != b) + abs(len(n1) - len(n2))
|
||||
sim = (max_len - distance) / max_len
|
||||
if sim > best_sim:
|
||||
best_sim = sim
|
||||
best = r
|
||||
if best and best_sim >= 0.85:
|
||||
return best
|
||||
return None
|
||||
|
||||
api_calls = 0
|
||||
matched = 0
|
||||
|
||||
for entry in pending:
|
||||
name = entry['artist_name']
|
||||
pool_id = entry['id']
|
||||
harvested_ids = {}
|
||||
best_image = None
|
||||
|
||||
# Pre-load existing IDs from the entry itself
|
||||
for col in id_cols:
|
||||
if entry.get(col):
|
||||
harvested_ids[col] = entry[col]
|
||||
|
||||
# --- DB STRATEGIES (free, no API calls) ---
|
||||
|
||||
# 1. Library artists table
|
||||
try:
|
||||
conn = database._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM artists WHERE name = ? COLLATE NOCASE LIMIT 1", (name,))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
r = dict(row)
|
||||
for col in id_cols:
|
||||
if r.get(col) and col not in harvested_ids:
|
||||
harvested_ids[col] = str(r[col])
|
||||
if _valid_image(r.get('thumb_url')):
|
||||
best_image = r['thumb_url']
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Watchlist artists
|
||||
try:
|
||||
conn = database._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT * FROM watchlist_artists WHERE artist_name = ? COLLATE NOCASE AND profile_id = ? LIMIT 1",
|
||||
(name, profile_id)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
wl = dict(row)
|
||||
for col in id_cols:
|
||||
if wl.get(col) and col not in harvested_ids:
|
||||
harvested_ids[col] = str(wl[col])
|
||||
if _valid_image(wl.get('image_url')) and not best_image:
|
||||
best_image = wl['image_url']
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. Metadata cache (all sources)
|
||||
try:
|
||||
conn = database._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT entity_id, source, image_url FROM metadata_cache_entities WHERE entity_type = 'artist' AND name = ? COLLATE NOCASE",
|
||||
(name,)
|
||||
)
|
||||
for row in cursor.fetchall():
|
||||
col = source_cols.get(row['source'])
|
||||
if col and col not in harvested_ids:
|
||||
harvested_ids[col] = row['entity_id']
|
||||
if _valid_image(row['image_url']) and not best_image:
|
||||
best_image = row['image_url']
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- API STRATEGIES (search each missing source) ---
|
||||
# Same pattern as watchlist scanner's _backfill_missing_ids
|
||||
for source, col in source_cols.items():
|
||||
if col in harvested_ids:
|
||||
continue # Already have this source's ID
|
||||
client = search_clients.get(source)
|
||||
if not client:
|
||||
continue
|
||||
if api_calls >= 200: # Hard cap per refresh cycle
|
||||
break
|
||||
try:
|
||||
results = client.search_artists(name, limit=5)
|
||||
best = _best_match(results, name)
|
||||
if best:
|
||||
harvested_ids[col] = best.id
|
||||
if hasattr(best, 'image_url') and _valid_image(best.image_url) and not best_image:
|
||||
best_image = best.image_url
|
||||
api_calls += 1
|
||||
time.sleep(0.4) # Rate limit breathing room
|
||||
except Exception as e:
|
||||
logger.debug(f"[Your Artists] {source} search failed for '{name}': {e}")
|
||||
api_calls += 1
|
||||
|
||||
# Save all harvested IDs
|
||||
if harvested_ids:
|
||||
# Determine best active source/ID — prefer Spotify, then iTunes, Deezer, Discogs
|
||||
resolved_source = None
|
||||
resolved_id = None
|
||||
for src in ('spotify', 'itunes', 'deezer', 'discogs'):
|
||||
col = source_cols[src]
|
||||
if col in harvested_ids:
|
||||
resolved_source = src
|
||||
resolved_id = harvested_ids[col]
|
||||
break
|
||||
|
||||
database.update_liked_artist_match(
|
||||
pool_id, active_source=resolved_source, active_source_id=resolved_id,
|
||||
image_url=best_image, all_ids=harvested_ids
|
||||
)
|
||||
matched += 1
|
||||
|
||||
database.sync_liked_artists_watchlist_flags(profile_id)
|
||||
logger.info(f"[Your Artists] Matched {matched}/{len(pending)} artists to {len(search_clients)} sources ({api_calls} API calls)")
|
||||
|
||||
# Image backfill: fetch images for matched artists that have IDs but no image
|
||||
_backfill_liked_artist_images(database, profile_id, search_clients)
|
||||
|
||||
|
||||
def _backfill_liked_artist_images(database, profile_id: int, search_clients: dict):
|
||||
"""Fetch images for matched artists missing artwork using their stored source IDs."""
|
||||
try:
|
||||
conn = database._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT id, artist_name, spotify_artist_id, itunes_artist_id, deezer_artist_id
|
||||
FROM liked_artists_pool
|
||||
WHERE profile_id = ? AND match_status = 'matched'
|
||||
AND (image_url IS NULL OR image_url = ''
|
||||
OR image_url LIKE '%2a96cbd8b46e442fc41c2b86b821562f%'
|
||||
OR image_url NOT LIKE 'http%')
|
||||
LIMIT 100
|
||||
""", (profile_id,))
|
||||
rows = cursor.fetchall()
|
||||
if not rows:
|
||||
return
|
||||
|
||||
logger.info(f"[Your Artists] Backfilling images for {len(rows)} artists...")
|
||||
filled = 0
|
||||
|
||||
for row in rows:
|
||||
r = dict(row)
|
||||
image_url = None
|
||||
|
||||
# Try Spotify artist lookup (has best images)
|
||||
if r.get('spotify_artist_id') and 'spotify' in search_clients:
|
||||
try:
|
||||
sp = search_clients['spotify']
|
||||
if hasattr(sp, 'sp') and sp.sp:
|
||||
artist_data = sp.sp.artist(r['spotify_artist_id'])
|
||||
if artist_data and artist_data.get('images'):
|
||||
image_url = artist_data['images'][0]['url']
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try Deezer (direct image URL from ID)
|
||||
if not image_url and r.get('deezer_artist_id'):
|
||||
image_url = f"https://api.deezer.com/artist/{r['deezer_artist_id']}/image?size=big"
|
||||
|
||||
if image_url:
|
||||
try:
|
||||
cursor2 = conn.cursor()
|
||||
cursor2.execute(
|
||||
"UPDATE liked_artists_pool SET image_url = ? WHERE id = ?",
|
||||
(image_url, r['id'])
|
||||
)
|
||||
filled += 1
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.3)
|
||||
|
||||
conn.commit()
|
||||
if filled:
|
||||
logger.info(f"[Your Artists] Backfilled {filled}/{len(rows)} artist images")
|
||||
except Exception as e:
|
||||
logger.debug(f"[Your Artists] Image backfill error: {e}")
|
||||
980
core/artists/map.py
Normal file
980
core/artists/map.py
Normal file
|
|
@ -0,0 +1,980 @@
|
|||
"""Artist Map endpoints — lifted from web_server.py.
|
||||
|
||||
The four route bodies (``get_artist_map_data``, ``get_artist_map_genre_list``,
|
||||
``get_artist_map_genres``, ``get_artist_map_explore``) plus their cache helpers
|
||||
and the artist-map cache are byte-identical to the originals. Module-level
|
||||
shims for ``get_current_profile_id``, ``_get_itunes_client``, and the
|
||||
``spotify_client`` proxy let the bodies resolve their original names without
|
||||
modification.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
from flask import g, jsonify, request
|
||||
|
||||
from database.music_database import get_database
|
||||
from core.metadata.registry import get_itunes_client, get_spotify_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_current_profile_id() -> int:
|
||||
"""Mirror of web_server.get_current_profile_id — uses Flask g."""
|
||||
try:
|
||||
return g.profile_id
|
||||
except AttributeError:
|
||||
return 1
|
||||
|
||||
|
||||
def _get_itunes_client():
|
||||
"""Mirror of web_server._get_itunes_client — delegates to registry."""
|
||||
return get_itunes_client()
|
||||
|
||||
|
||||
class _SpotifyClientProxy:
|
||||
"""Resolves the global Spotify client lazily so a Spotify re-auth that
|
||||
rebinds the cached client in core.metadata.registry is visible to the
|
||||
lifted route bodies."""
|
||||
|
||||
def __getattr__(self, name):
|
||||
client = get_spotify_client()
|
||||
if client is None:
|
||||
raise AttributeError(name)
|
||||
return getattr(client, name)
|
||||
|
||||
def __bool__(self):
|
||||
return get_spotify_client() is not None
|
||||
|
||||
|
||||
spotify_client = _SpotifyClientProxy()
|
||||
|
||||
|
||||
# Artist Map data cache — avoids re-querying 4+ tables on every request
|
||||
# Keys: 'watchlist_{profile}', 'genres_{profile}', 'genre_list'
|
||||
# Values: {'data': <json-ready dict>, 'ts': <timestamp>}
|
||||
_artist_map_cache = {}
|
||||
_ARTIST_MAP_CACHE_TTL = 300 # 5 minutes
|
||||
|
||||
|
||||
def _artmap_cache_get(key):
|
||||
"""Get cached artist map data if still fresh."""
|
||||
entry = _artist_map_cache.get(key)
|
||||
if entry and (time.time() - entry['ts']) < _ARTIST_MAP_CACHE_TTL:
|
||||
return entry['data']
|
||||
return None
|
||||
|
||||
|
||||
def _artmap_cache_set(key, data):
|
||||
"""Store artist map data in cache."""
|
||||
_artist_map_cache[key] = {'data': data, 'ts': time.time()}
|
||||
|
||||
|
||||
def _artmap_cache_invalidate(profile_id=None):
|
||||
"""Invalidate artist map cache (call after watchlist changes, scans, etc.)."""
|
||||
if profile_id:
|
||||
_artist_map_cache.pop(f'watchlist_{profile_id}', None)
|
||||
_artist_map_cache.pop(f'genres_{profile_id}', None)
|
||||
_artist_map_cache.pop('genre_list', None)
|
||||
|
||||
|
||||
def get_artist_map_data():
|
||||
"""Get watchlist artists + their similar artists for the force-directed artist map."""
|
||||
try:
|
||||
database = get_database()
|
||||
profile_id = get_current_profile_id()
|
||||
|
||||
cached = _artmap_cache_get(f'watchlist_{profile_id}')
|
||||
if cached:
|
||||
return jsonify(cached)
|
||||
|
||||
# Get all watchlist artists
|
||||
conn = database._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT id, artist_name, spotify_artist_id, itunes_artist_id, deezer_artist_id,
|
||||
discogs_artist_id, image_url
|
||||
FROM watchlist_artists WHERE profile_id = ?
|
||||
""", (profile_id,))
|
||||
watchlist_rows = cursor.fetchall()
|
||||
|
||||
nodes = [] # {id, name, image_url, type: 'watchlist'|'similar', genres, size}
|
||||
edges = [] # {source, target, weight}
|
||||
seen_names = {} # normalized_name → node index
|
||||
|
||||
def _norm(name):
|
||||
return (name or '').lower().strip()
|
||||
|
||||
# Add watchlist artists as anchor nodes
|
||||
for wa in watchlist_rows:
|
||||
w = dict(wa)
|
||||
norm = _norm(w['artist_name'])
|
||||
if norm in seen_names:
|
||||
continue
|
||||
idx = len(nodes)
|
||||
seen_names[norm] = idx
|
||||
# Get image — prefer HTTP URLs
|
||||
img = w.get('image_url', '') or ''
|
||||
if img and not img.startswith('http'):
|
||||
img = ''
|
||||
nodes.append({
|
||||
'id': idx,
|
||||
'name': w['artist_name'],
|
||||
'image_url': img,
|
||||
'type': 'watchlist',
|
||||
'genres': [],
|
||||
'spotify_id': w.get('spotify_artist_id') or '',
|
||||
'itunes_id': w.get('itunes_artist_id') or '',
|
||||
'deezer_id': w.get('deezer_artist_id') or '',
|
||||
'discogs_id': w.get('discogs_artist_id') or '',
|
||||
'source_db_id': str(w['id']),
|
||||
})
|
||||
|
||||
# Get all similar artists for all watchlist artists
|
||||
watchlist_ids = [dict(wa)['spotify_artist_id'] or dict(wa)['itunes_artist_id'] or str(dict(wa)['id']) for wa in watchlist_rows]
|
||||
if watchlist_ids:
|
||||
placeholders = ','.join(['?'] * len(watchlist_ids))
|
||||
cursor.execute(f"""
|
||||
SELECT source_artist_id, similar_artist_name, similar_artist_spotify_id,
|
||||
similar_artist_itunes_id, similar_artist_deezer_id,
|
||||
similarity_rank, occurrence_count, image_url, genres, popularity
|
||||
FROM similar_artists
|
||||
WHERE profile_id = ? AND source_artist_id IN ({placeholders})
|
||||
ORDER BY similarity_rank ASC
|
||||
""", [profile_id] + watchlist_ids)
|
||||
|
||||
for row in cursor.fetchall():
|
||||
r = dict(row)
|
||||
sim_norm = _norm(r['similar_artist_name'])
|
||||
|
||||
# Find or create similar artist node
|
||||
if sim_norm not in seen_names:
|
||||
idx = len(nodes)
|
||||
seen_names[sim_norm] = idx
|
||||
img = r.get('image_url', '') or ''
|
||||
if img and not img.startswith('http'):
|
||||
img = ''
|
||||
genres = []
|
||||
if r.get('genres'):
|
||||
try:
|
||||
genres = json.loads(r['genres'])
|
||||
except Exception:
|
||||
pass
|
||||
nodes.append({
|
||||
'id': idx,
|
||||
'name': r['similar_artist_name'],
|
||||
'image_url': img,
|
||||
'type': 'similar',
|
||||
'genres': genres,
|
||||
'spotify_id': r.get('similar_artist_spotify_id') or '',
|
||||
'itunes_id': r.get('similar_artist_itunes_id') or '',
|
||||
'deezer_id': r.get('similar_artist_deezer_id') or '',
|
||||
'rank': r.get('similarity_rank', 5),
|
||||
'occurrence': r.get('occurrence_count', 1),
|
||||
'popularity': r.get('popularity', 0),
|
||||
})
|
||||
|
||||
sim_idx = seen_names[sim_norm]
|
||||
|
||||
# Find the watchlist node that sourced this similar artist
|
||||
source_norm = None
|
||||
for wa in watchlist_rows:
|
||||
w = dict(wa)
|
||||
sid = w.get('spotify_artist_id') or w.get('itunes_artist_id') or str(w['id'])
|
||||
if sid == r['source_artist_id']:
|
||||
source_norm = _norm(w['artist_name'])
|
||||
break
|
||||
|
||||
if source_norm and source_norm in seen_names:
|
||||
source_idx = seen_names[source_norm]
|
||||
# Weight: inverse of rank (rank 1 = strongest connection)
|
||||
weight = max(1, 11 - (r.get('similarity_rank', 5)))
|
||||
edges.append({
|
||||
'source': source_idx,
|
||||
'target': sim_idx,
|
||||
'weight': weight,
|
||||
})
|
||||
|
||||
# Also check if any similar artists ARE watchlist artists (cross-links)
|
||||
# These create extra connections between watchlist nodes
|
||||
for i, node in enumerate(nodes):
|
||||
if node['type'] == 'similar':
|
||||
# Check if this similar artist is also a watchlist artist
|
||||
for j, wnode in enumerate(nodes):
|
||||
if wnode['type'] == 'watchlist' and i != j:
|
||||
if _norm(node['name']) == _norm(wnode['name']):
|
||||
# Merge: upgrade the similar node to watchlist
|
||||
node['type'] = 'watchlist'
|
||||
break
|
||||
|
||||
# ── Backfill from metadata cache: batch-lookup all node names across all sources ──
|
||||
# Single query to get ALL cached artist entries matching ANY node name
|
||||
try:
|
||||
all_names = list(set(_norm(n['name']) for n in nodes if n.get('name')))
|
||||
if all_names:
|
||||
# Build case-insensitive IN clause via temp matching
|
||||
# Lightweight query — no raw_json (can be huge)
|
||||
cursor.execute("""
|
||||
SELECT entity_id, source, name, image_url, genres, popularity
|
||||
FROM metadata_cache_entities
|
||||
WHERE entity_type = 'artist'
|
||||
""")
|
||||
cache_rows = cursor.fetchall()
|
||||
|
||||
# Index cache by normalized name → {source: {id, image_url, genres}}
|
||||
cache_by_name = {}
|
||||
for cr in cache_rows:
|
||||
cn = _norm(cr['name'] or '')
|
||||
if cn not in cache_by_name:
|
||||
cache_by_name[cn] = {}
|
||||
source = cr['source']
|
||||
genres = []
|
||||
if cr['genres']:
|
||||
try:
|
||||
genres = json.loads(cr['genres']) if isinstance(cr['genres'], str) else []
|
||||
except Exception:
|
||||
pass
|
||||
cache_by_name[cn][source] = {
|
||||
'id': cr['entity_id'],
|
||||
'image_url': cr['image_url'] or '',
|
||||
'genres': genres,
|
||||
}
|
||||
|
||||
# Apply cache data to nodes
|
||||
source_id_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id'}
|
||||
for n in nodes:
|
||||
nn = _norm(n['name'])
|
||||
cached = cache_by_name.get(nn)
|
||||
if not cached:
|
||||
continue
|
||||
for source, field in source_id_map.items():
|
||||
if not n.get(field) and source in cached:
|
||||
n[field] = cached[source]['id']
|
||||
# Backfill image if missing or local path
|
||||
if not n.get('image_url') or not n['image_url'].startswith('http'):
|
||||
for source in ('spotify', 'deezer', 'itunes'):
|
||||
if source in cached and cached[source].get('image_url', '').startswith('http'):
|
||||
n['image_url'] = cached[source]['image_url']
|
||||
break
|
||||
# Backfill genres if missing
|
||||
if not n.get('genres') or len(n.get('genres', [])) == 0:
|
||||
for source in ('spotify', 'deezer', 'itunes', 'discogs'):
|
||||
if source in cached and cached[source].get('genres'):
|
||||
n['genres'] = cached[source]['genres'][:5]
|
||||
break
|
||||
# Deezer direct URL fallback
|
||||
for n in nodes:
|
||||
if not n.get('image_url') or not n['image_url'].startswith('http'):
|
||||
if n.get('deezer_id'):
|
||||
n['image_url'] = f"https://api.deezer.com/artist/{n['deezer_id']}/image?size=big"
|
||||
|
||||
# Album art fallback (iTunes artists have no artist images)
|
||||
_album_art = {}
|
||||
try:
|
||||
cursor.execute("""
|
||||
SELECT artist_name, image_url FROM metadata_cache_entities
|
||||
WHERE entity_type = 'album' AND image_url LIKE 'http%'
|
||||
AND artist_name IS NOT NULL AND artist_name != ''
|
||||
""")
|
||||
for r in cursor.fetchall():
|
||||
an = _norm(r['artist_name'])
|
||||
if an and an not in _album_art:
|
||||
_album_art[an] = r['image_url']
|
||||
except Exception:
|
||||
pass
|
||||
for n in nodes:
|
||||
if not n.get('image_url') or not n['image_url'].startswith('http'):
|
||||
nn = _norm(n['name'])
|
||||
if nn in _album_art:
|
||||
n['image_url'] = _album_art[nn]
|
||||
|
||||
except Exception as cache_err:
|
||||
logger.debug(f"Artist map cache backfill error: {cache_err}")
|
||||
|
||||
result = {
|
||||
'success': True,
|
||||
'nodes': nodes,
|
||||
'edges': edges,
|
||||
'watchlist_count': sum(1 for n in nodes if n['type'] == 'watchlist'),
|
||||
'similar_count': sum(1 for n in nodes if n['type'] == 'similar'),
|
||||
}
|
||||
_artmap_cache_set(f'watchlist_{profile_id}', result)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting artist map data: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
def get_artist_map_genre_list():
|
||||
"""Lightweight endpoint — just genre names + counts for the picker. No node data."""
|
||||
try:
|
||||
cached = _artmap_cache_get('genre_list')
|
||||
if cached:
|
||||
return jsonify(cached)
|
||||
|
||||
database = get_database()
|
||||
conn = database._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Fast query: just count artists per genre from cache
|
||||
genre_counts = {}
|
||||
cursor.execute("""
|
||||
SELECT genres FROM metadata_cache_entities
|
||||
WHERE entity_type = 'artist' AND genres IS NOT NULL AND genres != '' AND genres != '[]'
|
||||
""")
|
||||
for r in cursor.fetchall():
|
||||
try:
|
||||
for g in json.loads(r['genres']):
|
||||
if g and isinstance(g, str):
|
||||
gl = g.lower().strip()
|
||||
genre_counts[gl] = genre_counts.get(gl, 0) + 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Sort by count descending
|
||||
sorted_genres = sorted(genre_counts.items(), key=lambda x: -x[1])
|
||||
|
||||
result = {
|
||||
'success': True,
|
||||
'genres': [{'name': g, 'count': c} for g, c in sorted_genres],
|
||||
'total': len(sorted_genres)
|
||||
}
|
||||
_artmap_cache_set('genre_list', result)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
def get_artist_map_genres():
|
||||
"""Get ALL artists from every data source, grouped by genre for the genre map."""
|
||||
try:
|
||||
database = get_database()
|
||||
profile_id = get_current_profile_id()
|
||||
|
||||
cached = _artmap_cache_get(f'genres_{profile_id}')
|
||||
if cached:
|
||||
return jsonify(cached)
|
||||
|
||||
conn = database._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
artists_by_name = {} # normalized_name → {name, image, genres[], sources, ids}
|
||||
|
||||
def _norm(n):
|
||||
return (n or '').lower().strip()
|
||||
|
||||
def _add(name, image_url=None, genres=None, spotify_id=None, itunes_id=None, deezer_id=None, discogs_id=None, source='unknown', popularity=0):
|
||||
n = _norm(name)
|
||||
if not n or len(n) < 2:
|
||||
return
|
||||
if n not in artists_by_name:
|
||||
artists_by_name[n] = {
|
||||
'name': name, 'image_url': '', 'genres': set(),
|
||||
'spotify_id': '', 'itunes_id': '', 'deezer_id': '', 'discogs_id': '',
|
||||
'sources': set(), 'popularity': 0
|
||||
}
|
||||
a = artists_by_name[n]
|
||||
if image_url and image_url.startswith('http') and not a['image_url']:
|
||||
a['image_url'] = image_url
|
||||
if genres:
|
||||
for g in (genres if isinstance(genres, list) else []):
|
||||
if g and isinstance(g, str):
|
||||
a['genres'].add(g.lower().strip())
|
||||
if spotify_id and not a['spotify_id']:
|
||||
a['spotify_id'] = str(spotify_id)
|
||||
if itunes_id and not a['itunes_id']:
|
||||
a['itunes_id'] = str(itunes_id)
|
||||
if deezer_id and not a['deezer_id']:
|
||||
a['deezer_id'] = str(deezer_id)
|
||||
if discogs_id and not a['discogs_id']:
|
||||
a['discogs_id'] = str(discogs_id)
|
||||
if popularity > a['popularity']:
|
||||
a['popularity'] = popularity
|
||||
a['sources'].add(source)
|
||||
|
||||
# 1. Metadata cache — biggest source
|
||||
cursor.execute("""
|
||||
SELECT name, entity_id, source, image_url, genres, popularity
|
||||
FROM metadata_cache_entities WHERE entity_type = 'artist'
|
||||
""")
|
||||
for r in cursor.fetchall():
|
||||
genres = []
|
||||
if r['genres']:
|
||||
try:
|
||||
genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
|
||||
except Exception:
|
||||
pass
|
||||
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id'}
|
||||
kwargs = {src_map.get(r['source'], 'spotify_id'): r['entity_id']}
|
||||
_add(r['name'], image_url=r['image_url'], genres=genres, source='cache', popularity=r['popularity'] or 0, **kwargs)
|
||||
|
||||
# 2. Similar artists
|
||||
cursor.execute("""
|
||||
SELECT similar_artist_name, similar_artist_spotify_id, similar_artist_itunes_id,
|
||||
similar_artist_deezer_id, image_url, genres, popularity
|
||||
FROM similar_artists WHERE profile_id = ?
|
||||
""", (profile_id,))
|
||||
for r in cursor.fetchall():
|
||||
genres = []
|
||||
if r['genres']:
|
||||
try:
|
||||
genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
|
||||
except Exception:
|
||||
pass
|
||||
_add(r['similar_artist_name'], image_url=r['image_url'], genres=genres,
|
||||
spotify_id=r['similar_artist_spotify_id'], itunes_id=r['similar_artist_itunes_id'],
|
||||
deezer_id=r['similar_artist_deezer_id'], source='similar', popularity=r['popularity'] or 0)
|
||||
|
||||
# 3. Watchlist artists
|
||||
cursor.execute("""
|
||||
SELECT artist_name, spotify_artist_id, itunes_artist_id, deezer_artist_id,
|
||||
discogs_artist_id, image_url
|
||||
FROM watchlist_artists WHERE profile_id = ?
|
||||
""", (profile_id,))
|
||||
for r in cursor.fetchall():
|
||||
_add(r['artist_name'], image_url=r['image_url'],
|
||||
spotify_id=r['spotify_artist_id'], itunes_id=r['itunes_artist_id'],
|
||||
deezer_id=r['deezer_artist_id'], discogs_id=r['discogs_artist_id'], source='watchlist')
|
||||
|
||||
# 4. Library artists
|
||||
cursor.execute("SELECT name, thumb_url, genres FROM artists")
|
||||
for r in cursor.fetchall():
|
||||
genres = []
|
||||
if r['genres']:
|
||||
try:
|
||||
genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
|
||||
except Exception:
|
||||
pass
|
||||
img = r['thumb_url'] if r['thumb_url'] and r['thumb_url'].startswith('http') else None
|
||||
_add(r['name'], image_url=img, genres=genres, source='library')
|
||||
|
||||
# Filter: only include artists that have at least one genre
|
||||
genre_artists = {k: v for k, v in artists_by_name.items() if v['genres']}
|
||||
|
||||
# Build genre → artists map
|
||||
genre_map = {} # genre_name → [artist_keys]
|
||||
for key, a in genre_artists.items():
|
||||
for g in a['genres']:
|
||||
if g not in genre_map:
|
||||
genre_map[g] = []
|
||||
genre_map[g].append(key)
|
||||
|
||||
# Sort genres by artist count, take top genres
|
||||
sorted_genres = sorted(genre_map.items(), key=lambda x: -len(x[1]))
|
||||
|
||||
# Build nodes
|
||||
nodes = []
|
||||
node_idx = {}
|
||||
for key, a in genre_artists.items():
|
||||
idx = len(nodes)
|
||||
node_idx[key] = idx
|
||||
nodes.append({
|
||||
'id': idx,
|
||||
'name': a['name'],
|
||||
'image_url': a['image_url'],
|
||||
'genres': list(a['genres'])[:5],
|
||||
'spotify_id': a['spotify_id'],
|
||||
'itunes_id': a['itunes_id'],
|
||||
'deezer_id': a['deezer_id'],
|
||||
'discogs_id': a['discogs_id'],
|
||||
'popularity': a['popularity'],
|
||||
'type': 'watchlist' if 'watchlist' in a['sources'] else 'similar',
|
||||
})
|
||||
|
||||
# Build genre clusters — allow artists in multiple genres
|
||||
top_genres = sorted_genres[:40]
|
||||
|
||||
# Sort genres by co-occurrence so related genres are adjacent in the list.
|
||||
# This makes the spiral layout place related genres near each other.
|
||||
if len(top_genres) > 2:
|
||||
genre_sets = {g: set(keys) for g, keys in top_genres}
|
||||
ordered = [top_genres[0][0]] # Start with biggest genre
|
||||
remaining = {g for g, _ in top_genres[1:]}
|
||||
while remaining:
|
||||
last = ordered[-1]
|
||||
last_set = genre_sets.get(last, set())
|
||||
# Find most similar remaining genre (highest artist overlap)
|
||||
best = None
|
||||
best_overlap = -1
|
||||
for g in remaining:
|
||||
overlap = len(last_set & genre_sets.get(g, set()))
|
||||
if overlap > best_overlap:
|
||||
best_overlap = overlap
|
||||
best = g
|
||||
ordered.append(best)
|
||||
remaining.remove(best)
|
||||
# Rebuild top_genres in the ordered sequence
|
||||
genre_dict = dict(top_genres)
|
||||
top_genres = [(g, genre_dict[g]) for g in ordered if g in genre_dict]
|
||||
|
||||
genres_out = []
|
||||
for genre, artist_keys in top_genres:
|
||||
genres_out.append({
|
||||
'name': genre,
|
||||
'count': len(artist_keys),
|
||||
'artist_ids': [node_idx[k] for k in artist_keys if k in node_idx],
|
||||
})
|
||||
|
||||
# Image cleanup + multi-source fallback
|
||||
# Build two lookups: name→image_url AND name→deezer_entity_id
|
||||
_img_cache = {}
|
||||
_deezer_id_cache = {}
|
||||
_album_art_cache = {} # artist_name → album image (iTunes fallback)
|
||||
try:
|
||||
# Artist images + Deezer IDs
|
||||
cursor.execute("""
|
||||
SELECT name, entity_id, source, image_url FROM metadata_cache_entities
|
||||
WHERE entity_type = 'artist'
|
||||
AND ((image_url IS NOT NULL AND image_url != '' AND image_url LIKE 'http%')
|
||||
OR source = 'deezer')
|
||||
""")
|
||||
for r in cursor.fetchall():
|
||||
nn = (r['name'] or '').lower().strip()
|
||||
if not nn:
|
||||
continue
|
||||
if r['image_url'] and r['image_url'].startswith('http') and nn not in _img_cache:
|
||||
_img_cache[nn] = r['image_url']
|
||||
if r['source'] == 'deezer' and r['entity_id'] and nn not in _deezer_id_cache:
|
||||
_deezer_id_cache[nn] = r['entity_id']
|
||||
|
||||
# Album art by artist name (for iTunes artists with no artist image)
|
||||
cursor.execute("""
|
||||
SELECT artist_name, image_url FROM metadata_cache_entities
|
||||
WHERE entity_type = 'album'
|
||||
AND image_url IS NOT NULL AND image_url != '' AND image_url LIKE 'http%'
|
||||
AND artist_name IS NOT NULL AND artist_name != ''
|
||||
""")
|
||||
for r in cursor.fetchall():
|
||||
nn = (r['artist_name'] or '').lower().strip()
|
||||
if nn and nn not in _album_art_cache:
|
||||
_album_art_cache[nn] = r['image_url']
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for n in nodes:
|
||||
img = n.get('image_url', '')
|
||||
if img in ('None', 'null', '') or (img and not img.startswith('http')):
|
||||
n['image_url'] = ''
|
||||
nn = n['name'].lower().strip()
|
||||
if not n['image_url']:
|
||||
# Try cache image by name
|
||||
n['image_url'] = _img_cache.get(nn, '')
|
||||
if not n['image_url'] and n.get('deezer_id'):
|
||||
n['image_url'] = f"https://api.deezer.com/artist/{n['deezer_id']}/image?size=big"
|
||||
if not n['image_url']:
|
||||
# Try Deezer ID from cache by name
|
||||
did = _deezer_id_cache.get(nn)
|
||||
if did:
|
||||
n['deezer_id'] = did
|
||||
n['image_url'] = f"https://api.deezer.com/artist/{did}/image?size=big"
|
||||
if not n['image_url']:
|
||||
# Try album art by artist name (iTunes artists have no artist images)
|
||||
n['image_url'] = _album_art_cache.get(nn, '')
|
||||
|
||||
_img_count = sum(1 for n in nodes if n.get('image_url'))
|
||||
_deezer_count = sum(1 for n in nodes if n.get('image_url', '').startswith('https://api.deezer'))
|
||||
_none_count = sum(1 for n in nodes if not n.get('image_url'))
|
||||
logger.info(f"[Genre Map] {len(nodes)} artists, {len(sorted_genres)} genres")
|
||||
logger.warning(f"[Genre Map] Images: {_img_count} have URLs, {_deezer_count} Deezer fallback, {_none_count} missing")
|
||||
if _none_count > 0:
|
||||
samples = [n['name'] for n in nodes if not n.get('image_url')][:5]
|
||||
logger.warning(f"[Genre Map] Missing image samples: {samples}")
|
||||
|
||||
result = {
|
||||
'success': True,
|
||||
'nodes': nodes,
|
||||
'genres': genres_out,
|
||||
'total_artists': len(nodes),
|
||||
'total_genres': len(sorted_genres),
|
||||
}
|
||||
_artmap_cache_set(f'genres_{profile_id}', result)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting genre map data: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
def get_artist_map_explore():
|
||||
"""Build an exploration map outward from a single artist."""
|
||||
try:
|
||||
artist_name = request.args.get('name', '').strip()
|
||||
artist_id = request.args.get('id', '').strip()
|
||||
|
||||
if not artist_name and not artist_id:
|
||||
return jsonify({"success": False, "error": "Provide artist name or id"}), 400
|
||||
|
||||
database = get_database()
|
||||
profile_id = get_current_profile_id()
|
||||
conn = database._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
def _norm(n):
|
||||
return (n or '').lower().strip()
|
||||
|
||||
nodes = []
|
||||
edges = []
|
||||
seen = {} # norm_name → node index
|
||||
|
||||
# Find the center artist
|
||||
center_name = artist_name
|
||||
center_image = ''
|
||||
center_ids = {'spotify_id': '', 'itunes_id': '', 'deezer_id': '', 'discogs_id': ''}
|
||||
center_genres = []
|
||||
|
||||
# Search metadata cache for the center artist
|
||||
if artist_id:
|
||||
cursor.execute("""
|
||||
SELECT name, entity_id, source, image_url, genres FROM metadata_cache_entities
|
||||
WHERE entity_type = 'artist' AND entity_id = ? LIMIT 1
|
||||
""", (artist_id,))
|
||||
else:
|
||||
cursor.execute("""
|
||||
SELECT name, entity_id, source, image_url, genres FROM metadata_cache_entities
|
||||
WHERE entity_type = 'artist' AND name = ? COLLATE NOCASE LIMIT 1
|
||||
""", (artist_name,))
|
||||
|
||||
row = cursor.fetchone()
|
||||
artist_found = False
|
||||
if row:
|
||||
artist_found = True
|
||||
center_name = row['name']
|
||||
if row['image_url'] and row['image_url'].startswith('http'):
|
||||
center_image = row['image_url']
|
||||
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id'}
|
||||
k = src_map.get(row['source'], 'spotify_id')
|
||||
center_ids[k] = row['entity_id']
|
||||
if row['genres']:
|
||||
try:
|
||||
center_genres = json.loads(row['genres']) if isinstance(row['genres'], str) else []
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check watchlist + library if not in cache
|
||||
if not artist_found and not artist_id:
|
||||
cursor.execute("SELECT artist_name, image_url, spotify_artist_id, itunes_artist_id, deezer_artist_id, discogs_artist_id FROM watchlist_artists WHERE artist_name = ? COLLATE NOCASE LIMIT 1", (artist_name,))
|
||||
wr = cursor.fetchone()
|
||||
if wr:
|
||||
artist_found = True
|
||||
center_name = wr['artist_name']
|
||||
if wr['image_url'] and str(wr['image_url']).startswith('http'):
|
||||
center_image = wr['image_url']
|
||||
for k, col in [('spotify_id', 'spotify_artist_id'), ('itunes_id', 'itunes_artist_id'), ('deezer_id', 'deezer_artist_id'), ('discogs_id', 'discogs_artist_id')]:
|
||||
if wr[col]:
|
||||
center_ids[k] = str(wr[col])
|
||||
else:
|
||||
cursor.execute("SELECT name, thumb_url FROM artists WHERE name = ? COLLATE NOCASE LIMIT 1", (artist_name,))
|
||||
lr = cursor.fetchone()
|
||||
if lr:
|
||||
artist_found = True
|
||||
center_name = lr['name']
|
||||
if lr['thumb_url'] and str(lr['thumb_url']).startswith('http'):
|
||||
center_image = lr['thumb_url']
|
||||
|
||||
# If not found locally, validate via metadata API search
|
||||
if not artist_found and not artist_id:
|
||||
try:
|
||||
api_match = None
|
||||
if spotify_client and spotify_client.is_spotify_authenticated():
|
||||
results = spotify_client.search_artists(artist_name, limit=1)
|
||||
if results and len(results) > 0:
|
||||
sa = results[0]
|
||||
if sa.name.lower().strip() == artist_name.lower().strip() or \
|
||||
artist_name.lower().strip() in sa.name.lower().strip():
|
||||
api_match = sa
|
||||
center_name = sa.name
|
||||
center_ids['spotify_id'] = sa.id
|
||||
center_image = sa.image_url if hasattr(sa, 'image_url') else ''
|
||||
center_genres = sa.genres if hasattr(sa, 'genres') else []
|
||||
artist_found = True
|
||||
if not artist_found:
|
||||
ic = _get_itunes_client()
|
||||
results = ic.search_artists(artist_name, limit=1)
|
||||
if results and len(results) > 0:
|
||||
ia = results[0]
|
||||
if ia.name.lower().strip() == artist_name.lower().strip() or \
|
||||
artist_name.lower().strip() in ia.name.lower().strip():
|
||||
center_name = ia.name
|
||||
center_ids['itunes_id'] = str(ia.id)
|
||||
center_image = ia.image_url if hasattr(ia, 'image_url') else ''
|
||||
artist_found = True
|
||||
except Exception as e:
|
||||
logger.debug(f"[Artist Explorer] API validation failed for '{artist_name}': {e}")
|
||||
|
||||
if not artist_found:
|
||||
return jsonify({"success": False, "error": f"Artist '{artist_name}' not found"}), 404
|
||||
|
||||
# Also check cache for other source IDs
|
||||
cursor.execute("""
|
||||
SELECT entity_id, source, image_url, genres FROM metadata_cache_entities
|
||||
WHERE entity_type = 'artist' AND name = ? COLLATE NOCASE
|
||||
""", (center_name,))
|
||||
for r in cursor.fetchall():
|
||||
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id'}
|
||||
k = src_map.get(r['source'], 'spotify_id')
|
||||
if not center_ids.get(k):
|
||||
center_ids[k] = r['entity_id']
|
||||
if r['image_url'] and r['image_url'].startswith('http') and not center_image:
|
||||
center_image = r['image_url']
|
||||
if r['genres'] and not center_genres:
|
||||
try:
|
||||
center_genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Add center node
|
||||
center_idx = 0
|
||||
seen[_norm(center_name)] = center_idx
|
||||
nodes.append({
|
||||
'id': 0, 'name': center_name, 'image_url': center_image,
|
||||
'type': 'center', 'genres': center_genres[:5],
|
||||
**center_ids, 'ring': 0
|
||||
})
|
||||
|
||||
# Ring 1: Direct similar artists from similar_artists table
|
||||
# Search by all known IDs
|
||||
id_values = [v for v in center_ids.values() if v]
|
||||
ring1_artists = []
|
||||
if id_values:
|
||||
placeholders = ','.join(['?'] * len(id_values))
|
||||
cursor.execute(f"""
|
||||
SELECT DISTINCT similar_artist_name, similar_artist_spotify_id,
|
||||
similar_artist_itunes_id, similar_artist_deezer_id,
|
||||
image_url, genres, popularity, similarity_rank
|
||||
FROM similar_artists
|
||||
WHERE source_artist_id IN ({placeholders}) AND profile_id = ?
|
||||
ORDER BY similarity_rank ASC
|
||||
""", id_values + [profile_id])
|
||||
ring1_artists = cursor.fetchall()
|
||||
|
||||
# Also search by name (the center artist might be a watchlist source)
|
||||
cursor.execute("""
|
||||
SELECT DISTINCT sa.similar_artist_name, sa.similar_artist_spotify_id,
|
||||
sa.similar_artist_itunes_id, sa.similar_artist_deezer_id,
|
||||
sa.image_url, sa.genres, sa.popularity, sa.similarity_rank
|
||||
FROM similar_artists sa
|
||||
JOIN watchlist_artists wa ON sa.source_artist_id = COALESCE(wa.spotify_artist_id, wa.itunes_artist_id, CAST(wa.id AS TEXT))
|
||||
WHERE wa.artist_name = ? COLLATE NOCASE AND sa.profile_id = ?
|
||||
ORDER BY sa.similarity_rank ASC
|
||||
""", (center_name, profile_id))
|
||||
ring1_artists.extend(cursor.fetchall())
|
||||
|
||||
# If no similar artists in DB, fetch from MusicMap on-the-fly
|
||||
if not ring1_artists:
|
||||
try:
|
||||
logger.debug(f"[Artist Explorer] No stored similar artists for '{center_name}', fetching from MusicMap...")
|
||||
from core.watchlist_scanner import WatchlistScanner
|
||||
scanner = WatchlistScanner(spotify_client=spotify_client) if spotify_client else None
|
||||
if scanner:
|
||||
similar = scanner._fetch_similar_artists_from_musicmap(center_name, limit=15)
|
||||
if similar:
|
||||
source_artist_id = center_ids.get('spotify_id') or center_ids.get('itunes_id') or center_name
|
||||
# Store in DB for future use
|
||||
for rank, sa in enumerate(similar, 1):
|
||||
try:
|
||||
database.add_or_update_similar_artist(
|
||||
source_artist_id=source_artist_id,
|
||||
similar_artist_name=sa['name'],
|
||||
similar_artist_spotify_id=sa.get('spotify_id'),
|
||||
similar_artist_itunes_id=sa.get('itunes_id'),
|
||||
similarity_rank=rank,
|
||||
profile_id=profile_id,
|
||||
image_url=sa.get('image_url'),
|
||||
genres=sa.get('genres'),
|
||||
popularity=sa.get('popularity', 0),
|
||||
similar_artist_deezer_id=sa.get('deezer_id')
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Re-query from DB to get consistent format
|
||||
if id_values:
|
||||
placeholders = ','.join(['?'] * len(id_values))
|
||||
cursor.execute(f"""
|
||||
SELECT DISTINCT similar_artist_name, similar_artist_spotify_id,
|
||||
similar_artist_itunes_id, similar_artist_deezer_id,
|
||||
image_url, genres, popularity, similarity_rank
|
||||
FROM similar_artists
|
||||
WHERE source_artist_id IN ({placeholders}) AND profile_id = ?
|
||||
ORDER BY similarity_rank ASC
|
||||
""", id_values + [profile_id])
|
||||
ring1_artists = cursor.fetchall()
|
||||
if not ring1_artists:
|
||||
# Fallback: query by name-based source ID
|
||||
cursor.execute("""
|
||||
SELECT DISTINCT similar_artist_name, similar_artist_spotify_id,
|
||||
similar_artist_itunes_id, similar_artist_deezer_id,
|
||||
image_url, genres, popularity, similarity_rank
|
||||
FROM similar_artists
|
||||
WHERE source_artist_id = ? AND profile_id = ?
|
||||
ORDER BY similarity_rank ASC
|
||||
""", (source_artist_id, profile_id))
|
||||
ring1_artists = cursor.fetchall()
|
||||
logger.debug(f"[Artist Explorer] Fetched {len(ring1_artists)} similar artists from MusicMap for '{center_name}'")
|
||||
_artmap_cache_invalidate(profile_id) # New similar artists added
|
||||
except Exception as e:
|
||||
logger.debug(f"[Artist Explorer] MusicMap fetch failed for '{center_name}': {e}")
|
||||
|
||||
# Deduplicate ring 1
|
||||
for r in ring1_artists:
|
||||
nn = _norm(r['similar_artist_name'])
|
||||
if nn in seen:
|
||||
continue
|
||||
idx = len(nodes)
|
||||
seen[nn] = idx
|
||||
genres = []
|
||||
if r['genres']:
|
||||
try:
|
||||
genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
|
||||
except Exception:
|
||||
pass
|
||||
img = r['image_url'] if r['image_url'] and r['image_url'].startswith('http') else ''
|
||||
nodes.append({
|
||||
'id': idx, 'name': r['similar_artist_name'], 'image_url': img,
|
||||
'type': 'ring1', 'genres': genres[:5],
|
||||
'spotify_id': r['similar_artist_spotify_id'] or '',
|
||||
'itunes_id': r['similar_artist_itunes_id'] or '',
|
||||
'deezer_id': r['similar_artist_deezer_id'] or '',
|
||||
'discogs_id': '',
|
||||
'popularity': r['popularity'] or 0,
|
||||
'rank': r['similarity_rank'] or 5,
|
||||
'ring': 1,
|
||||
})
|
||||
weight = max(1, 11 - (r['similarity_rank'] or 5))
|
||||
edges.append({'source': center_idx, 'target': idx, 'weight': weight})
|
||||
|
||||
# Ring 2: Similar artists of ring 1 artists (from similar_artists table)
|
||||
ring1_ids = []
|
||||
for n in nodes[1:]: # skip center
|
||||
for sid in [n.get('spotify_id'), n.get('itunes_id')]:
|
||||
if sid:
|
||||
ring1_ids.append(sid)
|
||||
|
||||
if ring1_ids:
|
||||
placeholders = ','.join(['?'] * len(ring1_ids))
|
||||
cursor.execute(f"""
|
||||
SELECT DISTINCT source_artist_id, similar_artist_name,
|
||||
similar_artist_spotify_id, similar_artist_itunes_id,
|
||||
similar_artist_deezer_id, image_url, genres, popularity, similarity_rank
|
||||
FROM similar_artists
|
||||
WHERE source_artist_id IN ({placeholders}) AND profile_id = ?
|
||||
ORDER BY similarity_rank ASC
|
||||
""", ring1_ids + [profile_id])
|
||||
|
||||
for r in cursor.fetchall():
|
||||
nn = _norm(r['similar_artist_name'])
|
||||
if nn in seen:
|
||||
# Create edge to existing node if not center
|
||||
existing_idx = seen[nn]
|
||||
# Find the ring1 node that sourced this
|
||||
source_norm = None
|
||||
for n in nodes[1:]:
|
||||
for sid in [n.get('spotify_id'), n.get('itunes_id')]:
|
||||
if sid == r['source_artist_id']:
|
||||
source_norm = _norm(n['name'])
|
||||
break
|
||||
if source_norm:
|
||||
break
|
||||
if source_norm and source_norm in seen and existing_idx != seen[source_norm]:
|
||||
edges.append({'source': seen[source_norm], 'target': existing_idx, 'weight': 3})
|
||||
continue
|
||||
|
||||
idx = len(nodes)
|
||||
if idx >= 500: # Cap at 500 nodes for performance
|
||||
break
|
||||
seen[nn] = idx
|
||||
genres = []
|
||||
if r['genres']:
|
||||
try:
|
||||
genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
|
||||
except Exception:
|
||||
pass
|
||||
img = r['image_url'] if r['image_url'] and r['image_url'].startswith('http') else ''
|
||||
nodes.append({
|
||||
'id': idx, 'name': r['similar_artist_name'], 'image_url': img,
|
||||
'type': 'ring2', 'genres': genres[:5],
|
||||
'spotify_id': r['similar_artist_spotify_id'] or '',
|
||||
'itunes_id': r['similar_artist_itunes_id'] or '',
|
||||
'deezer_id': r['similar_artist_deezer_id'] or '',
|
||||
'discogs_id': '',
|
||||
'popularity': r['popularity'] or 0,
|
||||
'rank': r['similarity_rank'] or 5,
|
||||
'ring': 2,
|
||||
})
|
||||
# Find the ring1 source
|
||||
for n in nodes[1:]:
|
||||
for sid in [n.get('spotify_id'), n.get('itunes_id')]:
|
||||
if sid == r['source_artist_id']:
|
||||
edges.append({'source': n['id'], 'target': idx, 'weight': max(1, 11 - (r['similarity_rank'] or 5))})
|
||||
break
|
||||
|
||||
# Backfill images/genres from ALL cache sources + Deezer fallback
|
||||
for n in nodes:
|
||||
# Clean up string "None" stored as image URL
|
||||
if n['image_url'] in ('None', 'null', ''):
|
||||
n['image_url'] = ''
|
||||
if n['image_url'] and n['genres']:
|
||||
continue
|
||||
# Check all cache entries for this artist (multiple sources)
|
||||
cursor.execute("""
|
||||
SELECT entity_id, source, image_url, genres FROM metadata_cache_entities
|
||||
WHERE entity_type = 'artist' AND name = ? COLLATE NOCASE
|
||||
""", (n['name'],))
|
||||
for cr in cursor.fetchall():
|
||||
if not n['image_url'] and cr['image_url'] and cr['image_url'].startswith('http'):
|
||||
n['image_url'] = cr['image_url']
|
||||
if not n['genres'] and cr['genres']:
|
||||
try:
|
||||
n['genres'] = json.loads(cr['genres'])[:5] if isinstance(cr['genres'], str) else []
|
||||
except Exception:
|
||||
pass
|
||||
# Harvest missing IDs from cache
|
||||
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id'}
|
||||
k = src_map.get(cr['source'])
|
||||
if k and not n.get(k):
|
||||
n[k] = cr['entity_id']
|
||||
|
||||
# Deezer image fallback — construct URL directly from ID
|
||||
if not n['image_url'] and n.get('deezer_id'):
|
||||
n['image_url'] = f"https://api.deezer.com/artist/{n['deezer_id']}/image?size=big"
|
||||
|
||||
# Spotify image fallback — try API if authenticated
|
||||
if not n['image_url'] and n.get('spotify_id'):
|
||||
try:
|
||||
if spotify_client and spotify_client.is_spotify_authenticated():
|
||||
from core.api_call_tracker import api_call_tracker
|
||||
api_call_tracker.record_call('spotify', endpoint='artist')
|
||||
artist_data = spotify_client.sp.artist(n['spotify_id'])
|
||||
if artist_data and artist_data.get('images'):
|
||||
n['image_url'] = artist_data['images'][0]['url']
|
||||
if not n['genres'] and artist_data.get('genres'):
|
||||
n['genres'] = artist_data['genres'][:5]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Album art fallback (iTunes artists have no artist images)
|
||||
if not n['image_url']:
|
||||
cursor.execute("""
|
||||
SELECT image_url FROM metadata_cache_entities
|
||||
WHERE entity_type = 'album' AND image_url LIKE 'http%'
|
||||
AND artist_name = ? COLLATE NOCASE LIMIT 1
|
||||
""", (n['name'],))
|
||||
alb = cursor.fetchone()
|
||||
if alb:
|
||||
n['image_url'] = alb['image_url']
|
||||
|
||||
logger.info(f"[Artist Explorer] Center: {center_name}, Ring 1: {sum(1 for n in nodes if n.get('ring')==1)}, Ring 2: {sum(1 for n in nodes if n.get('ring')==2)}, Edges: {len(edges)}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'nodes': nodes,
|
||||
'edges': edges,
|
||||
'center': center_name,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting artist explorer data: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
329
core/artists/quality.py
Normal file
329
core/artists/quality.py
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
"""Artist quality enhancement helper.
|
||||
|
||||
`enhance_artist_quality(artist_id, track_ids, deps)` is the route-handler
|
||||
body for the `/api/library/artist/<artist_id>/enhance` endpoint. It walks
|
||||
the user's selected tracks, finds the best Spotify (preferred) or iTunes
|
||||
(fallback) match for each, and queues high-quality re-downloads on the
|
||||
wishlist with `source_type='enhance'`.
|
||||
|
||||
Per-track flow:
|
||||
|
||||
1. Resolve the existing track via the artist's full detail map (built up
|
||||
front from `database.get_artist_full_detail`).
|
||||
2. Read current quality tier from the file extension.
|
||||
3. Build `matched_track_data` for the wishlist entry, in priority order:
|
||||
- Direct Spotify lookup via stored `spotify_track_id` (preferred).
|
||||
- Spotify search fallback using matching_engine queries.
|
||||
- iTunes/fallback source search.
|
||||
4. Add to wishlist via `wishlist_service.add_spotify_track_to_wishlist`
|
||||
with `source_type='enhance'` and a `source_context` carrying the
|
||||
original file path, format tier, bitrate, and artist name.
|
||||
5. Tally `enhanced_count` / `failed_count` / per-track failure reasons.
|
||||
|
||||
Returns `(payload_dict, http_status_code)` so the route wrapper can
|
||||
`jsonify()` and return.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArtistQualityDeps:
|
||||
"""Bundle of cross-cutting deps the artist quality enhancement needs."""
|
||||
spotify_client: Any
|
||||
matching_engine: Any
|
||||
get_database: Callable[[], Any]
|
||||
get_wishlist_service: Callable[[], Any]
|
||||
get_current_profile_id: Callable[[], int]
|
||||
get_quality_tier_from_extension: Callable
|
||||
get_metadata_fallback_client: Callable[[], Any]
|
||||
|
||||
|
||||
def enhance_artist_quality(artist_id, track_ids, deps: ArtistQualityDeps):
|
||||
"""Add selected tracks to wishlist for quality enhancement re-download."""
|
||||
try:
|
||||
if not track_ids:
|
||||
return {"success": False, "error": "No track IDs provided"}, 400
|
||||
|
||||
database = deps.get_database()
|
||||
wishlist_service = deps.get_wishlist_service()
|
||||
profile_id = deps.get_current_profile_id()
|
||||
|
||||
# Get artist info
|
||||
artist_result = database.get_artist_full_detail(artist_id)
|
||||
if not artist_result.get('success'):
|
||||
return {"success": False, "error": "Artist not found"}, 404
|
||||
|
||||
artist_name = artist_result.get('artist', {}).get('name', 'Unknown Artist')
|
||||
|
||||
# Build lookup of all tracks for this artist
|
||||
track_lookup = {}
|
||||
for album in artist_result.get('albums', []):
|
||||
album_title = album.get('title', '')
|
||||
for track in album.get('tracks', []):
|
||||
tid = str(track.get('id', ''))
|
||||
track['_album_title'] = album_title
|
||||
track['_album_id'] = album.get('id')
|
||||
track_lookup[tid] = track
|
||||
|
||||
enhanced_count = 0
|
||||
failed_count = 0
|
||||
failed_tracks = []
|
||||
|
||||
for track_id in track_ids:
|
||||
track_id_str = str(track_id)
|
||||
track = track_lookup.get(track_id_str)
|
||||
if not track:
|
||||
failed_count += 1
|
||||
failed_tracks.append({'track_id': track_id, 'reason': 'Track not found'})
|
||||
continue
|
||||
|
||||
file_path = track.get('file_path')
|
||||
if not file_path:
|
||||
failed_count += 1
|
||||
failed_tracks.append({'track_id': track_id, 'reason': 'No file path'})
|
||||
continue
|
||||
|
||||
tier_name, tier_num = deps.get_quality_tier_from_extension(file_path)
|
||||
title = track.get('title', '') or ''
|
||||
if not title.strip():
|
||||
title = os.path.splitext(os.path.basename(file_path))[0]
|
||||
spotify_tid = track.get('spotify_track_id')
|
||||
|
||||
# Build Spotify track data for wishlist
|
||||
matched_track_data = None
|
||||
|
||||
if spotify_tid and deps.spotify_client:
|
||||
# Direct lookup via stored Spotify ID — raw_data has full Spotify API format
|
||||
try:
|
||||
track_details = deps.spotify_client.get_track_details(spotify_tid)
|
||||
if track_details and track_details.get('raw_data'):
|
||||
matched_track_data = track_details['raw_data']
|
||||
elif track_details:
|
||||
# Enhanced format — rebuild with images for wishlist compatibility
|
||||
album_data = track_details.get('album', {})
|
||||
album_images = []
|
||||
# Try to get album art from a full album lookup
|
||||
if album_data.get('id'):
|
||||
try:
|
||||
full_album = deps.spotify_client.get_album(album_data['id'])
|
||||
if full_album and full_album.get('images'):
|
||||
album_images = full_album['images']
|
||||
except Exception:
|
||||
pass
|
||||
matched_track_data = {
|
||||
'id': spotify_tid,
|
||||
'name': track_details.get('name', title),
|
||||
'artists': [{'name': a} for a in track_details.get('artists', [artist_name])],
|
||||
'album': {
|
||||
'id': album_data.get('id', ''),
|
||||
'name': album_data.get('name', track.get('_album_title', '')),
|
||||
'album_type': album_data.get('album_type', 'album'),
|
||||
'release_date': album_data.get('release_date', ''),
|
||||
'total_tracks': album_data.get('total_tracks', 1),
|
||||
'artists': [{'name': a} for a in album_data.get('artists', [artist_name])],
|
||||
'images': album_images,
|
||||
},
|
||||
'duration_ms': track_details.get('duration_ms', track.get('duration', 0)),
|
||||
'track_number': track_details.get('track_number', track.get('track_number', 1)),
|
||||
'disc_number': track_details.get('disc_number', 1),
|
||||
'popularity': 0,
|
||||
'preview_url': None,
|
||||
'external_urls': {},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[Enhance] Spotify lookup failed for {spotify_tid}: {e}")
|
||||
|
||||
if not matched_track_data and deps.spotify_client:
|
||||
# Fallback: Spotify search matching — need full track data for wishlist
|
||||
try:
|
||||
temp_track = type('TempTrack', (), {
|
||||
'name': title, 'artists': [artist_name],
|
||||
'album': track.get('_album_title', '')
|
||||
})()
|
||||
search_queries = deps.matching_engine.generate_download_queries(temp_track)
|
||||
best_match = None
|
||||
best_match_raw = None
|
||||
best_confidence = 0.0
|
||||
|
||||
for search_query in search_queries[:3]: # Limit queries
|
||||
try:
|
||||
results = deps.spotify_client.search_tracks(search_query, limit=5)
|
||||
if not results:
|
||||
continue
|
||||
for sp_track in results:
|
||||
artist_conf = max(
|
||||
(deps.matching_engine.similarity_score(
|
||||
deps.matching_engine.normalize_string(artist_name),
|
||||
deps.matching_engine.normalize_string(a)
|
||||
) for a in (sp_track.artists or [artist_name])),
|
||||
default=0
|
||||
)
|
||||
title_conf = deps.matching_engine.similarity_score(
|
||||
deps.matching_engine.normalize_string(title),
|
||||
deps.matching_engine.normalize_string(sp_track.name)
|
||||
)
|
||||
combined = artist_conf * 0.5 + title_conf * 0.5
|
||||
# Small bonus for album tracks over singles
|
||||
_at = getattr(sp_track, 'album_type', None) or ''
|
||||
if _at == 'album':
|
||||
combined += 0.02
|
||||
elif _at == 'ep':
|
||||
combined += 0.01
|
||||
if combined > best_confidence and combined >= 0.7:
|
||||
best_confidence = combined
|
||||
best_match = sp_track
|
||||
if best_confidence >= 0.9:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if best_match:
|
||||
# Fetch full track data from Spotify for proper wishlist format
|
||||
try:
|
||||
full_details = deps.spotify_client.get_track_details(best_match.id)
|
||||
if full_details and full_details.get('raw_data'):
|
||||
matched_track_data = full_details['raw_data']
|
||||
else:
|
||||
raise ValueError("No raw_data from get_track_details")
|
||||
except Exception:
|
||||
# Build from Track dataclass with image
|
||||
album_images = [{'url': best_match.image_url}] if best_match.image_url else []
|
||||
matched_track_data = {
|
||||
'id': best_match.id,
|
||||
'name': best_match.name,
|
||||
'artists': [{'name': a} for a in best_match.artists],
|
||||
'album': {
|
||||
'name': best_match.album,
|
||||
'artists': [{'name': a} for a in best_match.artists],
|
||||
'album_type': 'album',
|
||||
'release_date': getattr(best_match, 'release_date', '') or '',
|
||||
'images': album_images,
|
||||
},
|
||||
'duration_ms': best_match.duration_ms,
|
||||
'popularity': best_match.popularity or 0,
|
||||
'preview_url': best_match.preview_url,
|
||||
'external_urls': best_match.external_urls or {},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[Enhance] Search match failed for {title}: {e}")
|
||||
|
||||
# Fallback source when Spotify unavailable or no match found
|
||||
if not matched_track_data:
|
||||
try:
|
||||
fallback_client = deps.get_metadata_fallback_client()
|
||||
itunes_best = None
|
||||
itunes_best_conf = 0.0
|
||||
|
||||
itunes_queries = deps.matching_engine.generate_download_queries(
|
||||
type('TempTrack', (), {
|
||||
'name': title, 'artists': [artist_name],
|
||||
'album': track.get('_album_title', '')
|
||||
})()
|
||||
)
|
||||
|
||||
for search_query in itunes_queries[:3]:
|
||||
try:
|
||||
itunes_results = fallback_client.search_tracks(search_query, limit=5)
|
||||
if not itunes_results:
|
||||
continue
|
||||
for it_track in itunes_results:
|
||||
artist_conf = max(
|
||||
(deps.matching_engine.similarity_score(
|
||||
deps.matching_engine.normalize_string(artist_name),
|
||||
deps.matching_engine.normalize_string(a)
|
||||
) for a in (it_track.artists or [artist_name])),
|
||||
default=0
|
||||
)
|
||||
title_conf = deps.matching_engine.similarity_score(
|
||||
deps.matching_engine.normalize_string(title),
|
||||
deps.matching_engine.normalize_string(it_track.name)
|
||||
)
|
||||
combined = artist_conf * 0.5 + title_conf * 0.5
|
||||
# Small bonus for album tracks over singles
|
||||
_at = getattr(it_track, 'album_type', None) or ''
|
||||
if _at == 'album':
|
||||
combined += 0.02
|
||||
elif _at == 'ep':
|
||||
combined += 0.01
|
||||
if combined > itunes_best_conf and combined >= 0.7:
|
||||
itunes_best_conf = combined
|
||||
itunes_best = it_track
|
||||
if itunes_best_conf >= 0.9:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if itunes_best:
|
||||
album_images = [{'url': itunes_best.image_url, 'height': 600, 'width': 600}] if itunes_best.image_url else []
|
||||
matched_track_data = {
|
||||
'id': itunes_best.id,
|
||||
'name': itunes_best.name,
|
||||
'artists': [{'name': a} for a in itunes_best.artists],
|
||||
'album': {
|
||||
'name': itunes_best.album,
|
||||
'artists': [{'name': a} for a in itunes_best.artists],
|
||||
'album_type': 'album',
|
||||
'images': album_images,
|
||||
'release_date': itunes_best.release_date or '',
|
||||
'total_tracks': 1,
|
||||
},
|
||||
'duration_ms': itunes_best.duration_ms,
|
||||
'track_number': itunes_best.track_number or 1,
|
||||
'disc_number': itunes_best.disc_number or 1,
|
||||
'popularity': itunes_best.popularity or 0,
|
||||
'preview_url': itunes_best.preview_url,
|
||||
'external_urls': itunes_best.external_urls or {},
|
||||
}
|
||||
logger.warning(f"[Enhance] Fallback match for {title}: {itunes_best.artists[0]} - {itunes_best.name} (conf: {itunes_best_conf:.3f})")
|
||||
except Exception as e:
|
||||
logger.error(f"[Enhance] Fallback source failed for {title}: {e}")
|
||||
|
||||
if not matched_track_data:
|
||||
failed_count += 1
|
||||
failed_tracks.append({'track_id': track_id, 'title': title, 'reason': 'No Spotify or fallback match'})
|
||||
continue
|
||||
|
||||
# Add to wishlist with enhance source
|
||||
source_context = {
|
||||
'enhance': True,
|
||||
'original_file_path': file_path,
|
||||
'original_format': tier_name,
|
||||
'original_bitrate': track.get('bitrate'),
|
||||
'original_tier': tier_num,
|
||||
'artist_name': artist_name,
|
||||
}
|
||||
|
||||
success = wishlist_service.add_spotify_track_to_wishlist(
|
||||
spotify_track_data=matched_track_data,
|
||||
failure_reason=f"Quality enhance - upgrading from {tier_name.replace('_', ' ').title()}",
|
||||
source_type='enhance',
|
||||
source_context=source_context,
|
||||
profile_id=profile_id
|
||||
)
|
||||
|
||||
if success:
|
||||
enhanced_count += 1
|
||||
logger.info(f"[Enhance] Queued for upgrade: {artist_name} - {title} ({tier_name})")
|
||||
else:
|
||||
failed_count += 1
|
||||
failed_tracks.append({'track_id': track_id, 'title': title, 'reason': 'Wishlist add failed'})
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'enhanced_count': enhanced_count,
|
||||
'failed_count': failed_count,
|
||||
'failed_tracks': failed_tracks
|
||||
}, 200
|
||||
except Exception as e:
|
||||
logger.error(f"[Enhance] {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {"success": False, "error": str(e)}, 500
|
||||
7
core/automation/__init__.py
Normal file
7
core/automation/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
"""Automation API + progress tracking helpers 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.
|
||||
"""
|
||||
367
core/automation/api.py
Normal file
367
core/automation/api.py
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
"""Automation REST API helpers.
|
||||
|
||||
CRUD + run + progress + history logic for /api/automations/* routes.
|
||||
Each function takes the deps it needs (database, automation_engine,
|
||||
profile_id) so the route layer is left as pure HTTP shuffling.
|
||||
|
||||
Out of scope:
|
||||
- /api/automations/blocks — static JSON + one call into signals.py;
|
||||
stays inline in web_server.py for now.
|
||||
- /api/test/automation — touches scan manager + media clients +
|
||||
config_manager; stays inline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hydration helpers — convert raw DB rows to API-friendly dicts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_JSON_FIELDS = ('trigger_config', 'action_config', 'notify_config', 'last_result')
|
||||
_JSON_DEFAULT_DICT = {'trigger_config', 'action_config', 'notify_config'}
|
||||
|
||||
|
||||
def _hydrate_automation(auto: dict) -> dict:
|
||||
"""Parse JSON columns and backfill `then_actions` from legacy notify_*."""
|
||||
for field in _JSON_FIELDS:
|
||||
try:
|
||||
auto[field] = json.loads(auto[field]) if isinstance(auto[field], str) else auto[field]
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
auto[field] = {} if field in _JSON_DEFAULT_DICT else None
|
||||
|
||||
try:
|
||||
raw = auto.get('then_actions')
|
||||
auto['then_actions'] = json.loads(raw or '[]') if isinstance(raw, str) else (raw or [])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
auto['then_actions'] = []
|
||||
|
||||
if not auto['then_actions'] and auto.get('notify_type'):
|
||||
auto['then_actions'] = [{
|
||||
'type': auto['notify_type'],
|
||||
'config': auto.get('notify_config', {}),
|
||||
}]
|
||||
return auto
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signal cycle detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _has_signal_concern(trigger_type: str, then_actions: list[dict]) -> bool:
|
||||
return trigger_type == 'signal_received' or any(
|
||||
t.get('type') == 'fire_signal' for t in then_actions
|
||||
)
|
||||
|
||||
|
||||
def _check_create_cycle(
|
||||
automation_engine,
|
||||
database,
|
||||
profile_id: int,
|
||||
trigger_type: str,
|
||||
trigger_config_json: str,
|
||||
then_actions_json: str,
|
||||
then_actions: list[dict],
|
||||
) -> Optional[str]:
|
||||
"""Return cycle path string if creating this automation would loop, else None."""
|
||||
if not automation_engine or not _has_signal_concern(trigger_type, then_actions):
|
||||
return None
|
||||
all_autos = database.get_automations(profile_id)
|
||||
test_auto = {
|
||||
'trigger_type': trigger_type,
|
||||
'trigger_config': trigger_config_json,
|
||||
'then_actions': then_actions_json,
|
||||
'enabled': True,
|
||||
}
|
||||
all_autos.append(test_auto)
|
||||
cycle = automation_engine.detect_signal_cycles(all_autos)
|
||||
if cycle:
|
||||
return ' → '.join(cycle)
|
||||
return None
|
||||
|
||||
|
||||
def _check_update_cycle(
|
||||
automation_engine,
|
||||
database,
|
||||
automation_id: int,
|
||||
data: dict,
|
||||
) -> Optional[str]:
|
||||
"""Return cycle path string if updating this automation would loop, else None."""
|
||||
if not automation_engine:
|
||||
return None
|
||||
trigger_type = data.get('trigger_type', '')
|
||||
then_actions = data.get('then_actions', [])
|
||||
if not _has_signal_concern(trigger_type, then_actions):
|
||||
return None
|
||||
|
||||
all_autos = database.get_automations()
|
||||
test_autos = []
|
||||
for a in all_autos:
|
||||
if a['id'] == automation_id:
|
||||
merged = dict(a)
|
||||
if 'trigger_type' in data:
|
||||
merged['trigger_type'] = data['trigger_type']
|
||||
if 'trigger_config' in data:
|
||||
merged['trigger_config'] = json.dumps(data['trigger_config'])
|
||||
if 'then_actions' in data:
|
||||
merged['then_actions'] = json.dumps(data['then_actions'])
|
||||
merged['enabled'] = True
|
||||
test_autos.append(merged)
|
||||
else:
|
||||
test_autos.append(a)
|
||||
cycle = automation_engine.detect_signal_cycles(test_autos)
|
||||
if cycle:
|
||||
return ' → '.join(cycle)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD helpers — return (response_dict, http_status)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def list_automations(database, profile_id: int) -> list[dict]:
|
||||
"""All automations for the profile, with JSON columns parsed."""
|
||||
automations = database.get_automations(profile_id)
|
||||
return [_hydrate_automation(a) for a in automations]
|
||||
|
||||
|
||||
def get_automation(database, automation_id: int) -> Optional[dict]:
|
||||
"""One automation, hydrated. Returns None if not found."""
|
||||
auto = database.get_automation(automation_id)
|
||||
if not auto:
|
||||
return None
|
||||
return _hydrate_automation(auto)
|
||||
|
||||
|
||||
def create_automation(
|
||||
database,
|
||||
automation_engine,
|
||||
profile_id: int,
|
||||
data: dict,
|
||||
) -> tuple[dict, int]:
|
||||
"""Create + schedule an automation. Returns (response_body, http_status)."""
|
||||
name = (data.get('name') or '').strip()
|
||||
if not name:
|
||||
return {'error': 'Name is required'}, 400
|
||||
|
||||
trigger_type = data.get('trigger_type', 'schedule')
|
||||
trigger_config = json.dumps(data.get('trigger_config', {}))
|
||||
action_type = data.get('action_type', 'process_wishlist')
|
||||
action_config = json.dumps(data.get('action_config', {}))
|
||||
then_actions = data.get('then_actions', [])
|
||||
then_actions_json = json.dumps(then_actions)
|
||||
|
||||
if then_actions:
|
||||
notify_type = then_actions[0].get('type')
|
||||
notify_config = json.dumps(then_actions[0].get('config', {}))
|
||||
else:
|
||||
notify_type = data.get('notify_type') or None
|
||||
notify_config = json.dumps(data.get('notify_config', {})) if notify_type else '{}'
|
||||
|
||||
cycle_path = _check_create_cycle(
|
||||
automation_engine, database, profile_id,
|
||||
trigger_type, trigger_config, then_actions_json, then_actions,
|
||||
)
|
||||
if cycle_path:
|
||||
return {'error': f'Signal cycle detected: {cycle_path}. This would cause an infinite loop.'}, 400
|
||||
|
||||
group_name = data.get('group_name') or None
|
||||
auto_id = database.create_automation(
|
||||
name, trigger_type, trigger_config, action_type, action_config,
|
||||
profile_id, notify_type, notify_config, then_actions_json, group_name,
|
||||
)
|
||||
if auto_id is None:
|
||||
return {'error': 'Failed to create automation'}, 500
|
||||
|
||||
if automation_engine:
|
||||
automation_engine.schedule_automation(auto_id)
|
||||
return {'success': True, 'id': auto_id}, 200
|
||||
|
||||
|
||||
def update_automation(
|
||||
database,
|
||||
automation_engine,
|
||||
automation_id: int,
|
||||
data: dict,
|
||||
) -> tuple[dict, int]:
|
||||
"""Update + reschedule an automation. Returns (response_body, http_status)."""
|
||||
update_fields: dict[str, Any] = {}
|
||||
if 'name' in data:
|
||||
update_fields['name'] = data['name'].strip()
|
||||
if 'trigger_type' in data:
|
||||
update_fields['trigger_type'] = data['trigger_type']
|
||||
if 'trigger_config' in data:
|
||||
update_fields['trigger_config'] = json.dumps(data['trigger_config'])
|
||||
if 'action_type' in data:
|
||||
update_fields['action_type'] = data['action_type']
|
||||
if 'action_config' in data:
|
||||
update_fields['action_config'] = json.dumps(data['action_config'])
|
||||
if 'then_actions' in data:
|
||||
then_actions = data['then_actions']
|
||||
update_fields['then_actions'] = json.dumps(then_actions)
|
||||
if then_actions:
|
||||
update_fields['notify_type'] = then_actions[0].get('type')
|
||||
update_fields['notify_config'] = json.dumps(then_actions[0].get('config', {}))
|
||||
else:
|
||||
update_fields['notify_type'] = None
|
||||
update_fields['notify_config'] = '{}'
|
||||
elif 'notify_type' in data:
|
||||
update_fields['notify_type'] = data['notify_type'] or None
|
||||
if 'notify_config' in data and 'then_actions' not in data:
|
||||
update_fields['notify_config'] = json.dumps(data['notify_config'])
|
||||
if 'group_name' in data:
|
||||
update_fields['group_name'] = data['group_name'] or None
|
||||
|
||||
if not update_fields:
|
||||
return {'error': 'No fields to update'}, 400
|
||||
|
||||
cycle_path = _check_update_cycle(automation_engine, database, automation_id, data)
|
||||
if cycle_path:
|
||||
return {'error': f'Signal cycle detected: {cycle_path}. This would cause an infinite loop.'}, 400
|
||||
|
||||
success = database.update_automation(automation_id, **update_fields)
|
||||
if not success:
|
||||
return {'error': 'Automation not found'}, 404
|
||||
|
||||
if automation_engine:
|
||||
auto = database.get_automation(automation_id)
|
||||
if auto and auto.get('enabled'):
|
||||
automation_engine.schedule_automation(automation_id)
|
||||
else:
|
||||
automation_engine.cancel_automation(automation_id)
|
||||
return {'success': True}, 200
|
||||
|
||||
|
||||
def batch_update_group(database, automation_ids: list, group_name: Optional[str]) -> tuple[dict, int]:
|
||||
"""Move/rename a set of automations into a single group (or ungroup)."""
|
||||
if not automation_ids or not isinstance(automation_ids, list):
|
||||
return {'error': 'automation_ids must be a non-empty list'}, 400
|
||||
try:
|
||||
automation_ids = [int(aid) for aid in automation_ids]
|
||||
except (ValueError, TypeError):
|
||||
return {'error': 'automation_ids must contain integers'}, 400
|
||||
|
||||
updated = database.batch_update_group(automation_ids, group_name)
|
||||
return {'success': True, 'updated': updated}, 200
|
||||
|
||||
|
||||
def bulk_toggle(
|
||||
database,
|
||||
automation_engine,
|
||||
automation_ids: list,
|
||||
enabled: bool,
|
||||
) -> tuple[dict, int]:
|
||||
"""Bulk enable/disable a set of automations + reschedule each affected."""
|
||||
if not automation_ids or not isinstance(automation_ids, list):
|
||||
return {'error': 'automation_ids must be a non-empty list'}, 400
|
||||
try:
|
||||
automation_ids = [int(aid) for aid in automation_ids]
|
||||
except (ValueError, TypeError):
|
||||
return {'error': 'automation_ids must contain integers'}, 400
|
||||
|
||||
updated = database.bulk_set_enabled(automation_ids, bool(enabled))
|
||||
|
||||
if automation_engine and updated > 0:
|
||||
for aid in automation_ids:
|
||||
auto = database.get_automation(aid)
|
||||
if auto:
|
||||
if auto.get('enabled'):
|
||||
automation_engine.schedule_automation(auto)
|
||||
else:
|
||||
automation_engine.cancel_automation(aid)
|
||||
return {'success': True, 'updated': updated}, 200
|
||||
|
||||
|
||||
def delete_automation(database, automation_engine, automation_id: int) -> tuple[dict, int]:
|
||||
"""Delete an automation. System automations are protected."""
|
||||
auto = database.get_automation(automation_id)
|
||||
if auto and auto.get('is_system'):
|
||||
return {'error': 'System automations cannot be deleted'}, 403
|
||||
if automation_engine:
|
||||
automation_engine.cancel_automation(automation_id)
|
||||
success = database.delete_automation(automation_id)
|
||||
if not success:
|
||||
return {'error': 'Automation not found'}, 404
|
||||
return {'success': True}, 200
|
||||
|
||||
|
||||
def duplicate_automation(
|
||||
database,
|
||||
automation_engine,
|
||||
profile_id: int,
|
||||
automation_id: int,
|
||||
) -> tuple[dict, int]:
|
||||
"""Duplicate an automation. System automations are protected."""
|
||||
auto = database.get_automation(automation_id)
|
||||
if not auto:
|
||||
return {'error': 'Automation not found'}, 404
|
||||
if auto.get('is_system'):
|
||||
return {'error': 'System automations cannot be duplicated'}, 403
|
||||
new_id = database.create_automation(
|
||||
name=f"{auto['name']} (Copy)",
|
||||
trigger_type=auto['trigger_type'],
|
||||
trigger_config=auto.get('trigger_config', '{}'),
|
||||
action_type=auto['action_type'],
|
||||
action_config=auto.get('action_config', '{}'),
|
||||
profile_id=profile_id,
|
||||
notify_type=auto.get('notify_type'),
|
||||
notify_config=auto.get('notify_config', '{}'),
|
||||
then_actions=auto.get('then_actions', '[]'),
|
||||
group_name=auto.get('group_name'),
|
||||
)
|
||||
if new_id is None:
|
||||
return {'error': 'Failed to duplicate automation'}, 500
|
||||
if automation_engine:
|
||||
automation_engine.schedule_automation(new_id)
|
||||
return {'success': True, 'id': new_id}, 200
|
||||
|
||||
|
||||
def toggle_automation(database, automation_engine, automation_id: int) -> tuple[dict, int]:
|
||||
"""Toggle an automation's enabled state + reschedule/cancel."""
|
||||
success = database.toggle_automation(automation_id)
|
||||
if not success:
|
||||
return {'error': 'Automation not found'}, 404
|
||||
|
||||
if automation_engine:
|
||||
auto = database.get_automation(automation_id)
|
||||
if auto and auto.get('enabled'):
|
||||
automation_engine.schedule_automation(automation_id)
|
||||
else:
|
||||
automation_engine.cancel_automation(automation_id)
|
||||
return {'success': True}, 200
|
||||
|
||||
|
||||
def run_automation(automation_engine, automation_id: int, profile_id: int) -> tuple[dict, int]:
|
||||
"""Manually trigger an automation."""
|
||||
if not automation_engine:
|
||||
return {'error': 'Automation engine not available'}, 500
|
||||
success = automation_engine.run_now(automation_id, profile_id=profile_id)
|
||||
if not success:
|
||||
return {'error': 'Automation not found'}, 404
|
||||
return {'success': True}, 200
|
||||
|
||||
|
||||
def get_history(database, automation_id: int, *, limit: int, offset: int) -> dict:
|
||||
"""Run-history page for an automation, with log_lines/result_json parsed."""
|
||||
data = database.get_automation_run_history(automation_id, limit=limit, offset=offset)
|
||||
for entry in data.get('history', []):
|
||||
if entry.get('log_lines'):
|
||||
try:
|
||||
entry['log_lines'] = json.loads(entry['log_lines'])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
entry['log_lines'] = []
|
||||
else:
|
||||
entry['log_lines'] = []
|
||||
if entry.get('result_json'):
|
||||
try:
|
||||
entry['result_json'] = json.loads(entry['result_json'])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
data['automation_id'] = automation_id
|
||||
return data
|
||||
215
core/automation/blocks.py
Normal file
215
core/automation/blocks.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
"""Static block definitions for the automation builder UI.
|
||||
|
||||
Returned verbatim by `/api/automations/blocks` (with `known_signals`
|
||||
injected by the route from `signals.collect_known_signals`).
|
||||
|
||||
Three top-level lists:
|
||||
- `TRIGGERS` — WHEN blocks: schedule, daily/weekly time, app started,
|
||||
event triggers (track_downloaded, batch_complete, etc.), signal_received,
|
||||
webhook_received.
|
||||
- `ACTIONS` — DO blocks: process_wishlist, scan_library, etc.
|
||||
- `NOTIFICATIONS` — THEN blocks: discord/pushbullet/telegram/webhook,
|
||||
plus fire_signal and run_script then-actions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
TRIGGERS: list[dict] = [
|
||||
{"type": "schedule", "label": "Schedule", "icon": "clock", "description": "Run on a timer interval", "available": True,
|
||||
"config_fields": [
|
||||
{"key": "interval", "type": "number", "label": "Every", "default": 6, "min": 1},
|
||||
{"key": "unit", "type": "select", "label": "Unit",
|
||||
"options": [{"value": "minutes", "label": "Minutes"}, {"value": "hours", "label": "Hours"}, {"value": "days", "label": "Days"}],
|
||||
"default": "hours"}
|
||||
]},
|
||||
{"type": "daily_time", "label": "Daily Time", "icon": "clock", "description": "Run every day at a specific time", "available": True,
|
||||
"config_fields": [
|
||||
{"key": "time", "type": "time", "label": "At", "default": "03:00"}
|
||||
]},
|
||||
{"type": "weekly_time", "label": "Weekly Schedule", "icon": "calendar", "description": "Run on specific days of the week at a set time", "available": True,
|
||||
"config_fields": [
|
||||
{"key": "time", "type": "time", "label": "At", "default": "03:00"},
|
||||
{"key": "days", "type": "multi_select", "label": "Days",
|
||||
"options": [{"value": "mon", "label": "Mon"}, {"value": "tue", "label": "Tue"}, {"value": "wed", "label": "Wed"},
|
||||
{"value": "thu", "label": "Thu"}, {"value": "fri", "label": "Fri"}, {"value": "sat", "label": "Sat"}, {"value": "sun", "label": "Sun"}]}
|
||||
]},
|
||||
{"type": "app_started", "label": "App Started", "icon": "power", "description": "When SoulSync starts up", "available": True},
|
||||
{"type": "track_downloaded", "label": "Track Downloaded", "icon": "download", "description": "When a track finishes downloading", "available": True,
|
||||
"has_conditions": True,
|
||||
"condition_fields": ["artist", "title", "album", "quality"],
|
||||
"variables": ["artist", "title", "album", "quality"]},
|
||||
{"type": "batch_complete", "label": "Batch Complete", "icon": "check-circle", "description": "When an album/playlist download finishes", "available": True,
|
||||
"has_conditions": True,
|
||||
"condition_fields": ["playlist_name"],
|
||||
"variables": ["playlist_name", "total_tracks", "completed_tracks", "failed_tracks"]},
|
||||
{"type": "watchlist_new_release", "label": "New Release Found", "icon": "bell", "description": "When watchlist detects new music", "available": True,
|
||||
"has_conditions": True,
|
||||
"condition_fields": ["artist"],
|
||||
"variables": ["artist", "new_tracks", "added_to_wishlist"]},
|
||||
{"type": "playlist_synced", "label": "Playlist Synced", "icon": "refresh", "description": "When a playlist sync completes", "available": True,
|
||||
"has_conditions": True,
|
||||
"condition_fields": ["playlist_name"],
|
||||
"variables": ["playlist_name", "total_tracks", "matched_tracks", "synced_tracks", "failed_tracks"]},
|
||||
{"type": "playlist_changed", "label": "Playlist Changed", "icon": "edit", "description": "When a mirrored playlist detects track changes from source", "available": True,
|
||||
"has_conditions": True,
|
||||
"condition_fields": ["playlist_name"],
|
||||
"variables": ["playlist_name", "old_count", "new_count", "added", "removed"]},
|
||||
{"type": "discovery_completed", "label": "Discovery Complete", "icon": "search", "description": "When playlist track discovery finishes", "available": True,
|
||||
"has_conditions": True,
|
||||
"condition_fields": ["playlist_name"],
|
||||
"variables": ["playlist_name", "total_tracks", "discovered_count", "failed_count", "skipped_count"]},
|
||||
# Phase 3 triggers
|
||||
{"type": "wishlist_processing_completed", "label": "Wishlist Processed", "icon": "check-circle",
|
||||
"description": "When auto-wishlist processing finishes", "available": True,
|
||||
"variables": ["tracks_processed", "tracks_found", "tracks_failed"]},
|
||||
{"type": "watchlist_scan_completed", "label": "Watchlist Scan Done", "icon": "check-circle",
|
||||
"description": "When watchlist scan finishes", "available": True,
|
||||
"variables": ["artists_scanned", "new_tracks_found", "tracks_added"]},
|
||||
{"type": "database_update_completed", "label": "Database Updated", "icon": "database",
|
||||
"description": "When library database refresh finishes", "available": True,
|
||||
"variables": ["total_artists", "total_albums", "total_tracks"]},
|
||||
{"type": "library_scan_completed", "label": "Library Scan Done", "icon": "hard-drive",
|
||||
"description": "When media library scan finishes", "available": True,
|
||||
"variables": ["server_type"]},
|
||||
{"type": "download_failed", "label": "Download Failed", "icon": "x-circle",
|
||||
"description": "When a track permanently fails to download", "available": True,
|
||||
"has_conditions": True, "condition_fields": ["artist", "title", "reason"],
|
||||
"variables": ["artist", "title", "reason"]},
|
||||
{"type": "download_quarantined", "label": "File Quarantined", "icon": "alert-triangle",
|
||||
"description": "When AcoustID verification fails", "available": True,
|
||||
"has_conditions": True, "condition_fields": ["artist", "title"],
|
||||
"variables": ["artist", "title", "reason"]},
|
||||
{"type": "wishlist_item_added", "label": "Wishlist Item Added", "icon": "plus-circle",
|
||||
"description": "When a track is added to wishlist", "available": True,
|
||||
"has_conditions": True, "condition_fields": ["artist", "title"],
|
||||
"variables": ["artist", "title", "reason"]},
|
||||
{"type": "watchlist_artist_added", "label": "Artist Watched", "icon": "user-plus",
|
||||
"description": "When an artist is added to watchlist", "available": True,
|
||||
"has_conditions": True, "condition_fields": ["artist"],
|
||||
"variables": ["artist", "artist_id"]},
|
||||
{"type": "watchlist_artist_removed", "label": "Artist Unwatched", "icon": "user-minus",
|
||||
"description": "When an artist is removed from watchlist", "available": True,
|
||||
"has_conditions": True, "condition_fields": ["artist"],
|
||||
"variables": ["artist", "artist_id"]},
|
||||
{"type": "import_completed", "label": "Import Complete", "icon": "upload",
|
||||
"description": "When album/track import finishes", "available": True,
|
||||
"has_conditions": True, "condition_fields": ["artist", "album_name"],
|
||||
"variables": ["track_count", "album_name", "artist"]},
|
||||
{"type": "mirrored_playlist_created", "label": "Playlist Mirrored", "icon": "copy",
|
||||
"description": "When a new playlist is mirrored", "available": True,
|
||||
"has_conditions": True, "condition_fields": ["playlist_name", "source"],
|
||||
"variables": ["playlist_name", "source", "track_count"]},
|
||||
{"type": "quality_scan_completed", "label": "Quality Scan Done", "icon": "bar-chart",
|
||||
"description": "When quality scan finishes", "available": True,
|
||||
"variables": ["quality_met", "low_quality", "total_scanned"]},
|
||||
{"type": "duplicate_scan_completed", "label": "Duplicate Scan Done", "icon": "layers",
|
||||
"description": "When duplicate cleaner finishes", "available": True,
|
||||
"variables": ["files_scanned", "duplicates_found", "space_freed"]},
|
||||
# Signal trigger
|
||||
{"type": "signal_received", "label": "Signal Received", "icon": "zap",
|
||||
"description": "When another automation fires a named signal", "available": True,
|
||||
"config_fields": [
|
||||
{"key": "signal_name", "type": "signal_input", "label": "Signal Name"}
|
||||
],
|
||||
"variables": ["signal_name"]},
|
||||
# Webhook trigger
|
||||
{"type": "webhook_received", "label": "Webhook Received", "icon": "globe",
|
||||
"description": "When an external API request is received (POST /api/v1/request)", "available": True,
|
||||
"variables": ["query", "request_id", "source"]},
|
||||
]
|
||||
|
||||
|
||||
ACTIONS: list[dict] = [
|
||||
{"type": "process_wishlist", "label": "Process Wishlist", "icon": "list", "description": "Retry failed downloads from wishlist", "available": True,
|
||||
"config_fields": [{"key": "category", "type": "select", "label": "Category", "options": [{"value": "all", "label": "All"}, {"value": "albums", "label": "Albums"}, {"value": "singles", "label": "Singles"}], "default": "all"}]},
|
||||
{"type": "scan_watchlist", "label": "Scan Watchlist", "icon": "eye", "description": "Check watched artists for new releases", "available": True},
|
||||
{"type": "scan_library", "label": "Scan Library", "icon": "refresh", "description": "Trigger media server library scan", "available": True},
|
||||
{"type": "refresh_mirrored", "label": "Refresh Mirrored Playlist", "icon": "copy", "description": "Re-fetch playlist from source and update mirror", "available": True,
|
||||
"config_fields": [
|
||||
{"key": "playlist_id", "type": "mirrored_playlist_select", "label": "Playlist"},
|
||||
{"key": "all", "type": "checkbox", "label": "Refresh all mirrored playlists", "default": False}
|
||||
]},
|
||||
{"type": "sync_playlist", "label": "Sync Playlist", "icon": "sync", "description": "Sync mirrored playlist to media server", "available": True,
|
||||
"config_fields": [
|
||||
{"key": "playlist_id", "type": "mirrored_playlist_select", "label": "Playlist"}
|
||||
]},
|
||||
{"type": "discover_playlist", "label": "Discover Playlist", "icon": "search", "description": "Find official Spotify/iTunes metadata for mirrored playlist tracks", "available": True,
|
||||
"config_fields": [
|
||||
{"key": "playlist_id", "type": "mirrored_playlist_select", "label": "Playlist"},
|
||||
{"key": "all", "type": "checkbox", "label": "Discover all mirrored playlists", "default": False}
|
||||
]},
|
||||
{"type": "playlist_pipeline", "label": "Playlist Pipeline", "icon": "rocket",
|
||||
"description": "Full lifecycle: refresh → discover → sync → download missing. One automation for the entire flow.",
|
||||
"available": True,
|
||||
"config_fields": [
|
||||
{"key": "playlist_id", "type": "mirrored_playlist_select", "label": "Playlist"},
|
||||
{"key": "all", "type": "checkbox", "label": "Process all mirrored playlists", "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",
|
||||
"description": "Trigger library database refresh", "available": True,
|
||||
"config_fields": [
|
||||
{"key": "full_refresh", "type": "checkbox", "label": "Full refresh (slower)", "default": False}
|
||||
]},
|
||||
{"type": "run_duplicate_cleaner", "label": "Run Duplicate Cleaner", "icon": "layers",
|
||||
"description": "Scan for and remove duplicate files", "available": True},
|
||||
{"type": "clear_quarantine", "label": "Clear Quarantine", "icon": "trash",
|
||||
"description": "Delete all quarantined files", "available": True},
|
||||
{"type": "cleanup_wishlist", "label": "Clean Up Wishlist", "icon": "filter",
|
||||
"description": "Remove duplicate/owned tracks from wishlist", "available": True},
|
||||
{"type": "update_discovery_pool", "label": "Update Discovery", "icon": "compass",
|
||||
"description": "Refresh discovery pool with new tracks", "available": True},
|
||||
{"type": "start_quality_scan", "label": "Run Quality Scan", "icon": "bar-chart",
|
||||
"description": "Scan for low-quality audio files", "available": True,
|
||||
"config_fields": [
|
||||
{"key": "scope", "type": "select", "label": "Scope",
|
||||
"options": [{"value": "watchlist", "label": "Watchlist Artists"}, {"value": "library", "label": "Full Library"}],
|
||||
"default": "watchlist"}
|
||||
]},
|
||||
{"type": "backup_database", "label": "Backup Database", "icon": "save",
|
||||
"description": "Create timestamped database backup", "available": True},
|
||||
{"type": "refresh_beatport_cache", "label": "Refresh Beatport Cache", "icon": "music",
|
||||
"description": "Scrape Beatport homepage and warm the cache", "available": True},
|
||||
{"type": "clean_search_history", "label": "Clean Search History", "icon": "trash-2",
|
||||
"description": "Remove old searches from Soulseek", "available": True},
|
||||
{"type": "clean_completed_downloads", "label": "Clean Completed Downloads", "icon": "check-square",
|
||||
"description": "Clear completed downloads and empty directories", "available": True},
|
||||
{"type": "full_cleanup", "label": "Full Cleanup", "icon": "trash",
|
||||
"description": "Clear quarantine, download queue, import folder, and search history in one sweep", "available": True},
|
||||
{"type": "deep_scan_library", "label": "Deep Scan Library", "icon": "search",
|
||||
"description": "Full library comparison without losing enrichment data", "available": True},
|
||||
{"type": "run_script", "label": "Run Script", "icon": "terminal",
|
||||
"description": "Execute a script from the scripts folder", "available": True},
|
||||
{"type": "search_and_download", "label": "Search & Download", "icon": "download",
|
||||
"description": "Search for a track and download the best match", "available": True,
|
||||
"config_fields": [
|
||||
{"key": "query", "type": "text", "label": "Search Query",
|
||||
"placeholder": "Artist - Track (leave empty to use trigger's query)"}
|
||||
]},
|
||||
]
|
||||
|
||||
|
||||
NOTIFICATIONS: list[dict] = [
|
||||
{"type": "discord_webhook", "label": "Discord Webhook", "icon": "message", "description": "Send a Discord notification", "available": True,
|
||||
"variables": ["time", "name", "run_count", "status"]},
|
||||
{"type": "pushbullet", "label": "Pushbullet", "icon": "push", "description": "Push notification to phone/desktop", "available": True,
|
||||
"variables": ["time", "name", "run_count", "status"]},
|
||||
{"type": "telegram", "label": "Telegram", "icon": "message", "description": "Send a Telegram message", "available": True,
|
||||
"variables": ["time", "name", "run_count", "status"]},
|
||||
{"type": "webhook", "label": "Webhook (POST)", "icon": "globe", "description": "Send a POST request to any URL", "available": True,
|
||||
"variables": ["time", "name", "run_count", "status"]},
|
||||
# Signal fire action
|
||||
{"type": "fire_signal", "label": "Fire Signal", "icon": "zap",
|
||||
"description": "Fire a signal that other automations can listen for", "available": True,
|
||||
"config_fields": [
|
||||
{"key": "signal_name", "type": "signal_input", "label": "Signal Name"}
|
||||
]},
|
||||
# Run script then-action
|
||||
{"type": "run_script", "label": "Run Script", "icon": "terminal",
|
||||
"description": "Execute a script after the action completes", "available": True,
|
||||
"config_fields": [
|
||||
{"key": "script_name", "type": "script_select", "label": "Script"}
|
||||
]},
|
||||
]
|
||||
216
core/automation/progress.py
Normal file
216
core/automation/progress.py
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
"""Automation progress tracking.
|
||||
|
||||
Owns the in-memory progress state dict that backs both
|
||||
`/api/automations/progress` polling and the WebSocket
|
||||
`automation:progress` push emitter. State is per-automation, capped at
|
||||
50 log entries each, and finished/error states are reaped 60s after
|
||||
they finish so the frontend has a window to show the final state.
|
||||
|
||||
Functions are written so the route layer / engine callbacks can pass
|
||||
their own socketio emitter, db handle, and shutdown flag. The progress
|
||||
state dict (`progress_states`) and its lock (`progress_lock`) are
|
||||
module-level so all callers share one view — same as the original
|
||||
web_server.py globals.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Shared mutable state — module globals so every caller (routes, engine
|
||||
# progress callbacks, emit loop) sees the same dict. Mirrors the original
|
||||
# `automation_progress_states` / `automation_progress_lock` in web_server.
|
||||
progress_states: dict[int, dict] = {}
|
||||
progress_lock = threading.Lock()
|
||||
|
||||
|
||||
def init_progress(automation_id: int, automation_name: str, action_type: str) -> None:
|
||||
"""Initialize progress state when an automation starts running."""
|
||||
with progress_lock:
|
||||
progress_states[automation_id] = {
|
||||
'status': 'running',
|
||||
'action_type': action_type,
|
||||
'progress': 0,
|
||||
'phase': 'Starting...',
|
||||
'current_item': '',
|
||||
'processed': 0,
|
||||
'total': 0,
|
||||
'log': [{'type': 'info', 'text': f'Starting {automation_name}'}],
|
||||
'started_at': datetime.now(timezone.utc).isoformat(),
|
||||
'finished_at': None,
|
||||
}
|
||||
|
||||
|
||||
def update_progress(
|
||||
automation_id: Optional[int],
|
||||
*,
|
||||
socketio_emit: Optional[Callable[[str, Any], None]] = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Update progress state from handler threads. Thread-safe.
|
||||
|
||||
`socketio_emit` lets callers wire in the live socketio.emit so that
|
||||
finished/error transitions push immediately without waiting for the
|
||||
1s emitter loop. Falls back to no-op if not provided.
|
||||
"""
|
||||
if automation_id is None:
|
||||
return
|
||||
with progress_lock:
|
||||
state = progress_states.get(automation_id)
|
||||
if not state:
|
||||
return
|
||||
for k, v in kwargs.items():
|
||||
if k == 'log_line':
|
||||
state['log'].append({'type': kwargs.get('log_type', 'info'), 'text': v})
|
||||
if len(state['log']) > 50:
|
||||
state['log'] = state['log'][-50:]
|
||||
elif k != 'log_type':
|
||||
state[k] = v
|
||||
if kwargs.get('status') in ('finished', 'error'):
|
||||
state['finished_at'] = datetime.now(timezone.utc).isoformat()
|
||||
if socketio_emit is not None:
|
||||
try:
|
||||
socketio_emit('automation:progress', {str(automation_id): dict(state)})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_running_progress() -> dict[str, dict]:
|
||||
"""Snapshot of running/finished/error states for the polling endpoint."""
|
||||
with progress_lock:
|
||||
result: dict[str, dict] = {}
|
||||
for aid, state in progress_states.items():
|
||||
if state['status'] in ('running', 'finished', 'error'):
|
||||
cp = dict(state)
|
||||
cp['log'] = list(state['log'])
|
||||
result[str(aid)] = cp
|
||||
return result
|
||||
|
||||
|
||||
def record_history(
|
||||
automation_id: int,
|
||||
result: dict,
|
||||
database,
|
||||
) -> None:
|
||||
"""Capture progress state into run history before cleanup clears it.
|
||||
|
||||
`database` is passed in so the function works without a `get_database()`
|
||||
global.
|
||||
"""
|
||||
try:
|
||||
with progress_lock:
|
||||
state = progress_states.get(automation_id)
|
||||
if state:
|
||||
started_at = state.get('started_at')
|
||||
finished_at = state.get('finished_at') or datetime.now(timezone.utc).isoformat()
|
||||
log_entries = list(state.get('log', []))
|
||||
else:
|
||||
started_at = datetime.now(timezone.utc).isoformat()
|
||||
finished_at = datetime.now(timezone.utc).isoformat()
|
||||
log_entries = []
|
||||
|
||||
duration = None
|
||||
if started_at and finished_at:
|
||||
try:
|
||||
t0 = datetime.fromisoformat(started_at)
|
||||
t1 = datetime.fromisoformat(finished_at)
|
||||
duration = (t1 - t0).total_seconds()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
r_status = result.get('status', 'completed') if result else 'completed'
|
||||
if r_status == 'error':
|
||||
status = 'error'
|
||||
elif r_status == 'skipped':
|
||||
status = 'skipped'
|
||||
elif r_status == 'timeout':
|
||||
status = 'timeout'
|
||||
else:
|
||||
status = 'completed'
|
||||
|
||||
summary = None
|
||||
for entry in reversed(log_entries):
|
||||
if entry.get('type') in ('success', 'error'):
|
||||
summary = entry.get('text', '')
|
||||
break
|
||||
if not summary and log_entries:
|
||||
summary = log_entries[-1].get('text', '')
|
||||
if not summary and result:
|
||||
summary = result.get('reason') or result.get('error') or result.get('status', '')
|
||||
|
||||
result_json = json.dumps({k: v for k, v in result.items() if not k.startswith('_')}) if result else None
|
||||
log_json = json.dumps(log_entries) if log_entries else None
|
||||
|
||||
database.insert_automation_run_history(
|
||||
automation_id=automation_id,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
duration_seconds=duration,
|
||||
status=status,
|
||||
summary=summary,
|
||||
result_json=result_json,
|
||||
log_lines=log_json,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error recording automation history for {automation_id}: {e}")
|
||||
|
||||
|
||||
def emit_progress_loop(
|
||||
socketio,
|
||||
*,
|
||||
is_shutting_down: Callable[[], bool],
|
||||
poll_interval: float = 1.0,
|
||||
timeout_seconds: int = 7200,
|
||||
cleanup_after_seconds: int = 60,
|
||||
) -> None:
|
||||
"""Push `automation:progress` events for active automations.
|
||||
|
||||
Long-running loop — caller wires this into a socketio background task.
|
||||
- Times out zombie running states after `timeout_seconds` (default 2h).
|
||||
- Reaps finished/error states `cleanup_after_seconds` after finish so the
|
||||
frontend has a final-state window before they disappear.
|
||||
"""
|
||||
while not is_shutting_down():
|
||||
socketio.sleep(poll_interval)
|
||||
try:
|
||||
with progress_lock:
|
||||
active: dict[str, dict] = {}
|
||||
stale: list[int] = []
|
||||
now = datetime.now()
|
||||
for aid, state in progress_states.items():
|
||||
if state['status'] == 'running':
|
||||
try:
|
||||
started = datetime.fromisoformat(state.get('started_at', ''))
|
||||
if (now - started).total_seconds() > timeout_seconds:
|
||||
state['status'] = 'error'
|
||||
state['phase'] = 'Timed out'
|
||||
state['finished_at'] = now.isoformat()
|
||||
state['log'].append({'type': 'error', 'text': f'Timed out after {timeout_seconds // 3600} hours'})
|
||||
cp = dict(state)
|
||||
cp['log'] = list(state['log'])
|
||||
active[str(aid)] = cp
|
||||
continue
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
cp = dict(state)
|
||||
cp['log'] = list(state['log'])
|
||||
active[str(aid)] = cp
|
||||
elif state['status'] in ('finished', 'error') and state.get('finished_at'):
|
||||
try:
|
||||
finished_time = datetime.fromisoformat(state['finished_at'])
|
||||
if (now - finished_time).total_seconds() > cleanup_after_seconds:
|
||||
stale.append(aid)
|
||||
except (ValueError, TypeError):
|
||||
stale.append(aid)
|
||||
for aid in stale:
|
||||
del progress_states[aid]
|
||||
if active:
|
||||
socketio.emit('automation:progress', active)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error emitting automation progress: {e}")
|
||||
43
core/automation/signals.py
Normal file
43
core/automation/signals.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"""Automation signal helpers — name collection for autocomplete.
|
||||
|
||||
Signal cycle detection itself lives in core/automation_engine.py
|
||||
(`detect_signal_cycles`); this module just enumerates known signal
|
||||
names from the saved automation set so the builder UI can autocomplete.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def collect_known_signals(database) -> list[str]:
|
||||
"""Return sorted, deduped signal names referenced by any saved automation.
|
||||
|
||||
Walks every automation and pulls signal names from both the
|
||||
`signal_received` trigger config and any `fire_signal` then-actions.
|
||||
Errors at every layer are swallowed — the autocomplete is best-effort.
|
||||
"""
|
||||
signals: set[str] = set()
|
||||
try:
|
||||
for auto in database.get_automations():
|
||||
if auto.get('trigger_type') == 'signal_received':
|
||||
try:
|
||||
tc = json.loads(auto.get('trigger_config') or '{}')
|
||||
sig = tc.get('signal_name', '').strip()
|
||||
if sig:
|
||||
signals.add(sig)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
try:
|
||||
ta = json.loads(auto.get('then_actions') or '[]')
|
||||
for item in ta:
|
||||
if item.get('type') == 'fire_signal':
|
||||
sig = item.get('config', {}).get('signal_name', '').strip()
|
||||
if sig:
|
||||
signals.add(sig)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
return sorted(signals)
|
||||
|
|
@ -515,7 +515,17 @@ class AutomationEngine:
|
|||
|
||||
# Update run stats (no reschedule — event triggers don't use timers)
|
||||
last_result = json.dumps({k: v for k, v in merged.items() if not k.startswith('_')})
|
||||
error = result.get('error') if result.get('status') == 'error' else None
|
||||
# Surface every failure mode to last_error: handlers in this codebase use
|
||||
# 'error', 'reason', or 'message' interchangeably when returning gracefully.
|
||||
if result.get('status') == 'error':
|
||||
error = (
|
||||
result.get('error')
|
||||
or result.get('reason')
|
||||
or result.get('message')
|
||||
or 'Handler reported failure'
|
||||
)
|
||||
else:
|
||||
error = None
|
||||
self.db.update_automation_run(automation_id, error=error, last_result=last_result)
|
||||
|
||||
if self._history_record_fn:
|
||||
|
|
@ -609,6 +619,17 @@ class AutomationEngine:
|
|||
try:
|
||||
result = handler_info['handler'](action_config) or {}
|
||||
logger.info(f"Automation '{auto['name']}' (id={automation_id}) executed: {result.get('status', 'ok')}")
|
||||
# Handlers may signal failure by RETURNING {'status': 'error', ...} instead of
|
||||
# raising. Surface that to the DB so `last_error` reflects every failure mode,
|
||||
# not just uncaught exceptions. Falls back through ('error', 'reason', 'message')
|
||||
# because handlers in this codebase aren't consistent about which key they set.
|
||||
if result.get('status') == 'error':
|
||||
error = (
|
||||
result.get('error')
|
||||
or result.get('reason')
|
||||
or result.get('message')
|
||||
or 'Handler reported failure'
|
||||
)
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
result = {'status': 'error', 'error': error}
|
||||
|
|
|
|||
257
core/connection_detect.py
Normal file
257
core/connection_detect.py
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
"""Network detection — lifted from web_server.py.
|
||||
|
||||
Body is byte-identical to the original. Pure stdlib + requests, no
|
||||
web_server-specific globals or runtime state.
|
||||
"""
|
||||
import ipaddress
|
||||
import logging
|
||||
import platform
|
||||
import socket
|
||||
import subprocess
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_detection(server_type):
|
||||
"""
|
||||
Performs comprehensive network detection for a given server type (plex, jellyfin, slskd).
|
||||
This implements the same scanning logic as the GUI's detection threads.
|
||||
"""
|
||||
logger.info(f"Running comprehensive detection for {server_type}...")
|
||||
|
||||
def get_network_info():
|
||||
"""Get comprehensive network information with subnet detection"""
|
||||
try:
|
||||
# Get local IP using socket method
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
local_ip = s.getsockname()[0]
|
||||
s.close()
|
||||
|
||||
# Try to get actual subnet mask
|
||||
try:
|
||||
if platform.system() == "Windows":
|
||||
# Windows: Use netsh to get subnet info
|
||||
result = subprocess.run(['netsh', 'interface', 'ip', 'show', 'config'],
|
||||
capture_output=True, text=True, timeout=3)
|
||||
# Parse output for subnet mask (simplified)
|
||||
subnet_mask = "255.255.255.0" # Default fallback
|
||||
else:
|
||||
# Linux/Mac: Try to parse network interfaces
|
||||
result = subprocess.run(['ip', 'route', 'show'],
|
||||
capture_output=True, text=True, timeout=3)
|
||||
subnet_mask = "255.255.255.0" # Default fallback
|
||||
except:
|
||||
subnet_mask = "255.255.255.0" # Default /24
|
||||
|
||||
# Calculate network range
|
||||
network = ipaddress.IPv4Network(f"{local_ip}/{subnet_mask}", strict=False)
|
||||
return str(network.network_address), str(network.netmask), local_ip, network
|
||||
|
||||
except Exception as e:
|
||||
# Fallback to original method
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
local_ip = s.getsockname()[0]
|
||||
s.close()
|
||||
|
||||
# Default to /24 network
|
||||
network = ipaddress.IPv4Network(f"{local_ip}/24", strict=False)
|
||||
return str(network.network_address), "255.255.255.0", local_ip, network
|
||||
|
||||
def test_plex_server(ip, port=32400):
|
||||
"""Test if a Plex server is running at the given IP and port"""
|
||||
try:
|
||||
url = f"http://{ip}:{port}/web/index.html"
|
||||
response = requests.get(url, timeout=2, allow_redirects=True)
|
||||
|
||||
# Check for Plex-specific indicators
|
||||
if response.status_code == 200:
|
||||
# Check if it's actually Plex
|
||||
if 'plex' in response.text.lower() or 'X-Plex' in str(response.headers):
|
||||
return f"http://{ip}:{port}"
|
||||
|
||||
# Also try the API endpoint
|
||||
api_url = f"http://{ip}:{port}/identity"
|
||||
api_response = requests.get(api_url, timeout=1)
|
||||
if api_response.status_code == 200 and 'MediaContainer' in api_response.text:
|
||||
return f"http://{ip}:{port}"
|
||||
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
def test_jellyfin_server(ip, port=8096):
|
||||
"""Test if a Jellyfin server is running at the given IP and port"""
|
||||
try:
|
||||
# Try the system info endpoint first
|
||||
url = f"http://{ip}:{port}/System/Info"
|
||||
response = requests.get(url, timeout=2, allow_redirects=True)
|
||||
|
||||
if response.status_code == 200:
|
||||
# Check if response contains Jellyfin-specific content
|
||||
if 'jellyfin' in response.text.lower() or 'ServerName' in response.text:
|
||||
return f"http://{ip}:{port}"
|
||||
|
||||
# Also try the web interface
|
||||
web_url = f"http://{ip}:{port}/web/index.html"
|
||||
web_response = requests.get(web_url, timeout=1)
|
||||
if web_response.status_code == 200 and 'jellyfin' in web_response.text.lower():
|
||||
return f"http://{ip}:{port}"
|
||||
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
def test_slskd_server(ip, port=5030):
|
||||
"""Test if a slskd server is running at the given IP and port"""
|
||||
try:
|
||||
# slskd specific API endpoint
|
||||
url = f"http://{ip}:{port}/api/v0/session"
|
||||
response = requests.get(url, timeout=2)
|
||||
|
||||
# slskd returns 401 when not authenticated, which is still a valid response
|
||||
if response.status_code in [200, 401]:
|
||||
return f"http://{ip}:{port}"
|
||||
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
def test_navidrome_server(ip, port=4533):
|
||||
"""Test if a Navidrome server is running at the given IP and port"""
|
||||
try:
|
||||
# Try Navidrome's ping endpoint (part of Subsonic API)
|
||||
url = f"http://{ip}:{port}/rest/ping"
|
||||
response = requests.get(url, timeout=2, params={
|
||||
'u': 'test', # Dummy username for ping test
|
||||
'v': '1.16.1', # API version
|
||||
'c': 'soulsync', # Client name
|
||||
'f': 'json' # Response format
|
||||
})
|
||||
|
||||
# Navidrome should respond even with invalid credentials for ping
|
||||
if response.status_code in [200, 401, 403]:
|
||||
try:
|
||||
data = response.json()
|
||||
# Check for Subsonic/Navidrome API response structure
|
||||
if 'subsonic-response' in data:
|
||||
return f"http://{ip}:{port}"
|
||||
except:
|
||||
pass
|
||||
|
||||
# Also try the web interface
|
||||
web_url = f"http://{ip}:{port}/"
|
||||
web_response = requests.get(web_url, timeout=2)
|
||||
if web_response.status_code == 200 and 'navidrome' in web_response.text.lower():
|
||||
return f"http://{ip}:{port}"
|
||||
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
try:
|
||||
network_addr, netmask, local_ip, network = get_network_info()
|
||||
|
||||
# Select the appropriate test function
|
||||
test_functions = {
|
||||
'plex': test_plex_server,
|
||||
'jellyfin': test_jellyfin_server,
|
||||
'navidrome': test_navidrome_server,
|
||||
'slskd': test_slskd_server
|
||||
}
|
||||
|
||||
test_func = test_functions.get(server_type)
|
||||
if not test_func:
|
||||
return None
|
||||
|
||||
# Priority 1: Test localhost first
|
||||
logger.debug(f"Testing localhost for {server_type}...")
|
||||
localhost_result = test_func("localhost")
|
||||
if localhost_result:
|
||||
logger.info(f"Found {server_type} at localhost!")
|
||||
return localhost_result
|
||||
|
||||
# Priority 1.5: In Docker, try Docker host IP
|
||||
import os
|
||||
if os.path.exists('/.dockerenv'):
|
||||
logger.info(f"Docker detected, testing Docker host for {server_type}...")
|
||||
try:
|
||||
# Try host.docker.internal (Windows/Mac)
|
||||
host_result = test_func("host.docker.internal")
|
||||
if host_result:
|
||||
logger.info(f"Found {server_type} at Docker host!")
|
||||
return host_result.replace("host.docker.internal", "localhost") # Convert back to localhost for config
|
||||
|
||||
# Try Docker bridge gateway (Linux)
|
||||
gateway_result = test_func("172.17.0.1")
|
||||
if gateway_result:
|
||||
logger.info(f"Found {server_type} at Docker gateway!")
|
||||
return gateway_result.replace("172.17.0.1", "localhost") # Convert back to localhost for config
|
||||
except Exception as e:
|
||||
logger.error(f"Docker host detection failed: {e}")
|
||||
|
||||
# Priority 2: Test local IP
|
||||
logger.debug(f"Testing local IP {local_ip} for {server_type}...")
|
||||
local_result = test_func(local_ip)
|
||||
if local_result:
|
||||
logger.info(f"Found {server_type} at {local_ip}!")
|
||||
return local_result
|
||||
|
||||
# Priority 3: Test common IPs (router gateway, etc.)
|
||||
common_ips = [
|
||||
local_ip.rsplit('.', 1)[0] + '.1', # Typical gateway
|
||||
local_ip.rsplit('.', 1)[0] + '.2', # Alternative gateway
|
||||
local_ip.rsplit('.', 1)[0] + '.100', # Common static IP
|
||||
]
|
||||
|
||||
logger.debug(f"Testing common IPs for {server_type}...")
|
||||
for ip in common_ips:
|
||||
logger.info(f" Checking {ip}...")
|
||||
result = test_func(ip)
|
||||
if result:
|
||||
logger.info(f"Found {server_type} at {ip}!")
|
||||
return result
|
||||
|
||||
# Priority 4: Scan the network range (limited to reasonable size)
|
||||
network_hosts = list(network.hosts())
|
||||
if len(network_hosts) > 50:
|
||||
# Limit scan to reasonable size for performance
|
||||
step = max(1, len(network_hosts) // 50)
|
||||
network_hosts = network_hosts[::step]
|
||||
|
||||
logger.debug(f"Scanning network range for {server_type} ({len(network_hosts)} hosts)...")
|
||||
|
||||
# Use ThreadPoolExecutor for concurrent scanning (limited for web context)
|
||||
with ThreadPoolExecutor(max_workers=5) as executor:
|
||||
# Submit all tasks
|
||||
future_to_ip = {executor.submit(test_func, str(ip)): str(ip)
|
||||
for ip in network_hosts}
|
||||
|
||||
try:
|
||||
for future in as_completed(future_to_ip):
|
||||
ip = future_to_ip[future]
|
||||
try:
|
||||
result = future.result()
|
||||
if result:
|
||||
logger.info(f"Found {server_type} at {ip}!")
|
||||
# Cancel all pending futures before returning
|
||||
for f in future_to_ip:
|
||||
if not f.done():
|
||||
f.cancel()
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error testing {ip}: {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"Error in concurrent scanning: {e}")
|
||||
|
||||
logger.warning(f"No {server_type} server found on network")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during {server_type} detection: {e}")
|
||||
return None
|
||||
398
core/connection_test.py
Normal file
398
core/connection_test.py
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
"""Service connection test — lifted from web_server.py.
|
||||
|
||||
The function body is byte-identical to the original. soulseek_client,
|
||||
qobuz_enrichment_worker, hydrabase_client, docker_resolve_url, and
|
||||
docker_resolve_path are injected at runtime because they live in
|
||||
web_server.py and are constructed there.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
|
||||
import requests
|
||||
|
||||
from config.settings import config_manager
|
||||
from core.jellyfin_client import JellyfinClient
|
||||
from core.metadata.registry import get_primary_source
|
||||
from core.plex_client import PlexClient
|
||||
from core.spotify_client import SpotifyClient
|
||||
from core.tidal_client import TidalClient
|
||||
from utils.async_helpers import run_async
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_metadata_fallback_source():
|
||||
"""Mirror of web_server._get_metadata_fallback_source — delegates to registry."""
|
||||
return get_primary_source()
|
||||
|
||||
|
||||
# Injected at runtime via init().
|
||||
soulseek_client = None
|
||||
qobuz_enrichment_worker = None
|
||||
hydrabase_client = None
|
||||
docker_resolve_url = None
|
||||
docker_resolve_path = None
|
||||
|
||||
|
||||
def init(
|
||||
soulseek_client_obj,
|
||||
qobuz_worker,
|
||||
hydrabase_client_obj,
|
||||
docker_resolve_url_fn,
|
||||
docker_resolve_path_fn,
|
||||
):
|
||||
"""Bind web_server-side helpers/globals so the lifted body can resolve them."""
|
||||
global soulseek_client, qobuz_enrichment_worker, hydrabase_client
|
||||
global docker_resolve_url, docker_resolve_path
|
||||
soulseek_client = soulseek_client_obj
|
||||
qobuz_enrichment_worker = qobuz_worker
|
||||
hydrabase_client = hydrabase_client_obj
|
||||
docker_resolve_url = docker_resolve_url_fn
|
||||
docker_resolve_path = docker_resolve_path_fn
|
||||
|
||||
|
||||
def run_service_test(service, test_config):
|
||||
"""
|
||||
Performs the actual connection test for a given service.
|
||||
This logic is adapted from your ServiceTestThread.
|
||||
It temporarily modifies the config, runs the test, then restores the config.
|
||||
"""
|
||||
original_config = {}
|
||||
try:
|
||||
# 1. Save original config for the specific service
|
||||
original_config = config_manager.get(service, {})
|
||||
|
||||
# 2. Temporarily set the new config for the test (with Docker URL resolution)
|
||||
for key, value in test_config.items():
|
||||
# Apply Docker URL resolution for URL/URI fields
|
||||
if isinstance(value, str) and ('url' in key.lower() or 'uri' in key.lower()):
|
||||
value = docker_resolve_url(value)
|
||||
config_manager.set(f"{service}.{key}", value)
|
||||
|
||||
# 3. Run the test with the temporary config
|
||||
if service == "spotify":
|
||||
temp_client = SpotifyClient()
|
||||
|
||||
# Check if Spotify credentials are configured
|
||||
spotify_config = config_manager.get('spotify', {})
|
||||
spotify_configured = bool(spotify_config.get('client_id') and spotify_config.get('client_secret'))
|
||||
|
||||
if temp_client.is_authenticated():
|
||||
# Determine which source is active
|
||||
if temp_client.is_spotify_authenticated():
|
||||
return True, "Spotify connection successful!"
|
||||
else:
|
||||
# Using fallback metadata source
|
||||
fb_src = _get_metadata_fallback_source()
|
||||
fallback_name = 'Deezer' if fb_src == 'deezer' else 'Discogs' if fb_src == 'discogs' else 'iTunes'
|
||||
if spotify_configured:
|
||||
return True, f"{fallback_name} connection successful! (Spotify configured but not authenticated)"
|
||||
else:
|
||||
return True, f"{fallback_name} connection successful! (Spotify not configured)"
|
||||
else:
|
||||
return False, "Music service authentication failed. Check credentials and complete OAuth flow in browser if prompted."
|
||||
elif service == "tidal":
|
||||
temp_client = TidalClient()
|
||||
if temp_client.is_authenticated():
|
||||
user_info = temp_client.get_user_info()
|
||||
username = user_info.get('display_name', 'Tidal User') if user_info else 'Tidal User'
|
||||
return True, f"Tidal connection successful! Connected as: {username}"
|
||||
else:
|
||||
return False, "Tidal authentication failed. Please use the 'Authenticate' button and complete the flow in your browser."
|
||||
elif service == "plex":
|
||||
temp_client = PlexClient()
|
||||
if temp_client.is_connected():
|
||||
return True, f"Successfully connected to Plex server: {temp_client.server.friendlyName}"
|
||||
else:
|
||||
return False, "Could not connect to Plex. Check URL and Token."
|
||||
elif service == "jellyfin":
|
||||
temp_client = JellyfinClient()
|
||||
if temp_client.is_connected():
|
||||
# FIX: Check if server_info exists before accessing it.
|
||||
server_name = "Unknown Server"
|
||||
if hasattr(temp_client, 'server_info') and temp_client.server_info:
|
||||
server_name = temp_client.server_info.get('ServerName', 'Unknown Server')
|
||||
return True, f"Successfully connected to Jellyfin server: {server_name}"
|
||||
else:
|
||||
return False, "Could not connect to Jellyfin. Check URL and API Key."
|
||||
elif service == "navidrome":
|
||||
# Test Navidrome connection using Subsonic API
|
||||
base_url = test_config.get('base_url', '')
|
||||
username = test_config.get('username', '')
|
||||
password = test_config.get('password', '')
|
||||
|
||||
if not all([base_url, username, password]):
|
||||
return False, "Missing Navidrome URL, username, or password."
|
||||
|
||||
try:
|
||||
import hashlib
|
||||
import random
|
||||
import string
|
||||
|
||||
# Generate salt and token for Subsonic API authentication
|
||||
salt = ''.join(random.choices(string.ascii_letters + string.digits, k=6))
|
||||
token = hashlib.md5((password + salt).encode()).hexdigest()
|
||||
|
||||
# Test ping endpoint
|
||||
url = f"{base_url.rstrip('/')}/rest/ping"
|
||||
response = requests.get(url, params={
|
||||
'u': username,
|
||||
't': token,
|
||||
's': salt,
|
||||
'v': '1.16.1',
|
||||
'c': 'soulsync',
|
||||
'f': 'json'
|
||||
}, timeout=5)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('subsonic-response', {}).get('status') == 'ok':
|
||||
server_version = data.get('subsonic-response', {}).get('version', 'Unknown')
|
||||
return True, f"Successfully connected to Navidrome server (v{server_version})"
|
||||
else:
|
||||
error = data.get('subsonic-response', {}).get('error', {})
|
||||
return False, f"Navidrome authentication failed: {error.get('message', 'Unknown error')}"
|
||||
else:
|
||||
return False, f"Could not connect to Navidrome server (HTTP {response.status_code})"
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Navidrome connection error: {str(e)}"
|
||||
elif service == "soulsync":
|
||||
transfer_path = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer'))
|
||||
if os.path.isdir(transfer_path):
|
||||
# Quick check — count a few audio files to confirm it's a music folder
|
||||
audio_exts = {'.mp3', '.flac', '.ogg', '.opus', '.m4a', '.aac', '.wav'}
|
||||
count = 0
|
||||
found_enough = False
|
||||
for _root, _dirs, files in os.walk(transfer_path):
|
||||
for f in files:
|
||||
if os.path.splitext(f)[1].lower() in audio_exts:
|
||||
count += 1
|
||||
if count >= 10:
|
||||
found_enough = True
|
||||
break
|
||||
if found_enough:
|
||||
break
|
||||
return True, f"SoulSync standalone ready! Output folder: {transfer_path}" + (f" ({count}+ audio files)" if count > 0 else " (empty)")
|
||||
else:
|
||||
return False, f"Output folder not found: {transfer_path}"
|
||||
elif service == "soulseek":
|
||||
if soulseek_client is None:
|
||||
return False, "Download orchestrator failed to initialize. Check server logs for startup errors."
|
||||
|
||||
# Test the orchestrator's configured download source (not just Soulseek)
|
||||
download_mode = config_manager.get('download_source.mode', 'hybrid')
|
||||
|
||||
if run_async(soulseek_client.check_connection()):
|
||||
# Success message based on active mode
|
||||
mode_messages = {
|
||||
'soulseek': "Successfully connected to Soulseek network via slskd.",
|
||||
'youtube': "YouTube download source ready.",
|
||||
'tidal': "Tidal download source ready.",
|
||||
'qobuz': "Qobuz download source ready.",
|
||||
'hifi': "HiFi download source ready.",
|
||||
'hybrid': "Download sources ready (Hybrid mode)."
|
||||
}
|
||||
message = mode_messages.get(download_mode, "Download source connected.")
|
||||
return True, message
|
||||
else:
|
||||
# Failure message based on active mode
|
||||
mode_errors = {
|
||||
'soulseek': "slskd is not connected to the Soulseek network. Check slskd status and credentials.",
|
||||
'youtube': "YouTube download source not available.",
|
||||
'tidal': "Tidal download source not available. Check authentication.",
|
||||
'qobuz': "Qobuz download source not available. Check authentication.",
|
||||
'hifi': "HiFi download source not available. Public API instances may be down.",
|
||||
'hybrid': "Could not connect to download sources. Check configuration."
|
||||
}
|
||||
error = mode_errors.get(download_mode, "Download source connection failed.")
|
||||
return False, error
|
||||
elif service == "listenbrainz":
|
||||
token = test_config.get('token', '')
|
||||
|
||||
if not token:
|
||||
return False, "Missing ListenBrainz user token."
|
||||
|
||||
try:
|
||||
# Test ListenBrainz API by validating the token
|
||||
custom_base = test_config.get('base_url', '').rstrip('/')
|
||||
if custom_base:
|
||||
if not custom_base.endswith('/1'):
|
||||
custom_base += '/1'
|
||||
lb_api_base = custom_base
|
||||
else:
|
||||
lb_api_base = "https://api.listenbrainz.org/1"
|
||||
url = f"{lb_api_base}/validate-token"
|
||||
headers = {
|
||||
'Authorization': f'Token {token}'
|
||||
}
|
||||
response = requests.get(url, headers=headers, timeout=5)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('valid'):
|
||||
username = data.get('user_name', 'Unknown')
|
||||
return True, f"Successfully connected to ListenBrainz! Connected as: {username}"
|
||||
else:
|
||||
return False, "Invalid ListenBrainz token."
|
||||
elif response.status_code == 401:
|
||||
return False, "Invalid ListenBrainz token (unauthorized)."
|
||||
else:
|
||||
return False, f"Could not connect to ListenBrainz (HTTP {response.status_code})"
|
||||
|
||||
except Exception as e:
|
||||
return False, f"ListenBrainz connection error: {str(e)}"
|
||||
elif service == "acoustid":
|
||||
api_key = test_config.get('api_key', '')
|
||||
|
||||
if not api_key:
|
||||
return False, "Missing AcoustID API key."
|
||||
|
||||
try:
|
||||
from core.acoustid_client import AcoustIDClient, CHROMAPRINT_AVAILABLE, ACOUSTID_AVAILABLE, FPCALC_PATH
|
||||
|
||||
if not ACOUSTID_AVAILABLE:
|
||||
return False, "pyacoustid library not installed. Run: pip install pyacoustid"
|
||||
|
||||
client = AcoustIDClient()
|
||||
|
||||
# Override the cached API key with the test config key
|
||||
client._api_key = api_key
|
||||
|
||||
# Check chromaprint/fpcalc availability
|
||||
if CHROMAPRINT_AVAILABLE and FPCALC_PATH:
|
||||
fingerprint_status = f"fpcalc ready: {FPCALC_PATH}"
|
||||
elif CHROMAPRINT_AVAILABLE:
|
||||
fingerprint_status = "Fingerprint backend available"
|
||||
else:
|
||||
fingerprint_status = "fpcalc not found (will auto-download on first use)"
|
||||
|
||||
# Validate API key with test request
|
||||
success, message = client.test_api_key()
|
||||
if success:
|
||||
return True, f"AcoustID API key is valid! {fingerprint_status}"
|
||||
else:
|
||||
return False, f"{message}. {fingerprint_status}"
|
||||
except Exception as e:
|
||||
return False, f"AcoustID test error: {str(e)}"
|
||||
elif service == "lastfm":
|
||||
api_key = test_config.get('api_key', '')
|
||||
|
||||
if not api_key:
|
||||
return False, "Missing Last.fm API key."
|
||||
|
||||
try:
|
||||
from core.lastfm_client import LastFMClient
|
||||
client = LastFMClient(api_key=api_key)
|
||||
if client.validate_api_key():
|
||||
return True, "Successfully connected to Last.fm!"
|
||||
else:
|
||||
return False, "Invalid Last.fm API key."
|
||||
except Exception as e:
|
||||
return False, f"Last.fm connection error: {str(e)}"
|
||||
elif service == "genius":
|
||||
access_token = test_config.get('access_token', '')
|
||||
|
||||
if not access_token:
|
||||
return False, "Missing Genius access token."
|
||||
|
||||
try:
|
||||
from core.genius_client import GeniusClient
|
||||
client = GeniusClient(access_token=access_token)
|
||||
if client.validate_token():
|
||||
return True, "Successfully connected to Genius!"
|
||||
else:
|
||||
return False, "Invalid Genius access token."
|
||||
except Exception as e:
|
||||
return False, f"Genius connection error: {str(e)}"
|
||||
elif service == "lidarr" or service == "lidarr_download":
|
||||
url = config_manager.get('lidarr_download.url', '')
|
||||
api_key = config_manager.get('lidarr_download.api_key', '')
|
||||
if not url or not api_key:
|
||||
return False, "Lidarr URL and API key are required."
|
||||
try:
|
||||
import requests as _req
|
||||
resp = _req.get(f"{url.rstrip('/')}/api/v1/system/status",
|
||||
headers={'X-Api-Key': api_key}, timeout=10)
|
||||
if resp.ok:
|
||||
version = resp.json().get('version', '?')
|
||||
return True, f"Connected to Lidarr v{version}"
|
||||
return False, f"Lidarr returned HTTP {resp.status_code}"
|
||||
except Exception as e:
|
||||
return False, f"Lidarr connection error: {str(e)}"
|
||||
elif service == "itunes":
|
||||
# Public API — just confirm we can reach it with a cheap search
|
||||
try:
|
||||
storefront = config_manager.get('itunes.storefront', 'US') or 'US'
|
||||
resp = requests.get(
|
||||
'https://itunes.apple.com/search',
|
||||
params={'term': 'beatles', 'limit': 1, 'country': storefront, 'media': 'music'},
|
||||
timeout=5,
|
||||
)
|
||||
if resp.ok and resp.json().get('resultCount', 0) >= 0:
|
||||
return True, f"iTunes Search API reachable (storefront: {storefront})"
|
||||
return False, f"iTunes returned HTTP {resp.status_code}"
|
||||
except Exception as e:
|
||||
return False, f"iTunes connection error: {str(e)}"
|
||||
elif service == "deezer":
|
||||
# Public API — anon search works without credentials
|
||||
try:
|
||||
resp = requests.get(
|
||||
'https://api.deezer.com/search/artist',
|
||||
params={'q': 'beatles', 'limit': 1},
|
||||
timeout=5,
|
||||
)
|
||||
if resp.ok and isinstance(resp.json(), dict):
|
||||
return True, "Deezer Public API reachable"
|
||||
return False, f"Deezer returned HTTP {resp.status_code}"
|
||||
except Exception as e:
|
||||
return False, f"Deezer connection error: {str(e)}"
|
||||
elif service == "discogs":
|
||||
token = test_config.get('token', '') or config_manager.get('discogs.token', '')
|
||||
if not token:
|
||||
return False, "Missing Discogs personal token."
|
||||
try:
|
||||
resp = requests.get(
|
||||
'https://api.discogs.com/database/search',
|
||||
params={'q': 'beatles', 'per_page': 1},
|
||||
headers={'Authorization': f'Discogs token={token}', 'User-Agent': 'SoulSync/1.0'},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.ok:
|
||||
return True, "Discogs API reachable with provided token"
|
||||
if resp.status_code == 401:
|
||||
return False, "Discogs token rejected (HTTP 401)"
|
||||
return False, f"Discogs returned HTTP {resp.status_code}"
|
||||
except Exception as e:
|
||||
return False, f"Discogs connection error: {str(e)}"
|
||||
elif service == "qobuz":
|
||||
try:
|
||||
if qobuz_enrichment_worker and qobuz_enrichment_worker.client and qobuz_enrichment_worker.client.is_authenticated():
|
||||
return True, "Qobuz client authenticated"
|
||||
return False, "Qobuz not authenticated. Provide email/password or user auth token."
|
||||
except Exception as e:
|
||||
return False, f"Qobuz connection error: {str(e)}"
|
||||
elif service == "hydrabase":
|
||||
try:
|
||||
if hydrabase_client and hydrabase_client.is_connected():
|
||||
return True, "Hydrabase connected"
|
||||
return False, "Hydrabase not connected. Configure URL + API key and click Connect."
|
||||
except Exception as e:
|
||||
return False, f"Hydrabase connection error: {str(e)}"
|
||||
return False, "Unknown service."
|
||||
except AttributeError as e:
|
||||
# This specifically catches the error you reported for Jellyfin
|
||||
if "'JellyfinClient' object has no attribute 'server_info'" in str(e):
|
||||
return False, "Connection failed. Please check your Jellyfin URL and API Key."
|
||||
else:
|
||||
return False, f"An unexpected error occurred: {e}"
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False, str(e)
|
||||
finally:
|
||||
# 4. CRITICAL: Restore the original config
|
||||
if original_config:
|
||||
for key, value in original_config.items():
|
||||
config_manager.set(f"{service}.{key}", value)
|
||||
logger.debug(f"Restored original config for '{service}' after test.")
|
||||
377
core/debug_info.py
Normal file
377
core/debug_info.py
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
"""Debug info endpoint — lifted from web_server.py.
|
||||
|
||||
The function bodies are byte-identical to the originals. Module-level
|
||||
shims for ``spotify_client`` and ``tidal_client`` (proxies that resolve
|
||||
through the metadata registry / runtime client registry) plus injected
|
||||
state dicts and helpers let the bodies resolve their original names
|
||||
without modification.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
from flask import jsonify, request
|
||||
|
||||
from config.settings import config_manager
|
||||
from core.metadata.registry import get_spotify_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _SpotifyClientProxy:
|
||||
"""Resolves the global Spotify client lazily through core.metadata.registry."""
|
||||
|
||||
def __getattr__(self, name):
|
||||
client = get_spotify_client()
|
||||
if client is None:
|
||||
raise AttributeError(name)
|
||||
return getattr(client, name)
|
||||
|
||||
def __bool__(self):
|
||||
return get_spotify_client() is not None
|
||||
|
||||
|
||||
class _TidalClientProxy:
|
||||
"""Resolves the global Tidal client lazily via an injected getter so a
|
||||
Tidal re-auth that rebinds web_server.tidal_client is visible here."""
|
||||
|
||||
def __getattr__(self, name):
|
||||
if _get_tidal_client is None:
|
||||
raise AttributeError(name)
|
||||
client = _get_tidal_client()
|
||||
if client is None:
|
||||
raise AttributeError(name)
|
||||
return getattr(client, name)
|
||||
|
||||
def __bool__(self):
|
||||
if _get_tidal_client is None:
|
||||
return False
|
||||
return _get_tidal_client() is not None
|
||||
|
||||
|
||||
spotify_client = _SpotifyClientProxy()
|
||||
tidal_client = _TidalClientProxy()
|
||||
_get_tidal_client = None # injected via init()
|
||||
|
||||
|
||||
# Injected at runtime via init().
|
||||
SOULSYNC_VERSION = None
|
||||
_DIRECT_RUN = None
|
||||
_status_cache = None
|
||||
qobuz_enrichment_worker = None
|
||||
download_batches = None
|
||||
sync_states = None
|
||||
youtube_playlist_states = None
|
||||
tidal_discovery_states = None
|
||||
soulseek_client = None
|
||||
_log_path = None
|
||||
_log_dir = None
|
||||
app = None
|
||||
get_database = None
|
||||
|
||||
|
||||
def init(
|
||||
soulsync_version,
|
||||
direct_run,
|
||||
status_cache,
|
||||
qobuz_worker,
|
||||
download_batches_dict,
|
||||
sync_states_dict,
|
||||
youtube_playlist_states_dict,
|
||||
tidal_discovery_states_dict,
|
||||
soulseek_client_obj,
|
||||
log_path,
|
||||
log_dir,
|
||||
flask_app,
|
||||
get_database_fn,
|
||||
tidal_client_getter,
|
||||
):
|
||||
"""Bind shared state/helpers from web_server."""
|
||||
global SOULSYNC_VERSION, _DIRECT_RUN, _status_cache, qobuz_enrichment_worker
|
||||
global download_batches, sync_states, youtube_playlist_states
|
||||
global tidal_discovery_states, soulseek_client, _log_path, _log_dir
|
||||
global app, get_database, _get_tidal_client
|
||||
SOULSYNC_VERSION = soulsync_version
|
||||
_DIRECT_RUN = direct_run
|
||||
_status_cache = status_cache
|
||||
qobuz_enrichment_worker = qobuz_worker
|
||||
download_batches = download_batches_dict
|
||||
sync_states = sync_states_dict
|
||||
youtube_playlist_states = youtube_playlist_states_dict
|
||||
tidal_discovery_states = tidal_discovery_states_dict
|
||||
soulseek_client = soulseek_client_obj
|
||||
_log_path = log_path
|
||||
_log_dir = log_dir
|
||||
app = flask_app
|
||||
get_database = get_database_fn
|
||||
_get_tidal_client = tidal_client_getter
|
||||
|
||||
|
||||
def _safe_check(fn, default=False):
|
||||
"""Safely evaluate a check function, returning default on any error."""
|
||||
try:
|
||||
return fn()
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def get_debug_info():
|
||||
"""Collect system diagnostics for troubleshooting support requests."""
|
||||
import sys
|
||||
import psutil
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
log_lines = request.args.get('lines', 20, type=int)
|
||||
log_lines = max(10, min(log_lines, 500))
|
||||
log_source = request.args.get('log', 'app')
|
||||
|
||||
info = {}
|
||||
|
||||
# App info
|
||||
info['version'] = SOULSYNC_VERSION
|
||||
info['os'] = f"{platform.system()} {platform.release()}"
|
||||
info['python'] = sys.version.split()[0]
|
||||
info['docker'] = os.path.exists('/.dockerenv')
|
||||
info['runner'] = 'gunicorn' if not _DIRECT_RUN else 'direct (python web_server.py)'
|
||||
|
||||
# ffmpeg version
|
||||
try:
|
||||
import subprocess
|
||||
result = subprocess.run(['ffmpeg', '-version'], capture_output=True, text=True, timeout=5)
|
||||
first_line = result.stdout.split('\n')[0] if result.stdout else ''
|
||||
# e.g. "ffmpeg version 6.1.1 Copyright ..."
|
||||
info['ffmpeg'] = first_line.split('Copyright')[0].replace('ffmpeg version', '').strip() if first_line else 'installed (version unknown)'
|
||||
except FileNotFoundError:
|
||||
info['ffmpeg'] = 'NOT INSTALLED'
|
||||
except Exception:
|
||||
info['ffmpeg'] = 'unknown'
|
||||
|
||||
# Uptime
|
||||
start_time = getattr(app, 'start_time', time.time())
|
||||
uptime_seconds = time.time() - start_time
|
||||
info['uptime'] = str(timedelta(seconds=int(uptime_seconds)))
|
||||
|
||||
# Paths
|
||||
download_path = config_manager.get('soulseek.download_path', './downloads')
|
||||
transfer_folder = config_manager.get('soulseek.transfer_path', './Transfer')
|
||||
staging_folder = config_manager.get('import.staging_path', '')
|
||||
info['paths'] = {
|
||||
'download_path': download_path,
|
||||
'download_path_exists': os.path.isdir(download_path) if download_path else False,
|
||||
'download_path_writable': os.access(download_path, os.W_OK) if download_path and os.path.isdir(download_path) else False,
|
||||
'transfer_folder': transfer_folder,
|
||||
'transfer_folder_exists': os.path.isdir(transfer_folder) if transfer_folder else False,
|
||||
'transfer_folder_writable': os.access(transfer_folder, os.W_OK) if transfer_folder and os.path.isdir(transfer_folder) else False,
|
||||
'staging_folder': staging_folder,
|
||||
'staging_folder_exists': os.path.isdir(staging_folder) if staging_folder else False,
|
||||
}
|
||||
# Music library paths (Settings > Library)
|
||||
music_paths = config_manager.get('library.music_paths', [])
|
||||
if isinstance(music_paths, list) and music_paths:
|
||||
info['paths']['music_library_paths'] = []
|
||||
for p in music_paths:
|
||||
if p and isinstance(p, str):
|
||||
info['paths']['music_library_paths'].append({
|
||||
'path': p,
|
||||
'exists': os.path.isdir(p),
|
||||
})
|
||||
# Music videos directory
|
||||
music_videos_path = config_manager.get('library.music_videos_path', '')
|
||||
if music_videos_path:
|
||||
info['paths']['music_videos_path'] = music_videos_path
|
||||
info['paths']['music_videos_path_exists'] = os.path.isdir(music_videos_path)
|
||||
|
||||
# Services from status cache
|
||||
spotify_cache = _status_cache.get('spotify', {})
|
||||
media_server_cache = _status_cache.get('media_server', {})
|
||||
soulseek_cache = _status_cache.get('soulseek', {})
|
||||
info['services'] = {
|
||||
'music_source': spotify_cache.get('source', 'unknown'),
|
||||
'spotify_connected': spotify_cache.get('connected', False),
|
||||
'spotify_rate_limited': spotify_cache.get('rate_limited', False),
|
||||
'media_server_type': media_server_cache.get('type', 'none'),
|
||||
'media_server_connected': media_server_cache.get('connected', False),
|
||||
'soulseek_connected': soulseek_cache.get('connected', False),
|
||||
'download_source': config_manager.get('download_source.mode', 'hybrid'),
|
||||
'tidal_connected': _safe_check(lambda: bool(tidal_client and tidal_client.is_authenticated())),
|
||||
'qobuz_connected': _safe_check(lambda: bool(qobuz_enrichment_worker and qobuz_enrichment_worker.client and qobuz_enrichment_worker.client.is_authenticated())),
|
||||
}
|
||||
|
||||
# Enrichment workers
|
||||
workers = {}
|
||||
worker_names = ['musicbrainz', 'audiodb', 'deezer', 'spotify', 'itunes', 'lastfm', 'genius', 'discogs', 'tidal', 'qobuz']
|
||||
for name in worker_names:
|
||||
paused_key = f'{name}_enrichment_paused'
|
||||
workers[name] = 'paused' if config_manager.get(paused_key, False) else 'active'
|
||||
info['enrichment_workers'] = workers
|
||||
|
||||
# Library stats — use same method as dashboard (filters by active server)
|
||||
try:
|
||||
db = get_database()
|
||||
lib_stats = db.get_database_info_for_server()
|
||||
info['library'] = {
|
||||
'artists': lib_stats.get('artists', 0),
|
||||
'albums': lib_stats.get('albums', 0),
|
||||
'tracks': lib_stats.get('tracks', 0),
|
||||
}
|
||||
except Exception:
|
||||
info['library'] = {'artists': 0, 'albums': 0, 'tracks': 0}
|
||||
|
||||
# Watchlist count
|
||||
try:
|
||||
db = get_database()
|
||||
info['watchlist_count'] = db.get_watchlist_count()
|
||||
except Exception:
|
||||
info['watchlist_count'] = 0
|
||||
|
||||
# Wishlist pending count
|
||||
try:
|
||||
db = get_database()
|
||||
info['wishlist_count'] = db.get_wishlist_count()
|
||||
except Exception:
|
||||
info['wishlist_count'] = 0
|
||||
|
||||
# Automation count
|
||||
try:
|
||||
db = get_database()
|
||||
automations = db.get_automations()
|
||||
info['automations'] = {
|
||||
'total': len(automations),
|
||||
'enabled': len([a for a in automations if a.get('enabled', False)]),
|
||||
}
|
||||
except Exception:
|
||||
info['automations'] = {'total': 0, 'enabled': 0}
|
||||
|
||||
# Active downloads & syncs (use list() snapshots to avoid RuntimeError from concurrent mutation)
|
||||
try:
|
||||
active_downloads = len([bid for bid, bd in list(download_batches.items()) if bd.get('phase') == 'downloading'])
|
||||
except Exception:
|
||||
active_downloads = 0
|
||||
active_syncs = 0
|
||||
try:
|
||||
for _pid, ss in list(sync_states.items()):
|
||||
if ss.get('status') == 'syncing':
|
||||
active_syncs += 1
|
||||
for _uh, st in list(youtube_playlist_states.items()):
|
||||
if st.get('phase') == 'syncing':
|
||||
active_syncs += 1
|
||||
for _pid, st in list(tidal_discovery_states.items()):
|
||||
if st.get('phase') == 'syncing':
|
||||
active_syncs += 1
|
||||
except Exception:
|
||||
pass
|
||||
info['active_downloads'] = active_downloads
|
||||
info['active_syncs'] = active_syncs
|
||||
|
||||
# Config settings relevant to troubleshooting
|
||||
source_mode = config_manager.get('download_source.mode', 'hybrid')
|
||||
info['config'] = {
|
||||
'source_mode': source_mode,
|
||||
'quality_profile': config_manager.get('download_source.quality_profile', 'default'),
|
||||
'organization_template': config_manager.get('organization.folder_template', ''),
|
||||
'post_processing_enabled': config_manager.get('post_processing.enabled', True),
|
||||
'acoustid_enabled': bool(config_manager.get('acoustid.api_key', '')),
|
||||
'auto_scan_enabled': config_manager.get('watchlist.auto_scan', False),
|
||||
'm3u_export_enabled': config_manager.get('m3u.enabled', False),
|
||||
'log_level': config_manager.get('logging.level', 'INFO'),
|
||||
'primary_metadata_source': config_manager.get('metadata.fallback_source', 'deezer'),
|
||||
'lossy_copy_enabled': config_manager.get('post_processing.lossy_copy.enabled', False),
|
||||
'lossy_copy_format': config_manager.get('post_processing.lossy_copy.format', 'mp3'),
|
||||
'lossy_copy_bitrate': config_manager.get('post_processing.lossy_copy.bitrate', 320),
|
||||
'allow_duplicate_tracks': config_manager.get('library.allow_duplicate_tracks', False),
|
||||
'replace_lower_quality': config_manager.get('import.replace_lower_quality', False),
|
||||
'auto_import_enabled': config_manager.get('import.auto_import_enabled', False),
|
||||
}
|
||||
# Hybrid source priority order
|
||||
if source_mode == 'hybrid':
|
||||
info['config']['hybrid_sources'] = config_manager.get('download_source.hybrid_order', [])
|
||||
# Discogs connection status
|
||||
info['services']['discogs_connected'] = bool(config_manager.get('discogs.token', ''))
|
||||
|
||||
# Download client init failures
|
||||
info['download_client_failures'] = []
|
||||
if soulseek_client and hasattr(soulseek_client, '_init_failures'):
|
||||
info['download_client_failures'] = soulseek_client._init_failures
|
||||
elif not soulseek_client:
|
||||
info['download_client_failures'] = ['ALL (orchestrator failed to initialize)']
|
||||
|
||||
# API rate monitor — current calls/min, 24h totals, peaks, rate limit events
|
||||
try:
|
||||
from core.api_call_tracker import api_call_tracker
|
||||
rates = api_call_tracker.get_all_rates()
|
||||
info['api_rates'] = rates
|
||||
# Rich 24h debug summary with peaks, totals, per-endpoint breakdown, events
|
||||
info['api_debug_summary'] = api_call_tracker.get_debug_summary()
|
||||
# Spotify rate limit details
|
||||
if spotify_client:
|
||||
rl_info = spotify_client.get_rate_limit_info()
|
||||
if rl_info:
|
||||
info['spotify_rate_limit'] = {
|
||||
'active': True,
|
||||
'remaining_seconds': rl_info.get('remaining_seconds', 0),
|
||||
'retry_after': rl_info.get('retry_after', 0),
|
||||
'endpoint': rl_info.get('endpoint', ''),
|
||||
'expires_at': rl_info.get('expires_at', ''),
|
||||
}
|
||||
else:
|
||||
info['spotify_rate_limit'] = {'active': False}
|
||||
except Exception:
|
||||
info['api_rates'] = {}
|
||||
info['api_debug_summary'] = {}
|
||||
info['spotify_rate_limit'] = {'active': False}
|
||||
|
||||
# Database size
|
||||
db_path = os.path.join('database', 'music_library.db')
|
||||
if os.path.exists(db_path):
|
||||
db_size_mb = os.path.getsize(db_path) / (1024 * 1024)
|
||||
info['database_size'] = f"{db_size_mb:.1f} MB"
|
||||
else:
|
||||
info['database_size'] = 'not found'
|
||||
|
||||
# Memory & CPU
|
||||
process = psutil.Process(os.getpid())
|
||||
mem = process.memory_info()
|
||||
info['memory_usage'] = f"{mem.rss / (1024 * 1024):.0f} MB"
|
||||
info['system_memory'] = f"{psutil.virtual_memory().percent}%"
|
||||
try:
|
||||
info['cpu_percent'] = f"{process.cpu_percent(interval=0.1):.1f}%"
|
||||
except Exception:
|
||||
info['cpu_percent'] = 'unknown'
|
||||
info['thread_count'] = process.num_threads()
|
||||
|
||||
# Log lines
|
||||
log_map = {
|
||||
'app': Path(_log_path),
|
||||
'acoustid': _log_dir / 'acoustid.log',
|
||||
'post_processing': _log_dir / 'post_processing.log',
|
||||
'source_reuse': _log_dir / 'source_reuse.log',
|
||||
}
|
||||
log_path = log_map.get(log_source, log_map['app'])
|
||||
info['log_source'] = log_source
|
||||
info['log_lines_requested'] = log_lines
|
||||
info['recent_logs'] = []
|
||||
if os.path.exists(log_path):
|
||||
try:
|
||||
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
lines = f.readlines()
|
||||
info['recent_logs'] = [line.rstrip() for line in lines[-log_lines:]]
|
||||
except Exception:
|
||||
info['recent_logs'] = ['(could not read log file)']
|
||||
|
||||
# Available log files
|
||||
info['available_logs'] = []
|
||||
logs_dir = 'logs'
|
||||
if os.path.isdir(logs_dir):
|
||||
for fname in sorted(os.listdir(logs_dir)):
|
||||
if fname.endswith('.log'):
|
||||
fpath = os.path.join(logs_dir, fname)
|
||||
size_kb = os.path.getsize(fpath) / 1024
|
||||
info['available_logs'].append({
|
||||
'name': fname.replace('.log', ''),
|
||||
'file': fname,
|
||||
'size': f"{size_kb:.0f} KB" if size_kb < 1024 else f"{size_kb/1024:.1f} MB",
|
||||
})
|
||||
|
||||
return jsonify(info)
|
||||
|
|
@ -6,7 +6,7 @@ from typing import Dict, List, Optional, Any
|
|||
from functools import wraps
|
||||
from dataclasses import dataclass
|
||||
from utils.logging_config import get_logger
|
||||
from core.metadata_cache import get_metadata_cache
|
||||
from core.metadata.cache import get_metadata_cache
|
||||
|
||||
logger = get_logger("deezer_client")
|
||||
|
||||
|
|
|
|||
|
|
@ -411,7 +411,7 @@ class DeezerDownloadClient:
|
|||
album_ids.add(str(aid))
|
||||
album_release_dates = {}
|
||||
try:
|
||||
from core.metadata_cache import get_metadata_cache
|
||||
from core.metadata.cache import get_metadata_cache
|
||||
cache = get_metadata_cache()
|
||||
except Exception:
|
||||
cache = None
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import re
|
|||
import time
|
||||
import threading
|
||||
import requests
|
||||
from core.metadata_cache import get_metadata_cache
|
||||
from core.metadata.cache import get_metadata_cache
|
||||
from typing import List, Dict, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
from functools import wraps
|
||||
|
|
|
|||
0
core/discovery/__init__.py
Normal file
0
core/discovery/__init__.py
Normal file
388
core/discovery/beatport.py
Normal file
388
core/discovery/beatport.py
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
"""Background worker for Beatport chart discovery.
|
||||
|
||||
`run_beatport_discovery_worker(url_hash, deps)` is the function the
|
||||
beatport discovery start-endpoint submits to its executor to match each
|
||||
Beatport chart track against Spotify (preferred) or iTunes (fallback):
|
||||
|
||||
1. Pause enrichment workers (release shared resources).
|
||||
2. For each Beatport track:
|
||||
- Cancellation gate (state['phase'] != 'discovering').
|
||||
- Clean Beatport text (artist/title) of common annotations.
|
||||
- Discovery cache lookup; cache hit short-circuits the search and
|
||||
normalizes cached artists from ['str'] → [{'name': 'str'}].
|
||||
- matching_engine search-query generation, with high min_confidence
|
||||
(0.9) to avoid bad matches.
|
||||
- Strategy 1: scored candidates from initial Spotify/iTunes searches.
|
||||
- Strategy 4: extended search with limit=50 if no high-confidence
|
||||
match found.
|
||||
- On Spotify match: format artists as [{'name': str}] objects, pull
|
||||
full album object from raw cache when available.
|
||||
- On iTunes match: format with image_url-derived album.images entry.
|
||||
- Save matched result to discovery cache when confidence >= 0.75.
|
||||
- On miss: Wing It stub stored as 'wing-it' status (success ticked).
|
||||
3. After all tracks: phase='discovered', activity feed entry, sync
|
||||
discovery results back to mirrored playlist via
|
||||
`_sync_discovery_results_to_mirrored`.
|
||||
4. On error: state['phase']='fresh' + status='error'.
|
||||
5. Finally: resume enrichment workers.
|
||||
|
||||
Lifted verbatim from web_server.py. Wide dependency surface (Spotify
|
||||
and iTunes clients, matching engine, multiple discovery helpers, state
|
||||
dict, mirrored sync) all injected via `BeatportDiscoveryDeps`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BeatportDiscoveryDeps:
|
||||
"""Bundle of cross-cutting deps the Beatport discovery worker needs."""
|
||||
beatport_chart_states: dict
|
||||
spotify_client: Any
|
||||
matching_engine: Any
|
||||
pause_enrichment_workers: Callable[[str], dict]
|
||||
resume_enrichment_workers: Callable[[dict, str], None]
|
||||
get_active_discovery_source: Callable[[], str]
|
||||
get_metadata_fallback_client: Callable[[], Any]
|
||||
clean_beatport_text: Callable[[str], str]
|
||||
get_discovery_cache_key: Callable
|
||||
get_database: Callable[[], Any]
|
||||
validate_discovery_cache_artist: Callable
|
||||
spotify_rate_limited: Callable[[], bool]
|
||||
discovery_score_candidates: Callable
|
||||
get_metadata_cache: Callable[[], Any]
|
||||
build_discovery_wing_it_stub: Callable
|
||||
add_activity_item: Callable
|
||||
sync_discovery_results_to_mirrored: Callable
|
||||
|
||||
|
||||
def run_beatport_discovery_worker(url_hash, deps: BeatportDiscoveryDeps):
|
||||
"""Background worker for Beatport discovery process (Spotify preferred, iTunes fallback)"""
|
||||
_ew_state = {}
|
||||
try:
|
||||
_ew_state = deps.pause_enrichment_workers('Beatport discovery')
|
||||
state = deps.beatport_chart_states[url_hash]
|
||||
chart = state['chart']
|
||||
tracks = chart['tracks']
|
||||
|
||||
# Determine which provider to use
|
||||
discovery_source = deps.get_active_discovery_source()
|
||||
use_spotify = (discovery_source == 'spotify') and deps.spotify_client and deps.spotify_client.is_spotify_authenticated()
|
||||
|
||||
# Initialize fallback client if needed
|
||||
itunes_client_instance = None
|
||||
if not use_spotify:
|
||||
itunes_client_instance = deps.get_metadata_fallback_client()
|
||||
|
||||
logger.info(f"Starting {discovery_source.upper()} discovery for {len(tracks)} Beatport tracks...")
|
||||
|
||||
# Store discovery source in state for frontend
|
||||
state['discovery_source'] = discovery_source
|
||||
|
||||
# Process each track for discovery
|
||||
for i, track in enumerate(tracks):
|
||||
try:
|
||||
# Check for cancellation
|
||||
if state.get('phase') != 'discovering':
|
||||
logger.warning(f"Beatport discovery cancelled (phase changed to '{state.get('phase')}')")
|
||||
return
|
||||
|
||||
# Update progress
|
||||
state['discovery_progress'] = int((i / len(tracks)) * 100)
|
||||
|
||||
# Get track info from Beatport data (frontend sends 'name' and 'artists' fields)
|
||||
track_title = deps.clean_beatport_text(track.get('name', 'Unknown Title'))
|
||||
track_artists = track.get('artists', ['Unknown Artist'])
|
||||
# Handle artists - could be a list or string
|
||||
if isinstance(track_artists, list):
|
||||
if len(track_artists) > 0 and isinstance(track_artists[0], str):
|
||||
# Handle case like ["CID,Taylr Renee"] - split on comma and clean
|
||||
track_artist = deps.clean_beatport_text(track_artists[0].split(',')[0].strip())
|
||||
else:
|
||||
track_artist = deps.clean_beatport_text(track_artists[0] if track_artists else 'Unknown Artist')
|
||||
else:
|
||||
track_artist = deps.clean_beatport_text(str(track_artists))
|
||||
|
||||
logger.debug(f"Searching {discovery_source.upper()} for: '{track_artist}' - '{track_title}'")
|
||||
|
||||
# Check discovery cache first
|
||||
cache_key = deps.get_discovery_cache_key(track_title, track_artist)
|
||||
try:
|
||||
cache_db = deps.get_database()
|
||||
cached_match = cache_db.get_discovery_cache_match(cache_key[0], cache_key[1], discovery_source)
|
||||
if cached_match and deps.validate_discovery_cache_artist(track_artist, cached_match):
|
||||
logger.debug(f"CACHE HIT [{i+1}/{len(tracks)}]: {track_artist} - {track_title}")
|
||||
# Convert artists from ['str'] to [{'name': 'str'}] for Beatport frontend format
|
||||
beatport_artists = cached_match.get('artists', [])
|
||||
if beatport_artists and isinstance(beatport_artists[0], str):
|
||||
cached_match['artists'] = [{'name': a} for a in beatport_artists]
|
||||
result_entry = {
|
||||
'index': i,
|
||||
'beatport_track': {
|
||||
'title': track_title,
|
||||
'artist': track_artist
|
||||
},
|
||||
'status': 'found',
|
||||
'status_class': 'found',
|
||||
'discovery_source': discovery_source,
|
||||
'spotify_data': cached_match
|
||||
}
|
||||
state['spotify_matches'] += 1
|
||||
state['discovery_results'].append(result_entry)
|
||||
continue
|
||||
except Exception as cache_err:
|
||||
logger.error(f"Cache lookup error: {cache_err}")
|
||||
|
||||
# Use matching engine for track matching
|
||||
found_track = None
|
||||
best_confidence = 0.0
|
||||
best_raw_track = None
|
||||
min_confidence = 0.9 # Higher threshold for Beatport to avoid bad matches
|
||||
|
||||
# Generate search queries using matching engine (with fallback)
|
||||
try:
|
||||
temp_track = type('TempTrack', (), {
|
||||
'name': track_title,
|
||||
'artists': [track_artist],
|
||||
'album': None
|
||||
})()
|
||||
search_queries = deps.matching_engine.generate_download_queries(temp_track)
|
||||
logger.debug(f"Generated {len(search_queries)} search queries using matching engine")
|
||||
except Exception as e:
|
||||
logger.error(f"Matching engine failed for Beatport, falling back to basic queries: {e}")
|
||||
if use_spotify:
|
||||
search_queries = [
|
||||
f"{track_artist} {track_title}",
|
||||
f'artist:"{track_artist}" track:"{track_title}"',
|
||||
f'"{track_artist}" "{track_title}"'
|
||||
]
|
||||
else:
|
||||
search_queries = [
|
||||
f"{track_artist} {track_title}",
|
||||
f"{track_title} {track_artist}",
|
||||
track_title
|
||||
]
|
||||
|
||||
for query_idx, search_query in enumerate(search_queries):
|
||||
try:
|
||||
logger.debug(f"Query {query_idx + 1}/{len(search_queries)}: {search_query} ({discovery_source.upper()})")
|
||||
|
||||
search_results = None
|
||||
|
||||
if use_spotify and not deps.spotify_rate_limited():
|
||||
search_results = deps.spotify_client.search_tracks(search_query, limit=10)
|
||||
else:
|
||||
search_results = itunes_client_instance.search_tracks(search_query, limit=10)
|
||||
|
||||
if not search_results:
|
||||
continue
|
||||
|
||||
# Score all results using the matching engine
|
||||
match, confidence, match_idx = deps.discovery_score_candidates(
|
||||
track_title, track_artist, 0, search_results
|
||||
)
|
||||
|
||||
if match and confidence > best_confidence and confidence >= min_confidence:
|
||||
best_confidence = confidence
|
||||
found_track = match
|
||||
if use_spotify and match.id:
|
||||
_cache = deps.get_metadata_cache()
|
||||
best_raw_track = _cache.get_entity('spotify', 'track', match.id)
|
||||
else:
|
||||
best_raw_track = None
|
||||
logger.debug(f"New best Beatport match: {match.artists[0]} - {match.name} (confidence: {confidence:.3f})")
|
||||
|
||||
if best_confidence >= 0.9:
|
||||
logger.debug(f"High confidence match found ({best_confidence:.3f}), stopping search")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error in {discovery_source.upper()} search for query '{search_query}': {e}")
|
||||
continue
|
||||
|
||||
# Strategy 4: Extended search with higher limit (last resort)
|
||||
if not found_track:
|
||||
logger.debug("Beatport Strategy 4: Extended search with limit=50")
|
||||
query = f"{track_artist} {track_title}"
|
||||
if use_spotify:
|
||||
extended_results = deps.spotify_client.search_tracks(query, limit=50)
|
||||
else:
|
||||
extended_results = itunes_client_instance.search_tracks(query, limit=50)
|
||||
if extended_results:
|
||||
match, confidence, _ = deps.discovery_score_candidates(
|
||||
track_title, track_artist, 0, extended_results
|
||||
)
|
||||
if match and confidence >= min_confidence:
|
||||
found_track = match
|
||||
best_confidence = confidence
|
||||
logger.debug(f"Strategy 4 Beatport match (extended): {match.artists[0]} - {match.name} (confidence: {confidence:.3f})")
|
||||
|
||||
if found_track:
|
||||
logger.info(f"Final Beatport match: {found_track.artists[0]} - {found_track.name} (confidence: {best_confidence:.3f})")
|
||||
else:
|
||||
logger.warning(f"No suitable match found (best confidence was {best_confidence:.3f}, required {min_confidence:.3f})")
|
||||
|
||||
# Create result entry
|
||||
result_entry = {
|
||||
'index': i, # Add index for frontend table row identification
|
||||
'beatport_track': {
|
||||
'title': track_title,
|
||||
'artist': track_artist
|
||||
},
|
||||
'status': 'found' if found_track else 'not_found',
|
||||
'status_class': 'found' if found_track else 'not-found',
|
||||
'discovery_source': discovery_source,
|
||||
'confidence': best_confidence
|
||||
}
|
||||
|
||||
if found_track:
|
||||
if use_spotify:
|
||||
# SPOTIFY result formatting
|
||||
# Debug: show available attributes
|
||||
logger.debug(f"Spotify track attributes: {dir(found_track)}")
|
||||
|
||||
# Format artists correctly for frontend compatibility
|
||||
formatted_artists = []
|
||||
if isinstance(found_track.artists, list):
|
||||
# If it's already a list of strings, convert to objects with 'name' property
|
||||
for artist in found_track.artists:
|
||||
if isinstance(artist, str):
|
||||
formatted_artists.append({'name': artist})
|
||||
else:
|
||||
# If it's already an object, use as-is
|
||||
formatted_artists.append(artist)
|
||||
else:
|
||||
# Single artist case
|
||||
formatted_artists = [{'name': str(found_track.artists)}]
|
||||
|
||||
# Use full album object from raw Spotify data if available
|
||||
album_data = best_raw_track.get('album', {}) if best_raw_track else {}
|
||||
if not album_data:
|
||||
# Fallback to string album name
|
||||
album_data = {'name': found_track.album, 'album_type': 'album', 'release_date': getattr(found_track, 'release_date', '') or '', 'images': []}
|
||||
|
||||
result_entry['spotify_data'] = {
|
||||
'name': found_track.name,
|
||||
'artists': formatted_artists, # Now formatted as list of objects with 'name' property
|
||||
'album': album_data, # Full album object with images
|
||||
'id': found_track.id,
|
||||
'source': 'spotify'
|
||||
}
|
||||
else:
|
||||
# ITUNES result formatting
|
||||
# Note: iTunes Track dataclass has 'artists' (list) and 'image_url', not 'artist' and 'artwork_url'
|
||||
result_artists = found_track.artists if hasattr(found_track, 'artists') else []
|
||||
result_artist = result_artists[0] if result_artists else 'Unknown'
|
||||
result_name = found_track.name if hasattr(found_track, 'name') else 'Unknown'
|
||||
album_name = found_track.album if hasattr(found_track, 'album') else 'Unknown Album'
|
||||
image_url = found_track.image_url if hasattr(found_track, 'image_url') else ''
|
||||
track_id = found_track.id if hasattr(found_track, 'id') else ''
|
||||
|
||||
# Format artists as list of objects for frontend compatibility
|
||||
formatted_artists = [{'name': result_artist}]
|
||||
|
||||
# Build album data with artwork
|
||||
album_data = {
|
||||
'name': album_name,
|
||||
'album_type': 'album',
|
||||
'release_date': getattr(found_track, 'release_date', '') or '',
|
||||
'images': [{'url': image_url, 'height': 300, 'width': 300}] if image_url else []
|
||||
}
|
||||
|
||||
result_entry['spotify_data'] = { # Use same key for frontend compatibility
|
||||
'name': result_name,
|
||||
'artists': formatted_artists,
|
||||
'album': album_data,
|
||||
'id': track_id,
|
||||
'source': discovery_source
|
||||
}
|
||||
|
||||
state['spotify_matches'] += 1
|
||||
|
||||
# Save to discovery cache (normalize artists from [{name:str}] to [str] for canonical format)
|
||||
if best_confidence >= 0.75:
|
||||
try:
|
||||
cache_data = dict(result_entry['spotify_data'])
|
||||
cache_artists = cache_data.get('artists', [])
|
||||
if cache_artists and isinstance(cache_artists[0], dict):
|
||||
cache_data['artists'] = [a.get('name', '') for a in cache_artists]
|
||||
# Extract image URL for discovery pool display
|
||||
if 'image_url' not in cache_data:
|
||||
_bp_album = cache_data.get('album', {})
|
||||
_bp_images = _bp_album.get('images', []) if isinstance(_bp_album, dict) else []
|
||||
cache_data['image_url'] = _bp_images[0].get('url', '') if _bp_images else ''
|
||||
cache_db = deps.get_database()
|
||||
cache_db.save_discovery_cache_match(
|
||||
cache_key[0], cache_key[1], discovery_source, best_confidence,
|
||||
cache_data, track_title, track_artist
|
||||
)
|
||||
logger.debug(f"CACHE SAVED: {track_artist} - {track_title} (confidence: {best_confidence:.3f})")
|
||||
except Exception as cache_err:
|
||||
logger.error(f"Cache save error: {cache_err}")
|
||||
|
||||
# Auto Wing It fallback for unmatched tracks
|
||||
if result_entry.get('status_class') == 'not-found':
|
||||
bp_t = result_entry.get('beatport_track', {})
|
||||
stub = deps.build_discovery_wing_it_stub(
|
||||
bp_t.get('title', ''),
|
||||
bp_t.get('artist', ''),
|
||||
)
|
||||
result_entry['status'] = 'found'
|
||||
result_entry['status_class'] = 'wing-it'
|
||||
result_entry['spotify_data'] = stub
|
||||
result_entry['match_data'] = stub
|
||||
result_entry['wing_it_fallback'] = True
|
||||
result_entry['confidence'] = 0
|
||||
state['spotify_matches'] = state.get('spotify_matches', 0) + 1
|
||||
state['wing_it_count'] = state.get('wing_it_count', 0) + 1
|
||||
|
||||
state['discovery_results'].append(result_entry)
|
||||
|
||||
# Small delay to avoid rate limiting
|
||||
time.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing Beatport track {i}: {e}")
|
||||
# Add error result
|
||||
state['discovery_results'].append({
|
||||
'index': i, # Add index for frontend table row identification
|
||||
'beatport_track': {
|
||||
'title': track.get('name', 'Unknown'), # Changed from 'title' to 'name' to match track structure
|
||||
'artist': track.get('artists', ['Unknown'])[0] if isinstance(track.get('artists'), list) else 'Unknown'
|
||||
},
|
||||
'status': 'error',
|
||||
'status_class': 'error', # Add status class for CSS styling
|
||||
'error': str(e),
|
||||
'discovery_source': discovery_source
|
||||
})
|
||||
|
||||
# Mark discovery as complete
|
||||
state['discovery_progress'] = 100
|
||||
state['phase'] = 'discovered'
|
||||
state['status'] = 'discovered'
|
||||
|
||||
# Add activity for completion
|
||||
chart_name = chart.get('name', 'Unknown Chart')
|
||||
source_label = discovery_source.upper()
|
||||
deps.add_activity_item("", f"Beatport Discovery Complete ({source_label})",
|
||||
f"'{chart_name}' - {state['spotify_matches']}/{len(tracks)} tracks found", "Now")
|
||||
|
||||
logger.info(f"Beatport discovery complete ({source_label}): {state['spotify_matches']}/{len(tracks)} tracks found")
|
||||
|
||||
# Sync discovery results back to mirrored playlist
|
||||
deps.sync_discovery_results_to_mirrored('beatport', url_hash, state.get('discovery_results', []), discovery_source, profile_id=state.get('_profile_id', 1))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in Beatport discovery worker: {e}")
|
||||
if url_hash in deps.beatport_chart_states:
|
||||
deps.beatport_chart_states[url_hash]['status'] = 'error'
|
||||
deps.beatport_chart_states[url_hash]['phase'] = 'fresh'
|
||||
finally:
|
||||
deps.resume_enrichment_workers(_ew_state, 'Beatport discovery')
|
||||
327
core/discovery/deezer.py
Normal file
327
core/discovery/deezer.py
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
"""Background worker for Deezer playlist discovery.
|
||||
|
||||
`run_deezer_discovery_worker(playlist_id, deps)` is the function the
|
||||
Deezer discovery start-endpoint submits to the deezer discovery executor
|
||||
to match each Deezer playlist track against Spotify (preferred) or iTunes
|
||||
(fallback):
|
||||
|
||||
1. Pause enrichment workers (release shared resources).
|
||||
2. For each Deezer track:
|
||||
- Cancellation gate (state['cancelled']).
|
||||
- Discovery cache lookup; cache hit short-circuits the search.
|
||||
- SimpleNamespace duck-type → `_search_spotify_for_tidal_track`
|
||||
(shared search helper, returns tuple for Spotify or dict for iTunes).
|
||||
- On match: build `match_data` (Spotify path preserves track_number /
|
||||
disc_number from raw API data, image extracted from album images).
|
||||
- Save to discovery cache.
|
||||
- On miss: Wing It stub created from raw Deezer track data.
|
||||
3. After all tracks: phase='discovered', activity feed entry.
|
||||
4. Sync discovery results back to mirrored playlist via
|
||||
`_sync_discovery_results_to_mirrored`.
|
||||
5. On error: state['phase']='error' + status with error string.
|
||||
6. Finally: resume enrichment workers.
|
||||
|
||||
Lifted verbatim from web_server.py. Wide dependency surface (Spotify and
|
||||
iTunes clients, multiple metadata helpers, state dict, mirrored sync,
|
||||
shared tidal search helper) all injected via `DeezerDiscoveryDeps`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
import types
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeezerDiscoveryDeps:
|
||||
"""Bundle of cross-cutting deps the Deezer discovery worker needs."""
|
||||
deezer_discovery_states: dict
|
||||
spotify_client: Any
|
||||
pause_enrichment_workers: Callable[[str], dict]
|
||||
resume_enrichment_workers: Callable[[dict, str], None]
|
||||
get_active_discovery_source: Callable[[], str]
|
||||
get_metadata_fallback_client: Callable[[], Any]
|
||||
get_discovery_cache_key: Callable
|
||||
get_database: Callable[[], Any]
|
||||
validate_discovery_cache_artist: Callable
|
||||
search_spotify_for_tidal_track: Callable
|
||||
build_discovery_wing_it_stub: Callable
|
||||
add_activity_item: Callable
|
||||
sync_discovery_results_to_mirrored: Callable
|
||||
|
||||
|
||||
def run_deezer_discovery_worker(playlist_id, deps: DeezerDiscoveryDeps):
|
||||
"""Background worker for Deezer discovery process (Spotify preferred, iTunes fallback)"""
|
||||
_ew_state = {}
|
||||
try:
|
||||
_ew_state = deps.pause_enrichment_workers('Deezer discovery')
|
||||
state = deps.deezer_discovery_states[playlist_id]
|
||||
playlist = state['playlist']
|
||||
|
||||
# Determine which provider to use
|
||||
discovery_source = deps.get_active_discovery_source()
|
||||
use_spotify = (discovery_source == 'spotify') and deps.spotify_client and deps.spotify_client.is_spotify_authenticated()
|
||||
|
||||
# Initialize fallback client if needed
|
||||
itunes_client_instance = None
|
||||
if not use_spotify:
|
||||
itunes_client_instance = deps.get_metadata_fallback_client()
|
||||
|
||||
logger.info(f"Starting Deezer discovery for: {playlist['name']} (using {discovery_source.upper()})")
|
||||
|
||||
# Store discovery source in state for frontend
|
||||
state['discovery_source'] = discovery_source
|
||||
|
||||
successful_discoveries = 0
|
||||
tracks = playlist['tracks']
|
||||
|
||||
for i, deezer_track in enumerate(tracks):
|
||||
if state.get('cancelled', False):
|
||||
break
|
||||
|
||||
try:
|
||||
track_name = deezer_track['name']
|
||||
track_artists = deezer_track['artists']
|
||||
track_id = deezer_track['id']
|
||||
track_album = deezer_track.get('album', '')
|
||||
track_duration_ms = deezer_track.get('duration_ms', 0)
|
||||
|
||||
logger.info(f"[{i+1}/{len(tracks)}] Searching {discovery_source.upper()}: {track_name} by {', '.join(track_artists)}")
|
||||
|
||||
# Check discovery cache first
|
||||
cache_key = deps.get_discovery_cache_key(track_name, track_artists[0] if track_artists else '')
|
||||
try:
|
||||
cache_db = deps.get_database()
|
||||
cached_match = cache_db.get_discovery_cache_match(cache_key[0], cache_key[1], discovery_source)
|
||||
if cached_match and deps.validate_discovery_cache_artist(track_artists[0] if track_artists else '', cached_match):
|
||||
logger.debug(f"CACHE HIT [{i+1}/{len(tracks)}]: {track_name} by {', '.join(track_artists)}")
|
||||
# Extract display-friendly artist string from cached match
|
||||
cached_artists = cached_match.get('artists', [])
|
||||
if cached_artists:
|
||||
cached_artist_str = ', '.join(
|
||||
a if isinstance(a, str) else a.get('name', '') for a in cached_artists
|
||||
)
|
||||
else:
|
||||
cached_artist_str = ''
|
||||
cached_album = cached_match.get('album', '')
|
||||
if isinstance(cached_album, dict):
|
||||
cached_album = cached_album.get('name', '')
|
||||
|
||||
result = {
|
||||
'deezer_track': {
|
||||
'id': track_id,
|
||||
'name': track_name,
|
||||
'artists': track_artists or [],
|
||||
'album': track_album,
|
||||
'duration_ms': track_duration_ms,
|
||||
},
|
||||
'spotify_data': cached_match,
|
||||
'match_data': cached_match,
|
||||
'status': 'Found',
|
||||
'status_class': 'found',
|
||||
'spotify_track': cached_match.get('name', ''),
|
||||
'spotify_artist': cached_artist_str,
|
||||
'spotify_album': cached_album,
|
||||
'spotify_id': cached_match.get('id', ''),
|
||||
'discovery_source': discovery_source,
|
||||
'index': i
|
||||
}
|
||||
successful_discoveries += 1
|
||||
state['spotify_matches'] = successful_discoveries
|
||||
state['discovery_results'].append(result)
|
||||
state['discovery_progress'] = int(((i + 1) / len(tracks)) * 100)
|
||||
continue
|
||||
except Exception as cache_err:
|
||||
logger.error(f"Cache lookup error: {cache_err}")
|
||||
|
||||
# Create a SimpleNamespace duck-type object for _search_spotify_for_tidal_track
|
||||
track_ns = types.SimpleNamespace(
|
||||
id=track_id,
|
||||
name=track_name,
|
||||
artists=track_artists,
|
||||
album=track_album,
|
||||
duration_ms=track_duration_ms
|
||||
)
|
||||
|
||||
# Use the search function with appropriate provider
|
||||
track_result = deps.search_spotify_for_tidal_track(
|
||||
track_ns,
|
||||
use_spotify=use_spotify,
|
||||
itunes_client=itunes_client_instance
|
||||
)
|
||||
|
||||
# Create result entry
|
||||
result = {
|
||||
'deezer_track': {
|
||||
'id': track_id,
|
||||
'name': track_name,
|
||||
'artists': track_artists or [],
|
||||
'album': track_album,
|
||||
'duration_ms': track_duration_ms,
|
||||
},
|
||||
'spotify_data': None,
|
||||
'match_data': None,
|
||||
'status': 'Not Found',
|
||||
'status_class': 'not-found',
|
||||
'spotify_track': '',
|
||||
'spotify_artist': '',
|
||||
'spotify_album': '',
|
||||
'discovery_source': discovery_source
|
||||
}
|
||||
|
||||
match_confidence = 0.0
|
||||
|
||||
if use_spotify and isinstance(track_result, tuple):
|
||||
# Spotify: Function returns (Track, raw_data, confidence)
|
||||
track_obj, raw_track_data, match_confidence = track_result
|
||||
album_obj = raw_track_data.get('album', {}) if raw_track_data else {}
|
||||
# Ensure album has a name — fall back to track_obj.album if raw_data was missing
|
||||
if isinstance(album_obj, dict) and not album_obj.get('name') and track_obj.album:
|
||||
album_obj['name'] = track_obj.album
|
||||
elif not album_obj and track_obj.album:
|
||||
album_obj = {'name': track_obj.album}
|
||||
# Ensure release_date is present (raw Spotify data has it, but fallback may not)
|
||||
if isinstance(album_obj, dict) and not album_obj.get('release_date'):
|
||||
album_obj['release_date'] = getattr(track_obj, 'release_date', '') or ''
|
||||
# Extract image URL from album data or track object
|
||||
_album_images = album_obj.get('images', []) if isinstance(album_obj, dict) else []
|
||||
_image_url = _album_images[0].get('url', '') if _album_images else (getattr(track_obj, 'image_url', '') or '')
|
||||
|
||||
match_data = {
|
||||
'id': track_obj.id,
|
||||
'name': track_obj.name,
|
||||
'artists': track_obj.artists,
|
||||
'album': album_obj,
|
||||
'duration_ms': track_obj.duration_ms,
|
||||
'external_urls': track_obj.external_urls,
|
||||
'image_url': _image_url,
|
||||
'source': 'spotify'
|
||||
}
|
||||
# Preserve track_number/disc_number from raw Spotify API data
|
||||
if raw_track_data and raw_track_data.get('track_number'):
|
||||
match_data['track_number'] = raw_track_data['track_number']
|
||||
if raw_track_data and raw_track_data.get('disc_number'):
|
||||
match_data['disc_number'] = raw_track_data['disc_number']
|
||||
result['spotify_data'] = match_data
|
||||
result['match_data'] = match_data
|
||||
result['status'] = 'Found'
|
||||
result['status_class'] = 'found'
|
||||
result['spotify_track'] = track_obj.name
|
||||
result['spotify_artist'] = ', '.join(track_obj.artists) if isinstance(track_obj.artists, list) else str(track_obj.artists)
|
||||
result['spotify_album'] = album_obj.get('name', '') if isinstance(album_obj, dict) else str(album_obj)
|
||||
result['spotify_id'] = track_obj.id
|
||||
result['confidence'] = match_confidence
|
||||
successful_discoveries += 1
|
||||
state['spotify_matches'] = successful_discoveries
|
||||
|
||||
elif not use_spotify and track_result and isinstance(track_result, dict):
|
||||
# Fallback: Function returns a dict with track data (includes 'confidence' key)
|
||||
match_confidence = track_result.pop('confidence', 0.80)
|
||||
match_data = track_result
|
||||
match_data['source'] = discovery_source
|
||||
# Extract image URL from album images
|
||||
_fb_album = match_data.get('album', {})
|
||||
_fb_images = _fb_album.get('images', []) if isinstance(_fb_album, dict) else []
|
||||
if _fb_images and 'image_url' not in match_data:
|
||||
match_data['image_url'] = _fb_images[0].get('url', '')
|
||||
result['spotify_data'] = match_data
|
||||
result['match_data'] = match_data
|
||||
result['status'] = 'Found'
|
||||
result['status_class'] = 'found'
|
||||
result['spotify_track'] = match_data.get('name', '')
|
||||
itunes_artists = match_data.get('artists', [])
|
||||
result['spotify_artist'] = ', '.join(a if isinstance(a, str) else a.get('name', '') for a in itunes_artists) if itunes_artists else ''
|
||||
result['spotify_album'] = match_data.get('album', {}).get('name', '') if isinstance(match_data.get('album'), dict) else match_data.get('album', '')
|
||||
result['spotify_id'] = match_data.get('id', '')
|
||||
result['confidence'] = match_confidence
|
||||
successful_discoveries += 1
|
||||
state['spotify_matches'] = successful_discoveries
|
||||
|
||||
# Save to discovery cache if match found
|
||||
if result['status_class'] == 'found' and result.get('match_data'):
|
||||
try:
|
||||
cache_db = deps.get_database()
|
||||
cache_db.save_discovery_cache_match(
|
||||
cache_key[0], cache_key[1], discovery_source, match_confidence,
|
||||
result['match_data'], track_name,
|
||||
track_artists[0] if track_artists else ''
|
||||
)
|
||||
logger.info(f"CACHE SAVED: {track_name} (confidence: {match_confidence:.3f})")
|
||||
except Exception as cache_err:
|
||||
logger.error(f"Cache save error: {cache_err}")
|
||||
|
||||
# Auto Wing It fallback for unmatched tracks
|
||||
if result['status_class'] == 'not-found':
|
||||
deezer_t = result.get('deezer_track', {})
|
||||
stub = deps.build_discovery_wing_it_stub(
|
||||
deezer_t.get('name', ''),
|
||||
', '.join(deezer_t.get('artists', [])),
|
||||
deezer_t.get('duration_ms', 0)
|
||||
)
|
||||
result['status'] = 'Wing It'
|
||||
result['status_class'] = 'wing-it'
|
||||
result['spotify_data'] = stub
|
||||
result['match_data'] = stub
|
||||
result['spotify_track'] = deezer_t.get('name', '')
|
||||
result['spotify_artist'] = ', '.join(deezer_t.get('artists', []))
|
||||
result['wing_it_fallback'] = True
|
||||
result['confidence'] = 0
|
||||
successful_discoveries += 1
|
||||
state['spotify_matches'] = successful_discoveries
|
||||
state['wing_it_count'] = state.get('wing_it_count', 0) + 1
|
||||
|
||||
result['index'] = i
|
||||
state['discovery_results'].append(result)
|
||||
state['discovery_progress'] = int(((i + 1) / len(tracks)) * 100)
|
||||
|
||||
# Add delay between requests
|
||||
time.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing track {i+1}: {e}")
|
||||
# Add error result
|
||||
result = {
|
||||
'deezer_track': {
|
||||
'name': deezer_track.get('name', 'Unknown'),
|
||||
'artists': deezer_track.get('artists', []),
|
||||
},
|
||||
'spotify_data': None,
|
||||
'match_data': None,
|
||||
'status': 'Error',
|
||||
'status_class': 'error',
|
||||
'spotify_track': '',
|
||||
'spotify_artist': '',
|
||||
'spotify_album': '',
|
||||
'error': str(e),
|
||||
'discovery_source': discovery_source,
|
||||
'index': i
|
||||
}
|
||||
state['discovery_results'].append(result)
|
||||
state['discovery_progress'] = int(((i + 1) / len(tracks)) * 100)
|
||||
|
||||
# Mark as complete
|
||||
state['phase'] = 'discovered'
|
||||
state['status'] = 'discovered'
|
||||
state['discovery_progress'] = 100
|
||||
|
||||
# Add activity for discovery completion
|
||||
source_label = discovery_source.upper()
|
||||
deps.add_activity_item("", f"Deezer Discovery Complete ({source_label})", f"'{playlist['name']}' - {successful_discoveries}/{len(tracks)} tracks found", "Now")
|
||||
|
||||
logger.info(f"Deezer discovery complete ({source_label}): {successful_discoveries}/{len(tracks)} tracks found")
|
||||
|
||||
# Sync discovery results back to mirrored playlist
|
||||
deps.sync_discovery_results_to_mirrored('deezer', playlist_id, state.get('discovery_results', []), discovery_source, profile_id=state.get('_profile_id', 1))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in Deezer discovery worker: {e}")
|
||||
if playlist_id in deps.deezer_discovery_states:
|
||||
deps.deezer_discovery_states[playlist_id]['phase'] = 'error'
|
||||
deps.deezer_discovery_states[playlist_id]['status'] = f'error: {str(e)}'
|
||||
finally:
|
||||
deps.resume_enrichment_workers(_ew_state, 'Deezer discovery')
|
||||
234
core/discovery/hero.py
Normal file
234
core/discovery/hero.py
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
"""Discover Hero endpoint — lifted from web_server.py.
|
||||
|
||||
The function body is byte-identical to the original. The
|
||||
``spotify_client`` proxy + helper shims let the body resolve its
|
||||
original names; the more complex ``_get_metadata_fallback_client``
|
||||
is injected via init() because it composes multiple registry helpers
|
||||
that web_server.py wires together.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from flask import g, jsonify
|
||||
|
||||
from database.music_database import get_database
|
||||
from core.metadata.registry import get_primary_source, get_spotify_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_current_profile_id() -> int:
|
||||
"""Mirror of web_server.get_current_profile_id — uses Flask g."""
|
||||
try:
|
||||
return g.profile_id
|
||||
except AttributeError:
|
||||
return 1
|
||||
|
||||
|
||||
def _get_active_discovery_source():
|
||||
"""Mirror of web_server._get_active_discovery_source — delegates to registry."""
|
||||
return get_primary_source()
|
||||
|
||||
|
||||
class _SpotifyClientProxy:
|
||||
"""Resolves the global Spotify client lazily through core.metadata.registry."""
|
||||
|
||||
def __getattr__(self, name):
|
||||
client = get_spotify_client()
|
||||
if client is None:
|
||||
raise AttributeError(name)
|
||||
return getattr(client, name)
|
||||
|
||||
def __bool__(self):
|
||||
return get_spotify_client() is not None
|
||||
|
||||
|
||||
spotify_client = _SpotifyClientProxy()
|
||||
|
||||
|
||||
# Injected at runtime via init().
|
||||
_get_metadata_fallback_client = None
|
||||
|
||||
|
||||
def init(get_metadata_fallback_client_fn):
|
||||
"""Bind web_server's _get_metadata_fallback_client helper."""
|
||||
global _get_metadata_fallback_client
|
||||
_get_metadata_fallback_client = get_metadata_fallback_client_fn
|
||||
|
||||
|
||||
def get_discover_hero():
|
||||
"""Get featured similar artists for hero slideshow"""
|
||||
try:
|
||||
database = get_database()
|
||||
|
||||
# Determine active source
|
||||
active_source = _get_active_discovery_source()
|
||||
logger.info(f"Discover hero using source: {active_source}")
|
||||
|
||||
# Import fallback client for non-Spotify lookups
|
||||
itunes_client = _get_metadata_fallback_client()
|
||||
|
||||
# Get top similar artists (excluding watchlist, cycled by last_featured)
|
||||
# Fetch more than needed since strict source filtering may drop many
|
||||
pid = get_current_profile_id()
|
||||
logger.info(f"[Discover Hero] Profile ID: {pid}, Active source: {active_source}")
|
||||
similar_artists = database.get_top_similar_artists(limit=200, profile_id=pid, require_source=active_source)
|
||||
|
||||
# FALLBACK: If no similar artists exist, use watchlist artists for Hero section
|
||||
if not similar_artists:
|
||||
logger.warning("[Discover Hero] No similar artists found, falling back to watchlist artists")
|
||||
watchlist_artists = database.get_watchlist_artists(profile_id=pid)
|
||||
|
||||
if not watchlist_artists:
|
||||
return jsonify({"success": True, "artists": [], "source": active_source})
|
||||
|
||||
# Convert watchlist artists to hero format
|
||||
import random
|
||||
shuffled_watchlist = list(watchlist_artists)
|
||||
random.shuffle(shuffled_watchlist)
|
||||
|
||||
hero_artists = []
|
||||
for artist in shuffled_watchlist[:10]:
|
||||
if active_source == 'spotify':
|
||||
artist_id = artist.spotify_artist_id
|
||||
elif active_source == 'deezer':
|
||||
artist_id = getattr(artist, 'deezer_artist_id', None) or artist.itunes_artist_id
|
||||
else:
|
||||
artist_id = artist.itunes_artist_id
|
||||
if not artist_id:
|
||||
continue
|
||||
|
||||
artist_data = {
|
||||
"spotify_artist_id": artist.spotify_artist_id,
|
||||
"itunes_artist_id": artist.itunes_artist_id,
|
||||
"artist_id": artist_id,
|
||||
"artist_name": artist.artist_name,
|
||||
"occurrence_count": 1,
|
||||
"similarity_rank": 1,
|
||||
"source": active_source,
|
||||
"is_watchlist": True
|
||||
}
|
||||
|
||||
# Use cached image from watchlist — no API call needed
|
||||
if hasattr(artist, 'image_url') and artist.image_url:
|
||||
artist_data['image_url'] = artist.image_url
|
||||
|
||||
hero_artists.append(artist_data)
|
||||
|
||||
logger.warning(f"[Discover Hero] Returning {len(hero_artists)} watchlist artists as fallback")
|
||||
return jsonify({"success": True, "artists": hero_artists, "source": active_source, "fallback": "watchlist"})
|
||||
|
||||
# Artists are already filtered by source in SQL — no post-filter needed
|
||||
valid_artists = list(similar_artists)
|
||||
|
||||
# FALLBACK: If no valid artists for fallback source, try to resolve IDs on-the-fly
|
||||
if active_source in ('itunes', 'deezer') and not valid_artists:
|
||||
logger.warning(f"[{active_source} Fallback] No artists with {active_source} IDs found, attempting on-the-fly resolution for {len(similar_artists)} artists")
|
||||
resolved_count = 0
|
||||
for artist in similar_artists:
|
||||
existing_id = getattr(artist, f'similar_artist_{active_source}_id', None) or (artist.similar_artist_itunes_id if active_source == 'itunes' else None)
|
||||
if existing_id:
|
||||
valid_artists.append(artist)
|
||||
continue
|
||||
# Try to resolve ID by name
|
||||
try:
|
||||
search_results = itunes_client.search_artists(artist.similar_artist_name, limit=1)
|
||||
if search_results and len(search_results) > 0:
|
||||
resolved_id = search_results[0].id
|
||||
# Cache the resolved ID for future use
|
||||
if active_source == 'deezer':
|
||||
database.update_similar_artist_deezer_id(artist.id, resolved_id)
|
||||
artist.similar_artist_deezer_id = resolved_id
|
||||
else:
|
||||
database.update_similar_artist_itunes_id(artist.id, resolved_id)
|
||||
artist.similar_artist_itunes_id = resolved_id
|
||||
valid_artists.append(artist)
|
||||
resolved_count += 1
|
||||
logger.info(f" [Resolved] {artist.similar_artist_name} -> {active_source} ID: {resolved_id}")
|
||||
except Exception as resolve_err:
|
||||
logger.error(f" [Failed] Could not resolve {active_source} ID for {artist.similar_artist_name}: {resolve_err}")
|
||||
# Stop after 10 successful resolutions to avoid rate limiting
|
||||
if len(valid_artists) >= 10:
|
||||
break
|
||||
logger.warning(f"[{active_source} Fallback] Resolved {resolved_count} artists with IDs")
|
||||
|
||||
logger.info(f"[Discover Hero] Found {len(valid_artists)} valid artists for source: {active_source}")
|
||||
|
||||
# Filter out blacklisted artists
|
||||
blacklisted = database.get_discovery_blacklist_names()
|
||||
if blacklisted:
|
||||
valid_artists = [a for a in valid_artists if a.similar_artist_name.lower() not in blacklisted]
|
||||
|
||||
# Take top 10 (already ordered by least-recently-featured, then quality)
|
||||
similar_artists = valid_artists[:10]
|
||||
|
||||
# Convert to JSON format — use cached metadata, only fetch from API if missing
|
||||
hero_artists = []
|
||||
for artist in similar_artists:
|
||||
# Use the ID for the active source, falling back to the other if needed
|
||||
if active_source == 'spotify':
|
||||
artist_id = artist.similar_artist_spotify_id or artist.similar_artist_itunes_id
|
||||
elif active_source == 'deezer':
|
||||
artist_id = getattr(artist, 'similar_artist_deezer_id', None) or artist.similar_artist_itunes_id or artist.similar_artist_spotify_id
|
||||
else:
|
||||
artist_id = artist.similar_artist_itunes_id or artist.similar_artist_spotify_id
|
||||
|
||||
artist_data = {
|
||||
"spotify_artist_id": artist.similar_artist_spotify_id,
|
||||
"itunes_artist_id": artist.similar_artist_itunes_id,
|
||||
"artist_id": artist_id,
|
||||
"artist_name": artist.similar_artist_name,
|
||||
"occurrence_count": artist.occurrence_count,
|
||||
"similarity_rank": artist.similarity_rank,
|
||||
"source": active_source
|
||||
}
|
||||
|
||||
# Use cached metadata if available
|
||||
if artist.image_url:
|
||||
artist_data['image_url'] = artist.image_url
|
||||
artist_data['genres'] = artist.genres or []
|
||||
artist_data['popularity'] = artist.popularity or 0
|
||||
else:
|
||||
# No cached metadata — fetch from API and cache for next time
|
||||
try:
|
||||
if active_source == 'spotify' and artist.similar_artist_spotify_id:
|
||||
if spotify_client and spotify_client.is_authenticated():
|
||||
sp_artist = spotify_client.get_artist(artist.similar_artist_spotify_id)
|
||||
if sp_artist and sp_artist.get('images'):
|
||||
artist_data['artist_name'] = sp_artist.get('name', artist.similar_artist_name)
|
||||
artist_data['image_url'] = sp_artist['images'][0]['url'] if sp_artist['images'] else None
|
||||
artist_data['genres'] = sp_artist.get('genres', [])
|
||||
artist_data['popularity'] = sp_artist.get('popularity', 0)
|
||||
# Cache it
|
||||
database.update_similar_artist_metadata(
|
||||
artist.id, artist_data.get('image_url'),
|
||||
artist_data.get('genres'), artist_data.get('popularity')
|
||||
)
|
||||
elif active_source in ('itunes', 'deezer'):
|
||||
fb_artist_id = getattr(artist, 'similar_artist_deezer_id', None) if active_source == 'deezer' else None
|
||||
fb_artist_id = fb_artist_id or artist.similar_artist_itunes_id
|
||||
if fb_artist_id:
|
||||
fb_artist_data = itunes_client.get_artist(fb_artist_id)
|
||||
if fb_artist_data:
|
||||
artist_data['artist_name'] = fb_artist_data.get('name', artist.similar_artist_name)
|
||||
artist_data['image_url'] = fb_artist_data.get('images', [{}])[0].get('url') if fb_artist_data.get('images') else None
|
||||
artist_data['genres'] = fb_artist_data.get('genres', [])
|
||||
artist_data['popularity'] = fb_artist_data.get('popularity', 0)
|
||||
# Cache it
|
||||
database.update_similar_artist_metadata(
|
||||
artist.id, artist_data.get('image_url'),
|
||||
artist_data.get('genres'), artist_data.get('popularity')
|
||||
)
|
||||
except Exception as img_err:
|
||||
logger.error(f"Could not fetch artist image: {img_err}")
|
||||
|
||||
hero_artists.append(artist_data)
|
||||
|
||||
# Mark these artists as featured so they cycle to the back of the queue
|
||||
featured_names = [a["artist_name"] for a in hero_artists]
|
||||
database.mark_artists_featured(featured_names)
|
||||
|
||||
return jsonify({"success": True, "artists": hero_artists, "source": active_source})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting discover hero: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
342
core/discovery/listenbrainz.py
Normal file
342
core/discovery/listenbrainz.py
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
"""Background worker for ListenBrainz playlist discovery.
|
||||
|
||||
`run_listenbrainz_discovery_worker(state_key, deps)` is the function
|
||||
the listenbrainz discovery start-endpoint submits to its executor to
|
||||
match each ListenBrainz playlist track against Spotify (preferred) or
|
||||
iTunes (fallback):
|
||||
|
||||
1. Pause enrichment workers (release shared resources).
|
||||
2. For each ListenBrainz track:
|
||||
- Cancellation gate (state['phase'] != 'discovering').
|
||||
- Discovery cache lookup; cache hit short-circuits the search.
|
||||
- Strategy 1: matching_engine search queries with confidence scoring.
|
||||
- Strategy 2: swapped artist/title query.
|
||||
- Strategy 3: album-based query (if album_name available).
|
||||
- Strategy 4: extended search with limit=50.
|
||||
- On match → save to discovery cache.
|
||||
- On miss → build Wing It stub from raw source data.
|
||||
3. After loop: phase='discovered', activity feed entry.
|
||||
4. On error → state['status']='error', phase='fresh'.
|
||||
5. Finally: resume enrichment workers.
|
||||
|
||||
Lifted verbatim from web_server.py. Wide dependency surface (Spotify
|
||||
and iTunes clients, matching engine, multiple metadata helpers, state
|
||||
dict, database access) all injected via `ListenbrainzDiscoveryDeps`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ListenbrainzDiscoveryDeps:
|
||||
"""Bundle of cross-cutting deps the ListenBrainz discovery worker needs."""
|
||||
listenbrainz_playlist_states: dict
|
||||
spotify_client: Any
|
||||
matching_engine: Any
|
||||
pause_enrichment_workers: Callable[[str], dict]
|
||||
resume_enrichment_workers: Callable[[dict, str], None]
|
||||
get_active_discovery_source: Callable[[], str]
|
||||
get_metadata_fallback_client: Callable[[], Any]
|
||||
get_discovery_cache_key: Callable
|
||||
get_database: Callable[[], Any]
|
||||
validate_discovery_cache_artist: Callable
|
||||
extract_artist_name: Callable
|
||||
spotify_rate_limited: Callable[[], bool]
|
||||
discovery_score_candidates: Callable
|
||||
get_metadata_cache: Callable[[], Any]
|
||||
build_discovery_wing_it_stub: Callable
|
||||
add_activity_item: Callable
|
||||
|
||||
|
||||
def run_listenbrainz_discovery_worker(state_key, deps: ListenbrainzDiscoveryDeps):
|
||||
"""Background worker for ListenBrainz music discovery process (Spotify preferred, iTunes fallback)"""
|
||||
playlist_mbid = state_key.split(':', 1)[1] if ':' in state_key else state_key
|
||||
_ew_state = {}
|
||||
try:
|
||||
_ew_state = deps.pause_enrichment_workers('ListenBrainz discovery')
|
||||
state = deps.listenbrainz_playlist_states[state_key]
|
||||
playlist = state['playlist']
|
||||
tracks = playlist['tracks']
|
||||
|
||||
# Determine which provider to use (Spotify preferred, iTunes fallback)
|
||||
discovery_source = deps.get_active_discovery_source()
|
||||
use_spotify = (discovery_source == 'spotify') and deps.spotify_client and deps.spotify_client.is_spotify_authenticated()
|
||||
|
||||
# Get fallback client
|
||||
itunes_client = deps.get_metadata_fallback_client()
|
||||
|
||||
logger.info(f"Starting {discovery_source} discovery for {len(tracks)} ListenBrainz tracks...")
|
||||
|
||||
# Store the discovery source in state
|
||||
state['discovery_source'] = discovery_source
|
||||
|
||||
# Process each track for discovery
|
||||
for i, track in enumerate(tracks):
|
||||
try:
|
||||
# Check for cancellation
|
||||
if state.get('phase') != 'discovering':
|
||||
logger.warning(f"ListenBrainz discovery cancelled (phase changed to '{state.get('phase')}')")
|
||||
return
|
||||
|
||||
# Update progress
|
||||
state['discovery_progress'] = int((i / len(tracks)) * 100)
|
||||
|
||||
# Get cleaned track data from ListenBrainz
|
||||
cleaned_title = track['track_name']
|
||||
cleaned_artist = track['artist_name']
|
||||
album_name = track.get('album_name', '')
|
||||
duration_ms = track.get('duration_ms', 0)
|
||||
|
||||
logger.info(f"Searching {discovery_source} for: '{cleaned_artist}' - '{cleaned_title}'")
|
||||
|
||||
# Check discovery cache first
|
||||
cache_key = deps.get_discovery_cache_key(cleaned_title, cleaned_artist)
|
||||
try:
|
||||
cache_db = deps.get_database()
|
||||
cached_match = cache_db.get_discovery_cache_match(cache_key[0], cache_key[1], discovery_source)
|
||||
if cached_match and deps.validate_discovery_cache_artist(cleaned_artist, cached_match):
|
||||
logger.debug(f"CACHE HIT [{i+1}/{len(tracks)}]: {cleaned_artist} - {cleaned_title}")
|
||||
result = {
|
||||
'index': i,
|
||||
'lb_track': cleaned_title,
|
||||
'lb_artist': cleaned_artist,
|
||||
'status': 'Found',
|
||||
'status_class': 'found',
|
||||
'spotify_track': cached_match.get('name', ''),
|
||||
'spotify_artist': deps.extract_artist_name(cached_match.get('artists', [''])[0]) if cached_match.get('artists') else '',
|
||||
'spotify_album': cached_match.get('album', {}).get('name', '') if isinstance(cached_match.get('album'), dict) else cached_match.get('album', ''),
|
||||
'duration': f"{int(duration_ms) // 60000}:{(int(duration_ms) % 60000) // 1000:02d}" if duration_ms else '0:00',
|
||||
'discovery_source': discovery_source,
|
||||
'matched_data': cached_match,
|
||||
'spotify_data': cached_match
|
||||
}
|
||||
state['spotify_matches'] += 1
|
||||
state['discovery_results'].append(result)
|
||||
continue
|
||||
except Exception as cache_err:
|
||||
logger.error(f"Cache lookup error: {cache_err}")
|
||||
|
||||
# Try multiple search strategies using matching engine
|
||||
matched_track = None
|
||||
best_confidence = 0.0
|
||||
best_raw_track = None
|
||||
min_confidence = 0.9
|
||||
source_duration = duration_ms or 0
|
||||
|
||||
# Strategy 1: Use matching_engine search queries
|
||||
try:
|
||||
temp_track = type('TempTrack', (), {
|
||||
'name': cleaned_title,
|
||||
'artists': [cleaned_artist],
|
||||
'album': album_name if album_name else None
|
||||
})()
|
||||
search_queries = deps.matching_engine.generate_download_queries(temp_track)
|
||||
logger.info(f"Generated {len(search_queries)} search queries for ListenBrainz track")
|
||||
except Exception as e:
|
||||
logger.error(f"Matching engine failed for ListenBrainz, falling back to basic query: {e}")
|
||||
search_queries = [f"{cleaned_artist} {cleaned_title}", cleaned_title]
|
||||
|
||||
for query_idx, search_query in enumerate(search_queries):
|
||||
try:
|
||||
logger.debug(f"ListenBrainz query {query_idx + 1}/{len(search_queries)}: {search_query}")
|
||||
|
||||
search_results = None
|
||||
|
||||
if use_spotify and not deps.spotify_rate_limited():
|
||||
search_results = deps.spotify_client.search_tracks(search_query, limit=10)
|
||||
else:
|
||||
search_results = itunes_client.search_tracks(search_query, limit=10)
|
||||
|
||||
if not search_results:
|
||||
continue
|
||||
|
||||
# Score all results using the matching engine
|
||||
match, confidence, match_idx = deps.discovery_score_candidates(
|
||||
cleaned_title, cleaned_artist, source_duration, search_results
|
||||
)
|
||||
|
||||
if match and confidence > best_confidence and confidence >= min_confidence:
|
||||
best_confidence = confidence
|
||||
matched_track = match
|
||||
if use_spotify and match.id:
|
||||
_cache = deps.get_metadata_cache()
|
||||
best_raw_track = _cache.get_entity('spotify', 'track', match.id)
|
||||
else:
|
||||
best_raw_track = None
|
||||
logger.info(f"New best ListenBrainz match: {match.artists[0]} - {match.name} (confidence: {confidence:.3f})")
|
||||
|
||||
if best_confidence >= 0.9:
|
||||
logger.info(f"High confidence ListenBrainz match found ({best_confidence:.3f}), stopping search")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error in ListenBrainz search for query '{search_query}': {e}")
|
||||
continue
|
||||
|
||||
if matched_track:
|
||||
logger.info(f"Strategy 1 ListenBrainz match: {matched_track.artists[0]} - {matched_track.name} (confidence: {best_confidence:.3f})")
|
||||
|
||||
# Strategy 2: Swapped search (if first failed) - score results properly
|
||||
if not matched_track:
|
||||
logger.info("ListenBrainz Strategy 2: Trying swapped search (artist/title reversed)")
|
||||
if use_spotify:
|
||||
query = f"artist:{cleaned_title} track:{cleaned_artist}"
|
||||
fallback_results = deps.spotify_client.search_tracks(query, limit=5)
|
||||
else:
|
||||
query = f"{cleaned_title} {cleaned_artist}"
|
||||
fallback_results = itunes_client.search_tracks(query, limit=5)
|
||||
if fallback_results:
|
||||
match, confidence, _ = deps.discovery_score_candidates(
|
||||
cleaned_title, cleaned_artist, source_duration, fallback_results
|
||||
)
|
||||
if match and confidence >= min_confidence:
|
||||
matched_track = match
|
||||
best_confidence = confidence
|
||||
logger.info(f"Strategy 2 ListenBrainz match (swapped): {match.artists[0]} - {match.name} (confidence: {confidence:.3f})")
|
||||
|
||||
# Strategy 3: Album-based search (if still failed and we have album name) - score results properly
|
||||
if not matched_track and album_name:
|
||||
logger.info(f"ListenBrainz Strategy 3: Trying album-based search: '{cleaned_artist} {album_name} {cleaned_title}'")
|
||||
if use_spotify:
|
||||
query = f"artist:{cleaned_artist} album:{album_name} track:{cleaned_title}"
|
||||
fallback_results = deps.spotify_client.search_tracks(query, limit=5)
|
||||
else:
|
||||
query = f"{cleaned_artist} {album_name} {cleaned_title}"
|
||||
fallback_results = itunes_client.search_tracks(query, limit=5)
|
||||
if fallback_results:
|
||||
match, confidence, _ = deps.discovery_score_candidates(
|
||||
cleaned_title, cleaned_artist, source_duration, fallback_results
|
||||
)
|
||||
if match and confidence >= min_confidence:
|
||||
matched_track = match
|
||||
best_confidence = confidence
|
||||
logger.info(f"Strategy 3 ListenBrainz match (album): {match.artists[0]} - {match.name} (confidence: {confidence:.3f})")
|
||||
|
||||
# Strategy 4: Extended search with higher limit (last resort)
|
||||
if not matched_track:
|
||||
logger.info("ListenBrainz Strategy 4: Extended search with limit=50")
|
||||
query = f"{cleaned_artist} {cleaned_title}"
|
||||
if use_spotify:
|
||||
extended_results = deps.spotify_client.search_tracks(query, limit=50)
|
||||
else:
|
||||
extended_results = itunes_client.search_tracks(query, limit=50)
|
||||
if extended_results:
|
||||
match, confidence, _ = deps.discovery_score_candidates(
|
||||
cleaned_title, cleaned_artist, source_duration, extended_results
|
||||
)
|
||||
if match and confidence >= min_confidence:
|
||||
matched_track = match
|
||||
best_confidence = confidence
|
||||
logger.info(f"Strategy 4 ListenBrainz match (extended): {match.artists[0]} - {match.name} (confidence: {confidence:.3f})")
|
||||
|
||||
# Create result entry
|
||||
result = {
|
||||
'index': i,
|
||||
'lb_track': cleaned_title,
|
||||
'lb_artist': cleaned_artist,
|
||||
'status': 'Found' if matched_track else 'Not Found',
|
||||
'status_class': 'found' if matched_track else 'not-found',
|
||||
'spotify_track': matched_track.name if matched_track else '',
|
||||
'spotify_artist': deps.extract_artist_name(matched_track.artists[0]) if matched_track else '',
|
||||
'spotify_album': matched_track.album if matched_track else '',
|
||||
'duration': f"{int(duration_ms) // 60000}:{(int(duration_ms) % 60000) // 1000:02d}" if duration_ms else '0:00',
|
||||
'discovery_source': discovery_source,
|
||||
'confidence': best_confidence
|
||||
}
|
||||
|
||||
if matched_track:
|
||||
state['spotify_matches'] += 1
|
||||
|
||||
# Build album data based on provider
|
||||
if use_spotify and best_raw_track:
|
||||
album_data = best_raw_track.get('album', {})
|
||||
else:
|
||||
album_data = {
|
||||
'name': matched_track.album,
|
||||
'album_type': 'album',
|
||||
'release_date': getattr(matched_track, 'release_date', '') or '',
|
||||
'images': [{'url': matched_track.image_url}] if hasattr(matched_track, 'image_url') and matched_track.image_url else []
|
||||
}
|
||||
|
||||
# Extract image URL for discovery pool display
|
||||
_yt_album_images = album_data.get('images', [])
|
||||
_yt_image_url = _yt_album_images[0].get('url', '') if _yt_album_images else (getattr(matched_track, 'image_url', '') or '')
|
||||
|
||||
result['matched_data'] = {
|
||||
'id': matched_track.id,
|
||||
'name': matched_track.name,
|
||||
'artists': matched_track.artists,
|
||||
'album': album_data,
|
||||
'duration_ms': matched_track.duration_ms,
|
||||
'image_url': _yt_image_url,
|
||||
'source': discovery_source
|
||||
}
|
||||
result['spotify_data'] = result['matched_data']
|
||||
|
||||
# Save to discovery cache (only high-confidence matches)
|
||||
if best_confidence >= 0.7:
|
||||
try:
|
||||
cache_db = deps.get_database()
|
||||
cache_db.save_discovery_cache_match(
|
||||
cache_key[0], cache_key[1], discovery_source, best_confidence,
|
||||
result['matched_data'], cleaned_title, cleaned_artist
|
||||
)
|
||||
logger.info(f"CACHE SAVED: {cleaned_artist} - {cleaned_title} (confidence: {best_confidence:.3f})")
|
||||
except Exception as cache_err:
|
||||
logger.error(f"Cache save error: {cache_err}")
|
||||
|
||||
else:
|
||||
# Auto Wing It fallback — build stub from raw source data
|
||||
stub = deps.build_discovery_wing_it_stub(cleaned_title, cleaned_artist, duration_ms)
|
||||
result['status'] = 'Wing It'
|
||||
result['status_class'] = 'wing-it'
|
||||
result['spotify_track'] = cleaned_title
|
||||
result['spotify_artist'] = cleaned_artist
|
||||
result['spotify_album'] = ''
|
||||
result['matched_data'] = stub
|
||||
result['spotify_data'] = stub
|
||||
result['wing_it_fallback'] = True
|
||||
state['wing_it_count'] = state.get('wing_it_count', 0) + 1
|
||||
|
||||
state['discovery_results'].append(result)
|
||||
|
||||
logger.info(f" {'' if matched_track else ''} Track {i+1}/{len(tracks)}: {result['status']}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing track {i}: {e}")
|
||||
result = {
|
||||
'index': i,
|
||||
'lb_track': track['track_name'],
|
||||
'lb_artist': track['artist_name'],
|
||||
'status': 'Error',
|
||||
'status_class': 'error',
|
||||
'spotify_track': '',
|
||||
'spotify_artist': '',
|
||||
'spotify_album': '',
|
||||
'duration': '0:00'
|
||||
}
|
||||
state['discovery_results'].append(result)
|
||||
|
||||
# Complete discovery
|
||||
state['phase'] = 'discovered'
|
||||
state['status'] = 'complete'
|
||||
state['discovery_progress'] = 100
|
||||
|
||||
playlist_name = playlist.get('name') or playlist.get('title') or 'Unknown Playlist'
|
||||
source_label = discovery_source.upper()
|
||||
deps.add_activity_item("", f"ListenBrainz Discovery Complete ({source_label})", f"'{playlist_name}' - {state['spotify_matches']}/{len(tracks)} tracks found", "Now")
|
||||
|
||||
logger.info(f"ListenBrainz discovery complete ({discovery_source}): {state['spotify_matches']}/{len(tracks)} tracks matched")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in ListenBrainz discovery worker: {e}")
|
||||
state['status'] = 'error'
|
||||
state['phase'] = 'fresh'
|
||||
finally:
|
||||
deps.resume_enrichment_workers(_ew_state, 'ListenBrainz discovery')
|
||||
386
core/discovery/playlist.py
Normal file
386
core/discovery/playlist.py
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
"""Background worker for mirrored playlist track discovery.
|
||||
|
||||
`run_playlist_discovery_worker(playlists, automation_id, deps)` is the
|
||||
function the automation engine schedules to enrich undiscovered mirrored
|
||||
playlist tracks with Spotify (preferred) or iTunes (fallback) metadata:
|
||||
|
||||
1. Pause enrichment workers and pre-compute total track count for the
|
||||
automation progress card.
|
||||
2. For each playlist:
|
||||
- Fast pre-scan separates already-discovered tracks (skipped, unless
|
||||
incomplete metadata or a Wing It stub) from undiscovered ones.
|
||||
- For each undiscovered track:
|
||||
- Cancellation gate.
|
||||
- Discovery cache lookup (with artist validation).
|
||||
- matching_engine search-query generation, then Spotify/iTunes
|
||||
search + scoring across queries.
|
||||
- Extended search fallback (limit=50) if no high-confidence match.
|
||||
- On match → enrich album from metadata cache, build matched_data,
|
||||
store in track.extra_data, save discovery cache entry.
|
||||
- On miss → Wing It stub stored as 'wing_it_fallback' provider.
|
||||
3. After all playlists: emit `discovery_completed` event when at least
|
||||
one new track was discovered, mark automation progress 'finished'.
|
||||
4. On error → automation progress 'error', traceback printed.
|
||||
5. Finally: resume enrichment workers.
|
||||
|
||||
Lifted verbatim from web_server.py. Wide dependency surface (Spotify
|
||||
and iTunes clients, matching engine, discovery helpers, DB access,
|
||||
automation engine, cancellation set) all injected via
|
||||
`PlaylistDiscoveryDeps`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaylistDiscoveryDeps:
|
||||
"""Bundle of cross-cutting deps the playlist discovery worker needs."""
|
||||
spotify_client: Any
|
||||
matching_engine: Any
|
||||
automation_engine: Any
|
||||
playlist_discovery_cancelled: set
|
||||
pause_enrichment_workers: Callable[[str], dict]
|
||||
resume_enrichment_workers: Callable[[dict, str], None]
|
||||
get_active_discovery_source: Callable[[], str]
|
||||
get_metadata_fallback_client: Callable[[], Any]
|
||||
get_metadata_fallback_source: Callable[[], str]
|
||||
update_automation_progress: Callable
|
||||
get_database: Callable[[], Any]
|
||||
get_discovery_cache_key: Callable
|
||||
validate_discovery_cache_artist: Callable
|
||||
discovery_score_candidates: Callable
|
||||
get_metadata_cache: Callable[[], Any]
|
||||
build_discovery_wing_it_stub: Callable
|
||||
|
||||
|
||||
def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistDiscoveryDeps = None):
|
||||
"""Background worker that discovers Spotify/iTunes metadata for undiscovered
|
||||
mirrored playlist tracks. Stores results in extra_data for use by sync."""
|
||||
_ew_state = {}
|
||||
try:
|
||||
_ew_state = deps.pause_enrichment_workers('mirrored playlist discovery')
|
||||
discovery_source = deps.get_active_discovery_source()
|
||||
use_spotify = (discovery_source == 'spotify') and deps.spotify_client and deps.spotify_client.is_spotify_authenticated()
|
||||
|
||||
itunes_client_instance = None
|
||||
if not use_spotify:
|
||||
try:
|
||||
itunes_client_instance = deps.get_metadata_fallback_client()
|
||||
except Exception:
|
||||
logger.warning(f"Neither Spotify nor {deps.get_metadata_fallback_source()} available for discovery")
|
||||
deps.update_automation_progress(automation_id, status='error', progress=100,
|
||||
phase='Error', log_line=f'Neither Spotify nor {deps.get_metadata_fallback_source()} available',
|
||||
log_type='error')
|
||||
return
|
||||
|
||||
total_discovered = 0
|
||||
total_failed = 0
|
||||
total_skipped = 0
|
||||
total_tracks = 0
|
||||
last_playlist_name = ''
|
||||
|
||||
# Pre-compute grand total for progress tracking
|
||||
grand_total = 0
|
||||
db_init = deps.get_database()
|
||||
for pl in playlists:
|
||||
t = db_init.get_mirrored_playlist_tracks(pl['id'])
|
||||
if t:
|
||||
grand_total += len(t)
|
||||
deps.update_automation_progress(automation_id, total=grand_total)
|
||||
|
||||
for pl in playlists:
|
||||
pl_id = pl['id']
|
||||
pl_name = pl.get('name', '')
|
||||
last_playlist_name = pl_name
|
||||
source = pl.get('source', '')
|
||||
|
||||
db = deps.get_database()
|
||||
tracks = db.get_mirrored_playlist_tracks(pl_id)
|
||||
if not tracks:
|
||||
continue
|
||||
|
||||
logger.info(f"Starting discovery for playlist '{pl_name}' ({len(tracks)} tracks, using {discovery_source.upper()})")
|
||||
deps.update_automation_progress(automation_id, phase=f'Discovering: "{pl_name}"',
|
||||
log_line=f'Playlist "{pl_name}" — {len(tracks)} tracks ({discovery_source.upper()})', log_type='info')
|
||||
|
||||
# Fast pre-scan: separate already-discovered from undiscovered
|
||||
undiscovered_tracks = []
|
||||
pl_skipped = 0
|
||||
for track in tracks:
|
||||
existing_extra = {}
|
||||
if track.get('extra_data'):
|
||||
try:
|
||||
existing_extra = json.loads(track['extra_data']) if isinstance(track['extra_data'], str) else track['extra_data']
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
if existing_extra.get('discovered'):
|
||||
if existing_extra.get('wing_it_fallback'):
|
||||
# Wing It stub — always re-attempt to find a real match
|
||||
undiscovered_tracks.append(track)
|
||||
else:
|
||||
# Check if matched_data is complete — old discoveries may be missing
|
||||
# track_number/release_date due to the Track dataclass stripping them.
|
||||
# Re-discover these so the enriched pipeline fills in the gaps.
|
||||
md = existing_extra.get('matched_data', {})
|
||||
album = md.get('album', {})
|
||||
has_track_num = md.get('track_number')
|
||||
has_release = album.get('release_date') if isinstance(album, dict) else None
|
||||
has_album_id = album.get('id') if isinstance(album, dict) else None
|
||||
if has_track_num and (has_release or has_album_id):
|
||||
pl_skipped += 1
|
||||
total_skipped += 1
|
||||
else:
|
||||
# Incomplete discovery — re-discover to get full metadata
|
||||
undiscovered_tracks.append(track)
|
||||
elif existing_extra.get('unmatched_by_user'):
|
||||
# User explicitly removed this match — respect their choice
|
||||
pl_skipped += 1
|
||||
total_skipped += 1
|
||||
else:
|
||||
undiscovered_tracks.append(track)
|
||||
|
||||
if pl_skipped > 0:
|
||||
deps.update_automation_progress(automation_id,
|
||||
log_line=f'{pl_skipped} tracks already discovered — skipped', log_type='skip')
|
||||
|
||||
if not undiscovered_tracks:
|
||||
deps.update_automation_progress(automation_id,
|
||||
progress=((total_skipped + total_discovered + total_failed) / max(1, grand_total)) * 100,
|
||||
log_line=f'All {len(tracks)} tracks already discovered', log_type='skip')
|
||||
continue
|
||||
|
||||
deps.update_automation_progress(automation_id,
|
||||
log_line=f'{len(undiscovered_tracks)} tracks to discover', log_type='info')
|
||||
|
||||
for i, track in enumerate(undiscovered_tracks):
|
||||
# Check for cancellation
|
||||
if automation_id and automation_id in deps.playlist_discovery_cancelled:
|
||||
deps.playlist_discovery_cancelled.discard(automation_id)
|
||||
logger.warning(f"Playlist discovery cancelled (automation {automation_id})")
|
||||
deps.update_automation_progress(automation_id, status='finished', progress=100,
|
||||
phase='Discovery cancelled',
|
||||
log_line=f'Cancelled: {total_discovered} discovered, {total_failed} failed',
|
||||
log_type='info')
|
||||
return
|
||||
|
||||
total_tracks += 1
|
||||
track_id = track['id']
|
||||
track_name = track.get('track_name', '')
|
||||
artist_name = track.get('artist_name', '')
|
||||
duration_ms = track.get('duration_ms', 0)
|
||||
|
||||
# Step 1: Check discovery cache
|
||||
cache_key = deps.get_discovery_cache_key(track_name, artist_name)
|
||||
try:
|
||||
cached_match = db.get_discovery_cache_match(cache_key[0], cache_key[1], discovery_source)
|
||||
if cached_match and deps.validate_discovery_cache_artist(artist_name, cached_match):
|
||||
extra_data = {
|
||||
'discovered': True,
|
||||
'provider': discovery_source,
|
||||
'confidence': cached_match.get('confidence', 0.85),
|
||||
'matched_data': cached_match,
|
||||
}
|
||||
db.update_mirrored_track_extra_data(track_id, extra_data)
|
||||
total_discovered += 1
|
||||
logger.info(f"CACHE [{i+1}/{len(undiscovered_tracks)}]: {track_name} → {cached_match.get('name', '?')}")
|
||||
deps.update_automation_progress(automation_id,
|
||||
progress=((total_skipped + total_discovered + total_failed) / max(1, grand_total)) * 100,
|
||||
current_item=track_name,
|
||||
log_line=f'{track_name} → {cached_match.get("name", "?")} (cache)', log_type='success')
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Step 2: Generate search queries
|
||||
try:
|
||||
temp_track = type('TempTrack', (), {
|
||||
'name': track_name,
|
||||
'artists': [artist_name],
|
||||
'album': None
|
||||
})()
|
||||
search_queries = deps.matching_engine.generate_download_queries(temp_track)
|
||||
except Exception:
|
||||
search_queries = [f"{artist_name} {track_name}", track_name]
|
||||
|
||||
# Step 3: Search and score
|
||||
best_match = None
|
||||
best_confidence = 0.0
|
||||
min_confidence = 0.7
|
||||
|
||||
for search_query in search_queries:
|
||||
try:
|
||||
if use_spotify:
|
||||
results = deps.spotify_client.search_tracks(search_query, limit=10)
|
||||
else:
|
||||
results = itunes_client_instance.search_tracks(search_query, limit=10)
|
||||
if not results:
|
||||
continue
|
||||
|
||||
match, confidence, _ = deps.discovery_score_candidates(
|
||||
track_name, artist_name, duration_ms, results
|
||||
)
|
||||
|
||||
if match and confidence > best_confidence:
|
||||
best_confidence = confidence
|
||||
best_match = match
|
||||
|
||||
if best_confidence >= 0.9:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Extended search fallback
|
||||
if not best_match or best_confidence < min_confidence:
|
||||
try:
|
||||
query = f"{artist_name} {track_name}"
|
||||
if use_spotify:
|
||||
extended = deps.spotify_client.search_tracks(query, limit=50)
|
||||
else:
|
||||
extended = itunes_client_instance.search_tracks(query, limit=50)
|
||||
if extended:
|
||||
match, confidence, _ = deps.discovery_score_candidates(
|
||||
track_name, artist_name, duration_ms, extended
|
||||
)
|
||||
if match and confidence > best_confidence:
|
||||
best_confidence = confidence
|
||||
best_match = match
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Step 4: Store results
|
||||
if best_match and best_confidence >= min_confidence:
|
||||
match_artists = best_match.artists if hasattr(best_match, 'artists') else []
|
||||
match_image = getattr(best_match, 'image_url', None)
|
||||
album_name = best_match.album if hasattr(best_match, 'album') else ''
|
||||
album_obj = {'name': album_name, 'release_date': getattr(best_match, 'release_date', '') or ''}
|
||||
if match_image:
|
||||
album_obj['images'] = [{'url': match_image, 'height': 600, 'width': 600}]
|
||||
|
||||
# Enrich album data from metadata cache — search_tracks() caches the
|
||||
# raw API response which has full album info (id, images, total_tracks)
|
||||
# that the Track dataclass strips to just a name string
|
||||
track_number = None
|
||||
disc_number = None
|
||||
if hasattr(best_match, 'id') and best_match.id:
|
||||
try:
|
||||
cache = deps.get_metadata_cache()
|
||||
_raw = cache.get_entity(discovery_source if not use_spotify else 'spotify', 'track', best_match.id)
|
||||
if _raw and isinstance(_raw.get('album'), dict):
|
||||
_raw_album = _raw['album']
|
||||
if _raw_album.get('id'):
|
||||
album_obj['id'] = _raw_album['id']
|
||||
if _raw_album.get('images') and not album_obj.get('images'):
|
||||
album_obj['images'] = _raw_album['images']
|
||||
if _raw_album.get('total_tracks'):
|
||||
album_obj['total_tracks'] = _raw_album['total_tracks']
|
||||
if _raw_album.get('album_type'):
|
||||
album_obj['album_type'] = _raw_album['album_type']
|
||||
if _raw_album.get('release_date') and not album_obj.get('release_date'):
|
||||
album_obj['release_date'] = _raw_album['release_date']
|
||||
if _raw_album.get('artists'):
|
||||
album_obj['artists'] = _raw_album['artists']
|
||||
if _raw:
|
||||
track_number = _raw.get('track_number')
|
||||
disc_number = _raw.get('disc_number')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
matched_data = {
|
||||
'id': best_match.id if hasattr(best_match, 'id') else '',
|
||||
'name': best_match.name if hasattr(best_match, 'name') else '',
|
||||
'artists': [{'name': a} if isinstance(a, str) else a for a in match_artists],
|
||||
'album': album_obj,
|
||||
'duration_ms': best_match.duration_ms if hasattr(best_match, 'duration_ms') else 0,
|
||||
'image_url': match_image,
|
||||
'source': discovery_source,
|
||||
}
|
||||
if track_number:
|
||||
matched_data['track_number'] = track_number
|
||||
if disc_number:
|
||||
matched_data['disc_number'] = disc_number
|
||||
|
||||
extra_data = {
|
||||
'discovered': True,
|
||||
'provider': discovery_source,
|
||||
'confidence': best_confidence,
|
||||
'matched_data': matched_data,
|
||||
}
|
||||
db.update_mirrored_track_extra_data(track_id, extra_data)
|
||||
total_discovered += 1
|
||||
|
||||
# Save to discovery cache
|
||||
try:
|
||||
db.save_discovery_cache_match(
|
||||
cache_key[0], cache_key[1], discovery_source,
|
||||
best_confidence, matched_data,
|
||||
track_name, artist_name
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(f"[{i+1}/{len(undiscovered_tracks)}] {track_name} → {matched_data['name']} ({best_confidence:.2f})")
|
||||
deps.update_automation_progress(automation_id,
|
||||
progress=((total_skipped + total_discovered + total_failed) / max(1, grand_total)) * 100,
|
||||
processed=total_discovered + total_failed,
|
||||
current_item=f'{track_name} - {artist_name}',
|
||||
log_line=f'{track_name} → {matched_data["name"]} ({best_confidence:.2f})', log_type='success')
|
||||
else:
|
||||
# Auto Wing It fallback — mark as discovered with stub metadata
|
||||
stub = deps.build_discovery_wing_it_stub(track_name, artist_name, duration_ms)
|
||||
extra_data = {
|
||||
'discovered': True,
|
||||
'provider': 'wing_it_fallback',
|
||||
'confidence': 0,
|
||||
'wing_it_fallback': True,
|
||||
'matched_data': stub,
|
||||
}
|
||||
db.update_mirrored_track_extra_data(track_id, extra_data)
|
||||
total_discovered += 1
|
||||
logger.info(f"[{i+1}/{len(undiscovered_tracks)}] Wing It: {track_name} by {artist_name}")
|
||||
deps.update_automation_progress(automation_id,
|
||||
progress=((total_skipped + total_discovered + total_failed) / max(1, grand_total)) * 100,
|
||||
processed=total_discovered + total_failed,
|
||||
current_item=f'{track_name} - {artist_name}',
|
||||
log_line=f'{track_name} by {artist_name} → wing it (no API match)', log_type='info')
|
||||
|
||||
time.sleep(0.15)
|
||||
|
||||
# Emit completion event only if new tracks were actually discovered
|
||||
# (no point triggering downstream sync if nothing changed)
|
||||
try:
|
||||
if deps.automation_engine and total_discovered > 0:
|
||||
_disc_pl_id = str(playlists[0]['id']) if len(playlists) == 1 else ''
|
||||
deps.automation_engine.emit('discovery_completed', {
|
||||
'playlist_name': last_playlist_name if len(playlists) == 1 else f'{len(playlists)} playlists',
|
||||
'playlist_id': _disc_pl_id,
|
||||
'total_tracks': str(total_tracks),
|
||||
'discovered_count': str(total_discovered),
|
||||
'failed_count': str(total_failed),
|
||||
'skipped_count': str(total_skipped),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.error(f"Playlist discovery complete: {total_discovered} discovered, {total_failed} failed, {total_skipped} skipped")
|
||||
deps.update_automation_progress(automation_id, status='finished', progress=100,
|
||||
phase='Discovery complete',
|
||||
log_line=f'Done: {total_discovered} discovered, {total_failed} failed, {total_skipped} skipped',
|
||||
log_type='success')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in playlist discovery worker: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
deps.update_automation_progress(automation_id, status='error', progress=100,
|
||||
phase='Error',
|
||||
log_line=f'Error: {str(e)}', log_type='error')
|
||||
finally:
|
||||
deps.resume_enrichment_workers(_ew_state, 'mirrored playlist discovery')
|
||||
616
core/discovery/quality_scanner.py
Normal file
616
core/discovery/quality_scanner.py
Normal file
|
|
@ -0,0 +1,616 @@
|
|||
"""Background worker for the library quality scanner.
|
||||
|
||||
`run_quality_scanner(scope, profile_id, deps)` is the function the
|
||||
quality-scanner endpoint kicks off in a thread to scan the library
|
||||
for low-quality tracks (below the user's configured quality profile)
|
||||
and add provider matches to the wishlist:
|
||||
|
||||
1. Reset scanner state, load quality profile + minimum acceptable tier.
|
||||
2. Load tracks from DB based on scope:
|
||||
- 'watchlist' → tracks for watchlisted artists only.
|
||||
- other → all library tracks.
|
||||
3. For each track:
|
||||
- Stop-request gate (state['status'] != 'running').
|
||||
- Quality-tier check via _get_quality_tier_from_extension(file_path).
|
||||
- Skip tracks meeting standards (tier_num <= min_acceptable_tier).
|
||||
- For low-quality tracks: matching_engine search query gen, score
|
||||
candidates against the configured metadata source priority
|
||||
(artist + title similarity, album-type bonus), pick best match >=
|
||||
0.7 confidence.
|
||||
- On match: add normalized track data to wishlist via
|
||||
`wishlist_service.add_track_to_wishlist` with
|
||||
source_type='quality_scanner' and a source_context that captures
|
||||
original file_path, format tier, bitrate, and match confidence.
|
||||
4. After all tracks: status='finished', progress=100, activity feed
|
||||
entry, emit `quality_scan_completed` event for automation engine.
|
||||
5. On critical exception: status='error', error message captured.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable
|
||||
|
||||
from core.metadata.registry import get_client_for_source, get_primary_source, get_source_priority
|
||||
from core.wishlist.payloads import ensure_wishlist_track_format
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QualityScannerDeps:
|
||||
"""Bundle of cross-cutting deps the quality scanner needs."""
|
||||
quality_scanner_state: dict
|
||||
quality_scanner_lock: Any # threading.Lock
|
||||
QUALITY_TIERS: dict
|
||||
matching_engine: Any
|
||||
automation_engine: Any
|
||||
get_quality_tier_from_extension: Callable
|
||||
add_activity_item: Callable
|
||||
|
||||
|
||||
def _extract_lookup_value(value: Any, *names: str, default: Any = None) -> Any:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, (str, bytes)):
|
||||
return value
|
||||
|
||||
for name in names:
|
||||
if isinstance(value, dict):
|
||||
if name in value and value[name] is not None:
|
||||
return value[name]
|
||||
else:
|
||||
candidate = getattr(value, name, None)
|
||||
if candidate is not None:
|
||||
return candidate
|
||||
return default
|
||||
|
||||
|
||||
def _normalize_track_artists(track_item: Any) -> list[dict]:
|
||||
artists = _extract_lookup_value(track_item, 'artists', default=[]) or []
|
||||
if isinstance(artists, (str, bytes)):
|
||||
artists = [artists]
|
||||
elif isinstance(artists, dict):
|
||||
artists = [artists]
|
||||
else:
|
||||
try:
|
||||
artists = list(artists)
|
||||
except TypeError:
|
||||
artists = [artists]
|
||||
|
||||
normalized = []
|
||||
for artist in artists:
|
||||
artist_name = _extract_lookup_value(artist, 'name', 'artist_name', 'title')
|
||||
if not artist_name and isinstance(artist, (str, bytes)):
|
||||
artist_name = artist
|
||||
if artist_name:
|
||||
artist_data = {'name': str(artist_name)}
|
||||
artist_images = _normalize_image_entries(_extract_lookup_value(artist, 'images', default=[]))
|
||||
artist_image_url = _extract_lookup_value(artist, 'image_url', 'artist_image_url', default=None)
|
||||
if artist_image_url and not artist_images:
|
||||
artist_images = [{'url': str(artist_image_url)}]
|
||||
if artist_images:
|
||||
artist_data['images'] = artist_images
|
||||
artist_data['image_url'] = artist_images[0].get('url')
|
||||
normalized.append(artist_data)
|
||||
|
||||
if not normalized:
|
||||
normalized.append({'name': 'Unknown Artist'})
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_image_entries(image_value: Any) -> list[dict]:
|
||||
if not image_value:
|
||||
return []
|
||||
|
||||
if isinstance(image_value, dict):
|
||||
image_value = [image_value]
|
||||
elif isinstance(image_value, (str, bytes)):
|
||||
image_value = [image_value]
|
||||
else:
|
||||
try:
|
||||
image_value = list(image_value)
|
||||
except TypeError:
|
||||
return []
|
||||
|
||||
normalized = []
|
||||
seen_urls = set()
|
||||
for image in image_value:
|
||||
if isinstance(image, dict):
|
||||
image_url = image.get('url') or image.get('image_url')
|
||||
if not image_url:
|
||||
continue
|
||||
image_dict = dict(image)
|
||||
image_dict['url'] = str(image_url)
|
||||
elif isinstance(image, (str, bytes)):
|
||||
image_dict = {'url': str(image)}
|
||||
else:
|
||||
continue
|
||||
|
||||
if image_dict['url'] in seen_urls:
|
||||
continue
|
||||
|
||||
seen_urls.add(image_dict['url'])
|
||||
normalized.append(image_dict)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_track_album(track_item: Any) -> dict:
|
||||
album = _extract_lookup_value(track_item, 'album', default={})
|
||||
if isinstance(album, dict):
|
||||
album_data = dict(album)
|
||||
else:
|
||||
album_data = {
|
||||
'name': _extract_lookup_value(album, 'name', 'title', default=str(album) if album else '') or '',
|
||||
'album_type': _extract_lookup_value(album, 'album_type', default='album') or 'album',
|
||||
'total_tracks': _extract_lookup_value(album, 'total_tracks', 'track_count', default=0) or 0,
|
||||
'release_date': _extract_lookup_value(album, 'release_date', default='') or '',
|
||||
}
|
||||
|
||||
album_data.setdefault('name', _extract_lookup_value(track_item, 'album_name', default='Unknown Album') or 'Unknown Album')
|
||||
album_data.setdefault('album_type', _extract_lookup_value(track_item, 'album_type', default='album') or 'album')
|
||||
album_data.setdefault('total_tracks', _extract_lookup_value(track_item, 'total_tracks', 'track_count', default=0) or 0)
|
||||
album_data.setdefault('release_date', _extract_lookup_value(track_item, 'release_date', default='') or '')
|
||||
|
||||
album_images = _normalize_image_entries(album_data.get('images'))
|
||||
if not album_images and isinstance(album, dict):
|
||||
album_images = _normalize_image_entries(
|
||||
album.get('images')
|
||||
or album.get('image_url')
|
||||
or album.get('album_cover_url')
|
||||
or album.get('cover_url')
|
||||
)
|
||||
|
||||
if not album_images:
|
||||
album_images = _normalize_image_entries(
|
||||
_extract_lookup_value(track_item, 'images', default=None)
|
||||
or _extract_lookup_value(track_item, 'image_url', default=None)
|
||||
or _extract_lookup_value(track_item, 'album_cover_url', default=None)
|
||||
or _extract_lookup_value(track_item, 'cover_url', default=None)
|
||||
)
|
||||
|
||||
if album_images:
|
||||
album_data['images'] = album_images
|
||||
album_data.setdefault('image_url', album_images[0].get('url'))
|
||||
else:
|
||||
album_data['images'] = []
|
||||
|
||||
album_data.setdefault('artists', _normalize_track_artists(track_item))
|
||||
return album_data
|
||||
|
||||
|
||||
def _normalize_track_match(track_item: Any, provider: str) -> dict:
|
||||
track_data = {
|
||||
'id': _extract_lookup_value(track_item, 'id', 'track_id', default='') or '',
|
||||
'name': _extract_lookup_value(track_item, 'name', 'title', default='Unknown Track') or 'Unknown Track',
|
||||
'artists': _normalize_track_artists(track_item),
|
||||
'album': _normalize_track_album(track_item),
|
||||
'image_url': _extract_lookup_value(track_item, 'image_url', 'album_cover_url', default=None),
|
||||
'duration_ms': _extract_lookup_value(track_item, 'duration_ms', default=0) or 0,
|
||||
'track_number': _extract_lookup_value(track_item, 'track_number', default=1) or 1,
|
||||
'disc_number': _extract_lookup_value(track_item, 'disc_number', default=1) or 1,
|
||||
'preview_url': _extract_lookup_value(track_item, 'preview_url', default=None),
|
||||
'external_urls': _extract_lookup_value(track_item, 'external_urls', default={}) or {},
|
||||
'popularity': _extract_lookup_value(track_item, 'popularity', default=0) or 0,
|
||||
'provider': provider,
|
||||
'source': provider,
|
||||
}
|
||||
if not track_data['image_url']:
|
||||
album_images = track_data['album'].get('images') if isinstance(track_data['album'], dict) else []
|
||||
if isinstance(album_images, list) and album_images:
|
||||
first_image = album_images[0]
|
||||
if isinstance(first_image, dict):
|
||||
track_data['image_url'] = first_image.get('url')
|
||||
return ensure_wishlist_track_format(track_data)
|
||||
|
||||
|
||||
def _track_name(track_item: Any) -> str:
|
||||
return str(_extract_lookup_value(track_item, 'name', 'title', default='Unknown Track') or 'Unknown Track')
|
||||
|
||||
|
||||
def _track_artist_names(track_item: Any) -> list[str]:
|
||||
artists = _extract_lookup_value(track_item, 'artists', default=[]) or []
|
||||
if isinstance(artists, (str, bytes)):
|
||||
artists = [artists]
|
||||
elif isinstance(artists, dict):
|
||||
artists = [artists]
|
||||
else:
|
||||
try:
|
||||
artists = list(artists)
|
||||
except TypeError:
|
||||
artists = [artists]
|
||||
|
||||
normalized = []
|
||||
for artist in artists:
|
||||
artist_name = _extract_lookup_value(artist, 'name', 'artist_name', 'title')
|
||||
if not artist_name and isinstance(artist, (str, bytes)):
|
||||
artist_name = artist
|
||||
if artist_name:
|
||||
normalized.append(str(artist_name))
|
||||
return normalized
|
||||
|
||||
|
||||
def _search_tracks_for_source(source: str, query: str, limit: int = 5, client: Any = None):
|
||||
if client is None:
|
||||
client = get_client_for_source(source)
|
||||
if not client or not hasattr(client, 'search_tracks'):
|
||||
return []
|
||||
|
||||
try:
|
||||
if source == 'spotify':
|
||||
return client.search_tracks(query, limit=limit, allow_fallback=False) or []
|
||||
return client.search_tracks(query, limit=limit) or []
|
||||
except TypeError:
|
||||
try:
|
||||
return client.search_tracks(query, limit=limit) or []
|
||||
except Exception as exc:
|
||||
logger.debug("Could not search %s for %s: %s", source, query, exc)
|
||||
return []
|
||||
except Exception as exc:
|
||||
logger.debug("Could not search %s for %s: %s", source, query, exc)
|
||||
return []
|
||||
|
||||
|
||||
def run_quality_scanner(scope='watchlist', profile_id=1, deps: QualityScannerDeps = None):
|
||||
"""Main quality scanner worker function"""
|
||||
from core.wishlist_service import get_wishlist_service
|
||||
from database.music_database import MusicDatabase
|
||||
|
||||
try:
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["status"] = "running"
|
||||
deps.quality_scanner_state["phase"] = "Initializing scan..."
|
||||
deps.quality_scanner_state["progress"] = 0
|
||||
deps.quality_scanner_state["processed"] = 0
|
||||
deps.quality_scanner_state["total"] = 0
|
||||
deps.quality_scanner_state["quality_met"] = 0
|
||||
deps.quality_scanner_state["low_quality"] = 0
|
||||
deps.quality_scanner_state["matched"] = 0
|
||||
deps.quality_scanner_state["results"] = []
|
||||
deps.quality_scanner_state["error_message"] = ""
|
||||
|
||||
logger.info(f"[Quality Scanner] Starting scan with scope: {scope}")
|
||||
|
||||
# Get database instance
|
||||
db = MusicDatabase()
|
||||
|
||||
# Get quality profile to determine preferred quality
|
||||
quality_profile = db.get_quality_profile()
|
||||
preferred_qualities = quality_profile.get('qualities', {})
|
||||
|
||||
# Determine minimum acceptable tier based on enabled qualities
|
||||
min_acceptable_tier = 999
|
||||
for quality_name, quality_config in preferred_qualities.items():
|
||||
if quality_config.get('enabled', False):
|
||||
# Map quality profile names to tier names
|
||||
tier_map = {
|
||||
'flac': 'lossless',
|
||||
'mp3_320': 'low_lossy',
|
||||
'mp3_256': 'low_lossy',
|
||||
'mp3_192': 'low_lossy'
|
||||
}
|
||||
tier_name = tier_map.get(quality_name)
|
||||
if tier_name:
|
||||
tier_num = deps.QUALITY_TIERS[tier_name]['tier']
|
||||
min_acceptable_tier = min(min_acceptable_tier, tier_num)
|
||||
|
||||
logger.info(f"[Quality Scanner] Minimum acceptable tier: {min_acceptable_tier}")
|
||||
|
||||
# Get tracks to scan based on scope
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["phase"] = "Loading tracks from database..."
|
||||
|
||||
if scope == 'watchlist':
|
||||
# Get watchlist artists
|
||||
watchlist_artists = db.get_watchlist_artists(profile_id=profile_id)
|
||||
if not watchlist_artists:
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["status"] = "finished"
|
||||
deps.quality_scanner_state["phase"] = "No watchlist artists found"
|
||||
deps.quality_scanner_state["error_message"] = "Please add artists to watchlist first"
|
||||
logger.warning("[Quality Scanner] No watchlist artists found")
|
||||
return
|
||||
|
||||
# Get artist names from watchlist
|
||||
artist_names = [artist.artist_name for artist in watchlist_artists]
|
||||
logger.info(f"[Quality Scanner] Scanning {len(artist_names)} watchlist artists")
|
||||
|
||||
# Get all tracks for these artists by name
|
||||
conn = db._get_connection()
|
||||
placeholders = ','.join(['?' for _ in artist_names])
|
||||
tracks_to_scan = conn.execute(
|
||||
f"SELECT t.id, t.title, t.artist_id, t.album_id, t.file_path, t.bitrate, a.name as artist_name, al.title as album_title "
|
||||
f"FROM tracks t "
|
||||
f"JOIN artists a ON t.artist_id = a.id "
|
||||
f"JOIN albums al ON t.album_id = al.id "
|
||||
f"WHERE a.name IN ({placeholders}) AND t.file_path IS NOT NULL",
|
||||
artist_names
|
||||
).fetchall()
|
||||
conn.close()
|
||||
else:
|
||||
# Scan all library tracks
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["phase"] = "Loading all library tracks..."
|
||||
|
||||
conn = db._get_connection()
|
||||
tracks_to_scan = conn.execute(
|
||||
"SELECT t.id, t.title, t.artist_id, t.album_id, t.file_path, t.bitrate, a.name as artist_name, al.title as album_title "
|
||||
"FROM tracks t "
|
||||
"JOIN artists a ON t.artist_id = a.id "
|
||||
"JOIN albums al ON t.album_id = al.id "
|
||||
"WHERE t.file_path IS NOT NULL"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
total_tracks = len(tracks_to_scan)
|
||||
logger.info(f"[Quality Scanner] Found {total_tracks} tracks to scan")
|
||||
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["total"] = total_tracks
|
||||
deps.quality_scanner_state["phase"] = f"Scanning {total_tracks} tracks..."
|
||||
|
||||
source_priority = get_source_priority(get_primary_source())
|
||||
if not source_priority:
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["status"] = "error"
|
||||
deps.quality_scanner_state["phase"] = "No metadata provider available"
|
||||
deps.quality_scanner_state["error_message"] = "No metadata provider is available for quality scanning"
|
||||
logger.info("[Quality Scanner] No metadata provider available")
|
||||
return
|
||||
|
||||
logger.info("[Quality Scanner] Using metadata source priority: %s", source_priority)
|
||||
|
||||
wishlist_service = get_wishlist_service()
|
||||
add_to_wishlist = getattr(wishlist_service, 'add_track_to_wishlist', None)
|
||||
if add_to_wishlist is None:
|
||||
add_to_wishlist = getattr(wishlist_service, 'add_spotify_track_to_wishlist', None)
|
||||
if add_to_wishlist is None:
|
||||
raise AttributeError("Wishlist service does not expose an add-to-wishlist method")
|
||||
|
||||
# Scan each track
|
||||
for idx, track_row in enumerate(tracks_to_scan, 1):
|
||||
# Check for stop request
|
||||
if deps.quality_scanner_state.get('status') != 'running':
|
||||
logger.info(f"[Quality Scanner] Stop requested, halting at track {idx}/{total_tracks}")
|
||||
break
|
||||
|
||||
try:
|
||||
track_id, title, artist_id, album_id, file_path, bitrate, artist_name, album_title = track_row
|
||||
|
||||
# Check quality tier
|
||||
tier_name, tier_num = deps.get_quality_tier_from_extension(file_path)
|
||||
|
||||
# Update progress
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["processed"] = idx
|
||||
deps.quality_scanner_state["progress"] = (idx / total_tracks) * 100
|
||||
deps.quality_scanner_state["phase"] = f"Scanning: {artist_name} - {title}"
|
||||
|
||||
# Check if meets quality standards
|
||||
if tier_num <= min_acceptable_tier:
|
||||
# Quality met
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["quality_met"] += 1
|
||||
continue
|
||||
|
||||
# Low quality track found
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["low_quality"] += 1
|
||||
|
||||
logger.info(f"[Quality Scanner] Low quality: {artist_name} - {title} ({tier_name}, {file_path})")
|
||||
|
||||
# Attempt to match using the active metadata provider
|
||||
matched = False
|
||||
matched_track_data = None
|
||||
best_source = None
|
||||
attempted_any_provider = False
|
||||
|
||||
try:
|
||||
# Generate search queries using matching engine
|
||||
temp_track = type('TempTrack', (), {
|
||||
'name': title,
|
||||
'artists': [artist_name],
|
||||
'album': album_title
|
||||
})()
|
||||
|
||||
search_queries = deps.matching_engine.generate_download_queries(temp_track)
|
||||
logger.info(f"[Quality Scanner] Generated {len(search_queries)} search queries for {artist_name} - {title}")
|
||||
|
||||
# Find best match using confidence scoring
|
||||
best_match = None
|
||||
best_confidence = 0.0
|
||||
min_confidence = 0.7 # Match existing standard
|
||||
|
||||
for _query_idx, search_query in enumerate(search_queries):
|
||||
try:
|
||||
for source in source_priority:
|
||||
client = get_client_for_source(source)
|
||||
if not client or not hasattr(client, 'search_tracks'):
|
||||
continue
|
||||
|
||||
attempted_any_provider = True
|
||||
provider_matches = _search_tracks_for_source(source, search_query, limit=5, client=client)
|
||||
time.sleep(0.5) # Rate limit metadata API calls
|
||||
|
||||
if not provider_matches:
|
||||
continue
|
||||
|
||||
# Score each result using matching engine
|
||||
for provider_track in provider_matches:
|
||||
try:
|
||||
# Calculate artist confidence
|
||||
artist_confidence = 0.0
|
||||
provider_artists = _track_artist_names(provider_track)
|
||||
if provider_artists:
|
||||
for result_artist in provider_artists:
|
||||
artist_sim = deps.matching_engine.similarity_score(
|
||||
deps.matching_engine.normalize_string(artist_name),
|
||||
deps.matching_engine.normalize_string(result_artist)
|
||||
)
|
||||
artist_confidence = max(artist_confidence, artist_sim)
|
||||
|
||||
# Calculate title confidence
|
||||
title_confidence = deps.matching_engine.similarity_score(
|
||||
deps.matching_engine.normalize_string(title),
|
||||
deps.matching_engine.normalize_string(_track_name(provider_track))
|
||||
)
|
||||
|
||||
# Combined confidence (50% artist + 50% title)
|
||||
combined_confidence = (artist_confidence * 0.5 + title_confidence * 0.5)
|
||||
|
||||
# Small bonus for album tracks over singles
|
||||
_at = _extract_lookup_value(provider_track, 'album_type', default='') or ''
|
||||
if _at == 'album':
|
||||
combined_confidence += 0.02
|
||||
elif _at == 'ep':
|
||||
combined_confidence += 0.01
|
||||
|
||||
candidate_artist = provider_artists[0] if provider_artists else 'Unknown Artist'
|
||||
candidate_name = _track_name(provider_track)
|
||||
logger.info(
|
||||
f"[Quality Scanner] Candidate ({source}): '{candidate_artist}' - "
|
||||
f"'{candidate_name}' (confidence: {combined_confidence:.3f})"
|
||||
)
|
||||
|
||||
# Update best match if this is better
|
||||
if combined_confidence > best_confidence and combined_confidence >= min_confidence:
|
||||
best_confidence = combined_confidence
|
||||
best_match = provider_track
|
||||
best_source = source
|
||||
logger.info(
|
||||
f"[Quality Scanner] New best match ({source}): {candidate_artist} - "
|
||||
f"{candidate_name} (confidence: {combined_confidence:.3f})"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Quality Scanner] Error scoring result: {e}")
|
||||
continue
|
||||
|
||||
# If we found a very high confidence match, stop searching this query
|
||||
if best_confidence >= 0.9:
|
||||
logger.info(f"[Quality Scanner] High confidence match found ({best_confidence:.3f}), stopping search")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"[Quality Scanner] Error searching with query '{search_query}': {e}")
|
||||
continue
|
||||
|
||||
if not attempted_any_provider:
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["status"] = "error"
|
||||
deps.quality_scanner_state["phase"] = "No metadata provider available"
|
||||
deps.quality_scanner_state["error_message"] = "No metadata provider is available for quality scanning"
|
||||
logger.info("[Quality Scanner] No metadata provider available")
|
||||
return
|
||||
|
||||
# Process best match
|
||||
if best_match:
|
||||
matched = True
|
||||
final_artist = _track_artist_names(best_match)[0] if _track_artist_names(best_match) else 'Unknown Artist'
|
||||
final_name = _track_name(best_match)
|
||||
final_source = best_source or 'metadata'
|
||||
logger.info(
|
||||
f"[Quality Scanner] Final match ({final_source}): {final_artist} - "
|
||||
f"{final_name} (confidence: {best_confidence:.3f})"
|
||||
)
|
||||
|
||||
# Build normalized track data for wishlist
|
||||
matched_track_data = _normalize_track_match(best_match, final_source)
|
||||
|
||||
# Add to wishlist
|
||||
source_context = {
|
||||
'quality_scanner': True,
|
||||
'original_file_path': file_path,
|
||||
'original_format': tier_name,
|
||||
'original_bitrate': bitrate,
|
||||
'match_confidence': best_confidence,
|
||||
'scan_date': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
success = add_to_wishlist(
|
||||
track_data=matched_track_data,
|
||||
failure_reason=f"Low quality - {tier_name.replace('_', ' ').title()} format",
|
||||
source_type='quality_scanner',
|
||||
source_context=source_context,
|
||||
profile_id=profile_id
|
||||
)
|
||||
|
||||
if success:
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["matched"] += 1
|
||||
logger.info(f"[Quality Scanner] Matched and added to wishlist: {artist_name} - {title}")
|
||||
else:
|
||||
logger.error(f"[Quality Scanner] Failed to add to wishlist: {artist_name} - {title}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"[Quality Scanner] No suitable metadata match found "
|
||||
f"(best confidence: {best_confidence:.3f}, required: {min_confidence:.3f})"
|
||||
)
|
||||
|
||||
except Exception as matching_error:
|
||||
logger.error(f"[Quality Scanner] Matching error for {artist_name} - {title}: {matching_error}")
|
||||
|
||||
# Store result
|
||||
result_entry = {
|
||||
'track_id': track_id,
|
||||
'title': title,
|
||||
'artist': artist_name,
|
||||
'album': album_title,
|
||||
'file_path': file_path,
|
||||
'current_format': tier_name,
|
||||
'bitrate': bitrate,
|
||||
'matched': matched,
|
||||
'match_id': matched_track_data['id'] if matched_track_data else None,
|
||||
'provider': best_source if matched else None,
|
||||
'spotify_id': matched_track_data['id'] if matched_track_data else None,
|
||||
}
|
||||
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["results"].append(result_entry)
|
||||
|
||||
if not matched:
|
||||
logger.warning(f"[Quality Scanner] No metadata match found for: {artist_name} - {title}")
|
||||
|
||||
except Exception as track_error:
|
||||
logger.error(f"[Quality Scanner] Error processing track: {track_error}")
|
||||
continue
|
||||
|
||||
# Scan complete (don't overwrite if already stopped by user)
|
||||
with deps.quality_scanner_lock:
|
||||
was_stopped = deps.quality_scanner_state["status"] != "running"
|
||||
deps.quality_scanner_state["status"] = "finished"
|
||||
deps.quality_scanner_state["progress"] = 100
|
||||
if not was_stopped:
|
||||
deps.quality_scanner_state["phase"] = "Scan complete"
|
||||
|
||||
logger.info(f"[Quality Scanner] Scan {'stopped' if was_stopped else 'complete'}: {deps.quality_scanner_state['processed']} processed, "
|
||||
f"{deps.quality_scanner_state['low_quality']} low quality, {deps.quality_scanner_state['matched']} matched to metadata providers")
|
||||
|
||||
# Add activity
|
||||
deps.add_activity_item("", "Quality Scan Complete",
|
||||
f"{deps.quality_scanner_state['matched']} tracks added to wishlist", "Now")
|
||||
|
||||
try:
|
||||
if deps.automation_engine:
|
||||
deps.automation_engine.emit('quality_scan_completed', {
|
||||
'quality_met': str(deps.quality_scanner_state.get('quality_met', 0)),
|
||||
'low_quality': str(deps.quality_scanner_state.get('low_quality', 0)),
|
||||
'total_scanned': str(deps.quality_scanner_state.get('processed', 0)),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Quality Scanner] Critical error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["status"] = "error"
|
||||
deps.quality_scanner_state["error_message"] = str(e)
|
||||
deps.quality_scanner_state["phase"] = f"Error: {str(e)}"
|
||||
323
core/discovery/scoring.py
Normal file
323
core/discovery/scoring.py
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
"""Discovery scoring + tidal-track search — lifted from web_server.py.
|
||||
|
||||
Both function bodies are byte-identical to the originals. The
|
||||
``spotify_client`` proxy and ``_get_metadata_fallback_source`` shim
|
||||
let the bodies resolve their original names without modification.
|
||||
``matching_engine`` is injected via init() because it is constructed
|
||||
in web_server.py and referenced by name throughout the bodies.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from core.metadata.cache import get_metadata_cache
|
||||
from core.metadata.registry import get_primary_source, get_spotify_client
|
||||
from core.spotify_client import _is_globally_rate_limited as _spotify_rate_limited
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_metadata_fallback_source():
|
||||
"""Mirror of web_server._get_metadata_fallback_source — delegates to registry."""
|
||||
return get_primary_source()
|
||||
|
||||
|
||||
class _SpotifyClientProxy:
|
||||
"""Resolves the global Spotify client lazily through core.metadata.registry."""
|
||||
|
||||
def __getattr__(self, name):
|
||||
client = get_spotify_client()
|
||||
if client is None:
|
||||
raise AttributeError(name)
|
||||
return getattr(client, name)
|
||||
|
||||
def __bool__(self):
|
||||
return get_spotify_client() is not None
|
||||
|
||||
|
||||
spotify_client = _SpotifyClientProxy()
|
||||
|
||||
|
||||
# Injected at runtime via init().
|
||||
matching_engine = None
|
||||
|
||||
|
||||
def init(matching_engine_obj):
|
||||
"""Bind the shared matching engine instance from web_server."""
|
||||
global matching_engine
|
||||
matching_engine = matching_engine_obj
|
||||
|
||||
|
||||
def _discovery_score_candidates(source_title, source_artist, source_duration_ms, search_results):
|
||||
"""Score search results against a source track using the matching engine.
|
||||
|
||||
Both artist AND title must independently pass minimum similarity floors.
|
||||
This prevents weighted scoring from allowing a perfect artist to carry a
|
||||
garbage title (or vice versa). If either dimension doesn't match, the
|
||||
candidate is rejected — no match is better than a wrong match.
|
||||
|
||||
Args:
|
||||
source_title: The source track title (already cleaned for YouTube, raw for others)
|
||||
source_artist: The source track primary artist
|
||||
source_duration_ms: The source track duration in ms (0 if unknown)
|
||||
search_results: List of Track objects (Spotify or iTunes) from search
|
||||
|
||||
Returns:
|
||||
(best_match, best_confidence, best_index) or (None, 0.0, -1) if no results
|
||||
"""
|
||||
best_match = None
|
||||
best_confidence = 0.0
|
||||
best_index = -1
|
||||
min_artist_similarity = 0.5
|
||||
min_title_similarity = 0.5
|
||||
|
||||
source_artist_cleaned = matching_engine.clean_artist(source_artist)
|
||||
source_title_cleaned = matching_engine.clean_title(source_title)
|
||||
source_core_title = matching_engine.get_core_string(source_title)
|
||||
|
||||
for idx, result in enumerate(search_results):
|
||||
try:
|
||||
result_artists = result.artists if hasattr(result, 'artists') and result.artists else []
|
||||
result_name = result.name if hasattr(result, 'name') else ''
|
||||
result_duration = result.duration_ms if hasattr(result, 'duration_ms') else 0
|
||||
|
||||
# Artist floor — both must match, not just the weighted score
|
||||
best_artist_sim = 0.0
|
||||
for cand_artist in result_artists:
|
||||
if not cand_artist:
|
||||
continue
|
||||
cand_cleaned = matching_engine.clean_artist(cand_artist)
|
||||
cand_normalized = matching_engine.normalize_string(cand_artist)
|
||||
if source_artist_cleaned and source_artist_cleaned in cand_normalized:
|
||||
best_artist_sim = 1.0
|
||||
break
|
||||
sim = matching_engine.similarity_score(source_artist_cleaned, cand_cleaned)
|
||||
if sim > best_artist_sim:
|
||||
best_artist_sim = sim
|
||||
|
||||
if best_artist_sim < min_artist_similarity:
|
||||
continue
|
||||
|
||||
# Title floor — both must match, not just the weighted score
|
||||
cand_title_cleaned = matching_engine.clean_title(result_name)
|
||||
cand_core_title = matching_engine.get_core_string(result_name)
|
||||
|
||||
# Core title exact match bypasses the floor (e.g., "edamame" == "edamame")
|
||||
title_passes = False
|
||||
if source_core_title and cand_core_title and source_core_title == cand_core_title:
|
||||
title_passes = True
|
||||
else:
|
||||
title_sim = matching_engine.similarity_score(source_title_cleaned, cand_title_cleaned)
|
||||
if title_sim >= min_title_similarity:
|
||||
title_passes = True
|
||||
|
||||
if not title_passes:
|
||||
continue
|
||||
|
||||
# Both floors passed — now do full scoring
|
||||
confidence, match_type = matching_engine.score_track_match(
|
||||
source_title=source_title,
|
||||
source_artists=[source_artist],
|
||||
source_duration_ms=source_duration_ms,
|
||||
candidate_title=result_name,
|
||||
candidate_artists=result_artists,
|
||||
candidate_duration_ms=result_duration
|
||||
)
|
||||
|
||||
if confidence > best_confidence:
|
||||
best_confidence = confidence
|
||||
best_match = result
|
||||
best_index = idx
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error scoring candidate {idx}: {e}")
|
||||
continue
|
||||
|
||||
return best_match, best_confidence, best_index
|
||||
|
||||
|
||||
def _search_spotify_for_tidal_track(tidal_track, use_spotify=True, itunes_client=None):
|
||||
"""Search Spotify/fallback for a Tidal track using matching_engine for better accuracy
|
||||
|
||||
Args:
|
||||
tidal_track: The Tidal track to search for
|
||||
use_spotify: If True, use Spotify; if False, use fallback source
|
||||
itunes_client: Fallback client instance (required when use_spotify=False)
|
||||
|
||||
Returns:
|
||||
For Spotify: (Track, raw_data, confidence) tuple or None
|
||||
For fallback: dict with track data (includes 'confidence' key) or None
|
||||
"""
|
||||
if use_spotify:
|
||||
if not spotify_client or not spotify_client.is_authenticated():
|
||||
return None
|
||||
else:
|
||||
if not itunes_client:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Get track info
|
||||
track_name = tidal_track.name
|
||||
artists = tidal_track.artists or []
|
||||
|
||||
if not artists:
|
||||
return None
|
||||
|
||||
artist_name = artists[0] # Use primary artist
|
||||
source_duration = getattr(tidal_track, 'duration_ms', 0) or 0
|
||||
source_name = "Spotify" if use_spotify else _get_metadata_fallback_source().capitalize()
|
||||
|
||||
logger.info(f"Tidal track: '{artist_name}' - '{track_name}' (searching {source_name})")
|
||||
|
||||
# Use matching engine to generate search queries (with fallback)
|
||||
try:
|
||||
temp_track = type('TempTrack', (), {
|
||||
'name': track_name,
|
||||
'artists': [artist_name],
|
||||
'album': None
|
||||
})()
|
||||
search_queries = matching_engine.generate_download_queries(temp_track)
|
||||
logger.info(f"Generated {len(search_queries)} search queries for Tidal track")
|
||||
except Exception as e:
|
||||
logger.error(f"Matching engine failed for Tidal, falling back to basic queries: {e}")
|
||||
if use_spotify:
|
||||
search_queries = [
|
||||
f'track:"{track_name}" artist:"{artist_name}"',
|
||||
f'"{track_name}" "{artist_name}"',
|
||||
f'{track_name} {artist_name}'
|
||||
]
|
||||
else:
|
||||
search_queries = [
|
||||
f'{artist_name} {track_name}',
|
||||
f'{track_name} {artist_name}',
|
||||
track_name
|
||||
]
|
||||
|
||||
best_match = None
|
||||
best_match_raw = None
|
||||
best_confidence = 0.0
|
||||
min_confidence = 0.9
|
||||
|
||||
for query_idx, search_query in enumerate(search_queries):
|
||||
try:
|
||||
logger.debug(f"Tidal query {query_idx + 1}/{len(search_queries)}: {search_query} ({source_name})")
|
||||
|
||||
if use_spotify and not _spotify_rate_limited():
|
||||
results = spotify_client.search_tracks(search_query, limit=10)
|
||||
if not results:
|
||||
continue
|
||||
else:
|
||||
results = itunes_client.search_tracks(search_query, limit=10)
|
||||
if not results:
|
||||
continue
|
||||
|
||||
# Score all results using the matching engine
|
||||
match, confidence, match_idx = _discovery_score_candidates(
|
||||
track_name, artist_name, source_duration, results
|
||||
)
|
||||
|
||||
if match and confidence > best_confidence and confidence >= min_confidence:
|
||||
best_confidence = confidence
|
||||
best_match = match
|
||||
if use_spotify and match.id:
|
||||
_cache = get_metadata_cache()
|
||||
best_match_raw = _cache.get_entity('spotify', 'track', match.id)
|
||||
else:
|
||||
best_match_raw = None
|
||||
logger.info(f"New best Tidal match: {match.artists[0]} - {match.name} (confidence: {confidence:.3f})")
|
||||
|
||||
if best_confidence >= 0.9:
|
||||
logger.info(f"High confidence Tidal match found ({best_confidence:.3f}), stopping search")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error in Tidal {source_name} search for query '{search_query}': {e}")
|
||||
continue
|
||||
|
||||
# Strategy 4: Extended search with higher limit (last resort)
|
||||
if not best_match:
|
||||
logger.info("Tidal Strategy 4: Extended search with limit=50")
|
||||
query = f"{artist_name} {track_name}"
|
||||
if use_spotify:
|
||||
extended_results = spotify_client.search_tracks(query, limit=50)
|
||||
else:
|
||||
extended_results = itunes_client.search_tracks(query, limit=50)
|
||||
if extended_results:
|
||||
match, confidence, match_idx = _discovery_score_candidates(
|
||||
track_name, artist_name, source_duration, extended_results
|
||||
)
|
||||
if match and confidence >= min_confidence:
|
||||
best_match = match
|
||||
best_confidence = confidence
|
||||
logger.info(f"Strategy 4 Tidal match (extended): {match.artists[0]} - {match.name} (confidence: {confidence:.3f})")
|
||||
|
||||
if best_match:
|
||||
if use_spotify:
|
||||
logger.info(f"Final Tidal Spotify match: {best_match.artists[0]} - {best_match.name} (confidence: {best_confidence:.3f})")
|
||||
return (best_match, best_match_raw, best_confidence)
|
||||
else:
|
||||
result_artists = best_match.artists if hasattr(best_match, 'artists') else []
|
||||
result_artist = result_artists[0] if result_artists else 'Unknown'
|
||||
result_name = best_match.name if hasattr(best_match, 'name') else 'Unknown'
|
||||
logger.info(f"Final Tidal {source_name} match: {result_artist} - {result_name} (confidence: {best_confidence:.3f})")
|
||||
|
||||
album_name = best_match.album if hasattr(best_match, 'album') else 'Unknown Album'
|
||||
image_url = best_match.image_url if hasattr(best_match, 'image_url') else ''
|
||||
track_id = best_match.id if hasattr(best_match, 'id') else ''
|
||||
duration_ms = best_match.duration_ms if hasattr(best_match, 'duration_ms') else 0
|
||||
|
||||
# Fetch full track details to get album ID, track_number, etc.
|
||||
# The Track dataclass strips this data — the API has it
|
||||
album_obj = {
|
||||
'name': album_name,
|
||||
'album_type': 'album',
|
||||
'release_date': getattr(best_match, 'release_date', '') or '',
|
||||
'images': [{'url': image_url, 'height': 300, 'width': 300}] if image_url else []
|
||||
}
|
||||
track_number = None
|
||||
disc_number = None
|
||||
if track_id:
|
||||
try:
|
||||
detailed = itunes_client.get_track_details(track_id)
|
||||
if detailed and isinstance(detailed.get('album'), dict):
|
||||
dt_album = detailed['album']
|
||||
if dt_album.get('id'):
|
||||
album_obj['id'] = dt_album['id']
|
||||
if dt_album.get('total_tracks'):
|
||||
album_obj['total_tracks'] = dt_album['total_tracks']
|
||||
if dt_album.get('release_date') and not album_obj.get('release_date'):
|
||||
album_obj['release_date'] = dt_album['release_date']
|
||||
if dt_album.get('album_type'):
|
||||
album_obj['album_type'] = dt_album['album_type']
|
||||
if dt_album.get('images') and not album_obj.get('images'):
|
||||
album_obj['images'] = dt_album['images']
|
||||
if dt_album.get('artists'):
|
||||
album_obj['artists'] = dt_album['artists']
|
||||
if detailed:
|
||||
track_number = detailed.get('track_number')
|
||||
disc_number = detailed.get('disc_number')
|
||||
logger.info(f"[Discovery Enrich] {result_name}: track_number={track_number}, disc={disc_number}")
|
||||
else:
|
||||
logger.info(f"[Discovery Enrich] get_track_details returned None for ID {track_id} ({result_name})")
|
||||
except Exception as _enrich_err:
|
||||
logger.error(f"[Discovery Enrich] Failed for {result_name} (ID {track_id}): {_enrich_err}")
|
||||
|
||||
result_data = {
|
||||
'id': track_id,
|
||||
'name': result_name,
|
||||
'artists': [result_artist],
|
||||
'album': album_obj,
|
||||
'duration_ms': duration_ms,
|
||||
'source': _get_metadata_fallback_source(),
|
||||
'confidence': best_confidence
|
||||
}
|
||||
if track_number:
|
||||
result_data['track_number'] = track_number
|
||||
if disc_number:
|
||||
result_data['disc_number'] = disc_number
|
||||
return result_data
|
||||
else:
|
||||
logger.warning(f"No suitable Tidal match found (best confidence was {best_confidence:.3f}, required {min_confidence:.3f})")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching Spotify for Tidal track: {e}")
|
||||
return None
|
||||
337
core/discovery/spotify_public.py
Normal file
337
core/discovery/spotify_public.py
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
"""Background worker for public Spotify-link playlist discovery.
|
||||
|
||||
`run_spotify_public_discovery_worker(url_hash, deps)` is the function the
|
||||
spotify-public discovery start-endpoint submits to its executor to match
|
||||
each public Spotify playlist track against Spotify (preferred) or iTunes
|
||||
(fallback):
|
||||
|
||||
1. Pause enrichment workers (release shared resources).
|
||||
2. For each track:
|
||||
- Cancellation gate (state['cancelled']).
|
||||
- Discovery cache lookup; cache hit short-circuits the search and
|
||||
populates display fields from the cached match.
|
||||
- SimpleNamespace duck-type → `_search_spotify_for_tidal_track`
|
||||
(shared search helper, returns tuple for Spotify or dict for iTunes).
|
||||
- On Spotify match: build `match_data` preserving track_number /
|
||||
disc_number from raw API data, image extracted from album images
|
||||
or track object fallback, release_date filled from track.release_date
|
||||
when album dict is missing it.
|
||||
- On iTunes match: dict result populated as `match_data`, source set
|
||||
to discovery_source, image extracted from album images.
|
||||
- Save matched result to discovery cache.
|
||||
- On miss: Wing It stub stored as 'wing-it' status.
|
||||
3. After all tracks: phase='discovered', activity feed entry.
|
||||
4. On error: state['phase']='error' + status with error string.
|
||||
5. Finally: resume enrichment workers.
|
||||
|
||||
Lifted verbatim from web_server.py. Wide dependency surface (Spotify and
|
||||
iTunes clients, multiple metadata helpers, state dict, shared tidal
|
||||
search helper) all injected via `SpotifyPublicDiscoveryDeps`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
import types
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpotifyPublicDiscoveryDeps:
|
||||
"""Bundle of cross-cutting deps the Spotify Public discovery worker needs."""
|
||||
spotify_public_discovery_states: dict
|
||||
spotify_client: Any
|
||||
pause_enrichment_workers: Callable[[str], dict]
|
||||
resume_enrichment_workers: Callable[[dict, str], None]
|
||||
get_active_discovery_source: Callable[[], str]
|
||||
get_metadata_fallback_client: Callable[[], Any]
|
||||
get_discovery_cache_key: Callable
|
||||
get_database: Callable[[], Any]
|
||||
validate_discovery_cache_artist: Callable
|
||||
search_spotify_for_tidal_track: Callable
|
||||
build_discovery_wing_it_stub: Callable
|
||||
add_activity_item: Callable
|
||||
|
||||
|
||||
def run_spotify_public_discovery_worker(url_hash, deps: SpotifyPublicDiscoveryDeps):
|
||||
"""Background worker for Spotify Public discovery process (Spotify preferred, iTunes fallback)"""
|
||||
_ew_state = {}
|
||||
try:
|
||||
_ew_state = deps.pause_enrichment_workers('Spotify Public discovery')
|
||||
state = deps.spotify_public_discovery_states[url_hash]
|
||||
playlist = state['playlist']
|
||||
|
||||
# Determine which provider to use — respect user's configured primary source
|
||||
discovery_source = deps.get_active_discovery_source()
|
||||
use_spotify = (discovery_source == 'spotify') and deps.spotify_client and deps.spotify_client.is_spotify_authenticated()
|
||||
|
||||
# Initialize fallback client if needed
|
||||
itunes_client_instance = None
|
||||
if not use_spotify:
|
||||
itunes_client_instance = deps.get_metadata_fallback_client()
|
||||
|
||||
logger.info(f"Starting Spotify Public discovery for: {playlist['name']} (using {discovery_source.upper()})")
|
||||
|
||||
# Store discovery source in state for frontend
|
||||
state['discovery_source'] = discovery_source
|
||||
|
||||
successful_discoveries = 0
|
||||
tracks = playlist['tracks']
|
||||
|
||||
for i, sp_track in enumerate(tracks):
|
||||
if state.get('cancelled', False):
|
||||
break
|
||||
|
||||
try:
|
||||
track_name = sp_track['name']
|
||||
track_artists_raw = sp_track.get('artists', [])
|
||||
# Normalize artists to list of strings
|
||||
track_artists = []
|
||||
for a in track_artists_raw:
|
||||
if isinstance(a, dict):
|
||||
track_artists.append(a.get('name', ''))
|
||||
else:
|
||||
track_artists.append(str(a))
|
||||
track_id = sp_track.get('id', '')
|
||||
track_album = sp_track.get('album', '')
|
||||
if isinstance(track_album, dict):
|
||||
track_album_name = track_album.get('name', '')
|
||||
else:
|
||||
track_album_name = track_album or ''
|
||||
track_duration_ms = sp_track.get('duration_ms', 0)
|
||||
|
||||
logger.info(f"[{i+1}/{len(tracks)}] Searching {discovery_source.upper()}: {track_name} by {', '.join(track_artists)}")
|
||||
|
||||
# Check discovery cache first
|
||||
cache_key = deps.get_discovery_cache_key(track_name, track_artists[0] if track_artists else '')
|
||||
try:
|
||||
cache_db = deps.get_database()
|
||||
cached_match = cache_db.get_discovery_cache_match(cache_key[0], cache_key[1], discovery_source)
|
||||
if cached_match and deps.validate_discovery_cache_artist(track_artists[0] if track_artists else '', cached_match):
|
||||
logger.debug(f"CACHE HIT [{i+1}/{len(tracks)}]: {track_name} by {', '.join(track_artists)}")
|
||||
# Extract display-friendly artist string from cached match
|
||||
cached_artists = cached_match.get('artists', [])
|
||||
if cached_artists:
|
||||
cached_artist_str = ', '.join(
|
||||
a if isinstance(a, str) else a.get('name', '') for a in cached_artists
|
||||
)
|
||||
else:
|
||||
cached_artist_str = ''
|
||||
cached_album = cached_match.get('album', '')
|
||||
if isinstance(cached_album, dict):
|
||||
cached_album = cached_album.get('name', '')
|
||||
|
||||
result = {
|
||||
'spotify_public_track': {
|
||||
'id': track_id,
|
||||
'name': track_name,
|
||||
'artists': track_artists or [],
|
||||
'album': track_album_name,
|
||||
'duration_ms': track_duration_ms,
|
||||
},
|
||||
'spotify_data': cached_match,
|
||||
'match_data': cached_match,
|
||||
'status': 'Found',
|
||||
'status_class': 'found',
|
||||
'spotify_track': cached_match.get('name', ''),
|
||||
'spotify_artist': cached_artist_str,
|
||||
'spotify_album': cached_album,
|
||||
'spotify_id': cached_match.get('id', ''),
|
||||
'discovery_source': discovery_source,
|
||||
'index': i
|
||||
}
|
||||
successful_discoveries += 1
|
||||
state['spotify_matches'] = successful_discoveries
|
||||
state['discovery_results'].append(result)
|
||||
state['discovery_progress'] = int(((i + 1) / len(tracks)) * 100)
|
||||
continue
|
||||
except Exception as cache_err:
|
||||
logger.error(f"Cache lookup error: {cache_err}")
|
||||
|
||||
# Create a SimpleNamespace duck-type object for _search_spotify_for_tidal_track
|
||||
track_ns = types.SimpleNamespace(
|
||||
id=track_id,
|
||||
name=track_name,
|
||||
artists=track_artists,
|
||||
album=track_album_name,
|
||||
duration_ms=track_duration_ms
|
||||
)
|
||||
|
||||
# Use the search function with appropriate provider
|
||||
track_result = deps.search_spotify_for_tidal_track(
|
||||
track_ns,
|
||||
use_spotify=use_spotify,
|
||||
itunes_client=itunes_client_instance
|
||||
)
|
||||
|
||||
# Create result entry
|
||||
result = {
|
||||
'spotify_public_track': {
|
||||
'id': track_id,
|
||||
'name': track_name,
|
||||
'artists': track_artists or [],
|
||||
'album': track_album_name,
|
||||
'duration_ms': track_duration_ms,
|
||||
},
|
||||
'spotify_data': None,
|
||||
'match_data': None,
|
||||
'status': 'Not Found',
|
||||
'status_class': 'not-found',
|
||||
'spotify_track': '',
|
||||
'spotify_artist': '',
|
||||
'spotify_album': '',
|
||||
'discovery_source': discovery_source
|
||||
}
|
||||
|
||||
match_confidence = 0.0
|
||||
|
||||
if use_spotify and isinstance(track_result, tuple):
|
||||
# Spotify: Function returns (Track, raw_data, confidence)
|
||||
track_obj, raw_track_data, match_confidence = track_result
|
||||
album_obj = raw_track_data.get('album', {}) if raw_track_data else {}
|
||||
# Ensure album has a name — fall back to track_obj.album if raw_data was missing
|
||||
if isinstance(album_obj, dict) and not album_obj.get('name') and track_obj.album:
|
||||
album_obj['name'] = track_obj.album
|
||||
elif not album_obj and track_obj.album:
|
||||
album_obj = {'name': track_obj.album}
|
||||
# Ensure release_date is present (raw Spotify data has it, but fallback may not)
|
||||
if isinstance(album_obj, dict) and not album_obj.get('release_date'):
|
||||
album_obj['release_date'] = getattr(track_obj, 'release_date', '') or ''
|
||||
# Extract image URL from album data or track object
|
||||
_album_images = album_obj.get('images', []) if isinstance(album_obj, dict) else []
|
||||
_image_url = _album_images[0].get('url', '') if _album_images else (getattr(track_obj, 'image_url', '') or '')
|
||||
|
||||
match_data = {
|
||||
'id': track_obj.id,
|
||||
'name': track_obj.name,
|
||||
'artists': track_obj.artists,
|
||||
'album': album_obj,
|
||||
'duration_ms': track_obj.duration_ms,
|
||||
'external_urls': track_obj.external_urls,
|
||||
'image_url': _image_url,
|
||||
'source': 'spotify'
|
||||
}
|
||||
# Preserve track_number/disc_number from raw Spotify API data
|
||||
if raw_track_data and raw_track_data.get('track_number'):
|
||||
match_data['track_number'] = raw_track_data['track_number']
|
||||
if raw_track_data and raw_track_data.get('disc_number'):
|
||||
match_data['disc_number'] = raw_track_data['disc_number']
|
||||
result['spotify_data'] = match_data
|
||||
result['match_data'] = match_data
|
||||
result['status'] = 'Found'
|
||||
result['status_class'] = 'found'
|
||||
result['spotify_track'] = track_obj.name
|
||||
result['spotify_artist'] = ', '.join(track_obj.artists) if isinstance(track_obj.artists, list) else str(track_obj.artists)
|
||||
result['spotify_album'] = album_obj.get('name', '') if isinstance(album_obj, dict) else str(album_obj)
|
||||
result['spotify_id'] = track_obj.id
|
||||
result['confidence'] = match_confidence
|
||||
successful_discoveries += 1
|
||||
state['spotify_matches'] = successful_discoveries
|
||||
|
||||
elif not use_spotify and track_result and isinstance(track_result, dict):
|
||||
# Fallback: Function returns a dict with track data (includes 'confidence' key)
|
||||
match_confidence = track_result.pop('confidence', 0.80)
|
||||
match_data = track_result
|
||||
match_data['source'] = discovery_source
|
||||
# Extract image URL from album images
|
||||
_fb_album = match_data.get('album', {})
|
||||
_fb_images = _fb_album.get('images', []) if isinstance(_fb_album, dict) else []
|
||||
if _fb_images and 'image_url' not in match_data:
|
||||
match_data['image_url'] = _fb_images[0].get('url', '')
|
||||
result['spotify_data'] = match_data
|
||||
result['match_data'] = match_data
|
||||
result['status'] = 'Found'
|
||||
result['status_class'] = 'found'
|
||||
result['spotify_track'] = match_data.get('name', '')
|
||||
itunes_artists = match_data.get('artists', [])
|
||||
result['spotify_artist'] = ', '.join(a if isinstance(a, str) else a.get('name', '') for a in itunes_artists) if itunes_artists else ''
|
||||
result['spotify_album'] = match_data.get('album', {}).get('name', '') if isinstance(match_data.get('album'), dict) else match_data.get('album', '')
|
||||
result['spotify_id'] = match_data.get('id', '')
|
||||
result['confidence'] = match_confidence
|
||||
successful_discoveries += 1
|
||||
state['spotify_matches'] = successful_discoveries
|
||||
|
||||
# Save to discovery cache if match found
|
||||
if result['status_class'] == 'found' and result.get('match_data'):
|
||||
try:
|
||||
cache_db = deps.get_database()
|
||||
cache_db.save_discovery_cache_match(
|
||||
cache_key[0], cache_key[1], discovery_source, match_confidence,
|
||||
result['match_data'], track_name,
|
||||
track_artists[0] if track_artists else ''
|
||||
)
|
||||
logger.info(f"CACHE SAVED: {track_name} (confidence: {match_confidence:.3f})")
|
||||
except Exception as cache_err:
|
||||
logger.error(f"Cache save error: {cache_err}")
|
||||
|
||||
# Auto Wing It fallback for unmatched tracks
|
||||
if result['status_class'] == 'not-found':
|
||||
sp_t = result.get('spotify_public_track', {})
|
||||
stub = deps.build_discovery_wing_it_stub(
|
||||
sp_t.get('name', ''),
|
||||
', '.join(sp_t.get('artists', [])),
|
||||
sp_t.get('duration_ms', 0)
|
||||
)
|
||||
result['status'] = 'Wing It'
|
||||
result['status_class'] = 'wing-it'
|
||||
result['spotify_data'] = stub
|
||||
result['match_data'] = stub
|
||||
result['spotify_track'] = sp_t.get('name', '')
|
||||
result['spotify_artist'] = ', '.join(sp_t.get('artists', []))
|
||||
result['wing_it_fallback'] = True
|
||||
result['confidence'] = 0
|
||||
successful_discoveries += 1
|
||||
state['spotify_matches'] = successful_discoveries
|
||||
state['wing_it_count'] = state.get('wing_it_count', 0) + 1
|
||||
|
||||
result['index'] = i
|
||||
state['discovery_results'].append(result)
|
||||
state['discovery_progress'] = int(((i + 1) / len(tracks)) * 100)
|
||||
|
||||
# Add delay between requests
|
||||
time.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing track {i+1}: {e}")
|
||||
# Add error result
|
||||
result = {
|
||||
'spotify_public_track': {
|
||||
'name': sp_track.get('name', 'Unknown'),
|
||||
'artists': sp_track.get('artists', []),
|
||||
},
|
||||
'spotify_data': None,
|
||||
'match_data': None,
|
||||
'status': 'Error',
|
||||
'status_class': 'error',
|
||||
'spotify_track': '',
|
||||
'spotify_artist': '',
|
||||
'spotify_album': '',
|
||||
'error': str(e),
|
||||
'discovery_source': discovery_source,
|
||||
'index': i
|
||||
}
|
||||
state['discovery_results'].append(result)
|
||||
state['discovery_progress'] = int(((i + 1) / len(tracks)) * 100)
|
||||
|
||||
# Mark as complete
|
||||
state['phase'] = 'discovered'
|
||||
state['status'] = 'discovered'
|
||||
state['discovery_progress'] = 100
|
||||
|
||||
# Add activity for discovery completion
|
||||
source_label = discovery_source.upper()
|
||||
deps.add_activity_item("", f"Spotify Link Discovery Complete ({source_label})", f"'{playlist['name']}' - {successful_discoveries}/{len(tracks)} tracks found", "Now")
|
||||
|
||||
logger.info(f"Spotify Public discovery complete ({source_label}): {successful_discoveries}/{len(tracks)} tracks found")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in Spotify Public discovery worker: {e}")
|
||||
if url_hash in deps.spotify_public_discovery_states:
|
||||
deps.spotify_public_discovery_states[url_hash]['phase'] = 'error'
|
||||
deps.spotify_public_discovery_states[url_hash]['status'] = f'error: {str(e)}'
|
||||
finally:
|
||||
deps.resume_enrichment_workers(_ew_state, 'Spotify Public discovery')
|
||||
502
core/discovery/sync.py
Normal file
502
core/discovery/sync.py
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
"""Background worker for the playlist sync task.
|
||||
|
||||
`run_sync_task(playlist_id, playlist_name, tracks_json, automation_id, profile_id,
|
||||
playlist_image_url, deps)` is the function `sync_executor.submit(...)` invokes
|
||||
to drive the entire playlist-sync workflow:
|
||||
|
||||
1. Convert frontend JSON tracks → SpotifyTrack/SpotifyPlaylist objects.
|
||||
2. Normalize artist/album shapes for downstream wishlist parity.
|
||||
3. Wire a progress_callback that updates `sync_states` + automation card.
|
||||
4. Patch sync_service for database-only fallback when no media server is connected.
|
||||
5. `run_async(sync_service.sync_playlist(...))` and capture the result.
|
||||
6. Update sync_states to 'finished', push playlist poster image to Plex/Jellyfin/Emby,
|
||||
record sync history (with re-sync vs new-sync branching), emit
|
||||
`playlist_synced` event for automation engine, and persist sync status with a
|
||||
tracks_hash for smart-skip on the next scheduled sync.
|
||||
7. On exception → mark error in sync_states + automation; finally clear progress
|
||||
callback + drop `_original_tracks_map` from sync_service.
|
||||
|
||||
Lifted verbatim from web_server.py. Wide dependency surface (sync_service,
|
||||
sync_states, plex/jellyfin clients, automation engine, multiple helper funcs)
|
||||
all injected via `SyncDeps`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
from core.spotify_client import Playlist as SpotifyPlaylist, Track as SpotifyTrack
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncDeps:
|
||||
"""Bundle of cross-cutting deps the sync worker needs."""
|
||||
config_manager: Any
|
||||
sync_service: Any
|
||||
plex_client: Any
|
||||
jellyfin_client: Any
|
||||
automation_engine: Any
|
||||
run_async: Callable[..., Any]
|
||||
record_sync_history_start: Callable
|
||||
update_automation_progress: Callable
|
||||
update_and_save_sync_status: Callable
|
||||
sync_states: dict
|
||||
sync_lock: Any # threading.Lock
|
||||
|
||||
|
||||
def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, profile_id=1, playlist_image_url='', deps: SyncDeps = None):
|
||||
"""The actual sync function that runs in the background thread."""
|
||||
sync_states = deps.sync_states
|
||||
sync_lock = deps.sync_lock
|
||||
sync_service = deps.sync_service
|
||||
|
||||
task_start_time = time.time()
|
||||
logger.info(f"[TIMING] _run_sync_task STARTED for playlist '{playlist_name}' at {time.strftime('%H:%M:%S')}")
|
||||
logger.info(f"Received {len(tracks_json)} tracks from frontend")
|
||||
|
||||
# Record sync history start (skip for re-syncs triggered from history)
|
||||
_is_resync = playlist_id.startswith('resync_')
|
||||
_resync_entry_id = None
|
||||
sync_batch_id = f"sync_{playlist_id}_{int(time.time())}"
|
||||
if _is_resync:
|
||||
# Extract the original entry ID from resync_{entryId}_{timestamp}
|
||||
try:
|
||||
_resync_entry_id = int(playlist_id.split('_')[1])
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
else:
|
||||
deps.record_sync_history_start(
|
||||
batch_id=sync_batch_id,
|
||||
playlist_id=playlist_id,
|
||||
playlist_name=playlist_name,
|
||||
tracks=tracks_json,
|
||||
is_album_download=False,
|
||||
album_context=None,
|
||||
artist_context=None,
|
||||
playlist_folder_mode=False,
|
||||
source_page='sync'
|
||||
)
|
||||
|
||||
try:
|
||||
# Recreate a Playlist object from the JSON data sent by the frontend
|
||||
# This avoids needing to re-fetch it from Spotify
|
||||
logger.info("Converting JSON tracks to SpotifyTrack objects...")
|
||||
|
||||
# Store original track data with full album objects (for wishlist with cover art)
|
||||
# Normalize formats for wishlist: album must be dict {'name': ...}, artists must be [{'name': ...}]
|
||||
# Important: copy data — don't mutate tracks_json since SpotifyTrack expects List[str] artists
|
||||
original_tracks_map = {}
|
||||
for t in tracks_json:
|
||||
track_id = t.get('id', '')
|
||||
if track_id:
|
||||
normalized = dict(t)
|
||||
# Normalize album to dict format, preserving images and metadata
|
||||
raw_album = normalized.get('album', '')
|
||||
if isinstance(raw_album, str):
|
||||
normalized['album'] = {
|
||||
'name': raw_album or normalized.get('name', 'Unknown Album'),
|
||||
'images': [], 'album_type': 'single', 'total_tracks': 1, 'release_date': ''
|
||||
}
|
||||
elif not isinstance(raw_album, dict):
|
||||
normalized['album'] = {
|
||||
'name': str(raw_album) if raw_album else normalized.get('name', 'Unknown Album'),
|
||||
'images': [], 'album_type': 'single', 'total_tracks': 1, 'release_date': ''
|
||||
}
|
||||
else:
|
||||
# Dict — ensure required keys exist
|
||||
raw_album.setdefault('name', 'Unknown Album')
|
||||
raw_album.setdefault('images', [])
|
||||
# Normalize artists to list of dicts
|
||||
raw_artists = normalized.get('artists', [])
|
||||
if raw_artists and isinstance(raw_artists[0], str):
|
||||
normalized['artists'] = [{'name': a} for a in raw_artists]
|
||||
original_tracks_map[track_id] = normalized
|
||||
|
||||
tracks = []
|
||||
for i, t in enumerate(tracks_json):
|
||||
# Handle album field - extract name if it's a dictionary
|
||||
raw_album = t.get('album', '')
|
||||
if isinstance(raw_album, dict) and 'name' in raw_album:
|
||||
album_name = raw_album['name']
|
||||
elif isinstance(raw_album, str):
|
||||
album_name = raw_album
|
||||
else:
|
||||
album_name = str(raw_album)
|
||||
|
||||
# Extract image URL from album data if available
|
||||
_track_image = ''
|
||||
if isinstance(raw_album, dict):
|
||||
_imgs = raw_album.get('images', [])
|
||||
if _imgs and isinstance(_imgs, list) and len(_imgs) > 0:
|
||||
_track_image = _imgs[0].get('url', '') if isinstance(_imgs[0], dict) else ''
|
||||
if not _track_image:
|
||||
_track_image = t.get('image_url', '')
|
||||
|
||||
# Create SpotifyTrack objects with proper default values for missing fields
|
||||
track = SpotifyTrack(
|
||||
id=t.get('id', ''),
|
||||
name=t.get('name', ''),
|
||||
artists=t.get('artists', []),
|
||||
album=album_name,
|
||||
duration_ms=t.get('duration_ms', 0),
|
||||
popularity=t.get('popularity', 0),
|
||||
preview_url=t.get('preview_url'),
|
||||
external_urls=t.get('external_urls'),
|
||||
image_url=_track_image or None
|
||||
)
|
||||
tracks.append(track)
|
||||
if i < 3: # Log first 3 tracks for debugging
|
||||
logger.info(f" Track {i+1}: '{track.name}' by {track.artists}")
|
||||
|
||||
logger.info(f"Created {len(tracks)} SpotifyTrack objects")
|
||||
|
||||
playlist = SpotifyPlaylist(
|
||||
id=playlist_id,
|
||||
name=playlist_name,
|
||||
description=None, # Not needed for sync
|
||||
owner="web_user", # Placeholder
|
||||
public=False, # Default
|
||||
collaborative=False, # Default
|
||||
tracks=tracks,
|
||||
total_tracks=len(tracks)
|
||||
)
|
||||
logger.info(f"Created SpotifyPlaylist object: '{playlist.name}' with {playlist.total_tracks} tracks")
|
||||
|
||||
first_callback_time = [None] # Use list to allow modification in nested function
|
||||
|
||||
def progress_callback(progress):
|
||||
"""Callback to update the shared state."""
|
||||
if first_callback_time[0] is None:
|
||||
first_callback_time[0] = time.time()
|
||||
first_callback_duration = (first_callback_time[0] - task_start_time) * 1000
|
||||
logger.info(f"⏱️ [TIMING] FIRST progress callback at {time.strftime('%H:%M:%S')} (took {first_callback_duration:.1f}ms from start)")
|
||||
|
||||
logger.info(f"PROGRESS CALLBACK: {progress.current_step} - {progress.current_track}")
|
||||
logger.error(f" Progress: {progress.progress}% ({progress.matched_tracks}/{progress.total_tracks} matched, {progress.failed_tracks} failed)")
|
||||
|
||||
with sync_lock:
|
||||
sync_states[playlist_id] = {
|
||||
"status": "syncing",
|
||||
"progress": progress.__dict__ # Convert dataclass to dict
|
||||
}
|
||||
logger.info(f" Updated sync_states for {playlist_id}")
|
||||
|
||||
# Update automation progress card
|
||||
if automation_id:
|
||||
step = getattr(progress, 'current_step', '')
|
||||
track = getattr(progress, 'current_track', '')
|
||||
pct = getattr(progress, 'progress', 0)
|
||||
matched = getattr(progress, 'matched_tracks', 0)
|
||||
failed = getattr(progress, 'failed_tracks', 0)
|
||||
total = getattr(progress, 'total_tracks', 0)
|
||||
log_type = 'success' if 'matched' in step.lower() or 'found' in step.lower() else 'info'
|
||||
if 'not found' in step.lower() or 'failed' in step.lower():
|
||||
log_type = 'error'
|
||||
deps.update_automation_progress(automation_id, progress=pct,
|
||||
phase=f'Syncing: {step}',
|
||||
processed=matched + failed, total=total,
|
||||
current_item=track,
|
||||
log_line=f'{track} — {step}' if track else step, log_type=log_type)
|
||||
|
||||
except Exception as setup_error:
|
||||
logger.error(f"SETUP ERROR in _run_sync_task: {setup_error}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
with sync_lock:
|
||||
sync_states[playlist_id] = {
|
||||
"status": "error",
|
||||
"error": f"Setup error: {str(setup_error)}"
|
||||
}
|
||||
if automation_id:
|
||||
deps.update_automation_progress(automation_id, status='error', progress=100,
|
||||
phase='Error', log_line=f'Setup error: {str(setup_error)}', log_type='error')
|
||||
return
|
||||
|
||||
try:
|
||||
logger.info("Setting up sync service...")
|
||||
logger.info(f" sync_service available: {sync_service is not None}")
|
||||
|
||||
if sync_service is None:
|
||||
raise Exception("sync_service is None - not initialized properly")
|
||||
|
||||
# Check sync service components
|
||||
logger.info(f" spotify_client: {sync_service.spotify_client is not None}")
|
||||
logger.info(f" deps.plex_client: {sync_service.plex_client is not None}")
|
||||
logger.info(f" deps.jellyfin_client: {sync_service.jellyfin_client is not None}")
|
||||
|
||||
# Check media server connection before starting
|
||||
from config.settings import config_manager
|
||||
active_server = config_manager.get_active_media_server()
|
||||
logger.info(f" Active media server: {active_server}")
|
||||
|
||||
media_client, server_type = sync_service._get_active_media_client()
|
||||
logger.info(f" Media client available: {media_client is not None}")
|
||||
|
||||
if media_client:
|
||||
is_connected = media_client.is_connected()
|
||||
logger.info(f" Media client connected: {is_connected}")
|
||||
|
||||
# Check database access
|
||||
try:
|
||||
from database.music_database import MusicDatabase
|
||||
db = MusicDatabase()
|
||||
logger.debug(f" Database initialized: {db is not None}")
|
||||
except Exception as db_error:
|
||||
logger.error(f" Database initialization failed: {db_error}")
|
||||
|
||||
logger.info("Attaching progress callback...")
|
||||
# Attach the progress callback
|
||||
sync_service.set_progress_callback(progress_callback, playlist.name)
|
||||
logger.info(f"Progress callback attached for playlist: {playlist.name}")
|
||||
|
||||
# CRITICAL FIX: Add database-only fallback for web context
|
||||
# If media client is not connected, patch the sync service to use database-only matching
|
||||
if media_client is None or not media_client.is_connected():
|
||||
logger.info("Media client not connected - patching sync service for database-only matching")
|
||||
|
||||
# Store original method
|
||||
original_find_track = sync_service._find_track_in_media_server
|
||||
|
||||
# Create database-only replacement method
|
||||
async def database_only_find_track(spotify_track):
|
||||
logger.info(f"Database-only search for: '{spotify_track.name}' by {spotify_track.artists}")
|
||||
try:
|
||||
from database.music_database import MusicDatabase
|
||||
from config.settings import config_manager
|
||||
|
||||
db = MusicDatabase()
|
||||
active_server = config_manager.get_active_media_server()
|
||||
original_title = spotify_track.name
|
||||
spotify_id = getattr(spotify_track, 'id', '') or ''
|
||||
|
||||
# --- Sync match cache fast-path ---
|
||||
if spotify_id:
|
||||
try:
|
||||
cached = db.read_sync_match_cache(spotify_id, active_server)
|
||||
if cached:
|
||||
db_track_check = db.get_track_by_id(cached['server_track_id'])
|
||||
if db_track_check:
|
||||
class DatabaseTrackCached:
|
||||
def __init__(self, db_t):
|
||||
self.ratingKey = db_t.id
|
||||
self.title = db_t.title
|
||||
self.id = db_t.id
|
||||
logger.debug(f"Sync cache hit: '{original_title}' → server track {cached['server_track_id']}")
|
||||
return DatabaseTrackCached(db_track_check), cached['confidence']
|
||||
logger.warning(f"Sync cache stale for '{original_title}' — track gone")
|
||||
except Exception:
|
||||
pass
|
||||
# --- End cache fast-path ---
|
||||
|
||||
# Try each artist (same logic as original)
|
||||
for artist in spotify_track.artists:
|
||||
# Extract artist name from both string and dict formats
|
||||
if isinstance(artist, str):
|
||||
artist_name = artist
|
||||
elif isinstance(artist, dict) and 'name' in artist:
|
||||
artist_name = artist['name']
|
||||
else:
|
||||
artist_name = str(artist)
|
||||
|
||||
db_track, confidence = db.check_track_exists(
|
||||
original_title, artist_name,
|
||||
confidence_threshold=0.80,
|
||||
server_source=active_server
|
||||
)
|
||||
|
||||
if db_track and confidence >= 0.80:
|
||||
logger.info(f"Database match: '{db_track.title}' (confidence: {confidence:.2f})")
|
||||
|
||||
# Save to sync match cache
|
||||
if spotify_id:
|
||||
try:
|
||||
from core.matching_engine import MusicMatchingEngine
|
||||
me = MusicMatchingEngine()
|
||||
db.save_sync_match_cache(
|
||||
spotify_id, me.clean_title(original_title), me.clean_artist(artist_name),
|
||||
active_server, db_track.id, db_track.title, confidence
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Create mock track object for playlist creation
|
||||
class DatabaseTrackMock:
|
||||
def __init__(self, db_track):
|
||||
self.ratingKey = db_track.id
|
||||
self.title = db_track.title
|
||||
self.id = db_track.id
|
||||
|
||||
return DatabaseTrackMock(db_track), confidence
|
||||
|
||||
logger.warning(f"No database match found for: '{original_title}'")
|
||||
return None, 0.0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Database search error: {e}")
|
||||
return None, 0.0
|
||||
|
||||
# Patch the method
|
||||
sync_service._find_track_in_media_server = database_only_find_track
|
||||
logger.info("Patched sync service to use database-only matching")
|
||||
|
||||
sync_start_time = time.time()
|
||||
setup_duration = (sync_start_time - task_start_time) * 1000
|
||||
logger.info(f"⏱️ [TIMING] Setup completed at {time.strftime('%H:%M:%S')} (took {setup_duration:.1f}ms)")
|
||||
logger.info("Starting actual sync process with run_async()...")
|
||||
|
||||
# Attach original tracks map to sync_service for wishlist with album images
|
||||
sync_service._original_tracks_map = original_tracks_map
|
||||
|
||||
# Wing It mode — skip wishlist for unmatched tracks
|
||||
with sync_lock:
|
||||
is_wing_it = sync_states.get(playlist_id, {}).get('wing_it', False)
|
||||
sync_service._skip_wishlist = is_wing_it
|
||||
|
||||
# Run the sync (this is a blocking call within this thread)
|
||||
result = deps.run_async(sync_service.sync_playlist(playlist, download_missing=False, profile_id=profile_id))
|
||||
|
||||
# Clear progress callback immediately to prevent race condition where a
|
||||
# late-firing progress callback overwrites the "finished" state below
|
||||
if sync_service:
|
||||
sync_service.clear_progress_callback(playlist.name)
|
||||
|
||||
sync_duration = (time.time() - sync_start_time) * 1000
|
||||
total_duration = (time.time() - task_start_time) * 1000
|
||||
logger.info(f"⏱️ [TIMING] Sync completed at {time.strftime('%H:%M:%S')} (sync: {sync_duration:.1f}ms, total: {total_duration:.1f}ms)")
|
||||
logger.info(f"Sync process completed! Result type: {type(result)}")
|
||||
logger.info(f" Result details: matched={getattr(result, 'matched_tracks', 'N/A')}, total={getattr(result, 'total_tracks', 'N/A')}")
|
||||
|
||||
# Update final state on completion
|
||||
# Convert result to JSON-serializable dict (datetime/errors can't be emitted via SocketIO)
|
||||
# Exclude match_details (large) but include a summary of unmatched tracks
|
||||
result_dict = {
|
||||
k: (v.isoformat() if hasattr(v, 'isoformat') else v)
|
||||
for k, v in result.__dict__.items()
|
||||
if k != 'match_details'
|
||||
}
|
||||
# Include unmatched track names so the frontend can show which tracks failed
|
||||
match_details = getattr(result, 'match_details', None)
|
||||
if match_details:
|
||||
unmatched_summary = [
|
||||
{'name': d.get('name', ''), 'artist': d.get('artist', ''), 'image_url': d.get('image_url', '')}
|
||||
for d in match_details if d.get('status') == 'not_found'
|
||||
]
|
||||
if unmatched_summary:
|
||||
result_dict['unmatched_tracks'] = unmatched_summary
|
||||
with sync_lock:
|
||||
sync_states[playlist_id] = {
|
||||
"status": "finished",
|
||||
"progress": result_dict,
|
||||
"result": result_dict
|
||||
}
|
||||
logger.info(f"Sync finished for {playlist_id} - state updated")
|
||||
|
||||
# Set playlist poster image if available (Plex, Jellyfin, Emby)
|
||||
_synced = getattr(result, 'synced_tracks', 0)
|
||||
logger.info(f"[PLAYLIST IMAGE] image_url={playlist_image_url!r}, synced_tracks={_synced}")
|
||||
if playlist_image_url and _synced > 0:
|
||||
try:
|
||||
active_server = deps.config_manager.get_active_media_server()
|
||||
logger.info(f"[PLAYLIST IMAGE] active_server={active_server}")
|
||||
if active_server == 'plex' and deps.plex_client:
|
||||
ok = deps.plex_client.set_playlist_image(playlist_name, playlist_image_url)
|
||||
logger.info(f"[PLAYLIST IMAGE] Plex upload result: {ok}")
|
||||
elif active_server in ('jellyfin', 'emby') and deps.jellyfin_client:
|
||||
ok = deps.jellyfin_client.set_playlist_image(playlist_name, playlist_image_url)
|
||||
logger.info(f"[PLAYLIST IMAGE] Jellyfin upload result: {ok}")
|
||||
# Navidrome doesn't support custom playlist images
|
||||
except Exception as img_err:
|
||||
logger.error(f"[PLAYLIST IMAGE] Exception: {img_err}")
|
||||
|
||||
# Record sync history completion with per-track data
|
||||
try:
|
||||
matched = getattr(result, 'matched_tracks', 0)
|
||||
failed = getattr(result, 'failed_tracks', 0)
|
||||
synced = getattr(result, 'synced_tracks', 0)
|
||||
db = MusicDatabase()
|
||||
target_batch_id = sync_batch_id
|
||||
if _is_resync and _resync_entry_id:
|
||||
db.refresh_sync_history_entry(_resync_entry_id, matched, synced, failed)
|
||||
# For re-sync, get the batch_id from the original entry
|
||||
try:
|
||||
entry = db.get_sync_history_entry(_resync_entry_id)
|
||||
if entry:
|
||||
target_batch_id = entry.get('batch_id', sync_batch_id)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
db.update_sync_history_completion(sync_batch_id, matched, synced, failed)
|
||||
|
||||
# Save per-track match details from sync service
|
||||
match_details = getattr(result, 'match_details', None)
|
||||
if match_details:
|
||||
try:
|
||||
track_results_json = json.dumps(match_details, default=str)
|
||||
saved = db.update_sync_history_track_results(target_batch_id, track_results_json)
|
||||
logger.info(f"[Sync History] Saved {len(match_details)} track results for batch {target_batch_id} (saved={saved})")
|
||||
except Exception as json_err:
|
||||
logger.error(f"[Sync History] Failed to serialize track results: {json_err}")
|
||||
else:
|
||||
logger.warning(f"[Sync History] No match_details on SyncResult for batch {target_batch_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to record sync history completion: {e}")
|
||||
|
||||
if automation_id:
|
||||
matched = getattr(result, 'matched_tracks', 0)
|
||||
total = getattr(result, 'total_tracks', 0)
|
||||
failed = getattr(result, 'failed_tracks', 0)
|
||||
deps.update_automation_progress(automation_id, status='finished', progress=100,
|
||||
phase='Sync complete',
|
||||
log_line=f'Done: {matched}/{total} matched, {failed} failed', log_type='success')
|
||||
|
||||
# Emit playlist_synced event for automation engine
|
||||
try:
|
||||
if deps.automation_engine:
|
||||
deps.automation_engine.emit('playlist_synced', {
|
||||
'playlist_name': playlist_name,
|
||||
'total_tracks': str(getattr(result, 'total_tracks', 0)),
|
||||
'matched_tracks': str(getattr(result, 'matched_tracks', 0)),
|
||||
'synced_tracks': str(getattr(result, 'synced_tracks', 0)),
|
||||
'failed_tracks': str(getattr(result, 'failed_tracks', 0)),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Save sync status with match counts and track hash for smart-skip on next scheduled sync
|
||||
import hashlib as _hl
|
||||
_track_ids_str = ','.join(sorted(t.get('id', '') for t in tracks_json))
|
||||
_tracks_hash = _hl.md5(_track_ids_str.encode()).hexdigest()
|
||||
snapshot_id = getattr(playlist, 'snapshot_id', None)
|
||||
deps.update_and_save_sync_status(playlist_id, playlist_name, playlist.owner, snapshot_id,
|
||||
matched_tracks=getattr(result, 'matched_tracks', 0),
|
||||
total_tracks=getattr(result, 'total_tracks', 0),
|
||||
discovered_tracks=len(tracks_json),
|
||||
tracks_hash=_tracks_hash)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SYNC FAILED for {playlist_id}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
with sync_lock:
|
||||
sync_states[playlist_id] = {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
if automation_id:
|
||||
deps.update_automation_progress(automation_id, status='error', progress=100,
|
||||
phase='Error', log_line=f'Sync failed: {str(e)}', log_type='error')
|
||||
finally:
|
||||
logger.info(f"Cleaning up progress callback for {playlist.name}")
|
||||
# Clean up the callback
|
||||
if sync_service:
|
||||
sync_service.clear_progress_callback(playlist.name)
|
||||
# Clean up original tracks map
|
||||
if hasattr(sync_service, '_original_tracks_map'):
|
||||
del sync_service._original_tracks_map
|
||||
logger.info(f"Cleanup completed for {playlist_id}")
|
||||
273
core/discovery/tidal.py
Normal file
273
core/discovery/tidal.py
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
"""Background worker for Tidal playlist discovery.
|
||||
|
||||
`run_tidal_discovery_worker(playlist_id, deps)` is the function the tidal
|
||||
discovery start-endpoint submits to its executor to match each Tidal
|
||||
playlist track against Spotify (preferred) or iTunes (fallback). Same
|
||||
shape as the other source-specific discovery workers in this package.
|
||||
|
||||
1. Pause enrichment workers (release shared resources).
|
||||
2. For each Tidal track:
|
||||
- Cancellation gate (state['cancelled']).
|
||||
- Discovery cache lookup; cache hit short-circuits the search.
|
||||
- `_search_spotify_for_tidal_track` (shared helper that the deezer +
|
||||
spotify_public workers also use; returns tuple for Spotify or dict
|
||||
for iTunes).
|
||||
- On Spotify match: build `match_data` preserving track_number /
|
||||
disc_number from raw API data; image extracted from album images
|
||||
or track object fallback; release_date filled from
|
||||
track.release_date when album dict is missing it.
|
||||
- On iTunes match: dict result populated as `match_data` with source
|
||||
set to discovery_source; image extracted from album images.
|
||||
- Save matched result to discovery cache.
|
||||
- On miss: Wing It stub stored as 'wing-it' status (success ticked).
|
||||
3. After all tracks: phase='discovered', activity feed entry, sync
|
||||
discovery results back to mirrored playlist via
|
||||
`_sync_discovery_results_to_mirrored` with 'tidal' tag.
|
||||
4. On error: state['phase']='error' + status with error string.
|
||||
5. Finally: resume enrichment workers.
|
||||
|
||||
Lifted verbatim from web_server.py. Wide dependency surface (Spotify
|
||||
and iTunes clients, multiple metadata helpers, state dict, mirrored
|
||||
sync, shared tidal search helper) all injected via `TidalDiscoveryDeps`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TidalDiscoveryDeps:
|
||||
"""Bundle of cross-cutting deps the Tidal discovery worker needs."""
|
||||
tidal_discovery_states: dict
|
||||
spotify_client: Any
|
||||
pause_enrichment_workers: Callable[[str], dict]
|
||||
resume_enrichment_workers: Callable[[dict, str], None]
|
||||
get_active_discovery_source: Callable[[], str]
|
||||
get_metadata_fallback_client: Callable[[], Any]
|
||||
get_discovery_cache_key: Callable
|
||||
get_database: Callable[[], Any]
|
||||
validate_discovery_cache_artist: Callable
|
||||
search_spotify_for_tidal_track: Callable
|
||||
build_discovery_wing_it_stub: Callable
|
||||
add_activity_item: Callable
|
||||
sync_discovery_results_to_mirrored: Callable
|
||||
|
||||
|
||||
def run_tidal_discovery_worker(playlist_id, deps: TidalDiscoveryDeps):
|
||||
"""Background worker for Tidal discovery process (Spotify preferred, iTunes fallback)"""
|
||||
_ew_state = {}
|
||||
try:
|
||||
_ew_state = deps.pause_enrichment_workers('Tidal discovery')
|
||||
state = deps.tidal_discovery_states[playlist_id]
|
||||
playlist = state['playlist']
|
||||
|
||||
# Determine which provider to use — respect user's configured primary source
|
||||
discovery_source = deps.get_active_discovery_source()
|
||||
use_spotify = (discovery_source == 'spotify') and deps.spotify_client and deps.spotify_client.is_spotify_authenticated()
|
||||
|
||||
# Initialize fallback client if needed
|
||||
itunes_client_instance = None
|
||||
if not use_spotify:
|
||||
itunes_client_instance = deps.get_metadata_fallback_client()
|
||||
|
||||
logger.info(f"Starting Tidal discovery for: {playlist.name} (using {discovery_source.upper()})")
|
||||
|
||||
# Store discovery source in state for frontend
|
||||
state['discovery_source'] = discovery_source
|
||||
|
||||
successful_discoveries = 0
|
||||
|
||||
for i, tidal_track in enumerate(playlist.tracks):
|
||||
if state.get('cancelled', False):
|
||||
break
|
||||
|
||||
try:
|
||||
logger.info(f"[{i+1}/{len(playlist.tracks)}] Searching {discovery_source.upper()}: {tidal_track.name} by {', '.join(tidal_track.artists)}")
|
||||
|
||||
# Check discovery cache first
|
||||
cache_key = deps.get_discovery_cache_key(tidal_track.name, tidal_track.artists[0] if tidal_track.artists else '')
|
||||
try:
|
||||
cache_db = deps.get_database()
|
||||
cached_match = cache_db.get_discovery_cache_match(cache_key[0], cache_key[1], discovery_source)
|
||||
if cached_match and deps.validate_discovery_cache_artist(tidal_track.artists[0] if tidal_track.artists else '', cached_match):
|
||||
logger.debug(f"CACHE HIT [{i+1}/{len(playlist.tracks)}]: {tidal_track.name} by {', '.join(tidal_track.artists)}")
|
||||
result = {
|
||||
'tidal_track': {
|
||||
'id': tidal_track.id,
|
||||
'name': tidal_track.name,
|
||||
'artists': tidal_track.artists or [],
|
||||
'album': getattr(tidal_track, 'album', 'Unknown Album'),
|
||||
'duration_ms': getattr(tidal_track, 'duration_ms', 0),
|
||||
},
|
||||
'spotify_data': cached_match,
|
||||
'match_data': cached_match,
|
||||
'status': 'found',
|
||||
'discovery_source': discovery_source
|
||||
}
|
||||
successful_discoveries += 1
|
||||
state['spotify_matches'] = successful_discoveries
|
||||
state['discovery_results'].append(result)
|
||||
state['discovery_progress'] = int(((i + 1) / len(playlist.tracks)) * 100)
|
||||
continue
|
||||
except Exception as cache_err:
|
||||
logger.error(f"Cache lookup error: {cache_err}")
|
||||
|
||||
# Use the search function with appropriate provider
|
||||
track_result = deps.search_spotify_for_tidal_track(
|
||||
tidal_track,
|
||||
use_spotify=use_spotify,
|
||||
itunes_client=itunes_client_instance
|
||||
)
|
||||
|
||||
# Create result entry - use 'match_data' as generic key for both providers
|
||||
result = {
|
||||
'tidal_track': {
|
||||
'id': tidal_track.id,
|
||||
'name': tidal_track.name,
|
||||
'artists': tidal_track.artists or [],
|
||||
'album': getattr(tidal_track, 'album', 'Unknown Album'),
|
||||
'duration_ms': getattr(tidal_track, 'duration_ms', 0),
|
||||
},
|
||||
'spotify_data': None, # Keep for backwards compatibility
|
||||
'match_data': None, # Generic field for any provider
|
||||
'status': 'not_found',
|
||||
'discovery_source': discovery_source
|
||||
}
|
||||
|
||||
match_confidence = 0.0
|
||||
|
||||
if use_spotify and isinstance(track_result, tuple):
|
||||
# Spotify: Function returns (Track, raw_data, confidence)
|
||||
track_obj, raw_track_data, match_confidence = track_result
|
||||
album_obj = raw_track_data.get('album', {}) if raw_track_data else {}
|
||||
# Ensure album has a name — fall back to track_obj.album if raw_data was missing
|
||||
if isinstance(album_obj, dict) and not album_obj.get('name') and track_obj.album:
|
||||
album_obj['name'] = track_obj.album
|
||||
elif not album_obj and track_obj.album:
|
||||
album_obj = {'name': track_obj.album}
|
||||
# Ensure release_date is present (raw Spotify data has it, but fallback may not)
|
||||
if isinstance(album_obj, dict) and not album_obj.get('release_date'):
|
||||
album_obj['release_date'] = getattr(track_obj, 'release_date', '') or ''
|
||||
# Extract image URL from album data or track object
|
||||
_album_images = album_obj.get('images', []) if isinstance(album_obj, dict) else []
|
||||
_image_url = _album_images[0].get('url', '') if _album_images else (getattr(track_obj, 'image_url', '') or '')
|
||||
|
||||
match_data = {
|
||||
'id': track_obj.id,
|
||||
'name': track_obj.name,
|
||||
'artists': track_obj.artists,
|
||||
'album': album_obj,
|
||||
'duration_ms': track_obj.duration_ms,
|
||||
'external_urls': track_obj.external_urls,
|
||||
'image_url': _image_url,
|
||||
'source': 'spotify'
|
||||
}
|
||||
# Preserve track_number/disc_number from raw Spotify API data
|
||||
if raw_track_data and raw_track_data.get('track_number'):
|
||||
match_data['track_number'] = raw_track_data['track_number']
|
||||
if raw_track_data and raw_track_data.get('disc_number'):
|
||||
match_data['disc_number'] = raw_track_data['disc_number']
|
||||
result['spotify_data'] = match_data
|
||||
result['match_data'] = match_data
|
||||
result['status'] = 'found'
|
||||
result['confidence'] = match_confidence
|
||||
successful_discoveries += 1
|
||||
state['spotify_matches'] = successful_discoveries
|
||||
|
||||
elif not use_spotify and track_result and isinstance(track_result, dict):
|
||||
# Fallback: Function returns a dict with track data (includes 'confidence' key)
|
||||
match_confidence = track_result.pop('confidence', 0.80)
|
||||
match_data = track_result
|
||||
match_data['source'] = discovery_source
|
||||
# Extract image URL from album images
|
||||
_fb_album = match_data.get('album', {})
|
||||
_fb_images = _fb_album.get('images', []) if isinstance(_fb_album, dict) else []
|
||||
if _fb_images and 'image_url' not in match_data:
|
||||
match_data['image_url'] = _fb_images[0].get('url', '')
|
||||
result['spotify_data'] = match_data
|
||||
result['match_data'] = match_data
|
||||
result['status'] = 'found'
|
||||
result['confidence'] = match_confidence
|
||||
successful_discoveries += 1
|
||||
state['spotify_matches'] = successful_discoveries
|
||||
|
||||
# Save to discovery cache if match found
|
||||
if result['status'] == 'found' and result.get('match_data'):
|
||||
try:
|
||||
cache_db = deps.get_database()
|
||||
cache_db.save_discovery_cache_match(
|
||||
cache_key[0], cache_key[1], discovery_source, match_confidence,
|
||||
result['match_data'], tidal_track.name,
|
||||
tidal_track.artists[0] if tidal_track.artists else ''
|
||||
)
|
||||
logger.info(f"CACHE SAVED: {tidal_track.name} (confidence: {match_confidence:.3f})")
|
||||
except Exception as cache_err:
|
||||
logger.error(f"Cache save error: {cache_err}")
|
||||
|
||||
# Auto Wing It fallback for unmatched tracks
|
||||
if result['status'] != 'found':
|
||||
tidal_t = result.get('tidal_track', {})
|
||||
stub = deps.build_discovery_wing_it_stub(
|
||||
tidal_t.get('name', ''),
|
||||
', '.join(tidal_t.get('artists', [])),
|
||||
tidal_t.get('duration_ms', 0)
|
||||
)
|
||||
result['status'] = 'found'
|
||||
result['status_class'] = 'wing-it'
|
||||
result['spotify_data'] = stub
|
||||
result['match_data'] = stub
|
||||
result['wing_it_fallback'] = True
|
||||
result['confidence'] = 0
|
||||
successful_discoveries += 1
|
||||
state['spotify_matches'] = successful_discoveries
|
||||
state['wing_it_count'] = state.get('wing_it_count', 0) + 1
|
||||
|
||||
state['discovery_results'].append(result)
|
||||
state['discovery_progress'] = int(((i + 1) / len(playlist.tracks)) * 100)
|
||||
|
||||
# Add delay between requests
|
||||
time.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing track {i+1}: {e}")
|
||||
# Add error result
|
||||
result = {
|
||||
'tidal_track': {
|
||||
'name': tidal_track.name,
|
||||
'artists': tidal_track.artists or [],
|
||||
},
|
||||
'spotify_data': None,
|
||||
'match_data': None,
|
||||
'status': 'error',
|
||||
'error': str(e),
|
||||
'discovery_source': discovery_source
|
||||
}
|
||||
state['discovery_results'].append(result)
|
||||
state['discovery_progress'] = int(((i + 1) / len(playlist.tracks)) * 100)
|
||||
|
||||
# Mark as complete
|
||||
state['phase'] = 'discovered'
|
||||
state['status'] = 'discovered'
|
||||
state['discovery_progress'] = 100
|
||||
|
||||
# Add activity for discovery completion
|
||||
source_label = discovery_source.upper()
|
||||
deps.add_activity_item("", f"Tidal Discovery Complete ({source_label})", f"'{playlist.name}' - {successful_discoveries}/{len(playlist.tracks)} tracks found", "Now")
|
||||
|
||||
logger.info(f"Tidal discovery complete ({source_label}): {successful_discoveries}/{len(playlist.tracks)} tracks found")
|
||||
|
||||
# Sync discovery results back to mirrored playlist
|
||||
deps.sync_discovery_results_to_mirrored('tidal', playlist_id, state.get('discovery_results', []), discovery_source, profile_id=state.get('_profile_id', 1))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in Tidal discovery worker: {e}")
|
||||
state['phase'] = 'error'
|
||||
state['status'] = f'error: {str(e)}'
|
||||
finally:
|
||||
deps.resume_enrichment_workers(_ew_state, 'Tidal discovery')
|
||||
388
core/discovery/youtube.py
Normal file
388
core/discovery/youtube.py
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
"""Background worker for YouTube playlist discovery.
|
||||
|
||||
`run_youtube_discovery_worker(url_hash, deps)` is the function
|
||||
`youtube_discovery_executor.submit(...)` invokes to match each YouTube
|
||||
playlist track against Spotify (preferred) or iTunes (fallback):
|
||||
|
||||
1. Pause enrichment workers (release shared resources).
|
||||
2. For each YouTube track:
|
||||
- Check discovery cache; cache hit short-circuits the search.
|
||||
- Strategy 1: matching_engine search queries with confidence scoring.
|
||||
- Strategy 2: swapped artist/title query.
|
||||
- Strategy 3: raw (untokenized) query.
|
||||
- Strategy 4: extended search with limit=50.
|
||||
- On match → save to discovery cache.
|
||||
- On miss → build a Wing It stub from raw source data.
|
||||
3. After all tracks: mark phase 'discovered', sort results by index, and
|
||||
for mirrored playlists write extra_data back to the DB.
|
||||
4. Activity feed entry with match summary.
|
||||
5. On error → state['status'] = 'error', phase reset to 'fresh'.
|
||||
6. Finally: resume enrichment workers.
|
||||
|
||||
Lifted verbatim from web_server.py. Wide dependency surface (Spotify and
|
||||
iTunes clients, matching engine, multiple metadata helpers, state dicts,
|
||||
database access) all injected via `YoutubeDiscoveryDeps`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class YoutubeDiscoveryDeps:
|
||||
"""Bundle of cross-cutting deps the YouTube discovery worker needs."""
|
||||
youtube_playlist_states: dict
|
||||
spotify_client: Any
|
||||
matching_engine: Any
|
||||
pause_enrichment_workers: Callable[[str], dict]
|
||||
resume_enrichment_workers: Callable[[dict, str], None]
|
||||
get_active_discovery_source: Callable[[], str]
|
||||
get_metadata_fallback_client: Callable[[], Any]
|
||||
get_discovery_cache_key: Callable
|
||||
validate_discovery_cache_artist: Callable
|
||||
extract_artist_name: Callable
|
||||
spotify_rate_limited: Callable[[], bool]
|
||||
discovery_score_candidates: Callable
|
||||
get_metadata_cache: Callable[[], Any]
|
||||
build_discovery_wing_it_stub: Callable
|
||||
get_database: Callable[[], Any]
|
||||
add_activity_item: Callable
|
||||
|
||||
|
||||
def run_youtube_discovery_worker(url_hash, deps: YoutubeDiscoveryDeps):
|
||||
"""Background worker for YouTube music discovery process (Spotify preferred, iTunes fallback)"""
|
||||
_ew_state = {}
|
||||
try:
|
||||
_ew_state = deps.pause_enrichment_workers('YouTube discovery')
|
||||
state = deps.youtube_playlist_states[url_hash]
|
||||
playlist = state['playlist']
|
||||
tracks = playlist['tracks']
|
||||
|
||||
# Determine which provider to use (Spotify preferred, iTunes fallback)
|
||||
discovery_source = deps.get_active_discovery_source()
|
||||
use_spotify = (discovery_source == 'spotify') and deps.spotify_client and deps.spotify_client.is_spotify_authenticated()
|
||||
|
||||
# Get fallback client
|
||||
itunes_client = deps.get_metadata_fallback_client()
|
||||
|
||||
logger.info(f"Starting {discovery_source} discovery for {len(tracks)} YouTube tracks...")
|
||||
|
||||
# Store the discovery source in state
|
||||
state['discovery_source'] = discovery_source
|
||||
|
||||
# Process each track for discovery
|
||||
for i, track in enumerate(tracks):
|
||||
try:
|
||||
# Check for cancellation (phase changed by reset/delete/close)
|
||||
if state.get('phase') != 'discovering':
|
||||
logger.warning(f"Discovery cancelled for {url_hash} (phase changed to '{state.get('phase')}')")
|
||||
return
|
||||
|
||||
# Update progress
|
||||
state['discovery_progress'] = int((i / len(tracks)) * 100)
|
||||
|
||||
# Skip tracks flagged by retry (already found)
|
||||
if track.get('skip_discovery'):
|
||||
continue
|
||||
|
||||
# Search for track using active provider
|
||||
cleaned_title = track['name']
|
||||
cleaned_artist = track['artists'][0] if track['artists'] else 'Unknown Artist'
|
||||
|
||||
logger.info(f"Searching {discovery_source} for: '{cleaned_artist}' - '{cleaned_title}'")
|
||||
|
||||
# Check discovery cache first
|
||||
cache_key = deps.get_discovery_cache_key(cleaned_title, cleaned_artist)
|
||||
try:
|
||||
cache_db = deps.get_database()
|
||||
cached_match = cache_db.get_discovery_cache_match(cache_key[0], cache_key[1], discovery_source)
|
||||
if cached_match and deps.validate_discovery_cache_artist(cleaned_artist, cached_match):
|
||||
logger.debug(f"CACHE HIT [{i+1}/{len(tracks)}]: {cleaned_artist} - {cleaned_title}")
|
||||
result = {
|
||||
'index': i,
|
||||
'yt_track': cleaned_title,
|
||||
'yt_artist': cleaned_artist,
|
||||
'status': 'Found',
|
||||
'status_class': 'found',
|
||||
'spotify_track': cached_match.get('name', ''),
|
||||
'spotify_artist': deps.extract_artist_name(cached_match.get('artists', [''])[0]) if cached_match.get('artists') else '',
|
||||
'spotify_album': cached_match.get('album', {}).get('name', '') if isinstance(cached_match.get('album'), dict) else cached_match.get('album', ''),
|
||||
'duration': f"{int(track['duration_ms']) // 60000}:{(int(track['duration_ms']) % 60000) // 1000:02d}" if track['duration_ms'] else '0:00',
|
||||
'discovery_source': discovery_source,
|
||||
'matched_data': cached_match,
|
||||
'spotify_data': cached_match
|
||||
}
|
||||
state['spotify_matches'] += 1
|
||||
state['discovery_results'].append(result)
|
||||
continue
|
||||
except Exception as cache_err:
|
||||
logger.error(f"Cache lookup error: {cache_err}")
|
||||
|
||||
# Try multiple search strategies using matching engine
|
||||
matched_track = None
|
||||
best_confidence = 0.0
|
||||
best_raw_track = None
|
||||
min_confidence = 0.9
|
||||
source_duration = track.get('duration_ms', 0) or 0
|
||||
|
||||
# Strategy 1: Use matching_engine search queries
|
||||
try:
|
||||
temp_track = type('TempTrack', (), {
|
||||
'name': cleaned_title,
|
||||
'artists': [cleaned_artist],
|
||||
'album': None
|
||||
})()
|
||||
search_queries = deps.matching_engine.generate_download_queries(temp_track)
|
||||
logger.info(f"Generated {len(search_queries)} search queries for YouTube track")
|
||||
except Exception as e:
|
||||
logger.error(f"Matching engine failed for YouTube, falling back to basic query: {e}")
|
||||
search_queries = [f"{cleaned_artist} {cleaned_title}", cleaned_title]
|
||||
|
||||
for query_idx, search_query in enumerate(search_queries):
|
||||
try:
|
||||
logger.debug(f"YouTube query {query_idx + 1}/{len(search_queries)}: {search_query}")
|
||||
|
||||
search_results = None
|
||||
|
||||
if use_spotify and not deps.spotify_rate_limited():
|
||||
search_results = deps.spotify_client.search_tracks(search_query, limit=10)
|
||||
else:
|
||||
search_results = itunes_client.search_tracks(search_query, limit=10)
|
||||
|
||||
if not search_results:
|
||||
continue
|
||||
|
||||
# Score all results using the matching engine
|
||||
match, confidence, match_idx = deps.discovery_score_candidates(
|
||||
cleaned_title, cleaned_artist, source_duration, search_results
|
||||
)
|
||||
|
||||
if match and confidence > best_confidence and confidence >= min_confidence:
|
||||
best_confidence = confidence
|
||||
matched_track = match
|
||||
if use_spotify and match.id:
|
||||
_cache = deps.get_metadata_cache()
|
||||
best_raw_track = _cache.get_entity('spotify', 'track', match.id)
|
||||
else:
|
||||
best_raw_track = None
|
||||
logger.info(f"New best YouTube match: {match.artists[0]} - {match.name} (confidence: {confidence:.3f})")
|
||||
|
||||
if best_confidence >= 0.9:
|
||||
logger.info(f"High confidence YouTube match found ({best_confidence:.3f}), stopping search")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error in YouTube search for query '{search_query}': {e}")
|
||||
continue
|
||||
|
||||
if matched_track:
|
||||
logger.info(f"Strategy 1 YouTube match: {matched_track.artists[0]} - {matched_track.name} (confidence: {best_confidence:.3f})")
|
||||
|
||||
# Strategy 2: Swapped search (if first failed) - score results properly
|
||||
if not matched_track:
|
||||
logger.info("YouTube Strategy 2: Trying swapped search (artist/title reversed)")
|
||||
if use_spotify:
|
||||
query = f"artist:{cleaned_title} track:{cleaned_artist}"
|
||||
fallback_results = deps.spotify_client.search_tracks(query, limit=5)
|
||||
else:
|
||||
query = f"{cleaned_title} {cleaned_artist}"
|
||||
fallback_results = itunes_client.search_tracks(query, limit=5)
|
||||
if fallback_results:
|
||||
match, confidence, _ = deps.discovery_score_candidates(
|
||||
cleaned_title, cleaned_artist, source_duration, fallback_results
|
||||
)
|
||||
if match and confidence >= min_confidence:
|
||||
matched_track = match
|
||||
best_confidence = confidence
|
||||
logger.info(f"Strategy 2 YouTube match (swapped): {match.artists[0]} - {match.name} (confidence: {confidence:.3f})")
|
||||
|
||||
# Strategy 3: Raw data search (if still failed) - score results properly
|
||||
if not matched_track:
|
||||
raw_title = track.get('raw_title', cleaned_title)
|
||||
raw_artist = track.get('raw_artist', cleaned_artist)
|
||||
logger.info(f"YouTube Strategy 3: Trying raw data search: '{raw_artist} {raw_title}'")
|
||||
query = f"{raw_artist} {raw_title}"
|
||||
if use_spotify:
|
||||
fallback_results = deps.spotify_client.search_tracks(query, limit=5)
|
||||
else:
|
||||
fallback_results = itunes_client.search_tracks(query, limit=5)
|
||||
if fallback_results:
|
||||
match, confidence, _ = deps.discovery_score_candidates(
|
||||
cleaned_title, cleaned_artist, source_duration, fallback_results
|
||||
)
|
||||
if match and confidence >= min_confidence:
|
||||
matched_track = match
|
||||
best_confidence = confidence
|
||||
logger.info(f"Strategy 3 YouTube match (raw): {match.artists[0]} - {match.name} (confidence: {confidence:.3f})")
|
||||
|
||||
# Strategy 4: Extended search with higher limit (last resort)
|
||||
if not matched_track:
|
||||
logger.info("YouTube Strategy 4: Extended search with limit=50")
|
||||
query = f"{cleaned_artist} {cleaned_title}"
|
||||
if use_spotify:
|
||||
extended_results = deps.spotify_client.search_tracks(query, limit=50)
|
||||
else:
|
||||
extended_results = itunes_client.search_tracks(query, limit=50)
|
||||
if extended_results:
|
||||
match, confidence, _ = deps.discovery_score_candidates(
|
||||
cleaned_title, cleaned_artist, source_duration, extended_results
|
||||
)
|
||||
if match and confidence >= min_confidence:
|
||||
matched_track = match
|
||||
best_confidence = confidence
|
||||
logger.info(f"Strategy 4 YouTube match (extended): {match.artists[0]} - {match.name} (confidence: {confidence:.3f})")
|
||||
|
||||
# Create result entry
|
||||
result = {
|
||||
'index': i,
|
||||
'yt_track': cleaned_title,
|
||||
'yt_artist': cleaned_artist,
|
||||
'status': 'Found' if matched_track else 'Not Found',
|
||||
'status_class': 'found' if matched_track else 'not-found',
|
||||
'spotify_track': matched_track.name if matched_track else '',
|
||||
'spotify_artist': deps.extract_artist_name(matched_track.artists[0]) if matched_track else '',
|
||||
'spotify_album': matched_track.album if matched_track else '',
|
||||
'duration': f"{int(track['duration_ms']) // 60000}:{(int(track['duration_ms']) % 60000) // 1000:02d}" if track['duration_ms'] else '0:00',
|
||||
'discovery_source': discovery_source,
|
||||
'confidence': best_confidence
|
||||
}
|
||||
|
||||
if matched_track:
|
||||
state['spotify_matches'] += 1
|
||||
|
||||
# Build album data based on provider
|
||||
if use_spotify and best_raw_track:
|
||||
album_data = best_raw_track.get('album', {})
|
||||
else:
|
||||
album_data = {
|
||||
'name': matched_track.album,
|
||||
'album_type': 'album',
|
||||
'release_date': getattr(matched_track, 'release_date', '') or '',
|
||||
'images': [{'url': matched_track.image_url}] if hasattr(matched_track, 'image_url') and matched_track.image_url else []
|
||||
}
|
||||
|
||||
# Extract image URL for discovery pool display
|
||||
_yt_album_images = album_data.get('images', [])
|
||||
_yt_image_url = _yt_album_images[0].get('url', '') if _yt_album_images else (getattr(matched_track, 'image_url', '') or '')
|
||||
|
||||
result['matched_data'] = {
|
||||
'id': matched_track.id,
|
||||
'name': matched_track.name,
|
||||
'artists': matched_track.artists,
|
||||
'album': album_data,
|
||||
'duration_ms': matched_track.duration_ms,
|
||||
'image_url': _yt_image_url,
|
||||
'source': discovery_source
|
||||
}
|
||||
result['spotify_data'] = result['matched_data']
|
||||
|
||||
# Save to discovery cache (only high-confidence matches)
|
||||
if best_confidence >= 0.7:
|
||||
try:
|
||||
cache_db = deps.get_database()
|
||||
cache_db.save_discovery_cache_match(
|
||||
cache_key[0], cache_key[1], discovery_source, best_confidence,
|
||||
result['matched_data'], cleaned_title, cleaned_artist
|
||||
)
|
||||
logger.info(f"CACHE SAVED: {cleaned_artist} - {cleaned_title} (confidence: {best_confidence:.3f})")
|
||||
except Exception as cache_err:
|
||||
logger.error(f"Cache save error: {cache_err}")
|
||||
|
||||
else:
|
||||
# Auto Wing It fallback — build stub from raw source data
|
||||
stub = deps.build_discovery_wing_it_stub(cleaned_title, cleaned_artist, track.get('duration_ms', 0))
|
||||
result['status'] = 'Wing It'
|
||||
result['status_class'] = 'wing-it'
|
||||
result['spotify_track'] = cleaned_title
|
||||
result['spotify_artist'] = cleaned_artist
|
||||
result['spotify_album'] = ''
|
||||
result['matched_data'] = stub
|
||||
result['spotify_data'] = stub
|
||||
result['wing_it_fallback'] = True
|
||||
state['wing_it_count'] = state.get('wing_it_count', 0) + 1
|
||||
|
||||
state['discovery_results'].append(result)
|
||||
|
||||
logger.info(f" {'' if matched_track else ''} Track {i+1}/{len(tracks)}: {result['status']}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing track {i}: {e}")
|
||||
result = {
|
||||
'index': i,
|
||||
'yt_track': track['name'],
|
||||
'yt_artist': track['artists'][0] if track['artists'] else 'Unknown',
|
||||
'status': 'Error',
|
||||
'status_class': 'error',
|
||||
'spotify_track': '',
|
||||
'spotify_artist': '',
|
||||
'spotify_album': '',
|
||||
'duration': '0:00'
|
||||
}
|
||||
state['discovery_results'].append(result)
|
||||
|
||||
# Complete discovery
|
||||
state['phase'] = 'discovered'
|
||||
state['status'] = 'complete'
|
||||
state['discovery_progress'] = 100
|
||||
|
||||
# Sort results by index so array position matches result['index'].
|
||||
# Critical after retry where found results are kept at the front
|
||||
# and newly-discovered results are appended out of order.
|
||||
state['discovery_results'].sort(key=lambda r: r.get('index', 0))
|
||||
|
||||
# Write back discovery results to DB for mirrored playlists
|
||||
if url_hash.startswith('mirrored_'):
|
||||
try:
|
||||
db = deps.get_database()
|
||||
for result in state['discovery_results']:
|
||||
idx = result.get('index', -1)
|
||||
if idx < 0 or idx >= len(tracks):
|
||||
continue
|
||||
db_track_id = tracks[idx].get('db_track_id')
|
||||
if not db_track_id:
|
||||
continue
|
||||
if result.get('status_class') in ('found', 'wing-it') and result.get('matched_data'):
|
||||
extra_data = {
|
||||
'discovered': True,
|
||||
'provider': result.get('discovery_source', discovery_source),
|
||||
'confidence': result.get('confidence', 0),
|
||||
'matched_data': result['matched_data'],
|
||||
}
|
||||
if result.get('manual_match'):
|
||||
extra_data['manual_match'] = True
|
||||
if result.get('wing_it_fallback'):
|
||||
extra_data['wing_it_fallback'] = True
|
||||
extra_data['provider'] = 'wing_it_fallback'
|
||||
db.update_mirrored_track_extra_data(db_track_id, extra_data)
|
||||
else:
|
||||
extra_data = {
|
||||
'discovered': False,
|
||||
'discovery_attempted': True,
|
||||
'provider': discovery_source,
|
||||
}
|
||||
db.update_mirrored_track_extra_data(db_track_id, extra_data)
|
||||
logger.info(f"Wrote discovery results to DB for {url_hash}")
|
||||
except Exception as wb_err:
|
||||
logger.error(f"Error writing discovery results to DB: {wb_err}")
|
||||
|
||||
playlist_name = playlist['name']
|
||||
source_label = discovery_source.upper()
|
||||
wing_it_count = state.get('wing_it_count', 0)
|
||||
activity_msg = f"'{playlist_name}' - {state['spotify_matches']}/{len(tracks)} tracks found"
|
||||
if wing_it_count:
|
||||
activity_msg += f", {wing_it_count} wing it"
|
||||
deps.add_activity_item("", f"YouTube Discovery Complete ({source_label})", activity_msg, "Now")
|
||||
|
||||
logger.info(f"YouTube discovery complete ({discovery_source}): {state['spotify_matches']}/{len(tracks)} tracks matched, {wing_it_count} wing it")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in YouTube discovery worker: {e}")
|
||||
state['status'] = 'error'
|
||||
state['phase'] = 'fresh'
|
||||
finally:
|
||||
deps.resume_enrichment_workers(_ew_state, 'YouTube discovery')
|
||||
|
|
@ -457,12 +457,25 @@ class DownloadOrchestrator:
|
|||
True if successful
|
||||
"""
|
||||
results = []
|
||||
for client in [self.soulseek, self.youtube, self.tidal, self.qobuz, self.hifi, self.deezer_dl, self.lidarr]:
|
||||
if client:
|
||||
for name, client in [
|
||||
("soulseek", self.soulseek),
|
||||
("youtube", self.youtube),
|
||||
("tidal", self.tidal),
|
||||
("qobuz", self.qobuz),
|
||||
("hifi", self.hifi),
|
||||
("deezer_dl", self.deezer_dl),
|
||||
("lidarr", self.lidarr),
|
||||
]:
|
||||
if not client:
|
||||
continue
|
||||
if hasattr(client, "is_configured") and not client.is_configured():
|
||||
logger.debug("Skipping %s clear_all_completed_downloads (not configured)", name)
|
||||
continue
|
||||
try:
|
||||
results.append(await client.clear_all_completed_downloads())
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.warning("%s clear_all_completed_downloads failed: %s", name, exc)
|
||||
results.append(False)
|
||||
|
||||
return all(results) if results else True
|
||||
|
||||
|
|
|
|||
8
core/downloads/__init__.py
Normal file
8
core/downloads/__init__.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
"""Download orchestrator helpers package.
|
||||
|
||||
Lifted from web_server.py download/sync orchestration code. Each module
|
||||
covers a discrete piece of the pipeline:
|
||||
|
||||
- history — sync_history table writes (start + completion)
|
||||
- (more arriving in subsequent PRs as the orchestrator gets carved up)
|
||||
"""
|
||||
103
core/downloads/cancel.py
Normal file
103
core/downloads/cancel.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""Download cancellation + clear helpers.
|
||||
|
||||
Four discrete operations lifted from web_server.py:
|
||||
|
||||
- `cancel_single_download(client, run_async, download_id, username)` — cancel
|
||||
one slskd transfer.
|
||||
- `cancel_all_active(client, run_async, sweep_callback)` — cancel every
|
||||
active slskd transfer, then clear the now-cancelled ones, then sweep
|
||||
empty download directories.
|
||||
- `clear_finished_active(client, run_async, sweep_callback)` — clear all
|
||||
terminal transfers from slskd (no cancel step), sweep dirs.
|
||||
- `clear_completed_local()` — prune terminal-status tasks from the
|
||||
local `download_tasks` tracker, drop empty batches, drop their locks.
|
||||
Pure local mutation, doesn't touch slskd.
|
||||
|
||||
The slskd-touching helpers take the soulseek client and run_async callback
|
||||
explicitly; the local helper imports its globals directly from
|
||||
`core.runtime_state` since those are module-level shared state and every
|
||||
caller sees the same dict.
|
||||
|
||||
Out of scope for this PR (deferred to the batch-lifecycle lift):
|
||||
- `cancel_download_task` (calls _on_download_completed)
|
||||
- `cancel_task_v2` + `_atomic_cancel_task` (manipulate batch active_count)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
from core.runtime_state import (
|
||||
batch_locks,
|
||||
download_batches,
|
||||
download_tasks,
|
||||
tasks_lock,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TERMINAL_STATUSES = {
|
||||
'completed', 'failed', 'not_found', 'cancelled', 'skipped', 'already_owned',
|
||||
}
|
||||
|
||||
|
||||
def cancel_single_download(soulseek_client, run_async: Callable,
|
||||
download_id: str, username: str) -> bool:
|
||||
"""Cancel one specific slskd download (with `remove=True`)."""
|
||||
return run_async(soulseek_client.cancel_download(download_id, username, remove=True))
|
||||
|
||||
|
||||
def cancel_all_active(soulseek_client, run_async: Callable,
|
||||
sweep_callback: Callable[[], None]) -> tuple[bool, str]:
|
||||
"""Cancel every active slskd download, clear the resulting ones, sweep dirs.
|
||||
|
||||
Returns `(success, message)` so the route can map to the right HTTP shape.
|
||||
"""
|
||||
cancel_success = run_async(soulseek_client.cancel_all_downloads())
|
||||
if not cancel_success:
|
||||
return False, "Failed to cancel active downloads."
|
||||
|
||||
run_async(soulseek_client.clear_all_completed_downloads())
|
||||
sweep_callback()
|
||||
return True, "All downloads cancelled and cleared."
|
||||
|
||||
|
||||
def clear_finished_active(soulseek_client, run_async: Callable,
|
||||
sweep_callback: Callable[[], None]) -> bool:
|
||||
"""Clear all terminal transfers from slskd, sweep dirs on success."""
|
||||
success = run_async(soulseek_client.clear_all_completed_downloads())
|
||||
if success:
|
||||
sweep_callback()
|
||||
return success
|
||||
|
||||
|
||||
def clear_completed_local() -> int:
|
||||
"""Remove completed/failed/cancelled tasks from the local tracker.
|
||||
|
||||
Also prunes batches whose queues are now empty, and removes the matching
|
||||
`batch_locks` entry. Returns the number of cleared tasks.
|
||||
"""
|
||||
cleared = 0
|
||||
with tasks_lock:
|
||||
task_ids_to_remove = [
|
||||
tid for tid, task in download_tasks.items()
|
||||
if task.get('status') in _TERMINAL_STATUSES
|
||||
]
|
||||
for tid in task_ids_to_remove:
|
||||
del download_tasks[tid]
|
||||
cleared += 1
|
||||
|
||||
empty_batches = []
|
||||
for bid, batch in download_batches.items():
|
||||
remaining = [t for t in batch.get('queue', []) if t in download_tasks]
|
||||
if not remaining:
|
||||
empty_batches.append(bid)
|
||||
else:
|
||||
batch['queue'] = remaining
|
||||
for bid in empty_batches:
|
||||
del download_batches[bid]
|
||||
if bid in batch_locks:
|
||||
del batch_locks[bid]
|
||||
|
||||
return cleared
|
||||
372
core/downloads/candidates.py
Normal file
372
core/downloads/candidates.py
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
"""Candidate fallback download logic.
|
||||
|
||||
`attempt_download_with_candidates(task_id, candidates, track, batch_id, deps)`
|
||||
is the function the search/match pipeline calls once it has a sorted list of
|
||||
Soulseek candidates for a track. It walks the candidates by descending
|
||||
confidence and starts the first one that:
|
||||
|
||||
1. Hasn't been tried for this task already (`used_sources` dedup).
|
||||
2. Isn't blacklisted (user-flagged bad match).
|
||||
3. Doesn't trigger a cancellation race (checked at three points).
|
||||
|
||||
When a candidate accepts:
|
||||
|
||||
- Stores rich post-processing context in `matched_downloads_context` keyed by
|
||||
`make_context_key(username, filename)` — clean Spotify metadata, album
|
||||
context (real or synthesized), `is_album_download` flag, batch/task IDs.
|
||||
- For tracks with clean Spotify data, resolves track_number / disc_number
|
||||
from (1) track_info → (2) track object → (3) Spotify API call, with album
|
||||
metadata backfilled from the API response when local context is incomplete.
|
||||
- Updates the task with the assigned `download_id`, falls through with a
|
||||
"searching" reset on failure so the next attempt finds a clean state.
|
||||
|
||||
On cancellation mid-download, attempts to cancel the active Soulseek transfer
|
||||
and notifies the lifecycle via `on_download_completed(success=False)` so the
|
||||
worker slot frees up.
|
||||
|
||||
Lifted verbatim from web_server.py. Wide dependency surface
|
||||
(soulseek_client, spotify_client, lifecycle callback, context-key helper,
|
||||
status updater, DB) all injected via `CandidatesDeps`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
from core.runtime_state import (
|
||||
download_tasks,
|
||||
matched_context_lock,
|
||||
matched_downloads_context,
|
||||
tasks_lock,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CandidatesDeps:
|
||||
"""Bundle of cross-cutting deps the candidate-fallback logic needs."""
|
||||
soulseek_client: Any
|
||||
spotify_client: Any
|
||||
run_async: Callable[..., Any]
|
||||
get_database: Callable[[], Any]
|
||||
update_task_status: Callable
|
||||
make_context_key: Callable[[str, str], str]
|
||||
on_download_completed: Callable
|
||||
|
||||
|
||||
def attempt_download_with_candidates(task_id, candidates, track, batch_id=None, deps: CandidatesDeps = None):
|
||||
"""
|
||||
Attempts to download with fallback candidate logic (matches GUI's retry_parallel_download_with_fallback).
|
||||
Returns True if successful, False if all candidates fail.
|
||||
"""
|
||||
# Sort candidates by confidence (best first)
|
||||
candidates.sort(key=lambda r: r.confidence, reverse=True)
|
||||
|
||||
with tasks_lock:
|
||||
task = download_tasks.get(task_id)
|
||||
if not task:
|
||||
return False
|
||||
used_sources = task.get('used_sources', set())
|
||||
|
||||
# Try each candidate until one succeeds (like GUI's fallback logic)
|
||||
for candidate_index, candidate in enumerate(candidates):
|
||||
# Check cancellation before each attempt
|
||||
with tasks_lock:
|
||||
if task_id not in download_tasks:
|
||||
logger.info(f"[Modal Worker] Task {task_id} was deleted during candidate {candidate_index + 1}")
|
||||
return False
|
||||
if download_tasks[task_id]['status'] == 'cancelled':
|
||||
logger.warning(f"[Modal Worker] Task {task_id} cancelled during candidate {candidate_index + 1}")
|
||||
# Don't call _on_download_completed for cancelled tasks as it can stop monitoring
|
||||
return False
|
||||
download_tasks[task_id]['current_candidate_index'] = candidate_index
|
||||
|
||||
# Create source key to avoid duplicate attempts (like GUI)
|
||||
source_key = f"{candidate.username}_{candidate.filename}"
|
||||
if source_key in used_sources:
|
||||
logger.info(f"[Modal Worker] Skipping already tried source: {source_key}")
|
||||
continue
|
||||
|
||||
# Blacklist check — skip sources the user has flagged as bad matches
|
||||
try:
|
||||
_bl_db = deps.get_database()
|
||||
if _bl_db.is_blacklisted(candidate.username, candidate.filename):
|
||||
logger.info(f"[Modal Worker] Skipping blacklisted source: {source_key}")
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# CRITICAL: Add source to used_sources IMMEDIATELY to prevent race conditions
|
||||
# This must happen BEFORE starting download to prevent multiple retries from picking same source
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['used_sources'].add(source_key)
|
||||
logger.info(f"[Modal Worker] Marked source as used before download attempt: {source_key}")
|
||||
|
||||
logger.info(f"[Modal Worker] Trying candidate {candidate_index + 1}/{len(candidates)}: {candidate.filename} (Confidence: {candidate.confidence:.2f})")
|
||||
|
||||
try:
|
||||
# Update task status to downloading
|
||||
deps.update_task_status(task_id, 'downloading')
|
||||
|
||||
# Prepare download - check if we have explicit album context from artist page
|
||||
track_info = {}
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
raw_track_info = download_tasks[task_id].get('track_info')
|
||||
track_info = raw_track_info if isinstance(raw_track_info, dict) else {}
|
||||
|
||||
# Use explicit album/artist context if available (from artist album downloads)
|
||||
has_explicit_context = track_info and track_info.get('_is_explicit_album_download', False)
|
||||
|
||||
if has_explicit_context:
|
||||
# Use the real Spotify album/artist data from the UI
|
||||
explicit_album = track_info.get('_explicit_album_context', {})
|
||||
explicit_artist = track_info.get('_explicit_artist_context', {})
|
||||
# Normalize artist context if it's a plain string (e.g. from wishlist spotify_data)
|
||||
if isinstance(explicit_artist, str):
|
||||
explicit_artist = {'name': explicit_artist}
|
||||
|
||||
spotify_artist_context = {
|
||||
'id': explicit_artist.get('id', 'explicit_artist'),
|
||||
'name': explicit_artist.get('name', track.artists[0] if track.artists else 'Unknown'),
|
||||
'genres': explicit_artist.get('genres', [])
|
||||
}
|
||||
# Handle both image_url formats (direct string or images array)
|
||||
album_image_url = None
|
||||
if explicit_album.get('image_url'):
|
||||
# Backend API returns image_url as direct string
|
||||
album_image_url = explicit_album.get('image_url')
|
||||
elif explicit_album.get('images'):
|
||||
# Fallback: images array format from Spotify API
|
||||
album_image_url = explicit_album.get('images', [{}])[0].get('url')
|
||||
|
||||
spotify_album_context = {
|
||||
'id': explicit_album.get('id', 'explicit_album'),
|
||||
'name': explicit_album.get('name', track.album),
|
||||
'release_date': explicit_album.get('release_date', ''),
|
||||
'image_url': album_image_url,
|
||||
'total_tracks': explicit_album.get('total_tracks', 0),
|
||||
'total_discs': explicit_album.get('total_discs', 1),
|
||||
'album_type': explicit_album.get('album_type', 'album'),
|
||||
'artists': explicit_album.get('artists', [{'name': spotify_artist_context.get('name', '')}])
|
||||
}
|
||||
logger.info(f"[Explicit Context] Using real album data: '{spotify_album_context['name']}' ({spotify_album_context['album_type']}, {spotify_album_context['total_discs']} disc(s))")
|
||||
else:
|
||||
# Fallback to generic context for playlists/wishlists
|
||||
# Extract album metadata from track_info if available (discovery enriches tracks with full album objects)
|
||||
fallback_album = track_info.get('album', {}) if track_info else {}
|
||||
if isinstance(fallback_album, str):
|
||||
fallback_album = {'name': fallback_album}
|
||||
elif not isinstance(fallback_album, dict):
|
||||
fallback_album = {}
|
||||
fallback_image_url = None
|
||||
fallback_images = fallback_album.get('images', [])
|
||||
if fallback_album.get('image_url'):
|
||||
fallback_image_url = fallback_album['image_url']
|
||||
elif fallback_images and isinstance(fallback_images, list) and len(fallback_images) > 0:
|
||||
fallback_image_url = fallback_images[0].get('url') if isinstance(fallback_images[0], dict) else None
|
||||
spotify_artist_context = {'id': 'from_sync_modal', 'name': track.artists[0] if track.artists else 'Unknown', 'genres': []}
|
||||
# Preserve album-level artists for consistent folder naming
|
||||
_fallback_album_artists = fallback_album.get('artists', [])
|
||||
if not _fallback_album_artists:
|
||||
_fallback_album_artists = [{'name': track.artists[0]}] if track.artists else []
|
||||
spotify_album_context = {
|
||||
'id': fallback_album.get('id', 'from_sync_modal'),
|
||||
'name': fallback_album.get('name', '') or track.album,
|
||||
'release_date': fallback_album.get('release_date', ''),
|
||||
'image_url': fallback_image_url,
|
||||
'album_type': fallback_album.get('album_type', 'album'),
|
||||
'total_tracks': fallback_album.get('total_tracks', 0),
|
||||
'total_discs': fallback_album.get('total_discs', 1),
|
||||
'artists': _fallback_album_artists
|
||||
}
|
||||
|
||||
download_payload = candidate.__dict__
|
||||
|
||||
username = download_payload.get('username')
|
||||
filename = download_payload.get('filename')
|
||||
size = download_payload.get('size', 0)
|
||||
|
||||
if not username or not filename:
|
||||
logger.error("[Modal Worker] Invalid candidate data: missing username or filename")
|
||||
continue
|
||||
|
||||
# PROTECTION: Check if there's already an active download for this task
|
||||
current_download_id = None
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
current_download_id = download_tasks[task_id].get('download_id')
|
||||
|
||||
if current_download_id:
|
||||
logger.info(f"[Modal Worker] Task {task_id} already has active download {current_download_id} - skipping new download attempt")
|
||||
logger.info("[Modal Worker] This prevents race condition where multiple retries start overlapping downloads")
|
||||
continue
|
||||
|
||||
# Initiate download
|
||||
logger.info(f"[Modal Worker] Starting download: {username} / {os.path.basename(filename)}")
|
||||
download_id = deps.run_async(deps.soulseek_client.download(username, filename, size))
|
||||
|
||||
if download_id:
|
||||
# Store context for post-processing with complete Spotify metadata (GUI PARITY)
|
||||
context_key = deps.make_context_key(username, filename)
|
||||
with matched_context_lock:
|
||||
# Create WebUI equivalent of GUI's SpotifyBasedSearchResult data structure
|
||||
enhanced_payload = download_payload.copy()
|
||||
|
||||
# Extract clean Spotify metadata from track object (same as GUI)
|
||||
has_clean_spotify_data = track and hasattr(track, 'name') and hasattr(track, 'album')
|
||||
if has_clean_spotify_data:
|
||||
# Use clean Spotify metadata (matches GUI's SpotifyBasedSearchResult)
|
||||
enhanced_payload['spotify_clean_title'] = track.name
|
||||
enhanced_payload['spotify_clean_album'] = track.album
|
||||
enhanced_payload['spotify_clean_artist'] = track.artists[0] if track.artists else enhanced_payload.get('artist', '')
|
||||
# Preserve all artists for metadata tagging
|
||||
enhanced_payload['artists'] = [{'name': artist} for artist in track.artists] if track.artists else []
|
||||
logger.info(f"[Context] Using clean Spotify metadata - Album: '{track.album}', Title: '{track.name}'")
|
||||
|
||||
# Get track_number and disc_number — prefer track data we already have,
|
||||
# fall back to detailed API call only if needed
|
||||
got_track_number = False
|
||||
|
||||
# 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
|
||||
if tn and tn > 0:
|
||||
enhanced_payload['track_number'] = tn
|
||||
enhanced_payload['disc_number'] = dn
|
||||
got_track_number = True
|
||||
logger.info(f"[Context] Added track_number from track_info: {tn}, disc_number: {dn}")
|
||||
|
||||
# 2. Try the track object itself (from album tracks response)
|
||||
if not got_track_number and hasattr(track, 'track_number') and track.track_number:
|
||||
enhanced_payload['track_number'] = track.track_number
|
||||
enhanced_payload['disc_number'] = getattr(track, 'disc_number', 1) or 1
|
||||
got_track_number = True
|
||||
logger.info(f"[Context] Added track_number from track object: {track.track_number}, disc_number: {enhanced_payload['disc_number']}")
|
||||
|
||||
# 3. Last resort — fetch from metadata source API
|
||||
if not got_track_number and hasattr(track, 'id') and track.id:
|
||||
try:
|
||||
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)
|
||||
got_track_number = True
|
||||
logger.info(f"[Context] Added track_number from API: {detailed_track['track_number']}, disc_number: {enhanced_payload['disc_number']}")
|
||||
|
||||
# Backfill album metadata from detailed track when context
|
||||
# has incomplete data (missing release_date, total_tracks, etc.)
|
||||
if isinstance(detailed_track.get('album'), dict):
|
||||
dt_album = detailed_track['album']
|
||||
if not spotify_album_context.get('release_date') and dt_album.get('release_date'):
|
||||
spotify_album_context['release_date'] = dt_album['release_date']
|
||||
logger.info(f"[Context] Backfilled release_date from API: {dt_album['release_date']}")
|
||||
if not spotify_album_context.get('album_type') and dt_album.get('album_type'):
|
||||
spotify_album_context['album_type'] = dt_album['album_type']
|
||||
if not spotify_album_context.get('total_tracks') and dt_album.get('total_tracks'):
|
||||
spotify_album_context['total_tracks'] = dt_album['total_tracks']
|
||||
if not spotify_album_context.get('id') and dt_album.get('id'):
|
||||
spotify_album_context['id'] = dt_album['id']
|
||||
if not spotify_album_context.get('image_url') and dt_album.get('images'):
|
||||
spotify_album_context['image_url'] = dt_album['images'][0].get('url', '')
|
||||
except Exception as e:
|
||||
logger.error(f"[Context] API track details failed: {e}")
|
||||
|
||||
if not got_track_number:
|
||||
enhanced_payload.setdefault('track_number', 0)
|
||||
enhanced_payload.setdefault('disc_number', 1)
|
||||
logger.warning("[Context] No track_number found from any source")
|
||||
|
||||
# Determine if this should be treated as album download
|
||||
# First check if we have explicit album context from artist page
|
||||
if has_explicit_context:
|
||||
is_album_context = True
|
||||
logger.info("[Context] Using explicit album context flag from artist page")
|
||||
else:
|
||||
# Fall back to guessing based on clean data
|
||||
is_album_context = (
|
||||
track.album and
|
||||
track.album.strip() and
|
||||
track.album != "Unknown Album" and
|
||||
track.album.lower() != track.name.lower() # Album different from track
|
||||
)
|
||||
else:
|
||||
# Fallback to original data
|
||||
enhanced_payload['spotify_clean_title'] = enhanced_payload.get('title', '')
|
||||
enhanced_payload['spotify_clean_album'] = enhanced_payload.get('album', '')
|
||||
enhanced_payload['spotify_clean_artist'] = enhanced_payload.get('artist', '')
|
||||
# Preserve existing artists array if available, otherwise create from single artist
|
||||
if 'artists' not in enhanced_payload and enhanced_payload.get('artist'):
|
||||
enhanced_payload['artists'] = [{'name': enhanced_payload['artist']}]
|
||||
enhanced_payload['track_number'] = track_info.get('track_number', 1) # Fallback when no clean Spotify data
|
||||
is_album_context = False
|
||||
logger.warning(f"[Context] Using fallback data - no clean Spotify metadata available, track_number={enhanced_payload['track_number']}")
|
||||
|
||||
matched_downloads_context[context_key] = {
|
||||
"spotify_artist": spotify_artist_context,
|
||||
"spotify_album": spotify_album_context,
|
||||
"original_search_result": enhanced_payload,
|
||||
"is_album_download": is_album_context, # Critical fix: Use actual album context
|
||||
"has_clean_spotify_data": has_clean_spotify_data, # Flag for post-processing
|
||||
"task_id": task_id, # Add task_id for completion callbacks
|
||||
"batch_id": batch_id, # Add batch_id for completion callbacks
|
||||
"track_info": track_info, # Add track_info for playlist folder mode
|
||||
"_download_username": username, # Source username for AcoustID skip logic
|
||||
}
|
||||
|
||||
logger.info(f"[Context] Set is_album_download: {is_album_context} (has clean data: {has_clean_spotify_data})")
|
||||
logger.debug(f"[Debug] Context creation - track_info: {track_info is not None}, playlist_folder_mode: {track_info.get('_playlist_folder_mode', False) if track_info else False}")
|
||||
|
||||
# Update task with successful download info
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
# PHASE 3: Final cancellation check after download started (GUI PARITY)
|
||||
if download_tasks[task_id]['status'] == 'cancelled':
|
||||
logger.warning(f"[Modal Worker] Task {task_id} cancelled after download {download_id} started - attempting to cancel download")
|
||||
# Try to cancel the download immediately
|
||||
try:
|
||||
deps.run_async(deps.soulseek_client.cancel_download(download_id, username, remove=True))
|
||||
logger.warning(f"Successfully cancelled active download {download_id}")
|
||||
except Exception as cancel_error:
|
||||
logger.error(f"Failed to cancel active download {download_id}: {cancel_error}")
|
||||
|
||||
# Free worker slot
|
||||
if batch_id:
|
||||
deps.on_download_completed(batch_id, task_id, success=False)
|
||||
return False
|
||||
|
||||
# Store download information - use real download ID from soulseek_client
|
||||
# CRITICAL FIX: Trust the download ID returned by soulseek_client.download()
|
||||
download_tasks[task_id]['download_id'] = download_id
|
||||
|
||||
download_tasks[task_id]['username'] = username
|
||||
download_tasks[task_id]['filename'] = filename
|
||||
|
||||
logger.info(f"[Modal Worker] Download started successfully for '{filename}'. Download ID: {download_id}")
|
||||
return True # Success!
|
||||
else:
|
||||
logger.error(f"[Modal Worker] Failed to start download for '{filename}'")
|
||||
# Reset status back to searching for next attempt
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'searching'
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.error(f"[Modal Worker] Error attempting download for '{candidate.filename}': {e}")
|
||||
traceback.print_exc()
|
||||
# Reset status back to searching for next attempt
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'searching'
|
||||
continue
|
||||
|
||||
# All candidates failed
|
||||
logger.error(f"[Modal Worker] All {len(candidates)} candidates failed for '{track.name}'")
|
||||
return False
|
||||
100
core/downloads/cleanup.py
Normal file
100
core/downloads/cleanup.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Automatic wishlist cleanup after database updates.
|
||||
|
||||
Runs as a background task after the library DB refresh completes — walks
|
||||
every profile's wishlist, fuzzy-matches each track against the freshly
|
||||
scanned library, and removes hits. Best-effort: logs and continues on
|
||||
per-track failure, swallows top-level exceptions so the executor doesn't
|
||||
get a propagated failure.
|
||||
|
||||
Lifted verbatim from web_server.py's `_automatic_wishlist_cleanup_after_db_update`.
|
||||
The single global dep (`config_manager`) is passed in to keep this module
|
||||
free of web_server imports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def cleanup_wishlist_after_db_update(config_manager) -> None:
|
||||
"""Walk all profiles' wishlists and remove tracks now present in the library."""
|
||||
try:
|
||||
from core.wishlist_service import get_wishlist_service
|
||||
from database.music_database import MusicDatabase, get_database
|
||||
|
||||
wishlist_service = get_wishlist_service()
|
||||
db = MusicDatabase()
|
||||
active_server = config_manager.get_active_media_server()
|
||||
|
||||
logger.info("[Auto Cleanup] Starting automatic wishlist cleanup after database update...")
|
||||
|
||||
# Get all wishlist tracks (across all profiles - cleanup is global)
|
||||
database = get_database()
|
||||
all_profiles = database.get_all_profiles()
|
||||
wishlist_tracks = []
|
||||
for p in all_profiles:
|
||||
wishlist_tracks.extend(wishlist_service.get_wishlist_tracks_for_download(profile_id=p['id']))
|
||||
if not wishlist_tracks:
|
||||
logger.warning("[Auto Cleanup] No tracks in wishlist to clean up")
|
||||
return
|
||||
|
||||
logger.info(f"[Auto Cleanup] Found {len(wishlist_tracks)} tracks in wishlist")
|
||||
|
||||
removed_count = 0
|
||||
|
||||
for track in wishlist_tracks:
|
||||
track_name = track.get('name', '')
|
||||
artists = track.get('artists', [])
|
||||
spotify_track_id = track.get('spotify_track_id') or track.get('id')
|
||||
track_album = track.get('album', {}).get('name') if isinstance(track.get('album'), dict) else track.get('album')
|
||||
|
||||
# Skip if no essential data
|
||||
if not track_name or not artists or not spotify_track_id:
|
||||
continue
|
||||
|
||||
# Check each artist
|
||||
found_in_db = False
|
||||
for artist in artists:
|
||||
# Handle both string format and dict format
|
||||
if isinstance(artist, str):
|
||||
artist_name = artist
|
||||
elif isinstance(artist, dict) and 'name' in artist:
|
||||
artist_name = artist['name']
|
||||
else:
|
||||
artist_name = str(artist)
|
||||
|
||||
try:
|
||||
db_track, confidence = db.check_track_exists(
|
||||
track_name, artist_name,
|
||||
confidence_threshold=0.7,
|
||||
server_source=active_server,
|
||||
album=track_album,
|
||||
)
|
||||
|
||||
if db_track and confidence >= 0.7:
|
||||
found_in_db = True
|
||||
logger.info(f"[Auto Cleanup] Track found in database: '{track_name}' by {artist_name} (confidence: {confidence:.2f})")
|
||||
break
|
||||
|
||||
except Exception as db_error:
|
||||
logger.error(f"[Auto Cleanup] Error checking database for track '{track_name}': {db_error}")
|
||||
continue
|
||||
|
||||
# If found in database, remove from wishlist
|
||||
if found_in_db:
|
||||
try:
|
||||
removed = wishlist_service.mark_track_download_result(spotify_track_id, success=True)
|
||||
if removed:
|
||||
removed_count += 1
|
||||
logger.info(f"[Auto Cleanup] Removed track from wishlist: '{track_name}' ({spotify_track_id})")
|
||||
except Exception as remove_error:
|
||||
logger.error(f"[Auto Cleanup] Error removing track from wishlist: {remove_error}")
|
||||
|
||||
logger.info(f"[Auto Cleanup] Completed automatic cleanup: {removed_count} tracks removed from wishlist")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Auto Cleanup] Error in automatic wishlist cleanup: {e}")
|
||||
traceback.print_exc()
|
||||
204
core/downloads/history.py
Normal file
204
core/downloads/history.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
"""Sync history recording.
|
||||
|
||||
Two write paths: `record_sync_history_start` runs when a batch is
|
||||
submitted (creates or updates a sync_history row), and
|
||||
`record_sync_history_completion` runs when a batch finishes (updates
|
||||
counts + per-track results). Plus `detect_sync_source` which derives
|
||||
the source label from the playlist_id prefix.
|
||||
|
||||
Every write is wrapped in a try/except — sync history is best-effort,
|
||||
a failure here must never break a real download.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from core.runtime_state import download_tasks
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_SOURCE_PREFIX_MAP = [
|
||||
# Mirrored playlists go through YouTube discovery, so youtube_mirrored_ must be checked first
|
||||
('auto_mirror_', 'mirrored'), ('youtube_mirrored_', 'mirrored'),
|
||||
('youtube_', 'youtube'), ('beatport_', 'beatport'),
|
||||
('tidal_', 'tidal'), ('deezer_', 'deezer'), ('listenbrainz_', 'listenbrainz'),
|
||||
('spotify_public_', 'spotify_public'), ('discover_album_', 'discover'),
|
||||
('seasonal_album_', 'discover'), ('library_redownload_', 'library'),
|
||||
('issue_download_', 'library'), ('artist_album_', 'spotify'),
|
||||
('enhanced_search_', 'spotify'), ('spotify_library_', 'spotify'),
|
||||
('beatport_release_', 'beatport'), ('beatport_chart_', 'beatport'),
|
||||
('beatport_top100_', 'beatport'), ('beatport_hype100_', 'beatport'),
|
||||
('beatport_sync_', 'beatport'),
|
||||
]
|
||||
|
||||
|
||||
def detect_sync_source(playlist_id: str) -> str:
|
||||
"""Derive the sync source from the playlist_id prefix."""
|
||||
for prefix, source in _SOURCE_PREFIX_MAP:
|
||||
if playlist_id.startswith(prefix):
|
||||
return source
|
||||
if playlist_id == 'wishlist':
|
||||
return 'wishlist'
|
||||
return 'spotify'
|
||||
|
||||
|
||||
def record_sync_history_start(
|
||||
database,
|
||||
batch_id: str,
|
||||
playlist_id: str,
|
||||
playlist_name: str,
|
||||
tracks: list,
|
||||
is_album_download: bool,
|
||||
album_context,
|
||||
artist_context,
|
||||
playlist_folder_mode: bool,
|
||||
source_page=None,
|
||||
) -> None:
|
||||
"""Record a sync start to the database.
|
||||
|
||||
If a previous sync_history row exists for the same playlist_id, update
|
||||
it in place rather than creating a duplicate.
|
||||
"""
|
||||
try:
|
||||
source = detect_sync_source(playlist_id)
|
||||
if playlist_id == 'wishlist':
|
||||
sync_type = 'wishlist'
|
||||
elif is_album_download:
|
||||
sync_type = 'album'
|
||||
else:
|
||||
sync_type = 'playlist'
|
||||
|
||||
# Extract thumb URL from album context or first track
|
||||
thumb_url = None
|
||||
if album_context:
|
||||
images = album_context.get('images', [])
|
||||
if images and isinstance(images, list) and len(images) > 0:
|
||||
thumb_url = images[0].get('url') if isinstance(images[0], dict) else images[0]
|
||||
if not thumb_url:
|
||||
thumb_url = album_context.get('image_url')
|
||||
if not thumb_url and tracks:
|
||||
first_album = tracks[0].get('album', {})
|
||||
if isinstance(first_album, dict):
|
||||
imgs = first_album.get('images', [])
|
||||
if imgs and isinstance(imgs, list) and len(imgs) > 0:
|
||||
thumb_url = imgs[0].get('url') if isinstance(imgs[0], dict) else imgs[0]
|
||||
|
||||
# Check for existing entry with same playlist_id — update instead of duplicating
|
||||
existing = database.get_latest_sync_history_by_playlist(playlist_id)
|
||||
if existing:
|
||||
try:
|
||||
conn = database._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE sync_history
|
||||
SET batch_id = ?, playlist_name = ?, source = ?, sync_type = ?,
|
||||
tracks_json = ?, artist_context = ?, album_context = ?,
|
||||
thumb_url = ?, total_tracks = ?, is_album_download = ?,
|
||||
playlist_folder_mode = ?, source_page = ?, started_at = CURRENT_TIMESTAMP,
|
||||
completed_at = NULL, tracks_found = 0, tracks_downloaded = 0, tracks_failed = 0
|
||||
WHERE id = ?
|
||||
""",
|
||||
(batch_id, playlist_name, source, sync_type,
|
||||
json.dumps(tracks, ensure_ascii=False),
|
||||
json.dumps(artist_context, ensure_ascii=False) if artist_context else None,
|
||||
json.dumps(album_context, ensure_ascii=False) if album_context else None,
|
||||
thumb_url, len(tracks), int(is_album_download), int(playlist_folder_mode),
|
||||
source_page, existing['id']),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info(f"Updated existing sync history entry {existing['id']} for '{playlist_name}'")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update existing sync history, creating new: {e}")
|
||||
|
||||
database.add_sync_history_entry(
|
||||
batch_id=batch_id,
|
||||
playlist_id=playlist_id,
|
||||
playlist_name=playlist_name,
|
||||
source=source,
|
||||
sync_type=sync_type,
|
||||
tracks_json=json.dumps(tracks, ensure_ascii=False),
|
||||
artist_context=json.dumps(artist_context, ensure_ascii=False) if artist_context else None,
|
||||
album_context=json.dumps(album_context, ensure_ascii=False) if album_context else None,
|
||||
thumb_url=thumb_url,
|
||||
total_tracks=len(tracks),
|
||||
is_album_download=is_album_download,
|
||||
playlist_folder_mode=playlist_folder_mode,
|
||||
source_page=source_page,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to record sync history start: {e}")
|
||||
|
||||
|
||||
def record_sync_history_completion(database, batch_id: str, batch: dict) -> None:
|
||||
"""Update sync_history with completion stats + per-track results.
|
||||
|
||||
NOTE: Called from within tasks_lock context — does NOT acquire it here.
|
||||
Reads from `download_tasks` (also lock-protected by caller).
|
||||
"""
|
||||
try:
|
||||
analysis_results = batch.get('analysis_results', [])
|
||||
tracks_found = sum(1 for r in analysis_results if r.get('found'))
|
||||
queue = batch.get('queue', [])
|
||||
completed_count = 0
|
||||
failed_count = len(batch.get('permanently_failed_tracks', []))
|
||||
|
||||
# Build download status map: track_index → status
|
||||
download_status_map: dict = {}
|
||||
for task_id in queue:
|
||||
task = download_tasks.get(task_id, {})
|
||||
ti = task.get('track_index')
|
||||
if ti is not None:
|
||||
download_status_map[ti] = task.get('status', 'unknown')
|
||||
if task.get('status') == 'completed':
|
||||
completed_count += 1
|
||||
|
||||
# Build per-track results from analysis
|
||||
track_results = []
|
||||
for res in analysis_results:
|
||||
track_data = res.get('track', {})
|
||||
artists = track_data.get('artists', [])
|
||||
if artists:
|
||||
first = artists[0]
|
||||
artist_name = first.get('name', first) if isinstance(first, dict) else str(first)
|
||||
else:
|
||||
artist_name = ''
|
||||
|
||||
album = track_data.get('album', '')
|
||||
album_name = album.get('name', '') if isinstance(album, dict) else str(album or '')
|
||||
|
||||
# Extract image URL
|
||||
image_url = ''
|
||||
album_obj = track_data.get('album', {})
|
||||
if isinstance(album_obj, dict):
|
||||
imgs = album_obj.get('images', [])
|
||||
if imgs and isinstance(imgs, list) and len(imgs) > 0:
|
||||
image_url = imgs[0].get('url', '') if isinstance(imgs[0], dict) else ''
|
||||
|
||||
idx = res.get('track_index', 0)
|
||||
entry = {
|
||||
'index': idx,
|
||||
'name': track_data.get('name', ''),
|
||||
'artist': artist_name,
|
||||
'album': album_name,
|
||||
'image_url': image_url,
|
||||
'duration_ms': track_data.get('duration_ms', 0),
|
||||
'source_track_id': track_data.get('id', ''),
|
||||
'status': 'found' if res.get('found') else 'not_found',
|
||||
'confidence': round(res.get('confidence', 0.0), 3),
|
||||
'matched_track': None,
|
||||
'download_status': download_status_map.get(idx),
|
||||
}
|
||||
track_results.append(entry)
|
||||
|
||||
database.update_sync_history_completion(batch_id, tracks_found, completed_count, failed_count)
|
||||
|
||||
if track_results:
|
||||
database.update_sync_history_track_results(batch_id, json.dumps(track_results))
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to record sync history completion: {e}")
|
||||
664
core/downloads/lifecycle.py
Normal file
664
core/downloads/lifecycle.py
Normal file
|
|
@ -0,0 +1,664 @@
|
|||
"""Batch lifecycle: start workers, on-completion accounting, completion check.
|
||||
|
||||
Three deeply-coupled functions:
|
||||
|
||||
- `start_next_batch_of_downloads(batch_id, deps)` — launches workers up to
|
||||
the batch's max_concurrent. Skips cancelled tasks, sets searching status,
|
||||
submits to the executor, decrement-safe on submit failures (no ghost
|
||||
workers).
|
||||
|
||||
- `on_download_completed(batch_id, task_id, success, deps)` — called when
|
||||
a single track download finishes (good or bad). Tracks failed/cancelled
|
||||
tracks for wishlist replay, decrements active count, then runs the full
|
||||
batch-completion check — which is its own beast: stuck-task detection
|
||||
(searching > 10min → not_found, post_processing > 5min → completed),
|
||||
M3U regeneration, repair worker hand-off, album consistency pass,
|
||||
wishlist failed-tracks processing.
|
||||
|
||||
- `check_batch_completion_v2(batch_id, deps)` — same completion check
|
||||
but called from the V2 atomic cancel path (which bypasses
|
||||
on_download_completed). Duplicate logic preserved verbatim.
|
||||
|
||||
Lifted verbatim from web_server.py. Dependencies injected via
|
||||
`LifecycleDeps` since the surface is wide (15+ callbacks/refs).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from core.downloads.history import record_sync_history_completion
|
||||
from core.runtime_state import (
|
||||
add_activity_item,
|
||||
download_batches,
|
||||
download_tasks,
|
||||
tasks_lock,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LifecycleDeps:
|
||||
"""Bundle of cross-cutting deps the batch lifecycle needs."""
|
||||
config_manager: Any
|
||||
automation_engine: Any
|
||||
download_monitor: Any
|
||||
repair_worker: Any
|
||||
mb_worker: Any
|
||||
is_shutting_down: Callable[[], bool]
|
||||
get_batch_lock: Callable[[str], Any] # (batch_id) -> threading.Lock
|
||||
submit_download_track_worker: Callable # (task_id, batch_id) -> None (submits to executor)
|
||||
submit_failed_to_wishlist: Callable[[str], None] # async — submits to executor
|
||||
submit_failed_to_wishlist_with_auto_completion: Callable[[str], None] # async — submits to executor
|
||||
process_failed_to_wishlist: Callable[[str], None] # sync — direct call (used by v2 path)
|
||||
process_failed_to_wishlist_with_auto_completion: Callable[[str], None] # sync — direct call (used by v2 path)
|
||||
get_track_artist_name: Callable
|
||||
check_and_remove_from_wishlist: Callable
|
||||
regenerate_batch_m3u: Callable
|
||||
youtube_playlist_states: dict
|
||||
tidal_discovery_states: dict
|
||||
deezer_discovery_states: dict
|
||||
spotify_public_discovery_states: dict
|
||||
ensure_wishlist_track_format: Callable | None = None
|
||||
ensure_spotify_track_format: Callable | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.ensure_wishlist_track_format is None:
|
||||
self.ensure_wishlist_track_format = self.ensure_spotify_track_format
|
||||
if self.ensure_spotify_track_format is None:
|
||||
self.ensure_spotify_track_format = self.ensure_wishlist_track_format
|
||||
|
||||
if self.ensure_wishlist_track_format is None:
|
||||
raise ValueError("LifecycleDeps requires a wishlist track format helper")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# start_next_batch_of_downloads
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def start_next_batch_of_downloads(batch_id: str, deps: LifecycleDeps) -> None:
|
||||
"""Start the next batch of downloads up to the concurrent limit (like GUI)."""
|
||||
# ENHANCED: Use batch-specific lock to prevent race conditions when multiple threads
|
||||
# try to start workers for the same batch concurrently
|
||||
batch_lock = deps.get_batch_lock(batch_id)
|
||||
|
||||
with batch_lock:
|
||||
# Prevent starting new tasks if shutting down
|
||||
if deps.is_shutting_down():
|
||||
logger.info(f"[Batch Manager] Server shutting down - skipping new tasks for batch {batch_id}")
|
||||
return
|
||||
|
||||
with tasks_lock:
|
||||
if batch_id not in download_batches:
|
||||
return
|
||||
|
||||
batch = download_batches[batch_id]
|
||||
max_concurrent = batch['max_concurrent']
|
||||
queue = batch['queue']
|
||||
queue_index = batch['queue_index']
|
||||
active_count = batch['active_count']
|
||||
|
||||
logger.info(f"[Batch Lock] Starting workers for {batch_id}: active={active_count}, max={max_concurrent}, queue_pos={queue_index}/{len(queue)}")
|
||||
|
||||
# Start downloads up to the concurrent limit
|
||||
while active_count < max_concurrent and queue_index < len(queue):
|
||||
task_id = queue[queue_index]
|
||||
|
||||
# CRITICAL V2 FIX: Skip cancelled tasks instead of trying to restart them
|
||||
if task_id in download_tasks:
|
||||
current_status = download_tasks[task_id]['status']
|
||||
if current_status == 'cancelled':
|
||||
logger.warning(f"[Batch Lock] Skipping cancelled task {task_id} (queue position {queue_index + 1})")
|
||||
download_batches[batch_id]['queue_index'] += 1
|
||||
queue_index += 1
|
||||
continue # Skip to next task without consuming worker slot
|
||||
|
||||
# IMPORTANT: Set status to 'searching' BEFORE starting worker (like GUI)
|
||||
# Must be done INSIDE the lock to prevent race conditions with status polling
|
||||
download_tasks[task_id]['status'] = 'searching'
|
||||
download_tasks[task_id]['status_change_time'] = time.time()
|
||||
logger.info(f"[Batch Manager] Set task {task_id} status to 'searching'")
|
||||
else:
|
||||
logger.warning(f"[Batch Lock] Task {task_id} not found in download_tasks - skipping")
|
||||
download_batches[batch_id]['queue_index'] += 1
|
||||
queue_index += 1
|
||||
continue
|
||||
|
||||
# CRITICAL FIX: Submit to executor BEFORE incrementing counters to prevent ghost workers
|
||||
try:
|
||||
# Submit to executor first - this can fail
|
||||
deps.submit_download_track_worker(task_id, batch_id)
|
||||
|
||||
# Only increment counters AFTER successful submit
|
||||
download_batches[batch_id]['active_count'] += 1
|
||||
download_batches[batch_id]['queue_index'] += 1
|
||||
|
||||
logger.info(f"[Batch Lock] Started download {queue_index + 1}/{len(queue)} - Active: {active_count + 1}/{max_concurrent}")
|
||||
|
||||
# Update local counters for next iteration
|
||||
active_count += 1
|
||||
queue_index += 1
|
||||
|
||||
except Exception as submit_error:
|
||||
logger.error(f"[Batch Lock] CRITICAL: Failed to submit task {task_id} to executor: {submit_error}")
|
||||
logger.info("[Batch Lock] Worker slot NOT consumed - preventing ghost worker")
|
||||
|
||||
# Reset task status since worker never started
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
logger.error(f"[Batch Lock] Set task {task_id} status to 'failed' due to submit failure")
|
||||
|
||||
# Don't increment counters - no worker was actually started
|
||||
# This prevents the "ghost worker" issue where active_count is incremented but no actual worker runs
|
||||
break # Stop trying to start more workers if executor is failing
|
||||
|
||||
logger.info(f"[Batch Lock] Finished starting workers for {batch_id}: final_active={download_batches[batch_id]['active_count']}, max={max_concurrent}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# on_download_completed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def on_download_completed(batch_id: str, task_id: str, success: bool, deps: LifecycleDeps) -> None:
|
||||
"""Called when a download completes to start the next one in queue."""
|
||||
with tasks_lock:
|
||||
if batch_id not in download_batches:
|
||||
logger.warning(f"[Batch Manager] Batch {batch_id} not found for completed task {task_id}")
|
||||
return
|
||||
|
||||
# Guard against double-calling: track which tasks have already been completed
|
||||
# This prevents active_count from being decremented multiple times for the same task
|
||||
# (e.g. monitor detects completion AND post-processing calls this again)
|
||||
# NOTE: On duplicate calls, we skip decrement/tracking but STILL check batch completion.
|
||||
# This is critical because the first call may see the task in 'post_processing' (not finished),
|
||||
# and the second call (from post-processing worker) arrives after the task is truly 'completed'.
|
||||
# Without the fallthrough, batch_complete would never be emitted.
|
||||
completed_tasks = download_batches[batch_id].setdefault('_completed_task_ids', set())
|
||||
_is_duplicate_completion = task_id in completed_tasks
|
||||
if _is_duplicate_completion:
|
||||
logger.info(f"[Batch Manager] Task {task_id} already completed — skipping decrement, still checking batch completion")
|
||||
# Set terminal status so the monitor loop stops re-processing this task
|
||||
if task_id in download_tasks and download_tasks[task_id].get('status') in ('downloading', 'queued'):
|
||||
download_tasks[task_id]['status'] = 'completed'
|
||||
# Fall through to batch completion check below (don't return)
|
||||
else:
|
||||
completed_tasks.add(task_id)
|
||||
|
||||
if not _is_duplicate_completion:
|
||||
# Track failed/cancelled tasks in batch state (replicating sync.py)
|
||||
if not success and task_id in download_tasks:
|
||||
task = download_tasks[task_id]
|
||||
task_status = task.get('status', 'unknown')
|
||||
|
||||
# Build track_info structure matching sync.py's permanently_failed_tracks format
|
||||
original_track_info = task.get('track_info', {})
|
||||
|
||||
# Ensure wishlist track has proper structure for wishlist service
|
||||
wishlist_track_data = deps.ensure_wishlist_track_format(original_track_info)
|
||||
|
||||
track_info = {
|
||||
'download_index': task.get('track_index', 0),
|
||||
'table_index': task.get('track_index', 0),
|
||||
'track_name': original_track_info.get('name', 'Unknown Track'),
|
||||
'artist_name': deps.get_track_artist_name(original_track_info),
|
||||
'retry_count': task.get('retry_count', 0),
|
||||
'track_data': wishlist_track_data,
|
||||
'spotify_track': wishlist_track_data, # Backward-compatible alias for older callers
|
||||
'failure_reason': 'Download cancelled' if task_status == 'cancelled' else ('No matching track found' if task_status == 'not_found' else 'Download failed'),
|
||||
'candidates': task.get('cached_candidates', []), # Include search results if available
|
||||
}
|
||||
|
||||
if task_status == 'cancelled':
|
||||
download_batches[batch_id]['cancelled_tracks'].add(task.get('track_index', 0))
|
||||
logger.warning(f"[Batch Manager] Added cancelled track to batch tracking: {track_info['track_name']}")
|
||||
add_activity_item("", "Download Cancelled", f"'{track_info['track_name']}'", "Now")
|
||||
elif task_status in ('failed', 'not_found'):
|
||||
download_batches[batch_id]['permanently_failed_tracks'].append(track_info)
|
||||
if task_status == 'not_found':
|
||||
logger.info(f"[Batch Manager] Added not-found track to batch tracking: {track_info['track_name']}")
|
||||
add_activity_item("", "Not Found", f"'{track_info['track_name']}'", "Now")
|
||||
else:
|
||||
logger.error(f"[Batch Manager] Added failed track to batch tracking: {track_info['track_name']}")
|
||||
add_activity_item("", "Download Failed", f"'{track_info['track_name']}'", "Now")
|
||||
|
||||
try:
|
||||
if deps.automation_engine:
|
||||
deps.automation_engine.emit('download_failed', {
|
||||
'artist': track_info.get('artist_name', ''),
|
||||
'title': track_info.get('track_name', ''),
|
||||
'reason': track_info.get('failure_reason', 'Unknown'),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# WISHLIST REMOVAL: Handle successful downloads for wishlist removal
|
||||
if success and task_id in download_tasks:
|
||||
try:
|
||||
task = download_tasks[task_id]
|
||||
track_info = task.get('track_info', {})
|
||||
logger.info(f"[Batch Manager] Successful download - checking wishlist removal for task {task_id}")
|
||||
|
||||
# Add activity for successful download
|
||||
track_name = track_info.get('name', 'Unknown Track')
|
||||
|
||||
# Safely extract artist name (handle both list and string formats)
|
||||
artists = track_info.get('artists', [])
|
||||
if isinstance(artists, list) and len(artists) > 0:
|
||||
first_artist = artists[0]
|
||||
artist_name = first_artist.get('name', 'Unknown Artist') if isinstance(first_artist, dict) else str(first_artist)
|
||||
elif isinstance(artists, str):
|
||||
artist_name = artists
|
||||
else:
|
||||
artist_name = 'Unknown Artist'
|
||||
|
||||
add_activity_item("", "Download Complete", f"'{track_name}' by {artist_name}", "Now")
|
||||
|
||||
# Try to remove from wishlist using track info
|
||||
if track_info:
|
||||
# Create a context-like structure for the wishlist removal function
|
||||
context = {
|
||||
'track_info': track_info,
|
||||
'original_search_result': track_info, # fallback
|
||||
}
|
||||
deps.check_and_remove_from_wishlist(context)
|
||||
except Exception as wishlist_error:
|
||||
logger.error(f"[Batch Manager] Error checking wishlist removal for successful download: {wishlist_error}")
|
||||
|
||||
# Decrement active count
|
||||
old_active = download_batches[batch_id]['active_count']
|
||||
download_batches[batch_id]['active_count'] -= 1
|
||||
new_active = download_batches[batch_id]['active_count']
|
||||
|
||||
logger.error(f"[Batch Manager] Task {task_id} completed ({'success' if success else 'failed/cancelled'}). Active workers: {old_active} → {new_active}/{download_batches[batch_id]['max_concurrent']}")
|
||||
|
||||
# ENHANCED: Always check batch completion after any task completes (including duplicate calls)
|
||||
# This ensures completion is detected even when mixing normal downloads with cancelled tasks
|
||||
logger.info(f"[Batch Manager] Checking batch completion after task {task_id} completed")
|
||||
|
||||
# FIXED: Check if batch is truly complete (all tasks finished, not just workers freed)
|
||||
batch = download_batches[batch_id]
|
||||
all_tasks_started = batch['queue_index'] >= len(batch['queue'])
|
||||
no_active_workers = batch['active_count'] == 0
|
||||
|
||||
# Count actually finished tasks (completed, failed, or cancelled)
|
||||
# CRITICAL: Don't include 'post_processing' as finished - it's still in progress (unless stuck)!
|
||||
# CRITICAL: Don't include 'searching' as finished - task is being retried (unless stuck)!
|
||||
finished_count = 0
|
||||
retrying_count = 0
|
||||
queue = batch.get('queue', [])
|
||||
current_time = time.time()
|
||||
for queue_task_id in queue:
|
||||
if queue_task_id in download_tasks:
|
||||
task = download_tasks[queue_task_id]
|
||||
task_status = task['status']
|
||||
|
||||
# STUCK DETECTION: Force fail tasks that have been in transitional states too long
|
||||
if task_status == 'searching':
|
||||
task_age = current_time - task.get('status_change_time', current_time)
|
||||
if task_age > 600: # 10 minutes
|
||||
logger.info(f"⏰ [Stuck Detection] Task {queue_task_id} stuck in searching for {task_age:.0f}s - forcing not_found")
|
||||
task['status'] = 'not_found'
|
||||
task['error_message'] = f'Search stuck for {int(task_age // 60)} minutes with no results — timed out'
|
||||
finished_count += 1
|
||||
else:
|
||||
retrying_count += 1
|
||||
elif task_status == 'post_processing':
|
||||
task_age = current_time - task.get('status_change_time', current_time)
|
||||
if task_age > 300: # 5 minutes (post-processing should be fast)
|
||||
logger.info(f"⏰ [Stuck Detection] Task {queue_task_id} stuck in post_processing for {task_age:.0f}s - forcing completion")
|
||||
task['status'] = 'completed' # Assume it worked if file verification is taking too long
|
||||
finished_count += 1
|
||||
else:
|
||||
retrying_count += 1
|
||||
elif task_status in ['completed', 'failed', 'cancelled', 'not_found']:
|
||||
finished_count += 1
|
||||
else:
|
||||
# Task ID in queue but not in download_tasks - treat as completed to prevent blocking
|
||||
logger.warning(f"[Orphaned Task] Task {queue_task_id} in queue but not in download_tasks - counting as finished")
|
||||
finished_count += 1
|
||||
|
||||
all_tasks_truly_finished = finished_count >= len(queue)
|
||||
has_retrying_tasks = retrying_count > 0
|
||||
|
||||
if all_tasks_started and no_active_workers and all_tasks_truly_finished and not has_retrying_tasks:
|
||||
logger.error(f"[Batch Manager] Batch {batch_id} truly complete - all {finished_count}/{len(queue)} tasks finished - processing failed tracks to wishlist")
|
||||
elif all_tasks_started and no_active_workers and has_retrying_tasks:
|
||||
logger.warning(f"[Batch Manager] Batch {batch_id}: all workers free but {retrying_count} tasks retrying - continuing monitoring")
|
||||
elif all_tasks_started and no_active_workers:
|
||||
# This used to incorrectly mark batch as complete!
|
||||
logger.info(f"[Batch Manager] Batch {batch_id}: all workers free but only {finished_count}/{len(queue)} tasks finished - continuing monitoring")
|
||||
|
||||
if all_tasks_started and no_active_workers and all_tasks_truly_finished and not has_retrying_tasks:
|
||||
|
||||
# Check if this is an auto-initiated batch
|
||||
is_auto_batch = batch.get('auto_initiated', False)
|
||||
|
||||
# FIXED: Ensure batch is not already marked as complete to prevent duplicate processing
|
||||
if batch.get('phase') != 'complete':
|
||||
# Mark batch as complete and set completion timestamp for auto-cleanup
|
||||
batch['phase'] = 'complete'
|
||||
batch['completion_time'] = time.time() # Track when batch completed
|
||||
|
||||
# Record sync history completion
|
||||
from database.music_database import MusicDatabase
|
||||
record_sync_history_completion(MusicDatabase(), batch_id, batch)
|
||||
|
||||
# Add activity for batch completion
|
||||
playlist_name = batch.get('playlist_name', 'Unknown Playlist')
|
||||
failed_count = len(batch.get('permanently_failed_tracks', []))
|
||||
successful_downloads = finished_count - failed_count
|
||||
add_activity_item("", "Download Batch Complete", f"'{playlist_name}' - {successful_downloads} tracks downloaded", "Now")
|
||||
|
||||
# Emit batch_complete event for automation engine (only if something downloaded)
|
||||
if successful_downloads > 0:
|
||||
try:
|
||||
if deps.automation_engine:
|
||||
deps.automation_engine.emit('batch_complete', {
|
||||
'playlist_name': playlist_name,
|
||||
'total_tracks': str(len(queue)),
|
||||
'completed_tracks': str(successful_downloads),
|
||||
'failed_tracks': str(failed_count),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist
|
||||
playlist_id = batch.get('playlist_id')
|
||||
if playlist_id and playlist_id.startswith('youtube_'):
|
||||
url_hash = playlist_id.replace('youtube_', '')
|
||||
if url_hash in deps.youtube_playlist_states:
|
||||
deps.youtube_playlist_states[url_hash]['phase'] = 'download_complete'
|
||||
logger.info(f"Updated YouTube playlist {url_hash} to download_complete phase")
|
||||
|
||||
# Update Tidal playlist phase to 'download_complete' if this is a Tidal playlist
|
||||
if playlist_id and playlist_id.startswith('tidal_'):
|
||||
tidal_playlist_id = playlist_id.replace('tidal_', '')
|
||||
if tidal_playlist_id in deps.tidal_discovery_states:
|
||||
deps.tidal_discovery_states[tidal_playlist_id]['phase'] = 'download_complete'
|
||||
logger.info(f"Updated Tidal playlist {tidal_playlist_id} to download_complete phase")
|
||||
|
||||
# Update Deezer playlist phase to 'download_complete' if this is a Deezer playlist
|
||||
if playlist_id and playlist_id.startswith('deezer_'):
|
||||
deezer_playlist_id = playlist_id.replace('deezer_', '')
|
||||
if deezer_playlist_id in deps.deezer_discovery_states:
|
||||
deps.deezer_discovery_states[deezer_playlist_id]['phase'] = 'download_complete'
|
||||
logger.info(f"Updated Deezer playlist {deezer_playlist_id} to download_complete phase")
|
||||
|
||||
# Update Spotify Public playlist phase to 'download_complete' if this is a Spotify Public playlist
|
||||
if playlist_id and playlist_id.startswith('spotify_public_'):
|
||||
spotify_public_url_hash = playlist_id.replace('spotify_public_', '')
|
||||
if spotify_public_url_hash in deps.spotify_public_discovery_states:
|
||||
deps.spotify_public_discovery_states[spotify_public_url_hash]['phase'] = 'download_complete'
|
||||
logger.info(f"Updated Spotify Public playlist {spotify_public_url_hash} to download_complete phase")
|
||||
|
||||
logger.info(f"[Batch Manager] Batch {batch_id} complete - stopping monitor")
|
||||
deps.download_monitor.stop_monitoring(batch_id)
|
||||
|
||||
# M3U REGENERATION: Regenerate M3U with real library paths now that
|
||||
# all post-processing (tagging, moving, DB writes) is complete.
|
||||
# The frontend M3U save may fire too early — this ensures paths resolve.
|
||||
if deps.config_manager.get('m3u_export.enabled', False):
|
||||
try:
|
||||
m3u_tracks = []
|
||||
for tid in queue:
|
||||
if tid in download_tasks and download_tasks[tid].get('status') == 'completed':
|
||||
ti = download_tasks[tid].get('track_info', {})
|
||||
artists = ti.get('artists', [])
|
||||
artist_str = artists[0] if isinstance(artists, list) and artists else ''
|
||||
if isinstance(artist_str, dict):
|
||||
artist_str = artist_str.get('name', '')
|
||||
m3u_tracks.append({
|
||||
'name': ti.get('name', ''),
|
||||
'artist': artist_str,
|
||||
'duration_ms': ti.get('duration_ms', 0),
|
||||
})
|
||||
if m3u_tracks:
|
||||
deps.regenerate_batch_m3u(batch, m3u_tracks)
|
||||
except Exception as m3u_err:
|
||||
logger.error(f"[M3U] Error regenerating M3U on batch complete: {m3u_err}")
|
||||
|
||||
# REPAIR: Scan all album folders from this batch for track number issues
|
||||
if deps.repair_worker:
|
||||
deps.repair_worker.process_batch(batch_id)
|
||||
|
||||
# ALBUM CONSISTENCY: Picard-style post-batch pass — pick ONE MusicBrainz
|
||||
# release and overwrite album-level tags on all files to guarantee consistency.
|
||||
# This is the safety net: even if per-track MB lookups drifted (different cache
|
||||
# keys, API hiccups), this pass forces every file to share the same release MBID,
|
||||
# album artist ID, release group ID, etc. — preventing Navidrome album splits.
|
||||
_cons_files = batch.get('_consistency_files', [])
|
||||
if batch.get('is_album_download') and _cons_files and len(_cons_files) >= 2:
|
||||
_cons_album = batch.get('album_context', {})
|
||||
_cons_artist = batch.get('artist_context', {})
|
||||
_cons_album_name = _cons_album.get('name', '') if isinstance(_cons_album, dict) else ''
|
||||
_cons_artist_name = _cons_artist.get('name', '') if isinstance(_cons_artist, dict) else ''
|
||||
if _cons_album_name and _cons_artist_name:
|
||||
try:
|
||||
_cons_mb_svc = deps.mb_worker.mb_service if deps.mb_worker else None
|
||||
if _cons_mb_svc and deps.config_manager.get('musicbrainz.embed_tags', True):
|
||||
from core.album_consistency import run_album_consistency
|
||||
from core.metadata.common import get_file_lock
|
||||
_cons_result = run_album_consistency(
|
||||
file_infos=_cons_files,
|
||||
album_name=_cons_album_name,
|
||||
artist_name=_cons_artist_name,
|
||||
mb_service=_cons_mb_svc,
|
||||
total_discs=_cons_album.get('total_discs', 1),
|
||||
file_lock_fn=get_file_lock,
|
||||
)
|
||||
if _cons_result.get('success'):
|
||||
logger.info(f"[Album Consistency] {_cons_result['tags_written']}/{_cons_result['total_files']} files "
|
||||
f"harmonized to release {_cons_result.get('release_mbid', '')[:8]}...")
|
||||
elif _cons_result.get('error'):
|
||||
logger.error(f"[Album Consistency] Skipped: {_cons_result['error']}")
|
||||
except Exception as cons_err:
|
||||
logger.error(f"[Album Consistency] Failed (non-fatal): {cons_err}")
|
||||
|
||||
# Mark that wishlist processing is starting (prevents premature cleanup)
|
||||
batch['wishlist_processing_started'] = True
|
||||
|
||||
# Process wishlist outside of the lock to prevent threading issues
|
||||
if is_auto_batch:
|
||||
# For auto-initiated batches, handle completion and schedule next cycle
|
||||
deps.submit_failed_to_wishlist_with_auto_completion(batch_id)
|
||||
else:
|
||||
# For manual batches, use standard wishlist processing
|
||||
deps.submit_failed_to_wishlist(batch_id)
|
||||
else:
|
||||
logger.warning(f"[Batch Manager] Batch {batch_id} already marked complete - skipping duplicate processing")
|
||||
|
||||
return # Don't start next batch if we're done
|
||||
|
||||
# Start next downloads in queue
|
||||
logger.info(f"[Batch Manager] Starting next batch for {batch_id}")
|
||||
start_next_batch_of_downloads(batch_id, deps)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_batch_completion_v2
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_batch_completion_v2(batch_id: str, deps: LifecycleDeps) -> Optional[bool]:
|
||||
"""V2 SYSTEM: Check if batch is complete after worker slot changes.
|
||||
|
||||
This is needed because V2 atomic cancel bypasses on_download_completed,
|
||||
so we need to manually check for batch completion.
|
||||
"""
|
||||
try:
|
||||
with tasks_lock:
|
||||
if batch_id not in download_batches:
|
||||
logger.warning(f"[Completion Check V2] Batch {batch_id} not found")
|
||||
return
|
||||
|
||||
batch = download_batches[batch_id]
|
||||
all_tasks_started = batch['queue_index'] >= len(batch['queue'])
|
||||
no_active_workers = batch['active_count'] == 0
|
||||
|
||||
# Count actually finished tasks (completed, failed, or cancelled)
|
||||
finished_count = 0
|
||||
retrying_count = 0
|
||||
queue = batch.get('queue', [])
|
||||
current_time = time.time()
|
||||
|
||||
for task_id in queue:
|
||||
if task_id in download_tasks:
|
||||
task = download_tasks[task_id]
|
||||
task_status = task['status']
|
||||
|
||||
# STUCK DETECTION: Force fail tasks that have been in transitional states too long
|
||||
if task_status == 'searching':
|
||||
task_age = current_time - task.get('status_change_time', current_time)
|
||||
if task_age > 600: # 10 minutes
|
||||
logger.info(f"⏰ [Stuck Detection V2] Task {task_id} stuck in searching for {task_age:.0f}s - forcing not_found")
|
||||
task['status'] = 'not_found'
|
||||
task['error_message'] = f'Search stuck for {int(task_age // 60)} minutes with no results — timed out'
|
||||
finished_count += 1
|
||||
else:
|
||||
retrying_count += 1
|
||||
elif task_status == 'post_processing':
|
||||
task_age = current_time - task.get('status_change_time', current_time)
|
||||
if task_age > 300: # 5 minutes (post-processing should be fast)
|
||||
logger.info(f"⏰ [Stuck Detection V2] Task {task_id} stuck in post_processing for {task_age:.0f}s - forcing completion")
|
||||
task['status'] = 'completed' # Assume it worked if file verification is taking too long
|
||||
finished_count += 1
|
||||
else:
|
||||
retrying_count += 1
|
||||
elif task_status in ['completed', 'failed', 'cancelled', 'not_found']:
|
||||
finished_count += 1
|
||||
else:
|
||||
# Task ID in queue but not in download_tasks - treat as completed to prevent blocking
|
||||
logger.warning(f"[Orphaned Task V2] Task {task_id} in queue but not in download_tasks - counting as finished")
|
||||
finished_count += 1
|
||||
|
||||
all_tasks_truly_finished = finished_count >= len(queue)
|
||||
has_retrying_tasks = retrying_count > 0
|
||||
|
||||
logger.warning(f"[Completion Check V2] Batch {batch_id}: tasks_started={all_tasks_started}, workers={no_active_workers}, finished={finished_count}/{len(queue)}, retrying={retrying_count}")
|
||||
|
||||
is_auto_batch = False
|
||||
if all_tasks_started and no_active_workers and all_tasks_truly_finished and not has_retrying_tasks:
|
||||
# FIXED: Ensure batch is not already marked as complete to prevent duplicate processing
|
||||
if batch.get('phase') != 'complete':
|
||||
logger.info(f"[Completion Check V2] Batch {batch_id} is complete - marking as finished")
|
||||
|
||||
# Check if this is an auto-initiated batch
|
||||
is_auto_batch = batch.get('auto_initiated', False)
|
||||
|
||||
# Mark batch as complete and set completion timestamp for auto-cleanup
|
||||
batch['phase'] = 'complete'
|
||||
batch['completion_time'] = time.time() # Track when batch completed
|
||||
|
||||
# Add activity for batch completion
|
||||
playlist_name = batch.get('playlist_name', 'Unknown Playlist')
|
||||
failed_count = len(batch.get('permanently_failed_tracks', []))
|
||||
successful_downloads = finished_count - failed_count
|
||||
add_activity_item("", "Download Batch Complete", f"'{playlist_name}' - {successful_downloads} tracks downloaded", "Now")
|
||||
|
||||
# Emit batch_complete event for automation engine (only if something downloaded)
|
||||
if successful_downloads > 0:
|
||||
try:
|
||||
if deps.automation_engine:
|
||||
deps.automation_engine.emit('batch_complete', {
|
||||
'playlist_name': playlist_name,
|
||||
'total_tracks': str(len(queue)),
|
||||
'completed_tracks': str(successful_downloads),
|
||||
'failed_tracks': str(failed_count),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
logger.warning(f"[Completion Check V2] Batch {batch_id} already marked complete - skipping duplicate processing")
|
||||
return True # Already complete
|
||||
|
||||
# Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist
|
||||
playlist_id = batch.get('playlist_id')
|
||||
if playlist_id and playlist_id.startswith('youtube_'):
|
||||
url_hash = playlist_id.replace('youtube_', '')
|
||||
if url_hash in deps.youtube_playlist_states:
|
||||
deps.youtube_playlist_states[url_hash]['phase'] = 'download_complete'
|
||||
logger.info(f"[Completion Check V2] Updated YouTube playlist {url_hash} to download_complete phase")
|
||||
|
||||
# Update Tidal playlist phase to 'download_complete' if this is a Tidal playlist
|
||||
if playlist_id and playlist_id.startswith('tidal_'):
|
||||
tidal_playlist_id = playlist_id.replace('tidal_', '')
|
||||
if tidal_playlist_id in deps.tidal_discovery_states:
|
||||
deps.tidal_discovery_states[tidal_playlist_id]['phase'] = 'download_complete'
|
||||
logger.info(f"[Completion Check V2] Updated Tidal playlist {tidal_playlist_id} to download_complete phase")
|
||||
|
||||
# Update Deezer playlist phase to 'download_complete' if this is a Deezer playlist
|
||||
if playlist_id and playlist_id.startswith('deezer_'):
|
||||
deezer_playlist_id = playlist_id.replace('deezer_', '')
|
||||
if deezer_playlist_id in deps.deezer_discovery_states:
|
||||
deps.deezer_discovery_states[deezer_playlist_id]['phase'] = 'download_complete'
|
||||
logger.info(f"[Completion Check V2] Updated Deezer playlist {deezer_playlist_id} to download_complete phase")
|
||||
|
||||
# Update Spotify Public playlist phase to 'download_complete' if this is a Spotify Public playlist
|
||||
if playlist_id and playlist_id.startswith('spotify_public_'):
|
||||
spotify_public_url_hash = playlist_id.replace('spotify_public_', '')
|
||||
if spotify_public_url_hash in deps.spotify_public_discovery_states:
|
||||
deps.spotify_public_discovery_states[spotify_public_url_hash]['phase'] = 'download_complete'
|
||||
logger.info(f"[Completion Check V2] Updated Spotify Public playlist {spotify_public_url_hash} to download_complete phase")
|
||||
|
||||
logger.info(f"[Completion Check V2] Batch {batch_id} complete - stopping monitor")
|
||||
deps.download_monitor.stop_monitoring(batch_id)
|
||||
|
||||
# REPAIR: Scan all album folders from this batch for track number issues
|
||||
if deps.repair_worker:
|
||||
deps.repair_worker.process_batch(batch_id)
|
||||
|
||||
# ALBUM CONSISTENCY: Same Picard-style pass as the primary completion path
|
||||
_cons_files = batch.get('_consistency_files', [])
|
||||
if batch.get('is_album_download') and _cons_files and len(_cons_files) >= 2:
|
||||
_cons_album = batch.get('album_context', {})
|
||||
_cons_artist = batch.get('artist_context', {})
|
||||
_cons_album_name = _cons_album.get('name', '') if isinstance(_cons_album, dict) else ''
|
||||
_cons_artist_name = _cons_artist.get('name', '') if isinstance(_cons_artist, dict) else ''
|
||||
if _cons_album_name and _cons_artist_name:
|
||||
try:
|
||||
_cons_mb_svc = deps.mb_worker.mb_service if deps.mb_worker else None
|
||||
if _cons_mb_svc and deps.config_manager.get('musicbrainz.embed_tags', True):
|
||||
from core.album_consistency import run_album_consistency
|
||||
from core.metadata.common import get_file_lock
|
||||
_cons_result = run_album_consistency(
|
||||
file_infos=_cons_files,
|
||||
album_name=_cons_album_name,
|
||||
artist_name=_cons_artist_name,
|
||||
mb_service=_cons_mb_svc,
|
||||
total_discs=_cons_album.get('total_discs', 1),
|
||||
file_lock_fn=get_file_lock,
|
||||
)
|
||||
if _cons_result.get('success'):
|
||||
logger.info(f"[Album Consistency V2] {_cons_result['tags_written']}/{_cons_result['total_files']} files "
|
||||
f"harmonized to release {_cons_result.get('release_mbid', '')[:8]}...")
|
||||
elif _cons_result.get('error'):
|
||||
logger.error(f"[Album Consistency V2] Skipped: {_cons_result['error']}")
|
||||
except Exception as cons_err:
|
||||
logger.error(f"[Album Consistency V2] Failed (non-fatal): {cons_err}")
|
||||
|
||||
# Process wishlist outside of the lock to prevent threading issues
|
||||
if all_tasks_started and no_active_workers and all_tasks_truly_finished and not has_retrying_tasks:
|
||||
# Call wishlist processing outside the lock — DIRECT (synchronous) call
|
||||
# to match original v2 behavior. The non-v2 path (on_download_completed)
|
||||
# uses the async submit_* deps; v2 calls directly because v2 itself runs
|
||||
# from a context where blocking is acceptable.
|
||||
if is_auto_batch:
|
||||
logger.info("[Completion Check V2] Processing auto-initiated batch completion")
|
||||
deps.process_failed_to_wishlist_with_auto_completion(batch_id)
|
||||
else:
|
||||
logger.info("[Completion Check V2] Processing regular batch completion")
|
||||
deps.process_failed_to_wishlist(batch_id)
|
||||
|
||||
return True # Batch was completed
|
||||
else:
|
||||
logger.warning(f"[Completion Check V2] Batch {batch_id} not yet complete: finished={finished_count}/{len(queue)}, retrying={retrying_count}, workers={batch['active_count']}")
|
||||
return False # Batch still in progress
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Completion Check V2] Error checking batch completion: {e}")
|
||||
traceback.print_exc()
|
||||
return False
|
||||
648
core/downloads/master.py
Normal file
648
core/downloads/master.py
Normal file
|
|
@ -0,0 +1,648 @@
|
|||
"""Master worker for the missing-tracks download workflow.
|
||||
|
||||
`run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps)` is
|
||||
the single 580-line worker that orchestrates the entire pipeline:
|
||||
|
||||
1. PHASE 1 — Analysis: per-track DB ownership check, with album fast path
|
||||
(lookup album by name+artist, match tracks within it) plus a
|
||||
MusicBrainz release-cache preflight so per-track post-processing all
|
||||
uses the same release MBID (prevents Navidrome album splits).
|
||||
2. Wishlist removal for tracks already in the library.
|
||||
3. Explicit-content filter.
|
||||
4. PHASE 2 transition — if nothing missing, mark batch complete, update
|
||||
per-source playlist phases, kick auto-wishlist completion handler.
|
||||
5. Soulseek album pre-flight — search for a complete album folder before
|
||||
falling back to track-by-track search, cache the source for reuse.
|
||||
6. Wishlist album grouping — derive per-album disc counts and resolve
|
||||
ONE artist context per album so collab albums don't fold-split.
|
||||
7. Task creation with explicit album/artist context injection.
|
||||
8. Hand off to download monitor + start_next_batch_of_downloads.
|
||||
|
||||
Lifted verbatim from web_server.py. Wide dependency surface (config, MB
|
||||
caches, Soulseek client, source-page state dicts, multiple helper funcs)
|
||||
all injected via `MasterDeps`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
from core.runtime_state import download_batches, download_tasks, tasks_lock
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MasterDeps:
|
||||
"""Bundle of cross-cutting deps the master worker needs."""
|
||||
config_manager: Any
|
||||
soulseek_client: Any
|
||||
run_async: Callable[..., Any]
|
||||
mb_worker: Any
|
||||
mb_release_cache: dict
|
||||
mb_release_cache_lock: Any
|
||||
mb_release_detail_cache: dict
|
||||
mb_release_detail_cache_lock: Any
|
||||
normalize_album_cache_key: Callable[[str], str]
|
||||
check_and_remove_track_from_wishlist_by_metadata: Callable
|
||||
is_explicit_blocked: Callable
|
||||
youtube_playlist_states: dict
|
||||
tidal_discovery_states: dict
|
||||
deezer_discovery_states: dict
|
||||
spotify_public_discovery_states: dict
|
||||
missing_download_executor: Any
|
||||
process_failed_tracks_to_wishlist_exact_with_auto_completion: Callable
|
||||
source_reuse_logger: Any
|
||||
download_monitor: Any
|
||||
start_next_batch_of_downloads: Callable[[str], None]
|
||||
reset_wishlist_auto_processing: Callable[[], None]
|
||||
|
||||
|
||||
def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: MasterDeps):
|
||||
"""
|
||||
A master worker that handles the entire missing tracks process:
|
||||
1. Runs the analysis.
|
||||
2. If missing tracks are found, it automatically queues them for download.
|
||||
"""
|
||||
try:
|
||||
# PHASE 1: ANALYSIS
|
||||
with tasks_lock:
|
||||
if batch_id in download_batches:
|
||||
download_batches[batch_id]['phase'] = 'analysis'
|
||||
download_batches[batch_id]['analysis_total'] = len(tracks_json)
|
||||
download_batches[batch_id]['analysis_processed'] = 0
|
||||
|
||||
from database.music_database import MusicDatabase
|
||||
db = MusicDatabase()
|
||||
active_server = deps.config_manager.get_active_media_server()
|
||||
analysis_results = []
|
||||
|
||||
# Get force download flag and album context from batch
|
||||
force_download_all = False
|
||||
batch_album_context = None
|
||||
batch_artist_context = None
|
||||
batch_is_album = False
|
||||
with tasks_lock:
|
||||
if batch_id in download_batches:
|
||||
force_download_all = download_batches[batch_id].get('force_download_all', False)
|
||||
batch_is_album = download_batches[batch_id].get('is_album_download', False)
|
||||
batch_album_context = download_batches[batch_id].get('album_context')
|
||||
batch_artist_context = download_batches[batch_id].get('artist_context')
|
||||
|
||||
if force_download_all:
|
||||
logger.warning(f"[Force Download] Force download mode enabled for batch {batch_id} - treating all tracks as missing")
|
||||
|
||||
# Allow duplicate tracks across albums — when enabled, only skip tracks already
|
||||
# owned in THIS album, not tracks owned in other albums
|
||||
allow_duplicates = deps.config_manager.get('wishlist.allow_duplicate_tracks', True)
|
||||
if allow_duplicates and batch_is_album:
|
||||
logger.info("[Duplicates] Allow duplicate tracks enabled — only checking ownership within target album")
|
||||
|
||||
# PREFLIGHT: Pre-populate MusicBrainz release cache for album downloads.
|
||||
# This ensures ALL tracks in the album use the same release MBID during
|
||||
# per-track post-processing, preventing Navidrome album splits.
|
||||
if batch_is_album and batch_album_context and batch_artist_context:
|
||||
try:
|
||||
album_name_pf = batch_album_context.get('name', '')
|
||||
artist_name_pf = batch_artist_context.get('name', '')
|
||||
if album_name_pf and artist_name_pf:
|
||||
mb_svc = deps.mb_worker.mb_service if deps.mb_worker else None
|
||||
if mb_svc:
|
||||
from core.album_consistency import _find_best_release
|
||||
release = _find_best_release(album_name_pf, artist_name_pf, len(tracks_json), mb_svc)
|
||||
if release and release.get('id'):
|
||||
release_mbid = release['id']
|
||||
_artist_key = artist_name_pf.lower().strip()
|
||||
_rc_key_norm = (deps.normalize_album_cache_key(album_name_pf), _artist_key)
|
||||
_rc_key_exact = (album_name_pf.lower().strip(), _artist_key)
|
||||
with deps.mb_release_cache_lock:
|
||||
deps.mb_release_cache[_rc_key_norm] = release_mbid
|
||||
deps.mb_release_cache[_rc_key_exact] = release_mbid
|
||||
# Also cache the full release detail for tag extraction
|
||||
with deps.mb_release_detail_cache_lock:
|
||||
deps.mb_release_detail_cache[release_mbid] = release
|
||||
logger.info(f"[Preflight] Pre-cached MB release for '{album_name_pf}': "
|
||||
f"'{release.get('title', '')}' ({release_mbid[:8]}...)")
|
||||
else:
|
||||
logger.warning(f"[Preflight] No MB release found for '{album_name_pf}' — per-track lookup will be used")
|
||||
except Exception as pf_err:
|
||||
logger.error(f"[Preflight] MB release preflight failed: {pf_err}")
|
||||
|
||||
# ALBUM FAST PATH: If this is an album download, try to find the album in the DB first
|
||||
# and match tracks within it — faster and more accurate than N global searches
|
||||
album_tracks_map = {} # Maps normalized title -> DatabaseTrack for album-scoped matching
|
||||
if batch_is_album and batch_album_context and batch_artist_context and not force_download_all:
|
||||
album_name = batch_album_context.get('name', '')
|
||||
artist_name = batch_artist_context.get('name', '')
|
||||
total_tracks = batch_album_context.get('total_tracks', 0)
|
||||
if album_name and artist_name:
|
||||
try:
|
||||
db_album, album_confidence = db.check_album_exists_with_editions(
|
||||
title=album_name, artist=artist_name,
|
||||
confidence_threshold=0.7,
|
||||
expected_track_count=total_tracks if total_tracks > 0 else None,
|
||||
server_source=active_server
|
||||
)
|
||||
if db_album and album_confidence >= 0.7:
|
||||
db_album_tracks = db.get_tracks_by_album(db_album.id)
|
||||
for t in db_album_tracks:
|
||||
album_tracks_map[t.title.lower().strip()] = t
|
||||
logger.info(f"[Album Analysis] Found album '{db_album.title}' in DB with {len(db_album_tracks)} tracks (confidence: {album_confidence:.2f})")
|
||||
else:
|
||||
logger.warning(f"[Album Analysis] Album '{album_name}' not found in DB — falling back to per-track search")
|
||||
except Exception as album_err:
|
||||
logger.error(f"[Album Analysis] Album lookup error: {album_err} — falling back to per-track search")
|
||||
|
||||
for i, track_data in enumerate(tracks_json):
|
||||
# Use original table index if provided (for partial track selection),
|
||||
# otherwise fall back to enumeration index
|
||||
track_index = track_data.get('_original_index', i)
|
||||
track_name = track_data.get('name', '')
|
||||
artists = track_data.get('artists', [])
|
||||
found, confidence = False, 0.0
|
||||
|
||||
# Skip database check if force download is enabled
|
||||
if force_download_all:
|
||||
logger.warning(f"[Force Download] Skipping database check for '{track_name}' - treating as missing")
|
||||
found, confidence = False, 0.0
|
||||
elif album_tracks_map:
|
||||
# Album-scoped matching: check against known album tracks first
|
||||
track_name_lower = track_name.lower().strip()
|
||||
# Direct title match
|
||||
if track_name_lower in album_tracks_map:
|
||||
found, confidence = True, 1.0
|
||||
else:
|
||||
# Fuzzy match against album tracks using string similarity
|
||||
best_sim = 0.0
|
||||
for db_title_lower, _db_track in album_tracks_map.items():
|
||||
sim = db._string_similarity(track_name_lower, db_title_lower)
|
||||
if sim > best_sim:
|
||||
best_sim = sim
|
||||
if best_sim >= 0.7:
|
||||
found, confidence = True, best_sim
|
||||
else:
|
||||
# Fall back to global per-track search for this track
|
||||
# When allow_duplicates is on for album downloads, skip global
|
||||
# search — the track isn't in THIS album so treat as missing
|
||||
if allow_duplicates and batch_is_album:
|
||||
found, confidence = False, 0.0
|
||||
else:
|
||||
_fallback_album = batch_album_context.get('name') if batch_album_context else None
|
||||
for artist in artists:
|
||||
if isinstance(artist, str):
|
||||
artist_name = artist
|
||||
elif isinstance(artist, dict) and 'name' in artist:
|
||||
artist_name = artist['name']
|
||||
else:
|
||||
artist_name = str(artist)
|
||||
db_track, track_confidence = db.check_track_exists(
|
||||
track_name, artist_name, confidence_threshold=0.7, server_source=active_server, album=_fallback_album
|
||||
)
|
||||
if db_track and track_confidence >= 0.7:
|
||||
found, confidence = True, track_confidence
|
||||
break
|
||||
elif allow_duplicates and batch_is_album:
|
||||
# Allow duplicates + album download + album not in DB yet → treat all as missing
|
||||
found, confidence = False, 0.0
|
||||
else:
|
||||
# Non-album download (playlist/single track) — always check global
|
||||
for artist in artists:
|
||||
# Handle both string format and Spotify API format {'name': 'Artist Name'}
|
||||
if isinstance(artist, str):
|
||||
artist_name = artist
|
||||
elif isinstance(artist, dict) and 'name' in artist:
|
||||
artist_name = artist['name']
|
||||
else:
|
||||
artist_name = str(artist)
|
||||
db_track, track_confidence = db.check_track_exists(
|
||||
track_name, artist_name, confidence_threshold=0.7, server_source=active_server
|
||||
)
|
||||
if db_track and track_confidence >= 0.7:
|
||||
found, confidence = True, track_confidence
|
||||
break
|
||||
|
||||
analysis_results.append({
|
||||
'track_index': track_index, 'track': track_data, 'found': found, 'confidence': confidence
|
||||
})
|
||||
|
||||
# WISHLIST REMOVAL: If track is found in database, check if it should be removed from wishlist
|
||||
if found and confidence >= 0.7:
|
||||
try:
|
||||
deps.check_and_remove_track_from_wishlist_by_metadata(track_data)
|
||||
except Exception as wishlist_error:
|
||||
logger.error(f"[Analysis] Error checking wishlist removal for found track: {wishlist_error}")
|
||||
|
||||
with tasks_lock:
|
||||
if batch_id in download_batches:
|
||||
download_batches[batch_id]['analysis_processed'] = i + 1
|
||||
# Store incremental results for live updates
|
||||
download_batches[batch_id]['analysis_results'] = analysis_results.copy()
|
||||
|
||||
missing_tracks = [res for res in analysis_results if not res['found']]
|
||||
|
||||
# Filter explicit tracks if content filter is enabled
|
||||
if not deps.config_manager.get('content_filter.allow_explicit', True):
|
||||
before_count = len(missing_tracks)
|
||||
missing_tracks = [res for res in missing_tracks if not deps.is_explicit_blocked(res.get('track', {}))]
|
||||
skipped = before_count - len(missing_tracks)
|
||||
if skipped > 0:
|
||||
logger.warning(f"[Content Filter] Filtered out {skipped} explicit track(s) from download queue")
|
||||
|
||||
with tasks_lock:
|
||||
if batch_id in download_batches:
|
||||
download_batches[batch_id]['analysis_results'] = analysis_results
|
||||
|
||||
# PHASE 2: TRANSITION TO DOWNLOAD (if necessary)
|
||||
if not missing_tracks:
|
||||
logger.warning(f"Analysis for batch {batch_id} complete. No missing tracks.")
|
||||
|
||||
# Record sync history — all tracks found, nothing to download
|
||||
tracks_found = sum(1 for r in analysis_results if r.get('found'))
|
||||
try:
|
||||
db_sh = MusicDatabase()
|
||||
db_sh.update_sync_history_completion(batch_id, tracks_found=tracks_found, tracks_downloaded=0, tracks_failed=0)
|
||||
# Save per-track results (all found, no downloads)
|
||||
track_results = []
|
||||
for res in analysis_results:
|
||||
td = res.get('track', {})
|
||||
artists = td.get('artists', [])
|
||||
first_artist = (artists[0].get('name', artists[0]) if isinstance(artists[0], dict) else str(artists[0])) if artists else ''
|
||||
alb = td.get('album', '')
|
||||
# Extract image
|
||||
_img = ''
|
||||
_alb_obj = td.get('album', {})
|
||||
if isinstance(_alb_obj, dict):
|
||||
_alb_imgs = _alb_obj.get('images', [])
|
||||
if _alb_imgs and isinstance(_alb_imgs, list) and len(_alb_imgs) > 0:
|
||||
_img = _alb_imgs[0].get('url', '') if isinstance(_alb_imgs[0], dict) else ''
|
||||
track_results.append({
|
||||
'index': res.get('track_index', 0),
|
||||
'name': td.get('name', ''),
|
||||
'artist': first_artist,
|
||||
'album': alb.get('name', '') if isinstance(alb, dict) else str(alb or ''),
|
||||
'image_url': _img,
|
||||
'duration_ms': td.get('duration_ms', 0),
|
||||
'source_track_id': td.get('id', ''),
|
||||
'status': 'found' if res.get('found') else 'not_found',
|
||||
'confidence': round(res.get('confidence', 0.0), 3),
|
||||
'matched_track': None,
|
||||
'download_status': None,
|
||||
})
|
||||
if track_results:
|
||||
db_sh.update_sync_history_track_results(batch_id, json.dumps(track_results))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
is_auto_batch = False
|
||||
with tasks_lock:
|
||||
if batch_id in download_batches:
|
||||
is_auto_batch = download_batches[batch_id].get('auto_initiated', False)
|
||||
download_batches[batch_id]['phase'] = 'complete'
|
||||
download_batches[batch_id]['completion_time'] = time.time() # Track for auto-cleanup
|
||||
|
||||
# Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist
|
||||
if playlist_id.startswith('youtube_'):
|
||||
url_hash = playlist_id.replace('youtube_', '')
|
||||
if url_hash in deps.youtube_playlist_states:
|
||||
deps.youtube_playlist_states[url_hash]['phase'] = 'download_complete'
|
||||
logger.warning(f"Updated YouTube playlist {url_hash} to download_complete phase (no missing tracks)")
|
||||
|
||||
# Update Tidal playlist phase to 'download_complete' if this is a Tidal playlist
|
||||
if playlist_id.startswith('tidal_'):
|
||||
tidal_playlist_id = playlist_id.replace('tidal_', '')
|
||||
if tidal_playlist_id in deps.tidal_discovery_states:
|
||||
deps.tidal_discovery_states[tidal_playlist_id]['phase'] = 'download_complete'
|
||||
logger.warning(f"Updated Tidal playlist {tidal_playlist_id} to download_complete phase (no missing tracks)")
|
||||
|
||||
# Update Deezer playlist phase to 'download_complete' if this is a Deezer playlist
|
||||
if playlist_id.startswith('deezer_'):
|
||||
deezer_playlist_id = playlist_id.replace('deezer_', '')
|
||||
if deezer_playlist_id in deps.deezer_discovery_states:
|
||||
deps.deezer_discovery_states[deezer_playlist_id]['phase'] = 'download_complete'
|
||||
logger.warning(f"Updated Deezer playlist {deezer_playlist_id} to download_complete phase (no missing tracks)")
|
||||
|
||||
# Update Spotify Public playlist phase to 'download_complete' if this is a Spotify Public playlist
|
||||
if playlist_id.startswith('spotify_public_'):
|
||||
spotify_public_url_hash = playlist_id.replace('spotify_public_', '')
|
||||
if spotify_public_url_hash in deps.spotify_public_discovery_states:
|
||||
deps.spotify_public_discovery_states[spotify_public_url_hash]['phase'] = 'download_complete'
|
||||
logger.warning(f"Updated Spotify Public playlist {spotify_public_url_hash} to download_complete phase (no missing tracks)")
|
||||
|
||||
# Handle auto-initiated wishlist completion even when no missing tracks
|
||||
if is_auto_batch and playlist_id == 'wishlist':
|
||||
logger.warning("[Auto-Wishlist] No missing tracks found - calling auto-completion handler to toggle cycle and reschedule")
|
||||
deps.missing_download_executor.submit(deps.process_failed_tracks_to_wishlist_exact_with_auto_completion, batch_id)
|
||||
|
||||
return
|
||||
|
||||
logger.warning(f" transitioning batch {batch_id} to download phase with {len(missing_tracks)} tracks.")
|
||||
|
||||
# Read batch context (quick lock) before doing any network I/O
|
||||
with tasks_lock:
|
||||
if batch_id not in download_batches: return
|
||||
batch = download_batches[batch_id]
|
||||
batch_album_context = batch.get('album_context')
|
||||
batch_artist_context = batch.get('artist_context')
|
||||
batch_is_album = batch.get('is_album_download', False)
|
||||
batch_playlist_folder_mode = batch.get('playlist_folder_mode', False)
|
||||
batch_playlist_name = batch.get('playlist_name', 'Unknown Playlist')
|
||||
|
||||
# === ALBUM PRE-FLIGHT: Search for complete album folder before track-by-track ===
|
||||
# Only run pre-flight when Soulseek is the download source (or hybrid with soulseek)
|
||||
preflight_source = None
|
||||
preflight_tracks = None
|
||||
dl_source_mode = deps.config_manager.get('download_source.mode', 'hybrid')
|
||||
_dl_hybrid_order = deps.config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek'])
|
||||
_dl_hybrid_first = _dl_hybrid_order[0] if _dl_hybrid_order else deps.config_manager.get('download_source.hybrid_primary', 'hifi')
|
||||
soulseek_is_source = dl_source_mode == 'soulseek' or (
|
||||
dl_source_mode == 'hybrid' and _dl_hybrid_first == 'soulseek'
|
||||
)
|
||||
if batch_is_album and batch_album_context and batch_artist_context and soulseek_is_source:
|
||||
artist_name = batch_artist_context.get('name', '')
|
||||
album_name = batch_album_context.get('name', '')
|
||||
if artist_name and album_name:
|
||||
try:
|
||||
_sr = deps.source_reuse_logger
|
||||
_sr.info(f"[Album Pre-flight] Searching for '{artist_name} {album_name}'")
|
||||
logger.info(f"[Album Pre-flight] Searching Soulseek for complete album: '{artist_name} - {album_name}'")
|
||||
|
||||
slsk = deps.soulseek_client.soulseek if hasattr(deps.soulseek_client, 'soulseek') else deps.soulseek_client
|
||||
|
||||
# Try multiple query variations (banned keywords in artist/album name can return 0 results)
|
||||
album_queries = [f"{artist_name} {album_name}"]
|
||||
# Clean artist name (remove feat., parentheticals)
|
||||
clean_artist = re.sub(r'\s*\(.*?\)', '', artist_name).strip()
|
||||
clean_artist = re.sub(r'\s*(feat\.?|ft\.?|featuring)\s+.*$', '', clean_artist, flags=re.IGNORECASE).strip()
|
||||
if clean_artist != artist_name:
|
||||
album_queries.append(f"{clean_artist} {album_name}")
|
||||
# Album name only (some users file by album)
|
||||
album_queries.append(album_name)
|
||||
|
||||
album_results = []
|
||||
track_results = []
|
||||
for aq in album_queries:
|
||||
_sr.info(f"[Album Pre-flight] Trying query: '{aq}'")
|
||||
track_results, album_results = deps.run_async(slsk.search(aq, timeout=30))
|
||||
if album_results:
|
||||
_sr.info(f"[Album Pre-flight] Found {len(album_results)} album results with query: '{aq}'")
|
||||
break
|
||||
_sr.info(f"[Album Pre-flight] No album results for query: '{aq}'")
|
||||
|
||||
if album_results:
|
||||
# Filter by quality preference
|
||||
quality_filtered = []
|
||||
for ar in album_results:
|
||||
filtered_tracks = slsk.filter_results_by_quality_preference(ar.tracks)
|
||||
if filtered_tracks:
|
||||
quality_filtered.append((ar, len(filtered_tracks)))
|
||||
|
||||
if quality_filtered:
|
||||
# Sort by track count (most complete album first), then quality score
|
||||
quality_filtered.sort(key=lambda x: (x[1], x[0].quality_score), reverse=True)
|
||||
best_album = quality_filtered[0][0]
|
||||
|
||||
_sr.info(f"[Album Pre-flight] Best album result: {best_album.username}:{best_album.album_path} "
|
||||
f"({best_album.track_count} tracks, quality={best_album.dominant_quality})")
|
||||
logger.info(f"[Album Pre-flight] Found album folder: {best_album.username} — "
|
||||
f"{best_album.track_count} tracks ({best_album.dominant_quality})")
|
||||
|
||||
# Browse the user's folder to get all tracks (may have more than search returned)
|
||||
browse_files = deps.run_async(slsk.browse_user_directory(best_album.username, best_album.album_path))
|
||||
if browse_files:
|
||||
folder_tracks = slsk.parse_browse_results_to_tracks(
|
||||
best_album.username, browse_files, directory=best_album.album_path
|
||||
)
|
||||
if folder_tracks:
|
||||
preflight_source = {
|
||||
'username': best_album.username,
|
||||
'folder_path': best_album.album_path
|
||||
}
|
||||
preflight_tracks = folder_tracks
|
||||
_sr.info(f"[Album Pre-flight] Browsed folder: {len(folder_tracks)} audio tracks available")
|
||||
logger.info(f"[Album Pre-flight] Cached {len(folder_tracks)} tracks from {best_album.username} for source reuse")
|
||||
else:
|
||||
_sr.info("[Album Pre-flight] Browse returned files but no audio tracks")
|
||||
else:
|
||||
# Browse failed — fall back to using the search result tracks directly
|
||||
_sr.info("[Album Pre-flight] Browse failed, using search result tracks directly")
|
||||
preflight_source = {
|
||||
'username': best_album.username,
|
||||
'folder_path': best_album.album_path
|
||||
}
|
||||
preflight_tracks = best_album.tracks
|
||||
logger.info(f"[Album Pre-flight] Using {len(best_album.tracks)} tracks from search results (browse unavailable)")
|
||||
else:
|
||||
_sr.info("[Album Pre-flight] No album results passed quality filter")
|
||||
logger.warning("[Album Pre-flight] No album results matched quality preferences")
|
||||
else:
|
||||
_sr.info(f"[Album Pre-flight] Search returned no album results (got {len(track_results)} individual tracks)")
|
||||
logger.warning("[Album Pre-flight] No complete album folders found, falling back to track-by-track search")
|
||||
|
||||
except Exception as preflight_err:
|
||||
logger.error(f"[Album Pre-flight] Search failed (non-fatal, falling back to track-by-track): {preflight_err}")
|
||||
deps.source_reuse_logger.info(f"[Album Pre-flight] Exception: {preflight_err}")
|
||||
|
||||
with tasks_lock:
|
||||
if batch_id not in download_batches: return
|
||||
|
||||
download_batches[batch_id]['phase'] = 'downloading'
|
||||
|
||||
# Store album pre-flight results on batch for source reuse
|
||||
if preflight_source and preflight_tracks:
|
||||
download_batches[batch_id]['last_good_source'] = preflight_source
|
||||
download_batches[batch_id]['source_folder_tracks'] = preflight_tracks
|
||||
download_batches[batch_id]['failed_sources'] = set()
|
||||
logger.info(f"[Album Pre-flight] Pre-loaded source reuse data on batch {batch_id}")
|
||||
|
||||
# Compute total_discs for multi-disc album subfolder support
|
||||
# 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)
|
||||
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')}'")
|
||||
|
||||
# Pre-compute per-album data for wishlist tracks (grouped by album ID)
|
||||
# Wishlist tracks aren't batch_is_album but each track has disc_number in spotify_data
|
||||
wishlist_album_disc_counts = {}
|
||||
wishlist_album_artist_map = {} # album_id -> resolved artist context (consistent per album)
|
||||
if playlist_id == 'wishlist':
|
||||
import json as _json
|
||||
# First pass: collect disc_number and resolve ONE artist per album
|
||||
for t in tracks_json:
|
||||
sp_data = t.get('spotify_data', {})
|
||||
if isinstance(sp_data, str):
|
||||
try:
|
||||
sp_data = _json.loads(sp_data)
|
||||
except:
|
||||
sp_data = {}
|
||||
album_val = sp_data.get('album')
|
||||
album_id = album_val.get('id') if isinstance(album_val, dict) else album_val if isinstance(album_val, str) else None
|
||||
# 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))
|
||||
if album_id:
|
||||
wishlist_album_disc_counts[album_id] = max(
|
||||
wishlist_album_disc_counts.get(album_id, 1), disc_num
|
||||
)
|
||||
# Resolve album-level artist once per album (first track wins)
|
||||
if album_id not in wishlist_album_artist_map:
|
||||
_wl_source = t.get('source_info') or {}
|
||||
if isinstance(_wl_source, str):
|
||||
try:
|
||||
_wl_source = _json.loads(_wl_source)
|
||||
except:
|
||||
_wl_source = {}
|
||||
_wl_album = album_val if isinstance(album_val, dict) else {}
|
||||
_wl_album_artists = _wl_album.get('artists', [])
|
||||
# Priority: watchlist artist > album artists > track artists
|
||||
if _wl_source.get('watchlist_artist_name'):
|
||||
wishlist_album_artist_map[album_id] = {
|
||||
'name': _wl_source['watchlist_artist_name'],
|
||||
'id': _wl_source.get('watchlist_artist_id', '')
|
||||
}
|
||||
elif _wl_source.get('artist_name'):
|
||||
wishlist_album_artist_map[album_id] = {'name': _wl_source['artist_name']}
|
||||
elif _wl_album_artists:
|
||||
_fa = _wl_album_artists[0]
|
||||
wishlist_album_artist_map[album_id] = _fa if isinstance(_fa, dict) else {'name': str(_fa)}
|
||||
else:
|
||||
_wl_track_artists = sp_data.get('artists', [])
|
||||
if _wl_track_artists:
|
||||
_fa = _wl_track_artists[0]
|
||||
wishlist_album_artist_map[album_id] = _fa if isinstance(_fa, dict) else {'name': str(_fa)}
|
||||
else:
|
||||
# Try top-level 'artists' (wishlist format uses plural)
|
||||
_tl_artists = t.get('artists', [])
|
||||
if _tl_artists:
|
||||
_tla = _tl_artists[0]
|
||||
_fallback_name = _tla.get('name', str(_tla)) if isinstance(_tla, dict) else str(_tla)
|
||||
else:
|
||||
_fallback_name = t.get('artist', '')
|
||||
wishlist_album_artist_map[album_id] = {'name': _fallback_name or 'Unknown Artist'}
|
||||
logger.info(f"[Wishlist Album Grouping] Album '{_wl_album.get('name', album_id)}' → artist: '{wishlist_album_artist_map[album_id].get('name', '?')}'")
|
||||
|
||||
|
||||
|
||||
for res in missing_tracks:
|
||||
task_id = str(uuid.uuid4())
|
||||
track_info = res['track'].copy()
|
||||
|
||||
# Add explicit album context to track_info for artist album downloads
|
||||
if batch_is_album and batch_album_context and batch_artist_context:
|
||||
track_info['_explicit_album_context'] = batch_album_context
|
||||
track_info['_explicit_artist_context'] = batch_artist_context
|
||||
track_info['_is_explicit_album_download'] = True
|
||||
logger.info(f"[Task Creation] Added explicit album context for: {track_info.get('name')}")
|
||||
|
||||
# SPECIAL WISHLIST HANDLING: Inject album context if available to force grouping
|
||||
elif playlist_id == 'wishlist':
|
||||
# Extract spotify_data again since it might be buried
|
||||
spotify_data = track_info.get('spotify_data')
|
||||
if isinstance(spotify_data, str):
|
||||
try:
|
||||
spotify_data = json.loads(spotify_data)
|
||||
except:
|
||||
spotify_data = {}
|
||||
|
||||
if not spotify_data:
|
||||
spotify_data = {}
|
||||
|
||||
s_album = spotify_data.get('album') or {}
|
||||
if isinstance(s_album, str):
|
||||
s_album = {'name': s_album} # Normalize string album to dict
|
||||
s_artists = spotify_data.get('artists', [])
|
||||
|
||||
# We need at least an album name and artist
|
||||
if s_album and isinstance(s_album, dict) and s_album.get('name'):
|
||||
# Use pre-computed album-level artist for folder consistency.
|
||||
# All tracks from the same album get the same artist context,
|
||||
# preventing folder splits on collab albums (KPOP Demon Hunters, etc.)
|
||||
album_id_for_lookup = s_album.get('id')
|
||||
# Fallback album key: match first-pass logic for missing IDs
|
||||
if not album_id_for_lookup and s_album.get('name'):
|
||||
album_id_for_lookup = f"_name_{s_album['name'].lower().strip()}"
|
||||
if not album_id_for_lookup:
|
||||
album_id_for_lookup = 'wishlist_album'
|
||||
artist_ctx = wishlist_album_artist_map.get(album_id_for_lookup, {})
|
||||
if not artist_ctx or not artist_ctx.get('name'):
|
||||
# Fallback: per-track resolution from artists array
|
||||
_fb_artists = track_info.get('artists', [])
|
||||
if _fb_artists:
|
||||
_fb_a = _fb_artists[0]
|
||||
_fb_name = _fb_a.get('name', str(_fb_a)) if isinstance(_fb_a, dict) else str(_fb_a)
|
||||
else:
|
||||
_fb_name = track_info.get('artist', '')
|
||||
artist_ctx = {'name': _fb_name or 'Unknown Artist'}
|
||||
|
||||
# Construct minimal album context
|
||||
# Ensure images are preserved (important for artwork)
|
||||
album_id = s_album.get('id', 'wishlist_album')
|
||||
album_ctx = {
|
||||
'id': album_id,
|
||||
'name': s_album.get('name'),
|
||||
'release_date': s_album.get('release_date', ''),
|
||||
'total_tracks': s_album.get('total_tracks', 1),
|
||||
'total_discs': wishlist_album_disc_counts.get(album_id, 1),
|
||||
'album_type': s_album.get('album_type', 'album'),
|
||||
'images': s_album.get('images', []) # Pass images array directly
|
||||
}
|
||||
|
||||
track_info['_explicit_album_context'] = album_ctx
|
||||
track_info['_explicit_artist_context'] = artist_ctx
|
||||
track_info['_is_explicit_album_download'] = True
|
||||
logger.info(f"[Wishlist] Added album context for: '{track_info.get('name')}' -> '{album_ctx['name']}'")
|
||||
|
||||
|
||||
# Add playlist folder mode flag for sync page playlists
|
||||
if batch_playlist_folder_mode:
|
||||
track_info['_playlist_folder_mode'] = True
|
||||
track_info['_playlist_name'] = batch_playlist_name
|
||||
logger.info(f"[Task Creation] Added playlist folder mode for: {track_info.get('name')} → {batch_playlist_name}")
|
||||
else:
|
||||
logger.debug(f"[Debug] Task Creation - playlist folder mode NOT enabled for: {track_info.get('name')}")
|
||||
|
||||
download_tasks[task_id] = {
|
||||
'status': 'pending', 'track_info': track_info,
|
||||
'playlist_id': playlist_id, 'batch_id': batch_id,
|
||||
'track_index': res['track_index'], 'retry_count': 0,
|
||||
'cached_candidates': [], 'used_sources': set(),
|
||||
'status_change_time': time.time(),
|
||||
'metadata_enhanced': False
|
||||
}
|
||||
download_batches[batch_id]['queue'].append(task_id)
|
||||
|
||||
deps.download_monitor.start_monitoring(batch_id)
|
||||
deps.start_next_batch_of_downloads(batch_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Master worker for batch {batch_id} failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
is_auto_batch = False
|
||||
with tasks_lock:
|
||||
if batch_id in download_batches:
|
||||
is_auto_batch = download_batches[batch_id].get('auto_initiated', False)
|
||||
download_batches[batch_id]['phase'] = 'error'
|
||||
download_batches[batch_id]['error'] = str(e)
|
||||
|
||||
# Reset YouTube playlist phase to 'discovered' if this is a YouTube playlist on error
|
||||
if playlist_id.startswith('youtube_'):
|
||||
url_hash = playlist_id.replace('youtube_', '')
|
||||
if url_hash in deps.youtube_playlist_states:
|
||||
deps.youtube_playlist_states[url_hash]['phase'] = 'discovered'
|
||||
logger.error(f"Reset YouTube playlist {url_hash} to discovered phase (error)")
|
||||
|
||||
# Handle auto-initiated wishlist errors - reset flag
|
||||
if is_auto_batch and playlist_id == 'wishlist':
|
||||
logger.error("[Auto-Wishlist] Master worker error - resetting auto-processing flag")
|
||||
deps.reset_wishlist_auto_processing()
|
||||
798
core/downloads/monitor.py
Normal file
798
core/downloads/monitor.py
Normal file
|
|
@ -0,0 +1,798 @@
|
|||
"""WebUIDownloadMonitor — lifted from web_server.py.
|
||||
|
||||
The class body is byte-identical to the original. Module-level globals
|
||||
(injected via ``init()`` from web_server) include the worker / completion
|
||||
helpers and orchestrator handles. ``IS_SHUTTING_DOWN`` is a module-level
|
||||
flag mirrored from web_server's own flag in ``_shutdown_runtime_components``.
|
||||
"""
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from config.settings import config_manager
|
||||
from core.runtime_state import (
|
||||
download_batches,
|
||||
download_tasks,
|
||||
matched_context_lock,
|
||||
matched_downloads_context,
|
||||
tasks_lock,
|
||||
)
|
||||
from utils.async_helpers import run_async
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mirrored from web_server.IS_SHUTTING_DOWN via _shutdown_runtime_components.
|
||||
IS_SHUTTING_DOWN = False
|
||||
|
||||
# Injected at runtime via init() — these are defined later in web_server.py
|
||||
# than the class is instantiated, so we late-bind them.
|
||||
_make_context_key = None
|
||||
_on_download_completed = None
|
||||
_download_track_worker = None
|
||||
_run_post_processing_worker = None
|
||||
_start_next_batch_of_downloads = None
|
||||
_orphaned_download_keys = None
|
||||
missing_download_executor = None
|
||||
soulseek_client = None
|
||||
|
||||
|
||||
def init(
|
||||
make_context_key,
|
||||
on_download_completed,
|
||||
download_track_worker,
|
||||
run_post_processing_worker,
|
||||
start_next_batch_of_downloads,
|
||||
orphaned_download_keys,
|
||||
missing_download_executor_obj,
|
||||
soulseek_client_obj,
|
||||
):
|
||||
"""Bind web_server-side helpers/globals so the class body can resolve them."""
|
||||
global _make_context_key, _on_download_completed, _download_track_worker
|
||||
global _run_post_processing_worker, _start_next_batch_of_downloads
|
||||
global _orphaned_download_keys, missing_download_executor, soulseek_client
|
||||
_make_context_key = make_context_key
|
||||
_on_download_completed = on_download_completed
|
||||
_download_track_worker = download_track_worker
|
||||
_run_post_processing_worker = run_post_processing_worker
|
||||
_start_next_batch_of_downloads = start_next_batch_of_downloads
|
||||
_orphaned_download_keys = orphaned_download_keys
|
||||
missing_download_executor = missing_download_executor_obj
|
||||
soulseek_client = soulseek_client_obj
|
||||
|
||||
|
||||
class WebUIDownloadMonitor:
|
||||
"""
|
||||
Background monitor for download progress and retry logic, matching GUI's SyncStatusProcessingWorker.
|
||||
Implements identical timeout detection and automatic retry functionality.
|
||||
"""
|
||||
def __init__(self):
|
||||
self.monitoring = False
|
||||
self.monitor_thread = None
|
||||
self.monitored_batches = set()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def start_monitoring(self, batch_id):
|
||||
"""Start monitoring a download batch"""
|
||||
with self._lock:
|
||||
self.monitored_batches.add(batch_id)
|
||||
if not self.monitoring:
|
||||
self.monitoring = True
|
||||
self.monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
||||
self.monitor_thread.start()
|
||||
logger.info(f"Started download monitor for batch {batch_id}")
|
||||
|
||||
def stop_monitoring(self, batch_id):
|
||||
"""Stop monitoring a specific batch"""
|
||||
with self._lock:
|
||||
self.monitored_batches.discard(batch_id)
|
||||
if not self.monitored_batches:
|
||||
self.monitoring = False
|
||||
logger.debug("Stopped download monitor (no active batches)")
|
||||
|
||||
def shutdown(self):
|
||||
"""Stop the monitor loop and clear active batch tracking."""
|
||||
with self._lock:
|
||||
self.monitoring = False
|
||||
self.monitored_batches.clear()
|
||||
self.monitor_thread = None
|
||||
logger.info("Download monitor shutdown requested")
|
||||
|
||||
def _monitor_loop(self):
|
||||
"""Main monitoring loop - checks downloads every 1 second for responsive web UX"""
|
||||
while self.monitoring and self.monitored_batches:
|
||||
try:
|
||||
if globals().get('IS_SHUTTING_DOWN', False):
|
||||
self.monitoring = False
|
||||
break
|
||||
self._check_all_downloads()
|
||||
time.sleep(1) # 1-second polling for fast web UI updates
|
||||
except Exception as e:
|
||||
# If we get shutdown errors, stop monitoring gracefully
|
||||
if "interpreter shutdown" in str(e) or "cannot schedule new futures" in str(e):
|
||||
logger.info("Monitor detected shutdown, stopping gracefully")
|
||||
self.monitoring = False
|
||||
break
|
||||
logger.error(f"Download monitor error: {e}")
|
||||
|
||||
logger.info("Download monitor loop ended")
|
||||
|
||||
def _check_all_downloads(self):
|
||||
"""Check all active downloads for timeouts and failures"""
|
||||
current_time = time.time()
|
||||
|
||||
# Get live transfer data from slskd
|
||||
live_transfers_lookup = self._get_live_transfers()
|
||||
|
||||
# Track tasks with exhausted retries to handle after releasing lock
|
||||
exhausted_tasks = [] # List of (batch_id, task_id) tuples
|
||||
# Track completed downloads to handle after releasing lock (prevents deadlock)
|
||||
completed_tasks = [] # List of (batch_id, task_id) tuples
|
||||
# Track deferred operations (network calls, nested locks) to run after releasing tasks_lock
|
||||
deferred_ops = []
|
||||
|
||||
with tasks_lock:
|
||||
# Check all monitored batches for timeouts and errors
|
||||
for batch_id in list(self.monitored_batches):
|
||||
if batch_id not in download_batches:
|
||||
self.monitored_batches.discard(batch_id)
|
||||
continue
|
||||
|
||||
for task_id in download_batches[batch_id].get('queue', []):
|
||||
task = download_tasks.get(task_id)
|
||||
if not task or task['status'] not in ['downloading', 'queued']:
|
||||
continue
|
||||
|
||||
# Check for timeouts and errors - retries handled directly in _should_retry_task
|
||||
# If _should_retry_task returns True, it means retries were exhausted
|
||||
retry_exhausted = self._should_retry_task(task_id, task, live_transfers_lookup, current_time, deferred_ops)
|
||||
# Collect exhausted tasks to handle outside lock (prevents deadlock)
|
||||
if retry_exhausted:
|
||||
exhausted_tasks.append((batch_id, task_id))
|
||||
|
||||
# ENHANCED: Check for successful completions (especially YouTube)
|
||||
task_filename = task.get('filename') or task.get('track_info', {}).get('filename')
|
||||
task_username = task.get('username') or task.get('track_info', {}).get('username')
|
||||
|
||||
if task_filename and task_username:
|
||||
lookup_key = _make_context_key(task_username, task_filename)
|
||||
live_info = live_transfers_lookup.get(lookup_key)
|
||||
|
||||
if live_info:
|
||||
state = live_info.get('state', '')
|
||||
# Trigger post-processing if download is completed successfully
|
||||
# slskd uses compound states like 'Completed, Succeeded' - use substring matching
|
||||
# Must exclude error states first (matching _build_batch_status_data's prioritized checking)
|
||||
has_error = ('Errored' in state or 'Failed' in state or 'Rejected' in state or 'TimedOut' in state)
|
||||
has_completion = ('Completed' in state or 'Succeeded' in state)
|
||||
# Verify bytes actually transferred before trusting state string.
|
||||
# slskd can report "Completed" before the full file is flushed to disk,
|
||||
# or on connection drops that leave a partial file.
|
||||
if has_completion and not has_error:
|
||||
expected_size = live_info.get('size', 0)
|
||||
transferred = live_info.get('bytesTransferred', 0)
|
||||
if expected_size > 0 and transferred < expected_size:
|
||||
if not task.get('_incomplete_warned'):
|
||||
logger.debug(f"Monitor: {task_id} state={state} but bytes incomplete ({transferred}/{expected_size}) — waiting")
|
||||
task['_incomplete_warned'] = True
|
||||
continue
|
||||
if has_completion and not has_error and task['status'] == 'downloading':
|
||||
task.pop('_incomplete_warned', None)
|
||||
# CRITICAL FIX: Transition to 'post_processing' HERE so downloads
|
||||
# don't depend on browser polling to trigger post-processing.
|
||||
# Previously, post-processing was only submitted by _build_batch_status_data
|
||||
# (called from browser-polled endpoints), meaning closing the browser
|
||||
# left tasks stuck in 'downloading' forever.
|
||||
task['status'] = 'post_processing'
|
||||
task['status_change_time'] = current_time
|
||||
logger.info(f"Monitor detected completed download for {task_id} ({state}) - submitting post-processing")
|
||||
# Collect for handling outside the lock to prevent deadlock.
|
||||
# _on_download_completed acquires tasks_lock which is non-reentrant.
|
||||
completed_tasks.append((batch_id, task_id))
|
||||
|
||||
# ---- All work below runs WITHOUT tasks_lock held ----
|
||||
if globals().get('IS_SHUTTING_DOWN', False) or not self.monitoring:
|
||||
return
|
||||
|
||||
# Execute deferred operations from _should_retry_task (network calls, nested locks)
|
||||
for op in deferred_ops:
|
||||
try:
|
||||
if op[0] == 'cancel_download':
|
||||
_, download_id, username = op
|
||||
logger.debug(f"[Deferred] Cancelling download: {download_id} from {username}")
|
||||
run_async(soulseek_client.cancel_download(download_id, username, remove=True))
|
||||
logger.debug(f"[Deferred] Successfully cancelled download {download_id}")
|
||||
elif op[0] == 'cleanup_orphan':
|
||||
_, context_key = op
|
||||
with matched_context_lock:
|
||||
matched_downloads_context.pop(context_key, None)
|
||||
logger.debug(f"[Deferred] Cleaned up orphaned download context: {context_key}")
|
||||
elif op[0] == 'restart_worker':
|
||||
_, task_id, batch_id = op
|
||||
logger.debug(f"[Deferred] Restarting worker for task {task_id}")
|
||||
missing_download_executor.submit(_download_track_worker, task_id, batch_id)
|
||||
logger.debug(f"[Deferred] Successfully restarted worker for task {task_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Deferred] Error executing deferred operation {op[0]}: {e}")
|
||||
|
||||
# Handle completed downloads outside the lock to prevent deadlock
|
||||
# (_on_download_completed acquires tasks_lock internally)
|
||||
for batch_id, task_id in completed_tasks:
|
||||
try:
|
||||
# Submit post-processing worker (file move, tagging, AcoustID verification)
|
||||
# This makes batch downloads fully independent of browser polling.
|
||||
logger.info(f"[Monitor] Submitting post-processing worker for task {task_id}")
|
||||
missing_download_executor.submit(_run_post_processing_worker, task_id, batch_id)
|
||||
# Chain to next download in the batch queue
|
||||
_on_download_completed(batch_id, task_id, success=True)
|
||||
except Exception as e:
|
||||
logger.error(f"[Monitor] Error handling completed task {task_id}: {e}")
|
||||
# Handle exhausted retry tasks outside the lock to prevent deadlock
|
||||
for batch_id, task_id in exhausted_tasks:
|
||||
try:
|
||||
logger.info(f"[Monitor] Calling completion callback for exhausted task {task_id}")
|
||||
_on_download_completed(batch_id, task_id, success=False)
|
||||
except Exception as e:
|
||||
logger.error(f"[Monitor] Error handling exhausted task {task_id}: {e}")
|
||||
|
||||
# ENHANCED: Add worker count validation to detect ghost workers
|
||||
self._validate_worker_counts()
|
||||
|
||||
def _get_live_transfers(self):
|
||||
"""Get current transfer status from slskd API and YouTube client"""
|
||||
try:
|
||||
# Check if we should stop due to shutdown
|
||||
if not self.monitoring:
|
||||
return {}
|
||||
|
||||
live_transfers = {}
|
||||
|
||||
# Only hit slskd API if soulseek is actually configured and active
|
||||
dl_mode = config_manager.get('download_source.mode', 'hybrid')
|
||||
hybrid_order = config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek'])
|
||||
soulseek_active = (dl_mode == 'soulseek' or
|
||||
(dl_mode == 'hybrid' and 'soulseek' in hybrid_order))
|
||||
|
||||
# Get Soulseek downloads from API
|
||||
transfers_data = None
|
||||
if soulseek_active and soulseek_client and getattr(soulseek_client, 'soulseek', None) and soulseek_client.soulseek.base_url:
|
||||
transfers_data = run_async(soulseek_client._make_request('GET', 'transfers/downloads'))
|
||||
if transfers_data:
|
||||
for user_data in transfers_data:
|
||||
username = user_data.get('username', 'Unknown')
|
||||
if 'directories' in user_data:
|
||||
for directory in user_data['directories']:
|
||||
if 'files' in directory:
|
||||
for file_info in directory['files']:
|
||||
key = _make_context_key(username, file_info.get('filename', ''))
|
||||
live_transfers[key] = file_info
|
||||
|
||||
# Also get non-Soulseek downloads (YouTube/Tidal/Qobuz/HiFi/Deezer/Lidarr)
|
||||
# Call each client directly to avoid redundant slskd API call through orchestrator
|
||||
try:
|
||||
all_downloads = []
|
||||
for _dl_client in [soulseek_client.youtube, soulseek_client.tidal, soulseek_client.qobuz,
|
||||
soulseek_client.hifi, soulseek_client.deezer_dl, soulseek_client.lidarr]:
|
||||
if _dl_client:
|
||||
try:
|
||||
all_downloads.extend(run_async(_dl_client.get_all_downloads()))
|
||||
except Exception:
|
||||
pass
|
||||
for download in all_downloads:
|
||||
key = _make_context_key(download.username, download.filename)
|
||||
# Convert DownloadStatus to transfer dict format for monitor compatibility
|
||||
live_transfers[key] = {
|
||||
'id': download.id,
|
||||
'filename': download.filename,
|
||||
'username': download.username,
|
||||
'state': download.state,
|
||||
'percentComplete': download.progress,
|
||||
'size': download.size,
|
||||
'bytesTransferred': download.transferred,
|
||||
'averageSpeed': download.speed,
|
||||
}
|
||||
except Exception as yt_error:
|
||||
logger.error(f"Monitor: Could not fetch streaming source downloads: {yt_error}")
|
||||
|
||||
return live_transfers
|
||||
except Exception as e:
|
||||
# If we get shutdown-related errors, stop monitoring immediately
|
||||
if ("interpreter shutdown" in str(e) or
|
||||
"cannot schedule new futures" in str(e) or
|
||||
"Event loop is closed" in str(e)):
|
||||
logger.info("Monitor detected shutdown, stopping immediately")
|
||||
self.monitoring = False
|
||||
return {}
|
||||
else:
|
||||
logger.error(f"Monitor: Could not fetch live transfers: {e}")
|
||||
return {}
|
||||
|
||||
def _should_retry_task(self, task_id, task, live_transfers_lookup, current_time, deferred_ops):
|
||||
"""
|
||||
Determine if a task should be retried due to timeout (matches GUI logic).
|
||||
|
||||
IMPORTANT: This runs while tasks_lock is held. All network calls (slskd API)
|
||||
and nested lock acquisitions (matched_context_lock) are collected into deferred_ops
|
||||
to be executed AFTER releasing tasks_lock. This prevents deadlocks and long lock holds.
|
||||
|
||||
Returns True if retries are exhausted and _on_download_completed should be called outside the lock.
|
||||
"""
|
||||
ti = task.get('track_info') if isinstance(task.get('track_info'), dict) else {}
|
||||
task_filename = task.get('filename') or ti.get('filename')
|
||||
task_username = task.get('username') or ti.get('username')
|
||||
|
||||
if not task_filename or not task_username:
|
||||
return False
|
||||
|
||||
lookup_key = _make_context_key(task_username, task_filename)
|
||||
live_info = live_transfers_lookup.get(lookup_key)
|
||||
|
||||
if not live_info:
|
||||
# Task not in live transfers but status is downloading/queued - likely stuck
|
||||
if current_time - task.get('status_change_time', current_time) > 90:
|
||||
retry_count = task.get('stuck_retry_count', 0)
|
||||
last_retry = task.get('last_retry_time', 0)
|
||||
|
||||
if retry_count < 3 and (current_time - last_retry) > 30:
|
||||
logger.warning(f"Task not in live transfers for >90s - retry {retry_count + 1}/3")
|
||||
task['stuck_retry_count'] = retry_count + 1
|
||||
task['last_retry_time'] = current_time
|
||||
|
||||
download_id = task.get('download_id')
|
||||
|
||||
# Defer slskd cancel to outside the lock
|
||||
if task_username and download_id:
|
||||
deferred_ops.append(('cancel_download', download_id, task_username))
|
||||
|
||||
# Mark current source as used (full filename to match worker format)
|
||||
if task_username and task_filename:
|
||||
used_sources = task.get('used_sources', set())
|
||||
source_key = f"{task_username}_{task_filename}"
|
||||
used_sources.add(source_key)
|
||||
task['used_sources'] = used_sources
|
||||
logger.warning(f"Marked missing-transfer source as used: {source_key}")
|
||||
|
||||
# Defer orphan cleanup
|
||||
if task_username and task_filename:
|
||||
_orphaned_download_keys.add(lookup_key)
|
||||
deferred_ops.append(('cleanup_orphan', lookup_key))
|
||||
|
||||
# Clear download info and reset for retry
|
||||
task.pop('download_id', None)
|
||||
task.pop('username', None)
|
||||
task.pop('filename', None)
|
||||
task['status'] = 'searching'
|
||||
task.pop('queued_start_time', None)
|
||||
task.pop('downloading_start_time', None)
|
||||
task['status_change_time'] = current_time
|
||||
logger.warning(f"Task {task.get('track_info', {}).get('name', 'Unknown')} reset for missing-transfer retry")
|
||||
|
||||
batch_id = task.get('batch_id')
|
||||
if task_id and batch_id:
|
||||
deferred_ops.append(('restart_worker', task_id, batch_id))
|
||||
return False
|
||||
elif retry_count < 3:
|
||||
return False
|
||||
else:
|
||||
track_label = task.get('track_info', {}).get('name', 'Unknown')
|
||||
tried_sources = task.get('used_sources', set())
|
||||
sources_str = f' (tried {len(tried_sources)} source{"s" if len(tried_sources) != 1 else ""})' if tried_sources else ''
|
||||
logger.error("Task failed after 3 retry attempts (not in live transfers)")
|
||||
task['status'] = 'failed'
|
||||
task['error_message'] = f'Download disappeared from transfer list 3 times for "{track_label}"{sources_str} — source may be unavailable'
|
||||
|
||||
batch_id = task.get('batch_id')
|
||||
if batch_id:
|
||||
return True
|
||||
return False
|
||||
return False
|
||||
|
||||
state_str = live_info.get('state', '')
|
||||
progress = live_info.get('percentComplete', 0)
|
||||
|
||||
# IMMEDIATE ERROR RETRY: Check for errored/rejected/timed-out downloads first (no timeout needed)
|
||||
if 'Errored' in state_str or 'Failed' in state_str or 'Rejected' in state_str or 'TimedOut' in state_str:
|
||||
retry_count = task.get('error_retry_count', 0)
|
||||
last_retry = task.get('last_error_retry_time', 0)
|
||||
|
||||
# Don't retry too frequently (wait at least 5 seconds between error retries)
|
||||
if retry_count < 3 and (current_time - last_retry) > 5: # Max 3 error retry attempts
|
||||
logger.error(f"Task errored (state: {state_str}) - immediate retry {retry_count + 1}/3")
|
||||
task['error_retry_count'] = retry_count + 1
|
||||
task['last_error_retry_time'] = current_time
|
||||
|
||||
_ti = task.get('track_info') if isinstance(task.get('track_info'), dict) else {}
|
||||
username = task.get('username') or _ti.get('username')
|
||||
filename = task.get('filename') or _ti.get('filename')
|
||||
download_id = task.get('download_id')
|
||||
|
||||
# Defer slskd cancel to outside the lock
|
||||
if username and download_id:
|
||||
deferred_ops.append(('cancel_download', download_id, username))
|
||||
|
||||
# Mark current source as used to prevent retry loops
|
||||
# CRITICAL: Use full filename (not basename) to match worker's source_key format
|
||||
if username and filename:
|
||||
used_sources = task.get('used_sources', set())
|
||||
source_key = f"{username}_{filename}"
|
||||
used_sources.add(source_key)
|
||||
task['used_sources'] = used_sources
|
||||
logger.error(f"Marked errored source as used: {source_key}")
|
||||
|
||||
# Defer orphan cleanup to outside the lock (needs matched_context_lock)
|
||||
if username and filename:
|
||||
old_context_key = _make_context_key(username, filename)
|
||||
_orphaned_download_keys.add(old_context_key)
|
||||
deferred_ops.append(('cleanup_orphan', old_context_key))
|
||||
|
||||
# Clear download info since we cancelled it
|
||||
task.pop('download_id', None)
|
||||
task.pop('username', None)
|
||||
task.pop('filename', None)
|
||||
|
||||
# Reset task state for immediate retry
|
||||
task['status'] = 'searching'
|
||||
task.pop('queued_start_time', None)
|
||||
task.pop('downloading_start_time', None)
|
||||
task['status_change_time'] = current_time
|
||||
logger.error(f"Task {task.get('track_info', {}).get('name', 'Unknown')} reset for error retry")
|
||||
|
||||
# Defer worker restart to outside the lock
|
||||
batch_id = task.get('batch_id')
|
||||
if task_id and batch_id:
|
||||
deferred_ops.append(('restart_worker', task_id, batch_id))
|
||||
return False
|
||||
elif retry_count < 3:
|
||||
# Wait a bit before next error retry
|
||||
return False
|
||||
else:
|
||||
# Too many error retries, mark as failed
|
||||
track_label = task.get('track_info', {}).get('name', 'Unknown')
|
||||
tried_sources = task.get('used_sources', set())
|
||||
sources_str = f' (tried {len(tried_sources)} source{"s" if len(tried_sources) != 1 else ""})' if tried_sources else ''
|
||||
logger.error("Task failed after 3 error retry attempts")
|
||||
task['status'] = 'failed'
|
||||
# Tidal-specific error: check if this was a quality issue.
|
||||
# task['username'] is popped on error-retry (line ~2866) so we can't rely on it;
|
||||
# used_sources keys are formatted as "{username}_{filename}", so startswith is exact.
|
||||
is_tidal = any(s.startswith('tidal_') for s in tried_sources)
|
||||
if is_tidal:
|
||||
tidal_quality = config_manager.get('tidal_download.quality', 'lossless')
|
||||
allow_fb = config_manager.get('tidal_download.allow_fallback', True)
|
||||
if tidal_quality == 'hires' and not allow_fb:
|
||||
task['error_message'] = (
|
||||
f'Tidal download failed for "{track_label}" — HiRes quality is unavailable for this track '
|
||||
f'on your account or in your region. Enable "Quality Fallback" in Tidal settings to fall back to Lossless.'
|
||||
)
|
||||
else:
|
||||
task['error_message'] = (
|
||||
f'Tidal download failed for "{track_label}"{sources_str} — '
|
||||
f'check Tidal authentication and quality settings.'
|
||||
)
|
||||
else:
|
||||
task['error_message'] = f'Soulseek transfer errored 3 times for "{track_label}"{sources_str} — all sources failed or became unavailable'
|
||||
|
||||
# CRITICAL: Notify batch manager so track is added to permanently_failed_tracks
|
||||
batch_id = task.get('batch_id')
|
||||
if batch_id:
|
||||
logger.error(f"[Retry Exhausted] Notifying batch manager of permanent failure for task {task_id}")
|
||||
return True # Signal that we need to call completion outside the lock
|
||||
return False
|
||||
|
||||
# Check for queued timeout (90 seconds like GUI)
|
||||
elif 'Queued' in state_str or task['status'] == 'queued':
|
||||
if 'queued_start_time' not in task:
|
||||
task['queued_start_time'] = current_time
|
||||
return False
|
||||
else:
|
||||
queue_time = current_time - task['queued_start_time']
|
||||
|
||||
# Use context-aware timeouts like GUI:
|
||||
# - 15 seconds for artist album downloads (streaming context)
|
||||
# - 90 seconds for background playlist downloads
|
||||
is_streaming_context = task.get('track_info', {}).get('is_album_download', False)
|
||||
timeout_threshold = 15.0 if is_streaming_context else 90.0
|
||||
|
||||
if queue_time > timeout_threshold:
|
||||
# Track retry attempts to prevent rapid loops
|
||||
retry_count = task.get('stuck_retry_count', 0)
|
||||
last_retry = task.get('last_retry_time', 0)
|
||||
|
||||
# Don't retry too frequently (wait at least 30 seconds between retries)
|
||||
if retry_count < 3 and (current_time - last_retry) > 30: # Max 3 retry attempts
|
||||
logger.warning(f"Task stuck in queue for {queue_time:.1f}s - immediate retry {retry_count + 1}/3")
|
||||
task['stuck_retry_count'] = retry_count + 1
|
||||
task['last_retry_time'] = current_time
|
||||
|
||||
_ti = task.get('track_info') if isinstance(task.get('track_info'), dict) else {}
|
||||
username = task.get('username') or _ti.get('username')
|
||||
filename = task.get('filename') or _ti.get('filename')
|
||||
download_id = task.get('download_id')
|
||||
|
||||
# Defer slskd cancel to outside the lock
|
||||
if username and download_id:
|
||||
deferred_ops.append(('cancel_download', download_id, username))
|
||||
|
||||
# UNIFIED RETRY LOGIC: Handle timeout retry exactly like error retry
|
||||
# Mark current source as used to prevent retry loops
|
||||
# CRITICAL: Use full filename (not basename) to match worker's source_key format
|
||||
if username and filename:
|
||||
used_sources = task.get('used_sources', set())
|
||||
source_key = f"{username}_{filename}"
|
||||
used_sources.add(source_key)
|
||||
task['used_sources'] = used_sources
|
||||
logger.error(f"Marked timeout source as used: {source_key}")
|
||||
|
||||
# Defer orphan cleanup to outside the lock (needs matched_context_lock)
|
||||
if username and filename:
|
||||
old_context_key = _make_context_key(username, filename)
|
||||
_orphaned_download_keys.add(old_context_key)
|
||||
deferred_ops.append(('cleanup_orphan', old_context_key))
|
||||
|
||||
# Clear download info since we cancelled it
|
||||
task.pop('download_id', None)
|
||||
task.pop('username', None)
|
||||
task.pop('filename', None)
|
||||
|
||||
# Reset task state for immediate retry (like error retry)
|
||||
task['status'] = 'searching'
|
||||
task.pop('queued_start_time', None)
|
||||
task.pop('downloading_start_time', None)
|
||||
task['status_change_time'] = current_time
|
||||
logger.error(f"Task {task.get('track_info', {}).get('name', 'Unknown')} reset for timeout retry")
|
||||
|
||||
# Defer worker restart to outside the lock
|
||||
batch_id = task.get('batch_id')
|
||||
if task_id and batch_id:
|
||||
deferred_ops.append(('restart_worker', task_id, batch_id))
|
||||
return False
|
||||
elif retry_count < 3:
|
||||
# Wait longer before next retry
|
||||
return False
|
||||
else:
|
||||
# Too many retries, mark as failed
|
||||
track_label = task.get('track_info', {}).get('name', 'Unknown')
|
||||
tried_sources = task.get('used_sources', set())
|
||||
sources_str = f' (tried {len(tried_sources)} source{"s" if len(tried_sources) != 1 else ""})' if tried_sources else ''
|
||||
logger.error("Task failed after 3 retry attempts (queue timeout)")
|
||||
task['status'] = 'failed'
|
||||
task['error_message'] = f'Download stayed queued too long 3 times for "{track_label}"{sources_str} — peers may be offline or have full queues'
|
||||
# Clear timers to prevent further retry loops
|
||||
task.pop('queued_start_time', None)
|
||||
task.pop('downloading_start_time', None)
|
||||
|
||||
# CRITICAL: Notify batch manager so track is added to permanently_failed_tracks
|
||||
batch_id = task.get('batch_id')
|
||||
if batch_id:
|
||||
logger.error(f"[Retry Exhausted] Notifying batch manager of permanent failure for task {task_id}")
|
||||
return True # Signal that we need to call completion outside the lock
|
||||
return False
|
||||
|
||||
# Check for downloading at 0% timeout (90 seconds like GUI)
|
||||
elif 'InProgress' in state_str and progress < 1:
|
||||
if 'downloading_start_time' not in task:
|
||||
task['downloading_start_time'] = current_time
|
||||
return False
|
||||
else:
|
||||
download_time = current_time - task['downloading_start_time']
|
||||
|
||||
# Use context-aware timeouts like GUI:
|
||||
# - 15 seconds for artist album downloads (streaming context)
|
||||
# - 90 seconds for background playlist downloads
|
||||
is_streaming_context = task.get('track_info', {}).get('is_album_download', False)
|
||||
timeout_threshold = 15.0 if is_streaming_context else 90.0
|
||||
|
||||
if download_time > timeout_threshold:
|
||||
retry_count = task.get('stuck_retry_count', 0)
|
||||
last_retry = task.get('last_retry_time', 0)
|
||||
|
||||
# Don't retry too frequently (wait at least 30 seconds between retries)
|
||||
if retry_count < 3 and (current_time - last_retry) > 30: # Max 3 retry attempts
|
||||
logger.warning(f"Task stuck at 0% for {download_time:.1f}s - immediate retry {retry_count + 1}/3")
|
||||
task['stuck_retry_count'] = retry_count + 1
|
||||
task['last_retry_time'] = current_time
|
||||
|
||||
_ti = task.get('track_info') if isinstance(task.get('track_info'), dict) else {}
|
||||
username = task.get('username') or _ti.get('username')
|
||||
filename = task.get('filename') or _ti.get('filename')
|
||||
download_id = task.get('download_id')
|
||||
|
||||
# Defer slskd cancel to outside the lock
|
||||
if username and download_id:
|
||||
deferred_ops.append(('cancel_download', download_id, username))
|
||||
|
||||
# UNIFIED RETRY LOGIC: Handle 0% timeout retry exactly like error retry
|
||||
# Mark current source as used to prevent retry loops
|
||||
# CRITICAL: Use full filename (not basename) to match worker's source_key format
|
||||
if username and filename:
|
||||
used_sources = task.get('used_sources', set())
|
||||
source_key = f"{username}_{filename}"
|
||||
used_sources.add(source_key)
|
||||
task['used_sources'] = used_sources
|
||||
logger.info(f"Marked 0% progress source as used: {source_key}")
|
||||
|
||||
# Defer orphan cleanup to outside the lock (needs matched_context_lock)
|
||||
if username and filename:
|
||||
old_context_key = _make_context_key(username, filename)
|
||||
_orphaned_download_keys.add(old_context_key)
|
||||
deferred_ops.append(('cleanup_orphan', old_context_key))
|
||||
|
||||
# Clear download info since we cancelled it
|
||||
task.pop('download_id', None)
|
||||
task.pop('username', None)
|
||||
task.pop('filename', None)
|
||||
|
||||
# Reset task state for immediate retry (like error retry)
|
||||
task['status'] = 'searching'
|
||||
task.pop('queued_start_time', None)
|
||||
task.pop('downloading_start_time', None)
|
||||
task['status_change_time'] = current_time
|
||||
logger.warning(f"Task {task.get('track_info', {}).get('name', 'Unknown')} reset for 0% retry")
|
||||
|
||||
# Defer worker restart to outside the lock
|
||||
batch_id = task.get('batch_id')
|
||||
if task_id and batch_id:
|
||||
deferred_ops.append(('restart_worker', task_id, batch_id))
|
||||
return False
|
||||
elif retry_count < 3:
|
||||
# Wait longer before next retry
|
||||
return False
|
||||
else:
|
||||
track_label = task.get('track_info', {}).get('name', 'Unknown')
|
||||
tried_sources = task.get('used_sources', set())
|
||||
sources_str = f' (tried {len(tried_sources)} source{"s" if len(tried_sources) != 1 else ""})' if tried_sources else ''
|
||||
logger.error("Task failed after 3 retry attempts (0% progress timeout)")
|
||||
task['status'] = 'failed'
|
||||
task['error_message'] = f'Download stuck at 0% three times for "{track_label}"{sources_str} — peers may have connection issues'
|
||||
# Clear timers to prevent further retry loops
|
||||
task.pop('queued_start_time', None)
|
||||
task.pop('downloading_start_time', None)
|
||||
|
||||
# CRITICAL: Notify batch manager so track is added to permanently_failed_tracks
|
||||
batch_id = task.get('batch_id')
|
||||
if batch_id:
|
||||
logger.error(f"[Retry Exhausted] Notifying batch manager of permanent failure for task {task_id}")
|
||||
return True # Signal that we need to call completion outside the lock
|
||||
return False
|
||||
else:
|
||||
# Only reset timers if actual byte progress is being made
|
||||
bytes_transferred = live_info.get('bytesTransferred', 0)
|
||||
if progress >= 1 or bytes_transferred > 0:
|
||||
# Real progress happening, reset timers and retry counts
|
||||
task.pop('queued_start_time', None)
|
||||
task.pop('downloading_start_time', None)
|
||||
task.pop('stuck_retry_count', None)
|
||||
else:
|
||||
# Unknown state with no progress (e.g., "Requested", "Initializing")
|
||||
# Treat like 0% stuck — start/keep the downloading timer running
|
||||
if 'downloading_start_time' not in task:
|
||||
task['downloading_start_time'] = current_time
|
||||
download_time = current_time - task['downloading_start_time']
|
||||
|
||||
# Use context-aware timeouts
|
||||
is_streaming_context = task.get('track_info', {}).get('is_album_download', False)
|
||||
timeout_threshold = 15.0 if is_streaming_context else 90.0
|
||||
|
||||
if download_time > timeout_threshold:
|
||||
retry_count = task.get('stuck_retry_count', 0)
|
||||
last_retry = task.get('last_retry_time', 0)
|
||||
|
||||
if retry_count < 3 and (current_time - last_retry) > 30:
|
||||
logger.warning(f"Task stuck in unknown state '{state_str}' with 0 progress for {download_time:.1f}s - retry {retry_count + 1}/3")
|
||||
task['stuck_retry_count'] = retry_count + 1
|
||||
task['last_retry_time'] = current_time
|
||||
|
||||
_ti = task.get('track_info') if isinstance(task.get('track_info'), dict) else {}
|
||||
username = task.get('username') or _ti.get('username')
|
||||
filename = task.get('filename') or _ti.get('filename')
|
||||
download_id = task.get('download_id')
|
||||
|
||||
if username and download_id:
|
||||
deferred_ops.append(('cancel_download', download_id, username))
|
||||
|
||||
if username and filename:
|
||||
used_sources = task.get('used_sources', set())
|
||||
source_key = f"{username}_{filename}"
|
||||
used_sources.add(source_key)
|
||||
task['used_sources'] = used_sources
|
||||
logger.info(f"Marked unknown-state source as used: {source_key}")
|
||||
|
||||
if username and filename:
|
||||
old_context_key = _make_context_key(username, filename)
|
||||
_orphaned_download_keys.add(old_context_key)
|
||||
deferred_ops.append(('cleanup_orphan', old_context_key))
|
||||
|
||||
task.pop('download_id', None)
|
||||
task.pop('username', None)
|
||||
task.pop('filename', None)
|
||||
task['status'] = 'searching'
|
||||
task.pop('queued_start_time', None)
|
||||
task.pop('downloading_start_time', None)
|
||||
task['status_change_time'] = current_time
|
||||
|
||||
batch_id = task.get('batch_id')
|
||||
if task_id and batch_id:
|
||||
deferred_ops.append(('restart_worker', task_id, batch_id))
|
||||
return False
|
||||
elif retry_count >= 3:
|
||||
track_label = task.get('track_info', {}).get('name', 'Unknown')
|
||||
tried_sources = task.get('used_sources', set())
|
||||
sources_str = f' (tried {len(tried_sources)} source{"s" if len(tried_sources) != 1 else ""})' if tried_sources else ''
|
||||
logger.error(f"Task failed after 3 retry attempts (unknown state '{state_str}')")
|
||||
task['status'] = 'failed'
|
||||
task['error_message'] = f'Download stuck in "{state_str}" state 3 times for "{track_label}"{sources_str}'
|
||||
task.pop('queued_start_time', None)
|
||||
task.pop('downloading_start_time', None)
|
||||
|
||||
batch_id = task.get('batch_id')
|
||||
if batch_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _validate_worker_counts(self):
|
||||
"""
|
||||
Validate worker counts to detect and fix ghost workers or orphaned tasks.
|
||||
This prevents the modal from showing wrong worker counts permanently.
|
||||
"""
|
||||
try:
|
||||
batches_needing_workers = []
|
||||
|
||||
with tasks_lock:
|
||||
for batch_id in list(self.monitored_batches):
|
||||
if batch_id not in download_batches:
|
||||
continue
|
||||
|
||||
batch = download_batches[batch_id]
|
||||
reported_active = batch['active_count']
|
||||
max_concurrent = batch['max_concurrent']
|
||||
queue = batch.get('queue', [])
|
||||
queue_index = batch.get('queue_index', 0)
|
||||
|
||||
# Count actually active tasks based on status
|
||||
actually_active = 0
|
||||
orphaned_tasks = []
|
||||
# Tasks already processed by _on_download_completed should NOT be counted
|
||||
# as active, even if their status hasn't been updated yet (race condition
|
||||
# between stream processor calling _on_download_completed and
|
||||
# _run_post_processing_worker setting status to 'completed')
|
||||
completed_task_ids = batch.get('_completed_task_ids', set())
|
||||
|
||||
for task_id in queue:
|
||||
if task_id in download_tasks:
|
||||
task_status = download_tasks[task_id]['status']
|
||||
if task_status in ['searching', 'downloading', 'queued', 'post_processing']:
|
||||
if task_id not in completed_task_ids:
|
||||
actually_active += 1
|
||||
elif task_status in ['failed', 'completed', 'cancelled', 'not_found'] and task_id in queue[queue_index:]:
|
||||
# These are orphaned tasks - they're done but still in active queue
|
||||
orphaned_tasks.append(task_id)
|
||||
|
||||
# Check for discrepancies
|
||||
if reported_active != actually_active or orphaned_tasks:
|
||||
logger.warning(f"[Worker Validation] Batch {batch_id}: reported={reported_active}, actual={actually_active}, orphaned={len(orphaned_tasks)}")
|
||||
|
||||
if orphaned_tasks:
|
||||
logger.warning(f"[Worker Validation] Found {len(orphaned_tasks)} orphaned tasks to cleanup")
|
||||
|
||||
# Fix the active count if it's wrong
|
||||
if reported_active != actually_active:
|
||||
old_count = batch['active_count']
|
||||
batch['active_count'] = actually_active
|
||||
logger.info(f"[Worker Validation] Fixed active count: {old_count} → {actually_active}")
|
||||
|
||||
# Defer starting workers to outside the lock
|
||||
if actually_active < max_concurrent and queue_index < len(queue):
|
||||
batches_needing_workers.append(batch_id)
|
||||
|
||||
# Start replacement workers outside the lock
|
||||
for batch_id in batches_needing_workers:
|
||||
try:
|
||||
logger.info(f"[Worker Validation] Starting replacement workers for {batch_id}")
|
||||
_start_next_batch_of_downloads(batch_id)
|
||||
except Exception as e:
|
||||
logger.error(f"[Worker Validation] Error starting workers for {batch_id}: {e}")
|
||||
|
||||
except Exception as validation_error:
|
||||
logger.error(f"Error in worker count validation: {validation_error}")
|
||||
481
core/downloads/post_processing.py
Normal file
481
core/downloads/post_processing.py
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
"""Post-processing worker for completed downloads.
|
||||
|
||||
The verification workflow that runs AFTER a slskd transfer reports as
|
||||
'Succeeded' but BEFORE the task is marked completed in the UI. Locates
|
||||
the file on disk (with retries + multiple search strategies), routes
|
||||
it through metadata enhancement and the import pipeline, and finally
|
||||
calls the batch lifecycle completion callback.
|
||||
|
||||
Lifted verbatim from web_server.py's `_run_post_processing_worker`.
|
||||
The single function is intentionally kept as one ~400-line block to
|
||||
preserve byte-for-byte parity with the original — refactoring into
|
||||
smaller helpers gets its own follow-up PR.
|
||||
|
||||
Dependencies are passed in via `PostProcessDeps` since the function
|
||||
needs ~9 callbacks/refs and direct injection beats hidden imports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from core.imports.album_naming import resolve_album_group as _resolve_album_group
|
||||
from core.imports.context import (
|
||||
get_import_clean_album,
|
||||
get_import_clean_title,
|
||||
get_import_context_album,
|
||||
get_import_context_artist,
|
||||
get_import_original_search,
|
||||
normalize_import_context,
|
||||
)
|
||||
from core.imports.filename import extract_track_number_from_filename
|
||||
from core.metadata import enrichment as metadata_enrichment
|
||||
from core.runtime_state import (
|
||||
download_tasks,
|
||||
matched_context_lock,
|
||||
matched_downloads_context,
|
||||
tasks_lock,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PostProcessDeps:
|
||||
"""Bundle of dependencies the post-processing worker needs.
|
||||
|
||||
Constructed per-call by the route layer so client / config refs are
|
||||
always live (no caching of pre-init Spotify clients etc).
|
||||
"""
|
||||
config_manager: Any
|
||||
soulseek_client: Any
|
||||
run_async: Callable
|
||||
docker_resolve_path: Callable[[str], str]
|
||||
extract_filename: Callable[[str], str]
|
||||
make_context_key: Callable[[str, str], str]
|
||||
find_completed_file: Callable
|
||||
enhance_file_metadata: Callable
|
||||
wipe_source_tags: Callable[[str], bool]
|
||||
post_process_with_verification: Callable
|
||||
mark_task_completed: Callable[[str, Optional[dict]], None]
|
||||
on_download_completed: Callable[[str, str, bool], None]
|
||||
|
||||
|
||||
def run_post_processing_worker(task_id: str, batch_id: str, deps: PostProcessDeps) -> None:
|
||||
"""NEW VERIFICATION WORKFLOW: Post-processing worker that only sets 'completed'
|
||||
status after successful file verification and processing. This matches sync.py's
|
||||
reliability.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"[Post-Processing] Starting verification for task {task_id}")
|
||||
|
||||
# Retrieve task details from global state
|
||||
with tasks_lock:
|
||||
if task_id not in download_tasks:
|
||||
logger.warning(f"[Post-Processing] Task {task_id} not found in download_tasks")
|
||||
return
|
||||
task = download_tasks[task_id].copy()
|
||||
|
||||
# Check if task was cancelled or already completed during post-processing
|
||||
if task['status'] == 'cancelled':
|
||||
logger.warning(f"[Post-Processing] Task {task_id} was cancelled, skipping verification")
|
||||
return
|
||||
if task['status'] == 'completed' or task.get('stream_processed'):
|
||||
logger.info(f"[Post-Processing] Task {task_id} already completed by stream processor, skipping verification")
|
||||
return
|
||||
|
||||
# Extract file information for verification
|
||||
track_info = task.get('track_info', {})
|
||||
task_filename = task.get('filename') or track_info.get('filename')
|
||||
task_username = task.get('username') or track_info.get('username')
|
||||
|
||||
if not task_filename or not task_username:
|
||||
logger.warning(f"[Post-Processing] Missing filename or username for task {task_id}")
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
download_tasks[task_id]['error_message'] = 'Post-processing failed: missing file or source information from Soulseek transfer'
|
||||
deps.on_download_completed(batch_id, task_id, False)
|
||||
return
|
||||
|
||||
download_dir = deps.docker_resolve_path(deps.config_manager.get('soulseek.download_path', './downloads'))
|
||||
transfer_dir = deps.docker_resolve_path(deps.config_manager.get('soulseek.transfer_path', './Transfer'))
|
||||
|
||||
# Try to get context for generating the correct final filename
|
||||
task_basename = deps.extract_filename(task_filename)
|
||||
context_key = deps.make_context_key(task_username, task_filename)
|
||||
expected_final_filename = None
|
||||
|
||||
logger.info(f"[Post-Processing] Looking up context with key: {context_key}")
|
||||
|
||||
with matched_context_lock:
|
||||
context = matched_downloads_context.get(context_key)
|
||||
# Debug: Show all available context keys
|
||||
available_keys = list(matched_downloads_context.keys())
|
||||
logger.info(f"[Post-Processing] Available context keys: {available_keys[:10]}...") # Show first 10 keys
|
||||
|
||||
if context:
|
||||
logger.info(f"[Post-Processing] Found context for key: {context_key}")
|
||||
try:
|
||||
original_search = context.get("original_search_result", {})
|
||||
logger.info(f"[Post-Processing] original_search keys: {list(original_search.keys())}")
|
||||
|
||||
clean_title = get_import_clean_title(context, default=original_search.get('title', ''))
|
||||
track_number = original_search.get('track_number')
|
||||
|
||||
logger.info(f"[Post-Processing] clean_title: '{clean_title}', track_number: {track_number}")
|
||||
|
||||
if clean_title and track_number:
|
||||
# Generate expected final filename that stream processor would create
|
||||
# Pattern: f"{track_number:02d} - {clean_title}.flac"
|
||||
sanitized_title = clean_title.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_')
|
||||
expected_final_filename = f"{track_number:02d} - {sanitized_title}.flac"
|
||||
logger.info(f"[Post-Processing] Generated expected final filename: {expected_final_filename}")
|
||||
else:
|
||||
logger.warning(f"[Post-Processing] Missing required data - clean_title: {bool(clean_title)}, track_number: {bool(track_number)}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Post-Processing] Error generating expected filename: {e}")
|
||||
traceback.print_exc()
|
||||
else:
|
||||
logger.warning(f"[Post-Processing] No context found for key: {context_key}")
|
||||
# Try fuzzy matching with similar keys containing the filename
|
||||
# SAFETY: Constrain to same Soulseek username to prevent cross-album
|
||||
# metadata contamination during mass downloads (e.g., two albums both
|
||||
# having "01 - Intro.flac" would match the wrong context without this)
|
||||
with matched_context_lock:
|
||||
similar_keys = [k for k in matched_downloads_context.keys()
|
||||
if k.startswith(f"{task_username}::") and task_basename in k]
|
||||
if similar_keys:
|
||||
# Use the first similar key found
|
||||
fuzzy_key = similar_keys[0]
|
||||
context = matched_downloads_context.get(fuzzy_key)
|
||||
logger.info(f"[Post-Processing] Found context using fuzzy key matching: {fuzzy_key}")
|
||||
|
||||
# Generate expected final filename using the found context
|
||||
try:
|
||||
original_search = context.get("original_search_result", {})
|
||||
logger.info(f"[Post-Processing] fuzzy context original_search keys: {list(original_search.keys())}")
|
||||
|
||||
clean_title = get_import_clean_title(context, default=original_search.get('title', ''))
|
||||
track_number = original_search.get('track_number')
|
||||
|
||||
logger.info(f"[Post-Processing] fuzzy context clean_title: '{clean_title}', track_number: {track_number}")
|
||||
|
||||
if clean_title and track_number:
|
||||
# Generate expected final filename that stream processor would create
|
||||
# Pattern: f"{track_number:02d} - {clean_title}.flac"
|
||||
sanitized_title = clean_title.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_')
|
||||
expected_final_filename = f"{track_number:02d} - {sanitized_title}.flac"
|
||||
logger.info(f"[Post-Processing] Generated expected final filename from fuzzy match: {expected_final_filename}")
|
||||
else:
|
||||
logger.warning(f"[Post-Processing] Missing required data from fuzzy match - clean_title: {bool(clean_title)}, track_number: {bool(track_number)}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Post-Processing] Error generating expected filename from fuzzy match: {e}")
|
||||
traceback.print_exc()
|
||||
else:
|
||||
logger.warning(f"[Post-Processing] No similar keys found containing '{task_basename}'")
|
||||
# Show a sample of what keys actually exist for debugging
|
||||
sample_keys = list(matched_downloads_context.keys())[:5]
|
||||
logger.info(f"[Post-Processing] Sample of existing keys: {sample_keys}")
|
||||
|
||||
# RESILIENT FILE-FINDING LOOP: Try up to 3 times with delays
|
||||
found_file = None
|
||||
file_location = None
|
||||
|
||||
# CRITICAL FIX: For YouTube downloads, the filename in task is 'id||title' (metadata),
|
||||
# but the actual file on disk is 'Title.mp3'. We must ask the client for the real path.
|
||||
if (task.get('username') == 'youtube' or '||' in str(task_filename)) and not found_file:
|
||||
logger.info(f"[Post-Processing] Detected YouTube download task: {task_id}")
|
||||
try:
|
||||
# Query the download orchestrator for the status which contains the real file path
|
||||
# CRITICAL FIX: Use the actual download_id designated by the client, not the internal task_id
|
||||
actual_download_id = task.get('download_id') or task_id
|
||||
status = deps.run_async(deps.soulseek_client.get_download_status(actual_download_id))
|
||||
if status and status.file_path:
|
||||
real_path = status.file_path
|
||||
if os.path.exists(real_path):
|
||||
# Determine if it's in download or transfer directory
|
||||
real_path_obj = Path(real_path)
|
||||
download_dir_obj = Path(download_dir)
|
||||
transfer_dir_obj = Path(transfer_dir)
|
||||
|
||||
# Use absolute path comparison
|
||||
try:
|
||||
if download_dir_obj.resolve() in real_path_obj.resolve().parents:
|
||||
file_location = 'download'
|
||||
elif transfer_dir_obj.resolve() in real_path_obj.resolve().parents:
|
||||
file_location = 'transfer'
|
||||
else:
|
||||
file_location = 'absolute'
|
||||
except: # noqa: E722 -- byte-faithful to original (catches even KeyboardInterrupt)
|
||||
# Fallback if resolve fails (e.g. permission or path issues)
|
||||
file_location = 'absolute'
|
||||
|
||||
if file_location:
|
||||
# We found the file! Use the absolute path if it confuses the joining logic,
|
||||
# but usually we want just the filename if location is 'download'/'transfer'
|
||||
# CRITICAL FIX: Always use the absolute real_path.
|
||||
# Stripping to basename causes FileNotFoundError because post-processing
|
||||
# runs with CWD as project root, not download dir.
|
||||
found_file = real_path
|
||||
|
||||
logger.info(f"[Post-Processing] Resolved actual YouTube filename: {found_file} (Location: {file_location})")
|
||||
else:
|
||||
logger.warning(f"[Post-Processing] YouTube status reported path but file missing: {real_path}")
|
||||
else:
|
||||
logger.warning(f"[Post-Processing] YouTube status returned no file_path for task {task_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Post-Processing] Failed to retrieve YouTube task status: {e}")
|
||||
|
||||
_file_search_max_retries = 5
|
||||
for retry_count in range(_file_search_max_retries):
|
||||
# If we already resolved the file (e.g. via YouTube status), skip searching
|
||||
if found_file:
|
||||
logger.info(f"[Post-Processing] Skipping search loop, file already resolved: {found_file}")
|
||||
break
|
||||
|
||||
# Check if stream processor already completed this task while we were waiting
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
if download_tasks[task_id].get('stream_processed') or download_tasks[task_id]['status'] == 'completed':
|
||||
logger.info(f"[Post-Processing] Task {task_id} was completed by stream processor during file search - done")
|
||||
return
|
||||
|
||||
logger.warning(f"[Post-Processing] Attempt {retry_count + 1}/{_file_search_max_retries} to find file")
|
||||
logger.info(f"[Post-Processing] Original filename: {task_basename}")
|
||||
if expected_final_filename:
|
||||
logger.info(f"[Post-Processing] Expected final filename: {expected_final_filename}")
|
||||
else:
|
||||
logger.warning("[Post-Processing] No expected final filename available")
|
||||
|
||||
# Strategy 1: Try with original filename in both downloads and transfer
|
||||
logger.info("[Post-Processing] Strategy 1: Searching with original filename...")
|
||||
found_file, file_location = deps.find_completed_file(download_dir, task_filename, transfer_dir)
|
||||
|
||||
if found_file:
|
||||
logger.info(f"[Post-Processing] Strategy 1 SUCCESS: Found file with original filename in {file_location}: {found_file}")
|
||||
else:
|
||||
logger.error("[Post-Processing] Strategy 1 FAILED: Original filename not found in either location")
|
||||
|
||||
# Strategy 2: If not found and we have an expected final filename, try that in transfer folder
|
||||
if not found_file and expected_final_filename:
|
||||
logger.info("[Post-Processing] Strategy 2: Searching transfer folder with expected final filename...")
|
||||
found_result = deps.find_completed_file(transfer_dir, expected_final_filename)
|
||||
if found_result and found_result[0]:
|
||||
found_file, file_location = found_result[0], 'transfer'
|
||||
logger.info(f"[Post-Processing] Strategy 2 SUCCESS: Found file with expected final filename: {found_file}")
|
||||
else:
|
||||
logger.error("[Post-Processing] Strategy 2 FAILED: Expected final filename not found in transfer folder")
|
||||
elif not expected_final_filename:
|
||||
logger.warning("[Post-Processing] Strategy 2 SKIPPED: No expected final filename available")
|
||||
|
||||
if found_file:
|
||||
logger.warning(f"[Post-Processing] FILE FOUND after {retry_count + 1} attempts in {file_location}: {found_file}")
|
||||
break
|
||||
else:
|
||||
logger.error(f"[Post-Processing] All search strategies failed on attempt {retry_count + 1}/{_file_search_max_retries}")
|
||||
if retry_count < _file_search_max_retries - 1: # Don't sleep on final attempt
|
||||
logger.info("[Post-Processing] Waiting 5 seconds before next attempt...")
|
||||
time.sleep(5)
|
||||
|
||||
if not found_file:
|
||||
# CRITICAL: Before marking as failed, check if stream processor already handled this
|
||||
# The /api/downloads/status polling endpoint processes files independently and may have
|
||||
# already moved/renamed/tagged the file successfully while we were searching
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
if download_tasks[task_id].get('stream_processed') or download_tasks[task_id]['status'] == 'completed':
|
||||
logger.error(f"[Post-Processing] Task {task_id} was completed by stream processor - not marking as failed")
|
||||
return
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
download_tasks[task_id]['error_message'] = f'File not found on disk after {_file_search_max_retries} search attempts. Expected: {os.path.basename(task_filename)}'
|
||||
deps.on_download_completed(batch_id, task_id, False)
|
||||
return
|
||||
|
||||
# Handle file found in transfer folder - already completed by stream processor
|
||||
if file_location == 'transfer':
|
||||
logger.info(f"[Post-Processing] File found in transfer folder - already completed by stream processor: {found_file}")
|
||||
|
||||
# Check if metadata enhancement was completed
|
||||
metadata_enhanced = False
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
metadata_enhanced = download_tasks[task_id].get('metadata_enhanced', False)
|
||||
|
||||
if not metadata_enhanced:
|
||||
logger.warning("[Post-Processing] File in transfer folder missing metadata enhancement - completing now")
|
||||
# Attempt to complete metadata enhancement using context
|
||||
if context and expected_final_filename:
|
||||
try:
|
||||
context = normalize_import_context(context)
|
||||
# Extract required data from context
|
||||
original_search = get_import_original_search(context)
|
||||
artist_context = get_import_context_artist(context)
|
||||
album_context = get_import_context_album(context)
|
||||
|
||||
if artist_context and album_context:
|
||||
# CRITICAL FIX: Create album_info dict with proper structure for metadata enhancement
|
||||
# This must match the format used in main stream processor to ensure consistency
|
||||
|
||||
# Extract track number from context (should be available from fuzzy match)
|
||||
track_number = original_search.get('track_number', 1)
|
||||
|
||||
# If no track number in context, extract from filename
|
||||
if track_number == 1 and found_file:
|
||||
track_number = extract_track_number_from_filename(found_file)
|
||||
logger.warning(
|
||||
"[Verification] missing track_number; extracted from filename=%r -> %s",
|
||||
os.path.basename(found_file),
|
||||
track_number,
|
||||
)
|
||||
|
||||
# Ensure track_number is valid
|
||||
if not isinstance(track_number, int) or track_number < 1:
|
||||
logger.error(f"[Verification] Invalid track number ({track_number}), defaulting to 1")
|
||||
track_number = 1
|
||||
|
||||
# Get clean track name
|
||||
clean_track_name = get_import_clean_title(context, default=original_search.get('title', 'Unknown Track'))
|
||||
album_name = get_import_clean_album(context, default=album_context.get('name', 'Unknown Album'))
|
||||
album_image_url = album_context.get('image_url')
|
||||
if not album_image_url and album_context.get('images'):
|
||||
album_images = album_context.get('images', [])
|
||||
if album_images and isinstance(album_images[0], dict):
|
||||
album_image_url = album_images[0].get('url')
|
||||
|
||||
album_info = {
|
||||
'is_album': True, # CRITICAL: Mark as album track
|
||||
'album_name': album_name,
|
||||
'track_number': track_number, # CORRECTED TRACK NUMBER
|
||||
'disc_number': original_search.get('disc_number', 1),
|
||||
'clean_track_name': clean_track_name,
|
||||
'album_image_url': album_image_url,
|
||||
'confidence': 0.9,
|
||||
'source': 'verification_worker_corrected',
|
||||
}
|
||||
|
||||
# Apply album grouping for consistency with stream processor path.
|
||||
# Only for singles/auto-detected — explicit album downloads already
|
||||
# have the correct Spotify name and re-grouping would mangle it.
|
||||
if not context.get("is_album_download", False):
|
||||
try:
|
||||
raw_album_ctx = original_search.get('album')
|
||||
if isinstance(raw_album_ctx, str):
|
||||
original_album_ctx = raw_album_ctx
|
||||
elif isinstance(raw_album_ctx, dict) and 'name' in raw_album_ctx:
|
||||
original_album_ctx = raw_album_ctx['name']
|
||||
else:
|
||||
original_album_ctx = None
|
||||
consistent_album_name = _resolve_album_group(artist_context, album_info, original_album_ctx)
|
||||
album_info['album_name'] = consistent_album_name
|
||||
except Exception as group_err:
|
||||
logger.error(f"[Verification] Album grouping failed, using raw name: {group_err}")
|
||||
else:
|
||||
logger.info(f"[Verification] Explicit album download - preserving album name: '{album_info['album_name']}'")
|
||||
|
||||
logger.info(f"[Verification] Created proper album_info - track_number: {track_number}, album: {album_info['album_name']}")
|
||||
|
||||
logger.info(f"[Post-Processing] Attempting metadata enhancement for: {found_file}")
|
||||
logger.warning(f"[Metadata Input] Verification worker - artist: '{artist_context.get('name', 'MISSING')}' (id: {artist_context.get('id', 'MISSING')})")
|
||||
logger.warning(f"[Metadata Input] Verification worker - album: '{album_info.get('album_name', 'MISSING')}', track#: {album_info.get('track_number', 'MISSING')}, source: {album_info.get('source', 'unknown')}")
|
||||
enhancement_success = deps.enhance_file_metadata(found_file, context, artist_context, album_info)
|
||||
|
||||
if enhancement_success:
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['metadata_enhanced'] = True
|
||||
logger.info(f"[Post-Processing] Successfully completed metadata enhancement for: {os.path.basename(found_file)}")
|
||||
else:
|
||||
logger.info(f"[Post-Processing] Metadata enhancement returned False for: {os.path.basename(found_file)}")
|
||||
else:
|
||||
logger.warning("[Post-Processing] Missing artist or album in context")
|
||||
logger.info(f"[Post-Processing] artist_context: {artist_context is not None}, album_context: {album_context is not None}")
|
||||
# Wipe source tags even without full enhancement — prevents
|
||||
# Soulseek uploader's MusicBrainz IDs from causing album splits
|
||||
if found_file and os.path.exists(found_file):
|
||||
deps.wipe_source_tags(found_file)
|
||||
except Exception as enhancement_error:
|
||||
logger.error(f"[Post-Processing] Error during metadata enhancement: {enhancement_error}\n{traceback.format_exc()}")
|
||||
if found_file and os.path.exists(found_file):
|
||||
deps.wipe_source_tags(found_file)
|
||||
else:
|
||||
logger.warning("[Post-Processing] Cannot complete metadata enhancement - missing context or expected filename")
|
||||
if found_file and os.path.exists(found_file):
|
||||
deps.wipe_source_tags(found_file)
|
||||
else:
|
||||
logger.info("[Post-Processing] File already has metadata enhancement completed")
|
||||
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
track_info = download_tasks[task_id].get('track_info')
|
||||
deps.mark_task_completed(task_id, track_info)
|
||||
|
||||
# Clean up context now that both stream processor and verification worker are done
|
||||
with matched_context_lock:
|
||||
if context_key in matched_downloads_context:
|
||||
del matched_downloads_context[context_key]
|
||||
logger.info(f"[Verification] Cleaned up context after successful verification: {context_key}")
|
||||
|
||||
deps.on_download_completed(batch_id, task_id, True)
|
||||
return
|
||||
|
||||
# File found in downloads folder - attempt post-processing
|
||||
try:
|
||||
# Rebuild the context key using the same function that stored it
|
||||
context_key = deps.make_context_key(task_username, task_filename)
|
||||
|
||||
# Check if this download has matched context for post-processing
|
||||
with matched_context_lock:
|
||||
context = matched_downloads_context.get(context_key)
|
||||
|
||||
if context:
|
||||
logger.info(f"[Post-Processing] Found matched context, running full post-processing for: {context_key}")
|
||||
# Run the existing post-processing logic with verification
|
||||
deps.post_process_with_verification(context_key, context, found_file, task_id, batch_id)
|
||||
else:
|
||||
# No matched context - just mark as completed since file exists
|
||||
logger.warning(f"[Post-Processing] No matched context, marking as completed: {os.path.basename(found_file)}")
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
track_info = download_tasks[task_id].get('track_info')
|
||||
deps.mark_task_completed(task_id, track_info)
|
||||
|
||||
# Clean up context if it exists (might be leftover from stream processor)
|
||||
with matched_context_lock:
|
||||
if context_key in matched_downloads_context:
|
||||
del matched_downloads_context[context_key]
|
||||
logger.info(f"[Verification] Cleaned up leftover context: {context_key}")
|
||||
|
||||
# Call completion callback since there's no other post-processing to handle it
|
||||
deps.on_download_completed(batch_id, task_id, True)
|
||||
|
||||
except Exception as processing_error:
|
||||
logger.error(f"[Post-Processing] Processing failed for task {task_id}: {processing_error}")
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
download_tasks[task_id]['error_message'] = f"Post-processing failed: {str(processing_error)}"
|
||||
deps.on_download_completed(batch_id, task_id, False)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Post-Processing] Critical error in post-processing worker for task {task_id}: {e}")
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
download_tasks[task_id]['error_message'] = f"Critical post-processing error: {str(e)}"
|
||||
deps.on_download_completed(batch_id, task_id, False)
|
||||
|
||||
|
||||
# Re-export the metadata helper so callers can wrap it in a callback without
|
||||
# importing from core.metadata directly.
|
||||
__all__ = [
|
||||
'PostProcessDeps',
|
||||
'run_post_processing_worker',
|
||||
'metadata_enrichment',
|
||||
]
|
||||
263
core/downloads/staging.py
Normal file
263
core/downloads/staging.py
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
"""Staging-folder match shortcut for downloads.
|
||||
|
||||
`try_staging_match(task_id, batch_id, track, deps)` is the per-track
|
||||
shortcut the task worker calls before kicking off a Soulseek search.
|
||||
If the user has dropped audio files matching the track into the
|
||||
configured staging folder, we copy directly to the transfer dir and
|
||||
hand off to post-processing — skipping the network round-trip entirely.
|
||||
|
||||
1. Pull the staging-file cache for the batch (one scan per batch).
|
||||
2. Compute title + artist similarity (SequenceMatcher) against each
|
||||
staging entry; require title >= 0.80 and combined score >= 0.75.
|
||||
Score weighting flips based on whether artist info is available on
|
||||
both sides:
|
||||
- both have artist: 0.55*title + 0.45*artist
|
||||
- either side missing artist: 0.80*title + 0.20*artist (lean on title)
|
||||
3. Copy the matched file to the transfer dir (suffix "_staging" if a
|
||||
file with that name already exists).
|
||||
4. Mark the task as 'post_processing' with username='staging'.
|
||||
5. Build a synthetic spotify_artist / spotify_album context (mirrors
|
||||
the modal-worker's logic so the path template applies cleanly) and
|
||||
store it in matched_downloads_context under "staging_<task_id>".
|
||||
6. Hand off to `_post_process_matched_download_with_verification` which
|
||||
does tagging, path building, AcoustID verification, and DB insertion.
|
||||
|
||||
Returns True if the staging shortcut won; False to fall through to the
|
||||
normal Soulseek search path.
|
||||
|
||||
Lifted verbatim from web_server.py. Wide dependency surface
|
||||
(matching_engine, post-processing helper, file-system helpers, staging
|
||||
cache, runtime state) all injected via `StagingDeps`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
# `shutil` and `SequenceMatcher` are imported inline inside try_staging_match()
|
||||
# to keep the lift byte-identical with the original web_server.py function body.
|
||||
|
||||
from core.runtime_state import (
|
||||
download_tasks,
|
||||
matched_context_lock,
|
||||
matched_downloads_context,
|
||||
tasks_lock,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StagingDeps:
|
||||
"""Bundle of cross-cutting deps the staging-match helper needs."""
|
||||
config_manager: Any
|
||||
matching_engine: Any
|
||||
get_staging_file_cache: Callable[[str], list]
|
||||
docker_resolve_path: Callable[[str], str]
|
||||
post_process_matched_download_with_verification: Callable
|
||||
|
||||
|
||||
def try_staging_match(task_id, batch_id, track, deps: StagingDeps):
|
||||
"""Check if a matching file exists in the staging folder before downloading.
|
||||
|
||||
Returns True if a match was found and the file was moved to the transfer folder.
|
||||
Returns False to fall through to normal download.
|
||||
"""
|
||||
staging_files = deps.get_staging_file_cache(batch_id or task_id)
|
||||
if not staging_files:
|
||||
return False
|
||||
|
||||
track_title = track.name or ''
|
||||
track_artist = track.artists[0] if track.artists else ''
|
||||
|
||||
if not track_title:
|
||||
return False
|
||||
|
||||
from difflib import SequenceMatcher
|
||||
normalize = deps.matching_engine.normalize_string
|
||||
norm_title = normalize(track_title)
|
||||
norm_artist = normalize(track_artist)
|
||||
|
||||
best_match = None
|
||||
best_score = 0.0
|
||||
|
||||
for sf in staging_files:
|
||||
sf_norm_title = normalize(sf['title'])
|
||||
sf_norm_artist = normalize(sf['artist'])
|
||||
|
||||
if not sf_norm_title:
|
||||
continue
|
||||
|
||||
# Title similarity (primary)
|
||||
title_sim = SequenceMatcher(None, norm_title, sf_norm_title).ratio()
|
||||
if title_sim < 0.80:
|
||||
continue
|
||||
|
||||
# Artist similarity (secondary)
|
||||
artist_sim = 0.0
|
||||
if norm_artist and sf_norm_artist:
|
||||
artist_sim = SequenceMatcher(None, norm_artist, sf_norm_artist).ratio()
|
||||
elif not norm_artist and not sf_norm_artist:
|
||||
artist_sim = 0.5 # Both unknown — neutral
|
||||
elif norm_artist and not sf_norm_artist:
|
||||
artist_sim = 0.3 # Staging file lacks artist — partial credit if title is strong
|
||||
elif sf_norm_artist and not norm_artist:
|
||||
artist_sim = 0.3 # Track lacks artist — same partial credit
|
||||
|
||||
# Combined score: title-weighted (these are user-curated staging files)
|
||||
# If artist info is available, require it to match. If not, lean on title.
|
||||
if norm_artist and sf_norm_artist:
|
||||
combined = (title_sim * 0.55) + (artist_sim * 0.45)
|
||||
else:
|
||||
combined = (title_sim * 0.80) + (artist_sim * 0.20)
|
||||
|
||||
if combined > best_score:
|
||||
best_score = combined
|
||||
best_match = sf
|
||||
|
||||
# Require high confidence to avoid false positives
|
||||
if not best_match or best_score < 0.75:
|
||||
return False
|
||||
|
||||
logger.info(f"[Staging] Match found for '{track_title}' by '{track_artist}': "
|
||||
f"{os.path.basename(best_match['full_path'])} (score: {best_score:.2f})")
|
||||
|
||||
# Copy the file to the transfer folder
|
||||
try:
|
||||
transfer_dir = deps.docker_resolve_path(deps.config_manager.get('soulseek.transfer_path', './Transfer'))
|
||||
dest_filename = os.path.basename(best_match['full_path'])
|
||||
dest_path = os.path.join(transfer_dir, dest_filename)
|
||||
os.makedirs(transfer_dir, exist_ok=True)
|
||||
|
||||
# Don't overwrite existing files
|
||||
if os.path.exists(dest_path):
|
||||
base, ext = os.path.splitext(dest_filename)
|
||||
dest_path = os.path.join(transfer_dir, f"{base}_staging{ext}")
|
||||
|
||||
import shutil
|
||||
shutil.copy2(best_match['full_path'], dest_path)
|
||||
logger.info(f"[Staging] Copied to transfer: {dest_path}")
|
||||
|
||||
# Mark task as completed with staging context
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'post_processing'
|
||||
download_tasks[task_id]['filename'] = dest_path
|
||||
download_tasks[task_id]['username'] = 'staging'
|
||||
download_tasks[task_id]['staging_match'] = True
|
||||
|
||||
# Run post-processing (tagging, AcoustID verification, path building)
|
||||
context_key = f"staging_{task_id}"
|
||||
with tasks_lock:
|
||||
track_info = download_tasks.get(task_id, {}).get('track_info', {})
|
||||
if not isinstance(track_info, dict):
|
||||
track_info = {}
|
||||
|
||||
# Build spotify_artist / spotify_album context so post-processing can apply
|
||||
# the path template. Without these, _post_process_matched_download returns
|
||||
# early and the file stays at the transfer root with its original filename.
|
||||
# Mirror the context-building logic from the sync modal worker.
|
||||
has_explicit_context = track_info.get('_is_explicit_album_download', False)
|
||||
|
||||
if has_explicit_context:
|
||||
explicit_artist = track_info.get('_explicit_artist_context', {})
|
||||
if isinstance(explicit_artist, str):
|
||||
explicit_artist = {'name': explicit_artist}
|
||||
elif not isinstance(explicit_artist, dict):
|
||||
explicit_artist = {}
|
||||
spotify_artist_ctx = {
|
||||
'id': explicit_artist.get('id', 'staging'),
|
||||
'name': explicit_artist.get('name', track_artist),
|
||||
'genres': explicit_artist.get('genres', [])
|
||||
}
|
||||
explicit_album = track_info.get('_explicit_album_context', {})
|
||||
if not isinstance(explicit_album, dict):
|
||||
explicit_album = {}
|
||||
_album_image_url = explicit_album.get('image_url')
|
||||
if not _album_image_url and explicit_album.get('images'):
|
||||
_imgs = explicit_album['images']
|
||||
if isinstance(_imgs, list) and _imgs:
|
||||
_album_image_url = _imgs[0].get('url') if isinstance(_imgs[0], dict) else None
|
||||
spotify_album_ctx = {
|
||||
'id': explicit_album.get('id', 'staging'),
|
||||
'name': explicit_album.get('name', getattr(track, 'album', '') or ''),
|
||||
'release_date': explicit_album.get('release_date', ''),
|
||||
'image_url': _album_image_url,
|
||||
'album_type': explicit_album.get('album_type', 'album'),
|
||||
'total_tracks': explicit_album.get('total_tracks', 0),
|
||||
'total_discs': explicit_album.get('total_discs', 1),
|
||||
'artists': explicit_album.get('artists', [{'name': spotify_artist_ctx.get('name', '')}])
|
||||
}
|
||||
is_album_ctx = True
|
||||
has_clean_data = True
|
||||
else:
|
||||
fallback_album = track_info.get('album', {})
|
||||
if isinstance(fallback_album, str):
|
||||
fallback_album = {'name': fallback_album}
|
||||
elif not isinstance(fallback_album, dict):
|
||||
fallback_album = {}
|
||||
track_album_name = getattr(track, 'album', '') or fallback_album.get('name', '') or ''
|
||||
spotify_artist_ctx = {
|
||||
'id': 'staging',
|
||||
'name': track_artist or 'Unknown',
|
||||
'genres': []
|
||||
}
|
||||
spotify_album_ctx = {
|
||||
'id': 'staging',
|
||||
'name': track_album_name,
|
||||
'release_date': fallback_album.get('release_date', ''),
|
||||
'image_url': fallback_album.get('image_url'),
|
||||
'album_type': fallback_album.get('album_type', 'album'),
|
||||
'total_tracks': fallback_album.get('total_tracks', 0),
|
||||
'total_discs': fallback_album.get('total_discs', 1),
|
||||
'artists': [{'name': track_artist}] if track_artist else []
|
||||
}
|
||||
is_album_ctx = bool(
|
||||
track_album_name and
|
||||
track_album_name.strip() and
|
||||
track_album_name.lower() not in ('unknown album', '') and
|
||||
track_album_name.lower() != track_title.lower()
|
||||
)
|
||||
has_clean_data = bool(track_title and track_artist and track_album_name)
|
||||
|
||||
track_number = (
|
||||
track_info.get('track_number', 0) or
|
||||
getattr(track, 'track_number', 0) or 0
|
||||
)
|
||||
disc_number = (
|
||||
track_info.get('disc_number', 1) or
|
||||
getattr(track, 'disc_number', 1) or 1
|
||||
)
|
||||
|
||||
context = {
|
||||
'track_info': track_info,
|
||||
'spotify_artist': spotify_artist_ctx,
|
||||
'spotify_album': spotify_album_ctx,
|
||||
'original_search_result': {
|
||||
'title': track_title,
|
||||
'artist': track_artist,
|
||||
'spotify_clean_title': track_title,
|
||||
'spotify_clean_album': spotify_album_ctx.get('name', ''),
|
||||
'spotify_clean_artist': track_artist,
|
||||
'track_number': track_number,
|
||||
'disc_number': disc_number,
|
||||
},
|
||||
'is_album_download': is_album_ctx,
|
||||
'has_clean_spotify_data': has_clean_data,
|
||||
'staging_source': True,
|
||||
}
|
||||
|
||||
# Store context in the matched downloads context store (used by post-processing)
|
||||
with matched_context_lock:
|
||||
matched_downloads_context[context_key] = context
|
||||
|
||||
# Trigger post-processing which handles tagging, path building, and DB insertion
|
||||
deps.post_process_matched_download_with_verification(context_key, context, dest_path, task_id, batch_id)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Staging] Failed to use staging file: {e}")
|
||||
return False
|
||||
410
core/downloads/status.py
Normal file
410
core/downloads/status.py
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
"""Batch + unified download status helpers.
|
||||
|
||||
`build_batch_status_data` is the per-batch payload formatter shared by:
|
||||
- /api/playlists/<batch_id>/download_status (single batch)
|
||||
- /api/download_status/batch (multiple batches in one call)
|
||||
|
||||
It's NOT pure read-only — it has a safety valve that mutates task state
|
||||
when slskd reports terminal-but-stuck downloads, and it submits the
|
||||
post-processing worker when slskd reports 'Succeeded' or when a stuck
|
||||
file is recovered. Those side effects are preserved exactly.
|
||||
|
||||
`build_unified_downloads_response` powers /api/downloads/all — flattens
|
||||
all tasks across batches into one sorted list with per-row metadata for
|
||||
the centralized Downloads page.
|
||||
|
||||
Lifted verbatim from web_server.py. Dependencies that touch the live
|
||||
runtime (config, file finder, post-processing submitter, transfer cache)
|
||||
are passed via `StatusDeps` so the module is web_server-import-free.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from core.runtime_state import (
|
||||
download_batches,
|
||||
download_tasks,
|
||||
tasks_lock,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StatusDeps:
|
||||
"""Cross-cutting deps the status helpers need."""
|
||||
config_manager: Any
|
||||
docker_resolve_path: Callable[[str], str]
|
||||
find_completed_file: Callable
|
||||
make_context_key: Callable[[str, str], str]
|
||||
submit_post_processing: Callable[[str, str], None] # (task_id, batch_id) -> None
|
||||
get_cached_transfer_data: Callable[[], dict]
|
||||
|
||||
|
||||
def build_batch_status_data(batch_id: str, batch: dict, live_transfers_lookup: dict, deps: StatusDeps) -> dict:
|
||||
"""Build status payload for a single batch.
|
||||
|
||||
Includes a safety-valve that mutates stuck task state and submits the
|
||||
post-processing worker when slskd reports 'Succeeded' or when a
|
||||
stuck-but-recovered file is found on disk.
|
||||
"""
|
||||
response_data = {
|
||||
"phase": batch.get('phase', 'unknown'),
|
||||
"error": batch.get('error'),
|
||||
"auto_initiated": batch.get('auto_initiated', False),
|
||||
"playlist_id": batch.get('playlist_id'), # Include playlist_id for rehydration
|
||||
"playlist_name": batch.get('playlist_name'), # Include playlist_name for reference
|
||||
}
|
||||
|
||||
if response_data["phase"] == 'analysis':
|
||||
response_data['analysis_progress'] = {
|
||||
'total': batch.get('analysis_total', 0),
|
||||
'processed': batch.get('analysis_processed', 0),
|
||||
}
|
||||
response_data['analysis_results'] = batch.get('analysis_results', [])
|
||||
|
||||
elif response_data["phase"] in ['downloading', 'complete', 'error']:
|
||||
response_data['analysis_results'] = batch.get('analysis_results', [])
|
||||
batch_tasks = []
|
||||
for task_id in batch.get('queue', []):
|
||||
task = download_tasks.get(task_id)
|
||||
if not task:
|
||||
continue
|
||||
|
||||
# SAFETY VALVE: Check for downloads stuck too long
|
||||
current_time = time.time()
|
||||
task_start_time = task.get('status_change_time', current_time)
|
||||
task_age = current_time - task_start_time
|
||||
|
||||
# If task has been running too long, check if file completed
|
||||
_dl_timeout = deps.config_manager.get('soulseek.download_timeout', 600) or 600
|
||||
if task_age > _dl_timeout and task['status'] in ['downloading', 'queued', 'searching']:
|
||||
stuck_state = task['status']
|
||||
task_filename = task.get('filename') or (task.get('track_info') or {}).get('filename')
|
||||
|
||||
# Before failing, check if the file actually downloaded successfully
|
||||
recovered = False
|
||||
if task_filename and stuck_state == 'downloading':
|
||||
try:
|
||||
download_dir = deps.docker_resolve_path(deps.config_manager.get('soulseek.download_path', './downloads'))
|
||||
transfer_dir = deps.docker_resolve_path(deps.config_manager.get('soulseek.transfer_path', './Transfer'))
|
||||
found_file, file_location = deps.find_completed_file(download_dir, task_filename, transfer_dir)
|
||||
if found_file:
|
||||
logger.info(f"[Safety Valve] Task {task_id} stuck but file found in {file_location} — routing to post-processing")
|
||||
task['status'] = 'post_processing'
|
||||
task['status_change_time'] = current_time
|
||||
deps.submit_post_processing(task_id, batch_id)
|
||||
recovered = True
|
||||
except Exception as e:
|
||||
logger.error(f"[Safety Valve] Error checking for completed file: {e}")
|
||||
|
||||
if not recovered:
|
||||
if stuck_state == 'searching':
|
||||
logger.info(f"⏰ [Safety Valve] Task {task_id} stuck in searching for {task_age:.1f}s - marking not_found")
|
||||
task['status'] = 'not_found'
|
||||
task['error_message'] = f'Search stuck for {int(task_age // 60)} minutes with no results — timed out'
|
||||
else:
|
||||
logger.error(f"⏰ [Safety Valve] Task {task_id} stuck for {task_age:.1f}s - forcing failure")
|
||||
task['status'] = 'failed'
|
||||
task['error_message'] = f'Task stuck in {stuck_state} state for {int(task_age // 60)} minutes — forcibly stopped'
|
||||
|
||||
task_status = {
|
||||
'task_id': task_id,
|
||||
'track_index': task['track_index'],
|
||||
'status': task['status'],
|
||||
'track_info': task['track_info'],
|
||||
'progress': 0,
|
||||
# V2 SYSTEM: Add persistent state information
|
||||
'cancel_requested': task.get('cancel_requested', False),
|
||||
'cancel_timestamp': task.get('cancel_timestamp'),
|
||||
'ui_state': task.get('ui_state', 'normal'), # normal|cancelling|cancelled
|
||||
'playlist_id': task.get('playlist_id'), # For V2 system identification
|
||||
'error_message': task.get('error_message'), # Surface failure reasons to UI
|
||||
'has_candidates': bool(task.get('cached_candidates')), # Whether search found results (for clickable review)
|
||||
}
|
||||
_ti = task.get('track_info') if isinstance(task.get('track_info'), dict) else {}
|
||||
task_filename = task.get('filename') or _ti.get('filename')
|
||||
task_username = task.get('username') or _ti.get('username')
|
||||
if task_filename and task_username:
|
||||
lookup_key = deps.make_context_key(task_username, task_filename)
|
||||
|
||||
if lookup_key in live_transfers_lookup:
|
||||
live_info = live_transfers_lookup[lookup_key]
|
||||
state_str = live_info.get('state', 'Unknown')
|
||||
|
||||
# Don't override tasks that are already in terminal states or post-processing
|
||||
if task['status'] not in ['completed', 'failed', 'cancelled', 'not_found', 'post_processing']:
|
||||
# SYNC.PY PARITY: Prioritized state checking (Errored/Cancelled before Completed)
|
||||
# This prevents "Completed, Errored" states from being marked as completed
|
||||
if 'Cancelled' in state_str or 'Canceled' in state_str:
|
||||
task_status['status'] = 'cancelled'
|
||||
task['status'] = 'cancelled'
|
||||
elif 'Failed' in state_str or 'Errored' in state_str or 'Rejected' in state_str or 'TimedOut' in state_str:
|
||||
# UNIFIED ERROR HANDLING: Let monitor handle errors for consistency
|
||||
# Monitor will detect errored state and trigger retry within 5 seconds
|
||||
logger.error(f"Task {task_id} API shows error state: {state_str} - letting monitor handle retry")
|
||||
|
||||
# Keep task in current status (downloading/queued) so monitor can detect error
|
||||
# Don't mark as failed here - let the unified retry system handle it
|
||||
if task['status'] in ['searching', 'downloading', 'queued']:
|
||||
task_status['status'] = task['status'] # Keep current status for monitor
|
||||
else:
|
||||
task_status['status'] = 'downloading' # Default to downloading for error detection
|
||||
task['status'] = 'downloading'
|
||||
elif 'Completed' in state_str or 'Succeeded' in state_str:
|
||||
# Verify bytes actually transferred before trusting state string
|
||||
expected_size = live_info.get('size', 0)
|
||||
transferred = live_info.get('bytesTransferred', 0)
|
||||
if expected_size > 0 and transferred < expected_size:
|
||||
# State says complete but bytes don't match — keep current status
|
||||
task_status['status'] = task['status']
|
||||
logger.info(f"Task {task_id} state says complete but bytes incomplete ({transferred}/{expected_size})")
|
||||
# NEW VERIFICATION WORKFLOW: Use intermediate post_processing status
|
||||
# Only set this status once to prevent multiple worker submissions
|
||||
elif task['status'] != 'post_processing':
|
||||
task_status['status'] = 'post_processing'
|
||||
task['status'] = 'post_processing'
|
||||
logger.info(f"Task {task_id} API reports 'Succeeded' - starting post-processing verification")
|
||||
|
||||
# Submit post-processing worker to verify file and complete the task
|
||||
deps.submit_post_processing(task_id, batch_id)
|
||||
else:
|
||||
# FIXED: Always require verification workflow - no bypass for stream processed tasks
|
||||
# Stream processing only handles metadata, not file verification
|
||||
task_status['status'] = 'post_processing'
|
||||
logger.info(f"Task {task_id} waiting for verification worker to complete")
|
||||
elif 'InProgress' in state_str:
|
||||
task_status['status'] = 'downloading'
|
||||
else:
|
||||
task_status['status'] = 'queued'
|
||||
task_status['progress'] = live_info.get('percentComplete', 0)
|
||||
# For completed/post-processing tasks, keep appropriate progress
|
||||
elif task['status'] == 'completed':
|
||||
task_status['progress'] = 100
|
||||
elif task['status'] == 'post_processing':
|
||||
task_status['progress'] = 95 # Nearly complete, just verifying
|
||||
else:
|
||||
# If task is completed but not in live transfers, keep appropriate status
|
||||
if task['status'] == 'completed':
|
||||
task_status['progress'] = 100
|
||||
elif task['status'] == 'post_processing':
|
||||
task_status['progress'] = 95 # Nearly complete, just verifying
|
||||
batch_tasks.append(task_status)
|
||||
batch_tasks.sort(key=lambda x: x['track_index'])
|
||||
response_data['tasks'] = batch_tasks
|
||||
|
||||
# CRITICAL: Add batch worker management metadata (was missing!)
|
||||
# This is essential for client-side worker validation and prevents false desync warnings
|
||||
response_data['active_count'] = batch.get('active_count', 0)
|
||||
response_data['max_concurrent'] = batch.get('max_concurrent', 3)
|
||||
|
||||
# Add wishlist summary if batch is complete (matching sync.py behavior)
|
||||
if response_data["phase"] == 'complete' and 'wishlist_summary' in batch:
|
||||
response_data['wishlist_summary'] = batch['wishlist_summary']
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route-shaped builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_single_batch_status(batch_id: str, deps: StatusDeps) -> tuple[Optional[dict], int]:
|
||||
"""For /api/playlists/<batch_id>/download_status. Returns (response, status)."""
|
||||
live_transfers_lookup = deps.get_cached_transfer_data()
|
||||
|
||||
with tasks_lock:
|
||||
if batch_id not in download_batches:
|
||||
return {"error": "Batch not found"}, 404
|
||||
|
||||
batch = download_batches[batch_id]
|
||||
return build_batch_status_data(batch_id, batch, live_transfers_lookup, deps), 200
|
||||
|
||||
|
||||
def build_batched_status(requested_batch_ids: list, deps: StatusDeps) -> dict:
|
||||
"""For /api/download_status/batch. Returns the full response dict (always 200)."""
|
||||
live_transfers_lookup = deps.get_cached_transfer_data()
|
||||
response: dict[str, Any] = {"batches": {}}
|
||||
|
||||
with tasks_lock:
|
||||
if requested_batch_ids:
|
||||
target_batches = {
|
||||
bid: batch for bid, batch in download_batches.items()
|
||||
if bid in requested_batch_ids
|
||||
}
|
||||
else:
|
||||
target_batches = download_batches.copy()
|
||||
|
||||
for batch_id, batch in target_batches.items():
|
||||
try:
|
||||
response["batches"][batch_id] = build_batch_status_data(
|
||||
batch_id, batch, live_transfers_lookup, deps,
|
||||
)
|
||||
except Exception as batch_error:
|
||||
logger.error(f"Error processing batch {batch_id}: {batch_error}")
|
||||
response["batches"][batch_id] = {"error": str(batch_error)}
|
||||
|
||||
response["metadata"] = {
|
||||
"total_batches": len(response["batches"]),
|
||||
"requested_batch_ids": requested_batch_ids,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
debug_info = {}
|
||||
for batch_id, batch_status in response["batches"].items():
|
||||
if "error" not in batch_status:
|
||||
active_count = batch_status.get("active_count", 0)
|
||||
max_concurrent = batch_status.get("max_concurrent", 3)
|
||||
task_count = len(batch_status.get("tasks", []))
|
||||
active_tasks = len([t for t in batch_status.get("tasks", []) if t.get("status") in ['searching', 'downloading', 'queued']])
|
||||
|
||||
debug_info[batch_id] = {
|
||||
"reported_active": active_count,
|
||||
"actual_active_tasks": active_tasks,
|
||||
"max_concurrent": max_concurrent,
|
||||
"total_tasks": task_count,
|
||||
"worker_discrepancy": active_count != active_tasks,
|
||||
}
|
||||
|
||||
response["debug_info"] = debug_info
|
||||
|
||||
logger.info(f"[Batched Status] Returning status for {len(response['batches'])} batches")
|
||||
|
||||
discrepancies = [bid for bid, info in debug_info.items() if info.get("worker_discrepancy")]
|
||||
if discrepancies:
|
||||
logger.info(f"[Batched Status] Worker count discrepancies in batches: {discrepancies}")
|
||||
|
||||
return response
|
||||
|
||||
|
||||
_STATUS_PRIORITY = {
|
||||
'downloading': 0, 'searching': 1, 'post_processing': 2,
|
||||
'queued': 3, 'pending': 3,
|
||||
'completed': 4, 'skipped': 5, 'already_owned': 5,
|
||||
'not_found': 6, 'failed': 7, 'cancelled': 8,
|
||||
}
|
||||
|
||||
|
||||
def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict:
|
||||
"""Flat list of every task across batches, sorted active-first then by recency.
|
||||
|
||||
Powers /api/downloads/all for the centralized Downloads page.
|
||||
"""
|
||||
items = []
|
||||
with tasks_lock:
|
||||
for task_id, task in download_tasks.items():
|
||||
track_info = task.get('track_info') or {}
|
||||
batch_id = task.get('batch_id', '')
|
||||
batch = download_batches.get(batch_id, {})
|
||||
|
||||
# Extract track metadata — handle all format variations
|
||||
title = ''
|
||||
artist = ''
|
||||
album = ''
|
||||
artwork = ''
|
||||
if isinstance(track_info, dict):
|
||||
title = track_info.get('title') or track_info.get('name') or track_info.get('track_name') or ''
|
||||
|
||||
# Artist can be: string, list of strings, list of dicts with 'name'
|
||||
raw_artist = track_info.get('artist') or track_info.get('artist_name') or track_info.get('artists') or ''
|
||||
if isinstance(raw_artist, list):
|
||||
parts = []
|
||||
for a in raw_artist:
|
||||
if isinstance(a, dict):
|
||||
parts.append(a.get('name', ''))
|
||||
else:
|
||||
parts.append(str(a))
|
||||
artist = ', '.join(p for p in parts if p)
|
||||
elif isinstance(raw_artist, dict):
|
||||
artist = raw_artist.get('name', '')
|
||||
else:
|
||||
artist = str(raw_artist) if raw_artist else ''
|
||||
|
||||
# Album can be: string or dict with 'name'
|
||||
raw_album = track_info.get('album') or track_info.get('album_name') or ''
|
||||
if isinstance(raw_album, dict):
|
||||
album = raw_album.get('name', '')
|
||||
else:
|
||||
album = str(raw_album) if raw_album else ''
|
||||
|
||||
artwork = track_info.get('artwork_url') or track_info.get('image_url') or track_info.get('album_art') or ''
|
||||
# Try album images
|
||||
if not artwork:
|
||||
raw_alb = track_info.get('album')
|
||||
if isinstance(raw_alb, dict):
|
||||
images = raw_alb.get('images') or []
|
||||
if images and isinstance(images, list) and len(images) > 0:
|
||||
artwork = images[0].get('url', '') if isinstance(images[0], dict) else str(images[0])
|
||||
|
||||
status = task.get('status', 'queued')
|
||||
# Determine download progress percentage
|
||||
progress = 0
|
||||
if status == 'completed':
|
||||
progress = 100
|
||||
elif status == 'post_processing':
|
||||
progress = 95
|
||||
elif status in ('downloading', 'searching'):
|
||||
# Check live transfer data for real progress
|
||||
task_filename = task.get('filename') or track_info.get('filename')
|
||||
task_username = task.get('username') or track_info.get('username')
|
||||
if task_filename and task_username:
|
||||
lookup_key = deps.make_context_key(task_username, task_filename)
|
||||
live_info = deps.get_cached_transfer_data().get(lookup_key)
|
||||
if live_info:
|
||||
progress = live_info.get('percentComplete', 0)
|
||||
|
||||
items.append({
|
||||
'task_id': task_id,
|
||||
'title': title,
|
||||
'artist': artist,
|
||||
'album': album,
|
||||
'artwork': artwork,
|
||||
'status': status,
|
||||
'progress': progress,
|
||||
'error': task.get('error_message'),
|
||||
'batch_id': batch_id,
|
||||
'batch_name': batch.get('playlist_name') or batch.get('album_name') or '',
|
||||
'batch_source': batch.get('source_page') or batch.get('initiated_from') or '',
|
||||
# playlist_id is needed by per-row cancel (cancel_task_v2
|
||||
# takes playlist_id + track_index). Surfacing it here so
|
||||
# the frontend doesn't need a second lookup.
|
||||
'playlist_id': batch.get('playlist_id', ''),
|
||||
'track_index': task.get('track_index', 0),
|
||||
'batch_total': len(batch.get('queue', [])),
|
||||
'timestamp': task.get('status_change_time', 0),
|
||||
'priority': _STATUS_PRIORITY.get(status, 9),
|
||||
})
|
||||
|
||||
# Sort: active first (by priority), then by timestamp desc within each group
|
||||
items.sort(key=lambda x: (x['priority'], -x['timestamp']))
|
||||
|
||||
# Build batch summaries for the batch context panel
|
||||
batch_summaries = []
|
||||
with tasks_lock:
|
||||
for bid, batch in download_batches.items():
|
||||
queue = batch.get('queue', [])
|
||||
statuses = [download_tasks[tid]['status'] for tid in queue if tid in download_tasks]
|
||||
batch_summaries.append({
|
||||
'batch_id': bid,
|
||||
'playlist_id': batch.get('playlist_id', ''),
|
||||
'batch_name': batch.get('playlist_name') or batch.get('album_name') or '',
|
||||
'source_page': batch.get('source_page') or batch.get('initiated_from') or '',
|
||||
'phase': batch.get('phase', 'unknown'),
|
||||
'total': len(queue),
|
||||
'completed': sum(1 for s in statuses if s in ('completed', 'skipped', 'already_owned')),
|
||||
'failed': sum(1 for s in statuses if s in ('failed', 'not_found', 'cancelled')),
|
||||
'active': sum(1 for s in statuses if s in ('downloading', 'searching', 'post_processing')),
|
||||
'queued': sum(1 for s in statuses if s in ('queued', 'pending')),
|
||||
})
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'downloads': items[:limit],
|
||||
'total': len(items),
|
||||
'batches': batch_summaries,
|
||||
'timestamp': time.time(),
|
||||
}
|
||||
380
core/downloads/task_worker.py
Normal file
380
core/downloads/task_worker.py
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
"""Per-task download worker.
|
||||
|
||||
Runs as a background thread (one per task) that:
|
||||
1. Tries source-reuse (use the batch's last good slskd peer if available)
|
||||
2. Tries staging-match (file already in staging folder, no download needed)
|
||||
3. Generates smart search queries via the matching engine + legacy fallbacks
|
||||
4. Iterates queries sequentially against the soulseek client
|
||||
5. For each query: validates results, attempts download with fallback candidates
|
||||
6. If hybrid mode: falls back to remaining sources (youtube/tidal/qobuz/hifi/deezer_dl)
|
||||
7. On total failure: marks task not_found + records search diagnostics
|
||||
8. On any uncaught exception: marks failed + emergency worker-slot recovery
|
||||
|
||||
Lifted verbatim from web_server.py's `_download_track_worker`. The helpers
|
||||
this calls into (try_source_reuse, store_batch_source, try_staging_match,
|
||||
get_valid_candidates, attempt_download_with_candidates, on_download_completed,
|
||||
recover_worker_slot) are passed via `TaskWorkerDeps` since each is itself
|
||||
a large web_server.py helper that will get its own lift in subsequent PRs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from core.runtime_state import download_tasks, tasks_lock
|
||||
from core.spotify_client import Track as SpotifyTrack
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskWorkerDeps:
|
||||
"""Bundle of cross-cutting deps the per-task download worker needs."""
|
||||
soulseek_client: Any
|
||||
matching_engine: Any
|
||||
run_async: Callable
|
||||
try_source_reuse: Callable # (task_id, batch_id, track) -> bool
|
||||
store_batch_source: Callable # (batch_id, username, filename) -> None
|
||||
try_staging_match: Callable # (task_id, batch_id, track) -> bool
|
||||
get_valid_candidates: Callable # (results, spotify_track, query) -> list
|
||||
attempt_download_with_candidates: Callable # (task_id, candidates, track, batch_id) -> bool
|
||||
on_download_completed: Callable # (batch_id, task_id, success) -> None
|
||||
recover_worker_slot: Callable # (batch_id, task_id) -> None
|
||||
|
||||
|
||||
def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorkerDeps) -> None:
|
||||
"""Enhanced download worker that matches the GUI's exact retry logic.
|
||||
|
||||
Implements sequential query retry, fallback candidates, and download
|
||||
failure retry.
|
||||
"""
|
||||
try:
|
||||
# Retrieve task details from global state
|
||||
with tasks_lock:
|
||||
if task_id not in download_tasks:
|
||||
logger.warning(f"[Modal Worker] Task {task_id} not found in download_tasks")
|
||||
return
|
||||
task = download_tasks[task_id].copy()
|
||||
|
||||
# Cancellation Checkpoint 1: Before doing anything
|
||||
with tasks_lock:
|
||||
if task_id not in download_tasks:
|
||||
logger.info(f"[Modal Worker] Task {task_id} was deleted before starting")
|
||||
return
|
||||
if download_tasks[task_id]['status'] == 'cancelled':
|
||||
logger.warning(f"[Modal Worker] Task {task_id} cancelled before starting")
|
||||
# V2 FIX: Don't call _on_download_completed for cancelled V2 tasks
|
||||
# V2 system handles worker slot freeing in atomic cancel function
|
||||
task_playlist_id = download_tasks[task_id].get('playlist_id')
|
||||
if task_playlist_id:
|
||||
logger.warning(f"[Modal Worker] V2 task {task_id} cancelled - worker slot already freed by V2 system")
|
||||
return # V2 system already handled worker slot management
|
||||
elif batch_id:
|
||||
# Legacy system - use old completion callback
|
||||
logger.warning(f"[Modal Worker] Legacy task {task_id} cancelled - using legacy completion callback")
|
||||
deps.on_download_completed(batch_id, task_id, False)
|
||||
return
|
||||
|
||||
track_data = task['track_info']
|
||||
track_name = track_data.get('name', 'Unknown Track')
|
||||
|
||||
logger.info(f"[Modal Worker] Task {task_id} starting search for track: '{track_name}'")
|
||||
|
||||
# Recreate a SpotifyTrack object for the matching engine
|
||||
# Handle both string format and Spotify API format for artists
|
||||
raw_artists = track_data.get('artists', [])
|
||||
processed_artists = []
|
||||
for artist in raw_artists:
|
||||
if isinstance(artist, str):
|
||||
processed_artists.append(artist)
|
||||
elif isinstance(artist, dict) and 'name' in artist:
|
||||
processed_artists.append(artist['name'])
|
||||
else:
|
||||
processed_artists.append(str(artist))
|
||||
|
||||
# Handle album field - extract name if it's a dictionary
|
||||
raw_album = track_data.get('album', '')
|
||||
if isinstance(raw_album, dict) and 'name' in raw_album:
|
||||
album_name = raw_album['name']
|
||||
elif isinstance(raw_album, str):
|
||||
album_name = raw_album
|
||||
else:
|
||||
album_name = str(raw_album)
|
||||
|
||||
track = SpotifyTrack(
|
||||
id=track_data.get('id', ''),
|
||||
name=track_data.get('name', ''),
|
||||
artists=processed_artists,
|
||||
album=album_name,
|
||||
duration_ms=track_data.get('duration_ms', 0),
|
||||
popularity=track_data.get('popularity', 0),
|
||||
)
|
||||
logger.info(f"[Modal Worker] Starting download task for: {track.name} by {track.artists[0] if track.artists else 'Unknown'}")
|
||||
|
||||
# === SOURCE REUSE: Check batch's last good source before searching ===
|
||||
if deps.try_source_reuse(task_id, batch_id, track):
|
||||
# Store source for next worker (cascading reuse)
|
||||
with tasks_lock:
|
||||
used_filename = download_tasks.get(task_id, {}).get('filename')
|
||||
used_username = download_tasks.get(task_id, {}).get('username')
|
||||
if used_filename and used_username:
|
||||
deps.store_batch_source(batch_id, used_username, used_filename)
|
||||
return
|
||||
|
||||
# === STAGING CHECK: Check staging folder for existing file before searching ===
|
||||
if deps.try_staging_match(task_id, batch_id, track):
|
||||
return
|
||||
|
||||
# Initialize task state tracking (like GUI's parallel_search_tracking)
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'searching' # Now actively being processed
|
||||
download_tasks[task_id]['current_query_index'] = 0
|
||||
download_tasks[task_id]['current_candidate_index'] = 0
|
||||
download_tasks[task_id]['retry_count'] = 0
|
||||
download_tasks[task_id]['candidates'] = []
|
||||
# CRITICAL: Preserve used_sources from previous retry attempts (don't reset to empty set)
|
||||
# If this is a retry, the monitor will have already marked failed sources
|
||||
if 'used_sources' not in download_tasks[task_id]:
|
||||
download_tasks[task_id]['used_sources'] = set()
|
||||
# Else: keep existing used_sources to avoid retrying same failed hosts
|
||||
|
||||
# 1. Generate multiple search queries (like GUI's generate_smart_search_queries)
|
||||
artist_name = track.artists[0] if track.artists else None
|
||||
track_name = track.name
|
||||
|
||||
# Start with matching engine queries
|
||||
search_queries = deps.matching_engine.generate_download_queries(track)
|
||||
|
||||
# Add legacy fallback queries (like GUI does)
|
||||
legacy_queries = []
|
||||
|
||||
if artist_name:
|
||||
# Add first word of artist approach (legacy compatibility)
|
||||
artist_words = artist_name.split()
|
||||
if artist_words:
|
||||
first_word = artist_words[0]
|
||||
if first_word.lower() == 'the' and len(artist_words) > 1:
|
||||
first_word = artist_words[1]
|
||||
|
||||
if len(first_word) > 1:
|
||||
legacy_queries.append(f"{track_name} {first_word}".strip())
|
||||
|
||||
# Add track-only query
|
||||
if track_name.strip():
|
||||
legacy_queries.append(track_name.strip())
|
||||
|
||||
# Add traditional cleaned queries
|
||||
cleaned_name = re.sub(r'\s*\([^)]*\)', '', track_name).strip()
|
||||
cleaned_name = re.sub(r'\s*\[[^\]]*\]', '', cleaned_name).strip()
|
||||
|
||||
if cleaned_name and cleaned_name.lower() != track_name.lower():
|
||||
legacy_queries.append(cleaned_name.strip())
|
||||
|
||||
# Combine enhanced queries with legacy fallbacks
|
||||
all_queries = search_queries + legacy_queries
|
||||
|
||||
# Remove duplicates while preserving order
|
||||
unique_queries = []
|
||||
seen = set()
|
||||
for query in all_queries:
|
||||
if query and query.lower() not in seen:
|
||||
unique_queries.append(query)
|
||||
seen.add(query.lower())
|
||||
|
||||
search_queries = unique_queries
|
||||
logger.info(f"[Modal Worker] Generated {len(search_queries)} smart search queries for '{track.name}': {search_queries}")
|
||||
logger.info(f"[Modal Worker] About to start search loop for task {task_id} (track: '{track.name}')")
|
||||
|
||||
# 2. Sequential Query Search (matches GUI's start_search_worker_parallel logic)
|
||||
search_diagnostics = [] # Track what happened per query for detailed error messages
|
||||
all_raw_results = [] # Collect raw results across queries for candidate review modal
|
||||
for query_index, query in enumerate(search_queries):
|
||||
# Cancellation check before each query
|
||||
with tasks_lock:
|
||||
if task_id not in download_tasks:
|
||||
logger.debug(f"[Modal Worker] Task {task_id} was deleted during query {query_index + 1}")
|
||||
return
|
||||
if download_tasks[task_id]['status'] == 'cancelled':
|
||||
logger.debug(f"[Modal Worker] Task {task_id} cancelled during query {query_index + 1}")
|
||||
# Don't call _on_download_completed for cancelled tasks as it can stop monitoring
|
||||
return
|
||||
download_tasks[task_id]['current_query_index'] = query_index
|
||||
|
||||
logger.debug(f"[Modal Worker] Query {query_index + 1}/{len(search_queries)}: '{query}'")
|
||||
logger.debug(f"About to call soulseek search for task {task_id}")
|
||||
|
||||
try:
|
||||
# Perform search with timeout
|
||||
tracks_result, _ = deps.run_async(deps.soulseek_client.search(query, timeout=30))
|
||||
logger.debug(f"Search completed for task {task_id}, got {len(tracks_result) if tracks_result else 0} results")
|
||||
|
||||
# CRITICAL: Check cancellation immediately after search returns
|
||||
with tasks_lock:
|
||||
if task_id not in download_tasks:
|
||||
logger.info(f"[Modal Worker] Task {task_id} was deleted after search returned")
|
||||
return
|
||||
if download_tasks[task_id]['status'] == 'cancelled':
|
||||
logger.warning(f"[Modal Worker] Task {task_id} cancelled after search returned - ignoring results")
|
||||
# Don't call _on_download_completed for cancelled tasks as it can stop monitoring
|
||||
# The cancellation endpoint already handles batch management properly
|
||||
return
|
||||
|
||||
if tracks_result:
|
||||
result_count = len(tracks_result)
|
||||
# Validate candidates using GUI's get_valid_candidates logic
|
||||
candidates = deps.get_valid_candidates(tracks_result, track, query)
|
||||
if candidates:
|
||||
logger.debug(f"[Modal Worker] Found {len(candidates)} valid candidates for query '{query}'")
|
||||
|
||||
# CRITICAL: Check cancellation before processing candidates
|
||||
with tasks_lock:
|
||||
if task_id not in download_tasks:
|
||||
logger.info(f"[Modal Worker] Task {task_id} was deleted before processing candidates")
|
||||
return
|
||||
if download_tasks[task_id]['status'] == 'cancelled':
|
||||
logger.warning(f"[Modal Worker] Task {task_id} cancelled before processing candidates")
|
||||
# Don't call _on_download_completed for cancelled tasks as it can stop monitoring
|
||||
return
|
||||
# Store candidates for retry fallback (like GUI)
|
||||
download_tasks[task_id]['cached_candidates'] = candidates
|
||||
|
||||
# Try to download with these candidates
|
||||
success = deps.attempt_download_with_candidates(task_id, candidates, track, batch_id)
|
||||
if success:
|
||||
# Download initiated successfully - let the download monitoring system handle completion
|
||||
if batch_id:
|
||||
logger.info(f"[Modal Worker] Download initiated successfully for task {task_id} - monitoring will handle completion")
|
||||
# Store this source for batch reuse
|
||||
with tasks_lock:
|
||||
used_filename = download_tasks.get(task_id, {}).get('filename')
|
||||
used_username = download_tasks.get(task_id, {}).get('username')
|
||||
if used_filename and used_username:
|
||||
deps.store_batch_source(batch_id, used_username, used_filename)
|
||||
return # Success, exit the worker
|
||||
else:
|
||||
search_diagnostics.append(f'"{query}": {result_count} results, {len(candidates)} passed filters but download failed to start')
|
||||
else:
|
||||
search_diagnostics.append(f'"{query}": {result_count} results but none passed quality/artist filters')
|
||||
all_raw_results.extend(tracks_result[:20]) # Keep top results for review
|
||||
else:
|
||||
search_diagnostics.append(f'"{query}": no results found')
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"[Modal Worker] Search failed for query '{query}': {e}")
|
||||
search_diagnostics.append(f'"{query}": search error — {e}')
|
||||
continue
|
||||
|
||||
# === HYBRID FALLBACK: If primary source failed, try remaining sources directly ===
|
||||
# The orchestrator's hybrid search stops at the first source with results, even if
|
||||
# those results all fail quality filtering. Try remaining sources individually.
|
||||
if getattr(deps.soulseek_client, 'mode', '') == 'hybrid':
|
||||
try:
|
||||
orch = deps.soulseek_client
|
||||
hybrid_order = getattr(orch, 'hybrid_order', None) or []
|
||||
if not hybrid_order:
|
||||
primary = getattr(orch, 'hybrid_primary', 'soulseek')
|
||||
secondary = getattr(orch, 'hybrid_secondary', '')
|
||||
hybrid_order = [primary, secondary] if secondary and secondary != primary else [primary]
|
||||
|
||||
source_clients = {
|
||||
'soulseek': getattr(orch, 'soulseek', None),
|
||||
'youtube': getattr(orch, 'youtube', None),
|
||||
'tidal': getattr(orch, 'tidal', None),
|
||||
'qobuz': getattr(orch, 'qobuz', None),
|
||||
'hifi': getattr(orch, 'hifi', None),
|
||||
'deezer_dl': getattr(orch, 'deezer_dl', None),
|
||||
}
|
||||
|
||||
# The orchestrator tried sources in order but stopped at the first with results.
|
||||
# We don't know which it stopped at, so try ALL sources except the first
|
||||
# (which was definitely tried). If the first was skipped (unconfigured),
|
||||
# the orchestrator would have tried the second — but trying it again is
|
||||
# harmless (streaming sources return fast).
|
||||
remaining_sources = [s for s in hybrid_order[1:] if s in source_clients and source_clients[s]]
|
||||
if remaining_sources:
|
||||
logger.warning(f"[Hybrid Fallback] Primary source had no valid matches. Trying fallback sources: {remaining_sources}")
|
||||
|
||||
for fallback_source in remaining_sources:
|
||||
fb_client = source_clients[fallback_source]
|
||||
if hasattr(fb_client, 'is_configured') and not fb_client.is_configured():
|
||||
continue
|
||||
|
||||
# Use first 2 queries only for speed
|
||||
for fb_query in search_queries[:2]:
|
||||
try:
|
||||
logger.warning(f"[Hybrid Fallback] Trying {fallback_source}: '{fb_query}'")
|
||||
fb_results, _ = deps.run_async(fb_client.search(fb_query, timeout=20))
|
||||
if not fb_results:
|
||||
continue
|
||||
fb_candidates = deps.get_valid_candidates(fb_results, track, fb_query)
|
||||
if fb_candidates:
|
||||
logger.warning(f"[Hybrid Fallback] {fallback_source} found {len(fb_candidates)} valid candidates!")
|
||||
success = deps.attempt_download_with_candidates(task_id, fb_candidates, track, batch_id)
|
||||
if success:
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"[Hybrid Fallback] {fallback_source} search failed: {e}")
|
||||
continue
|
||||
|
||||
logger.warning(f"[Hybrid Fallback] {fallback_source} returned no valid candidates")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Hybrid Fallback] Error in fallback logic: {e}")
|
||||
|
||||
# If we get here, all search queries and hybrid fallbacks failed
|
||||
logger.warning(f"[Modal Worker] No valid candidates found for '{track.name}' after trying all {len(search_queries)} queries.")
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'not_found'
|
||||
_diag_summary = ' | '.join(search_diagnostics) if search_diagnostics else 'no queries attempted'
|
||||
download_tasks[task_id]['error_message'] = f'No match found for "{track_name}" by {artist_name or "Unknown"} after {len(search_queries)} queries. Breakdown: {_diag_summary}'
|
||||
# Store raw results so the user can review what Soulseek returned
|
||||
if all_raw_results and not download_tasks[task_id].get('cached_candidates'):
|
||||
download_tasks[task_id]['cached_candidates'] = all_raw_results
|
||||
|
||||
# Notify batch manager that this task completed (failed) - THREAD SAFE
|
||||
if batch_id:
|
||||
try:
|
||||
deps.on_download_completed(batch_id, task_id, False)
|
||||
except Exception as completion_error:
|
||||
logger.error(f"Error in batch completion callback for {task_id}: {completion_error}")
|
||||
|
||||
except Exception as e:
|
||||
track_name_safe = locals().get('track_name', 'unknown') # Safe fallback for track_name
|
||||
logger.error(f"CRITICAL ERROR in download task for '{track_name_safe}' (task_id: {task_id}): {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
# Update task status safely with timeout
|
||||
try:
|
||||
lock_acquired = tasks_lock.acquire(timeout=2.0)
|
||||
if lock_acquired:
|
||||
try:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
download_tasks[task_id]['error_message'] = f'Unexpected error during download: {type(e).__name__}: {e}'
|
||||
logger.error(f"[Exception Recovery] Set task {task_id} status to 'failed'")
|
||||
finally:
|
||||
tasks_lock.release()
|
||||
else:
|
||||
logger.error(f"[Exception Recovery] Could not acquire lock to update task {task_id} status")
|
||||
except Exception as status_error:
|
||||
logger.error(f"Error updating task status in exception handler: {status_error}")
|
||||
|
||||
# Notify batch manager that this task completed (failed) - THREAD SAFE with RECOVERY
|
||||
if batch_id:
|
||||
try:
|
||||
deps.on_download_completed(batch_id, task_id, False)
|
||||
logger.error(f"[Exception Recovery] Successfully freed worker slot for task {task_id}")
|
||||
except Exception as completion_error:
|
||||
logger.error(f"[Exception Recovery] Error in batch completion callback for {task_id}: {completion_error}")
|
||||
# CRITICAL: If batch completion fails, we need to manually recover the worker slot
|
||||
try:
|
||||
logger.error(f"[Exception Recovery] Attempting manual worker slot recovery for batch {batch_id}")
|
||||
deps.recover_worker_slot(batch_id, task_id)
|
||||
except Exception as recovery_error:
|
||||
logger.error(f"[Exception Recovery] FATAL: Could not recover worker slot: {recovery_error}")
|
||||
213
core/downloads/validation.py
Normal file
213
core/downloads/validation.py
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
"""Soulseek/streaming candidate validation — lifted from web_server.py.
|
||||
|
||||
Body is byte-identical to the original. ``matching_engine`` and
|
||||
``soulseek_client`` are injected via init() because both are
|
||||
constructed in web_server.py and referenced by name throughout
|
||||
the body.
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
|
||||
from config.settings import config_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Injected at runtime via init().
|
||||
matching_engine = None
|
||||
soulseek_client = None
|
||||
|
||||
|
||||
def init(matching_engine_obj, soulseek_client_obj):
|
||||
"""Bind the matching engine and download orchestrator from web_server."""
|
||||
global matching_engine, soulseek_client
|
||||
matching_engine = matching_engine_obj
|
||||
soulseek_client = soulseek_client_obj
|
||||
|
||||
|
||||
def get_valid_candidates(results, spotify_track, query):
|
||||
"""
|
||||
This function is a direct port from sync.py. It scores and filters
|
||||
Soulseek search results against a Spotify track to find the best, most
|
||||
accurate download candidates.
|
||||
"""
|
||||
if not results:
|
||||
return []
|
||||
|
||||
# Streaming sources (YouTube, Tidal, Qobuz, HiFi, Deezer) 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")
|
||||
if results[0].username in _streaming_sources:
|
||||
source_label = results[0].username.replace('_dl', '').title()
|
||||
expected_artists = spotify_track.artists if spotify_track else []
|
||||
expected_title = spotify_track.name if spotify_track else ''
|
||||
expected_duration = spotify_track.duration_ms if spotify_track else 0
|
||||
|
||||
# Detect if the expected track is a specific version (live, remix, acoustic, etc.)
|
||||
expected_title_lower = (expected_title or '').lower()
|
||||
_version_keywords = ['remix', 'live', 'acoustic', 'instrumental', 'radio edit',
|
||||
'extended', 'slowed', 'sped up', 'reverb', 'karaoke']
|
||||
expected_is_version = any(kw in expected_title_lower for kw in _version_keywords)
|
||||
|
||||
scored = []
|
||||
for r in results:
|
||||
# Score using matching engine's generic scorer (same weights as Soulseek)
|
||||
confidence, match_type = matching_engine.score_track_match(
|
||||
source_title=expected_title,
|
||||
source_artists=expected_artists,
|
||||
source_duration_ms=expected_duration,
|
||||
candidate_title=r.title or '',
|
||||
candidate_artists=[r.artist] if r.artist else [],
|
||||
candidate_duration_ms=r.duration or 0,
|
||||
)
|
||||
|
||||
# Version detection penalty — reject live/remix/acoustic when expecting original
|
||||
r_title_lower = (r.title or '').lower()
|
||||
is_wrong_version = False
|
||||
if not expected_is_version:
|
||||
# Expecting original — penalize versions
|
||||
for kw in _version_keywords:
|
||||
if kw in r_title_lower and kw not in expected_title_lower:
|
||||
confidence *= 0.4 # Heavy penalty
|
||||
is_wrong_version = True
|
||||
break
|
||||
else:
|
||||
# Expecting specific version — penalize results that don't have it
|
||||
for kw in _version_keywords:
|
||||
if kw in expected_title_lower and kw not in r_title_lower:
|
||||
confidence *= 0.5
|
||||
is_wrong_version = True
|
||||
break
|
||||
|
||||
# Artist gate — streaming APIs (Tidal/Qobuz/HiFi/Deezer) have reliable metadata,
|
||||
# so "My Will" by "B. Starr" should never match expected "B小町".
|
||||
# Skip for YouTube — artist is parsed from video titles and often unreliable.
|
||||
if r.username != 'youtube':
|
||||
from difflib import SequenceMatcher
|
||||
import re as _re
|
||||
_cand_artist_raw = r.artist or ''
|
||||
_cand_artist = matching_engine.normalize_string(_cand_artist_raw)
|
||||
_best_artist = 0.0
|
||||
for _ea in expected_artists:
|
||||
_ea_norm = matching_engine.normalize_string(_ea)
|
||||
if not _ea_norm:
|
||||
continue
|
||||
# For short normalized names (e.g. "B小町"→"b"), containment is useless.
|
||||
# Compare original Unicode strings directly via similarity instead.
|
||||
if len(_ea_norm) <= 2:
|
||||
_best_artist = max(_best_artist, SequenceMatcher(None, _ea.lower(), _cand_artist_raw.lower()).ratio())
|
||||
elif _re.search(r'\b' + _re.escape(_ea_norm) + r'\b', _cand_artist):
|
||||
_best_artist = 1.0
|
||||
break
|
||||
elif _ea_norm == _cand_artist:
|
||||
_best_artist = 1.0
|
||||
break
|
||||
else:
|
||||
_best_artist = max(_best_artist, SequenceMatcher(None, _ea_norm, _cand_artist).ratio())
|
||||
# Raised from 0.4 → 0.5 to close a fencepost bug: SequenceMatcher
|
||||
# returns exactly 0.400 for "maduk" vs "tom walker" (5 chars vs
|
||||
# 10 chars with 2 coincidental char matches), which bypassed the
|
||||
# strict `< 0.4` check and let Tom Walker through as a candidate
|
||||
# for a Maduk track. The word-boundary containment check above
|
||||
# already short-circuits legitimate formatting variations
|
||||
# ("Beatles"/"The Beatles", "Maduk"/"Maduk feat. X") to sim=1.0,
|
||||
# so falling to SequenceMatcher means the strings are genuinely
|
||||
# different. 0.5 gives a safer buffer without blocking real
|
||||
# matches that would have scored above 0.85 anyway.
|
||||
if _best_artist < 0.5 and confidence < 0.85:
|
||||
continue
|
||||
|
||||
r.confidence = confidence
|
||||
r.version_type = 'wrong_version' if is_wrong_version else match_type
|
||||
if confidence >= 0.60:
|
||||
scored.append(r)
|
||||
|
||||
if scored:
|
||||
# Sort by confidence (best match first)
|
||||
scored.sort(key=lambda x: x.confidence, reverse=True)
|
||||
best = scored[0]
|
||||
logger.info(f"[{source_label}] {len(scored)}/{len(results)} candidates passed validation "
|
||||
f"(best: {best.confidence:.2f} '{best.artist} - {best.title}')")
|
||||
return scored
|
||||
else:
|
||||
if results[0].username == 'youtube':
|
||||
logger.warning(f"[{source_label}] No streaming results passed validation — falling through to filename matching")
|
||||
# YouTube artist data is unreliable, allow fallback to filename-based matching
|
||||
else:
|
||||
logger.warning(f"[{source_label}] No streaming results passed validation (threshold: 0.60, artist gate: 0.50) — rejecting all candidates")
|
||||
return [] # Tidal/Qobuz/HiFi/Deezer have structured metadata; don't fall back to filename matching
|
||||
|
||||
# Uses the existing, powerful matching engine for scoring (Soulseek P2P results)
|
||||
_max_q = config_manager.get('soulseek.max_peer_queue', 0) or 0
|
||||
initial_candidates = matching_engine.find_best_slskd_matches_enhanced(spotify_track, results, max_peer_queue=_max_q)
|
||||
if not initial_candidates:
|
||||
return []
|
||||
|
||||
# Skip quality filtering for streaming source results that somehow got here
|
||||
is_streaming_source = initial_candidates[0].username in _streaming_sources if initial_candidates else False
|
||||
|
||||
if is_streaming_source:
|
||||
source_label = initial_candidates[0].username.title()
|
||||
logger.info(f"[{source_label}] Skipping quality filter - streaming source handles quality internally")
|
||||
quality_filtered_candidates = initial_candidates
|
||||
else:
|
||||
# Filter by user's quality profile before artist verification (Soulseek only)
|
||||
# Use existing soulseek_client to avoid re-initializing (which accesses download_path filesystem)
|
||||
quality_filtered_candidates = soulseek_client.soulseek.filter_results_by_quality_preference(initial_candidates)
|
||||
|
||||
# IMPORTANT: Respect empty results from quality filter
|
||||
# If user has strict quality requirements (e.g., FLAC-only with fallback disabled),
|
||||
# and no results match, we should fail the download rather than force a fallback.
|
||||
# The quality filter already has its own fallback logic controlled by the user's settings.
|
||||
if not quality_filtered_candidates:
|
||||
logger.error("[Quality Filter] No candidates match quality profile - download will fail per user preferences")
|
||||
return []
|
||||
|
||||
verified_candidates = []
|
||||
spotify_artists = spotify_track.artists if spotify_track.artists else []
|
||||
|
||||
# Pre-normalize all artist names into word sets using the matching engine
|
||||
# This handles Cyrillic, accents, special chars ($), separators, etc.
|
||||
artist_word_sets = []
|
||||
for artist_name in spotify_artists:
|
||||
normalized = matching_engine.normalize_string(artist_name)
|
||||
words = set(normalized.split())
|
||||
if words:
|
||||
artist_word_sets.append(words)
|
||||
|
||||
for candidate in quality_filtered_candidates:
|
||||
# Skip artist check for streaming results (title matching is sufficient as processed by matching engine)
|
||||
if is_streaming_source:
|
||||
verified_candidates.append(candidate)
|
||||
continue
|
||||
|
||||
# No artist info available — can't verify, accept candidate
|
||||
if not artist_word_sets:
|
||||
verified_candidates.append(candidate)
|
||||
continue
|
||||
|
||||
# Split the Soulseek path into segments (folders + filename) and check each one.
|
||||
# This prevents false positives where a short artist name like "Sia" accidentally
|
||||
# matches inside a folder name like "Enthusiastic" — by checking words within
|
||||
# individual segments rather than a flat substring of the entire path.
|
||||
path_segments = re.split(r'[/\\]', candidate.filename)
|
||||
|
||||
artist_found = False
|
||||
for segment in path_segments:
|
||||
if not segment:
|
||||
continue
|
||||
seg_words = set(matching_engine.normalize_string(segment).split())
|
||||
if not seg_words:
|
||||
continue
|
||||
|
||||
# Check if ANY artist's words are ALL present in this segment
|
||||
for artist_words in artist_word_sets:
|
||||
if artist_words.issubset(seg_words):
|
||||
artist_found = True
|
||||
break
|
||||
|
||||
if artist_found:
|
||||
break
|
||||
|
||||
if artist_found:
|
||||
verified_candidates.append(candidate)
|
||||
return verified_candidates
|
||||
222
core/downloads/wishlist_failed.py
Normal file
222
core/downloads/wishlist_failed.py
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
"""Failed-tracks wishlist processing — lifted from web_server.py.
|
||||
|
||||
Body is byte-identical to the original. Wishlist helpers are
|
||||
direct imports from core.wishlist.*; runtime state comes from
|
||||
core.runtime_state; automation_engine, soulseek_client, and the
|
||||
sweep helper are injected via init() because they are constructed
|
||||
in web_server.py.
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
|
||||
from core.runtime_state import (
|
||||
download_batches,
|
||||
download_tasks,
|
||||
tasks_lock,
|
||||
)
|
||||
from core.wishlist.processing import (
|
||||
add_cancelled_tracks_to_failed_tracks as _add_cancelled_tracks_to_failed_tracks,
|
||||
build_wishlist_source_context as _build_wishlist_source_context,
|
||||
recover_uncaptured_failed_tracks as _recover_uncaptured_failed_tracks,
|
||||
remove_completed_tracks_from_wishlist as _remove_completed_tracks_from_wishlist,
|
||||
)
|
||||
from core.wishlist.resolution import (
|
||||
check_and_remove_from_wishlist as _check_and_remove_from_wishlist,
|
||||
)
|
||||
from utils.async_helpers import run_async
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Injected at runtime via init().
|
||||
automation_engine = None
|
||||
soulseek_client = None
|
||||
_sweep_empty_download_directories = None
|
||||
|
||||
|
||||
def init(engine, soulseek_client_obj, sweep_fn):
|
||||
"""Bind shared singletons + the sweep helper from web_server."""
|
||||
global automation_engine, soulseek_client, _sweep_empty_download_directories
|
||||
automation_engine = engine
|
||||
soulseek_client = soulseek_client_obj
|
||||
_sweep_empty_download_directories = sweep_fn
|
||||
|
||||
|
||||
def _process_failed_tracks_to_wishlist_exact(batch_id):
|
||||
"""
|
||||
Process failed and cancelled tracks to wishlist - EXACT replication of sync.py's on_all_downloads_complete() logic.
|
||||
This matches sync.py's behavior precisely.
|
||||
"""
|
||||
try:
|
||||
from core.wishlist_service import get_wishlist_service
|
||||
|
||||
logger.info(f"[Wishlist Processing] Starting wishlist processing for batch {batch_id}")
|
||||
|
||||
with tasks_lock:
|
||||
if batch_id not in download_batches:
|
||||
logger.warning(f"[Wishlist Processing] Batch {batch_id} not found")
|
||||
return {'tracks_added': 0, 'errors': 0}
|
||||
|
||||
batch = download_batches[batch_id]
|
||||
|
||||
# Wing It mode — skip wishlist entirely for failed tracks
|
||||
if batch.get('wing_it'):
|
||||
failed_count = len(batch.get('permanently_failed_tracks', []))
|
||||
logger.error(f"[Wing It] Skipping wishlist for {failed_count} failed tracks (wing it mode)")
|
||||
return {'tracks_added': 0, 'errors': 0}
|
||||
permanently_failed_tracks = batch.get('permanently_failed_tracks', [])
|
||||
cancelled_tracks = batch.get('cancelled_tracks', set())
|
||||
|
||||
# STEP 0: Remove completed tracks from wishlist (THIS WAS MISSING!)
|
||||
logger.info("[Wishlist Processing] Checking completed tracks for wishlist removal")
|
||||
_remove_completed_tracks_from_wishlist(
|
||||
batch,
|
||||
download_tasks,
|
||||
_check_and_remove_from_wishlist,
|
||||
)
|
||||
|
||||
# STEP 1: Add cancelled tracks that were missing to permanently_failed_tracks (replicating sync.py)
|
||||
# This matches sync.py's logic for adding cancelled missing tracks to the failed list
|
||||
if cancelled_tracks:
|
||||
logger.warning(f"[Wishlist Processing] Processing {len(cancelled_tracks)} cancelled tracks")
|
||||
processed_count = _add_cancelled_tracks_to_failed_tracks(
|
||||
batch,
|
||||
download_tasks,
|
||||
permanently_failed_tracks,
|
||||
)
|
||||
logger.warning(f"[Wishlist Processing] Processed {processed_count} cancelled tracks")
|
||||
|
||||
# STEP 1.5: Recover any failed/not_found tasks not captured in permanently_failed_tracks.
|
||||
# Stuck detection (in _on_download_completed, _check_batch_completion_v2, and the Safety Valve)
|
||||
# can force-mark tasks as not_found/failed without adding them to permanently_failed_tracks,
|
||||
# causing them to silently skip wishlist processing.
|
||||
recovered_count = _recover_uncaptured_failed_tracks(
|
||||
batch,
|
||||
download_tasks,
|
||||
permanently_failed_tracks,
|
||||
)
|
||||
if recovered_count:
|
||||
logger.warning(f"[Wishlist Processing] Recovered {recovered_count} uncaptured failed tracks for wishlist")
|
||||
|
||||
# STEP 2: Add permanently failed tracks to wishlist (exact sync.py logic)
|
||||
failed_count = len(permanently_failed_tracks)
|
||||
wishlist_added_count = 0
|
||||
error_count = 0
|
||||
|
||||
logger.error(f"[Wishlist Processing] Processing {failed_count} failed tracks for wishlist")
|
||||
|
||||
if permanently_failed_tracks:
|
||||
try:
|
||||
wishlist_service = get_wishlist_service()
|
||||
|
||||
# Create source_context identical to sync.py
|
||||
source_context = _build_wishlist_source_context(batch)
|
||||
|
||||
# Process each failed track (matching sync.py's loop) with safety limit
|
||||
max_failed_tracks = min(len(permanently_failed_tracks), 50) # Safety limit
|
||||
wing_it_skipped = 0
|
||||
for i, failed_track_info in enumerate(permanently_failed_tracks[:max_failed_tracks]):
|
||||
try:
|
||||
track_name = failed_track_info.get('track_name', f'Track {i+1}')
|
||||
|
||||
# Skip wing-it fallback tracks — they had no real metadata match,
|
||||
# so adding them to wishlist would just retry with the same raw data.
|
||||
# Check the track ID prefix since the wishlist payload helper overwrites source.
|
||||
track_data = failed_track_info.get('track_data') or failed_track_info.get('spotify_track', {})
|
||||
sp_id = track_data.get('id', '') if isinstance(track_data, dict) else ''
|
||||
if str(sp_id).startswith('wing_it_'):
|
||||
wing_it_skipped += 1
|
||||
logger.info(f"[Wishlist Processing] Skipping wing-it track: {track_name}")
|
||||
continue
|
||||
|
||||
logger.error(f"[Wishlist Processing] Adding track {i+1}/{max_failed_tracks}: {track_name}")
|
||||
|
||||
success = wishlist_service.add_failed_track_from_modal(
|
||||
track_info=failed_track_info,
|
||||
source_type='playlist',
|
||||
source_context=source_context,
|
||||
profile_id=batch.get('profile_id', 1)
|
||||
)
|
||||
if success:
|
||||
wishlist_added_count += 1
|
||||
logger.info(f"[Wishlist Processing] Added {track_name} to wishlist")
|
||||
try:
|
||||
if automation_engine:
|
||||
automation_engine.emit('wishlist_item_added', {
|
||||
'artist': failed_track_info.get('artist_name', ''),
|
||||
'title': track_name,
|
||||
'reason': failed_track_info.get('failure_reason', ''),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
logger.error(f"[Wishlist Processing] Failed to add {track_name} to wishlist")
|
||||
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
logger.error(f"[Wishlist Processing] Exception adding track to wishlist: {e}")
|
||||
|
||||
if wing_it_skipped:
|
||||
logger.warning(f"[Wishlist Processing] Skipped {wing_it_skipped} wing-it fallback tracks")
|
||||
logger.error(f"[Wishlist Processing] Added {wishlist_added_count}/{failed_count} failed tracks to wishlist (errors: {error_count})")
|
||||
|
||||
except Exception as e:
|
||||
error_count = len(permanently_failed_tracks)
|
||||
logger.error(f"[Wishlist Processing] Critical error adding failed tracks to wishlist: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
else:
|
||||
logger.error("ℹ️ [Wishlist Processing] No failed tracks to add to wishlist")
|
||||
|
||||
# Store completion summary in batch for API response (matching sync.py pattern)
|
||||
completion_summary = {
|
||||
'tracks_added': wishlist_added_count,
|
||||
'errors': error_count,
|
||||
'total_failed': failed_count
|
||||
}
|
||||
|
||||
with tasks_lock:
|
||||
if batch_id in download_batches:
|
||||
download_batches[batch_id]['wishlist_summary'] = completion_summary
|
||||
download_batches[batch_id]['wishlist_processing_complete'] = True
|
||||
# Phase already set to 'complete' in _on_download_completed
|
||||
|
||||
logger.info(f"[Wishlist Processing] Completed wishlist processing for batch {batch_id}")
|
||||
|
||||
# Auto-cleanup: Clear completed downloads from slskd
|
||||
try:
|
||||
logger.info(f"[Auto-Cleanup] Clearing completed downloads from slskd after batch {batch_id}")
|
||||
run_async(soulseek_client.clear_all_completed_downloads())
|
||||
logger.info("[Auto-Cleanup] Completed downloads cleared from slskd")
|
||||
except Exception as cleanup_error:
|
||||
logger.warning(f"[Auto-Cleanup] Failed to clear completed downloads: {cleanup_error}")
|
||||
|
||||
# Sweep empty directories left behind by this batch's downloads
|
||||
try:
|
||||
_sweep_empty_download_directories()
|
||||
except Exception as sweep_error:
|
||||
logger.warning(f"[Auto-Cleanup] Failed to sweep empty directories: {sweep_error}")
|
||||
|
||||
return completion_summary
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Wishlist Processing] CRITICAL ERROR in wishlist processing: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Mark batch as complete even with errors to prevent infinite loops
|
||||
try:
|
||||
with tasks_lock:
|
||||
if batch_id in download_batches:
|
||||
download_batches[batch_id]['phase'] = 'complete'
|
||||
download_batches[batch_id]['completion_time'] = time.time() # Track for auto-cleanup
|
||||
download_batches[batch_id]['wishlist_summary'] = {
|
||||
'tracks_added': 0,
|
||||
'errors': 1,
|
||||
'total_failed': 0,
|
||||
'error_message': str(e)
|
||||
}
|
||||
download_batches[batch_id]['wishlist_processing_complete'] = True
|
||||
except Exception as lock_error:
|
||||
logger.error(f"[Wishlist Processing] Failed to update batch after error: {lock_error}")
|
||||
|
||||
return {'tracks_added': 0, 'errors': 1, 'total_failed': 0}
|
||||
|
|
@ -12,20 +12,22 @@ Supports:
|
|||
- Track search by title, artist, album
|
||||
- Album lookup by ID
|
||||
- Artist lookup by ID
|
||||
- Direct FLAC download URLs from Tidal CDN
|
||||
- Quality selection: HI_RES_LOSSLESS, LOSSLESS, HIGH, LOW
|
||||
- HLS manifest-based downloads via /trackManifests/ endpoint
|
||||
- Quality selection: HIRES_LOSSLESS, LOSSLESS, HIGH, LOW
|
||||
- Multiple API instance failover
|
||||
- FFmpeg demuxing for FLAC extraction from MP4 containers
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import base64
|
||||
import uuid
|
||||
import time
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import List, Optional, Dict, Any, Tuple
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests as http_requests
|
||||
|
||||
|
|
@ -35,38 +37,44 @@ from core.soulseek_client import TrackResult, AlbumResult, DownloadStatus
|
|||
|
||||
logger = get_logger("hifi_client")
|
||||
|
||||
# Quality tiers matching Tidal's internal quality labels
|
||||
HIFI_QUALITY_MAP = {
|
||||
# HLS quality presets mapping to /trackManifests/ format parameters
|
||||
HLS_QUALITY_MAP = {
|
||||
'hires': {
|
||||
'api_value': 'HI_RES_LOSSLESS',
|
||||
'label': 'FLAC 24-bit/96kHz',
|
||||
'formats': ['FLAC_HIRES'],
|
||||
'manifest_type': 'HLS',
|
||||
'extension': 'flac',
|
||||
'label': 'FLAC 24-bit/96kHz',
|
||||
'bitrate': 9216,
|
||||
'codec': 'flac',
|
||||
},
|
||||
'lossless': {
|
||||
'api_value': 'LOSSLESS',
|
||||
'label': 'FLAC 16-bit/44.1kHz',
|
||||
'formats': ['FLAC'],
|
||||
'manifest_type': 'HLS',
|
||||
'extension': 'flac',
|
||||
'label': 'FLAC 16-bit/44.1kHz',
|
||||
'bitrate': 1411,
|
||||
'codec': 'flac',
|
||||
},
|
||||
'high': {
|
||||
'api_value': 'HIGH',
|
||||
'label': 'AAC 320kbps',
|
||||
'formats': ['AACLC'],
|
||||
'manifest_type': 'HLS',
|
||||
'extension': 'm4a',
|
||||
'label': 'AAC 320kbps',
|
||||
'bitrate': 320,
|
||||
'codec': 'aac',
|
||||
},
|
||||
'low': {
|
||||
'api_value': 'LOW',
|
||||
'label': 'AAC 96kbps',
|
||||
'formats': ['HEAACV1'],
|
||||
'manifest_type': 'HLS',
|
||||
'extension': 'm4a',
|
||||
'label': 'AAC 96kbps',
|
||||
'bitrate': 96,
|
||||
'codec': 'aac',
|
||||
},
|
||||
}
|
||||
|
||||
HLS_MAP_TAG_RE = re.compile(r'#EXT-X-MAP:.*URI="([^"]+)"')
|
||||
|
||||
# Default public hifi-api instances (ordered by preference)
|
||||
DEFAULT_INSTANCES = [
|
||||
'https://triton.squid.wtf',
|
||||
|
|
@ -85,56 +93,68 @@ class HiFiClient:
|
|||
"""
|
||||
|
||||
def __init__(self, download_path: str = None, base_url: str = None):
|
||||
# Download path (use Soulseek path for consistency with post-processing)
|
||||
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)
|
||||
|
||||
# API instance management
|
||||
self._instances = list(DEFAULT_INSTANCES)
|
||||
if base_url:
|
||||
# User-provided instance gets top priority
|
||||
self._instances.insert(0, base_url.rstrip('/'))
|
||||
self._instances = []
|
||||
self._instance_lock = threading.Lock()
|
||||
self._load_instances_from_db()
|
||||
|
||||
self._current_instance = self._instances[0] if self._instances else None
|
||||
self._instance_lock = threading.Lock()
|
||||
|
||||
# HTTP session with retry-friendly settings
|
||||
self.session = http_requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'SoulSync/1.0',
|
||||
'Accept': 'application/json',
|
||||
})
|
||||
|
||||
# Download tracking (mirrors TidalDownloadClient pattern)
|
||||
self.active_downloads: Dict[str, Dict[str, Any]] = {}
|
||||
self._download_lock = threading.Lock()
|
||||
|
||||
# Shutdown check callback
|
||||
self.shutdown_check = None
|
||||
|
||||
# Rate limiting
|
||||
self._last_api_call = 0
|
||||
self._api_lock = threading.Lock()
|
||||
self._min_interval = 0.5 # 500ms between calls
|
||||
self._min_interval = 0.5
|
||||
|
||||
logger.info(f"HiFi client initialized (instance: {self._current_instance}, "
|
||||
f"download path: {self.download_path})")
|
||||
|
||||
def set_shutdown_check(self, check_callable):
|
||||
"""Set a callback function to check for system shutdown."""
|
||||
self.shutdown_check = check_callable
|
||||
|
||||
# ===================== Instance Management =====================
|
||||
def _load_instances_from_db(self):
|
||||
try:
|
||||
from database.music_database import get_database
|
||||
db = get_database()
|
||||
db.seed_hifi_instances(DEFAULT_INSTANCES)
|
||||
rows = db.get_hifi_instances()
|
||||
urls = [r['url'] for r in rows if r['enabled']]
|
||||
if urls:
|
||||
self._instances = urls
|
||||
else:
|
||||
self._instances = list(DEFAULT_INSTANCES)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load HiFi instances from DB, using defaults: {e}")
|
||||
self._instances = list(DEFAULT_INSTANCES)
|
||||
|
||||
def reload_instances(self):
|
||||
with self._instance_lock:
|
||||
old_current = self._current_instance
|
||||
self._load_instances_from_db()
|
||||
self._current_instance = self._instances[0] if self._instances else None
|
||||
if self._current_instance != old_current:
|
||||
logger.info(f"HiFi instances reloaded, active: {self._current_instance}")
|
||||
else:
|
||||
logger.info("HiFi instances reloaded")
|
||||
|
||||
def _get_instance(self) -> Optional[str]:
|
||||
"""Get the current active API instance URL."""
|
||||
with self._instance_lock:
|
||||
return self._current_instance
|
||||
|
||||
def _rotate_instance(self, failed_url: str):
|
||||
"""Move a failed instance to the back of the list and switch to next."""
|
||||
with self._instance_lock:
|
||||
if failed_url in self._instances:
|
||||
self._instances.remove(failed_url)
|
||||
|
|
@ -146,7 +166,6 @@ class HiFiClient:
|
|||
self._current_instance = None
|
||||
|
||||
def _rate_limit(self):
|
||||
"""Enforce minimum interval between API calls."""
|
||||
with self._api_lock:
|
||||
now = time.time()
|
||||
elapsed = now - self._last_api_call
|
||||
|
|
@ -155,10 +174,6 @@ class HiFiClient:
|
|||
self._last_api_call = time.time()
|
||||
|
||||
def _api_get(self, path: str, params: dict = None, timeout: int = 15) -> Optional[dict]:
|
||||
"""
|
||||
Make a GET request to the hifi-api, with instance failover.
|
||||
Tries each instance up to once before giving up.
|
||||
"""
|
||||
tried = set()
|
||||
|
||||
while True:
|
||||
|
|
@ -176,7 +191,6 @@ class HiFiClient:
|
|||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Check for API-level errors
|
||||
if isinstance(data, dict) and data.get('error'):
|
||||
logger.warning(f"HiFi API error from {instance}: {data['error']}")
|
||||
return None
|
||||
|
|
@ -201,10 +215,7 @@ class HiFiClient:
|
|||
logger.error(f"HiFi API unexpected error: {e}")
|
||||
return None
|
||||
|
||||
# ===================== Availability =====================
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if the HiFi API is reachable."""
|
||||
try:
|
||||
data = self._api_get('/', timeout=5)
|
||||
return data is not None
|
||||
|
|
@ -212,11 +223,9 @@ class HiFiClient:
|
|||
return False
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if HiFi client is configured and ready (matches Soulseek interface)."""
|
||||
return self._current_instance is not None
|
||||
|
||||
async def check_connection(self) -> bool:
|
||||
"""Test if HiFi API is accessible (async, Soulseek-compatible)."""
|
||||
try:
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
|
|
@ -226,28 +235,13 @@ class HiFiClient:
|
|||
return False
|
||||
|
||||
def get_version(self) -> Optional[str]:
|
||||
"""Get the API version of the current instance."""
|
||||
data = self._api_get('/')
|
||||
if data and isinstance(data, dict):
|
||||
return data.get('version') or data.get('data', {}).get('version')
|
||||
return None
|
||||
|
||||
# ===================== Search =====================
|
||||
|
||||
def search_tracks(self, title: str = None, artist: str = None,
|
||||
album: str = None, limit: int = 20) -> List[Dict]:
|
||||
"""
|
||||
Search for tracks on Tidal via hifi-api.
|
||||
|
||||
Args:
|
||||
title: Track title to search for
|
||||
artist: Artist name to search for
|
||||
album: Album name to search for
|
||||
limit: Max results to return
|
||||
|
||||
Returns:
|
||||
List of track dicts with id, title, artist, album, duration, etc.
|
||||
"""
|
||||
params = {'limit': limit}
|
||||
if title:
|
||||
params['s'] = title
|
||||
|
|
@ -264,7 +258,6 @@ class HiFiClient:
|
|||
if not data:
|
||||
return []
|
||||
|
||||
# Handle response format: {data: {items: [...]}} or {data: [...]}
|
||||
items = []
|
||||
if isinstance(data, dict):
|
||||
inner = data.get('data', data)
|
||||
|
|
@ -285,15 +278,9 @@ class HiFiClient:
|
|||
return results
|
||||
|
||||
def search_raw(self, query: str, limit: int = 20) -> List[Dict]:
|
||||
"""
|
||||
Generic search (free-text query). Maps to title search.
|
||||
Returns raw dicts (not TrackResult).
|
||||
"""
|
||||
return self.search_tracks(title=query, limit=limit)
|
||||
|
||||
def _parse_track(self, item: dict) -> Dict:
|
||||
"""Parse a track item from hifi-api response into a normalized dict."""
|
||||
# Artist can be a dict with 'name' or a list of artists
|
||||
artist_name = 'Unknown Artist'
|
||||
artists_raw = item.get('artists', item.get('artist'))
|
||||
if isinstance(artists_raw, list):
|
||||
|
|
@ -309,7 +296,6 @@ class HiFiClient:
|
|||
elif isinstance(artists_raw, str):
|
||||
artist_name = artists_raw
|
||||
|
||||
# Album
|
||||
album_raw = item.get('album', {})
|
||||
album_name = ''
|
||||
if isinstance(album_raw, dict):
|
||||
|
|
@ -317,7 +303,6 @@ class HiFiClient:
|
|||
elif isinstance(album_raw, str):
|
||||
album_name = album_raw
|
||||
|
||||
# Duration
|
||||
duration_s = item.get('duration', 0)
|
||||
duration_ms = duration_s * 1000 if duration_s and duration_s < 100000 else duration_s
|
||||
|
||||
|
|
@ -333,10 +318,7 @@ class HiFiClient:
|
|||
'quality': item.get('audioQuality', item.get('quality', '')),
|
||||
}
|
||||
|
||||
# ===================== Track Info & Stream URL =====================
|
||||
|
||||
def get_track_info(self, track_id: int) -> Optional[Dict]:
|
||||
"""Get detailed metadata for a specific track."""
|
||||
data = self._api_get('/info/', params={'id': track_id})
|
||||
if not data:
|
||||
return None
|
||||
|
|
@ -346,57 +328,7 @@ class HiFiClient:
|
|||
return self._parse_track(inner)
|
||||
return None
|
||||
|
||||
def get_stream_url(self, track_id: int, quality: str = 'lossless') -> Optional[Dict]:
|
||||
"""
|
||||
Get the direct download URL for a track.
|
||||
|
||||
Args:
|
||||
track_id: Tidal track ID
|
||||
quality: One of 'hires', 'lossless', 'high', 'low'
|
||||
|
||||
Returns:
|
||||
Dict with 'url', 'mime_type', 'codec', 'quality' or None on failure.
|
||||
"""
|
||||
q_info = HIFI_QUALITY_MAP.get(quality, HIFI_QUALITY_MAP['lossless'])
|
||||
api_quality = q_info['api_value']
|
||||
|
||||
data = self._api_get('/track/', params={'id': track_id, 'quality': api_quality})
|
||||
if not data:
|
||||
return None
|
||||
|
||||
# Extract manifest from response
|
||||
inner = data.get('data', data) if isinstance(data, dict) else data
|
||||
if not isinstance(inner, dict):
|
||||
return None
|
||||
|
||||
manifest_b64 = inner.get('manifest')
|
||||
if not manifest_b64:
|
||||
logger.warning(f"No manifest in track response for {track_id}")
|
||||
return None
|
||||
|
||||
try:
|
||||
manifest = json.loads(base64.b64decode(manifest_b64))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to decode manifest for track {track_id}: {e}")
|
||||
return None
|
||||
|
||||
urls = manifest.get('urls', [])
|
||||
if not urls:
|
||||
logger.warning(f"No URLs in manifest for track {track_id}")
|
||||
return None
|
||||
|
||||
return {
|
||||
'url': urls[0],
|
||||
'mime_type': manifest.get('mimeType', ''),
|
||||
'codec': manifest.get('codecs', ''),
|
||||
'encryption': manifest.get('encryptionType', 'NONE'),
|
||||
'quality': quality,
|
||||
}
|
||||
|
||||
# ===================== Album & Artist =====================
|
||||
|
||||
def get_album(self, album_id: int, limit: int = 100) -> Optional[Dict]:
|
||||
"""Get album metadata and track list."""
|
||||
data = self._api_get('/album/', params={'id': album_id, 'limit': limit})
|
||||
if not data:
|
||||
return None
|
||||
|
|
@ -405,7 +337,6 @@ class HiFiClient:
|
|||
if not isinstance(inner, dict):
|
||||
return None
|
||||
|
||||
# Parse tracks within album
|
||||
tracks_raw = inner.get('items', inner.get('tracks', []))
|
||||
tracks = []
|
||||
for item in tracks_raw:
|
||||
|
|
@ -426,7 +357,6 @@ class HiFiClient:
|
|||
}
|
||||
|
||||
def get_artist(self, artist_id: int) -> Optional[Dict]:
|
||||
"""Get artist info and top tracks."""
|
||||
data = self._api_get('/artist/', params={'id': artist_id})
|
||||
if not data:
|
||||
return None
|
||||
|
|
@ -434,14 +364,150 @@ class HiFiClient:
|
|||
inner = data.get('data', data) if isinstance(data, dict) else data
|
||||
return inner if isinstance(inner, dict) else None
|
||||
|
||||
# ===================== Soulseek-Compatible Search =====================
|
||||
def _parse_hls_playlist(self, text: str, playlist_url: str):
|
||||
init_uri = None
|
||||
segment_uris = []
|
||||
variant_uri = None
|
||||
|
||||
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
||||
|
||||
for index, line in enumerate(lines):
|
||||
if line.startswith('#EXTM3U'):
|
||||
continue
|
||||
|
||||
if line.startswith('#EXT-X-STREAM-INF'):
|
||||
for next_line in lines[index + 1:]:
|
||||
if not next_line.startswith('#'):
|
||||
variant_uri = urljoin(playlist_url, next_line)
|
||||
break
|
||||
break
|
||||
|
||||
if line.startswith('#EXT-X-MAP'):
|
||||
match = HLS_MAP_TAG_RE.search(line)
|
||||
if match:
|
||||
init_uri = match.group(1)
|
||||
continue
|
||||
|
||||
if line.startswith('#'):
|
||||
continue
|
||||
|
||||
segment_uris.append(urljoin(playlist_url, line))
|
||||
|
||||
if variant_uri:
|
||||
return None, [variant_uri]
|
||||
|
||||
if not segment_uris:
|
||||
raise ValueError('No segment URIs found in the HLS playlist')
|
||||
|
||||
if init_uri:
|
||||
init_uri = urljoin(playlist_url, init_uri)
|
||||
|
||||
return init_uri, segment_uris
|
||||
|
||||
def _get_hls_manifest(self, track_id: int, quality: str = 'lossless') -> Optional[Dict]:
|
||||
q_info = HLS_QUALITY_MAP.get(quality, HLS_QUALITY_MAP['lossless'])
|
||||
formats = q_info['formats']
|
||||
|
||||
params = [
|
||||
('id', str(track_id)),
|
||||
('formats', ','.join(formats)),
|
||||
('usage', 'DOWNLOAD'),
|
||||
('manifestType', 'HLS'),
|
||||
('adaptive', 'true'),
|
||||
('uriScheme', 'HTTPS'),
|
||||
]
|
||||
|
||||
data = self._api_get('/trackManifests/', params=params, timeout=20)
|
||||
if not data:
|
||||
return None
|
||||
|
||||
try:
|
||||
inner = data.get('data', data) if isinstance(data, dict) else data
|
||||
attrs = inner.get('data', {}).get('attributes', {})
|
||||
uri = attrs.get('uri')
|
||||
except (AttributeError, KeyError) as e:
|
||||
logger.warning(f"Failed to extract playlist URI from manifest response: {e}")
|
||||
return None
|
||||
|
||||
if not uri:
|
||||
logger.warning(f"No playlist URI in manifest for track {track_id}")
|
||||
return None
|
||||
|
||||
try:
|
||||
playlist_resp = self.session.get(uri, allow_redirects=True, timeout=30)
|
||||
playlist_resp.raise_for_status()
|
||||
playlist_text = playlist_resp.text
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch HLS playlist for track {track_id}: {e}")
|
||||
return None
|
||||
|
||||
try:
|
||||
init_uri, segment_uris = self._parse_hls_playlist(playlist_text, uri)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to parse HLS playlist for track {track_id}: {e}")
|
||||
return None
|
||||
|
||||
if '#EXT-X-STREAM-INF' in playlist_text and segment_uris:
|
||||
playlist_uri = segment_uris[0]
|
||||
try:
|
||||
logger.debug(f"Detected master HLS playlist, following variant: {playlist_uri}")
|
||||
variant_resp = self.session.get(playlist_uri, allow_redirects=True, timeout=30)
|
||||
variant_resp.raise_for_status()
|
||||
variant_text = variant_resp.text
|
||||
init_uri, segment_uris = self._parse_hls_playlist(variant_text, playlist_uri)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch variant playlist for track {track_id}: {e}")
|
||||
return None
|
||||
|
||||
if init_uri:
|
||||
logger.info(f"HiFi HLS manifest for track {track_id}: "
|
||||
f"init segment + {len(segment_uris)} segments ({quality})")
|
||||
else:
|
||||
logger.info(f"HiFi HLS manifest for track {track_id}: "
|
||||
f"{len(segment_uris)} segments ({quality})")
|
||||
|
||||
return {
|
||||
'init_uri': init_uri,
|
||||
'segment_uris': segment_uris,
|
||||
'extension': q_info['extension'],
|
||||
'codec': q_info['codec'],
|
||||
'quality': quality,
|
||||
}
|
||||
|
||||
def _demux_flac(self, input_path: Path, output_path: Path) -> None:
|
||||
ffmpeg = shutil.which('ffmpeg')
|
||||
if not ffmpeg:
|
||||
tools_dir = Path(__file__).parent.parent / 'tools'
|
||||
ffmpeg_candidate = tools_dir / ('ffmpeg.exe' if os.name == 'nt' else 'ffmpeg')
|
||||
if ffmpeg_candidate.exists():
|
||||
ffmpeg = str(ffmpeg_candidate)
|
||||
else:
|
||||
raise RuntimeError('ffmpeg is required to demux FLAC from MP4. Install ffmpeg and retry.')
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
ffmpeg,
|
||||
'-y',
|
||||
'-hide_banner',
|
||||
'-loglevel', 'error',
|
||||
'-i', str(input_path),
|
||||
'-map', '0:a:0',
|
||||
'-c', 'copy',
|
||||
str(output_path),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise RuntimeError(
|
||||
f'ffmpeg failed while demuxing {input_path} -> {output_path}: '
|
||||
f'{exc.returncode}\n{exc.stderr}'
|
||||
) from exc
|
||||
|
||||
async def search(self, query: str, timeout: int = None,
|
||||
progress_callback=None) -> Tuple[List[TrackResult], List[AlbumResult]]:
|
||||
"""
|
||||
Search with Soulseek-compatible return format (TrackResult, AlbumResult).
|
||||
Matches the interface expected by DownloadOrchestrator.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
|
|
@ -449,7 +515,7 @@ class HiFiClient:
|
|||
tracks = await loop.run_in_executor(None, lambda: self.search_raw(query))
|
||||
|
||||
quality_key = config_manager.get('hifi_download.quality', 'lossless')
|
||||
q_info = HIFI_QUALITY_MAP.get(quality_key, HIFI_QUALITY_MAP['lossless'])
|
||||
q_info = HLS_QUALITY_MAP.get(quality_key, HLS_QUALITY_MAP['lossless'])
|
||||
|
||||
results = []
|
||||
for t in tracks:
|
||||
|
|
@ -466,7 +532,6 @@ class HiFiClient:
|
|||
return ([], [])
|
||||
|
||||
def _to_track_result(self, track: Dict, quality_info: Dict) -> TrackResult:
|
||||
"""Convert a hifi track dict to a TrackResult."""
|
||||
display_name = f"{track['artist']} - {track['title']}"
|
||||
filename = f"{track['id']}||{display_name}"
|
||||
|
||||
|
|
@ -486,13 +551,7 @@ class HiFiClient:
|
|||
track_number=track.get('track_number'),
|
||||
)
|
||||
|
||||
# ===================== Download =====================
|
||||
|
||||
async def download(self, username: str, filename: str, file_size: int = 0) -> Optional[str]:
|
||||
"""
|
||||
Download a track (async, Soulseek-compatible interface).
|
||||
Filename format: "track_id||display_name"
|
||||
"""
|
||||
try:
|
||||
if '||' not in filename:
|
||||
logger.error(f"Invalid filename format: {filename}")
|
||||
|
|
@ -537,7 +596,6 @@ class HiFiClient:
|
|||
return None
|
||||
|
||||
def _download_worker(self, download_id: str, track_id: int, display_name: str):
|
||||
"""Background download thread."""
|
||||
try:
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
|
|
@ -561,111 +619,168 @@ class HiFiClient:
|
|||
self.active_downloads[download_id]['state'] = 'Errored'
|
||||
|
||||
def _download_sync(self, download_id: str, track_id: int, display_name: str) -> Optional[str]:
|
||||
"""
|
||||
Synchronous download with quality fallback chain.
|
||||
Returns file path on success, None on failure.
|
||||
"""
|
||||
quality_key = config_manager.get('hifi_download.quality', 'lossless')
|
||||
chain = ['hires', 'lossless', 'high', 'low']
|
||||
start = chain.index(quality_key) if quality_key in chain else 1
|
||||
allow_fallback = config_manager.get('hifi_download.allow_fallback', True)
|
||||
chain = chain[start:] if allow_fallback else [quality_key]
|
||||
|
||||
MIN_AUDIO_SIZE = 100 * 1024 # 100KB
|
||||
MIN_AUDIO_SIZE = 100 * 1024
|
||||
|
||||
for q_key in chain:
|
||||
if self.shutdown_check and self.shutdown_check():
|
||||
logger.info("Shutdown detected, aborting HiFi download")
|
||||
return None
|
||||
|
||||
stream_info = self.get_stream_url(track_id, quality=q_key)
|
||||
if not stream_info or not stream_info.get('url'):
|
||||
logger.warning(f"No stream URL at quality {q_key}, trying next")
|
||||
manifest_info = self._get_hls_manifest(track_id, quality=q_key)
|
||||
if not manifest_info or not manifest_info.get('segment_uris'):
|
||||
logger.warning(f"No HLS manifest at quality {q_key}, trying next")
|
||||
continue
|
||||
|
||||
download_url = stream_info['url']
|
||||
codec = stream_info.get('codec', '')
|
||||
|
||||
# Determine extension
|
||||
if 'flac' in codec.lower():
|
||||
extension = 'flac'
|
||||
elif 'mp4a' in codec.lower() or 'aac' in codec.lower():
|
||||
extension = 'm4a'
|
||||
else:
|
||||
extension = HIFI_QUALITY_MAP.get(q_key, {}).get('extension', 'flac')
|
||||
|
||||
# Build output path
|
||||
extension = manifest_info['extension']
|
||||
safe_name = re.sub(r'[<>:"/\\|?*]', '_', display_name)
|
||||
out_filename = f"{safe_name}.{extension}"
|
||||
out_path = self.download_path / out_filename
|
||||
|
||||
try:
|
||||
logger.info(f"Downloading from HiFi ({q_key}): {out_filename}")
|
||||
response = http_requests.get(download_url, stream=True, timeout=120)
|
||||
response.raise_for_status()
|
||||
is_flac = q_key in ('hires', 'lossless')
|
||||
intermediate_path = out_path.with_suffix('.m4a') if is_flac else out_path
|
||||
|
||||
try:
|
||||
init_uri = manifest_info.get('init_uri')
|
||||
segment_uris = manifest_info['segment_uris']
|
||||
total_segments = len(segment_uris) + (1 if init_uri else 0)
|
||||
|
||||
logger.info(f"Downloading from HiFi ({q_key}): {out_filename} "
|
||||
f"({total_segments} segments)")
|
||||
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
downloaded = 0
|
||||
chunk_size = 64 * 1024
|
||||
speed_start = time.time()
|
||||
last_speed_update = speed_start
|
||||
segments_completed = 0
|
||||
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['size'] = total_size
|
||||
self.active_downloads[download_id]['size'] = 0
|
||||
|
||||
with open(out_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=chunk_size):
|
||||
if not chunk:
|
||||
continue
|
||||
with intermediate_path.open('wb') as output_file:
|
||||
if init_uri:
|
||||
if self.shutdown_check and self.shutdown_check():
|
||||
f.close()
|
||||
out_path.unlink(missing_ok=True)
|
||||
logger.info("Shutdown detected, aborting HiFi download")
|
||||
intermediate_path.unlink(missing_ok=True)
|
||||
return None
|
||||
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
logger.debug(f"Downloading init segment: {init_uri}")
|
||||
init_data = self._download_segment_with_retry(init_uri)
|
||||
output_file.write(init_data)
|
||||
downloaded += len(init_data)
|
||||
segments_completed += 1
|
||||
|
||||
if total_size > 0:
|
||||
progress = (downloaded / total_size) * 100
|
||||
else:
|
||||
progress = 0
|
||||
self._update_download_progress(download_id, downloaded,
|
||||
segments_completed, total_segments, speed_start)
|
||||
|
||||
# Calculate speed every 0.5s
|
||||
now = time.time()
|
||||
elapsed_total = now - speed_start
|
||||
speed = int(downloaded / elapsed_total) if elapsed_total > 0 else 0
|
||||
time_remaining = int((total_size - downloaded) / speed) if speed > 0 and total_size > 0 else None
|
||||
for segment_url in segment_uris:
|
||||
if self.shutdown_check and self.shutdown_check():
|
||||
logger.info("Shutdown detected, aborting HiFi download")
|
||||
intermediate_path.unlink(missing_ok=True)
|
||||
return None
|
||||
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['transferred'] = downloaded
|
||||
self.active_downloads[download_id]['progress'] = round(progress, 1)
|
||||
self.active_downloads[download_id]['speed'] = speed
|
||||
self.active_downloads[download_id]['time_remaining'] = time_remaining
|
||||
segment_data = self._download_segment_with_retry(segment_url)
|
||||
output_file.write(segment_data)
|
||||
downloaded += len(segment_data)
|
||||
segments_completed += 1
|
||||
|
||||
self._update_download_progress(download_id, downloaded,
|
||||
segments_completed, total_segments, speed_start)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Download failed at quality {q_key}: {e}")
|
||||
out_path.unlink(missing_ok=True)
|
||||
intermediate_path.unlink(missing_ok=True)
|
||||
continue
|
||||
|
||||
# Validate file size
|
||||
if downloaded < MIN_AUDIO_SIZE:
|
||||
logger.warning(f"File too small at {q_key} ({downloaded} bytes), trying next")
|
||||
intermediate_path.unlink(missing_ok=True)
|
||||
continue
|
||||
|
||||
try:
|
||||
if is_flac:
|
||||
logger.info(f"Demuxing FLAC from MP4 container: {intermediate_path} -> {out_path}")
|
||||
self._demux_flac(intermediate_path, out_path)
|
||||
intermediate_path.unlink(missing_ok=True)
|
||||
final_size = out_path.stat().st_size if out_path.exists() else 0
|
||||
else:
|
||||
final_size = intermediate_path.stat().st_size if intermediate_path.exists() else 0
|
||||
|
||||
if final_size < MIN_AUDIO_SIZE:
|
||||
logger.warning(f"Final file too small after processing at {q_key} "
|
||||
f"({final_size} bytes), trying next")
|
||||
out_path.unlink(missing_ok=True)
|
||||
continue
|
||||
|
||||
logger.info(f"HiFi download complete ({q_key}): {out_path} "
|
||||
f"({downloaded / (1024*1024):.1f} MB)")
|
||||
f"({final_size / (1024*1024):.1f} MB)")
|
||||
return str(out_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Post-processing failed at quality {q_key}: {e}")
|
||||
out_path.unlink(missing_ok=True)
|
||||
intermediate_path.unlink(missing_ok=True)
|
||||
continue
|
||||
|
||||
logger.error(f"All quality tiers exhausted for '{display_name}'")
|
||||
return None
|
||||
|
||||
# ===================== Status / Cancel / Clear =====================
|
||||
def _download_segment_with_retry(self, url: str) -> bytes:
|
||||
"""Download a single HLS segment with 3 retries and 2s fixed backoff."""
|
||||
last_error = None
|
||||
for attempt in range(4):
|
||||
try:
|
||||
resp = self.session.get(url, allow_redirects=True, timeout=30)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
except http_requests.exceptions.HTTPError as e:
|
||||
status = e.response.status_code if e.response is not None else 0
|
||||
if 400 <= status < 500:
|
||||
raise
|
||||
last_error = e
|
||||
except (http_requests.exceptions.Timeout,
|
||||
http_requests.exceptions.ConnectionError) as e:
|
||||
last_error = e
|
||||
|
||||
if attempt < 3:
|
||||
if self.shutdown_check and self.shutdown_check():
|
||||
raise RuntimeError("Shutdown requested")
|
||||
logger.warning(f"Segment download failed (attempt {attempt + 1}/4), "
|
||||
f"retrying in 2s: {url}")
|
||||
time.sleep(2)
|
||||
|
||||
raise last_error
|
||||
|
||||
def _update_download_progress(self, download_id: str, downloaded: int,
|
||||
segments_completed: int, total_segments: int,
|
||||
speed_start: float):
|
||||
with self._download_lock:
|
||||
if download_id not in self.active_downloads:
|
||||
return
|
||||
info = self.active_downloads[download_id]
|
||||
info['transferred'] = downloaded
|
||||
|
||||
now = time.time()
|
||||
elapsed_total = now - speed_start
|
||||
speed = int(downloaded / elapsed_total) if elapsed_total > 0 else 0
|
||||
info['speed'] = speed
|
||||
|
||||
if total_segments > 0:
|
||||
progress = (segments_completed / total_segments) * 100
|
||||
info['progress'] = round(min(progress, 99.9), 1)
|
||||
|
||||
time_remaining = None
|
||||
if speed > 0:
|
||||
remaining_bytes = downloaded * (total_segments / max(segments_completed, 1)) - downloaded
|
||||
if remaining_bytes > 0:
|
||||
time_remaining = int(remaining_bytes / speed)
|
||||
info['time_remaining'] = time_remaining
|
||||
|
||||
async def get_all_downloads(self) -> List[DownloadStatus]:
|
||||
"""Get all active downloads (Soulseek-compatible)."""
|
||||
statuses = []
|
||||
with self._download_lock:
|
||||
for _dl_id, info in self.active_downloads.items():
|
||||
|
|
@ -684,7 +799,6 @@ class HiFiClient:
|
|||
return statuses
|
||||
|
||||
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
||||
"""Get status of a specific download."""
|
||||
with self._download_lock:
|
||||
info = self.active_downloads.get(download_id)
|
||||
if not info:
|
||||
|
|
@ -704,7 +818,6 @@ class HiFiClient:
|
|||
|
||||
async def cancel_download(self, download_id: str, username: str = None,
|
||||
remove: bool = False) -> bool:
|
||||
"""Cancel an active download."""
|
||||
with self._download_lock:
|
||||
if download_id not in self.active_downloads:
|
||||
return False
|
||||
|
|
@ -714,7 +827,6 @@ class HiFiClient:
|
|||
return True
|
||||
|
||||
async def clear_all_completed_downloads(self) -> bool:
|
||||
"""Clear all terminal downloads."""
|
||||
with self._download_lock:
|
||||
to_remove = [
|
||||
did for did, info in self.active_downloads.items()
|
||||
|
|
|
|||
1
core/imports/__init__.py
Normal file
1
core/imports/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Import flow helpers package."""
|
||||
478
core/imports/album.py
Normal file
478
core/imports/album.py
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
"""Album import helpers for staging matching and post-processing context."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set
|
||||
|
||||
from core.imports.context import normalize_import_context
|
||||
from core.imports.staging import collect_staging_files
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
|
||||
logger = get_logger("imports.album")
|
||||
|
||||
def get_client_for_source(source: str):
|
||||
from core.metadata_service import get_client_for_source as _get_client_for_source
|
||||
|
||||
return _get_client_for_source(source)
|
||||
|
||||
|
||||
def get_artist_album_tracks(
|
||||
album_id: str,
|
||||
artist_name: str = "",
|
||||
album_name: str = "",
|
||||
source: Optional[str] = None,
|
||||
):
|
||||
from core.metadata_service import get_artist_album_tracks as _get_artist_album_tracks
|
||||
|
||||
return _get_artist_album_tracks(
|
||||
album_id,
|
||||
artist_name=artist_name,
|
||||
album_name=album_name,
|
||||
source_override=source,
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
from core.matching_engine import MusicMatchingEngine
|
||||
_MATCHING_ENGINE_IMPORT_ERROR = None
|
||||
except Exception as exc: # pragma: no cover - only hits in stripped-down environments
|
||||
MusicMatchingEngine = None # type: ignore[assignment]
|
||||
_MATCHING_ENGINE_IMPORT_ERROR = exc
|
||||
|
||||
|
||||
_MATCHING_ENGINE = None
|
||||
|
||||
|
||||
def _get_matching_engine() -> Any:
|
||||
global _MATCHING_ENGINE
|
||||
if _MATCHING_ENGINE is None:
|
||||
if MusicMatchingEngine is None:
|
||||
raise RuntimeError("Music matching engine is unavailable") from _MATCHING_ENGINE_IMPORT_ERROR
|
||||
_MATCHING_ENGINE = MusicMatchingEngine()
|
||||
return _MATCHING_ENGINE
|
||||
|
||||
|
||||
def _normalize_artist_entries(artists: Any) -> List[Dict[str, Any]]:
|
||||
if not artists:
|
||||
return []
|
||||
|
||||
if isinstance(artists, (str, bytes)):
|
||||
artists = [artists]
|
||||
elif isinstance(artists, dict):
|
||||
artists = [artists]
|
||||
else:
|
||||
try:
|
||||
artists = list(artists)
|
||||
except TypeError:
|
||||
artists = [artists]
|
||||
|
||||
normalized: List[Dict[str, Any]] = []
|
||||
for artist in artists:
|
||||
if isinstance(artist, dict):
|
||||
entry: Dict[str, Any] = {}
|
||||
name = artist.get("name") or artist.get("artist_name") or artist.get("title") or ""
|
||||
artist_id = artist.get("id") or artist.get("artist_id") or ""
|
||||
if name:
|
||||
entry["name"] = str(name)
|
||||
if artist_id:
|
||||
entry["id"] = str(artist_id)
|
||||
genres = artist.get("genres")
|
||||
if genres is not None:
|
||||
entry["genres"] = genres
|
||||
if entry:
|
||||
normalized.append(entry)
|
||||
continue
|
||||
|
||||
name = str(artist).strip()
|
||||
if name:
|
||||
normalized.append({"name": name})
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_album_source(album: Dict[str, Any], source: str = "") -> str:
|
||||
album_source = source or album.get("source") or ""
|
||||
return str(album_source).strip().lower()
|
||||
|
||||
|
||||
def _strip_legacy_source_fields(payload: Any) -> Any:
|
||||
if not isinstance(payload, dict):
|
||||
return payload
|
||||
|
||||
cleaned = dict(payload)
|
||||
cleaned.pop("_source", None)
|
||||
cleaned.pop("provider", None)
|
||||
return cleaned
|
||||
|
||||
|
||||
def _extract_track_artist_name(track: Dict[str, Any]) -> str:
|
||||
artists = track.get("artists") or []
|
||||
if isinstance(artists, (str, bytes)):
|
||||
artists = [artists]
|
||||
elif isinstance(artists, dict):
|
||||
artists = [artists]
|
||||
else:
|
||||
try:
|
||||
artists = list(artists)
|
||||
except TypeError:
|
||||
artists = [artists]
|
||||
|
||||
if not artists:
|
||||
return ""
|
||||
|
||||
first = artists[0]
|
||||
if isinstance(first, dict):
|
||||
return str(first.get("name") or first.get("artist_name") or first.get("title") or "").strip()
|
||||
return str(first or "").strip()
|
||||
|
||||
|
||||
def _coerce_track_int(value: Any, default: int = 1) -> int:
|
||||
if value in (None, ""):
|
||||
return default
|
||||
try:
|
||||
return int(str(value).split("/")[0].strip() or default)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _normalize_match_track(track: Dict[str, Any], source: str, album: Dict[str, Any]) -> Dict[str, Any]:
|
||||
track_album = track.get("album") if isinstance(track.get("album"), dict) else album
|
||||
if isinstance(track_album, dict):
|
||||
track_album = _strip_legacy_source_fields(track_album)
|
||||
track_source = _normalize_album_source(track, source)
|
||||
track_artists = _normalize_artist_entries(track.get("artists") or [])
|
||||
|
||||
if not track_artists and album.get("artists"):
|
||||
track_artists = _normalize_artist_entries(album.get("artists"))
|
||||
|
||||
return {
|
||||
"id": track.get("id", ""),
|
||||
"name": track.get("name", "Unknown Track"),
|
||||
"track_number": _coerce_track_int(track.get("track_number", 1), default=1),
|
||||
"disc_number": _coerce_track_int(track.get("disc_number", 1), default=1),
|
||||
"duration_ms": _coerce_track_int(track.get("duration_ms", 0), default=0),
|
||||
"artists": track_artists,
|
||||
"uri": track.get("uri", ""),
|
||||
"album": track_album,
|
||||
"source": track_source,
|
||||
}
|
||||
|
||||
|
||||
def _score_album_track_match(track: Dict[str, Any], staging_file: Dict[str, Any], album_name: str) -> float:
|
||||
engine = _get_matching_engine()
|
||||
|
||||
track_name = track.get("name", "")
|
||||
staging_title = staging_file.get("title", "")
|
||||
score = 0.0
|
||||
|
||||
title_sim = engine.similarity_score(
|
||||
engine.normalize_string(track_name),
|
||||
engine.normalize_string(staging_title or ""),
|
||||
)
|
||||
score += title_sim * 0.45
|
||||
|
||||
track_artist_name = _extract_track_artist_name(track)
|
||||
staging_artist = staging_file.get("artist") or ""
|
||||
if track_artist_name and staging_artist:
|
||||
artist_sim = engine.similarity_score(
|
||||
engine.normalize_string(track_artist_name),
|
||||
engine.normalize_string(staging_artist),
|
||||
)
|
||||
score += artist_sim * 0.15
|
||||
else:
|
||||
score += 0.075
|
||||
|
||||
track_number = _coerce_track_int(track.get("track_number", 1), default=1)
|
||||
staging_track_number = _coerce_track_int(staging_file.get("track_number", 1), default=1)
|
||||
if staging_track_number and track_number:
|
||||
if staging_track_number == track_number:
|
||||
score += 0.30
|
||||
elif abs(staging_track_number - track_number) <= 1:
|
||||
score += 0.12
|
||||
|
||||
staging_album = staging_file.get("album") or ""
|
||||
if staging_album and album_name:
|
||||
album_sim = engine.similarity_score(
|
||||
engine.normalize_string(staging_album),
|
||||
engine.normalize_string(album_name),
|
||||
)
|
||||
score += album_sim * 0.10
|
||||
|
||||
return score
|
||||
|
||||
|
||||
def _fetch_artist_data_for_source(client: Any, artist_id: str, source: str) -> Any:
|
||||
if source == "spotify":
|
||||
try:
|
||||
return client.get_artist(artist_id, allow_fallback=False)
|
||||
except TypeError:
|
||||
return client.get_artist(artist_id)
|
||||
return client.get_artist(artist_id)
|
||||
|
||||
|
||||
def resolve_album_artist_context(album: Dict[str, Any], source: str = "") -> Dict[str, Any]:
|
||||
"""Build a neutral artist context for album import processing."""
|
||||
album = dict(album or {})
|
||||
source = _normalize_album_source(album, source)
|
||||
|
||||
artists = _normalize_artist_entries(album.get("artists") or [])
|
||||
if not artists:
|
||||
artist_name = album.get("artist") or album.get("artist_name") or ""
|
||||
artist_id = album.get("artist_id") or ""
|
||||
if artist_name or artist_id:
|
||||
artist_entry: Dict[str, Any] = {}
|
||||
if artist_name:
|
||||
artist_entry["name"] = str(artist_name)
|
||||
if artist_id:
|
||||
artist_entry["id"] = str(artist_id)
|
||||
artists = [artist_entry]
|
||||
|
||||
primary_artist = artists[0] if artists else {}
|
||||
artist_name = str(
|
||||
primary_artist.get("name")
|
||||
or album.get("artist")
|
||||
or album.get("artist_name")
|
||||
or "Unknown Artist"
|
||||
).strip()
|
||||
artist_id = str(primary_artist.get("id") or album.get("artist_id") or "").strip()
|
||||
|
||||
genres: List[Any] = []
|
||||
if artist_id and source:
|
||||
client = get_client_for_source(source)
|
||||
if client and hasattr(client, "get_artist"):
|
||||
try:
|
||||
artist_data = _fetch_artist_data_for_source(client, artist_id, source)
|
||||
raw_genres = artist_data.get("genres") if isinstance(artist_data, dict) else getattr(artist_data, "genres", [])
|
||||
if isinstance(raw_genres, str):
|
||||
genres = [raw_genres]
|
||||
elif raw_genres:
|
||||
try:
|
||||
genres = list(raw_genres)
|
||||
except TypeError:
|
||||
genres = [raw_genres]
|
||||
except Exception as exc:
|
||||
logger.debug("Could not resolve artist genres for %s on %s: %s", artist_id, source, exc)
|
||||
|
||||
return {
|
||||
"id": artist_id,
|
||||
"name": artist_name,
|
||||
"genres": genres,
|
||||
"source": source,
|
||||
}
|
||||
|
||||
|
||||
def build_album_import_context(
|
||||
album: Dict[str, Any],
|
||||
track: Dict[str, Any],
|
||||
*,
|
||||
artist_context: Optional[Dict[str, Any]] = None,
|
||||
total_discs: int = 1,
|
||||
source: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""Build a neutral post-processing context for one album track."""
|
||||
album = dict(album or {})
|
||||
track = dict(track or {})
|
||||
source = _normalize_album_source(album, source)
|
||||
|
||||
album_artists = _normalize_artist_entries(album.get("artists") or [])
|
||||
if not album_artists and artist_context:
|
||||
album_artists = _normalize_artist_entries([artist_context])
|
||||
|
||||
if artist_context:
|
||||
artist_ctx = dict(artist_context)
|
||||
else:
|
||||
artist_ctx = resolve_album_artist_context(album, source)
|
||||
|
||||
artist_ctx = _strip_legacy_source_fields(artist_ctx)
|
||||
artist_ctx.setdefault("genres", [])
|
||||
artist_ctx.setdefault("source", source)
|
||||
artist_ctx["genres"] = artist_ctx.get("genres") or []
|
||||
|
||||
track_artists = _normalize_artist_entries(track.get("artists") or [])
|
||||
if not track_artists:
|
||||
track_artists = album_artists or [artist_ctx]
|
||||
|
||||
track_album_value = track.get("album")
|
||||
if isinstance(track_album_value, dict):
|
||||
track_album_name = (
|
||||
track_album_value.get("name")
|
||||
or track_album_value.get("title")
|
||||
or album.get("name")
|
||||
or album.get("album_name")
|
||||
or ""
|
||||
)
|
||||
track_album_id = str(track_album_value.get("id") or track_album_value.get("album_id") or "").strip()
|
||||
track_album_type = track_album_value.get("album_type") or album.get("album_type") or "album"
|
||||
track_album_release = track_album_value.get("release_date") or album.get("release_date") or ""
|
||||
track_album_image = track_album_value.get("image_url") or album.get("image_url") or ""
|
||||
else:
|
||||
track_album_name = str(track_album_value or album.get("name") or album.get("album_name") or "").strip()
|
||||
track_album_id = str(album.get("id") or album.get("album_id") or "").strip()
|
||||
track_album_type = album.get("album_type") or "album"
|
||||
track_album_release = album.get("release_date") or ""
|
||||
track_album_image = album.get("image_url") or ""
|
||||
|
||||
album_name = str(album.get("name") or album.get("album_name") or track_album_name or "Unknown Album").strip()
|
||||
artist_name = str(
|
||||
artist_ctx.get("name")
|
||||
or album.get("artist")
|
||||
or album.get("artist_name")
|
||||
or "Unknown Artist"
|
||||
).strip()
|
||||
|
||||
track_number = _coerce_track_int(track.get("track_number", 1), default=1)
|
||||
disc_number = _coerce_track_int(track.get("disc_number", 1), default=1)
|
||||
|
||||
normalized_track = {
|
||||
"id": str(track.get("id") or track.get("track_id") or "").strip(),
|
||||
"name": str(track.get("name") or "Unknown Track").strip(),
|
||||
"track_number": track_number,
|
||||
"disc_number": disc_number,
|
||||
"duration_ms": _coerce_track_int(track.get("duration_ms", 0), default=0),
|
||||
"artists": track_artists,
|
||||
"uri": str(track.get("uri") or "").strip(),
|
||||
"album": track_album_name,
|
||||
"album_id": track_album_id,
|
||||
"album_type": track_album_type,
|
||||
"release_date": track_album_release,
|
||||
"source": source,
|
||||
}
|
||||
|
||||
normalized_album = {
|
||||
"id": str(album.get("id") or album.get("album_id") or track_album_id or "").strip(),
|
||||
"name": album_name,
|
||||
"artist": artist_name,
|
||||
"artist_name": artist_name,
|
||||
"artist_id": str(artist_ctx.get("id") or album.get("artist_id") or "").strip(),
|
||||
"artists": album_artists,
|
||||
"release_date": str(album.get("release_date") or track_album_release or "").strip(),
|
||||
"total_tracks": int(album.get("total_tracks") or track.get("total_tracks") or 0) or 1,
|
||||
"total_discs": int(total_discs or 1) if str(total_discs or 1).isdigit() else total_discs or 1,
|
||||
"album_type": str(album.get("album_type") or track_album_type or "album").strip() or "album",
|
||||
"image_url": str(album.get("image_url") or track_album_image or "").strip(),
|
||||
"images": album.get("images") or ([] if not track_album_image else [{"url": track_album_image}]),
|
||||
"source": source,
|
||||
}
|
||||
|
||||
original_search = {
|
||||
"title": normalized_track["name"],
|
||||
"artist": artist_name,
|
||||
"album": album_name,
|
||||
"track_number": track_number,
|
||||
"disc_number": disc_number,
|
||||
"clean_title": normalized_track["name"],
|
||||
"clean_album": album_name,
|
||||
"clean_artist": artist_name,
|
||||
"artists": track_artists,
|
||||
"duration_ms": normalized_track["duration_ms"],
|
||||
"id": normalized_track["id"],
|
||||
"source": source,
|
||||
}
|
||||
|
||||
context = {
|
||||
"artist": artist_ctx,
|
||||
"album": normalized_album,
|
||||
"track_info": normalized_track,
|
||||
"original_search_result": original_search,
|
||||
"is_album_download": True,
|
||||
"has_clean_metadata": bool(normalized_track["id"]),
|
||||
"has_full_metadata": bool(normalized_track["id"]),
|
||||
"source": source,
|
||||
}
|
||||
|
||||
normalized_context = normalize_import_context(context)
|
||||
normalized_context["artist"] = _strip_legacy_source_fields(normalized_context.get("artist"))
|
||||
normalized_context["album"] = _strip_legacy_source_fields(normalized_context.get("album"))
|
||||
normalized_context["track_info"] = _strip_legacy_source_fields(normalized_context.get("track_info"))
|
||||
normalized_context["original_search_result"] = _strip_legacy_source_fields(normalized_context.get("original_search_result"))
|
||||
return normalized_context
|
||||
|
||||
|
||||
def build_album_import_match_payload(
|
||||
album_id: str,
|
||||
*,
|
||||
album_name: str = "",
|
||||
album_artist: str = "",
|
||||
file_paths: Optional[Iterable[str]] = None,
|
||||
source: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the album import match payload using provider-priority metadata lookup."""
|
||||
album_response = get_artist_album_tracks(
|
||||
album_id,
|
||||
artist_name=album_artist,
|
||||
album_name=album_name,
|
||||
source=source,
|
||||
)
|
||||
|
||||
album = _strip_legacy_source_fields(dict(album_response.get("album") or {}))
|
||||
source = _normalize_album_source(album, album_response.get("source") or source or "")
|
||||
tracks = list(album_response.get("tracks") or [])
|
||||
if not album_response.get("success") or not tracks:
|
||||
return {
|
||||
"success": False,
|
||||
"error": album_response.get("error", "Album not found"),
|
||||
"status_code": album_response.get("status_code", 404),
|
||||
"album": {
|
||||
"id": album_id,
|
||||
"name": album_name or album_id,
|
||||
"artist": album_artist or "Unknown Artist",
|
||||
"artist_name": album_artist or "Unknown Artist",
|
||||
"artist_id": "",
|
||||
"artists": [],
|
||||
"release_date": "",
|
||||
"total_tracks": 0,
|
||||
"total_discs": 1,
|
||||
"album_type": "album",
|
||||
"image_url": "",
|
||||
"images": [],
|
||||
"source": source,
|
||||
},
|
||||
"matches": [],
|
||||
"unmatched_files": [],
|
||||
"source": source,
|
||||
"source_priority": album_response.get("source_priority", []),
|
||||
"resolved_album_id": album_response.get("resolved_album_id") or album_id,
|
||||
}
|
||||
|
||||
staging_files = collect_staging_files(file_paths)
|
||||
album_name_for_match = album.get("name") or album_name or ""
|
||||
matches: List[Dict[str, Any]] = []
|
||||
used_files: Set[int] = set()
|
||||
|
||||
for track in tracks:
|
||||
normalized_track = _normalize_match_track(track, source, album)
|
||||
best_match = None
|
||||
best_score = 0.0
|
||||
|
||||
for index, staging_file in enumerate(staging_files):
|
||||
if index in used_files:
|
||||
continue
|
||||
|
||||
score = _score_album_track_match(normalized_track, staging_file, album_name_for_match)
|
||||
if score > best_score and score >= 0.4:
|
||||
best_score = score
|
||||
best_match = index
|
||||
|
||||
matches.append(
|
||||
{
|
||||
"track": normalized_track,
|
||||
"staging_file": staging_files[best_match] if best_match is not None else None,
|
||||
"confidence": round(best_score, 2) if best_match is not None else 0,
|
||||
}
|
||||
)
|
||||
|
||||
if best_match is not None:
|
||||
used_files.add(best_match)
|
||||
|
||||
unmatched_files = [sf for index, sf in enumerate(staging_files) if index not in used_files]
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"album": album,
|
||||
"matches": matches,
|
||||
"unmatched_files": unmatched_files,
|
||||
"source": source,
|
||||
"source_priority": album_response.get("source_priority", []),
|
||||
"resolved_album_id": album_response.get("resolved_album_id") or album_id,
|
||||
}
|
||||
184
core/imports/album_naming.py
Normal file
184
core/imports/album_naming.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
"""Album naming and grouping helpers used by import flows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.imports.context import extract_artist_name
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
|
||||
logger = get_logger("imports.album_naming")
|
||||
|
||||
_album_cache_lock = threading.Lock()
|
||||
_album_editions: dict[str, str] = {}
|
||||
_album_name_cache: dict[str, str] = {}
|
||||
|
||||
|
||||
def clear_album_grouping_cache() -> None:
|
||||
"""Clear cached album grouping decisions.
|
||||
|
||||
Useful for tests and for any future config reload flows.
|
||||
"""
|
||||
with _album_cache_lock:
|
||||
_album_editions.clear()
|
||||
_album_name_cache.clear()
|
||||
|
||||
|
||||
def get_base_album_name(album_name: str) -> str:
|
||||
"""Extract the base album name without edition indicators."""
|
||||
base_name = album_name or ""
|
||||
base_name = re.sub(
|
||||
r"\s*[\[\(][^)\]]*\b(deluxe|special|expanded|extended|bonus|remaster(?:ed)?|anniversary|collectors?|limited|silver|gold|platinum)\b[^)\]]*[\]\)]\s*$",
|
||||
"",
|
||||
base_name,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
base_name = re.sub(r"\s*[\[\(][^)\]]*\bedition\b[^)\]]*[\]\)]\s*$", "", base_name, flags=re.IGNORECASE)
|
||||
base_name = re.sub(
|
||||
r"\s+(deluxe|special|expanded|extended|bonus|remastered|anniversary|collectors?|limited|silver|gold|platinum)\s*(edition)?\s*$",
|
||||
"",
|
||||
base_name,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
return base_name.strip()
|
||||
|
||||
|
||||
def detect_deluxe_edition(album_name: str) -> bool:
|
||||
"""Detect if an album name indicates a deluxe/special edition."""
|
||||
if not album_name:
|
||||
return False
|
||||
|
||||
album_lower = album_name.lower()
|
||||
deluxe_indicators = [
|
||||
"deluxe",
|
||||
"deluxe edition",
|
||||
"special edition",
|
||||
"expanded edition",
|
||||
"extended edition",
|
||||
"bonus",
|
||||
"remastered",
|
||||
"anniversary",
|
||||
"collectors edition",
|
||||
"limited edition",
|
||||
"silver edition",
|
||||
"gold edition",
|
||||
"platinum edition",
|
||||
]
|
||||
for indicator in deluxe_indicators:
|
||||
if indicator in album_lower:
|
||||
logger.info("Detected deluxe edition: %r contains %r", album_name, indicator)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def normalize_base_album_name(base_album: str, artist_name: str) -> str:
|
||||
"""Normalize the base album name to handle case variations and known corrections."""
|
||||
normalized_lower = (base_album or "").lower().strip()
|
||||
known_corrections = {
|
||||
# Add specific album name corrections here as needed.
|
||||
}
|
||||
|
||||
for variant, correction in known_corrections.items():
|
||||
if normalized_lower == variant.lower():
|
||||
logger.info("Album correction applied: %r -> %r", base_album, correction)
|
||||
return correction
|
||||
|
||||
normalized = base_album or ""
|
||||
normalized = re.sub(r"\s*&\s*", " & ", normalized)
|
||||
normalized = re.sub(r"\s+", " ", normalized)
|
||||
normalized = normalized.strip()
|
||||
logger.info("Album variant normalization: %r -> %r", base_album, normalized)
|
||||
return normalized
|
||||
|
||||
|
||||
def clean_album_title(album_title: str, artist_name: str) -> str:
|
||||
"""Clean up album title by removing common prefixes, suffixes, and artist redundancy."""
|
||||
original = (album_title or "").strip()
|
||||
cleaned = original
|
||||
logger.info("Album Title Cleaning: %r (artist: %r)", original, artist_name)
|
||||
|
||||
cleaned = re.sub(r"^Album\s*-\s*", "", cleaned, flags=re.IGNORECASE)
|
||||
artist_pattern = re.escape(artist_name or "") + r"\s*-\s*"
|
||||
cleaned = re.sub(f"^{artist_pattern}", "", cleaned, flags=re.IGNORECASE)
|
||||
cleaned = re.sub(r"\s*[\[\(]\d{4}[\]\)]\s*", " ", cleaned)
|
||||
|
||||
quality_patterns = [
|
||||
r"\s*[\[\(].*?320.*?kbps.*?[\]\)]\s*",
|
||||
r"\s*[\[\(].*?256.*?kbps.*?[\]\)]\s*",
|
||||
r"\s*[\[\(].*?flac.*?[\]\)]\s*",
|
||||
r"\s*[\[\(].*?mp3.*?[\]\)]\s*",
|
||||
r"\s*[\[\(].*?itunes.*?[\]\)]\s*",
|
||||
r"\s*[\[\(].*?web.*?[\]\)]\s*",
|
||||
r"\s*[\[\(].*?cd.*?[\]\)]\s*",
|
||||
]
|
||||
for pattern in quality_patterns:
|
||||
cleaned = re.sub(pattern, " ", cleaned, flags=re.IGNORECASE)
|
||||
|
||||
cleaned = re.sub(r"\s*[\[\(][^\]\)]*\b(deluxe|special|expanded|extended|bonus|remaster(?:ed)?|anniversary|collectors?|limited|silver|gold|platinum)\b[^\]\)]*[\]\)]\s*", " ", cleaned, flags=re.IGNORECASE)
|
||||
cleaned = re.sub(r"\s*[\[\(][^\]\)]*\bedition\b[^\]\)]*[\]\)]\s*", " ", cleaned, flags=re.IGNORECASE)
|
||||
cleaned = re.sub(r"\s*(deluxe|special|expanded|extended|bonus|remastered|anniversary|collectors?|limited|silver|gold|platinum)\s*(edition)?\s*$", "", cleaned, flags=re.IGNORECASE)
|
||||
cleaned = re.sub(r"^[-\s\.]+", "", cleaned)
|
||||
cleaned = re.sub(r"[-\s\.]+$", "", cleaned)
|
||||
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
||||
return cleaned if cleaned else original
|
||||
|
||||
|
||||
def resolve_album_group(artist_context: dict, album_info: dict, original_album: str = None) -> str:
|
||||
"""Smart album grouping: upgrade to deluxe if any track is deluxe."""
|
||||
try:
|
||||
with _album_cache_lock:
|
||||
artist_name = extract_artist_name(artist_context)
|
||||
detected_album = (album_info or {}).get("album_name", "")
|
||||
|
||||
if detected_album:
|
||||
base_album = get_base_album_name(detected_album)
|
||||
elif original_album:
|
||||
cleaned_original = clean_album_title(original_album, artist_name)
|
||||
base_album = get_base_album_name(cleaned_original)
|
||||
else:
|
||||
base_album = get_base_album_name(detected_album)
|
||||
|
||||
base_album = normalize_base_album_name(base_album, artist_name)
|
||||
album_key = f"{artist_name}::{base_album}"
|
||||
is_deluxe_track = False
|
||||
if detected_album:
|
||||
is_deluxe_track = detect_deluxe_edition(detected_album)
|
||||
elif original_album:
|
||||
is_deluxe_track = detect_deluxe_edition(original_album)
|
||||
|
||||
if album_key in _album_name_cache:
|
||||
cached_name = _album_name_cache[album_key]
|
||||
current_edition = _album_editions.get(album_key, "standard")
|
||||
if is_deluxe_track and current_edition == "standard":
|
||||
final_album_name = f"{base_album} (Deluxe Edition)"
|
||||
_album_editions[album_key] = "deluxe"
|
||||
_album_name_cache[album_key] = final_album_name
|
||||
logger.info("Album cache upgrade: %r -> %r", album_key, final_album_name)
|
||||
return final_album_name
|
||||
logger.info("Using cached album name for %r: %r", album_key, cached_name)
|
||||
return cached_name
|
||||
|
||||
logger.info("Album grouping - Key: %r, Detected: %r", album_key, detected_album)
|
||||
|
||||
current_edition = _album_editions.get(album_key, "standard")
|
||||
if is_deluxe_track and current_edition == "standard":
|
||||
logger.info("UPGRADE: Album %r upgraded from standard to deluxe!", base_album)
|
||||
_album_editions[album_key] = "deluxe"
|
||||
current_edition = "deluxe"
|
||||
|
||||
if current_edition == "deluxe":
|
||||
final_album_name = f"{base_album} (Deluxe Edition)"
|
||||
else:
|
||||
final_album_name = base_album
|
||||
|
||||
_album_name_cache[album_key] = final_album_name
|
||||
|
||||
logger.info("Album resolution: %r -> %r (edition: %s)", detected_album, final_album_name, current_edition)
|
||||
return final_album_name
|
||||
except Exception as e:
|
||||
logger.error("Error resolving album group: %s", e)
|
||||
album_name = (album_info or {}).get("album_name", "Unknown Album")
|
||||
return album_name
|
||||
407
core/imports/context.py
Normal file
407
core/imports/context.py
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
"""Helpers for normalizing and reading import contexts.
|
||||
|
||||
These functions keep the single-import pipeline source-agnostic while still
|
||||
accepting legacy `spotify_*` payloads from older callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
def _as_dict(value: Any) -> Dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _first_value(mapping: Dict[str, Any], *keys: str, default: Any = "") -> Any:
|
||||
for key in keys:
|
||||
if key in mapping:
|
||||
value = mapping.get(key)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def _first_id_value(*values: Any) -> str:
|
||||
for value in values:
|
||||
if value in (None, ""):
|
||||
continue
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def extract_artist_name(artist: Any) -> str:
|
||||
if isinstance(artist, dict):
|
||||
return str(artist.get("name", "") or "")
|
||||
if hasattr(artist, "name"):
|
||||
return str(artist.name or "")
|
||||
return str(artist) if artist else ""
|
||||
|
||||
|
||||
def normalize_import_context(context: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Normalize an import context to neutral fields in place and drop legacy aliases."""
|
||||
if not isinstance(context, dict):
|
||||
return {}
|
||||
|
||||
source = context.get("source") or context.get("_source") or ""
|
||||
artist = _as_dict(context.get("artist") or context.get("spotify_artist"))
|
||||
album = _as_dict(context.get("album") or context.get("spotify_album"))
|
||||
track_info = _as_dict(context.get("track_info"))
|
||||
original_search = _as_dict(context.get("original_search_result"))
|
||||
search_result = _as_dict(context.get("search_result"))
|
||||
normalized_search = original_search or search_result
|
||||
|
||||
if source:
|
||||
context["source"] = source
|
||||
context["artist"] = artist
|
||||
context["album"] = album
|
||||
context["track_info"] = track_info
|
||||
context["original_search_result"] = normalized_search
|
||||
context.pop("_source", None)
|
||||
context.pop("spotify_artist", None)
|
||||
context.pop("spotify_album", None)
|
||||
|
||||
for clean_key, legacy_key in (
|
||||
("clean_title", "spotify_clean_title"),
|
||||
("clean_album", "spotify_clean_album"),
|
||||
("clean_artist", "spotify_clean_artist"),
|
||||
):
|
||||
if clean_key not in normalized_search or normalized_search.get(clean_key) in (None, ""):
|
||||
legacy_value = normalized_search.get(legacy_key)
|
||||
if legacy_value not in (None, ""):
|
||||
normalized_search[clean_key] = legacy_value
|
||||
normalized_search.pop(legacy_key, None)
|
||||
|
||||
has_clean = bool(context.get("has_clean_metadata", context.get("has_clean_spotify_data", False)))
|
||||
has_full = bool(context.get("has_full_metadata", context.get("has_full_spotify_metadata", False)))
|
||||
context["has_clean_metadata"] = has_clean
|
||||
context["has_full_metadata"] = has_full
|
||||
context.pop("has_clean_spotify_data", None)
|
||||
context.pop("has_full_spotify_metadata", None)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def get_import_context_artist(context: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
if not isinstance(context, dict):
|
||||
return {}
|
||||
return _as_dict(context.get("artist") or context.get("spotify_artist"))
|
||||
|
||||
|
||||
def get_import_context_album(context: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
if not isinstance(context, dict):
|
||||
return {}
|
||||
return _as_dict(context.get("album") or context.get("spotify_album"))
|
||||
|
||||
|
||||
def get_import_track_info(context: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
if not isinstance(context, dict):
|
||||
return {}
|
||||
return _as_dict(context.get("track_info"))
|
||||
|
||||
|
||||
def get_import_original_search(context: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
if not isinstance(context, dict):
|
||||
return {}
|
||||
return _as_dict(context.get("original_search_result"))
|
||||
|
||||
|
||||
def get_import_search_result(context: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
if not isinstance(context, dict):
|
||||
return {}
|
||||
return _as_dict(context.get("search_result"))
|
||||
|
||||
|
||||
def get_import_source(context: Optional[Dict[str, Any]]) -> str:
|
||||
if not isinstance(context, dict):
|
||||
return ""
|
||||
|
||||
source = context.get("source")
|
||||
if source:
|
||||
return str(source)
|
||||
|
||||
track_info = get_import_track_info(context)
|
||||
source = _first_value(track_info, "source", default="")
|
||||
if source:
|
||||
return str(source)
|
||||
|
||||
original_search = get_import_original_search(context)
|
||||
source = _first_value(original_search, "source", default="")
|
||||
if source:
|
||||
return str(source)
|
||||
|
||||
album = get_import_context_album(context)
|
||||
source = _first_value(album, "source", default="")
|
||||
if source:
|
||||
return str(source)
|
||||
|
||||
artist = get_import_context_artist(context)
|
||||
source = _first_value(artist, "source", default="")
|
||||
return str(source) if source else ""
|
||||
|
||||
|
||||
def get_import_clean_title(
|
||||
context: Optional[Dict[str, Any]],
|
||||
album_info: Optional[Dict[str, Any]] = None,
|
||||
default: str = "Unknown Track",
|
||||
) -> str:
|
||||
original_search = get_import_original_search(context)
|
||||
title = _first_value(
|
||||
original_search,
|
||||
"clean_title",
|
||||
"title",
|
||||
default="",
|
||||
)
|
||||
if not title and album_info:
|
||||
title = _first_value(album_info, "clean_track_name", "track_name", default="")
|
||||
if not title:
|
||||
track_info = get_import_track_info(context)
|
||||
title = _first_value(track_info, "name", "title", default="")
|
||||
return str(title or default)
|
||||
|
||||
|
||||
def get_import_clean_album(
|
||||
context: Optional[Dict[str, Any]],
|
||||
album_info: Optional[Dict[str, Any]] = None,
|
||||
default: str = "Unknown Album",
|
||||
) -> str:
|
||||
original_search = get_import_original_search(context)
|
||||
album = _first_value(
|
||||
original_search,
|
||||
"clean_album",
|
||||
"album",
|
||||
default="",
|
||||
)
|
||||
if not album and album_info:
|
||||
album = _first_value(album_info, "album_name", "clean_album_name", default="")
|
||||
if not album:
|
||||
album_ctx = get_import_context_album(context)
|
||||
album = _first_value(album_ctx, "name", default="")
|
||||
return str(album or default)
|
||||
|
||||
|
||||
def get_import_clean_artist(context: Optional[Dict[str, Any]], default: str = "Unknown Artist") -> str:
|
||||
original_search = get_import_original_search(context)
|
||||
artist = _first_value(
|
||||
original_search,
|
||||
"clean_artist",
|
||||
"artist",
|
||||
default="",
|
||||
)
|
||||
if not artist:
|
||||
artist_ctx = get_import_context_artist(context)
|
||||
artist = _first_value(artist_ctx, "name", default="")
|
||||
return str(artist or default)
|
||||
|
||||
|
||||
def get_import_has_clean_metadata(context: Optional[Dict[str, Any]]) -> bool:
|
||||
if not isinstance(context, dict):
|
||||
return False
|
||||
return bool(context.get("has_clean_metadata", False))
|
||||
|
||||
|
||||
def get_import_has_full_metadata(context: Optional[Dict[str, Any]]) -> bool:
|
||||
if not isinstance(context, dict):
|
||||
return False
|
||||
return bool(context.get("has_full_metadata", False))
|
||||
|
||||
|
||||
def get_import_source_ids(context: Optional[Dict[str, Any]]) -> Dict[str, str]:
|
||||
track_info = get_import_track_info(context)
|
||||
original_search = get_import_original_search(context)
|
||||
search_result = get_import_search_result(context)
|
||||
artist = get_import_context_artist(context)
|
||||
album = get_import_context_album(context)
|
||||
|
||||
return {
|
||||
"track_id": _first_id_value(
|
||||
_first_value(track_info, "id", "track_id", "trackId", "source_track_id", default=""),
|
||||
_first_value(track_info, "spotify_track_id", "itunes_track_id", "deezer_id", "deezer_track_id", "discogs_id", "soul_id", default=""),
|
||||
_first_value(original_search, "id", "track_id", "source_track_id", default=""),
|
||||
_first_value(original_search, "spotify_track_id", "itunes_track_id", "deezer_id", "deezer_track_id", "discogs_id", "soul_id", default=""),
|
||||
_first_value(search_result, "id", "track_id", "source_track_id", default=""),
|
||||
_first_value(search_result, "spotify_track_id", "itunes_track_id", "deezer_id", "deezer_track_id", "discogs_id", "soul_id", default=""),
|
||||
),
|
||||
"artist_id": _first_id_value(
|
||||
_first_value(artist, "id", "artist_id", "source_artist_id", default=""),
|
||||
_first_value(artist, "spotify_artist_id", "itunes_artist_id", "deezer_id", "deezer_artist_id", "discogs_id", "soul_id", default=""),
|
||||
_first_value(original_search, "artist_id", "source_artist_id", default=""),
|
||||
_first_value(original_search, "spotify_artist_id", "itunes_artist_id", "deezer_id", "deezer_artist_id", "discogs_id", "soul_id", default=""),
|
||||
_first_value(search_result, "artist_id", "source_artist_id", default=""),
|
||||
_first_value(search_result, "spotify_artist_id", "itunes_artist_id", "deezer_id", "deezer_artist_id", "discogs_id", "soul_id", default=""),
|
||||
),
|
||||
"album_id": _first_id_value(
|
||||
_first_value(album, "id", "album_id", "collectionId", "source_album_id", default=""),
|
||||
_first_value(album, "spotify_album_id", "itunes_album_id", "deezer_id", "deezer_album_id", "discogs_id", "soul_id", "album_soul_id", "hydrabase_album_id", default=""),
|
||||
_first_value(original_search, "album_id", "source_album_id", default=""),
|
||||
_first_value(original_search, "spotify_album_id", "itunes_album_id", "deezer_id", "deezer_album_id", "discogs_id", "soul_id", "album_soul_id", "hydrabase_album_id", default=""),
|
||||
_first_value(track_info, "album_id", "source_album_id", default=""),
|
||||
_first_value(track_info, "spotify_album_id", "itunes_album_id", "deezer_id", "deezer_album_id", "discogs_id", "soul_id", "album_soul_id", "hydrabase_album_id", default=""),
|
||||
_first_value(search_result, "album_id", "source_album_id", default=""),
|
||||
_first_value(search_result, "spotify_album_id", "itunes_album_id", "deezer_id", "deezer_album_id", "discogs_id", "soul_id", "album_soul_id", "hydrabase_album_id", default=""),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_source_tag_names(source: str) -> Dict[str, Optional[str]]:
|
||||
source_name = (source or "").strip().lower()
|
||||
if source_name == "spotify":
|
||||
return {"track": "SPOTIFY_TRACK_ID", "artist": "SPOTIFY_ARTIST_ID", "album": "SPOTIFY_ALBUM_ID"}
|
||||
if source_name == "itunes":
|
||||
return {"track": "ITUNES_TRACK_ID", "artist": "ITUNES_ARTIST_ID", "album": "ITUNES_ALBUM_ID"}
|
||||
if source_name == "deezer":
|
||||
return {"track": "DEEZER_TRACK_ID", "artist": "DEEZER_ARTIST_ID", "album": None}
|
||||
if source_name == "hydrabase":
|
||||
return {"track": None, "artist": None, "album": None}
|
||||
if source_name == "discogs":
|
||||
return {"track": None, "artist": None, "album": None}
|
||||
return {"track": None, "artist": None, "album": None}
|
||||
|
||||
|
||||
def get_library_source_id_columns(source: str) -> Dict[str, Optional[str]]:
|
||||
source_name = (source or "").strip().lower()
|
||||
if source_name == "spotify":
|
||||
return {"artist": "spotify_artist_id", "album": "spotify_album_id", "track": "spotify_track_id"}
|
||||
if source_name == "itunes":
|
||||
return {"artist": "itunes_artist_id", "album": "itunes_album_id", "track": "itunes_track_id"}
|
||||
if source_name == "deezer":
|
||||
return {"artist": "deezer_id", "album": "deezer_id", "track": "deezer_id"}
|
||||
if source_name == "hydrabase":
|
||||
return {"artist": "soul_id", "album": "soul_id", "track": "soul_id", "track_album": "album_soul_id"}
|
||||
if source_name == "discogs":
|
||||
return {"artist": "discogs_id", "album": "discogs_id", "track": None}
|
||||
return {}
|
||||
|
||||
|
||||
def build_import_album_info(
|
||||
context: Optional[Dict[str, Any]],
|
||||
*,
|
||||
album_info: Optional[Dict[str, Any]] = None,
|
||||
force_album: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the album-info payload used by post-processing."""
|
||||
album_ctx = get_import_context_album(context)
|
||||
track_info = get_import_track_info(context)
|
||||
original_search = get_import_original_search(context)
|
||||
artist_ctx = get_import_context_artist(context)
|
||||
|
||||
track_number = (
|
||||
(album_info or {}).get("track_number")
|
||||
or track_info.get("track_number")
|
||||
or original_search.get("track_number")
|
||||
or 1
|
||||
)
|
||||
disc_number = (
|
||||
(album_info or {}).get("disc_number")
|
||||
or track_info.get("disc_number")
|
||||
or original_search.get("disc_number")
|
||||
or 1
|
||||
)
|
||||
|
||||
clean_track_name = get_import_clean_title(context, album_info=album_info, default=original_search.get("title", "Unknown Track"))
|
||||
album_name = get_import_clean_album(context, album_info=album_info, default=original_search.get("album", "Unknown Album"))
|
||||
album_image_url = (
|
||||
(album_info or {}).get("album_image_url")
|
||||
or album_ctx.get("image_url")
|
||||
or ""
|
||||
)
|
||||
total_tracks = (
|
||||
album_ctx.get("total_tracks")
|
||||
or track_info.get("total_tracks")
|
||||
or (album_info or {}).get("total_tracks")
|
||||
or 0
|
||||
)
|
||||
album_type = (album_ctx.get("album_type") or track_info.get("album_type") or "album")
|
||||
source = get_import_source(context)
|
||||
|
||||
artist_name = artist_ctx.get("name") or original_search.get("artist") or get_import_clean_artist(context)
|
||||
normalized_album = str(album_name or "").strip().lower()
|
||||
normalized_title = str(clean_track_name or "").strip().lower()
|
||||
normalized_artist = str(artist_name or "").strip().lower()
|
||||
|
||||
# Route through album_path when the metadata source has explicitly
|
||||
# identified the release type (single / EP / compilation). The
|
||||
# ``total_tracks > 1`` heuristic below catches normal multi-track
|
||||
# albums even without explicit type info, but it can't catch
|
||||
# singles (1 track, album name often equal to title) so they
|
||||
# used to fall through to single_path — which doesn't honour the
|
||||
# ``$albumtype`` template variable. Result: users with a
|
||||
# ``${albumtype}s/...`` template saw an "Albums" folder and never
|
||||
# any "Singles" or "EPs" folder. ``"album"`` is excluded from this
|
||||
# check because it's the default fallback when album_type is
|
||||
# missing — only treat values that came from a real source as
|
||||
# explicit.
|
||||
explicit_release_type = (album_type or "").strip().lower() in ("single", "ep", "compilation")
|
||||
|
||||
is_album = bool(
|
||||
force_album
|
||||
or explicit_release_type
|
||||
or (
|
||||
normalized_album
|
||||
and total_tracks
|
||||
and int(total_tracks) > 1
|
||||
and normalized_album != normalized_title
|
||||
and normalized_album != normalized_artist
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"is_album": is_album,
|
||||
"album_name": album_name,
|
||||
"track_number": int(track_number) if str(track_number).isdigit() else track_number,
|
||||
"disc_number": int(disc_number) if str(disc_number).isdigit() else disc_number,
|
||||
"clean_track_name": clean_track_name,
|
||||
"album_image_url": album_image_url,
|
||||
"confidence": (album_info or {}).get("confidence", 1.0 if is_album or force_album else 0.0),
|
||||
"source": source,
|
||||
"album_type": album_type,
|
||||
"total_tracks": int(total_tracks) if str(total_tracks).isdigit() else total_tracks,
|
||||
}
|
||||
|
||||
|
||||
def detect_album_info_web(context, artist_context=None):
|
||||
"""Best-effort album detection for single-track downloads."""
|
||||
context = normalize_import_context(context)
|
||||
if artist_context is None:
|
||||
artist_context = context.get("artist") or {}
|
||||
|
||||
album_info = build_import_album_info(context)
|
||||
if album_info.get("is_album"):
|
||||
return album_info
|
||||
|
||||
album_ctx = get_import_context_album(context)
|
||||
track_info = get_import_track_info(context)
|
||||
original_search = get_import_original_search(context)
|
||||
|
||||
album_name = (
|
||||
album_ctx.get("name")
|
||||
or track_info.get("album")
|
||||
or original_search.get("album")
|
||||
or ""
|
||||
)
|
||||
track_name = (
|
||||
track_info.get("name")
|
||||
or original_search.get("title")
|
||||
or ""
|
||||
)
|
||||
artist_name = extract_artist_name(artist_context) or get_import_clean_artist(context, default="")
|
||||
|
||||
if album_name and track_name and album_name.strip().lower() not in {
|
||||
track_name.strip().lower(),
|
||||
artist_name.strip().lower(),
|
||||
}:
|
||||
return build_import_album_info(
|
||||
context,
|
||||
album_info={
|
||||
"album_name": album_name,
|
||||
"track_number": track_info.get("track_number", 1),
|
||||
"disc_number": track_info.get("disc_number", 1),
|
||||
"album_image_url": album_ctx.get("image_url", ""),
|
||||
"confidence": 0.5,
|
||||
},
|
||||
force_album=True,
|
||||
)
|
||||
|
||||
return None
|
||||
456
core/imports/file_ops.py
Normal file
456
core/imports/file_ops.py
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
"""File operation helpers for the import flow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List
|
||||
|
||||
from config.settings import config_manager
|
||||
|
||||
logger = logging.getLogger("imports.file_ops")
|
||||
|
||||
|
||||
# slskd appends "_<19-digit unix-nanosecond timestamp>" to a downloaded
|
||||
# filename when the destination already contains a file with the same
|
||||
# name (concurrent downloads of the same track, partial-file retries
|
||||
# after a connection drop, cancelled-then-redownloaded files, the same
|
||||
# track surfacing in multiple synced playlists, etc.). The original
|
||||
# canonical file usually gets imported and moved into the library while
|
||||
# the timestamp-suffixed siblings sit orphaned in the downloads folder
|
||||
# forever. Match the suffix conservatively (≥ 18 digits) so genuine
|
||||
# user filenames containing trailing numbers don't get hit.
|
||||
_SLSKD_DEDUP_SUFFIX_RE = re.compile(r"_\d{18,}$")
|
||||
|
||||
|
||||
def _strip_slskd_dedup_suffix(stem: str) -> str:
|
||||
"""Return the canonical stem with any slskd dedup suffix removed."""
|
||||
return _SLSKD_DEDUP_SUFFIX_RE.sub("", stem)
|
||||
|
||||
|
||||
def cleanup_slskd_dedup_siblings(source_path) -> List[str]:
|
||||
"""Remove orphan ``<basename>_<timestamp>.<ext>`` siblings of a just-
|
||||
imported file from the source directory.
|
||||
|
||||
Call this AFTER a successful import (the canonical file has already
|
||||
moved away) using the path the canonical file came from. Looks at
|
||||
siblings in the same directory whose stem, with the slskd dedup
|
||||
suffix stripped, equals the imported file's canonical stem and the
|
||||
same extension. Deletes them.
|
||||
|
||||
Returns the list of deleted paths so the caller can log a summary.
|
||||
Failures (permissions, racing reader, etc.) are swallowed
|
||||
individually so a single locked file doesn't block the rest of the
|
||||
cleanup.
|
||||
"""
|
||||
source = Path(source_path)
|
||||
parent = source.parent
|
||||
if not parent.is_dir():
|
||||
return []
|
||||
|
||||
canonical_name = source.name
|
||||
canonical_stem, canonical_ext = os.path.splitext(canonical_name)
|
||||
# If the imported file ITSELF already had a dedup suffix, the
|
||||
# "canonical" name is the stripped form — every other sibling that
|
||||
# also strips down to it is redundant.
|
||||
canonical_stem = _strip_slskd_dedup_suffix(canonical_stem)
|
||||
|
||||
deleted: List[str] = []
|
||||
try:
|
||||
children: Iterable[Path] = list(parent.iterdir())
|
||||
except OSError as e:
|
||||
logger.debug(f"[Dedup Cleanup] could not list {parent}: {e}")
|
||||
return []
|
||||
|
||||
for sibling in children:
|
||||
if not sibling.is_file():
|
||||
continue
|
||||
# Skip the imported file itself if it's still on disk (it
|
||||
# shouldn't be — caller invokes us after the move — but the
|
||||
# check is cheap and keeps the function safe to call from
|
||||
# other contexts later).
|
||||
if sibling.name == canonical_name:
|
||||
continue
|
||||
sib_stem, sib_ext = os.path.splitext(sibling.name)
|
||||
if sib_ext.lower() != canonical_ext.lower():
|
||||
continue
|
||||
sib_canonical_stem = _strip_slskd_dedup_suffix(sib_stem)
|
||||
if sib_canonical_stem != canonical_stem:
|
||||
continue
|
||||
# Defensive: don't delete a file that doesn't actually carry
|
||||
# the slskd dedup suffix — that would imply it's a legitimate
|
||||
# different file the user intentionally placed there.
|
||||
if sib_stem == sib_canonical_stem:
|
||||
continue
|
||||
try:
|
||||
sibling.unlink()
|
||||
deleted.append(str(sibling))
|
||||
except OSError as e:
|
||||
logger.debug(f"[Dedup Cleanup] could not remove {sibling}: {e}")
|
||||
|
||||
if deleted:
|
||||
logger.info(
|
||||
"[Dedup Cleanup] removed %d slskd dedup orphan(s) for %r",
|
||||
len(deleted),
|
||||
canonical_name,
|
||||
)
|
||||
return deleted
|
||||
|
||||
|
||||
def safe_move_file(src, dst):
|
||||
"""Move a file safely across filesystems."""
|
||||
src = Path(src)
|
||||
dst = Path(dst)
|
||||
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not src.exists():
|
||||
if dst.exists():
|
||||
logger.info(f"Source gone but destination exists, file already transferred: {dst.name}")
|
||||
return
|
||||
raise FileNotFoundError(f"Source file not found and destination does not exist: {src}")
|
||||
|
||||
if dst.exists():
|
||||
for _attempt in range(3):
|
||||
try:
|
||||
dst.unlink()
|
||||
break
|
||||
except PermissionError:
|
||||
if _attempt < 2:
|
||||
time.sleep(1)
|
||||
else:
|
||||
logger.warning(f"Could not remove locked destination after 3 attempts: {dst.name}")
|
||||
except Exception:
|
||||
break
|
||||
|
||||
try:
|
||||
shutil.move(str(src), str(dst))
|
||||
return
|
||||
except FileNotFoundError:
|
||||
if dst.exists():
|
||||
logger.info(f"Source moved by another thread, destination exists: {dst.name}")
|
||||
return
|
||||
raise
|
||||
except (OSError, PermissionError) as e:
|
||||
error_msg = str(e).lower()
|
||||
|
||||
if dst.exists() and dst.stat().st_size > 0:
|
||||
logger.warning(f"Move raised {type(e).__name__} but destination exists, treating as success: {e}")
|
||||
try:
|
||||
src.unlink()
|
||||
except Exception:
|
||||
logger.info(f"Could not delete source file (may be owned by another process): {src}")
|
||||
return
|
||||
|
||||
if "cross-device" in error_msg or "operation not permitted" in error_msg or "permission denied" in error_msg:
|
||||
logger.warning(f"Cross-device move detected, using fallback copy method: {e}")
|
||||
try:
|
||||
with open(src, "rb") as f_src:
|
||||
with open(dst, "wb") as f_dst:
|
||||
shutil.copyfileobj(f_src, f_dst)
|
||||
f_dst.flush()
|
||||
os.fsync(f_dst.fileno())
|
||||
|
||||
try:
|
||||
src.unlink()
|
||||
except PermissionError:
|
||||
logger.info(f"Could not delete source file (may be owned by another process): {src}")
|
||||
logger.info(f"Successfully moved file using fallback method: {src} -> {dst}")
|
||||
return
|
||||
except Exception as fallback_error:
|
||||
logger.error(f"Fallback copy also failed: {fallback_error}")
|
||||
raise
|
||||
raise
|
||||
|
||||
|
||||
def cleanup_empty_directories(download_path, moved_file_path):
|
||||
"""Remove empty directories after a move, ignoring hidden files."""
|
||||
try:
|
||||
current_dir = os.path.dirname(moved_file_path)
|
||||
while current_dir != download_path and current_dir.startswith(download_path):
|
||||
is_empty = not any(not f.startswith(".") for f in os.listdir(current_dir))
|
||||
if is_empty:
|
||||
logger.warning(f"Removing empty directory: {current_dir}")
|
||||
os.rmdir(current_dir)
|
||||
current_dir = os.path.dirname(current_dir)
|
||||
else:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred during directory cleanup: {e}")
|
||||
|
||||
|
||||
def get_audio_quality_string(file_path):
|
||||
"""Return a compact audio quality string for the given file."""
|
||||
try:
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
|
||||
if ext == ".flac":
|
||||
from mutagen.flac import FLAC
|
||||
audio = FLAC(file_path)
|
||||
return f"FLAC {audio.info.bits_per_sample}bit"
|
||||
|
||||
if ext == ".mp3":
|
||||
from mutagen.mp3 import MP3, BitrateMode
|
||||
|
||||
audio = MP3(file_path)
|
||||
bitrate_kbps = audio.info.bitrate // 1000
|
||||
if audio.info.bitrate_mode == BitrateMode.VBR:
|
||||
return "MP3-VBR"
|
||||
return f"MP3-{bitrate_kbps}"
|
||||
|
||||
if ext in (".m4a", ".aac", ".mp4"):
|
||||
from mutagen.mp4 import MP4
|
||||
audio = MP4(file_path)
|
||||
return f"M4A-{audio.info.bitrate // 1000}"
|
||||
|
||||
if ext == ".ogg":
|
||||
from mutagen.oggvorbis import OggVorbis
|
||||
audio = OggVorbis(file_path)
|
||||
return f"OGG-{audio.info.bitrate // 1000}"
|
||||
|
||||
if ext == ".opus":
|
||||
from mutagen.oggopus import OggOpus
|
||||
|
||||
audio = OggOpus(file_path)
|
||||
return f"OPUS-{audio.info.bitrate // 1000}"
|
||||
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not determine audio quality for {file_path}: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def get_quality_tier_from_extension(file_path):
|
||||
"""Classify a file extension into a quality tier."""
|
||||
if not file_path:
|
||||
return ("unknown", 999)
|
||||
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
quality_tiers = {
|
||||
"lossless": {
|
||||
"extensions": [".flac", ".ape", ".wav", ".alac", ".dsf", ".dff", ".aiff", ".aif"],
|
||||
"tier": 1,
|
||||
},
|
||||
"high_lossy": {
|
||||
"extensions": [".opus", ".ogg"],
|
||||
"tier": 2,
|
||||
},
|
||||
"standard_lossy": {
|
||||
"extensions": [".m4a", ".aac"],
|
||||
"tier": 3,
|
||||
},
|
||||
"low_lossy": {
|
||||
"extensions": [".mp3", ".wma"],
|
||||
"tier": 4,
|
||||
},
|
||||
}
|
||||
|
||||
for tier_name, tier_data in quality_tiers.items():
|
||||
if ext in tier_data["extensions"]:
|
||||
return (tier_name, tier_data["tier"])
|
||||
|
||||
return ("unknown", 999)
|
||||
|
||||
|
||||
def downsample_hires_flac(final_path, context):
|
||||
"""Downsample a hi-res FLAC to 16-bit/44.1kHz if enabled."""
|
||||
from mutagen.flac import FLAC
|
||||
|
||||
if not config_manager.get("lossy_copy.downsample_hires", False):
|
||||
return None
|
||||
|
||||
if os.path.splitext(final_path)[1].lower() != ".flac":
|
||||
return None
|
||||
|
||||
try:
|
||||
audio = FLAC(final_path)
|
||||
original_bits = audio.info.bits_per_sample
|
||||
original_rate = audio.info.sample_rate
|
||||
except Exception as e:
|
||||
logger.error(f"[Downsample] Could not read FLAC info: {e}")
|
||||
return None
|
||||
|
||||
if original_bits <= 16 and original_rate <= 44100:
|
||||
return None
|
||||
|
||||
logger.info(f"[Downsample] Converting {original_bits}-bit/{original_rate}Hz -> 16-bit/44100Hz: {os.path.basename(final_path)}")
|
||||
|
||||
ffmpeg_bin = shutil.which("ffmpeg")
|
||||
if not ffmpeg_bin:
|
||||
local = os.path.join(os.path.dirname(__file__), "tools", "ffmpeg")
|
||||
if os.path.isfile(local):
|
||||
ffmpeg_bin = local
|
||||
else:
|
||||
logger.warning("[Downsample] ffmpeg not found - skipping hi-res conversion")
|
||||
return None
|
||||
|
||||
temp_path = final_path + ".tmp.flac"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
ffmpeg_bin, "-i", final_path,
|
||||
"-sample_fmt", "s16",
|
||||
"-ar", "44100",
|
||||
"-map_metadata", "0",
|
||||
"-compression_level", "8",
|
||||
"-y", temp_path,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"[Downsample] ffmpeg failed: {result.stderr[:200]}")
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
return None
|
||||
|
||||
if not os.path.isfile(temp_path) or os.path.getsize(temp_path) == 0:
|
||||
logger.warning("[Downsample] Output file missing or empty")
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
return None
|
||||
|
||||
verify_audio = FLAC(temp_path)
|
||||
if verify_audio.info.bits_per_sample != 16:
|
||||
logger.info(f"[Downsample] Output not 16-bit ({verify_audio.info.bits_per_sample}-bit), aborting")
|
||||
os.remove(temp_path)
|
||||
return None
|
||||
|
||||
os.replace(temp_path, final_path)
|
||||
logger.info(f"[Downsample] Converted to 16-bit/44.1kHz: {os.path.basename(final_path)}")
|
||||
|
||||
new_quality = "FLAC 16bit"
|
||||
try:
|
||||
updated_audio = FLAC(final_path)
|
||||
updated_audio["QUALITY"] = new_quality
|
||||
updated_audio.save()
|
||||
except Exception as tag_err:
|
||||
logger.error(f"[Downsample] Could not update QUALITY tag: {tag_err}")
|
||||
|
||||
old_quality = context.get("_audio_quality", "")
|
||||
context["_audio_quality"] = new_quality
|
||||
|
||||
if old_quality and old_quality != new_quality and old_quality in os.path.basename(final_path):
|
||||
new_basename = os.path.basename(final_path).replace(old_quality, new_quality)
|
||||
new_path = os.path.join(os.path.dirname(final_path), new_basename)
|
||||
try:
|
||||
os.rename(final_path, new_path)
|
||||
logger.info(f"[Downsample] Renamed: {os.path.basename(final_path)} -> {new_basename}")
|
||||
for lyrics_ext in (".lrc", ".txt"):
|
||||
old_lyrics = os.path.splitext(final_path)[0] + lyrics_ext
|
||||
if os.path.isfile(old_lyrics):
|
||||
new_lyrics = os.path.splitext(new_path)[0] + lyrics_ext
|
||||
os.rename(old_lyrics, new_lyrics)
|
||||
return new_path
|
||||
except Exception as rename_err:
|
||||
logger.error(f"[Downsample] Could not rename file: {rename_err}")
|
||||
|
||||
return final_path
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.info(f"[Downsample] Conversion timed out for: {os.path.basename(final_path)}")
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
except Exception as e:
|
||||
logger.error(f"[Downsample] Conversion error: {e}")
|
||||
if os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def create_lossy_copy(final_path):
|
||||
"""Convert a FLAC file to a lossy copy using the configured codec."""
|
||||
from mutagen.flac import FLAC
|
||||
|
||||
if not config_manager.get("lossy_copy.enabled", False):
|
||||
return None
|
||||
|
||||
if os.path.splitext(final_path)[1].lower() != ".flac":
|
||||
return None
|
||||
|
||||
codec = config_manager.get("lossy_copy.codec", "mp3").lower()
|
||||
bitrate = config_manager.get("lossy_copy.bitrate", "320")
|
||||
|
||||
if codec == "opus" and int(bitrate) > 256:
|
||||
bitrate = "256"
|
||||
|
||||
codec_map = {
|
||||
"mp3": ("libmp3lame", ".mp3", f"MP3-{bitrate}", ["-vn", "-id3v2_version", "3"]),
|
||||
"opus": ("libopus", ".opus", f"OPUS-{bitrate}", ["-vn", "-map", "0:a", "-vbr", "on"]),
|
||||
"aac": ("aac", ".m4a", f"AAC-{bitrate}", ["-vn", "-movflags", "+faststart"]),
|
||||
}
|
||||
|
||||
if codec not in codec_map:
|
||||
logger.info(f"[Lossy Copy] Unknown codec '{codec}' - skipping conversion")
|
||||
return None
|
||||
|
||||
ffmpeg_codec, out_ext, quality_label, extra_args = codec_map[codec]
|
||||
out_path = os.path.splitext(final_path)[0] + out_ext
|
||||
|
||||
original_quality = get_audio_quality_string(final_path)
|
||||
if original_quality:
|
||||
out_basename = os.path.basename(out_path)
|
||||
if original_quality in out_basename:
|
||||
out_basename = out_basename.replace(original_quality, quality_label)
|
||||
out_path = os.path.join(os.path.dirname(out_path), out_basename)
|
||||
|
||||
ffmpeg_bin = shutil.which("ffmpeg")
|
||||
if not ffmpeg_bin:
|
||||
local = os.path.join(os.path.dirname(__file__), "tools", "ffmpeg")
|
||||
if os.path.isfile(local):
|
||||
ffmpeg_bin = local
|
||||
else:
|
||||
logger.warning(f"[Lossy Copy] ffmpeg not found - skipping {codec.upper()} conversion")
|
||||
return None
|
||||
|
||||
try:
|
||||
logger.info(f"[Lossy Copy] Converting to {quality_label}: {os.path.basename(final_path)}")
|
||||
cmd = [
|
||||
ffmpeg_bin, "-i", final_path,
|
||||
"-codec:a", ffmpeg_codec,
|
||||
"-b:a", f"{bitrate}k",
|
||||
"-map_metadata", "0",
|
||||
] + extra_args + ["-y", out_path]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.info(f"[Lossy Copy] Created {quality_label} copy: {os.path.basename(out_path)}")
|
||||
try:
|
||||
from mutagen import File as MutagenFile
|
||||
audio = MutagenFile(out_path)
|
||||
if audio is not None:
|
||||
if codec == "mp3":
|
||||
from mutagen.id3 import TXXX
|
||||
audio.tags.add(TXXX(encoding=3, desc="QUALITY", text=[quality_label]))
|
||||
elif codec == "opus":
|
||||
audio["QUALITY"] = [quality_label]
|
||||
elif codec == "aac":
|
||||
from mutagen.mp4 import MP4FreeForm
|
||||
audio["----:com.apple.iTunes:QUALITY"] = [MP4FreeForm(quality_label.encode("utf-8"))]
|
||||
audio.save()
|
||||
except Exception as tag_err:
|
||||
logger.error(f"[Lossy Copy] Could not update QUALITY tag: {tag_err}")
|
||||
return out_path
|
||||
|
||||
logger.error(f"[Lossy Copy] ffmpeg failed: {result.stderr[:200]}")
|
||||
if os.path.exists(out_path):
|
||||
try:
|
||||
os.remove(out_path)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(f"[Lossy Copy] Conversion timed out for: {os.path.basename(final_path)}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Lossy Copy] Conversion error: {e}")
|
||||
return None
|
||||
92
core/imports/filename.py
Normal file
92
core/imports/filename.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""Filename parsing helpers used by import flows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
_TRACK_PATTERNS = (
|
||||
r"^(\d+)\s*[-\.]\s*(.+?)\s*[-–]\s*(.+)$",
|
||||
r"^(.+?)\s*[-–]\s*(.+)$",
|
||||
r"^(\d+)\s*[-\.]\s*(.+)$",
|
||||
)
|
||||
|
||||
|
||||
def extract_track_number_from_filename(filename: str, title: str = None) -> int:
|
||||
"""Extract track number from a filename. Returns 1 if not found."""
|
||||
basename = os.path.splitext(os.path.basename(filename))[0].strip()
|
||||
|
||||
match = re.match(r"^\d[\-\.](\d{1,2})\s*[\-\.]\s*", basename)
|
||||
if match:
|
||||
num = int(match.group(1))
|
||||
if 1 <= num <= 99:
|
||||
return num
|
||||
|
||||
match = re.match(r"^\(?(\d{1,3})\)?\s*[\-\.)\]]\s*", basename)
|
||||
if match:
|
||||
num = int(match.group(1))
|
||||
if 1 <= num <= 999:
|
||||
return num
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
def parse_filename_metadata(filename: str) -> Dict[str, Any]:
|
||||
"""Extract artist/title/album hints from a loose filename."""
|
||||
raw_path = str(filename or "")
|
||||
normalized_path = raw_path.replace("\\", "/")
|
||||
base_name = os.path.splitext(os.path.basename(normalized_path))[0]
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
"artist": "",
|
||||
"title": "",
|
||||
"album": "",
|
||||
"track_number": None,
|
||||
}
|
||||
|
||||
if not base_name:
|
||||
return result
|
||||
|
||||
for pattern in _TRACK_PATTERNS:
|
||||
match = re.match(pattern, base_name)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
groups = match.groups()
|
||||
if len(groups) == 3:
|
||||
try:
|
||||
result["track_number"] = int(groups[0])
|
||||
result["artist"] = result["artist"] or groups[1].strip()
|
||||
result["title"] = result["title"] or groups[2].strip()
|
||||
except ValueError:
|
||||
result["artist"] = result["artist"] or groups[0].strip()
|
||||
result["title"] = result["title"] or f"{groups[1]} - {groups[2]}".strip()
|
||||
elif len(groups) == 2:
|
||||
if groups[0].isdigit():
|
||||
try:
|
||||
result["track_number"] = int(groups[0])
|
||||
result["title"] = result["title"] or groups[1].strip()
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
result["artist"] = result["artist"] or groups[0].strip()
|
||||
result["title"] = result["title"] or groups[1].strip()
|
||||
break
|
||||
|
||||
if not result["title"]:
|
||||
result["title"] = base_name
|
||||
|
||||
if not result["album"] and "/" in normalized_path:
|
||||
path_parts = normalized_path.split("/")
|
||||
for part in reversed(path_parts[:-1]):
|
||||
if not part or part.startswith("@"):
|
||||
continue
|
||||
|
||||
cleaned = re.sub(r"^\d+\s*[-\.]\s*", "", part).strip()
|
||||
if len(cleaned) > 3:
|
||||
result["album"] = cleaned
|
||||
break
|
||||
|
||||
return result
|
||||
118
core/imports/guards.py
Normal file
118
core/imports/guards.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""Import post-processing guards and quarantine helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config.settings import config_manager
|
||||
from core.imports.context import (
|
||||
get_import_clean_artist,
|
||||
get_import_clean_title,
|
||||
get_import_context_artist,
|
||||
get_import_original_search,
|
||||
get_import_track_info,
|
||||
normalize_import_context,
|
||||
)
|
||||
from core.imports.file_ops import safe_move_file
|
||||
from database.music_database import MusicDatabase
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
|
||||
logger = get_logger("imports.guards")
|
||||
|
||||
|
||||
def _get_config_manager():
|
||||
return config_manager
|
||||
|
||||
|
||||
def move_to_quarantine(file_path: str, context: dict, reason: str, automation_engine=None) -> str:
|
||||
"""Move a file to the quarantine folder and write a metadata sidecar."""
|
||||
download_dir = _get_config_manager().get("soulseek.download_path", "./downloads")
|
||||
quarantine_dir = Path(download_dir) / "ss_quarantine"
|
||||
quarantine_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
original_name = Path(file_path).stem
|
||||
file_ext = Path(file_path).suffix
|
||||
|
||||
quarantine_filename = f"{timestamp}_{original_name}{file_ext}.quarantined"
|
||||
quarantine_path = quarantine_dir / quarantine_filename
|
||||
|
||||
safe_move_file(file_path, str(quarantine_path))
|
||||
|
||||
metadata_path = quarantine_dir / f"{timestamp}_{original_name}.json"
|
||||
context = normalize_import_context(context)
|
||||
original_search = get_import_original_search(context)
|
||||
artist_context = get_import_context_artist(context)
|
||||
|
||||
metadata = {
|
||||
"original_filename": Path(file_path).name,
|
||||
"quarantine_reason": reason,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"expected_track": get_import_clean_title(context, default=original_search.get("title", "Unknown")),
|
||||
"expected_artist": get_import_clean_artist(context, default=(artist_context.get("name", "") if isinstance(artist_context, dict) else "Unknown")),
|
||||
"context_key": context.get("context_key", "unknown"),
|
||||
}
|
||||
|
||||
try:
|
||||
with open(metadata_path, "w", encoding="utf-8") as f:
|
||||
json.dump(metadata, f, indent=2, ensure_ascii=False)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to write quarantine metadata: %s", exc)
|
||||
|
||||
logger.warning("File quarantined: %s - Reason: %s", quarantine_path, reason)
|
||||
|
||||
if automation_engine:
|
||||
try:
|
||||
ti = context.get("track_info", {})
|
||||
artists = ti.get("artists", [])
|
||||
artist_name = ""
|
||||
if artists:
|
||||
first = artists[0]
|
||||
artist_name = first.get("name", str(first)) if isinstance(first, dict) else str(first)
|
||||
automation_engine.emit(
|
||||
"download_quarantined",
|
||||
{
|
||||
"artist": artist_name,
|
||||
"title": ti.get("name", ""),
|
||||
"reason": reason or "Unknown",
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return str(quarantine_path)
|
||||
|
||||
|
||||
def check_flac_bit_depth(file_path: str, context: dict) -> Optional[str]:
|
||||
"""Return a rejection message if a FLAC file violates the configured bit depth."""
|
||||
if not context.get("_audio_quality", "").startswith("FLAC"):
|
||||
return None
|
||||
|
||||
quality_profile = MusicDatabase().get_quality_profile()
|
||||
flac_config = quality_profile.get("qualities", {}).get("flac", {})
|
||||
flac_pref = flac_config.get("bit_depth", "any")
|
||||
if flac_pref == "any":
|
||||
return None
|
||||
|
||||
actual_bits = context["_audio_quality"].replace("FLAC ", "").replace("bit", "")
|
||||
if actual_bits == flac_pref:
|
||||
return None
|
||||
|
||||
flac_fallback = flac_config.get("bit_depth_fallback", True)
|
||||
downsample_enabled = _get_config_manager().get("lossy_copy.downsample_hires", False)
|
||||
track_info = context.get("track_info", {})
|
||||
track_name = track_info.get("name", os.path.basename(file_path))
|
||||
|
||||
if flac_fallback or downsample_enabled:
|
||||
if downsample_enabled:
|
||||
logger.info("[FLAC Downsample] Accepted %s-bit FLAC (will be downsampled to %s-bit): %s", actual_bits, flac_pref, track_name)
|
||||
else:
|
||||
logger.warning("[FLAC Fallback] Accepted %s-bit FLAC (preferred %s-bit): %s", actual_bits, flac_pref, track_name)
|
||||
return None
|
||||
|
||||
return f"FLAC bit depth mismatch: file is {actual_bits}-bit, preference is {flac_pref}-bit"
|
||||
627
core/imports/paths.py
Normal file
627
core/imports/paths.py
Normal file
|
|
@ -0,0 +1,627 @@
|
|||
"""Shared path and naming helpers for import processing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Album grouping lives in core.imports.album_naming; this module keeps the
|
||||
# imported helper because the path builder still needs it.
|
||||
from core.imports.album_naming import resolve_album_group
|
||||
from core.imports.context import (
|
||||
extract_artist_name,
|
||||
get_import_clean_title,
|
||||
get_import_context_album,
|
||||
get_import_original_search,
|
||||
get_import_source,
|
||||
get_import_track_info,
|
||||
normalize_import_context,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("imports.paths")
|
||||
|
||||
|
||||
def _get_config_manager():
|
||||
try:
|
||||
from config.settings import config_manager
|
||||
return config_manager
|
||||
except Exception:
|
||||
class _FallbackConfig:
|
||||
@staticmethod
|
||||
def get(key, default=None):
|
||||
return default
|
||||
|
||||
return _FallbackConfig()
|
||||
|
||||
|
||||
def _get_itunes_client():
|
||||
try:
|
||||
from core.metadata_service import get_itunes_client
|
||||
return get_itunes_client()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _get_album_tracks_for_source(source: str, album_id: str):
|
||||
try:
|
||||
from core.metadata_service import get_album_tracks_for_source
|
||||
return get_album_tracks_for_source(source, album_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def docker_resolve_path(path_str: str) -> str:
|
||||
"""Resolve Docker-hosted Windows paths into container paths."""
|
||||
if os.path.exists("/.dockerenv") and len(path_str) >= 3 and path_str[1] == ":" and path_str[0].isalpha():
|
||||
drive_letter = path_str[0].lower()
|
||||
rest_of_path = path_str[2:].replace("\\", "/")
|
||||
return f"/host/mnt/{drive_letter}{rest_of_path}"
|
||||
return path_str
|
||||
|
||||
|
||||
def build_simple_download_destination(context, file_path: str):
|
||||
"""Build the destination path for a simple download into Transfer."""
|
||||
context = normalize_import_context(context)
|
||||
search_result = context.get("search_result", {}) or {}
|
||||
if not isinstance(search_result, dict):
|
||||
search_result = {}
|
||||
|
||||
transfer_dir = Path(docker_resolve_path(_get_config_manager().get("soulseek.transfer_path", "./Transfer")))
|
||||
album_name = None
|
||||
original_filename = search_result.get("filename", "")
|
||||
if "/" in original_filename or "\\" in original_filename:
|
||||
path_parts = original_filename.replace("\\", "/").split("/")
|
||||
if len(path_parts) >= 2:
|
||||
album_name = path_parts[-2]
|
||||
if not album_name:
|
||||
album_value = search_result.get("album")
|
||||
if isinstance(album_value, dict):
|
||||
album_name = album_value.get("name", "")
|
||||
else:
|
||||
album_name = album_value
|
||||
|
||||
filename = Path(file_path).name
|
||||
if album_name and str(album_name).lower() not in {"unknown", "unknown album", ""}:
|
||||
album_name = sanitize_filename(str(album_name))
|
||||
destination_dir = transfer_dir / album_name
|
||||
else:
|
||||
album_name = ""
|
||||
destination_dir = transfer_dir
|
||||
|
||||
destination_dir.mkdir(parents=True, exist_ok=True)
|
||||
return destination_dir / filename, album_name, filename
|
||||
|
||||
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
"""Sanitize filename for file system compatibility."""
|
||||
sanitized = re.sub(r'[<>:"/\\|?*]', "_", filename)
|
||||
sanitized = re.sub(r"\s+", " ", sanitized).strip()
|
||||
sanitized = sanitized.rstrip(". ") or "_"
|
||||
if re.match(r"^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)", sanitized, re.IGNORECASE):
|
||||
sanitized = "_" + sanitized
|
||||
return sanitized[:200]
|
||||
|
||||
|
||||
def sanitize_context_values(context: dict) -> dict:
|
||||
"""Sanitize all string values in a template context for path safety."""
|
||||
sanitized = {}
|
||||
for key, value in context.items():
|
||||
if isinstance(value, str) and value:
|
||||
sanitized[key] = sanitize_filename(value)
|
||||
else:
|
||||
sanitized[key] = value
|
||||
return sanitized
|
||||
|
||||
|
||||
def clean_track_title(track_title: str, artist_name: str) -> str:
|
||||
"""Clean up track title by removing artist prefix and other noise."""
|
||||
original = (track_title or "").strip()
|
||||
cleaned = original
|
||||
cleaned = re.sub(r"^\d{1,2}[\.\s\-]+", "", cleaned)
|
||||
artist_pattern = re.escape(artist_name or "") + r"\s*-\s*"
|
||||
cleaned = re.sub(f"^{artist_pattern}", "", cleaned, flags=re.IGNORECASE)
|
||||
cleaned = re.sub(r"^[A-Za-z0-9\.]+\s*-\s*\d{1,2}\s*-\s*", "", cleaned)
|
||||
quality_patterns = [
|
||||
r"\s*[\[\(][0-9]+\s*kbps[\]\)]\s*",
|
||||
r"\s*[\[\(]flac[\]\)]\s*",
|
||||
r"\s*[\[\(]mp3[\]\)]\s*",
|
||||
]
|
||||
for pattern in quality_patterns:
|
||||
cleaned = re.sub(pattern, "", cleaned, flags=re.IGNORECASE)
|
||||
cleaned = re.sub(r"^[-\s\.]+", "", cleaned)
|
||||
cleaned = re.sub(r"[-\s\.]+$", "", cleaned)
|
||||
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
||||
return cleaned if cleaned else original
|
||||
|
||||
|
||||
|
||||
|
||||
def get_album_type_display(raw_type, track_count) -> str:
|
||||
"""Return the display form of an album's type for the $albumtype template variable."""
|
||||
raw = (raw_type or "").strip().lower()
|
||||
try:
|
||||
tc = int(track_count or 0)
|
||||
except (TypeError, ValueError):
|
||||
tc = 0
|
||||
|
||||
if raw in ("compilation", "compile"):
|
||||
return "Compilation"
|
||||
if raw == "album":
|
||||
return "Album"
|
||||
if raw in ("single", "ep"):
|
||||
if tc <= 3:
|
||||
return "Single"
|
||||
if tc <= 6:
|
||||
return "EP"
|
||||
return "Album"
|
||||
|
||||
if tc <= 0:
|
||||
return "Album"
|
||||
if tc <= 3:
|
||||
return "Single"
|
||||
if tc <= 6:
|
||||
return "EP"
|
||||
return "Album"
|
||||
|
||||
|
||||
def _replace_template_variables(template: str, context: dict) -> str:
|
||||
clean_context = sanitize_context_values(context)
|
||||
result = template
|
||||
|
||||
album_artist_value = clean_context.get("albumartist", clean_context.get("artist", "Unknown Artist"))
|
||||
collab_mode = _get_config_manager().get("file_organization.collab_artist_mode", "first")
|
||||
if collab_mode == "first" and album_artist_value:
|
||||
artists_list = context.get("_artists_list")
|
||||
if artists_list and len(artists_list) > 1:
|
||||
first = artists_list[0]
|
||||
album_artist_value = first.get("name", first) if isinstance(first, dict) else str(first)
|
||||
elif artists_list and len(artists_list) == 1:
|
||||
itunes_artist_id = context.get("_itunes_artist_id")
|
||||
if itunes_artist_id and ("," in album_artist_value or " & " in album_artist_value):
|
||||
try:
|
||||
resolved_client = _get_itunes_client()
|
||||
if resolved_client and hasattr(resolved_client, "resolve_primary_artist"):
|
||||
resolved = resolved_client.resolve_primary_artist(itunes_artist_id)
|
||||
if resolved and resolved != album_artist_value:
|
||||
album_artist_value = resolved
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# $cdnum — smart CD label for multi-disc filenames. Produces "CD01" /
|
||||
# "CD02" etc. when the album has 2+ discs, empty string otherwise.
|
||||
# Empty output collapses gracefully via the trailing dash cleanup
|
||||
# regex below, so single-disc albums don't end up with "CD01" literal
|
||||
# in every name.
|
||||
_total_discs = _coerce_int(clean_context.get("total_discs", 1), 1)
|
||||
_disc_number = _coerce_int(clean_context.get("disc_number", 1), 1)
|
||||
cdnum_value = f"CD{_disc_number:02d}" if _total_discs > 1 else ""
|
||||
|
||||
bracket_map = {
|
||||
"albumartist": album_artist_value,
|
||||
"albumtype": clean_context.get("albumtype", "Album"),
|
||||
"playlist": clean_context.get("playlist_name", ""),
|
||||
"artistletter": (clean_context.get("artist", "U") or "U")[0].upper(),
|
||||
"artist": clean_context.get("artist", "Unknown Artist"),
|
||||
"album": clean_context.get("album", "Unknown Album"),
|
||||
"title": clean_context.get("title", "Unknown Track"),
|
||||
"track": f"{_coerce_int(clean_context.get('track_number', 1), 1):02d}",
|
||||
"cdnum": cdnum_value,
|
||||
"disc": str(_coerce_int(clean_context.get("disc_number", 1), 1)),
|
||||
"discnum": str(_coerce_int(clean_context.get("disc_number", 1), 1)),
|
||||
"year": str(clean_context.get("year", "")),
|
||||
"quality": clean_context.get("quality", ""),
|
||||
}
|
||||
for var_name, val in bracket_map.items():
|
||||
result = result.replace("${" + var_name + "}", val)
|
||||
|
||||
result = result.replace("$albumartist", album_artist_value)
|
||||
result = result.replace("$albumtype", clean_context.get("albumtype", "Album"))
|
||||
result = result.replace("$playlist", clean_context.get("playlist_name", ""))
|
||||
result = result.replace("$artistletter", (clean_context.get("artist", "U") or "U")[0].upper())
|
||||
result = result.replace("$artist", clean_context.get("artist", "Unknown Artist"))
|
||||
result = result.replace("$album", clean_context.get("album", "Unknown Album"))
|
||||
result = result.replace("$title", clean_context.get("title", "Unknown Track"))
|
||||
# $cdnum must replace before $track to follow the longest-prefix-first
|
||||
# rule used throughout this function (no current $c* var collides, but
|
||||
# ordering matches the web_server.py path-builder for parity).
|
||||
result = result.replace("$cdnum", cdnum_value)
|
||||
result = result.replace("$track", f"{clean_context.get('track_number', 1):02d}")
|
||||
result = result.replace("$year", str(clean_context.get("year", "")))
|
||||
|
||||
result = re.sub(r"\s+", " ", result)
|
||||
result = re.sub(r"\s*-\s*-\s*", " - ", result)
|
||||
result = result.strip()
|
||||
return result
|
||||
|
||||
|
||||
def apply_path_template(template: str, context: dict) -> str:
|
||||
"""Apply a template to build a path string."""
|
||||
return _replace_template_variables(template, context)
|
||||
|
||||
|
||||
def get_file_path_from_template_raw(template: str, context: dict) -> tuple[str, str]:
|
||||
"""Build file path using a user-provided template string directly."""
|
||||
full_path = apply_path_template(template, context)
|
||||
|
||||
quality_value = context.get("quality", "")
|
||||
disc_number = _coerce_int(context.get("disc_number", 1), 1)
|
||||
disc_value = f"{disc_number:02d}"
|
||||
disc_value_raw = str(disc_number)
|
||||
|
||||
path_parts = full_path.split("/")
|
||||
if len(path_parts) > 1:
|
||||
folder_parts = path_parts[:-1]
|
||||
filename_base = path_parts[-1]
|
||||
|
||||
cleaned_folders = []
|
||||
for part in folder_parts:
|
||||
part = part.replace("$quality", "")
|
||||
part = part.replace("$discnum", "")
|
||||
part = part.replace("$disc", "")
|
||||
part = part.replace("$cdnum", "")
|
||||
part = re.sub(r"\s*\[\s*\]", "", part)
|
||||
part = re.sub(r"\s*\(\s*\)", "", part)
|
||||
part = re.sub(r"\s*\{\s*\}", "", part)
|
||||
part = re.sub(r"\s*-\s*$", "", part)
|
||||
part = re.sub(r"^\s*-\s*", "", part)
|
||||
part = re.sub(r"\s+", " ", part).strip()
|
||||
if part:
|
||||
cleaned_folders.append(part)
|
||||
|
||||
filename_base = filename_base.replace("$quality", quality_value)
|
||||
filename_base = filename_base.replace("$discnum", disc_value_raw)
|
||||
filename_base = filename_base.replace("$disc", disc_value)
|
||||
filename_base = re.sub(r"\s*\[\s*\]", "", filename_base)
|
||||
filename_base = re.sub(r"\s*\(\s*\)", "", filename_base)
|
||||
filename_base = re.sub(r"\s*\{\s*\}", "", filename_base)
|
||||
filename_base = re.sub(r"\s*-\s*$", "", filename_base)
|
||||
# Leading dash cleanup — lets $cdnum at the start of a filename
|
||||
# cleanly disappear on single-disc albums (empty-value case).
|
||||
filename_base = re.sub(r"^\s*-\s*", "", filename_base)
|
||||
filename_base = re.sub(r"\s+", " ", filename_base).strip()
|
||||
|
||||
sanitized_folders = [sanitize_filename(part) for part in cleaned_folders]
|
||||
folder_path = os.path.join(*sanitized_folders) if sanitized_folders else ""
|
||||
return folder_path, sanitize_filename(filename_base)
|
||||
|
||||
full_path = full_path.replace("$quality", quality_value)
|
||||
full_path = full_path.replace("$discnum", disc_value_raw)
|
||||
full_path = full_path.replace("$disc", disc_value)
|
||||
full_path = re.sub(r"\s*\[\s*\]", "", full_path)
|
||||
full_path = re.sub(r"\s*\(\s*\)", "", full_path)
|
||||
full_path = re.sub(r"\s*\{\s*\}", "", full_path)
|
||||
full_path = re.sub(r"\s*-\s*$", "", full_path)
|
||||
full_path = re.sub(r"\s+", " ", full_path).strip()
|
||||
return "", sanitize_filename(full_path)
|
||||
|
||||
|
||||
def get_file_path_from_template(context: dict, template_type: str = "album_path") -> tuple[str, str]:
|
||||
"""Build complete file path using configured templates."""
|
||||
if not _get_config_manager().get("file_organization.enabled", True):
|
||||
return None, None
|
||||
|
||||
templates = _get_config_manager().get("file_organization.templates", {})
|
||||
template = templates.get(template_type)
|
||||
if not template:
|
||||
default_templates = {
|
||||
"album_path": "$albumartist/$albumartist - $album/$track - $title",
|
||||
"single_path": "$artist/$artist - $title/$title",
|
||||
"compilation_path": "Compilations/$album/$track - $artist - $title",
|
||||
"playlist_path": "$playlist/$artist - $title",
|
||||
}
|
||||
template = default_templates.get(template_type, "$artist/$album/$track - $title")
|
||||
|
||||
full_path = apply_path_template(template, context)
|
||||
|
||||
path_parts = full_path.split("/")
|
||||
quality_value = context.get("quality", "")
|
||||
disc_number = _coerce_int(context.get("disc_number", 1), 1)
|
||||
disc_value = f"{disc_number:02d}"
|
||||
disc_value_raw = str(disc_number)
|
||||
|
||||
if len(path_parts) > 1:
|
||||
folder_parts = path_parts[:-1]
|
||||
filename_base = path_parts[-1]
|
||||
|
||||
cleaned_folders = []
|
||||
for part in folder_parts:
|
||||
part = part.replace("$quality", "")
|
||||
part = part.replace("$discnum", "")
|
||||
part = part.replace("$disc", "")
|
||||
part = part.replace("$cdnum", "")
|
||||
part = re.sub(r"\s*\[\s*\]", "", part)
|
||||
part = re.sub(r"\s*\(\s*\)", "", part)
|
||||
part = re.sub(r"\s*\{\s*\}", "", part)
|
||||
part = re.sub(r"\s*-\s*$", "", part)
|
||||
part = re.sub(r"^\s*-\s*", "", part)
|
||||
part = re.sub(r"\s+", " ", part).strip()
|
||||
if part:
|
||||
cleaned_folders.append(part)
|
||||
|
||||
filename_base = filename_base.replace("$quality", quality_value)
|
||||
filename_base = filename_base.replace("$discnum", disc_value_raw)
|
||||
filename_base = filename_base.replace("$disc", disc_value)
|
||||
filename_base = re.sub(r"\s*\[\s*\]", "", filename_base)
|
||||
filename_base = re.sub(r"\s*\(\s*\)", "", filename_base)
|
||||
filename_base = re.sub(r"\s*\{\s*\}", "", filename_base)
|
||||
filename_base = re.sub(r"\s*-\s*$", "", filename_base)
|
||||
# Leading dash cleanup — lets $cdnum at the start of a filename
|
||||
# cleanly disappear on single-disc albums (empty-value case).
|
||||
filename_base = re.sub(r"^\s*-\s*", "", filename_base)
|
||||
filename_base = re.sub(r"\s+", " ", filename_base).strip()
|
||||
|
||||
sanitized_folders = [sanitize_filename(part) for part in cleaned_folders]
|
||||
folder_path = os.path.join(*sanitized_folders) if sanitized_folders else ""
|
||||
filename = sanitize_filename(filename_base)
|
||||
return folder_path, filename
|
||||
|
||||
full_path = full_path.replace("$quality", quality_value)
|
||||
full_path = full_path.replace("$discnum", disc_value_raw)
|
||||
full_path = full_path.replace("$disc", disc_value)
|
||||
full_path = re.sub(r"\s*\[\s*\]", "", full_path)
|
||||
full_path = re.sub(r"\s*\(\s*\)", "", full_path)
|
||||
full_path = re.sub(r"\s*\{\s*\}", "", full_path)
|
||||
full_path = re.sub(r"\s*-\s*$", "", full_path)
|
||||
full_path = re.sub(r"\s+", " ", full_path).strip()
|
||||
return "", sanitize_filename(full_path)
|
||||
|
||||
|
||||
def _max_disc_number(album_tracks: Any) -> int:
|
||||
items = []
|
||||
if isinstance(album_tracks, dict):
|
||||
items = album_tracks.get("items") or album_tracks.get("tracks") or []
|
||||
elif isinstance(album_tracks, list):
|
||||
items = album_tracks
|
||||
|
||||
max_disc = 1
|
||||
for track in items:
|
||||
if not isinstance(track, dict):
|
||||
continue
|
||||
try:
|
||||
disc_number = int(track.get("disc_number", 1) or 1)
|
||||
except (TypeError, ValueError):
|
||||
disc_number = 1
|
||||
if disc_number > max_disc:
|
||||
max_disc = disc_number
|
||||
return max_disc
|
||||
|
||||
|
||||
def _coerce_int(value: Any, default: int = 1) -> int:
|
||||
try:
|
||||
coerced = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return coerced if coerced > 0 else default
|
||||
|
||||
|
||||
def build_final_path_for_track(context, artist_context, album_info, file_ext):
|
||||
"""Shared path builder used by both post-processing and verification."""
|
||||
transfer_dir = docker_resolve_path(_get_config_manager().get("soulseek.transfer_path", "./Transfer"))
|
||||
context = normalize_import_context(context)
|
||||
track_info = get_import_track_info(context)
|
||||
original_search = get_import_original_search(context)
|
||||
album_context = get_import_context_album(context)
|
||||
source = get_import_source(context)
|
||||
playlist_folder_mode = track_info.get("_playlist_folder_mode", False)
|
||||
artist_name = extract_artist_name(artist_context)
|
||||
|
||||
source_info = track_info.get("source_info") or {}
|
||||
if isinstance(source_info, str):
|
||||
try:
|
||||
source_info = json.loads(source_info)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
source_info = {}
|
||||
if source_info.get("enhance") and source_info.get("original_file_path"):
|
||||
original_path = source_info["original_file_path"]
|
||||
original_dir = os.path.dirname(original_path)
|
||||
original_stem = os.path.splitext(os.path.basename(original_path))[0]
|
||||
final_path = os.path.join(original_dir, original_stem + file_ext)
|
||||
os.makedirs(original_dir, exist_ok=True)
|
||||
logger.info("[Enhance] Using original file location: %s", final_path)
|
||||
return final_path, True
|
||||
|
||||
year = ""
|
||||
if album_context and album_context.get("release_date"):
|
||||
release_date = album_context["release_date"]
|
||||
if release_date and len(release_date) >= 4:
|
||||
year = release_date[:4]
|
||||
|
||||
raw_album_type = ""
|
||||
if album_context:
|
||||
raw_album_type = album_context.get("album_type", "") or ""
|
||||
total_tracks = (album_context.get("total_tracks", 0) or 0) if album_context else 0
|
||||
album_type_display = get_album_type_display(raw_album_type, total_tracks)
|
||||
|
||||
if playlist_folder_mode:
|
||||
playlist_name = track_info.get("_playlist_name", "Unknown Playlist")
|
||||
track_name = get_import_clean_title(context, default=original_search.get("title", "Unknown Track"))
|
||||
_artists = original_search.get("artists") or track_info.get("artists") or []
|
||||
|
||||
template_context = {
|
||||
"artist": artist_name,
|
||||
"albumartist": artist_name,
|
||||
"album": track_name,
|
||||
"title": track_name,
|
||||
"playlist_name": playlist_name,
|
||||
"track_number": 1,
|
||||
"disc_number": 1,
|
||||
"year": year,
|
||||
"quality": context.get("_audio_quality", ""),
|
||||
"albumtype": album_type_display,
|
||||
"_artists_list": _artists,
|
||||
"_itunes_artist_id": str(artist_context.get("id", "")) if isinstance(artist_context, dict) and str(artist_context.get("id", "")).isdigit() and source == "itunes" else None,
|
||||
}
|
||||
|
||||
folder_path, filename_base = get_file_path_from_template(template_context, "playlist_path")
|
||||
if folder_path and filename_base:
|
||||
final_path = os.path.join(transfer_dir, folder_path, filename_base + file_ext)
|
||||
os.makedirs(os.path.join(transfer_dir, folder_path), exist_ok=True)
|
||||
return final_path, True
|
||||
|
||||
playlist_name_sanitized = sanitize_filename(playlist_name)
|
||||
playlist_dir = os.path.join(transfer_dir, playlist_name_sanitized)
|
||||
os.makedirs(playlist_dir, exist_ok=True)
|
||||
artist_name_sanitized = sanitize_filename(template_context["artist"])
|
||||
track_name_sanitized = sanitize_filename(track_name)
|
||||
new_filename = f"{artist_name_sanitized} - {track_name_sanitized}{file_ext}"
|
||||
return os.path.join(playlist_dir, new_filename), True
|
||||
|
||||
if album_info and album_info.get("is_album"):
|
||||
clean_track_name = get_import_clean_title(context, album_info=album_info, default=original_search.get("title", "Unknown Track"))
|
||||
track_number = _coerce_int(album_info.get("track_number", 1), 1)
|
||||
disc_number = _coerce_int(album_info.get("disc_number", 1), 1)
|
||||
_artists = original_search.get("artists") or track_info.get("artists") or []
|
||||
_album_ctx = album_context
|
||||
_itunes_aid = None
|
||||
_is_itunes = source == "itunes" or (isinstance(artist_context, dict) and str(artist_context.get("id", "")).isdigit() and source != "deezer")
|
||||
if _is_itunes and isinstance(artist_context, dict):
|
||||
_aid = artist_context.get("id", "")
|
||||
if str(_aid).isdigit():
|
||||
_itunes_aid = str(_aid)
|
||||
if not _itunes_aid and _album_ctx:
|
||||
_ext = _album_ctx.get("external_urls", {})
|
||||
if isinstance(_ext, dict) and _ext.get("itunes_artist_id"):
|
||||
_itunes_aid = _ext["itunes_artist_id"]
|
||||
|
||||
_artist_name = artist_name
|
||||
_album_artist_name = _artist_name
|
||||
_album_artists_for_collab = None
|
||||
_explicit_artist_ctx = track_info.get("_explicit_artist_context") if isinstance(track_info, dict) else None
|
||||
if isinstance(_explicit_artist_ctx, dict) and _explicit_artist_ctx.get("name"):
|
||||
_album_artist_name = _explicit_artist_ctx["name"]
|
||||
_album_artists_for_collab = [_explicit_artist_ctx]
|
||||
elif isinstance(_explicit_artist_ctx, str) and _explicit_artist_ctx:
|
||||
_album_artist_name = _explicit_artist_ctx
|
||||
_album_artists_for_collab = [{"name": _explicit_artist_ctx}]
|
||||
else:
|
||||
_sa_artists = _album_ctx.get("artists", []) if _album_ctx else []
|
||||
if _sa_artists:
|
||||
_first_sa = _sa_artists[0]
|
||||
if isinstance(_first_sa, dict) and _first_sa.get("name"):
|
||||
_album_artist_name = _first_sa["name"]
|
||||
elif isinstance(_first_sa, str) and _first_sa:
|
||||
_album_artist_name = _first_sa
|
||||
_album_artists_for_collab = _sa_artists
|
||||
|
||||
template_context = {
|
||||
"artist": _artist_name,
|
||||
"albumartist": _album_artist_name,
|
||||
"album": album_info["album_name"],
|
||||
"title": clean_track_name,
|
||||
"track_number": track_number,
|
||||
"disc_number": disc_number,
|
||||
"year": year,
|
||||
"quality": context.get("_audio_quality", ""),
|
||||
"albumtype": album_type_display,
|
||||
"_artists_list": _album_artists_for_collab if _album_artists_for_collab else _artists,
|
||||
"_itunes_artist_id": _itunes_aid,
|
||||
}
|
||||
total_discs = _coerce_int(album_context.get("total_discs", 1) if album_context else 1, 1)
|
||||
|
||||
if total_discs <= 1 and album_context and album_context.get("id"):
|
||||
if disc_number > 1:
|
||||
total_discs = disc_number
|
||||
else:
|
||||
try:
|
||||
_album_tracks = _get_album_tracks_for_source(source, str(album_context["id"]))
|
||||
if _album_tracks:
|
||||
total_discs = _max_disc_number(_album_tracks)
|
||||
if total_discs > 1:
|
||||
album_context["total_discs"] = total_discs
|
||||
logger.info(
|
||||
"[Multi-Disc] Resolved %s discs for single-track download of %r",
|
||||
total_discs,
|
||||
album_context.get("name"),
|
||||
)
|
||||
except Exception as _disc_err:
|
||||
logger.warning("[Multi-Disc] Could not resolve total_discs: %s", _disc_err)
|
||||
|
||||
# Now that total_discs is fully resolved, expose it to the template
|
||||
# so $cdnum can decide between "CDxx" and an empty string.
|
||||
template_context["total_discs"] = total_discs
|
||||
|
||||
album_template = _get_config_manager().get("file_organization.templates", {}).get("album_path", "") or ""
|
||||
# Suppress the auto-injected disc folder when the user already
|
||||
# encodes the disc in the filename via $disc, $discnum, or $cdnum.
|
||||
user_controls_disc = (
|
||||
"$disc" in album_template
|
||||
or "$cdnum" in album_template
|
||||
or "${disc}" in album_template
|
||||
or "${discnum}" in album_template
|
||||
or "${cdnum}" in album_template
|
||||
)
|
||||
disc_label = _get_config_manager().get("file_organization.disc_label", "Disc")
|
||||
|
||||
folder_path, filename_base = get_file_path_from_template(template_context, "album_path")
|
||||
if folder_path and filename_base:
|
||||
if total_discs > 1 and not user_controls_disc:
|
||||
disc_folder = f"{disc_label} {disc_number}"
|
||||
final_path = os.path.join(transfer_dir, folder_path, disc_folder, filename_base + file_ext)
|
||||
os.makedirs(os.path.join(transfer_dir, folder_path, disc_folder), exist_ok=True)
|
||||
else:
|
||||
final_path = os.path.join(transfer_dir, folder_path, filename_base + file_ext)
|
||||
os.makedirs(os.path.join(transfer_dir, folder_path), exist_ok=True)
|
||||
return final_path, True
|
||||
|
||||
artist_name_sanitized = sanitize_filename(template_context["albumartist"])
|
||||
album_name_sanitized = sanitize_filename(album_info["album_name"])
|
||||
artist_dir = os.path.join(transfer_dir, artist_name_sanitized)
|
||||
album_folder_name = f"{artist_name_sanitized} - {album_name_sanitized}"
|
||||
album_dir = os.path.join(artist_dir, album_folder_name)
|
||||
if total_discs > 1:
|
||||
album_dir = os.path.join(album_dir, f"{disc_label} {disc_number}")
|
||||
os.makedirs(album_dir, exist_ok=True)
|
||||
final_track_name_sanitized = sanitize_filename(clean_track_name)
|
||||
new_filename = f"{track_number:02d} - {final_track_name_sanitized}{file_ext}"
|
||||
return os.path.join(album_dir, new_filename), True
|
||||
|
||||
clean_track_name = get_import_clean_title(context, album_info=album_info, default=original_search.get("title", "Unknown Track"))
|
||||
_artists = original_search.get("artists") or track_info.get("artists") or []
|
||||
_album_ctx = album_context
|
||||
_itunes_aid = None
|
||||
_is_itunes = source == "itunes" or (isinstance(artist_context, dict) and str(artist_context.get("id", "")).isdigit() and source != "deezer")
|
||||
if _is_itunes and isinstance(artist_context, dict):
|
||||
_aid = artist_context.get("id", "")
|
||||
if str(_aid).isdigit():
|
||||
_itunes_aid = str(_aid)
|
||||
if not _itunes_aid and _album_ctx:
|
||||
_ext = _album_ctx.get("external_urls", {})
|
||||
if isinstance(_ext, dict) and _ext.get("itunes_artist_id"):
|
||||
_itunes_aid = _ext["itunes_artist_id"]
|
||||
|
||||
template_context = {
|
||||
"artist": artist_name,
|
||||
"albumartist": artist_name,
|
||||
"album": album_info.get("album_name", clean_track_name) if album_info else clean_track_name,
|
||||
"title": clean_track_name,
|
||||
"track_number": 1,
|
||||
"disc_number": 1,
|
||||
"year": year,
|
||||
"quality": context.get("_audio_quality", ""),
|
||||
"albumtype": album_type_display,
|
||||
"_artists_list": _artists,
|
||||
"_itunes_artist_id": _itunes_aid,
|
||||
}
|
||||
|
||||
folder_path, filename_base = get_file_path_from_template(template_context, "single_path")
|
||||
if filename_base:
|
||||
if folder_path:
|
||||
final_path = os.path.join(transfer_dir, folder_path, filename_base + file_ext)
|
||||
os.makedirs(os.path.join(transfer_dir, folder_path), exist_ok=True)
|
||||
else:
|
||||
final_path = os.path.join(transfer_dir, filename_base + file_ext)
|
||||
os.makedirs(transfer_dir, exist_ok=True)
|
||||
return final_path, True
|
||||
|
||||
artist_name_sanitized = sanitize_filename(template_context["artist"])
|
||||
final_track_name_sanitized = sanitize_filename(clean_track_name)
|
||||
artist_dir = os.path.join(transfer_dir, artist_name_sanitized)
|
||||
single_folder_name = f"{artist_name_sanitized} - {final_track_name_sanitized}"
|
||||
single_dir = os.path.join(artist_dir, single_folder_name)
|
||||
os.makedirs(single_dir, exist_ok=True)
|
||||
new_filename = f"{final_track_name_sanitized}{file_ext}"
|
||||
return os.path.join(single_dir, new_filename), True
|
||||
956
core/imports/pipeline.py
Normal file
956
core/imports/pipeline.py
Normal file
|
|
@ -0,0 +1,956 @@
|
|||
"""Import/post-processing pipeline for downloads and imported files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from config.settings import config_manager
|
||||
from core.imports.file_ops import (
|
||||
cleanup_empty_directories,
|
||||
cleanup_slskd_dedup_siblings,
|
||||
create_lossy_copy,
|
||||
downsample_hires_flac,
|
||||
get_audio_quality_string,
|
||||
get_quality_tier_from_extension,
|
||||
safe_move_file,
|
||||
)
|
||||
from core.imports.context import (
|
||||
build_import_album_info,
|
||||
detect_album_info_web,
|
||||
extract_artist_name,
|
||||
get_import_clean_artist,
|
||||
get_import_clean_title,
|
||||
get_import_context_artist,
|
||||
get_import_has_clean_metadata,
|
||||
get_import_original_search,
|
||||
get_import_source,
|
||||
get_import_track_info,
|
||||
normalize_import_context,
|
||||
)
|
||||
from core.imports.filename import extract_track_number_from_filename
|
||||
from core.imports.guards import check_flac_bit_depth, move_to_quarantine
|
||||
from core.imports.side_effects import (
|
||||
emit_track_downloaded,
|
||||
record_download_provenance,
|
||||
record_library_history_download,
|
||||
record_retag_download,
|
||||
record_soulsync_library_entry,
|
||||
)
|
||||
from core.wishlist.resolution import check_and_remove_from_wishlist
|
||||
from core.runtime_state import (
|
||||
add_activity_item,
|
||||
download_batches,
|
||||
download_tasks,
|
||||
matched_context_lock,
|
||||
matched_downloads_context,
|
||||
mark_task_completed as _mark_task_completed,
|
||||
post_process_locks,
|
||||
post_process_locks_lock,
|
||||
processed_download_ids,
|
||||
tasks_lock,
|
||||
)
|
||||
from core.metadata.artwork import download_cover_art
|
||||
from core.metadata.common import wipe_source_tags
|
||||
from core.metadata.enrichment import enhance_file_metadata
|
||||
from core.imports.paths import (
|
||||
build_final_path_for_track,
|
||||
build_simple_download_destination,
|
||||
docker_resolve_path,
|
||||
)
|
||||
from core.imports.album_naming import resolve_album_group
|
||||
from core.metadata.lyrics import generate_lrc_file
|
||||
from database.music_database import get_database
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
|
||||
logger = get_logger("imports.pipeline")
|
||||
pp_logger = get_logger("post_processing")
|
||||
|
||||
__all__ = [
|
||||
"build_import_pipeline_runtime",
|
||||
"post_process_matched_download",
|
||||
"post_process_matched_download_with_verification",
|
||||
]
|
||||
|
||||
|
||||
def build_import_pipeline_runtime(
|
||||
*,
|
||||
automation_engine: Any | None = None,
|
||||
on_download_completed: Any | None = None,
|
||||
web_scan_manager: Any | None = None,
|
||||
repair_worker: Any | None = None,
|
||||
) -> SimpleNamespace:
|
||||
"""Build the runtime object consumed by core.imports.pipeline."""
|
||||
return SimpleNamespace(
|
||||
automation_engine=automation_engine,
|
||||
on_download_completed=on_download_completed,
|
||||
web_scan_manager=web_scan_manager,
|
||||
repair_worker=repair_worker,
|
||||
)
|
||||
|
||||
|
||||
def post_process_matched_download(context_key, context, file_path, runtime, metadata_runtime=None):
|
||||
on_download_completed = getattr(runtime, "on_download_completed", None)
|
||||
automation_engine = getattr(runtime, "automation_engine", None)
|
||||
web_scan_manager = getattr(runtime, "web_scan_manager", None)
|
||||
repair_worker = getattr(runtime, "repair_worker", None)
|
||||
metadata_runtime = metadata_runtime or runtime
|
||||
|
||||
def _notify_download_completed(batch_id, task_id, success=True):
|
||||
if on_download_completed:
|
||||
on_download_completed(batch_id, task_id, success=success)
|
||||
|
||||
with post_process_locks_lock:
|
||||
if context_key not in post_process_locks:
|
||||
post_process_locks[context_key] = threading.Lock()
|
||||
file_lock = post_process_locks[context_key]
|
||||
|
||||
file_lock.acquire()
|
||||
try:
|
||||
if not os.path.exists(file_path):
|
||||
existing_final = context.get('_final_processed_path')
|
||||
if existing_final and os.path.exists(existing_final):
|
||||
logger.info(
|
||||
f"[Race Guard] Source gone but destination exists — already processed by another thread: "
|
||||
f"{os.path.basename(existing_final)}"
|
||||
)
|
||||
return
|
||||
logger.error(
|
||||
f"[Race Guard] Source file gone and no known destination — marking as failed: "
|
||||
f"{os.path.basename(file_path)}"
|
||||
)
|
||||
context['_race_guard_failed'] = True
|
||||
return
|
||||
|
||||
_basename = os.path.basename(file_path)
|
||||
_prev_size = -1
|
||||
for _stability_check in range(5):
|
||||
try:
|
||||
_cur_size = os.path.getsize(file_path)
|
||||
except OSError:
|
||||
_cur_size = -1
|
||||
if _cur_size == _prev_size and _cur_size > 0:
|
||||
break
|
||||
_prev_size = _cur_size
|
||||
if _stability_check == 0:
|
||||
logger.info(f"Waiting for file to stabilise: {_basename} ({_cur_size} bytes)")
|
||||
time.sleep(1.5)
|
||||
else:
|
||||
logger.info(f"File may still be writing after stability checks: {_basename} ({_prev_size} bytes)")
|
||||
|
||||
_skip_acoustid = False
|
||||
try:
|
||||
from core.acoustid_verification import AcoustIDVerification, VerificationResult
|
||||
|
||||
verifier = AcoustIDVerification()
|
||||
available, available_reason = verifier.quick_check_available()
|
||||
if available and not _skip_acoustid:
|
||||
context = normalize_import_context(context)
|
||||
track_info = get_import_track_info(context)
|
||||
original_search = get_import_original_search(context)
|
||||
artist_context = get_import_context_artist(context)
|
||||
|
||||
expected_track = get_import_clean_title(context, default=original_search.get('title', ''))
|
||||
expected_artist = ''
|
||||
track_artists = track_info.get('artists', [])
|
||||
if track_artists:
|
||||
first = track_artists[0]
|
||||
if isinstance(first, dict):
|
||||
expected_artist = first.get('name', '')
|
||||
elif isinstance(first, str):
|
||||
expected_artist = first
|
||||
if not expected_artist:
|
||||
expected_artist = extract_artist_name(artist_context) or get_import_clean_artist(context, default='')
|
||||
|
||||
if expected_track and expected_artist:
|
||||
logger.info(f"Running AcoustID verification for: '{expected_track}' by '{expected_artist}'")
|
||||
verification_result, verification_msg = verifier.verify_audio_file(
|
||||
file_path,
|
||||
expected_track,
|
||||
expected_artist,
|
||||
context,
|
||||
)
|
||||
logger.info(f"AcoustID verification result: {verification_result.value} - {verification_msg}")
|
||||
context['_acoustid_result'] = verification_result.value
|
||||
|
||||
if verification_result == VerificationResult.FAIL:
|
||||
try:
|
||||
quarantine_path = move_to_quarantine(
|
||||
file_path,
|
||||
context,
|
||||
verification_msg,
|
||||
automation_engine,
|
||||
)
|
||||
logger.error(f"File quarantined due to verification failure: {quarantine_path}")
|
||||
except Exception as quarantine_error:
|
||||
logger.error(f"Quarantine failed ({quarantine_error}), deleting wrong file: {file_path}")
|
||||
logger.error(f"Quarantine failed, deleting wrong file: {file_path}")
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except Exception as del_error:
|
||||
logger.error(f"Could not delete wrong file either: {del_error}")
|
||||
|
||||
context['_acoustid_quarantined'] = True
|
||||
context['_acoustid_failure_msg'] = verification_msg
|
||||
with matched_context_lock:
|
||||
if context_key in matched_downloads_context:
|
||||
del matched_downloads_context[context_key]
|
||||
|
||||
task_id = context.get('task_id')
|
||||
batch_id = context.get('batch_id')
|
||||
if task_id:
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
download_tasks[task_id]['error_message'] = (
|
||||
f"AcoustID verification failed: {verification_msg}"
|
||||
)
|
||||
|
||||
if task_id and batch_id:
|
||||
_notify_download_completed(batch_id, task_id, success=False)
|
||||
return
|
||||
else:
|
||||
logger.warning("AcoustID verification skipped: missing track/artist info")
|
||||
context['_acoustid_result'] = 'skip'
|
||||
else:
|
||||
logger.info(f"ℹ️ AcoustID verification not available: {available_reason}")
|
||||
context['_acoustid_result'] = 'disabled'
|
||||
except Exception as verify_error:
|
||||
logger.error(f"AcoustID verification error (continuing normally): {verify_error}")
|
||||
context['_acoustid_result'] = 'error'
|
||||
|
||||
search_result = context.get('search_result', {}) or {}
|
||||
if not isinstance(search_result, dict):
|
||||
search_result = {}
|
||||
is_simple_download = search_result.get('is_simple_download', False)
|
||||
if is_simple_download:
|
||||
logger.info(f"Processing simple download (no metadata enhancement): {file_path}")
|
||||
|
||||
destination, album_name, filename = build_simple_download_destination(context, file_path)
|
||||
if album_name:
|
||||
logger.info(f"Moving to album folder: {album_name}")
|
||||
else:
|
||||
logger.info("Moving to Transfer root (single track)")
|
||||
|
||||
safe_move_file(file_path, destination)
|
||||
logger.info(f"Moved simple download to: {destination}")
|
||||
cleanup_slskd_dedup_siblings(file_path)
|
||||
|
||||
with matched_context_lock:
|
||||
if context_key in matched_downloads_context:
|
||||
del matched_downloads_context[context_key]
|
||||
|
||||
if web_scan_manager:
|
||||
threading.Thread(
|
||||
target=lambda: web_scan_manager.request_scan("Simple download completed"),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
activity_target = f"{album_name}/{filename}" if album_name else filename
|
||||
add_activity_item("", "Download Complete", activity_target, "Now")
|
||||
logger.info(f"Simple download post-processing complete: {activity_target}")
|
||||
context['_simple_download_completed'] = True
|
||||
context['_final_path'] = str(destination)
|
||||
emit_track_downloaded(context, automation_engine)
|
||||
record_library_history_download(context)
|
||||
record_download_provenance(context)
|
||||
try:
|
||||
check_and_remove_from_wishlist(context)
|
||||
except Exception as wishlist_error:
|
||||
logger.error(f"[Simple Download] Error checking wishlist removal: {wishlist_error}")
|
||||
return
|
||||
|
||||
logger.info(f"Starting robust post-processing for: {context_key}")
|
||||
|
||||
context = normalize_import_context(context)
|
||||
artist_context = get_import_context_artist(context)
|
||||
track_info = get_import_track_info(context)
|
||||
original_search = get_import_original_search(context)
|
||||
has_clean_metadata = get_import_has_clean_metadata(context)
|
||||
|
||||
if not artist_context:
|
||||
logger.error("Post-processing failed: Missing artist context.")
|
||||
return
|
||||
|
||||
_junk_artist_names = {'', 'unknown', 'unknown artist', 'various artists', 'none', 'null'}
|
||||
_artist_name = (artist_context.get('name', '') if isinstance(artist_context, dict) else '').strip()
|
||||
if _artist_name.lower() in _junk_artist_names:
|
||||
logger.info(f"[Unknown Artist Guard] Artist name is '{_artist_name}' — attempting to resolve")
|
||||
_resolved = False
|
||||
track_info_guard = track_info or {}
|
||||
original_search_guard = original_search or {}
|
||||
|
||||
_ti_artists = track_info_guard.get('artists', [])
|
||||
if isinstance(_ti_artists, list) and _ti_artists:
|
||||
_first = _ti_artists[0]
|
||||
_name = _first.get('name', '') if isinstance(_first, dict) else str(_first)
|
||||
if _name and _name.strip().lower() not in _junk_artist_names:
|
||||
artist_context['name'] = _name.strip()
|
||||
logger.info(f"[Unknown Artist Guard] Resolved from track_info.artists: '{_name}'")
|
||||
_resolved = True
|
||||
|
||||
if not _resolved:
|
||||
_os_artist = original_search_guard.get('artist') or original_search_guard.get('artist_name') or ''
|
||||
if isinstance(_os_artist, str) and _os_artist.strip().lower() not in _junk_artist_names:
|
||||
artist_context['name'] = _os_artist.strip()
|
||||
logger.info(f"[Unknown Artist Guard] Resolved from original_search_result: '{_os_artist}'")
|
||||
_resolved = True
|
||||
|
||||
if not _resolved:
|
||||
_track_id = track_info_guard.get('id') or track_info_guard.get('track_id') or ''
|
||||
if _track_id:
|
||||
try:
|
||||
from core.metadata_service import get_client_for_source, get_primary_source
|
||||
|
||||
_guard_source = get_import_source(context) or get_primary_source()
|
||||
_fb_client = get_client_for_source(_guard_source) or get_client_for_source(get_primary_source())
|
||||
if hasattr(_fb_client, 'get_track_details'):
|
||||
_details = _fb_client.get_track_details(str(_track_id))
|
||||
if _details and isinstance(_details, dict):
|
||||
_d_artists = _details.get('artists', [])
|
||||
if isinstance(_d_artists, list) and _d_artists:
|
||||
_d_first = _d_artists[0]
|
||||
_d_name = _d_first.get('name', '') if isinstance(_d_first, dict) else str(_d_first)
|
||||
if _d_name and _d_name.strip().lower() not in _junk_artist_names:
|
||||
artist_context['name'] = _d_name.strip()
|
||||
logger.info(f"[Unknown Artist Guard] Resolved from metadata API: '{_d_name}'")
|
||||
_resolved = True
|
||||
except Exception as _guard_err:
|
||||
logger.error(f"[Unknown Artist Guard] Metadata re-fetch failed: {_guard_err}")
|
||||
|
||||
if not _resolved:
|
||||
logger.error(f"[Unknown Artist Guard] Could not resolve artist — proceeding with '{_artist_name}'")
|
||||
|
||||
context['artist'] = artist_context
|
||||
|
||||
playlist_folder_mode = track_info.get("_playlist_folder_mode", False)
|
||||
logger.debug(f"[Debug] Post-processing - track_info type: {type(track_info)}, is None: {track_info is None}, is empty: {not track_info}")
|
||||
logger.debug(f"[Debug] Post-processing - playlist_folder_mode: {playlist_folder_mode}")
|
||||
if track_info:
|
||||
logger.debug(f"[Debug] Post-processing - track_info keys: {list(track_info.keys())}")
|
||||
|
||||
if playlist_folder_mode:
|
||||
playlist_name = track_info.get("_playlist_name", "Unknown Playlist")
|
||||
logger.info(f"[Playlist Folder Mode] Organizing in playlist folder: {playlist_name}")
|
||||
|
||||
file_ext = os.path.splitext(file_path)[1]
|
||||
final_path, _ = build_final_path_for_track(context, artist_context, None, file_ext)
|
||||
logger.info(f"Playlist mode final path: '{final_path}'")
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
if os.path.exists(final_path):
|
||||
logger.info(
|
||||
f"[Playlist Folder Mode] Source gone but destination exists — already processed by another thread: "
|
||||
f"{os.path.basename(final_path)}"
|
||||
)
|
||||
context['_final_processed_path'] = final_path
|
||||
return
|
||||
pp_logger.info(f"[inner] EXCEPTION in post-processing for {context_key}: Source file not found and destination does not exist: {file_path}")
|
||||
raise FileNotFoundError(f"Source file not found and destination does not exist: {file_path}")
|
||||
|
||||
context['_audio_quality'] = get_audio_quality_string(file_path)
|
||||
if context['_audio_quality']:
|
||||
logger.info(f"Audio quality detected: {context['_audio_quality']}")
|
||||
|
||||
rejection_reason = check_flac_bit_depth(file_path, context)
|
||||
if rejection_reason:
|
||||
try:
|
||||
quarantine_path = move_to_quarantine(
|
||||
file_path,
|
||||
context,
|
||||
rejection_reason,
|
||||
automation_engine,
|
||||
)
|
||||
logger.info(f"File quarantined due to bit depth filter: {quarantine_path}")
|
||||
except Exception as quarantine_error:
|
||||
logger.error(f"Quarantine failed ({quarantine_error}), deleting file: {file_path}")
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
context['_bitdepth_rejected'] = True
|
||||
with matched_context_lock:
|
||||
if context_key in matched_downloads_context:
|
||||
del matched_downloads_context[context_key]
|
||||
|
||||
task_id = context.get('task_id')
|
||||
batch_id = context.get('batch_id')
|
||||
if task_id:
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
download_tasks[task_id]['error_message'] = f"Bit depth filter: {rejection_reason}"
|
||||
if task_id and batch_id:
|
||||
_notify_download_completed(batch_id, task_id, success=False)
|
||||
return
|
||||
|
||||
try:
|
||||
logger.warning(
|
||||
f"[Metadata Input] Playlist mode - artist: '{artist_context.get('name', 'MISSING')}' "
|
||||
f"(id: {artist_context.get('id', 'MISSING')})"
|
||||
)
|
||||
enhance_file_metadata(file_path, context, artist_context, None, runtime=metadata_runtime)
|
||||
except Exception as meta_err:
|
||||
import traceback
|
||||
pp_logger.info(f"[inner] Metadata enhancement FAILED for {context_key}: {meta_err}\n{traceback.format_exc()}")
|
||||
wipe_source_tags(file_path)
|
||||
|
||||
logger.info(f"Moving '{os.path.basename(file_path)}' to '{final_path}'")
|
||||
safe_move_file(file_path, final_path)
|
||||
context['_final_processed_path'] = final_path
|
||||
cleanup_slskd_dedup_siblings(file_path)
|
||||
|
||||
if config_manager.get('post_processing.replaygain_enabled', False):
|
||||
try:
|
||||
from core.replaygain import analyze_track as _rg_analyze, write_replaygain_tags as _rg_write, is_ffmpeg_available as _rg_ffmpeg_ok, RG_REFERENCE_LUFS as _RG_REF
|
||||
if _rg_ffmpeg_ok():
|
||||
lufs, peak_dbfs = _rg_analyze(final_path)
|
||||
gain_db = _RG_REF - lufs
|
||||
_rg_write(final_path, gain_db, peak_dbfs)
|
||||
pp_logger.info(f"ReplayGain: {gain_db:+.2f} dB — {os.path.basename(final_path)}")
|
||||
except Exception as rg_err:
|
||||
pp_logger.debug(f"ReplayGain analysis skipped: {rg_err}")
|
||||
|
||||
downsampled_path = downsample_hires_flac(final_path, context)
|
||||
if downsampled_path:
|
||||
final_path = downsampled_path
|
||||
context['_final_processed_path'] = final_path
|
||||
|
||||
blasphemy_path = create_lossy_copy(final_path)
|
||||
if blasphemy_path:
|
||||
context['_final_processed_path'] = blasphemy_path
|
||||
|
||||
downloads_path = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads'))
|
||||
cleanup_empty_directories(downloads_path, file_path)
|
||||
|
||||
logger.info(f"[Playlist Folder Mode] Post-processing complete: {final_path}")
|
||||
|
||||
try:
|
||||
check_and_remove_from_wishlist(context)
|
||||
except Exception as wishlist_error:
|
||||
logger.error(f"[Playlist Folder] Error checking wishlist removal: {wishlist_error}")
|
||||
|
||||
emit_track_downloaded(context, automation_engine)
|
||||
record_library_history_download(context)
|
||||
record_download_provenance(context)
|
||||
|
||||
task_id = context.get('task_id')
|
||||
batch_id = context.get('batch_id')
|
||||
if task_id and batch_id:
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['stream_processed'] = True
|
||||
download_tasks[task_id]['status'] = 'completed'
|
||||
logger.info(f"[Playlist Folder Mode] Marked task {task_id} as completed")
|
||||
_notify_download_completed(batch_id, task_id, success=True)
|
||||
return
|
||||
|
||||
is_album_download = bool(context.get("is_album_download", False))
|
||||
album_info = build_import_album_info(context, force_album=is_album_download)
|
||||
|
||||
if is_album_download:
|
||||
if has_clean_metadata:
|
||||
logger.info("Album context with clean metadata found - using normalized album info")
|
||||
else:
|
||||
logger.warning("Album context found without clean metadata - using normalized album info")
|
||||
elif not album_info.get('is_album'):
|
||||
logger.info("Single track download - attempting album detection")
|
||||
detected_album_info = detect_album_info_web(context, artist_context)
|
||||
if detected_album_info:
|
||||
album_info = detected_album_info
|
||||
|
||||
if album_info and album_info['is_album'] and not is_album_download:
|
||||
logger.info(
|
||||
"SMART ALBUM GROUPING for track=%r original_album=%r",
|
||||
album_info.get('clean_track_name', 'Unknown'),
|
||||
album_info.get('album_name', 'None'),
|
||||
)
|
||||
original_album = original_search.get("album") if original_search.get("album") else None
|
||||
consistent_album_name = resolve_album_group(artist_context, album_info, original_album)
|
||||
album_info['album_name'] = consistent_album_name
|
||||
logger.info("Album grouping complete: final_album=%r", consistent_album_name)
|
||||
elif album_info and album_info['is_album'] and is_album_download:
|
||||
logger.info(
|
||||
"EXPLICIT ALBUM DOWNLOAD - preserving album name=%r; skipping smart grouping",
|
||||
album_info.get('album_name', 'None'),
|
||||
)
|
||||
|
||||
context['_audio_quality'] = get_audio_quality_string(file_path)
|
||||
if context['_audio_quality']:
|
||||
logger.info(f"Audio quality detected: {context['_audio_quality']}")
|
||||
|
||||
rejection_reason = check_flac_bit_depth(file_path, context)
|
||||
if rejection_reason:
|
||||
try:
|
||||
quarantine_path = move_to_quarantine(
|
||||
file_path,
|
||||
context,
|
||||
rejection_reason,
|
||||
automation_engine,
|
||||
)
|
||||
logger.info(f"File quarantined due to bit depth filter: {quarantine_path}")
|
||||
except Exception as quarantine_error:
|
||||
logger.error(f"Quarantine failed ({quarantine_error}), deleting file: {file_path}")
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
context['_bitdepth_rejected'] = True
|
||||
with matched_context_lock:
|
||||
if context_key in matched_downloads_context:
|
||||
del matched_downloads_context[context_key]
|
||||
|
||||
task_id = context.get('task_id')
|
||||
batch_id = context.get('batch_id')
|
||||
if task_id:
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
download_tasks[task_id]['error_message'] = f"Bit depth filter: {rejection_reason}"
|
||||
if task_id and batch_id:
|
||||
_notify_download_completed(batch_id, task_id, success=False)
|
||||
return
|
||||
|
||||
file_ext = os.path.splitext(file_path)[1]
|
||||
clean_track_name = get_import_clean_title(
|
||||
context,
|
||||
album_info=album_info,
|
||||
default=original_search.get('title', 'Unknown Track'),
|
||||
)
|
||||
track_number = album_info.get('track_number', 1)
|
||||
logger.debug(
|
||||
"Final track_number processing: source=%s album_info_track_number=%s track_number=%s",
|
||||
album_info.get('source', 'unknown'),
|
||||
album_info.get('track_number', 'NOT_FOUND'),
|
||||
track_number,
|
||||
)
|
||||
if track_number is None:
|
||||
track_number = extract_track_number_from_filename(file_path)
|
||||
logger.info(
|
||||
"Track number was None; extracted from filename=%r -> %s",
|
||||
os.path.basename(file_path),
|
||||
track_number,
|
||||
)
|
||||
if not isinstance(track_number, int) or track_number < 1:
|
||||
logger.error(f"Invalid track number ({track_number}), defaulting to 1")
|
||||
track_number = 1
|
||||
|
||||
logger.debug(f"FINAL track_number used for filename: {track_number}")
|
||||
album_info['track_number'] = track_number
|
||||
album_info['clean_track_name'] = clean_track_name
|
||||
logger.info(f"[FIX] Updated album_info track_number to {track_number} for consistent metadata")
|
||||
|
||||
final_path, _ = build_final_path_for_track(context, artist_context, album_info, file_ext)
|
||||
logger.info(f"Resolved path: '{final_path}'")
|
||||
context['_final_processed_path'] = final_path
|
||||
|
||||
try:
|
||||
logger.warning(f"[Metadata Input] artist: '{artist_context.get('name', 'MISSING')}' (id: {artist_context.get('id', 'MISSING')})")
|
||||
if album_info:
|
||||
logger.warning(
|
||||
f"[Metadata Input] album: '{album_info.get('album_name', 'MISSING')}', "
|
||||
f"track#: {album_info.get('track_number', 'MISSING')}, disc#: {album_info.get('disc_number', 'MISSING')}, "
|
||||
f"source: {album_info.get('source', 'unknown')}"
|
||||
)
|
||||
else:
|
||||
logger.info("[Metadata Input] album_info: None (single track)")
|
||||
enhance_file_metadata(file_path, context, artist_context, album_info, runtime=metadata_runtime)
|
||||
except Exception as meta_err:
|
||||
import traceback
|
||||
pp_logger.info(f"[inner] Metadata enhancement FAILED for {context_key}: {meta_err}\n{traceback.format_exc()}")
|
||||
wipe_source_tags(file_path)
|
||||
|
||||
_enhance_source_info = context.get('track_info', {}).get('source_info') or {}
|
||||
if isinstance(_enhance_source_info, str):
|
||||
try:
|
||||
_enhance_source_info = json.loads(_enhance_source_info)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
_enhance_source_info = {}
|
||||
is_enhance_download = _enhance_source_info.get('enhance', False)
|
||||
|
||||
logger.info(f"Moving '{os.path.basename(file_path)}' to '{final_path}'")
|
||||
if os.path.exists(final_path):
|
||||
if not os.path.exists(file_path):
|
||||
logger.info(f"[Protection] Destination exists and source already gone - file already transferred: {os.path.basename(final_path)}")
|
||||
return
|
||||
try:
|
||||
from mutagen import File as MutagenFile
|
||||
existing_file = MutagenFile(final_path)
|
||||
has_metadata = existing_file is not None and len(existing_file.tags or {}) > 2
|
||||
if has_metadata and not is_enhance_download:
|
||||
_replace_lower = config_manager.get('import.replace_lower_quality', False)
|
||||
if _replace_lower:
|
||||
_existing_tier = get_quality_tier_from_extension(final_path)
|
||||
_incoming_tier = get_quality_tier_from_extension(file_path)
|
||||
if _incoming_tier[1] < _existing_tier[1]:
|
||||
logger.info(f"[Quality Replace] Replacing {_existing_tier[0]} with {_incoming_tier[0]}: {os.path.basename(final_path)}")
|
||||
try:
|
||||
os.remove(final_path)
|
||||
except Exception as e:
|
||||
logger.error(f"[Quality Replace] Could not remove existing file: {e}")
|
||||
else:
|
||||
logger.info(
|
||||
f"[Protection] Existing file is same or better quality ({_existing_tier[0]} vs {_incoming_tier[0]}) - skipping: "
|
||||
f"{os.path.basename(final_path)}"
|
||||
)
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"[Protection] Error removing redundant file: {e}")
|
||||
return
|
||||
else:
|
||||
logger.info(f"[Protection] Existing file already has metadata enhancement - skipping overwrite: {os.path.basename(final_path)}")
|
||||
logger.info(f"[Protection] Removing redundant download file: {os.path.basename(file_path)}")
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except FileNotFoundError:
|
||||
logger.error(f"[Protection] Could not remove redundant file (already gone): {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Protection] Error removing redundant file: {e}")
|
||||
return
|
||||
elif is_enhance_download:
|
||||
logger.info(f"[Enhance] Quality enhance mode — replacing existing file: {os.path.basename(final_path)}")
|
||||
try:
|
||||
os.remove(final_path)
|
||||
except Exception as e:
|
||||
logger.error(f"[Enhance] Could not remove existing file for replacement: {e}")
|
||||
else:
|
||||
logger.info(f"[Protection] Existing file lacks metadata - safe to overwrite: {os.path.basename(final_path)}")
|
||||
try:
|
||||
os.remove(final_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception as check_error:
|
||||
logger.error(f"[Protection] Error checking existing file metadata, proceeding with overwrite: {check_error}")
|
||||
try:
|
||||
if os.path.exists(final_path):
|
||||
os.remove(final_path)
|
||||
except Exception as e:
|
||||
logger.error(f"[Protection] Failed to remove existing file for overwrite: {e}")
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
if os.path.exists(final_path):
|
||||
logger.info(f"[Pre-Move] Source already gone and destination exists - another thread completed transfer: {os.path.basename(final_path)}")
|
||||
download_cover_art(album_info, os.path.dirname(final_path), context)
|
||||
generate_lrc_file(final_path, context, artist_context, album_info)
|
||||
return
|
||||
expected_dir = os.path.dirname(final_path)
|
||||
expected_stem = os.path.splitext(os.path.basename(final_path))[0]
|
||||
expected_ext = os.path.splitext(final_path)[1]
|
||||
found_variant = None
|
||||
check_exts = {expected_ext}
|
||||
if expected_ext == '.flac' and config_manager.get('lossy_copy.enabled', False) and config_manager.get('lossy_copy.delete_original', False):
|
||||
_lossy_ext_map = {'mp3': '.mp3', 'opus': '.opus', 'aac': '.m4a'}
|
||||
_lossy_codec = config_manager.get('lossy_copy.codec', 'mp3')
|
||||
check_exts.add(_lossy_ext_map.get(_lossy_codec, '.mp3'))
|
||||
if os.path.exists(expected_dir):
|
||||
for f in os.listdir(expected_dir):
|
||||
f_ext = os.path.splitext(f)[1].lower()
|
||||
if f_ext in check_exts and os.path.splitext(f)[0].startswith(expected_stem):
|
||||
found_variant = os.path.join(expected_dir, f)
|
||||
break
|
||||
if found_variant:
|
||||
logger.debug(f"[Pre-Move] Source gone but found variant in destination (stream processor handled it): {os.path.basename(found_variant)}")
|
||||
context['_final_processed_path'] = found_variant
|
||||
download_cover_art(album_info, expected_dir, context)
|
||||
generate_lrc_file(found_variant, context, artist_context, album_info)
|
||||
return
|
||||
logger.warning(f"[Pre-Move] Source file gone and no matching file in destination: {os.path.basename(file_path)}")
|
||||
raise FileNotFoundError(f"Source file vanished before move and destination does not exist: {file_path}")
|
||||
|
||||
safe_move_file(file_path, final_path)
|
||||
cleanup_slskd_dedup_siblings(file_path)
|
||||
|
||||
if is_enhance_download and _enhance_source_info.get('original_file_path'):
|
||||
original_enhance_path = _enhance_source_info['original_file_path']
|
||||
if os.path.normpath(original_enhance_path) != os.path.normpath(final_path) and os.path.exists(original_enhance_path):
|
||||
try:
|
||||
os.remove(original_enhance_path)
|
||||
old_fmt = os.path.splitext(original_enhance_path)[1]
|
||||
new_fmt = os.path.splitext(final_path)[1]
|
||||
logger.info(f"[Enhance] Upgraded {old_fmt} → {new_fmt}: {os.path.basename(final_path)}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Enhance] Could not remove old-format file: {e}")
|
||||
elif is_enhance_download:
|
||||
old_fmt = _enhance_source_info.get('original_format', 'unknown')
|
||||
new_fmt = os.path.splitext(final_path)[1]
|
||||
logger.info(f"[Enhance] Replaced in-place ({old_fmt} → {new_fmt}): {os.path.basename(final_path)}")
|
||||
|
||||
download_cover_art(album_info, os.path.dirname(final_path), context)
|
||||
generate_lrc_file(final_path, context, artist_context, album_info)
|
||||
|
||||
if config_manager.get('post_processing.replaygain_enabled', False):
|
||||
try:
|
||||
from core.replaygain import analyze_track as _rg_analyze, write_replaygain_tags as _rg_write, is_ffmpeg_available as _rg_ffmpeg_ok, RG_REFERENCE_LUFS as _RG_REF
|
||||
if _rg_ffmpeg_ok():
|
||||
lufs, peak_dbfs = _rg_analyze(final_path)
|
||||
gain_db = _RG_REF - lufs
|
||||
_rg_write(final_path, gain_db, peak_dbfs)
|
||||
pp_logger.info(f"ReplayGain: {gain_db:+.2f} dB, peak {peak_dbfs:.2f} dBFS — {os.path.basename(final_path)}")
|
||||
except Exception as rg_err:
|
||||
pp_logger.debug(f"ReplayGain analysis skipped: {rg_err}")
|
||||
|
||||
downsampled_path = downsample_hires_flac(final_path, context)
|
||||
if downsampled_path:
|
||||
final_path = downsampled_path
|
||||
context['_final_processed_path'] = final_path
|
||||
|
||||
blasphemy_path = create_lossy_copy(final_path)
|
||||
if blasphemy_path:
|
||||
context['_final_processed_path'] = blasphemy_path
|
||||
|
||||
downloads_path = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads'))
|
||||
cleanup_empty_directories(downloads_path, file_path)
|
||||
|
||||
logger.info(f"Post-processing complete for: {context.get('_final_processed_path', final_path)}")
|
||||
|
||||
emit_track_downloaded(context, automation_engine)
|
||||
record_library_history_download(context)
|
||||
record_download_provenance(context)
|
||||
record_soulsync_library_entry(context, artist_context, album_info)
|
||||
|
||||
try:
|
||||
if not playlist_folder_mode:
|
||||
completed_path = context.get('_final_processed_path', final_path)
|
||||
record_retag_download(context, artist_context, album_info, completed_path)
|
||||
except Exception as retag_err:
|
||||
logger.error(f"[Post-Process] Retag data capture failed (non-fatal): {retag_err}")
|
||||
|
||||
try:
|
||||
completed_path = context.get('_final_processed_path', final_path)
|
||||
batch_id_for_repair = context.get('batch_id')
|
||||
if completed_path and batch_id_for_repair and repair_worker:
|
||||
album_folder = os.path.dirname(str(completed_path))
|
||||
if album_folder:
|
||||
repair_worker.register_folder(batch_id_for_repair, album_folder)
|
||||
except Exception as repair_err:
|
||||
logger.error(f"[Post-Process] Repair folder registration failed: {repair_err}")
|
||||
|
||||
try:
|
||||
completed_path = context.get('_final_processed_path', final_path)
|
||||
batch_id_for_consistency = context.get('batch_id')
|
||||
if completed_path and batch_id_for_consistency and album_info and album_info.get('is_album'):
|
||||
_file_info = {
|
||||
'path': str(completed_path),
|
||||
'track_number': album_info.get('track_number', 1),
|
||||
'disc_number': album_info.get('disc_number', 1),
|
||||
'title': get_import_clean_title(
|
||||
context,
|
||||
album_info=album_info,
|
||||
default=album_info.get('clean_track_name', ''),
|
||||
),
|
||||
}
|
||||
with tasks_lock:
|
||||
if batch_id_for_consistency in download_batches:
|
||||
download_batches[batch_id_for_consistency].setdefault('_consistency_files', []).append(_file_info)
|
||||
except Exception as cons_err:
|
||||
logger.error(f"[Post-Process] Album consistency registration failed: {cons_err}")
|
||||
|
||||
try:
|
||||
check_and_remove_from_wishlist(context)
|
||||
except Exception as wishlist_error:
|
||||
logger.error(f"[Post-Process] Error checking wishlist removal: {wishlist_error}")
|
||||
|
||||
task_id = context.get('task_id')
|
||||
batch_id = context.get('batch_id')
|
||||
if task_id and batch_id:
|
||||
logger.info(f"[Post-Process] Calling completion callback for task {task_id} in batch {batch_id}")
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['stream_processed'] = True
|
||||
download_tasks[task_id]['status'] = 'completed'
|
||||
logger.info(f"[Post-Process] Marked task {task_id} as completed")
|
||||
_notify_download_completed(batch_id, task_id, success=True)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
pp_logger.info(f"[inner] EXCEPTION in post-processing for {context_key}: {e}")
|
||||
pp_logger.info(traceback.format_exc())
|
||||
logger.error(f"\nCRITICAL ERROR in post-processing for {context_key}: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
source_exists = os.path.exists(file_path) if file_path else False
|
||||
if source_exists:
|
||||
if context_key in processed_download_ids:
|
||||
processed_download_ids.remove(context_key)
|
||||
logger.warning(f"Removed {context_key} from processed set - will retry on next check")
|
||||
with matched_context_lock:
|
||||
if context_key not in matched_downloads_context:
|
||||
matched_downloads_context[context_key] = context
|
||||
logger.warning(f"Re-added {context_key} to context for retry")
|
||||
else:
|
||||
logger.warning(f"Source file gone, not retrying: {context_key}")
|
||||
finally:
|
||||
file_lock.release()
|
||||
with post_process_locks_lock:
|
||||
post_process_locks.pop(context_key, None)
|
||||
|
||||
|
||||
def post_process_matched_download_with_verification(context_key, context, file_path, task_id, batch_id, runtime, metadata_runtime=None):
|
||||
on_download_completed = getattr(runtime, "on_download_completed", None)
|
||||
|
||||
def _notify_download_completed(batch_id, task_id, success=True):
|
||||
if on_download_completed:
|
||||
on_download_completed(batch_id, task_id, success=success)
|
||||
|
||||
logger = pp_logger
|
||||
try:
|
||||
original_task_id = context.pop('task_id', None)
|
||||
original_batch_id = context.pop('batch_id', None)
|
||||
post_process_matched_download(context_key, context, file_path, runtime, metadata_runtime=metadata_runtime)
|
||||
if original_task_id:
|
||||
context['task_id'] = original_task_id
|
||||
if original_batch_id:
|
||||
context['batch_id'] = original_batch_id
|
||||
|
||||
if context.get('_race_guard_failed'):
|
||||
logger.info(f"Race guard: source file gone for task {task_id} — marking as failed")
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
download_tasks[task_id]['error_message'] = 'Source file was already processed or removed by another task'
|
||||
with matched_context_lock:
|
||||
if context_key in matched_downloads_context:
|
||||
del matched_downloads_context[context_key]
|
||||
_notify_download_completed(batch_id, task_id, success=False)
|
||||
return
|
||||
|
||||
if context.get('_acoustid_quarantined'):
|
||||
failure_msg = context.get('_acoustid_failure_msg', 'AcoustID verification failed')
|
||||
logger.info(f"File was quarantined by AcoustID verification (task={task_id}): {failure_msg}")
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
download_tasks[task_id]['error_message'] = f"AcoustID verification failed: {failure_msg}"
|
||||
with matched_context_lock:
|
||||
if context_key in matched_downloads_context:
|
||||
del matched_downloads_context[context_key]
|
||||
_notify_download_completed(batch_id, task_id, success=False)
|
||||
return
|
||||
|
||||
if context.get('_simple_download_completed'):
|
||||
expected_final_path = context.get('_final_path')
|
||||
if expected_final_path and os.path.exists(expected_final_path):
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
_mark_task_completed(task_id, context.get('track_info'))
|
||||
with matched_context_lock:
|
||||
if context_key in matched_downloads_context:
|
||||
del matched_downloads_context[context_key]
|
||||
_notify_download_completed(batch_id, task_id, success=True)
|
||||
return
|
||||
logger.info(
|
||||
f"FAILED simple download file not found at: {expected_final_path} "
|
||||
f"(task={task_id}, context={context_key})"
|
||||
)
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
download_tasks[task_id]['error_message'] = (
|
||||
f"Downloaded file not found at expected location: {os.path.basename(expected_final_path)}"
|
||||
)
|
||||
with matched_context_lock:
|
||||
if context_key in matched_downloads_context:
|
||||
del matched_downloads_context[context_key]
|
||||
_notify_download_completed(batch_id, task_id, success=False)
|
||||
return
|
||||
|
||||
expected_final_path = context.get('_final_processed_path')
|
||||
if not expected_final_path:
|
||||
logger.info(f"No _final_processed_path in context for task {task_id} — cannot verify, assuming success")
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
_mark_task_completed(task_id, context.get('track_info'))
|
||||
with matched_context_lock:
|
||||
if context_key in matched_downloads_context:
|
||||
del matched_downloads_context[context_key]
|
||||
_notify_download_completed(batch_id, task_id, success=True)
|
||||
return
|
||||
|
||||
if os.path.exists(expected_final_path):
|
||||
redownload_ctx = None
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
_mark_task_completed(task_id, context.get('track_info'))
|
||||
download_tasks[task_id]['metadata_enhanced'] = True
|
||||
redownload_ctx = download_tasks[task_id].get('_redownload_context')
|
||||
|
||||
with matched_context_lock:
|
||||
if context_key in matched_downloads_context:
|
||||
del matched_downloads_context[context_key]
|
||||
|
||||
if redownload_ctx:
|
||||
try:
|
||||
old_path = redownload_ctx.get('old_file_path')
|
||||
lib_track_id = redownload_ctx.get('library_track_id')
|
||||
if redownload_ctx.get('delete_old_file') and old_path and os.path.exists(old_path):
|
||||
if os.path.normpath(old_path) != os.path.normpath(expected_final_path):
|
||||
os.remove(old_path)
|
||||
logger.info(f"[Redownload] Deleted old file: {old_path}")
|
||||
if lib_track_id and expected_final_path:
|
||||
_rd_db = get_database()
|
||||
_rd_conn = _rd_db._get_connection()
|
||||
_rd_cursor = _rd_conn.cursor()
|
||||
_rd_cursor.execute(
|
||||
"""
|
||||
UPDATE tracks SET file_path = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(expected_final_path, lib_track_id),
|
||||
)
|
||||
_rd_conn.commit()
|
||||
_rd_conn.close()
|
||||
logger.info(f"[Redownload] Updated DB path for track {lib_track_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Redownload] Post-processing hook error: {e}")
|
||||
|
||||
_notify_download_completed(batch_id, task_id, success=True)
|
||||
else:
|
||||
track_name = get_import_clean_title(context, default=context_key)
|
||||
logger.info(f"FAILED verification for '{track_name}' (task={task_id})")
|
||||
logger.info(f" expected_final_path: {expected_final_path}")
|
||||
logger.info(f" file_path (source): {file_path}, exists={os.path.exists(file_path)}")
|
||||
logger.info(
|
||||
f" is_album={context.get('is_album_download', False)}, "
|
||||
f"has_clean_data={get_import_has_clean_metadata(context)}"
|
||||
)
|
||||
expected_dir = os.path.dirname(expected_final_path)
|
||||
if os.path.exists(expected_dir):
|
||||
dir_contents = os.listdir(expected_dir)
|
||||
logger.info(f" directory contains {len(dir_contents)} files: {dir_contents[:20]}")
|
||||
else:
|
||||
logger.info(f" directory does not exist: {expected_dir}")
|
||||
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
download_tasks[task_id]['error_message'] = (
|
||||
f'File verification failed: expected file at {os.path.basename(expected_final_path)} but it was not found after processing'
|
||||
)
|
||||
|
||||
with matched_context_lock:
|
||||
if context_key in matched_downloads_context:
|
||||
del matched_downloads_context[context_key]
|
||||
|
||||
_notify_download_completed(batch_id, task_id, success=False)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.info(f"EXCEPTION in post-processing for '{context_key}' (task={task_id}): {e}")
|
||||
logger.info(traceback.format_exc())
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
download_tasks[task_id]['error_message'] = f"Post-processing verification failed: {str(e)}"
|
||||
with matched_context_lock:
|
||||
if context_key in matched_downloads_context:
|
||||
del matched_downloads_context[context_key]
|
||||
_notify_download_completed(batch_id, task_id, success=False)
|
||||
405
core/imports/resolution.py
Normal file
405
core/imports/resolution.py
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
"""Single-track import lookup and context-building helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.metadata import registry as metadata_registry
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
|
||||
logger = get_logger("imports.resolution")
|
||||
|
||||
|
||||
def _extract_lookup_value(value: Any, *names: str, default: Any = None) -> Any:
|
||||
if value is None:
|
||||
return default
|
||||
|
||||
for name in names:
|
||||
if isinstance(value, dict):
|
||||
if name in value and value[name] is not None:
|
||||
return value[name]
|
||||
else:
|
||||
candidate = getattr(value, name, None)
|
||||
if candidate is not None:
|
||||
return candidate
|
||||
return default
|
||||
|
||||
|
||||
def _normalize_context_artists(artists: Any) -> List[Dict[str, Any]]:
|
||||
if not artists:
|
||||
return []
|
||||
|
||||
if isinstance(artists, (str, bytes)):
|
||||
artists = [artists]
|
||||
elif isinstance(artists, dict):
|
||||
artists = [artists]
|
||||
else:
|
||||
try:
|
||||
artists = list(artists)
|
||||
except TypeError:
|
||||
artists = [artists]
|
||||
|
||||
normalized: List[Dict[str, Any]] = []
|
||||
for artist in artists:
|
||||
if isinstance(artist, dict):
|
||||
name = _extract_lookup_value(artist, 'name', 'artist_name', 'title', default='') or ''
|
||||
artist_id = _extract_lookup_value(artist, 'id', 'artist_id', default='') or ''
|
||||
entry: Dict[str, Any] = {}
|
||||
if name:
|
||||
entry['name'] = str(name)
|
||||
if artist_id:
|
||||
entry['id'] = str(artist_id)
|
||||
genres = _extract_lookup_value(artist, 'genres', default=None)
|
||||
if genres is not None:
|
||||
entry['genres'] = genres
|
||||
if entry:
|
||||
normalized.append(entry)
|
||||
continue
|
||||
|
||||
name = str(artist).strip()
|
||||
if name:
|
||||
normalized.append({'name': name})
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _get_source_chain_for_lookup(
|
||||
source_override: Optional[str] = None,
|
||||
allow_fallback: bool = True,
|
||||
) -> List[str]:
|
||||
primary_source = metadata_registry.get_primary_source()
|
||||
source_chain = list(metadata_registry.get_source_priority(primary_source))
|
||||
override = (source_override or '').strip().lower()
|
||||
|
||||
if override:
|
||||
source_chain = [override] + [source for source in source_chain if source != override]
|
||||
|
||||
if not allow_fallback:
|
||||
source_chain = source_chain[:1]
|
||||
|
||||
return source_chain
|
||||
|
||||
|
||||
def _build_track_search_query(source: str, title: str, artist: str) -> str:
|
||||
base_query = " ".join(part for part in (title, artist) if part).strip()
|
||||
if source == 'deezer' and title:
|
||||
if artist:
|
||||
return f'artist:"{artist}" track:"{title}"'
|
||||
return f'track:"{title}"'
|
||||
return base_query or title or artist
|
||||
|
||||
|
||||
def _pick_best_track_match(search_results: List[Any], title: str, artist: str = '') -> Optional[Any]:
|
||||
if not search_results:
|
||||
return None
|
||||
|
||||
target_title = str(title or '').strip().lower()
|
||||
target_artist = str(artist or '').strip().lower()
|
||||
|
||||
for candidate in search_results:
|
||||
candidate_title = str(_extract_lookup_value(candidate, 'name', 'title', 'track_name', default='') or '').strip().lower()
|
||||
if candidate_title != target_title:
|
||||
continue
|
||||
|
||||
if not target_artist:
|
||||
return candidate
|
||||
|
||||
candidate_artists = _normalize_context_artists(_extract_lookup_value(candidate, 'artists', default=[]))
|
||||
candidate_artist_name = candidate_artists[0]['name'].strip().lower() if candidate_artists else ''
|
||||
if candidate_artist_name == target_artist:
|
||||
return candidate
|
||||
|
||||
return search_results[0]
|
||||
|
||||
|
||||
def search_tracks_for_source(source: str, client: Any, query: str, limit: int = 1) -> List[Any]:
|
||||
if not client or not hasattr(client, 'search_tracks'):
|
||||
return []
|
||||
|
||||
try:
|
||||
kwargs = {'limit': limit}
|
||||
if source == 'spotify':
|
||||
kwargs['allow_fallback'] = False
|
||||
return client.search_tracks(query, **kwargs) or []
|
||||
except Exception as exc:
|
||||
logger.debug("Could not search %s for %s: %s", source, query, exc)
|
||||
return []
|
||||
|
||||
|
||||
def _build_single_import_context_payload(
|
||||
track_data: Any,
|
||||
source: Optional[str],
|
||||
source_priority: List[str],
|
||||
requested_title: str = '',
|
||||
requested_artist: str = '',
|
||||
) -> Dict[str, Any]:
|
||||
album_data = _extract_lookup_value(track_data, 'album', default=None)
|
||||
|
||||
track_id = str(_extract_lookup_value(track_data, 'id', 'track_id', 'trackId', default='') or '')
|
||||
track_name = _extract_lookup_value(track_data, 'name', 'title', 'trackName', default='') or requested_title or 'Unknown Track'
|
||||
track_artists = _normalize_context_artists(_extract_lookup_value(track_data, 'artists', default=[]))
|
||||
if not track_artists and requested_artist:
|
||||
track_artists = [{'name': requested_artist}]
|
||||
|
||||
primary_track_artist = track_artists[0] if track_artists else {}
|
||||
primary_artist_name = primary_track_artist.get('name') or requested_artist or 'Unknown Artist'
|
||||
primary_artist_id = str(primary_track_artist.get('id', '') or _extract_lookup_value(track_data, 'artist_id', 'artistId', default='') or '')
|
||||
|
||||
album_name = _extract_lookup_value(track_data, 'album_name', 'collectionName', default='') or ''
|
||||
album_id = str(_extract_lookup_value(track_data, 'album_id', 'collectionId', 'albumId', default='') or '')
|
||||
release_date = str(_extract_lookup_value(track_data, 'release_date', default='') or '')
|
||||
album_type = str(_extract_lookup_value(track_data, 'album_type', default='album') or 'album')
|
||||
total_tracks = int(_extract_lookup_value(track_data, 'total_tracks', 'track_count', default=0) or 0)
|
||||
album_images: List[Dict[str, Any]] = []
|
||||
album_image_url = str(_extract_lookup_value(track_data, 'image_url', 'thumb_url', default='') or '')
|
||||
album_artists = _normalize_context_artists(_extract_lookup_value(track_data, 'album_artists', 'artists', default=[]))
|
||||
|
||||
if isinstance(album_data, dict):
|
||||
album_name = _extract_lookup_value(album_data, 'name', 'title', 'collectionName', default=album_name) or album_name
|
||||
album_id = str(_extract_lookup_value(album_data, 'id', 'album_id', 'collectionId', default=album_id) or album_id)
|
||||
release_date = str(_extract_lookup_value(album_data, 'release_date', default=release_date) or release_date)
|
||||
album_type = str(_extract_lookup_value(album_data, 'album_type', default=album_type) or album_type)
|
||||
total_tracks = int(_extract_lookup_value(album_data, 'total_tracks', 'track_count', 'nb_tracks', default=total_tracks) or total_tracks)
|
||||
album_images = _extract_lookup_value(album_data, 'images', default=[]) or []
|
||||
if not album_image_url:
|
||||
album_image_url = str(_extract_lookup_value(album_data, 'image_url', 'thumb_url', default='') or '')
|
||||
if not album_image_url and album_images:
|
||||
album_image_url = str(_extract_lookup_value(album_images[0], 'url', default='') or '')
|
||||
album_artists = _normalize_context_artists(_extract_lookup_value(album_data, 'artists', default=[]))
|
||||
elif album_data:
|
||||
album_name = album_name or str(album_data)
|
||||
|
||||
if not album_artists and primary_artist_name:
|
||||
album_artists = [{'name': primary_artist_name}]
|
||||
|
||||
if not album_image_url and album_images:
|
||||
album_image_url = str(_extract_lookup_value(album_images[0], 'url', default='') or '')
|
||||
|
||||
track_info = {
|
||||
'id': track_id,
|
||||
'name': track_name,
|
||||
'track_number': int(_extract_lookup_value(track_data, 'track_number', 'trackNumber', default=1) or 1),
|
||||
'disc_number': int(_extract_lookup_value(track_data, 'disc_number', 'discNumber', default=1) or 1),
|
||||
'duration_ms': int(_extract_lookup_value(track_data, 'duration_ms', 'duration', 'trackTimeMillis', default=0) or 0),
|
||||
'artists': track_artists or [{'name': primary_artist_name}],
|
||||
'uri': str(_extract_lookup_value(track_data, 'uri', default='') or ''),
|
||||
'album': album_name,
|
||||
'album_id': album_id,
|
||||
'album_type': album_type,
|
||||
'release_date': release_date,
|
||||
'_source': source or '',
|
||||
}
|
||||
|
||||
album_payload = {
|
||||
'id': album_id,
|
||||
'name': album_name,
|
||||
'release_date': release_date,
|
||||
'total_tracks': total_tracks or 1,
|
||||
'album_type': album_type,
|
||||
'image_url': album_image_url,
|
||||
'images': album_images,
|
||||
'artists': album_artists,
|
||||
'_source': source or '',
|
||||
}
|
||||
|
||||
artist_payload = {
|
||||
'id': primary_artist_id,
|
||||
'name': primary_artist_name,
|
||||
'genres': [],
|
||||
'_source': source or '',
|
||||
}
|
||||
|
||||
original_search = {
|
||||
'title': track_name,
|
||||
'artist': primary_artist_name,
|
||||
'album': album_name,
|
||||
'track_number': track_info['track_number'],
|
||||
'disc_number': track_info['disc_number'],
|
||||
'clean_title': track_name,
|
||||
'clean_album': album_name,
|
||||
'clean_artist': primary_artist_name,
|
||||
'artists': track_info['artists'],
|
||||
'duration_ms': track_info['duration_ms'],
|
||||
'id': track_id,
|
||||
'_source': source or '',
|
||||
}
|
||||
|
||||
return {
|
||||
'success': bool(track_id or track_name != requested_title or album_name),
|
||||
'source': source,
|
||||
'source_priority': source_priority,
|
||||
'context': {
|
||||
'artist': artist_payload,
|
||||
'album': album_payload,
|
||||
'track_info': track_info,
|
||||
'original_search_result': original_search,
|
||||
'is_album_download': False,
|
||||
'has_clean_metadata': bool(track_id),
|
||||
'has_full_metadata': bool(track_id),
|
||||
'source': source,
|
||||
'source_priority': source_priority,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_single_import_fallback_context(
|
||||
requested_title: str,
|
||||
requested_artist: str,
|
||||
source_priority: List[str],
|
||||
) -> Dict[str, Any]:
|
||||
artist_name = requested_artist or 'Unknown Artist'
|
||||
title = requested_title or 'Unknown Track'
|
||||
return {
|
||||
'success': False,
|
||||
'source': None,
|
||||
'source_priority': source_priority,
|
||||
'context': {
|
||||
'artist': {
|
||||
'id': '',
|
||||
'name': artist_name,
|
||||
'genres': [],
|
||||
'_source': '',
|
||||
},
|
||||
'album': {
|
||||
'id': '',
|
||||
'name': '',
|
||||
'release_date': '',
|
||||
'total_tracks': 1,
|
||||
'album_type': 'album',
|
||||
'image_url': '',
|
||||
'images': [],
|
||||
'artists': [],
|
||||
'_source': '',
|
||||
},
|
||||
'track_info': {
|
||||
'id': '',
|
||||
'name': title,
|
||||
'track_number': 1,
|
||||
'disc_number': 1,
|
||||
'duration_ms': 0,
|
||||
'artists': [{'name': artist_name}],
|
||||
'uri': '',
|
||||
'album': '',
|
||||
'album_id': '',
|
||||
'album_type': 'album',
|
||||
'release_date': '',
|
||||
'_source': '',
|
||||
},
|
||||
'original_search_result': {
|
||||
'title': title,
|
||||
'artist': artist_name,
|
||||
'album': '',
|
||||
'track_number': 1,
|
||||
'disc_number': 1,
|
||||
'clean_title': title,
|
||||
'clean_album': '',
|
||||
'clean_artist': artist_name,
|
||||
'artists': [{'name': artist_name}],
|
||||
'duration_ms': 0,
|
||||
'id': '',
|
||||
'_source': '',
|
||||
},
|
||||
'is_album_download': False,
|
||||
'has_clean_metadata': False,
|
||||
'has_full_metadata': False,
|
||||
'source': None,
|
||||
'source_priority': source_priority,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_single_track_import_context(
|
||||
title: str,
|
||||
artist: str = '',
|
||||
override_id: Optional[str] = None,
|
||||
override_source: str = 'spotify',
|
||||
source_override: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build an import context for singles using source-priority metadata lookup."""
|
||||
source_priority = _get_source_chain_for_lookup(source_override=source_override, allow_fallback=True)
|
||||
title = (title or '').strip()
|
||||
artist = (artist or '').strip()
|
||||
|
||||
if override_id:
|
||||
chosen_source = (override_source or 'spotify').strip().lower() or 'spotify'
|
||||
client = metadata_registry.get_client_for_source(chosen_source)
|
||||
if client and hasattr(client, 'get_track_details'):
|
||||
try:
|
||||
track_data = client.get_track_details(str(override_id))
|
||||
if track_data:
|
||||
payload = _build_single_import_context_payload(
|
||||
track_data,
|
||||
chosen_source,
|
||||
source_priority,
|
||||
requested_title=title,
|
||||
requested_artist=artist,
|
||||
)
|
||||
if payload['context']['artist'].get('id') and hasattr(client, 'get_artist'):
|
||||
try:
|
||||
artist_details = client.get_artist(payload['context']['artist']['id'])
|
||||
if artist_details:
|
||||
payload['context']['artist']['genres'] = _extract_lookup_value(
|
||||
artist_details,
|
||||
'genres',
|
||||
default=[],
|
||||
) or []
|
||||
except Exception:
|
||||
pass
|
||||
return payload
|
||||
except Exception as exc:
|
||||
logger.debug("Override track lookup failed on %s for %s: %s", chosen_source, override_id, exc)
|
||||
|
||||
for source in source_priority:
|
||||
client = metadata_registry.get_client_for_source(source)
|
||||
if not client:
|
||||
continue
|
||||
|
||||
search_query = _build_track_search_query(source, title, artist)
|
||||
if not search_query:
|
||||
continue
|
||||
|
||||
search_results = search_tracks_for_source(source, client, search_query, limit=5)
|
||||
if not search_results and search_query != title:
|
||||
search_results = search_tracks_for_source(source, client, title, limit=5)
|
||||
if not search_results and artist and search_query != artist:
|
||||
search_results = search_tracks_for_source(source, client, artist, limit=5)
|
||||
|
||||
if not search_results:
|
||||
continue
|
||||
|
||||
best_match = _pick_best_track_match(search_results, title or search_query, artist)
|
||||
if not best_match:
|
||||
continue
|
||||
|
||||
resolved_track_id = str(_extract_lookup_value(best_match, 'id', 'track_id', 'trackId', default='') or '')
|
||||
resolved_data = best_match
|
||||
if resolved_track_id and hasattr(client, 'get_track_details'):
|
||||
try:
|
||||
detailed = client.get_track_details(resolved_track_id)
|
||||
if detailed:
|
||||
resolved_data = detailed
|
||||
except Exception as exc:
|
||||
logger.debug("Track detail lookup failed on %s for %s: %s", source, resolved_track_id, exc)
|
||||
|
||||
payload = _build_single_import_context_payload(
|
||||
resolved_data,
|
||||
source,
|
||||
source_priority,
|
||||
requested_title=title,
|
||||
requested_artist=artist,
|
||||
)
|
||||
if payload['context']['artist'].get('id') and hasattr(client, 'get_artist'):
|
||||
try:
|
||||
artist_details = client.get_artist(payload['context']['artist']['id'])
|
||||
if artist_details:
|
||||
payload['context']['artist']['genres'] = _extract_lookup_value(
|
||||
artist_details,
|
||||
'genres',
|
||||
default=[],
|
||||
) or []
|
||||
except Exception:
|
||||
pass
|
||||
return payload
|
||||
|
||||
return _build_single_import_fallback_context(title, artist, source_priority)
|
||||
498
core/imports/side_effects.py
Normal file
498
core/imports/side_effects.py
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
"""Import post-processing side effects that do not need web runtime state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
from config.settings import config_manager
|
||||
from core.imports.context import (
|
||||
extract_artist_name,
|
||||
get_import_clean_album,
|
||||
get_import_clean_artist,
|
||||
get_import_clean_title,
|
||||
get_import_context_album,
|
||||
get_import_context_artist,
|
||||
get_import_original_search,
|
||||
get_import_search_result,
|
||||
get_import_source,
|
||||
get_import_source_ids,
|
||||
get_import_track_info,
|
||||
normalize_import_context,
|
||||
get_library_source_id_columns,
|
||||
)
|
||||
from database.music_database import get_database
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
|
||||
logger = get_logger("imports.side_effects")
|
||||
|
||||
|
||||
def _get_config_manager():
|
||||
return config_manager
|
||||
|
||||
|
||||
def _primary_track_artist_name(track_info: Dict[str, Any]) -> str:
|
||||
artists = (track_info or {}).get("artists", [])
|
||||
if isinstance(artists, list) and artists:
|
||||
first = artists[0]
|
||||
if isinstance(first, dict):
|
||||
return str(first.get("name", "") or "")
|
||||
return str(first or "")
|
||||
if isinstance(artists, str):
|
||||
return artists
|
||||
return str((track_info or {}).get("artist", "") or "")
|
||||
|
||||
|
||||
def _stable_soulsync_id(text: str) -> str:
|
||||
return str(abs(int(hashlib.md5(text.encode("utf-8", errors="replace")).hexdigest(), 16)) % (10 ** 9))
|
||||
|
||||
|
||||
def emit_track_downloaded(context: Dict[str, Any], automation_engine=None) -> None:
|
||||
"""Emit the track_downloaded automation event."""
|
||||
try:
|
||||
if not automation_engine:
|
||||
return
|
||||
|
||||
ti = context.get("track_info") or context.get("search_result") or {}
|
||||
artist_name = ""
|
||||
artists = ti.get("artists", [])
|
||||
if artists:
|
||||
first = artists[0]
|
||||
artist_name = first.get("name", str(first)) if isinstance(first, dict) else str(first)
|
||||
|
||||
automation_engine.emit(
|
||||
"track_downloaded",
|
||||
{
|
||||
"artist": artist_name,
|
||||
"title": ti.get("name", ti.get("title", "")),
|
||||
"album": ti.get("album", ""),
|
||||
"quality": context.get("_audio_quality", "Unknown"),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def record_library_history_download(context: Dict[str, Any]) -> None:
|
||||
"""Record a completed download to the library_history table."""
|
||||
try:
|
||||
search_result = context.get("original_search_result") or context.get("search_result") or {}
|
||||
username = search_result.get("username", context.get("_download_username", ""))
|
||||
source_map = {
|
||||
"youtube": "YouTube",
|
||||
"tidal": "Tidal",
|
||||
"qobuz": "Qobuz",
|
||||
"hifi": "HiFi",
|
||||
"deezer_dl": "Deezer",
|
||||
"lidarr": "Lidarr",
|
||||
}
|
||||
download_source = source_map.get(username, "Soulseek")
|
||||
|
||||
ti = context.get("track_info") or context.get("search_result") or {}
|
||||
artist_name = _primary_track_artist_name(ti)
|
||||
if not artist_name:
|
||||
artist_name = ti.get("artist", "")
|
||||
|
||||
album_raw = ti.get("album", "")
|
||||
album_name = album_raw.get("name", "") if isinstance(album_raw, dict) else str(album_raw or "")
|
||||
title = ti.get("name", ti.get("title", ""))
|
||||
quality = context.get("_audio_quality", "")
|
||||
file_path = context.get("_final_processed_path", context.get("_final_path", ""))
|
||||
|
||||
thumb_url = ""
|
||||
album_context = get_import_context_album(context)
|
||||
if album_context:
|
||||
thumb_url = album_context.get("image_url", "")
|
||||
if not thumb_url:
|
||||
images = album_context.get("images", [])
|
||||
if images:
|
||||
thumb_url = images[0].get("url", "")
|
||||
if not thumb_url:
|
||||
album_info = context.get("album_info", {})
|
||||
if isinstance(album_info, dict):
|
||||
thumb_url = album_info.get("album_image_url", "")
|
||||
|
||||
source_filename = search_result.get("filename", "")
|
||||
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"):
|
||||
stream_id = source_filename.split("||")[0]
|
||||
if stream_id and not source_track_id:
|
||||
source_track_id = stream_id
|
||||
|
||||
acoustid_result = context.get("_acoustid_result", "")
|
||||
|
||||
db = get_database()
|
||||
db.add_library_history_entry(
|
||||
event_type="download",
|
||||
title=title,
|
||||
artist_name=artist_name,
|
||||
album_name=album_name,
|
||||
quality=quality,
|
||||
file_path=file_path,
|
||||
thumb_url=thumb_url,
|
||||
download_source=download_source,
|
||||
source_track_id=source_track_id,
|
||||
source_track_title=source_track_title,
|
||||
source_filename=source_filename,
|
||||
acoustid_result=acoustid_result,
|
||||
source_artist=source_artist,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def record_download_provenance(context: Dict[str, Any]) -> None:
|
||||
"""Record source provenance for a completed download."""
|
||||
try:
|
||||
search_result = context.get("original_search_result") or context.get("search_result") or {}
|
||||
username = search_result.get("username", context.get("_download_username", ""))
|
||||
filename = search_result.get("filename", "")
|
||||
source_service = {
|
||||
"youtube": "youtube",
|
||||
"tidal": "tidal",
|
||||
"qobuz": "qobuz",
|
||||
"hifi": "hifi",
|
||||
"deezer_dl": "deezer",
|
||||
"lidarr": "lidarr",
|
||||
}.get(username, "soulseek")
|
||||
|
||||
ti = context.get("track_info") or context.get("search_result") or {}
|
||||
artist_name = _primary_track_artist_name(ti)
|
||||
if not artist_name:
|
||||
artist_name = ti.get("artist", "")
|
||||
|
||||
album_raw = ti.get("album", "")
|
||||
album_name = album_raw.get("name", "") if isinstance(album_raw, dict) else str(album_raw or "")
|
||||
title = ti.get("name", ti.get("title", ""))
|
||||
|
||||
file_path = context.get("_final_processed_path", context.get("_final_path", ""))
|
||||
quality = context.get("_audio_quality", "")
|
||||
size = search_result.get("size", 0)
|
||||
|
||||
bit_depth = None
|
||||
sample_rate = None
|
||||
bitrate = None
|
||||
try:
|
||||
if file_path and os.path.isfile(file_path):
|
||||
from mutagen import File as MutagenFile
|
||||
|
||||
audio = MutagenFile(file_path)
|
||||
if audio and audio.info:
|
||||
sample_rate = getattr(audio.info, "sample_rate", None)
|
||||
bitrate = getattr(audio.info, "bitrate", None)
|
||||
bit_depth = getattr(audio.info, "bits_per_sample", None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
db = get_database()
|
||||
db.record_track_download(
|
||||
file_path=file_path,
|
||||
source_service=source_service,
|
||||
source_username=username,
|
||||
source_filename=filename,
|
||||
source_size=size or 0,
|
||||
audio_quality=quality,
|
||||
track_title=title,
|
||||
track_artist=artist_name,
|
||||
track_album=album_name,
|
||||
bit_depth=bit_depth,
|
||||
sample_rate=sample_rate,
|
||||
bitrate=bitrate,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def record_soulsync_library_entry(context: Dict[str, Any], artist_context: Dict[str, Any], album_info: Dict[str, Any]) -> None:
|
||||
"""Write imported media to the SoulSync library tables when the active server is SoulSync."""
|
||||
try:
|
||||
if _get_config_manager().get_active_media_server() != "soulsync":
|
||||
return
|
||||
|
||||
context = normalize_import_context(context)
|
||||
final_path = context.get("_final_processed_path")
|
||||
if not final_path:
|
||||
return
|
||||
|
||||
album_ctx = get_import_context_album(context)
|
||||
track_info = get_import_track_info(context)
|
||||
original_search = get_import_original_search(context)
|
||||
source = get_import_source(context)
|
||||
source_ids = get_import_source_ids(context)
|
||||
source_columns = get_library_source_id_columns(source)
|
||||
|
||||
artist_name = extract_artist_name(artist_context) or get_import_clean_artist(context, default="")
|
||||
if not artist_name or artist_name in ("Unknown", "Unknown Artist"):
|
||||
return
|
||||
|
||||
album_name = ""
|
||||
if album_info and isinstance(album_info, dict):
|
||||
album_name = album_info.get("album_name", "")
|
||||
if not album_name:
|
||||
album_name = album_ctx.get("name", "") or original_search.get("album", "")
|
||||
if not album_name:
|
||||
album_name = track_info.get("name", "Unknown")
|
||||
|
||||
track_name = get_import_clean_title(
|
||||
context,
|
||||
album_info=album_info,
|
||||
default=track_info.get("name", "") or original_search.get("title", ""),
|
||||
)
|
||||
track_number = (track_info.get("track_number") or (album_info.get("track_number") if isinstance(album_info, dict) else None)) or 1
|
||||
duration_ms = track_info.get("duration_ms", 0) or 0
|
||||
|
||||
year = None
|
||||
release_date = album_ctx.get("release_date", "")
|
||||
if release_date and len(release_date) >= 4:
|
||||
try:
|
||||
year = int(release_date[:4])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
image_url = album_ctx.get("image_url", "")
|
||||
if not image_url:
|
||||
images = album_ctx.get("images", [])
|
||||
if images and isinstance(images, list) and len(images) > 0:
|
||||
img = images[0]
|
||||
image_url = img.get("url", "") if isinstance(img, dict) else str(img)
|
||||
|
||||
artist_source_id = source_ids.get("artist_id", "")
|
||||
album_source_id = source_ids.get("album_id", "")
|
||||
track_source_id = source_ids.get("track_id", "")
|
||||
for key in ("auto_import", "from_sync_modal", "explicit_artist", "explicit_album", ""):
|
||||
if artist_source_id == key:
|
||||
artist_source_id = ""
|
||||
if album_source_id == key:
|
||||
album_source_id = ""
|
||||
if track_source_id == key:
|
||||
track_source_id = ""
|
||||
|
||||
genres = (artist_context or {}).get("genres", []) if isinstance(artist_context, dict) else []
|
||||
if genres:
|
||||
from core.genre_filter import filter_genres as _filter_genres
|
||||
|
||||
genres = _filter_genres(genres, _get_config_manager())
|
||||
genres_json = json.dumps(genres) if genres else ""
|
||||
|
||||
bitrate = 0
|
||||
try:
|
||||
from mutagen import File as MutagenFile
|
||||
|
||||
audio = MutagenFile(final_path)
|
||||
if audio and hasattr(audio, "info") and audio.info and hasattr(audio.info, "bitrate"):
|
||||
bitrate = int(audio.info.bitrate / 1000) if audio.info.bitrate else 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
artist_id = _stable_soulsync_id(artist_name.lower().strip())
|
||||
album_id = _stable_soulsync_id(f"{artist_name}::{album_name}".lower().strip())
|
||||
track_id = _stable_soulsync_id(final_path)
|
||||
total_tracks = album_ctx.get("total_tracks", 0) or 0
|
||||
|
||||
db = get_database()
|
||||
with db._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT id FROM artists WHERE id = ? AND server_source = 'soulsync'", (artist_id,))
|
||||
if not cursor.fetchone():
|
||||
cursor.execute(
|
||||
"SELECT id FROM artists WHERE name COLLATE NOCASE = ? AND server_source = 'soulsync' LIMIT 1",
|
||||
(artist_name,),
|
||||
)
|
||||
existing_by_name = cursor.fetchone()
|
||||
if existing_by_name:
|
||||
artist_id = existing_by_name[0]
|
||||
else:
|
||||
cursor.execute("SELECT id FROM artists WHERE id = ?", (artist_id,))
|
||||
if cursor.fetchone():
|
||||
artist_id = _stable_soulsync_id(artist_name.lower().strip() + "::soulsync")
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO artists (id, name, genres, thumb_url, server_source, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'soulsync', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
""",
|
||||
(artist_id, artist_name, genres_json, image_url),
|
||||
)
|
||||
artist_source_col = source_columns.get("artist")
|
||||
if artist_source_col and artist_source_id:
|
||||
try:
|
||||
cursor.execute(
|
||||
f"UPDATE artists SET {artist_source_col} = ? WHERE id = ?",
|
||||
(artist_source_id, artist_id),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cursor.execute("SELECT id FROM albums WHERE id = ? AND server_source = 'soulsync'", (album_id,))
|
||||
if not cursor.fetchone():
|
||||
cursor.execute(
|
||||
"SELECT id FROM albums WHERE title COLLATE NOCASE = ? AND artist_id = ? AND server_source = 'soulsync' LIMIT 1",
|
||||
(album_name, artist_id),
|
||||
)
|
||||
existing_album_by_name = cursor.fetchone()
|
||||
if existing_album_by_name:
|
||||
album_id = existing_album_by_name[0]
|
||||
else:
|
||||
cursor.execute("SELECT id FROM albums WHERE id = ?", (album_id,))
|
||||
if cursor.fetchone():
|
||||
album_id = _stable_soulsync_id(f"{artist_name}::{album_name}::soulsync".lower().strip())
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO albums (id, artist_id, title, year, thumb_url, genres, track_count,
|
||||
duration, server_source, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'soulsync', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
""",
|
||||
(album_id, artist_id, album_name, year, image_url, genres_json, total_tracks, duration_ms),
|
||||
)
|
||||
album_source_col = source_columns.get("album")
|
||||
if album_source_col and album_source_id:
|
||||
try:
|
||||
cursor.execute(
|
||||
f"UPDATE albums SET {album_source_col} = ? WHERE id = ?",
|
||||
(album_source_id, album_id),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
track_artist = None
|
||||
track_artists_list = track_info.get("artists", []) or original_search.get("artists", [])
|
||||
if track_artists_list:
|
||||
first_track_artist = track_artists_list[0]
|
||||
if isinstance(first_track_artist, dict):
|
||||
ta_name = first_track_artist.get("name", "")
|
||||
else:
|
||||
ta_name = str(first_track_artist)
|
||||
if ta_name and ta_name.lower() != artist_name.lower():
|
||||
track_artist = ta_name
|
||||
|
||||
cursor.execute("SELECT id FROM tracks WHERE file_path = ?", (final_path,))
|
||||
if not cursor.fetchone():
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO tracks (id, album_id, artist_id, title, track_number,
|
||||
duration, file_path, bitrate, track_artist, server_source,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'soulsync', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
""",
|
||||
(
|
||||
track_id,
|
||||
album_id,
|
||||
artist_id,
|
||||
track_name,
|
||||
track_number,
|
||||
duration_ms,
|
||||
final_path,
|
||||
bitrate,
|
||||
track_artist,
|
||||
),
|
||||
)
|
||||
track_source_col = source_columns.get("track")
|
||||
if track_source_col and track_source_id:
|
||||
try:
|
||||
cursor.execute(
|
||||
f"UPDATE tracks SET {track_source_col} = ? WHERE id = ?",
|
||||
(track_source_id, track_id),
|
||||
)
|
||||
track_album_col = source_columns.get("track_album")
|
||||
if track_album_col and album_source_id:
|
||||
cursor.execute(
|
||||
f"UPDATE tracks SET {track_album_col} = ? WHERE id = ?",
|
||||
(album_source_id, track_id),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
conn.commit()
|
||||
logger.info("[SoulSync Library] Added: %s / %s / %s", artist_name, album_name, track_name)
|
||||
except Exception as exc:
|
||||
logger.error("[SoulSync Library] Could not record library entry: %s", exc)
|
||||
|
||||
|
||||
def record_retag_download(context: Dict[str, Any], artist_context: Dict[str, Any], album_info: Dict[str, Any], final_path: str) -> None:
|
||||
"""Record a completed download for later re-tagging."""
|
||||
try:
|
||||
db = get_database()
|
||||
|
||||
context = normalize_import_context(context)
|
||||
artist_context = get_import_context_artist(context) or (artist_context if isinstance(artist_context, dict) else {})
|
||||
album_context = get_import_context_album(context)
|
||||
track_info = get_import_track_info(context)
|
||||
original_search = get_import_original_search(context)
|
||||
source = get_import_source(context)
|
||||
source_ids = get_import_source_ids(context)
|
||||
|
||||
artist_name = extract_artist_name(artist_context) or get_import_clean_artist(context, default="Unknown Artist")
|
||||
is_album = album_info and album_info.get("is_album", False)
|
||||
group_type = "album" if is_album else "single"
|
||||
album_name = album_info.get("album_name", "") if album_info else get_import_clean_album(context, default=original_search.get("album", "Unknown"))
|
||||
|
||||
image_url = album_info.get("album_image_url") if album_info else None
|
||||
if not image_url:
|
||||
image_url = album_context.get("image_url", "")
|
||||
if not image_url and album_context.get("images"):
|
||||
images = album_context.get("images", [])
|
||||
if images and isinstance(images[0], dict):
|
||||
image_url = images[0].get("url", "")
|
||||
|
||||
total_tracks = album_context.get("total_tracks", 1) if album_context else 1
|
||||
release_date = album_context.get("release_date", "") if album_context else ""
|
||||
|
||||
spotify_album_id = None
|
||||
itunes_album_id = None
|
||||
if source == "spotify":
|
||||
spotify_album_id = source_ids.get("album_id", "") or None
|
||||
elif source == "itunes":
|
||||
itunes_album_id = source_ids.get("album_id", "") or None
|
||||
|
||||
group_id = db.find_retag_group(artist_name, album_name)
|
||||
if group_id is None:
|
||||
group_id = db.add_retag_group(
|
||||
group_type=group_type,
|
||||
artist_name=artist_name,
|
||||
album_name=album_name,
|
||||
image_url=image_url,
|
||||
spotify_album_id=spotify_album_id,
|
||||
itunes_album_id=itunes_album_id,
|
||||
total_tracks=total_tracks,
|
||||
release_date=release_date,
|
||||
)
|
||||
if group_id is None:
|
||||
return
|
||||
|
||||
track_number = album_info.get("track_number", 1) if album_info else (track_info.get("track_number", 1) or 1)
|
||||
disc_number = original_search.get("disc_number") or (album_info.get("disc_number", 1) if album_info else track_info.get("disc_number", 1) or 1)
|
||||
title = get_import_clean_title(
|
||||
context,
|
||||
album_info=album_info,
|
||||
default=album_info.get("clean_track_name", "Unknown Track") if album_info else "Unknown Track",
|
||||
)
|
||||
file_format = os.path.splitext(str(final_path))[1].lstrip(".").lower()
|
||||
|
||||
source_track_id = None
|
||||
itunes_track_id = None
|
||||
if source == "spotify":
|
||||
source_track_id = source_ids.get("track_id", "") or None
|
||||
elif source == "itunes":
|
||||
itunes_track_id = source_ids.get("track_id", "") or None
|
||||
|
||||
if not db.retag_track_exists(group_id, str(final_path)):
|
||||
db.add_retag_track(
|
||||
group_id=group_id,
|
||||
track_number=track_number,
|
||||
disc_number=disc_number,
|
||||
title=title,
|
||||
file_path=str(final_path),
|
||||
file_format=file_format,
|
||||
spotify_track_id=source_track_id,
|
||||
itunes_track_id=itunes_track_id,
|
||||
)
|
||||
logger.info("[Retag] Recorded track for retag: '%s' in '%s'", title, album_name)
|
||||
|
||||
db.trim_retag_groups(100)
|
||||
except Exception as exc:
|
||||
logger.error("[Retag] Could not record track for retag: %s", exc)
|
||||
556
core/imports/staging.py
Normal file
556
core/imports/staging.py
Normal file
|
|
@ -0,0 +1,556 @@
|
|||
"""Shared staging folder and import suggestion helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
from core.imports.paths import docker_resolve_path
|
||||
from core.imports.filename import extract_track_number_from_filename
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("imports.staging")
|
||||
|
||||
AUDIO_EXTENSIONS = {".mp3", ".flac", ".ogg", ".opus", ".m4a", ".aac", ".wav", ".wma", ".aiff", ".aif", ".ape"}
|
||||
|
||||
_import_suggestions_cache_lock = threading.Lock()
|
||||
_import_suggestions_cache: Dict[str, Any] = {
|
||||
"suggestions": [],
|
||||
"building": False,
|
||||
"built": False,
|
||||
}
|
||||
|
||||
|
||||
def _get_config_manager():
|
||||
try:
|
||||
from config.settings import config_manager
|
||||
|
||||
return config_manager
|
||||
except Exception:
|
||||
class _FallbackConfig:
|
||||
@staticmethod
|
||||
def get(key, default=None):
|
||||
return default
|
||||
|
||||
return _FallbackConfig()
|
||||
|
||||
|
||||
def get_staging_path() -> str:
|
||||
"""Resolve the configured staging folder path."""
|
||||
raw = _get_config_manager().get("import.staging_path", "./Staging")
|
||||
return docker_resolve_path(raw)
|
||||
|
||||
|
||||
def get_import_suggestions_cache() -> Dict[str, Any]:
|
||||
"""Expose the shared import suggestions cache."""
|
||||
return _import_suggestions_cache
|
||||
|
||||
|
||||
def get_primary_source() -> str:
|
||||
from core.metadata_service import get_primary_source as _get_primary_source
|
||||
|
||||
return _get_primary_source()
|
||||
|
||||
|
||||
def get_source_priority(preferred_source: str):
|
||||
from core.metadata_service import get_source_priority as _get_source_priority
|
||||
|
||||
return _get_source_priority(preferred_source)
|
||||
|
||||
|
||||
def get_client_for_source(source: str):
|
||||
from core.metadata_service import get_client_for_source as _get_client_for_source
|
||||
|
||||
return _get_client_for_source(source)
|
||||
|
||||
|
||||
def read_staging_file_metadata(file_path: str, filename: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Read common audio tag metadata from a staging file."""
|
||||
try:
|
||||
from mutagen import File as MutagenFile
|
||||
|
||||
tags = MutagenFile(file_path, easy=True)
|
||||
except Exception:
|
||||
tags = None
|
||||
|
||||
filename = filename or os.path.basename(file_path)
|
||||
stem = os.path.splitext(os.path.basename(filename))[0]
|
||||
|
||||
def _first_tag(*keys: str) -> str:
|
||||
if not tags:
|
||||
return ""
|
||||
for key in keys:
|
||||
try:
|
||||
value = tags.get(key) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
value = None
|
||||
if value:
|
||||
if isinstance(value, (list, tuple)):
|
||||
value = value[0] if value else ""
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
title = _first_tag("title")
|
||||
artist = _first_tag("artist")
|
||||
albumartist = _first_tag("albumartist")
|
||||
album = _first_tag("album")
|
||||
|
||||
if not title:
|
||||
title = stem
|
||||
if not albumartist:
|
||||
albumartist = artist
|
||||
|
||||
track_number = extract_track_number_from_filename(filename or file_path)
|
||||
try:
|
||||
# Preserve tag-based numbers when present, but still fall back to the filename parser.
|
||||
tag_track_number = _first_tag("tracknumber", "track_number")
|
||||
if tag_track_number:
|
||||
track_number = int(str(tag_track_number).split("/")[0].strip() or track_number)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
disc_number = 1
|
||||
try:
|
||||
tag_disc_number = _first_tag("discnumber", "disc_number")
|
||||
if tag_disc_number:
|
||||
disc_number = int(str(tag_disc_number).split("/")[0].strip() or 1)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"artist": artist,
|
||||
"albumartist": albumartist,
|
||||
"album": album,
|
||||
"track_number": track_number,
|
||||
"disc_number": disc_number,
|
||||
}
|
||||
|
||||
|
||||
def _search_albums_for_source(source: str, client: Any, query: str, limit: int = 5):
|
||||
from core.metadata_service import _search_albums_for_source as _metadata_search_albums_for_source
|
||||
|
||||
return _metadata_search_albums_for_source(source, client, query, limit=limit)
|
||||
|
||||
|
||||
def _search_tracks_for_source(source: str, client: Any, query: str, limit: int = 5):
|
||||
from core.imports.resolution import search_tracks_for_source
|
||||
|
||||
return search_tracks_for_source(source, client, query, limit=limit)
|
||||
|
||||
|
||||
def _extract_value(value: Any, *names: str, default: Any = None) -> Any:
|
||||
if value is None:
|
||||
return default
|
||||
|
||||
if isinstance(value, (str, bytes)):
|
||||
return default
|
||||
|
||||
for name in names:
|
||||
if isinstance(value, dict):
|
||||
if name in value and value[name] is not None:
|
||||
return value[name]
|
||||
else:
|
||||
candidate = getattr(value, name, None)
|
||||
if candidate is not None:
|
||||
return candidate
|
||||
|
||||
return default
|
||||
|
||||
|
||||
def _extract_artist_names(artists: Any) -> List[str]:
|
||||
if not artists:
|
||||
return []
|
||||
|
||||
if isinstance(artists, (str, bytes)):
|
||||
artist = str(artists).strip()
|
||||
return [artist] if artist else []
|
||||
|
||||
try:
|
||||
items = list(artists)
|
||||
except TypeError:
|
||||
items = [artists]
|
||||
|
||||
names: List[str] = []
|
||||
for artist in items:
|
||||
if isinstance(artist, dict):
|
||||
name = str(_extract_value(artist, "name", "artist_name", "title", default="") or "").strip()
|
||||
else:
|
||||
candidate = getattr(artist, "name", None)
|
||||
if candidate is None:
|
||||
candidate = artist
|
||||
name = str(candidate or "").strip()
|
||||
if name:
|
||||
names.append(name)
|
||||
|
||||
return names
|
||||
|
||||
|
||||
def _normalize_album_result(album: Any, source: str) -> Dict[str, Any]:
|
||||
album_id = str(_extract_value(album, "id", "album_id", "release_id", default="") or "").strip()
|
||||
album_name = str(_extract_value(album, "name", "title", default="") or "").strip()
|
||||
artists = _extract_artist_names(_extract_value(album, "artists", default=[]))
|
||||
artist_name = ", ".join(artists) if artists else str(
|
||||
_extract_value(album, "artist_name", "artist", default="Unknown Artist") or "Unknown Artist"
|
||||
).strip()
|
||||
release_date = str(_extract_value(album, "release_date", "releaseDate", default="") or "").strip()
|
||||
album_type = str(_extract_value(album, "album_type", "type", default="album") or "album").strip() or "album"
|
||||
|
||||
total_tracks = _extract_value(album, "total_tracks", "track_count", default=0)
|
||||
if isinstance(total_tracks, (list, tuple, set)):
|
||||
total_tracks = len(total_tracks)
|
||||
try:
|
||||
total_tracks = int(total_tracks or 0)
|
||||
except (TypeError, ValueError):
|
||||
total_tracks = 0
|
||||
|
||||
image_url = _extract_value(album, "image_url", "thumb_url", "cover_image", "cover_url", default="")
|
||||
if not image_url:
|
||||
images = _extract_value(album, "images", default=[]) or []
|
||||
if isinstance(images, dict):
|
||||
images = [images]
|
||||
elif isinstance(images, (str, bytes)):
|
||||
images = [images]
|
||||
try:
|
||||
images = list(images)
|
||||
except TypeError:
|
||||
images = [images]
|
||||
if images:
|
||||
first_image = images[0]
|
||||
if isinstance(first_image, (str, bytes)):
|
||||
image_url = str(first_image).strip()
|
||||
else:
|
||||
image_url = _extract_value(first_image, "url", "image_url", "src", default="")
|
||||
|
||||
return {
|
||||
"id": album_id or album_name or "unknown-album",
|
||||
"name": album_name or album_id or "Unknown Album",
|
||||
"artist": artist_name or "Unknown Artist",
|
||||
"release_date": release_date,
|
||||
"total_tracks": total_tracks,
|
||||
"image_url": str(image_url or ""),
|
||||
"album_type": album_type,
|
||||
"source": source,
|
||||
}
|
||||
|
||||
|
||||
def _album_fingerprint(album: Dict[str, Any]) -> Tuple[str, str, str, str]:
|
||||
return (
|
||||
str(album.get("name", "") or "").strip().casefold(),
|
||||
str(album.get("artist", "") or "").strip().casefold(),
|
||||
str(album.get("release_date", "") or "").strip()[:10].casefold(),
|
||||
str(album.get("album_type", "") or "").strip().casefold(),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_track_result(track: Any, source: str) -> Dict[str, Any]:
|
||||
track_id = str(_extract_value(track, "id", "track_id", "trackId", default="") or "").strip()
|
||||
track_name = str(_extract_value(track, "name", "title", "track_name", default="") or "").strip()
|
||||
artists = _extract_artist_names(_extract_value(track, "artists", default=[]))
|
||||
artist_name = ", ".join(artists) if artists else str(
|
||||
_extract_value(track, "artist", "artist_name", default="Unknown Artist") or "Unknown Artist"
|
||||
).strip()
|
||||
|
||||
album_value = _extract_value(track, "album", default=None)
|
||||
album_name = ""
|
||||
album_id = str(_extract_value(track, "album_id", "collectionId", "albumId", default="") or "").strip()
|
||||
if isinstance(album_value, dict):
|
||||
album_name = str(_extract_value(album_value, "name", "title", default="") or "").strip()
|
||||
album_id = album_id or str(_extract_value(album_value, "id", "album_id", "collectionId", default="") or "").strip()
|
||||
if not album_name:
|
||||
album_name = album_id
|
||||
elif isinstance(album_value, (str, bytes)):
|
||||
album_name = str(album_value).strip()
|
||||
elif album_value is not None:
|
||||
album_name = str(_extract_value(album_value, "name", "title", default=album_value) or "").strip()
|
||||
if not album_id:
|
||||
album_id = str(_extract_value(album_value, "id", "album_id", "collectionId", default="") or "").strip()
|
||||
|
||||
image_url = _extract_value(track, "image_url", "thumb_url", "cover_image", default="")
|
||||
if not image_url:
|
||||
images = _extract_value(track, "images", default=[]) or []
|
||||
if isinstance(images, dict):
|
||||
images = [images]
|
||||
elif isinstance(images, (str, bytes)):
|
||||
images = [images]
|
||||
try:
|
||||
images = list(images)
|
||||
except TypeError:
|
||||
images = [images]
|
||||
if images:
|
||||
first_image = images[0]
|
||||
if isinstance(first_image, (str, bytes)):
|
||||
image_url = str(first_image).strip()
|
||||
else:
|
||||
image_url = _extract_value(first_image, "url", "image_url", "src", default="")
|
||||
if not image_url and album_value is not None:
|
||||
album_images = _extract_value(album_value, "images", default=[]) or []
|
||||
if isinstance(album_images, dict):
|
||||
album_images = [album_images]
|
||||
elif isinstance(album_images, (str, bytes)):
|
||||
album_images = [album_images]
|
||||
try:
|
||||
album_images = list(album_images)
|
||||
except TypeError:
|
||||
album_images = [album_images]
|
||||
if album_images:
|
||||
first_album_image = album_images[0]
|
||||
if isinstance(first_album_image, (str, bytes)):
|
||||
image_url = str(first_album_image).strip()
|
||||
else:
|
||||
image_url = _extract_value(first_album_image, "url", "image_url", "src", default="")
|
||||
|
||||
duration_ms = _extract_value(track, "duration_ms", "duration", "trackTimeMillis", default=0)
|
||||
try:
|
||||
duration_ms = int(duration_ms or 0)
|
||||
except (TypeError, ValueError):
|
||||
duration_ms = 0
|
||||
|
||||
track_number = _extract_value(track, "track_number", "trackNumber", default=1)
|
||||
try:
|
||||
track_number = int(track_number or 1)
|
||||
except (TypeError, ValueError):
|
||||
track_number = 1
|
||||
|
||||
return {
|
||||
"id": track_id or track_name or "unknown-track",
|
||||
"name": track_name or track_id or "Unknown Track",
|
||||
"artist": artist_name or "Unknown Artist",
|
||||
"album": album_name or "",
|
||||
"album_id": album_id or "",
|
||||
"duration_ms": duration_ms,
|
||||
"image_url": str(image_url or ""),
|
||||
"track_number": track_number,
|
||||
"source": source,
|
||||
}
|
||||
|
||||
|
||||
def _read_staging_audio_tags(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
try:
|
||||
from mutagen import File as MutagenFile
|
||||
|
||||
tags = MutagenFile(file_path, easy=True)
|
||||
if not tags:
|
||||
return None, None
|
||||
|
||||
album = (tags.get("album") or [None])[0]
|
||||
artist = (tags.get("artist") or (tags.get("albumartist") or [None]))[0]
|
||||
album_text = str(album).strip() if album else ""
|
||||
artist_text = str(artist).strip() if artist else ""
|
||||
return (album_text or None, artist_text or None)
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
|
||||
def _collect_import_suggestion_queries(staging_path: str) -> List[str]:
|
||||
tag_albums: Dict[Tuple[str, str], int] = {}
|
||||
folder_hints: Dict[str, int] = {}
|
||||
|
||||
for root, _dirs, filenames in os.walk(staging_path):
|
||||
audio_files = [f for f in filenames if os.path.splitext(f)[1].lower() in AUDIO_EXTENSIONS]
|
||||
if not audio_files:
|
||||
continue
|
||||
|
||||
rel_dir = os.path.relpath(root, staging_path)
|
||||
if rel_dir != ".":
|
||||
top_folder = rel_dir.split(os.sep)[0]
|
||||
folder_hints[top_folder] = folder_hints.get(top_folder, 0) + len(audio_files)
|
||||
|
||||
for fname in audio_files:
|
||||
full_path = os.path.join(root, fname)
|
||||
album, artist = _read_staging_audio_tags(full_path)
|
||||
if album:
|
||||
key = (album.strip(), (artist or "").strip())
|
||||
tag_albums[key] = tag_albums.get(key, 0) + 1
|
||||
|
||||
queries: List[str] = []
|
||||
seen_lower = set()
|
||||
|
||||
for (album, artist), _count in sorted(tag_albums.items(), key=lambda item: -item[1]):
|
||||
q = f"{album} {artist}".strip() if artist else album
|
||||
if q and q.lower() not in seen_lower:
|
||||
seen_lower.add(q.lower())
|
||||
queries.append(q)
|
||||
|
||||
for folder, _count in sorted(folder_hints.items(), key=lambda item: -item[1]):
|
||||
q = folder.replace("_", " ")
|
||||
if q and q.lower() not in seen_lower:
|
||||
seen_lower.add(q.lower())
|
||||
queries.append(q)
|
||||
|
||||
return queries[:5]
|
||||
|
||||
|
||||
def search_import_albums(query: str, limit: int = 12) -> List[Dict[str, Any]]:
|
||||
"""Search albums using the configured metadata provider first."""
|
||||
query = (query or "").strip()
|
||||
if not query:
|
||||
return []
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
seen = set()
|
||||
source_chain = get_source_priority(get_primary_source())
|
||||
|
||||
for source in source_chain:
|
||||
client = get_client_for_source(source)
|
||||
if not client:
|
||||
continue
|
||||
|
||||
source_results = _search_albums_for_source(source, client, query, limit=limit)
|
||||
if not source_results:
|
||||
continue
|
||||
|
||||
added_for_source = False
|
||||
for album in source_results:
|
||||
suggestion = _normalize_album_result(album, source)
|
||||
fingerprint = _album_fingerprint(suggestion)
|
||||
if fingerprint in seen:
|
||||
continue
|
||||
seen.add(fingerprint)
|
||||
results.append(suggestion)
|
||||
added_for_source = True
|
||||
if len(results) >= limit:
|
||||
return results[:limit]
|
||||
|
||||
if added_for_source:
|
||||
break
|
||||
|
||||
return results[:limit]
|
||||
|
||||
|
||||
def search_import_tracks(query: str, limit: int = 30) -> List[Dict[str, Any]]:
|
||||
"""Search tracks using the configured metadata provider priority order."""
|
||||
query = (query or "").strip()
|
||||
if not query:
|
||||
return []
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
source_chain = get_source_priority(get_primary_source())
|
||||
|
||||
for source in source_chain:
|
||||
client = get_client_for_source(source)
|
||||
if not client:
|
||||
continue
|
||||
|
||||
source_results = _search_tracks_for_source(source, client, query, limit=limit)
|
||||
if not source_results:
|
||||
continue
|
||||
|
||||
for track in source_results:
|
||||
results.append(_normalize_track_result(track, source))
|
||||
if len(results) >= limit:
|
||||
return results[:limit]
|
||||
break
|
||||
|
||||
return results[:limit]
|
||||
|
||||
|
||||
def _build_import_suggestions_background():
|
||||
cache = _import_suggestions_cache
|
||||
|
||||
with _import_suggestions_cache_lock:
|
||||
if cache["building"]:
|
||||
return
|
||||
cache["building"] = True
|
||||
|
||||
try:
|
||||
staging_path = get_staging_path()
|
||||
if not os.path.isdir(staging_path):
|
||||
with _import_suggestions_cache_lock:
|
||||
cache["suggestions"] = []
|
||||
cache["built"] = True
|
||||
return
|
||||
|
||||
queries = _collect_import_suggestion_queries(staging_path)
|
||||
if not queries:
|
||||
with _import_suggestions_cache_lock:
|
||||
cache["suggestions"] = []
|
||||
cache["built"] = True
|
||||
return
|
||||
|
||||
suggestions: List[Dict[str, Any]] = []
|
||||
seen = set()
|
||||
for query in queries:
|
||||
try:
|
||||
albums = search_import_albums(query, limit=2)
|
||||
for album in albums:
|
||||
fingerprint = _album_fingerprint(album)
|
||||
if fingerprint in seen:
|
||||
continue
|
||||
seen.add(fingerprint)
|
||||
suggestions.append(album)
|
||||
except Exception as exc:
|
||||
logger.warning("Import suggestion search failed for %r: %s", query, exc)
|
||||
|
||||
with _import_suggestions_cache_lock:
|
||||
cache["suggestions"] = suggestions[:8]
|
||||
cache["built"] = True
|
||||
|
||||
logger.info(
|
||||
"Import suggestions cache built: %s suggestions from %s hints",
|
||||
len(cache["suggestions"]),
|
||||
len(queries),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Error building import suggestions cache: %s", exc)
|
||||
with _import_suggestions_cache_lock:
|
||||
cache["suggestions"] = []
|
||||
cache["built"] = True
|
||||
finally:
|
||||
with _import_suggestions_cache_lock:
|
||||
cache["building"] = False
|
||||
|
||||
|
||||
def start_import_suggestions_cache():
|
||||
"""Start building the import suggestions cache in a background thread."""
|
||||
threading.Thread(
|
||||
target=_build_import_suggestions_background,
|
||||
daemon=True,
|
||||
name="import-suggestions-cache",
|
||||
).start()
|
||||
|
||||
|
||||
def refresh_import_suggestions_cache():
|
||||
"""Invalidate and rebuild the suggestions cache."""
|
||||
with _import_suggestions_cache_lock:
|
||||
_import_suggestions_cache["built"] = False
|
||||
start_import_suggestions_cache()
|
||||
|
||||
|
||||
def collect_staging_files(file_paths: Optional[Iterable[str]] = None) -> List[Dict[str, Any]]:
|
||||
"""Collect audio files from the staging area with normalized metadata."""
|
||||
staging_path = get_staging_path()
|
||||
file_filter: Optional[set[str]] = set(file_paths) if file_paths else None
|
||||
staging_files: List[Dict[str, Any]] = []
|
||||
|
||||
if not os.path.isdir(staging_path):
|
||||
return staging_files
|
||||
|
||||
for root, _dirs, filenames in os.walk(staging_path):
|
||||
for filename in filenames:
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if ext not in AUDIO_EXTENSIONS:
|
||||
continue
|
||||
|
||||
full_path = os.path.join(root, filename)
|
||||
if file_filter is not None and full_path not in file_filter:
|
||||
continue
|
||||
|
||||
meta = read_staging_file_metadata(full_path, filename)
|
||||
staging_files.append(
|
||||
{
|
||||
"filename": filename,
|
||||
"full_path": full_path,
|
||||
"title": meta.get("title", ""),
|
||||
"artist": meta.get("albumartist") or meta.get("artist") or "",
|
||||
"album": meta.get("album", ""),
|
||||
"albumartist": meta.get("albumartist") or meta.get("artist") or "",
|
||||
"track_number": meta.get("track_number", 1),
|
||||
"disc_number": meta.get("disc_number", 1),
|
||||
}
|
||||
)
|
||||
|
||||
return staging_files
|
||||
|
|
@ -5,7 +5,7 @@ import threading
|
|||
from functools import wraps
|
||||
from dataclasses import dataclass
|
||||
from utils.logging_config import get_logger
|
||||
from core.metadata_cache import get_metadata_cache
|
||||
from core.metadata.cache import get_metadata_cache
|
||||
|
||||
logger = get_logger("itunes_client")
|
||||
|
||||
|
|
|
|||
0
core/library/__init__.py
Normal file
0
core/library/__init__.py
Normal file
223
core/library/duplicate_cleaner.py
Normal file
223
core/library/duplicate_cleaner.py
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
"""Duplicate cleaner — lifted from web_server.py.
|
||||
|
||||
The function body is byte-identical to the original. Module-level
|
||||
state and helpers are injected via init() because the duplicate
|
||||
cleaner state dict, lock, automation engine, and docker_resolve_path
|
||||
helper all live in web_server.py.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from config.settings import config_manager
|
||||
from core.runtime_state import add_activity_item
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Injected at runtime via init().
|
||||
duplicate_cleaner_state = None
|
||||
duplicate_cleaner_lock = None
|
||||
docker_resolve_path = None
|
||||
automation_engine = None
|
||||
|
||||
|
||||
def init(state, lock, resolve_path_fn, engine):
|
||||
"""Bind shared state/helpers from web_server."""
|
||||
global duplicate_cleaner_state, duplicate_cleaner_lock
|
||||
global docker_resolve_path, automation_engine
|
||||
duplicate_cleaner_state = state
|
||||
duplicate_cleaner_lock = lock
|
||||
docker_resolve_path = resolve_path_fn
|
||||
automation_engine = engine
|
||||
|
||||
|
||||
def _run_duplicate_cleaner():
|
||||
"""Main duplicate cleaner worker function - scans Transfer folder for duplicate files"""
|
||||
import os
|
||||
import shutil
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
with duplicate_cleaner_lock:
|
||||
duplicate_cleaner_state["status"] = "running"
|
||||
duplicate_cleaner_state["phase"] = "Initializing scan..."
|
||||
duplicate_cleaner_state["progress"] = 0
|
||||
duplicate_cleaner_state["files_scanned"] = 0
|
||||
duplicate_cleaner_state["total_files"] = 0
|
||||
duplicate_cleaner_state["duplicates_found"] = 0
|
||||
duplicate_cleaner_state["deleted"] = 0
|
||||
duplicate_cleaner_state["space_freed"] = 0
|
||||
duplicate_cleaner_state["error_message"] = ""
|
||||
|
||||
logger.warning("[Duplicate Cleaner] Starting duplicate scan...")
|
||||
|
||||
# Get Transfer folder path from config
|
||||
transfer_folder = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer'))
|
||||
if not transfer_folder or not os.path.exists(transfer_folder):
|
||||
with duplicate_cleaner_lock:
|
||||
duplicate_cleaner_state["status"] = "error"
|
||||
duplicate_cleaner_state["phase"] = "Output folder not configured or does not exist"
|
||||
duplicate_cleaner_state["error_message"] = "Please configure output folder in settings"
|
||||
logger.warning(f"[Duplicate Cleaner] Transfer folder not found: {transfer_folder}")
|
||||
return
|
||||
|
||||
# Create deleted folder if it doesn't exist
|
||||
deleted_folder = os.path.join(transfer_folder, 'deleted')
|
||||
os.makedirs(deleted_folder, exist_ok=True)
|
||||
logger.warning(f"[Duplicate Cleaner] Deleted folder: {deleted_folder}")
|
||||
|
||||
# Phase 1: Count total files for progress tracking
|
||||
with duplicate_cleaner_lock:
|
||||
duplicate_cleaner_state["phase"] = "Counting files..."
|
||||
|
||||
total_files = 0
|
||||
for _root, dirs, files in os.walk(transfer_folder):
|
||||
# Skip the deleted folder itself
|
||||
if 'deleted' in dirs:
|
||||
dirs.remove('deleted')
|
||||
total_files += len(files)
|
||||
|
||||
logger.warning(f"[Duplicate Cleaner] Found {total_files} total files to scan")
|
||||
|
||||
with duplicate_cleaner_lock:
|
||||
duplicate_cleaner_state["total_files"] = total_files
|
||||
duplicate_cleaner_state["phase"] = f"Scanning {total_files} files..."
|
||||
|
||||
# Phase 2: Scan and group files by directory and filename
|
||||
# Structure: {directory_path: {filename_without_ext: [full_file_paths]}}
|
||||
files_by_dir_and_name = defaultdict(lambda: defaultdict(list))
|
||||
files_scanned = 0
|
||||
|
||||
# Audio file extensions to consider
|
||||
audio_extensions = {'.flac', '.mp3', '.m4a', '.aac', '.opus', '.ogg', '.wav', '.ape', '.wma', '.alac', '.aiff', '.aif', '.dsf', '.dff'}
|
||||
|
||||
for root, dirs, files in os.walk(transfer_folder):
|
||||
# Skip the deleted folder
|
||||
if 'deleted' in dirs:
|
||||
dirs.remove('deleted')
|
||||
|
||||
for file in files:
|
||||
files_scanned += 1
|
||||
|
||||
# Update progress
|
||||
with duplicate_cleaner_lock:
|
||||
duplicate_cleaner_state["files_scanned"] = files_scanned
|
||||
duplicate_cleaner_state["progress"] = (files_scanned / total_files) * 100 if total_files > 0 else 0
|
||||
duplicate_cleaner_state["phase"] = f"Scanning: {file}"
|
||||
|
||||
# Get file extension
|
||||
file_path = os.path.join(root, file)
|
||||
file_name, file_ext = os.path.splitext(file)
|
||||
file_ext_lower = file_ext.lower()
|
||||
|
||||
# Only process audio files
|
||||
if file_ext_lower not in audio_extensions:
|
||||
continue
|
||||
|
||||
# Group by directory and filename (without extension)
|
||||
files_by_dir_and_name[root][file_name].append({
|
||||
'full_path': file_path,
|
||||
'extension': file_ext_lower,
|
||||
'size': os.path.getsize(file_path)
|
||||
})
|
||||
|
||||
# Phase 3: Process duplicates
|
||||
with duplicate_cleaner_lock:
|
||||
duplicate_cleaner_state["phase"] = "Processing duplicates..."
|
||||
|
||||
# Quality priority: FLAC > OPUS/OGG > M4A/AAC > MP3/WMA
|
||||
format_priority = {
|
||||
'.flac': 1, '.ape': 1, '.wav': 1, '.alac': 1, '.aiff': 1, '.aif': 1, '.dsf': 1, '.dff': 1, # Lossless
|
||||
'.opus': 2, '.ogg': 2, # High quality lossy
|
||||
'.m4a': 3, '.aac': 3, # Standard lossy
|
||||
'.mp3': 4, '.wma': 4 # Lower quality lossy
|
||||
}
|
||||
|
||||
duplicates_found = 0
|
||||
deleted_count = 0
|
||||
space_freed = 0
|
||||
|
||||
for directory, files_by_name in files_by_dir_and_name.items():
|
||||
for filename, file_versions in files_by_name.items():
|
||||
# Only process if we have duplicates (more than one version)
|
||||
if len(file_versions) <= 1:
|
||||
continue
|
||||
|
||||
duplicates_found += len(file_versions) - 1 # Count all but the one we keep
|
||||
logger.warning(f"[Duplicate Cleaner] Found {len(file_versions)} versions of '{filename}' in {directory}")
|
||||
|
||||
# Sort by priority: best format first, then largest size
|
||||
def sort_key(f):
|
||||
priority = format_priority.get(f['extension'], 999)
|
||||
size = f['size']
|
||||
return (priority, -size) # Negative size for descending order
|
||||
|
||||
sorted_versions = sorted(file_versions, key=sort_key)
|
||||
|
||||
# Keep the first one (best quality), delete the rest
|
||||
best_version = sorted_versions[0]
|
||||
logger.warning(f"[Duplicate Cleaner] Keeping: {os.path.basename(best_version['full_path'])} "
|
||||
f"({best_version['extension']}, {best_version['size']} bytes)")
|
||||
|
||||
for duplicate_file in sorted_versions[1:]:
|
||||
try:
|
||||
# Move to deleted folder with relative path preserved
|
||||
relative_path = os.path.relpath(duplicate_file['full_path'], transfer_folder)
|
||||
deleted_path = os.path.join(deleted_folder, relative_path)
|
||||
|
||||
# Create subdirectories in deleted folder if needed
|
||||
os.makedirs(os.path.dirname(deleted_path), exist_ok=True)
|
||||
|
||||
# Move the file
|
||||
shutil.move(duplicate_file['full_path'], deleted_path)
|
||||
|
||||
# Track stats
|
||||
deleted_count += 1
|
||||
space_freed += duplicate_file['size']
|
||||
|
||||
logger.warning(f"[Duplicate Cleaner] Moved to deleted: {os.path.basename(duplicate_file['full_path'])} "
|
||||
f"({duplicate_file['extension']}, {duplicate_file['size']} bytes)")
|
||||
|
||||
# Update stats
|
||||
with duplicate_cleaner_lock:
|
||||
duplicate_cleaner_state["deleted"] = deleted_count
|
||||
duplicate_cleaner_state["space_freed"] = space_freed
|
||||
duplicate_cleaner_state["duplicates_found"] = duplicates_found
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Duplicate Cleaner] Error moving file {duplicate_file['full_path']}: {e}")
|
||||
continue
|
||||
|
||||
# Scan complete
|
||||
with duplicate_cleaner_lock:
|
||||
duplicate_cleaner_state["status"] = "finished"
|
||||
duplicate_cleaner_state["progress"] = 100
|
||||
duplicate_cleaner_state["phase"] = "Cleaning complete"
|
||||
|
||||
space_mb = space_freed / (1024 * 1024)
|
||||
logger.warning(f"[Duplicate Cleaner] Scan complete: {files_scanned} files scanned, "
|
||||
f"{duplicates_found} duplicates found, {deleted_count} files moved to deleted folder, "
|
||||
f"{space_mb:.2f} MB freed")
|
||||
|
||||
# Add activity
|
||||
add_activity_item("", "Duplicate Cleaner Complete",
|
||||
f"{deleted_count} files removed, {space_mb:.1f} MB freed", "Now")
|
||||
|
||||
try:
|
||||
if automation_engine:
|
||||
automation_engine.emit('duplicate_scan_completed', {
|
||||
'files_scanned': str(files_scanned),
|
||||
'duplicates_found': str(duplicates_found),
|
||||
'space_freed': f"{space_mb:.1f} MB",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Duplicate Cleaner] Critical error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
with duplicate_cleaner_lock:
|
||||
duplicate_cleaner_state["status"] = "error"
|
||||
duplicate_cleaner_state["error_message"] = str(e)
|
||||
duplicate_cleaner_state["phase"] = f"Error: {str(e)}"
|
||||
259
core/library/redownload.py
Normal file
259
core/library/redownload.py
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
"""Track redownload endpoint — lifted from web_server.py.
|
||||
|
||||
Body is byte-identical to the original. The ``spotify_client`` proxy
|
||||
+ helper shims for the iTunes/Deezer registry clients let the body
|
||||
resolve its original names; ``_resolve_library_file_path``,
|
||||
``_attempt_download_with_candidates``, and ``missing_download_executor``
|
||||
are injected via init() because they live in web_server.py.
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
|
||||
from flask import jsonify, request
|
||||
|
||||
from core.runtime_state import (
|
||||
download_batches,
|
||||
download_tasks,
|
||||
tasks_lock,
|
||||
)
|
||||
from core.metadata.registry import (
|
||||
get_deezer_client,
|
||||
get_itunes_client,
|
||||
get_spotify_client,
|
||||
)
|
||||
from database.music_database import get_database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_itunes_client():
|
||||
"""Mirror of web_server._get_itunes_client — delegates to registry."""
|
||||
return get_itunes_client()
|
||||
|
||||
|
||||
def _get_deezer_client():
|
||||
"""Mirror of web_server._get_deezer_client — delegates to registry."""
|
||||
return get_deezer_client()
|
||||
|
||||
|
||||
class _SpotifyClientProxy:
|
||||
"""Resolves the global Spotify client lazily through core.metadata.registry."""
|
||||
|
||||
def __getattr__(self, name):
|
||||
client = get_spotify_client()
|
||||
if client is None:
|
||||
raise AttributeError(name)
|
||||
return getattr(client, name)
|
||||
|
||||
def __bool__(self):
|
||||
return get_spotify_client() is not None
|
||||
|
||||
|
||||
spotify_client = _SpotifyClientProxy()
|
||||
|
||||
|
||||
# Injected at runtime via init().
|
||||
_resolve_library_file_path = None
|
||||
_attempt_download_with_candidates = None
|
||||
missing_download_executor = None
|
||||
|
||||
|
||||
def init(resolve_library_file_path_fn, attempt_download_with_candidates_fn, executor):
|
||||
"""Bind shared helpers from web_server."""
|
||||
global _resolve_library_file_path, _attempt_download_with_candidates
|
||||
global missing_download_executor
|
||||
_resolve_library_file_path = resolve_library_file_path_fn
|
||||
_attempt_download_with_candidates = attempt_download_with_candidates_fn
|
||||
missing_download_executor = executor
|
||||
|
||||
|
||||
def redownload_start(track_id):
|
||||
"""Start downloading a specific track from a selected source to replace the current file."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
metadata = data.get('metadata', {})
|
||||
candidate = data.get('candidate', {})
|
||||
delete_old = data.get('delete_old_file', True)
|
||||
|
||||
if not candidate.get('username') or not candidate.get('filename'):
|
||||
return jsonify({"success": False, "error": "candidate with username and filename required"}), 400
|
||||
|
||||
# Get current track info for old file path
|
||||
database = get_database()
|
||||
conn = database._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT file_path FROM tracks WHERE id = ?", (track_id,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
old_file_path = None
|
||||
if row and row['file_path'] and delete_old:
|
||||
old_file_path = _resolve_library_file_path(row['file_path'])
|
||||
|
||||
task_id = f"redownload_{track_id}_{int(time.time())}"
|
||||
batch_id = f"redownload_batch_{track_id}"
|
||||
|
||||
# Fetch full track details from the metadata source for pipeline parity
|
||||
# This gives us track_number, disc_number, full album data
|
||||
meta_source = metadata.get('_source', '')
|
||||
meta_id = metadata.get('id', '')
|
||||
full_track_details = None
|
||||
full_album_data = None
|
||||
|
||||
if meta_id:
|
||||
try:
|
||||
if meta_source == 'spotify' and spotify_client and spotify_client.is_authenticated():
|
||||
full_track_details = spotify_client.get_track_details(meta_id)
|
||||
if full_track_details and full_track_details.get('album', {}).get('id'):
|
||||
full_album_data = spotify_client.get_album(full_track_details['album']['id'])
|
||||
elif meta_source == 'itunes':
|
||||
_it = _get_itunes_client()
|
||||
results = _it._lookup(id=meta_id, entity='song')
|
||||
if results:
|
||||
for r in results:
|
||||
if r.get('wrapperType') == 'track':
|
||||
full_track_details = r
|
||||
break
|
||||
elif meta_source == 'deezer':
|
||||
_dz = _get_deezer_client()
|
||||
full_track_details = _dz._api_get(f'track/{meta_id}')
|
||||
except Exception as e:
|
||||
logger.debug(f"[Redownload] Could not fetch full track details: {e}")
|
||||
|
||||
# Build track data with full metadata for pipeline parity
|
||||
track_number = None
|
||||
disc_number = 1
|
||||
album_data = {'name': metadata.get('album', '')}
|
||||
|
||||
if full_track_details:
|
||||
if meta_source == 'spotify':
|
||||
track_number = full_track_details.get('track_number')
|
||||
disc_number = full_track_details.get('disc_number', 1)
|
||||
album_raw = full_track_details.get('album', {})
|
||||
if album_raw:
|
||||
album_images = album_raw.get('images', [])
|
||||
album_data = {
|
||||
'id': album_raw.get('id', ''),
|
||||
'name': album_raw.get('name', metadata.get('album', '')),
|
||||
'release_date': album_raw.get('release_date', ''),
|
||||
'album_type': album_raw.get('album_type', 'album'),
|
||||
'total_tracks': album_raw.get('total_tracks', 0),
|
||||
'images': album_images,
|
||||
'image_url': album_images[0]['url'] if album_images else '',
|
||||
}
|
||||
elif meta_source == 'itunes':
|
||||
track_number = full_track_details.get('trackNumber')
|
||||
disc_number = full_track_details.get('discNumber', 1)
|
||||
elif meta_source == 'deezer':
|
||||
track_number = full_track_details.get('track_position')
|
||||
disc_number = full_track_details.get('disk_number', 1)
|
||||
|
||||
track_data = {
|
||||
'id': meta_id,
|
||||
'name': metadata.get('name', ''),
|
||||
'artists': [{'name': metadata.get('artist', '')}],
|
||||
'album': album_data,
|
||||
'duration_ms': metadata.get('duration_ms', 0),
|
||||
'track_number': track_number,
|
||||
'disc_number': disc_number,
|
||||
'_is_explicit_album_download': bool(full_album_data or (album_data.get('id'))),
|
||||
}
|
||||
|
||||
# Build explicit context if we have full album data
|
||||
if full_album_data or album_data.get('id'):
|
||||
track_data['_explicit_album_context'] = full_album_data if isinstance(full_album_data, dict) else album_data
|
||||
track_data['_explicit_artist_context'] = {'name': metadata.get('artist', ''), 'id': '', 'genres': []}
|
||||
|
||||
# Create batch
|
||||
with tasks_lock:
|
||||
download_batches[batch_id] = {
|
||||
'queue': [task_id],
|
||||
'queue_index': 1, # Already past the first (only) item
|
||||
'active_count': 1, # One worker is about to start
|
||||
'max_concurrent': 1,
|
||||
'playlist_id': f'redownload_{track_id}',
|
||||
'playlist_name': f"Redownload: {metadata.get('artist', '')} - {metadata.get('name', '')}",
|
||||
'phase': 'downloading',
|
||||
'total_tracks': 1,
|
||||
'completed_count': 0,
|
||||
'failed_count': 0,
|
||||
'cancelled_tracks': set(),
|
||||
'permanently_failed_tracks': [],
|
||||
'force_download': True,
|
||||
'auto_initiated': False,
|
||||
}
|
||||
|
||||
download_tasks[task_id] = {
|
||||
'status': 'queued',
|
||||
'track_info': track_data,
|
||||
'playlist_id': f'redownload_{track_id}',
|
||||
'batch_id': batch_id,
|
||||
'track_index': 0,
|
||||
'download_id': None,
|
||||
'username': None,
|
||||
'filename': None,
|
||||
'retry_count': 0,
|
||||
'cached_candidates': [],
|
||||
'used_sources': set(),
|
||||
'status_change_time': time.time(),
|
||||
'metadata_enhanced': False,
|
||||
'error_message': None,
|
||||
'_redownload_context': {
|
||||
'library_track_id': track_id,
|
||||
'old_file_path': old_file_path,
|
||||
'delete_old_file': delete_old,
|
||||
},
|
||||
}
|
||||
|
||||
# Build a TrackResult-like candidate and submit to download
|
||||
def _run_redownload():
|
||||
try:
|
||||
from core.soulseek_client import TrackResult
|
||||
from core.itunes_client import Track as MetaTrack
|
||||
tr = TrackResult(
|
||||
username=candidate['username'],
|
||||
filename=candidate['filename'],
|
||||
size=candidate.get('size', 0),
|
||||
bitrate=candidate.get('bitrate', 0),
|
||||
duration=candidate.get('duration', 0),
|
||||
quality=candidate.get('quality', ''),
|
||||
free_upload_slots=candidate.get('free_upload_slots', 0),
|
||||
upload_speed=candidate.get('upload_speed', 0),
|
||||
queue_length=candidate.get('queue_length', 0),
|
||||
)
|
||||
tr.artist = metadata.get('artist', '')
|
||||
tr.title = metadata.get('name', '')
|
||||
tr.album = metadata.get('album', '')
|
||||
tr.confidence = candidate.get('confidence', 1.0)
|
||||
|
||||
# Build a proper Track object (not a dict) — _attempt_download_with_candidates
|
||||
# accesses track.artists, track.album etc. as attributes
|
||||
artist_name = metadata.get('artist', '')
|
||||
track_obj = MetaTrack(
|
||||
id=metadata.get('id', ''),
|
||||
name=metadata.get('name', ''),
|
||||
artists=[artist_name] if artist_name else ['Unknown'],
|
||||
album=metadata.get('album', ''),
|
||||
duration_ms=metadata.get('duration_ms', 0),
|
||||
popularity=0,
|
||||
)
|
||||
|
||||
_attempt_download_with_candidates(task_id, [tr], track_obj, batch_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Redownload failed: {e}", exc_info=True)
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
download_tasks[task_id]['error_message'] = str(e)
|
||||
|
||||
missing_download_executor.submit(_run_redownload)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"task_id": task_id,
|
||||
"batch_id": batch_id,
|
||||
"message": "Redownload started",
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting redownload: {e}", exc_info=True)
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
329
core/library/retag.py
Normal file
329
core/library/retag.py
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
"""Library retag worker.
|
||||
|
||||
`execute_retag(group_id, album_id, deps)` rewrites tags + filenames for a
|
||||
group of audio files when the user has matched them to a different
|
||||
album. The worker:
|
||||
|
||||
1. Fetches album + track metadata for the new `album_id` (Spotify or
|
||||
iTunes — Spotify client transparently falls back).
|
||||
2. Loads existing files in the retag group from the DB.
|
||||
3. Matches each existing track to a new Spotify track:
|
||||
- Priority 1: same disc + track number.
|
||||
- Priority 2: title similarity >= 0.6 (SequenceMatcher).
|
||||
4. For each matched pair:
|
||||
- Re-write metadata tags via `_enhance_file_metadata`.
|
||||
- Compute the new path via `_build_final_path_for_track` and move
|
||||
the audio file (plus .lrc / .txt sidecars) if the path changes.
|
||||
- Drop an orphaned cover.jpg if it's left in an empty directory.
|
||||
- Clean up empty parent directories left behind.
|
||||
- Download the new cover art into the new album dir.
|
||||
5. Update the retag group record with the new artist / album / image /
|
||||
total_tracks / release_date and the appropriate Spotify-or-iTunes
|
||||
album ID.
|
||||
6. Mark the retag state 'finished' (or 'error' on exception).
|
||||
|
||||
The original mutated `retag_state` as a module global. Here it's exposed
|
||||
through the `RetagDeps` proxy as a Python property so the lifted body
|
||||
keeps the same `name[key] = value` syntax. The property setter rebinds
|
||||
the web_server.py reference if needed (currently the function only
|
||||
mutates in place via .update() and key assignment, so the setter never
|
||||
fires).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RetagDeps:
|
||||
"""Bundle of cross-cutting deps the retag worker needs.
|
||||
|
||||
`retag_state` is exposed as a property so the lifted body keeps
|
||||
`name[key] = value` / `name.update(...)` syntax.
|
||||
"""
|
||||
config_manager: Any
|
||||
retag_lock: Any # threading.Lock
|
||||
spotify_client: Any
|
||||
get_audio_quality_string: Callable[[str], str]
|
||||
enhance_file_metadata: Callable
|
||||
build_final_path_for_track: Callable
|
||||
safe_move_file: Callable
|
||||
cleanup_empty_directories: Callable
|
||||
download_cover_art: Callable
|
||||
docker_resolve_path: Callable[[str], str]
|
||||
_get_retag_state: Callable[[], dict]
|
||||
_set_retag_state: Callable[[dict], None]
|
||||
get_database: Callable[[], Any]
|
||||
|
||||
@property
|
||||
def retag_state(self) -> dict:
|
||||
return self._get_retag_state()
|
||||
|
||||
@retag_state.setter
|
||||
def retag_state(self, value: dict) -> None:
|
||||
self._set_retag_state(value)
|
||||
|
||||
|
||||
def execute_retag(group_id, album_id, deps: RetagDeps):
|
||||
"""Execute a retag operation: re-tag files in a group with metadata from a new album match."""
|
||||
try:
|
||||
with deps.retag_lock:
|
||||
deps.retag_state.update({
|
||||
"status": "running",
|
||||
"phase": "Fetching album metadata...",
|
||||
"progress": 0,
|
||||
"current_track": "",
|
||||
"total_tracks": 0,
|
||||
"processed": 0,
|
||||
"error_message": ""
|
||||
})
|
||||
|
||||
# 1. Fetch new album metadata from Spotify/iTunes
|
||||
album_data = deps.spotify_client.get_album(album_id)
|
||||
if not album_data:
|
||||
raise ValueError(f"Could not fetch album data for ID: {album_id}")
|
||||
|
||||
album_tracks_response = deps.spotify_client.get_album_tracks(album_id)
|
||||
if not album_tracks_response:
|
||||
raise ValueError(f"Could not fetch album tracks for ID: {album_id}")
|
||||
|
||||
album_tracks_items = album_tracks_response.get('items', [])
|
||||
|
||||
# Extract artist info
|
||||
album_artists = album_data.get('artists', [])
|
||||
new_artist = album_artists[0] if album_artists else {'name': 'Unknown Artist', 'id': ''}
|
||||
# Ensure artist is a dict with expected fields
|
||||
if not isinstance(new_artist, dict):
|
||||
new_artist = {'name': str(new_artist), 'id': ''}
|
||||
new_album_name = album_data.get('name', 'Unknown Album')
|
||||
new_images = album_data.get('images', [])
|
||||
new_image_url = new_images[0]['url'] if new_images else None
|
||||
new_release_date = album_data.get('release_date', '')
|
||||
total_tracks = album_data.get('total_tracks', len(album_tracks_items))
|
||||
|
||||
# Build spotify track list
|
||||
spotify_tracks = []
|
||||
for item in album_tracks_items:
|
||||
track_artists = item.get('artists', [])
|
||||
spotify_tracks.append({
|
||||
'name': item.get('name', ''),
|
||||
'track_number': item.get('track_number', 1),
|
||||
'disc_number': item.get('disc_number', 1),
|
||||
'id': item.get('id', ''),
|
||||
'artists': track_artists,
|
||||
'duration_ms': item.get('duration_ms', 0)
|
||||
})
|
||||
|
||||
total_discs = max((t['disc_number'] for t in spotify_tracks), default=1)
|
||||
|
||||
# 2. Load existing tracks for this group
|
||||
db = deps.get_database()
|
||||
existing_tracks = db.get_retag_tracks(group_id)
|
||||
if not existing_tracks:
|
||||
raise ValueError(f"No tracks found for retag group {group_id}")
|
||||
|
||||
with deps.retag_lock:
|
||||
deps.retag_state['total_tracks'] = len(existing_tracks)
|
||||
deps.retag_state['phase'] = "Matching tracks..."
|
||||
|
||||
# 3. Match existing files to new tracklist
|
||||
matched_pairs = []
|
||||
for existing_track in existing_tracks:
|
||||
best_match = None
|
||||
best_score = 0
|
||||
|
||||
# Priority 1: Match by track number
|
||||
for st in spotify_tracks:
|
||||
if (st['track_number'] == existing_track.get('track_number') and
|
||||
st['disc_number'] == existing_track.get('disc_number', 1)):
|
||||
best_match = st
|
||||
best_score = 1.0
|
||||
break
|
||||
|
||||
# Priority 2: Match by title similarity
|
||||
if not best_match:
|
||||
from difflib import SequenceMatcher
|
||||
existing_title = (existing_track.get('title') or '').lower().strip()
|
||||
for st in spotify_tracks:
|
||||
st_title = (st.get('name') or '').lower().strip()
|
||||
score = SequenceMatcher(None, existing_title, st_title).ratio()
|
||||
if score > best_score and score > 0.6:
|
||||
best_score = score
|
||||
best_match = st
|
||||
|
||||
if best_match:
|
||||
matched_pairs.append((existing_track, best_match))
|
||||
else:
|
||||
logger.warning(f"[Retag] No match found for track: '{existing_track.get('title')}'")
|
||||
matched_pairs.append((existing_track, None))
|
||||
|
||||
with deps.retag_lock:
|
||||
deps.retag_state['phase'] = "Retagging files..."
|
||||
|
||||
# 4. Retag each matched track
|
||||
for existing_track, matched_spotify in matched_pairs:
|
||||
current_file_path = existing_track.get('file_path', '')
|
||||
track_title = matched_spotify['name'] if matched_spotify else existing_track.get('title', 'Unknown')
|
||||
|
||||
with deps.retag_lock:
|
||||
deps.retag_state['current_track'] = track_title
|
||||
|
||||
if not matched_spotify:
|
||||
with deps.retag_lock:
|
||||
deps.retag_state['processed'] += 1
|
||||
deps.retag_state['progress'] = int(deps.retag_state['processed'] / deps.retag_state['total_tracks'] * 100)
|
||||
continue
|
||||
|
||||
# Verify file exists
|
||||
if not os.path.exists(current_file_path):
|
||||
logger.warning(f"[Retag] File not found, skipping: {current_file_path}")
|
||||
with deps.retag_lock:
|
||||
deps.retag_state['processed'] += 1
|
||||
deps.retag_state['progress'] = int(deps.retag_state['processed'] / deps.retag_state['total_tracks'] * 100)
|
||||
continue
|
||||
|
||||
# Build synthetic context for _enhance_file_metadata
|
||||
track_artists = matched_spotify.get('artists', [])
|
||||
context = {
|
||||
'original_search_result': {
|
||||
'spotify_clean_title': matched_spotify['name'],
|
||||
'spotify_clean_album': new_album_name,
|
||||
'track_number': matched_spotify['track_number'],
|
||||
'disc_number': matched_spotify.get('disc_number', 1),
|
||||
'artists': track_artists,
|
||||
'title': matched_spotify['name']
|
||||
},
|
||||
'spotify_album': {
|
||||
'id': album_id,
|
||||
'name': new_album_name,
|
||||
'release_date': new_release_date,
|
||||
'total_tracks': total_tracks,
|
||||
'image_url': new_image_url,
|
||||
'total_discs': total_discs
|
||||
},
|
||||
'track_info': {'id': matched_spotify['id']},
|
||||
'spotify_artist': new_artist,
|
||||
'_audio_quality': deps.get_audio_quality_string(current_file_path) or ''
|
||||
}
|
||||
|
||||
album_info = {
|
||||
'is_album': total_tracks > 1,
|
||||
'album_name': new_album_name,
|
||||
'track_number': matched_spotify['track_number'],
|
||||
'disc_number': matched_spotify.get('disc_number', 1),
|
||||
'clean_track_name': matched_spotify['name'],
|
||||
'album_image_url': new_image_url
|
||||
}
|
||||
|
||||
# Re-write metadata tags
|
||||
try:
|
||||
deps.enhance_file_metadata(current_file_path, context, new_artist, album_info)
|
||||
logger.info(f"[Retag] Re-tagged: '{track_title}'")
|
||||
except Exception as meta_err:
|
||||
logger.error(f"[Retag] Metadata write failed for '{track_title}': {meta_err}")
|
||||
|
||||
# Compute new path and move if different
|
||||
file_ext = os.path.splitext(current_file_path)[1]
|
||||
try:
|
||||
new_path, _ = deps.build_final_path_for_track(context, new_artist, album_info, file_ext)
|
||||
|
||||
if os.path.normpath(current_file_path) != os.path.normpath(new_path):
|
||||
logger.info(f"[Retag] Moving '{os.path.basename(current_file_path)}' -> '{new_path}'")
|
||||
old_dir = os.path.dirname(current_file_path)
|
||||
os.makedirs(os.path.dirname(new_path), exist_ok=True)
|
||||
deps.safe_move_file(current_file_path, new_path)
|
||||
|
||||
# Move lyrics sidecar file alongside audio file if it exists
|
||||
for lyrics_ext in ('.lrc', '.txt'):
|
||||
old_lyrics = os.path.splitext(current_file_path)[0] + lyrics_ext
|
||||
if os.path.exists(old_lyrics):
|
||||
new_lyrics = os.path.splitext(new_path)[0] + lyrics_ext
|
||||
try:
|
||||
deps.safe_move_file(old_lyrics, new_lyrics)
|
||||
logger.info(f"[Retag] Moved {lyrics_ext} file alongside audio")
|
||||
except Exception as lrc_err:
|
||||
logger.error(f"[Retag] Failed to move {lyrics_ext} file: {lrc_err}")
|
||||
|
||||
# Remove old cover.jpg if directory changed and old dir is now empty of audio
|
||||
new_dir = os.path.dirname(new_path)
|
||||
if os.path.normpath(old_dir) != os.path.normpath(new_dir):
|
||||
old_cover = os.path.join(old_dir, 'cover.jpg')
|
||||
if os.path.exists(old_cover):
|
||||
# Check if any audio files remain in old directory
|
||||
audio_exts = {'.flac', '.mp3', '.m4a', '.ogg', '.opus', '.wav', '.aac'}
|
||||
remaining_audio = [f for f in os.listdir(old_dir)
|
||||
if os.path.splitext(f)[1].lower() in audio_exts]
|
||||
if not remaining_audio:
|
||||
try:
|
||||
os.remove(old_cover)
|
||||
logger.warning("[Retag] Removed orphaned cover.jpg from old directory")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Cleanup old empty directories
|
||||
transfer_dir = deps.docker_resolve_path(deps.config_manager.get('soulseek.transfer_path', './Transfer'))
|
||||
deps.cleanup_empty_directories(transfer_dir, current_file_path)
|
||||
|
||||
# Update DB record
|
||||
db.update_retag_track_path(existing_track['id'], str(new_path))
|
||||
current_file_path = new_path
|
||||
else:
|
||||
logger.warning(f"[Retag] Path unchanged for '{track_title}', no move needed")
|
||||
except Exception as move_err:
|
||||
logger.error(f"[Retag] Path/move failed for '{track_title}': {move_err}")
|
||||
|
||||
# Download cover art to album directory
|
||||
try:
|
||||
deps.download_cover_art(album_info, os.path.dirname(current_file_path), context)
|
||||
except Exception as cover_err:
|
||||
logger.error(f"[Retag] Cover art download failed: {cover_err}")
|
||||
|
||||
with deps.retag_lock:
|
||||
deps.retag_state['processed'] += 1
|
||||
deps.retag_state['progress'] = int(deps.retag_state['processed'] / deps.retag_state['total_tracks'] * 100)
|
||||
|
||||
# 5. Update the retag group record with new metadata
|
||||
update_kwargs = {
|
||||
'artist_name': new_artist.get('name', 'Unknown Artist'),
|
||||
'album_name': new_album_name,
|
||||
'image_url': new_image_url,
|
||||
'total_tracks': total_tracks,
|
||||
'release_date': new_release_date
|
||||
}
|
||||
# Set the correct ID field based on Spotify vs iTunes
|
||||
if str(album_id).isdigit():
|
||||
update_kwargs['itunes_album_id'] = album_id
|
||||
update_kwargs['spotify_album_id'] = None
|
||||
else:
|
||||
update_kwargs['spotify_album_id'] = album_id
|
||||
update_kwargs['itunes_album_id'] = None
|
||||
|
||||
db.update_retag_group(group_id, **update_kwargs)
|
||||
|
||||
with deps.retag_lock:
|
||||
deps.retag_state.update({
|
||||
"status": "finished",
|
||||
"phase": "Retag complete!",
|
||||
"progress": 100,
|
||||
"current_track": ""
|
||||
})
|
||||
logger.info(f"[Retag] Retag operation complete for group {group_id}")
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.error(f"[Retag] Error during retag: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
with deps.retag_lock:
|
||||
deps.retag_state.update({
|
||||
"status": "error",
|
||||
"phase": "Error",
|
||||
"error_message": str(e)
|
||||
})
|
||||
296
core/library/service_search.py
Normal file
296
core/library/service_search.py
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
"""Library manual-match service search — lifted from web_server.py.
|
||||
|
||||
Both function bodies are byte-identical to the originals. Enrichment
|
||||
worker handles are injected at runtime via init() because the workers
|
||||
are constructed after this module is imported.
|
||||
"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Injected at runtime via init() — these workers are constructed in
|
||||
# web_server.py and bound here once they exist.
|
||||
spotify_enrichment_worker = None
|
||||
itunes_enrichment_worker = None
|
||||
mb_worker = None
|
||||
lastfm_worker = None
|
||||
genius_worker = None
|
||||
tidal_enrichment_worker = None
|
||||
qobuz_enrichment_worker = None
|
||||
discogs_worker = None
|
||||
audiodb_worker = None
|
||||
|
||||
|
||||
def init(
|
||||
spotify_worker=None,
|
||||
itunes_worker=None,
|
||||
musicbrainz_worker=None,
|
||||
lastfm_worker_obj=None,
|
||||
genius_worker_obj=None,
|
||||
tidal_worker=None,
|
||||
qobuz_worker=None,
|
||||
discogs_worker_obj=None,
|
||||
audiodb_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
|
||||
spotify_enrichment_worker = spotify_worker
|
||||
itunes_enrichment_worker = itunes_worker
|
||||
mb_worker = musicbrainz_worker
|
||||
lastfm_worker = lastfm_worker_obj
|
||||
genius_worker = genius_worker_obj
|
||||
tidal_enrichment_worker = tidal_worker
|
||||
qobuz_enrichment_worker = qobuz_worker
|
||||
discogs_worker = discogs_worker_obj
|
||||
audiodb_worker = audiodb_worker_obj
|
||||
|
||||
|
||||
def _detect_provider(items, client):
|
||||
"""Detect actual provider from result IDs. Spotify IDs are alphanumeric;
|
||||
iTunes/Deezer IDs are purely numeric. If the results have numeric IDs,
|
||||
they came from the fallback source, not Spotify."""
|
||||
if items and str(items[0].id).isdigit():
|
||||
return client._fallback_source
|
||||
return 'spotify'
|
||||
|
||||
|
||||
def _search_service(service, entity_type, query):
|
||||
"""Search a service and return normalized results."""
|
||||
import requests as req_lib
|
||||
|
||||
if service == 'spotify':
|
||||
if not spotify_enrichment_worker or not spotify_enrichment_worker.client:
|
||||
raise ValueError("Spotify worker not initialized")
|
||||
client = spotify_enrichment_worker.client
|
||||
if entity_type == 'artist':
|
||||
items = client.search_artists(query, limit=8)
|
||||
# Detect actual provider from result IDs — Spotify IDs are alphanumeric,
|
||||
# iTunes/Deezer IDs are purely numeric. Prevents storing wrong IDs.
|
||||
provider = _detect_provider(items, client)
|
||||
return [{'id': a.id, 'name': a.name, 'image': a.image_url, 'extra': ', '.join(a.genres[:3]) if a.genres else '', 'provider': provider} for a in items]
|
||||
elif entity_type == 'album':
|
||||
items = client.search_albums(query, limit=8)
|
||||
provider = _detect_provider(items, client)
|
||||
return [{'id': a.id, 'name': a.name, 'image': a.image_url, 'extra': f"{', '.join(a.artists)} · {a.release_date or ''}", 'provider': provider} for a in items]
|
||||
elif entity_type == 'track':
|
||||
items = client.search_tracks(query, limit=8)
|
||||
provider = _detect_provider(items, client)
|
||||
return [{'id': t.id, 'name': t.name, 'image': t.image_url, 'extra': f"{', '.join(t.artists)} · {t.album or ''}", 'provider': provider} for t in items]
|
||||
|
||||
elif service == 'itunes':
|
||||
if not itunes_enrichment_worker or not itunes_enrichment_worker.client:
|
||||
raise ValueError("iTunes worker not initialized")
|
||||
client = itunes_enrichment_worker.client
|
||||
if entity_type == 'artist':
|
||||
items = client.search_artists(query, limit=8)
|
||||
return [{'id': 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': 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': t.id, 'name': t.name, 'image': t.image_url, 'extra': f"{', '.join(t.artists)} · {t.album or ''}"} for t in items]
|
||||
|
||||
elif service == 'musicbrainz':
|
||||
if not mb_worker or not mb_worker.mb_service:
|
||||
raise ValueError("MusicBrainz worker not initialized")
|
||||
mb_client = mb_worker.mb_service.mb_client
|
||||
if entity_type == 'artist':
|
||||
items = mb_client.search_artist(query, limit=8)
|
||||
return [{'id': a['id'], 'name': a.get('name', ''), 'image': None,
|
||||
'extra': f"Score: {a.get('score', '')} · {a.get('disambiguation', '') or a.get('country', '')}"} for a in items]
|
||||
elif entity_type == 'album':
|
||||
items = mb_client.search_release(query, limit=8)
|
||||
results = []
|
||||
for r in items:
|
||||
artists = ', '.join(ac.get('name', '') for ac in r.get('artist-credit', []) if isinstance(ac, dict))
|
||||
# Cover Art Archive provides album art by release MBID
|
||||
cover_url = f"https://coverartarchive.org/release/{r['id']}/front-250" if r.get('id') else None
|
||||
results.append({'id': r['id'], 'name': r.get('title', ''), 'image': cover_url,
|
||||
'extra': f"{artists} · {r.get('date', '')} · Score: {r.get('score', '')}"})
|
||||
return results
|
||||
elif entity_type == 'track':
|
||||
items = mb_client.search_recording(query, limit=8)
|
||||
results = []
|
||||
for r in items:
|
||||
artists = ', '.join(ac.get('name', '') for ac in r.get('artist-credit', []) if isinstance(ac, dict))
|
||||
results.append({'id': r['id'], 'name': r.get('title', ''), 'image': None,
|
||||
'extra': f"{artists} · Score: {r.get('score', '')}"})
|
||||
return results
|
||||
|
||||
elif service == 'deezer':
|
||||
# Deezer client only returns single results, so hit the API directly for multiple
|
||||
type_map = {'artist': 'artist', 'album': 'album', 'track': 'track'}
|
||||
deezer_type = type_map.get(entity_type, 'track')
|
||||
try:
|
||||
resp = req_lib.get(f'https://api.deezer.com/search/{deezer_type}', params={'q': query, 'limit': 8}, timeout=10)
|
||||
data = resp.json().get('data', [])
|
||||
except Exception:
|
||||
data = []
|
||||
results = []
|
||||
for item in data:
|
||||
if entity_type == 'artist':
|
||||
results.append({'id': str(item.get('id', '')), 'name': item.get('name', ''),
|
||||
'image': item.get('picture_medium'), 'extra': f"{item.get('nb_fan', 0)} fans"})
|
||||
elif entity_type == 'album':
|
||||
artist_name = item.get('artist', {}).get('name', '') if isinstance(item.get('artist'), dict) else ''
|
||||
results.append({'id': str(item.get('id', '')), 'name': item.get('title', ''),
|
||||
'image': item.get('cover_medium'), 'extra': artist_name})
|
||||
elif entity_type == 'track':
|
||||
artist_name = item.get('artist', {}).get('name', '') if isinstance(item.get('artist'), dict) else ''
|
||||
album_name = item.get('album', {}).get('title', '') if isinstance(item.get('album'), dict) else ''
|
||||
results.append({'id': str(item.get('id', '')), 'name': item.get('title', ''),
|
||||
'image': item.get('album', {}).get('cover_medium') if isinstance(item.get('album'), dict) else None,
|
||||
'extra': f"{artist_name} · {album_name}"})
|
||||
return results
|
||||
|
||||
elif service == 'lastfm':
|
||||
if not lastfm_worker or not lastfm_worker.client:
|
||||
raise ValueError("Last.fm worker not initialized")
|
||||
client = lastfm_worker.client
|
||||
if entity_type == 'artist':
|
||||
result = client.search_artist(query)
|
||||
if result:
|
||||
image = client.get_best_image(result.get('image', []))
|
||||
return [{'id': result.get('url', ''), 'name': result.get('name', ''),
|
||||
'image': image, 'extra': f"{result.get('listeners', '0')} listeners"}]
|
||||
elif entity_type == 'album':
|
||||
result = client.search_album(query, '')
|
||||
if result:
|
||||
image = client.get_best_image(result.get('image', []))
|
||||
return [{'id': result.get('url', ''), 'name': result.get('name', ''),
|
||||
'image': image, 'extra': result.get('artist', '')}]
|
||||
elif entity_type == 'track':
|
||||
# search_track takes separate artist/track params
|
||||
parts = query.split(' - ', 1) if ' - ' in query else ['', query]
|
||||
result = client.search_track(parts[0], parts[1])
|
||||
if result:
|
||||
artist_name = result.get('artist', '')
|
||||
return [{'id': result.get('url', ''), 'name': result.get('name', ''),
|
||||
'image': None, 'extra': f"{artist_name} · {result.get('listeners', '0')} listeners"}]
|
||||
return []
|
||||
|
||||
elif service == 'genius':
|
||||
if not genius_worker or not genius_worker.client:
|
||||
raise ValueError("Genius worker not initialized")
|
||||
client = genius_worker.client
|
||||
if entity_type == 'artist':
|
||||
artists = client.search_artists(query, limit=8)
|
||||
return [{'id': str(a.get('id', '')), 'name': a.get('name', ''),
|
||||
'image': a.get('image_url'), 'extra': a.get('url', '')} for a in artists]
|
||||
elif entity_type == 'track':
|
||||
# Search with broader results for manual matching
|
||||
hits = client.search(f"{query}", per_page=10)
|
||||
results = []
|
||||
seen_ids = set()
|
||||
for hit in hits:
|
||||
r = hit.get('result', {})
|
||||
rid = r.get('id')
|
||||
if rid and rid not in seen_ids:
|
||||
seen_ids.add(rid)
|
||||
results.append({'id': str(rid), 'name': r.get('title', ''),
|
||||
'image': r.get('song_art_image_url'), 'extra': r.get('artist_names', '')})
|
||||
return results
|
||||
return []
|
||||
|
||||
elif service == 'tidal':
|
||||
if not tidal_enrichment_worker or not tidal_enrichment_worker.client:
|
||||
raise ValueError("Tidal worker not initialized")
|
||||
client = tidal_enrichment_worker.client
|
||||
if entity_type == 'artist':
|
||||
result = client.search_artist(query)
|
||||
if result:
|
||||
thumb = result.get('picture', '')
|
||||
if isinstance(thumb, list) and thumb:
|
||||
thumb = thumb[0].get('url', '') if isinstance(thumb[0], dict) else str(thumb[0])
|
||||
return [{'id': str(result.get('id', '')), 'name': result.get('name', ''),
|
||||
'image': thumb if isinstance(thumb, str) else None, 'extra': ''}]
|
||||
elif entity_type == 'album':
|
||||
result = client.search_album('', query)
|
||||
if result:
|
||||
return [{'id': str(result.get('id', '')), 'name': result.get('title', ''),
|
||||
'image': None, 'extra': result.get('artist', {}).get('name', '') if isinstance(result.get('artist'), dict) else ''}]
|
||||
elif entity_type == 'track':
|
||||
result = client.search_track('', query)
|
||||
if result:
|
||||
artist_name = result.get('artist', {}).get('name', '') if isinstance(result.get('artist'), dict) else ''
|
||||
return [{'id': str(result.get('id', '')), 'name': result.get('title', ''),
|
||||
'image': None, 'extra': artist_name}]
|
||||
return []
|
||||
|
||||
elif service == 'qobuz':
|
||||
if not qobuz_enrichment_worker or not qobuz_enrichment_worker.client:
|
||||
raise ValueError("Qobuz worker not initialized")
|
||||
client = qobuz_enrichment_worker.client
|
||||
if entity_type == 'artist':
|
||||
result = client.search_artist(query)
|
||||
if result:
|
||||
image = result.get('image', {})
|
||||
thumb = image.get('large', image.get('medium', '')) if isinstance(image, dict) else ''
|
||||
return [{'id': str(result.get('id', '')), 'name': result.get('name', ''),
|
||||
'image': thumb, 'extra': ''}]
|
||||
elif entity_type == 'album':
|
||||
result = client.search_album('', query)
|
||||
if result:
|
||||
artist_name = result.get('artist', {}).get('name', '') if isinstance(result.get('artist'), dict) else ''
|
||||
image = result.get('image', {})
|
||||
thumb = image.get('large', image.get('medium', '')) if isinstance(image, dict) else ''
|
||||
return [{'id': str(result.get('id', '')), 'name': result.get('title', ''),
|
||||
'image': thumb, 'extra': artist_name}]
|
||||
elif entity_type == 'track':
|
||||
result = client.search_track('', query)
|
||||
if result:
|
||||
artist_name = result.get('performer', {}).get('name', '') if isinstance(result.get('performer'), dict) else ''
|
||||
if not artist_name:
|
||||
artist_name = result.get('artist', {}).get('name', '') if isinstance(result.get('artist'), dict) else ''
|
||||
return [{'id': str(result.get('id', '')), 'name': result.get('title', ''),
|
||||
'image': None, 'extra': artist_name}]
|
||||
return []
|
||||
|
||||
elif service == 'discogs':
|
||||
if not discogs_worker or not discogs_worker.client:
|
||||
raise ValueError("Discogs worker not initialized")
|
||||
client = discogs_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 []
|
||||
|
||||
elif service == 'audiodb':
|
||||
if not audiodb_worker or not audiodb_worker.client:
|
||||
raise ValueError("AudioDB worker not initialized")
|
||||
client = audiodb_worker.client
|
||||
result = None
|
||||
if entity_type == 'artist':
|
||||
result = client.search_artist(query)
|
||||
elif entity_type == 'album':
|
||||
# AudioDB album search needs artist + album, try query as-is
|
||||
parts = query.split(' - ', 1) if ' - ' in query else [query, '']
|
||||
result = client.search_album(parts[0], parts[1] if len(parts) > 1 else query)
|
||||
elif entity_type == 'track':
|
||||
parts = query.split(' - ', 1) if ' - ' in query else [query, '']
|
||||
result = client.search_track(parts[0], parts[1] if len(parts) > 1 else query)
|
||||
if result:
|
||||
if entity_type == 'artist':
|
||||
return [{'id': str(result.get('idArtist', '')), 'name': result.get('strArtist', ''),
|
||||
'image': result.get('strArtistThumb'), 'extra': result.get('strGenre', '')}]
|
||||
elif entity_type == 'album':
|
||||
return [{'id': str(result.get('idAlbum', '')), 'name': result.get('strAlbum', ''),
|
||||
'image': result.get('strAlbumThumb'), 'extra': f"{result.get('strArtist', '')} · {result.get('intYearReleased', '')}"}]
|
||||
elif entity_type == 'track':
|
||||
return [{'id': str(result.get('idTrack', '')), 'name': result.get('strTrack', ''),
|
||||
'image': None, 'extra': f"{result.get('strArtist', '')} · {result.get('strAlbum', '')}"}]
|
||||
return []
|
||||
|
||||
return []
|
||||
88
core/metadata/__init__.py
Normal file
88
core/metadata/__init__.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""Metadata package public surface."""
|
||||
|
||||
from core.metadata.album_tracks import (
|
||||
get_album_for_source,
|
||||
get_album_tracks_for_source,
|
||||
get_artist_album_tracks,
|
||||
get_artist_albums_for_source,
|
||||
resolve_album_reference,
|
||||
)
|
||||
from core.metadata.artist_image import get_artist_image_url
|
||||
from core.metadata.cache import MetadataCache, get_metadata_cache
|
||||
from core.metadata.completion import (
|
||||
check_album_completion,
|
||||
check_artist_discography_completion,
|
||||
check_single_completion,
|
||||
iter_artist_discography_completion_events,
|
||||
)
|
||||
from core.metadata.discography import (
|
||||
get_artist_detail_discography,
|
||||
get_artist_discography,
|
||||
)
|
||||
from core.metadata.lookup import MetadataLookupOptions
|
||||
from core.metadata.registry import (
|
||||
METADATA_SOURCE_PRIORITY,
|
||||
clear_cached_metadata_client,
|
||||
clear_cached_metadata_clients,
|
||||
clear_cached_profile_spotify_client,
|
||||
get_client_for_source,
|
||||
get_deezer_client,
|
||||
get_discogs_client,
|
||||
get_hydrabase_client,
|
||||
get_itunes_client,
|
||||
get_primary_client,
|
||||
get_primary_source,
|
||||
get_spotify_client_for_profile,
|
||||
get_registered_runtime_client,
|
||||
get_source_priority,
|
||||
get_spotify_client,
|
||||
is_hydrabase_enabled,
|
||||
register_profile_spotify_credentials_provider,
|
||||
register_runtime_clients,
|
||||
)
|
||||
from core.metadata.service import MetadataProvider, MetadataService, get_metadata_service
|
||||
from core.metadata.similar_artists import (
|
||||
get_musicmap_similar_artists,
|
||||
iter_musicmap_similar_artist_events,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"METADATA_SOURCE_PRIORITY",
|
||||
"MetadataCache",
|
||||
"MetadataLookupOptions",
|
||||
"MetadataProvider",
|
||||
"MetadataService",
|
||||
"check_album_completion",
|
||||
"check_artist_discography_completion",
|
||||
"check_single_completion",
|
||||
"clear_cached_metadata_client",
|
||||
"clear_cached_metadata_clients",
|
||||
"clear_cached_profile_spotify_client",
|
||||
"get_album_for_source",
|
||||
"get_album_tracks_for_source",
|
||||
"get_artist_album_tracks",
|
||||
"get_artist_albums_for_source",
|
||||
"get_artist_detail_discography",
|
||||
"get_artist_discography",
|
||||
"get_artist_image_url",
|
||||
"get_client_for_source",
|
||||
"get_deezer_client",
|
||||
"get_discogs_client",
|
||||
"get_hydrabase_client",
|
||||
"get_itunes_client",
|
||||
"get_metadata_cache",
|
||||
"get_metadata_service",
|
||||
"get_musicmap_similar_artists",
|
||||
"get_primary_client",
|
||||
"get_primary_source",
|
||||
"get_spotify_client_for_profile",
|
||||
"get_registered_runtime_client",
|
||||
"get_spotify_client",
|
||||
"get_source_priority",
|
||||
"iter_artist_discography_completion_events",
|
||||
"iter_musicmap_similar_artist_events",
|
||||
"is_hydrabase_enabled",
|
||||
"register_profile_spotify_credentials_provider",
|
||||
"register_runtime_clients",
|
||||
"resolve_album_reference",
|
||||
]
|
||||
566
core/metadata/album_tracks.py
Normal file
566
core/metadata/album_tracks.py
Normal file
|
|
@ -0,0 +1,566 @@
|
|||
"""Album-track lookup helpers for metadata API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.metadata import registry as metadata_registry
|
||||
from core.metadata.lookup import MetadataLookupOptions
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("metadata.album_tracks")
|
||||
|
||||
__all__ = [
|
||||
"get_album_for_source",
|
||||
"get_album_tracks_for_source",
|
||||
"get_artist_album_tracks",
|
||||
"get_artist_albums_for_source",
|
||||
"resolve_album_reference",
|
||||
]
|
||||
|
||||
|
||||
def _extract_lookup_value(value: Any, *names: str, default: Any = None) -> Any:
|
||||
if value is None:
|
||||
return default
|
||||
|
||||
for name in names:
|
||||
if isinstance(value, dict):
|
||||
if name in value and value[name] is not None:
|
||||
return value[name]
|
||||
else:
|
||||
candidate = getattr(value, name, None)
|
||||
if candidate is not None:
|
||||
return candidate
|
||||
return default
|
||||
|
||||
|
||||
def _normalize_artist_name(value: Any) -> str:
|
||||
return (value or '').strip().casefold()
|
||||
|
||||
|
||||
def _get_source_chain_for_lookup(options: MetadataLookupOptions) -> List[str]:
|
||||
primary_source = metadata_registry.get_primary_source()
|
||||
source_chain = list(metadata_registry.get_source_priority(primary_source))
|
||||
override = (options.source_override or '').strip().lower()
|
||||
|
||||
if override:
|
||||
source_chain = [override] + [source for source in source_chain if source != override]
|
||||
|
||||
if not options.allow_fallback:
|
||||
source_chain = source_chain[:1]
|
||||
|
||||
return source_chain
|
||||
|
||||
|
||||
def _search_artists_for_source(source: str, client: Any, artist_name: str, limit: int = 5) -> List[Any]:
|
||||
if not client or not hasattr(client, 'search_artists'):
|
||||
return []
|
||||
|
||||
try:
|
||||
kwargs = {'limit': limit}
|
||||
if source == 'spotify':
|
||||
kwargs['allow_fallback'] = False
|
||||
return client.search_artists(artist_name, **kwargs) or []
|
||||
except Exception as exc:
|
||||
logger.debug("Could not search %s for %s: %s", source, artist_name, exc)
|
||||
return []
|
||||
|
||||
|
||||
def _search_albums_for_source(source: str, client: Any, query: str, limit: int = 5) -> List[Any]:
|
||||
if not client or not hasattr(client, 'search_albums'):
|
||||
return []
|
||||
|
||||
try:
|
||||
kwargs = {'limit': limit}
|
||||
if source == 'spotify':
|
||||
kwargs['allow_fallback'] = False
|
||||
return client.search_albums(query, **kwargs) or []
|
||||
except Exception as exc:
|
||||
logger.debug("Could not search %s for %s: %s", source, query, exc)
|
||||
return []
|
||||
|
||||
|
||||
def _pick_best_artist_match(search_results: List[Any], artist_name: str) -> Optional[Any]:
|
||||
if not search_results:
|
||||
return None
|
||||
|
||||
target_name = _normalize_artist_name(artist_name)
|
||||
for artist in search_results:
|
||||
candidate_name = _normalize_artist_name(
|
||||
_extract_lookup_value(artist, 'name', 'artist_name', 'title')
|
||||
)
|
||||
if candidate_name == target_name:
|
||||
return artist
|
||||
|
||||
return search_results[0]
|
||||
|
||||
|
||||
def _extract_track_items(api_tracks: Any) -> List[Dict[str, Any]]:
|
||||
if not api_tracks:
|
||||
return []
|
||||
if isinstance(api_tracks, dict):
|
||||
return api_tracks.get('items') or []
|
||||
if isinstance(api_tracks, list):
|
||||
return api_tracks
|
||||
return []
|
||||
|
||||
|
||||
def _normalize_track_artists(track_item: Any) -> List[str]:
|
||||
artists = _extract_lookup_value(track_item, 'artists', default=[]) or []
|
||||
if isinstance(artists, (str, bytes)):
|
||||
artists = [artists]
|
||||
elif isinstance(artists, dict):
|
||||
artists = [artists]
|
||||
else:
|
||||
try:
|
||||
artists = list(artists)
|
||||
except TypeError:
|
||||
artists = [artists]
|
||||
|
||||
normalized = []
|
||||
for artist in artists:
|
||||
artist_name = _extract_lookup_value(artist, 'name', 'artist_name', 'title')
|
||||
if not artist_name and isinstance(artist, str):
|
||||
artist_name = artist
|
||||
if artist_name:
|
||||
normalized.append(str(artist_name))
|
||||
return normalized
|
||||
|
||||
|
||||
def _extract_album_track_items(album_data: Any, tracks_data: Any = None) -> List[Dict[str, Any]]:
|
||||
embedded_tracks = _extract_lookup_value(album_data, 'tracks', default=None)
|
||||
if isinstance(embedded_tracks, dict):
|
||||
items = embedded_tracks.get('items') or []
|
||||
if items:
|
||||
return items
|
||||
elif isinstance(embedded_tracks, list):
|
||||
if embedded_tracks:
|
||||
return embedded_tracks
|
||||
|
||||
return _extract_track_items(tracks_data)
|
||||
|
||||
|
||||
def _normalize_context_artists(artists: Any) -> List[Dict[str, Any]]:
|
||||
if not artists:
|
||||
return []
|
||||
|
||||
if isinstance(artists, (str, bytes)):
|
||||
artists = [artists]
|
||||
elif isinstance(artists, dict):
|
||||
artists = [artists]
|
||||
else:
|
||||
try:
|
||||
artists = list(artists)
|
||||
except TypeError:
|
||||
artists = [artists]
|
||||
|
||||
normalized: List[Dict[str, Any]] = []
|
||||
for artist in artists:
|
||||
if isinstance(artist, dict):
|
||||
name = _extract_lookup_value(artist, 'name', 'artist_name', 'title', default='') or ''
|
||||
artist_id = _extract_lookup_value(artist, 'id', 'artist_id', default='') or ''
|
||||
entry: Dict[str, Any] = {}
|
||||
if name:
|
||||
entry['name'] = str(name)
|
||||
if artist_id:
|
||||
entry['id'] = str(artist_id)
|
||||
genres = _extract_lookup_value(artist, 'genres', default=None)
|
||||
if genres is not None:
|
||||
entry['genres'] = genres
|
||||
if entry:
|
||||
normalized.append(entry)
|
||||
continue
|
||||
|
||||
name = str(artist).strip()
|
||||
if name:
|
||||
normalized.append({'name': name})
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _build_album_info(album_data: Any, album_id: str, album_name: str = '', artist_name: str = '') -> Dict[str, Any]:
|
||||
images = _extract_lookup_value(album_data, 'images', default=[]) or []
|
||||
if not isinstance(images, list):
|
||||
images = list(images) if images else []
|
||||
|
||||
artists = _normalize_context_artists(_extract_lookup_value(album_data, 'artists', default=[]))
|
||||
if not artists and artist_name:
|
||||
artists = [{'name': artist_name}]
|
||||
|
||||
primary_artist = artists[0] if artists else {}
|
||||
resolved_artist_name = (
|
||||
_extract_lookup_value(primary_artist, 'name', default='')
|
||||
or artist_name
|
||||
or _extract_lookup_value(album_data, 'artist_name', 'artist', default='')
|
||||
or ''
|
||||
)
|
||||
resolved_artist_id = str(
|
||||
_extract_lookup_value(primary_artist, 'id', default='')
|
||||
or _extract_lookup_value(album_data, 'artist_id', default='')
|
||||
or ''
|
||||
).strip()
|
||||
|
||||
image_url = None
|
||||
if images:
|
||||
image_url = _extract_lookup_value(images[0], 'url')
|
||||
if not image_url:
|
||||
image_url = _extract_lookup_value(album_data, 'image_url', 'thumb_url')
|
||||
|
||||
return {
|
||||
'id': _extract_lookup_value(album_data, 'id', 'album_id', 'collectionId', 'release_id', default=album_id) or album_id,
|
||||
'name': _extract_lookup_value(album_data, 'name', 'title', default=album_name or album_id) or album_name or album_id,
|
||||
'artist': resolved_artist_name or '',
|
||||
'artist_name': resolved_artist_name or '',
|
||||
'artist_id': resolved_artist_id,
|
||||
'artists': artists,
|
||||
'image_url': image_url,
|
||||
'images': images,
|
||||
'release_date': _extract_lookup_value(album_data, 'release_date', default='') or '',
|
||||
'album_type': _extract_lookup_value(album_data, 'album_type', default='album') or 'album',
|
||||
'total_tracks': _extract_lookup_value(album_data, 'total_tracks', 'track_count', default=0) or 0,
|
||||
}
|
||||
|
||||
|
||||
def _build_album_track_entry(track_item: Any, album_info: Dict[str, Any], source: str) -> Dict[str, Any]:
|
||||
explicit_value = _extract_lookup_value(track_item, 'explicit', 'trackExplicitness', default=False)
|
||||
if isinstance(explicit_value, str):
|
||||
explicit_value = explicit_value.lower() == 'explicit'
|
||||
|
||||
return {
|
||||
'id': _extract_lookup_value(track_item, 'id', 'track_id', 'trackId', default='') or '',
|
||||
'name': _extract_lookup_value(track_item, 'name', 'track_name', 'trackName', default='Unknown Track') or 'Unknown Track',
|
||||
'artists': _normalize_track_artists(track_item),
|
||||
'duration_ms': _extract_lookup_value(track_item, 'duration_ms', 'trackTimeMillis', default=0) or 0,
|
||||
'track_number': _extract_lookup_value(track_item, 'track_number', 'trackNumber', default=0) or 0,
|
||||
'disc_number': _extract_lookup_value(track_item, 'disc_number', 'discNumber', default=1) or 1,
|
||||
'explicit': bool(explicit_value),
|
||||
'preview_url': _extract_lookup_value(track_item, 'preview_url', 'previewUrl'),
|
||||
'external_urls': _extract_lookup_value(track_item, 'external_urls', default={}) or {},
|
||||
'uri': _extract_lookup_value(track_item, 'uri', default='') or '',
|
||||
'album': album_info,
|
||||
'source': source,
|
||||
'provider': source,
|
||||
'_source': source,
|
||||
}
|
||||
|
||||
|
||||
def _build_album_tracks_payload(
|
||||
album_data: Any,
|
||||
tracks_data: Any,
|
||||
source: str,
|
||||
album_id: str,
|
||||
album_name: str = '',
|
||||
artist_name: str = '',
|
||||
) -> Dict[str, Any]:
|
||||
album_info = _build_album_info(album_data, album_id, album_name=album_name, artist_name=artist_name)
|
||||
album_info['source'] = source
|
||||
album_info['_source'] = source
|
||||
album_info['provider'] = source
|
||||
track_items = _extract_album_track_items(album_data, tracks_data)
|
||||
tracks = [_build_album_track_entry(track, album_info, source) for track in track_items]
|
||||
|
||||
return {
|
||||
'success': bool(tracks),
|
||||
'album': album_info,
|
||||
'tracks': tracks,
|
||||
'source': source,
|
||||
}
|
||||
|
||||
|
||||
def get_album_tracks_for_source(source: str, album_id: str):
|
||||
"""Get album tracks for an exact source."""
|
||||
client = metadata_registry.get_client_for_source(source)
|
||||
if not client:
|
||||
return None
|
||||
|
||||
try:
|
||||
fetch = getattr(client, 'get_album_tracks_dict', None) if source == 'hydrabase' else getattr(client, 'get_album_tracks', None)
|
||||
if not fetch:
|
||||
return None
|
||||
if source == 'spotify':
|
||||
return fetch(album_id, allow_fallback=False)
|
||||
return fetch(album_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_album_for_source(source: str, album_id: str):
|
||||
"""Get album metadata for an exact source."""
|
||||
client = metadata_registry.get_client_for_source(source)
|
||||
if not client or not hasattr(client, 'get_album'):
|
||||
return None
|
||||
|
||||
try:
|
||||
if source == 'spotify':
|
||||
return client.get_album(album_id, allow_fallback=False)
|
||||
return client.get_album(album_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_artist_albums_for_source(
|
||||
source: str,
|
||||
artist_id: str,
|
||||
artist_name: str = '',
|
||||
album_type: str = 'album,single',
|
||||
limit: int = 50,
|
||||
skip_cache: bool = False,
|
||||
max_pages: int = 0,
|
||||
):
|
||||
"""Get artist albums for an exact source."""
|
||||
client = metadata_registry.get_client_for_source(source)
|
||||
if not client or not hasattr(client, 'get_artist_albums'):
|
||||
return None
|
||||
|
||||
def _fetch_for_artist(target_artist_id: str):
|
||||
kwargs = {
|
||||
'album_type': album_type,
|
||||
'limit': limit,
|
||||
}
|
||||
if source == 'spotify':
|
||||
kwargs['allow_fallback'] = False
|
||||
kwargs['skip_cache'] = skip_cache
|
||||
kwargs['max_pages'] = max_pages
|
||||
return client.get_artist_albums(target_artist_id, **kwargs)
|
||||
|
||||
try:
|
||||
if artist_id:
|
||||
albums = _fetch_for_artist(artist_id) or []
|
||||
if albums:
|
||||
return albums
|
||||
else:
|
||||
albums = []
|
||||
|
||||
if not artist_name:
|
||||
return albums
|
||||
|
||||
search_results = _search_artists_for_source(source, client, artist_name, limit=5)
|
||||
if not search_results:
|
||||
return albums
|
||||
|
||||
best = _pick_best_artist_match(search_results, artist_name)
|
||||
if not best:
|
||||
return albums
|
||||
|
||||
found_artist_id = _extract_lookup_value(best, 'id', 'artist_id')
|
||||
if not found_artist_id:
|
||||
return albums
|
||||
|
||||
resolved = _fetch_for_artist(found_artist_id) or []
|
||||
if resolved:
|
||||
logger.debug("Found %s artist '%s' (id=%s)", source, _extract_lookup_value(best, 'name', 'artist_name', 'title'), found_artist_id)
|
||||
return resolved
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_album_reference(
|
||||
album_id: str,
|
||||
preferred_source: Optional[str] = None,
|
||||
album_name: str = '',
|
||||
artist_name: str = '',
|
||||
) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Resolve a local database album ID or name-based reference to a provider ID."""
|
||||
try:
|
||||
from database.music_database import get_database
|
||||
|
||||
database = get_database()
|
||||
with database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("PRAGMA table_info(albums)")
|
||||
album_columns = {row[1] for row in cursor.fetchall()}
|
||||
|
||||
source_chain = list(metadata_registry.get_source_priority(preferred_source or metadata_registry.get_primary_source()))
|
||||
override = (preferred_source or '').strip().lower()
|
||||
if override:
|
||||
source_chain = [override] + [source for source in source_chain if source != override]
|
||||
|
||||
source_columns = {
|
||||
'spotify': ('spotify_album_id',),
|
||||
'deezer': ('deezer_id', 'deezer_album_id'),
|
||||
'itunes': ('itunes_album_id',),
|
||||
'discogs': ('discogs_id',),
|
||||
'hydrabase': ('soul_id', 'hydrabase_album_id'),
|
||||
}
|
||||
|
||||
select_columns = ["a.title", "ar.name as artist_name"]
|
||||
for columns in source_columns.values():
|
||||
for column in columns:
|
||||
if column in album_columns:
|
||||
select_columns.append(f"a.{column}")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT {select_columns}
|
||||
FROM albums a
|
||||
JOIN artists ar ON a.artist_id = ar.id
|
||||
WHERE a.id = ?
|
||||
""".format(select_columns=", ".join(select_columns)),
|
||||
(album_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
for source in source_chain:
|
||||
for column in source_columns.get(source, ()):
|
||||
if column not in row.keys():
|
||||
continue
|
||||
value = row[column]
|
||||
if value:
|
||||
return value, source
|
||||
|
||||
search_title = album_name or row['title']
|
||||
search_artist = artist_name or row['artist_name']
|
||||
query = f"{search_artist} {search_title}".strip()
|
||||
|
||||
for source in source_chain:
|
||||
client = metadata_registry.get_client_for_source(source)
|
||||
if not client:
|
||||
continue
|
||||
results = _search_albums_for_source(source, client, query, limit=5)
|
||||
if results:
|
||||
for album in results:
|
||||
candidate_name = str(_extract_lookup_value(album, 'name', 'title', default='') or '').strip().lower()
|
||||
if candidate_name and candidate_name == str(search_title).strip().lower():
|
||||
return _extract_lookup_value(album, 'id', 'album_id', 'release_id'), source
|
||||
best = results[0]
|
||||
return _extract_lookup_value(best, 'id', 'album_id', 'release_id'), source
|
||||
|
||||
if not album_name and not artist_name:
|
||||
return None, None
|
||||
|
||||
query = " ".join(part for part in (artist_name, album_name) if part).strip() or album_id
|
||||
for source in source_chain:
|
||||
client = metadata_registry.get_client_for_source(source)
|
||||
if not client:
|
||||
continue
|
||||
results = _search_albums_for_source(source, client, query, limit=5)
|
||||
if results:
|
||||
for album in results:
|
||||
candidate_name = str(_extract_lookup_value(album, 'name', 'title', default='') or '').strip().lower()
|
||||
if album_name and candidate_name == album_name.strip().lower():
|
||||
return _extract_lookup_value(album, 'id', 'album_id', 'release_id'), source
|
||||
best = results[0]
|
||||
return _extract_lookup_value(best, 'id', 'album_id', 'release_id'), source
|
||||
except Exception as e:
|
||||
logger.debug("Error resolving album reference %s: %s", album_id, e)
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def get_artist_album_tracks(
|
||||
album_id: str,
|
||||
artist_name: str = '',
|
||||
album_name: str = '',
|
||||
source_override: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Get a normalized album-track payload using source-priority lookup."""
|
||||
source_chain = _get_source_chain_for_lookup(
|
||||
MetadataLookupOptions(source_override=source_override, allow_fallback=True)
|
||||
)
|
||||
preferred_source = source_chain[0] if source_chain else None
|
||||
|
||||
for source in source_chain:
|
||||
client = metadata_registry.get_client_for_source(source)
|
||||
if not client:
|
||||
continue
|
||||
|
||||
album_data = get_album_for_source(source, album_id)
|
||||
if not album_data:
|
||||
continue
|
||||
|
||||
tracks_data = None
|
||||
if not _extract_album_track_items(album_data):
|
||||
tracks_data = get_album_tracks_for_source(source, album_id)
|
||||
payload = _build_album_tracks_payload(
|
||||
album_data,
|
||||
tracks_data,
|
||||
source,
|
||||
album_id,
|
||||
album_name=album_name,
|
||||
artist_name=artist_name,
|
||||
)
|
||||
if payload['tracks']:
|
||||
payload['success'] = True
|
||||
payload['source_priority'] = source_chain
|
||||
payload['resolved_album_id'] = album_id
|
||||
return payload
|
||||
|
||||
resolved_album_id, resolved_source = resolve_album_reference(
|
||||
album_id,
|
||||
preferred_source=preferred_source,
|
||||
album_name=album_name,
|
||||
artist_name=artist_name,
|
||||
)
|
||||
|
||||
if resolved_album_id:
|
||||
retry_sources = []
|
||||
if resolved_source:
|
||||
retry_sources.append(resolved_source)
|
||||
retry_sources.extend(source for source in source_chain if source not in retry_sources)
|
||||
|
||||
for source in retry_sources:
|
||||
client = metadata_registry.get_client_for_source(source)
|
||||
if not client:
|
||||
continue
|
||||
|
||||
album_data = get_album_for_source(source, resolved_album_id)
|
||||
if not album_data:
|
||||
continue
|
||||
|
||||
tracks_data = None
|
||||
if not _extract_album_track_items(album_data):
|
||||
tracks_data = get_album_tracks_for_source(source, resolved_album_id)
|
||||
payload = _build_album_tracks_payload(
|
||||
album_data,
|
||||
tracks_data,
|
||||
source,
|
||||
resolved_album_id,
|
||||
album_name=album_name,
|
||||
artist_name=artist_name,
|
||||
)
|
||||
if payload['tracks']:
|
||||
payload['success'] = True
|
||||
payload['source_priority'] = source_chain
|
||||
payload['resolved_album_id'] = resolved_album_id
|
||||
return payload
|
||||
|
||||
# Keep trying the remaining sources in case another provider has the track listing.
|
||||
continue
|
||||
|
||||
if resolved_album_id:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'No tracks found for album — it may be region-restricted or unavailable on this metadata source',
|
||||
'status_code': 404,
|
||||
'source_priority': source_chain,
|
||||
'resolved_album_id': resolved_album_id,
|
||||
'tracks': [],
|
||||
'album': {
|
||||
'id': resolved_album_id,
|
||||
'name': album_name or resolved_album_id,
|
||||
'image_url': None,
|
||||
'images': [],
|
||||
'release_date': '',
|
||||
'album_type': 'album',
|
||||
'total_tracks': 0,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Album not found',
|
||||
'status_code': 404,
|
||||
'source_priority': source_chain,
|
||||
'resolved_album_id': None,
|
||||
'tracks': [],
|
||||
'album': {
|
||||
'id': album_id,
|
||||
'name': album_name or album_id,
|
||||
'image_url': None,
|
||||
'images': [],
|
||||
'release_date': '',
|
||||
'album_type': 'album',
|
||||
'total_tracks': 0,
|
||||
},
|
||||
}
|
||||
138
core/metadata/artist_image.py
Normal file
138
core/metadata/artist_image.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"""Artist image lookup helpers for metadata API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from core.metadata import registry as metadata_registry
|
||||
from core.metadata.discography import _extract_lookup_value
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("metadata.artist_image")
|
||||
|
||||
__all__ = [
|
||||
"get_artist_image_url",
|
||||
]
|
||||
|
||||
|
||||
def _extract_artist_image_url(artist_data: Any) -> Optional[str]:
|
||||
if not artist_data:
|
||||
return None
|
||||
|
||||
images = _extract_lookup_value(artist_data, 'images', default=[]) or []
|
||||
if not isinstance(images, list):
|
||||
try:
|
||||
images = list(images)
|
||||
except TypeError:
|
||||
images = []
|
||||
|
||||
if images:
|
||||
first_image = images[0]
|
||||
image_url = _extract_lookup_value(first_image, 'url')
|
||||
if image_url:
|
||||
return image_url
|
||||
|
||||
return _extract_lookup_value(
|
||||
artist_data,
|
||||
'image_url',
|
||||
'thumb_url',
|
||||
'cover_image',
|
||||
'picture_xl',
|
||||
'picture_big',
|
||||
'picture_medium',
|
||||
)
|
||||
|
||||
|
||||
def _get_artist_image_from_source(source: str, artist_id: str) -> Optional[str]:
|
||||
client = metadata_registry.get_client_for_source(source)
|
||||
if not client:
|
||||
return None
|
||||
|
||||
try:
|
||||
if source == 'spotify':
|
||||
artist_data = client.get_artist(artist_id, allow_fallback=False)
|
||||
else:
|
||||
artist_data = client.get_artist(artist_id)
|
||||
except Exception as exc:
|
||||
logger.debug("Could not fetch artist image for %s on %s: %s", artist_id, source, exc)
|
||||
artist_data = None
|
||||
|
||||
image_url = _extract_artist_image_url(artist_data)
|
||||
if image_url:
|
||||
return image_url
|
||||
|
||||
if hasattr(client, '_get_artist_image_from_albums'):
|
||||
try:
|
||||
return client._get_artist_image_from_albums(artist_id)
|
||||
except Exception as exc:
|
||||
logger.debug("Could not fetch artist album art for %s on %s: %s", artist_id, source, exc)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _lookup_artist_image_by_name(name: str) -> Optional[str]:
|
||||
"""Look up an artist image by name across fallback sources."""
|
||||
name = (name or '').strip()
|
||||
if not name:
|
||||
return None
|
||||
|
||||
skip_sources = {'musicbrainz', 'soulseek', 'youtube_videos', 'hydrabase'}
|
||||
for source in metadata_registry.get_source_priority(metadata_registry.get_primary_source()):
|
||||
if source in skip_sources:
|
||||
continue
|
||||
client = metadata_registry.get_client_for_source(source)
|
||||
if not client or not hasattr(client, 'search_artists'):
|
||||
continue
|
||||
try:
|
||||
results = client.search_artists(name, limit=1) or []
|
||||
if results:
|
||||
top = results[0]
|
||||
image_url = getattr(top, 'image_url', None) or (
|
||||
top.get('image_url') if isinstance(top, dict) else None
|
||||
)
|
||||
if image_url:
|
||||
return image_url
|
||||
except Exception as exc:
|
||||
logger.debug("Artist image lookup by name failed on %s for %r: %s", source, name, exc)
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_artist_image_url(
|
||||
artist_id: str,
|
||||
source_override: Optional[str] = None,
|
||||
plugin: Optional[str] = None,
|
||||
artist_name: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Resolve an artist image URL using the configured source priority."""
|
||||
if not artist_id:
|
||||
return None
|
||||
|
||||
if artist_id.startswith('soul_'):
|
||||
return None
|
||||
|
||||
source_override = (source_override or '').strip().lower()
|
||||
plugin = (plugin or '').strip().lower()
|
||||
|
||||
if source_override == 'hydrabase':
|
||||
if plugin in ('deezer', 'itunes'):
|
||||
return _get_artist_image_from_source(plugin, artist_id)
|
||||
if artist_id.isdigit():
|
||||
return _get_artist_image_from_source('itunes', artist_id)
|
||||
return None
|
||||
|
||||
if source_override == 'musicbrainz':
|
||||
if not artist_name:
|
||||
return None
|
||||
return _lookup_artist_image_by_name(artist_name)
|
||||
|
||||
if source_override:
|
||||
return _get_artist_image_from_source(source_override, artist_id)
|
||||
|
||||
for source in metadata_registry.get_source_priority(metadata_registry.get_primary_source()):
|
||||
image_url = _get_artist_image_from_source(source, artist_id)
|
||||
if image_url:
|
||||
return image_url
|
||||
|
||||
return None
|
||||
157
core/metadata/artwork.py
Normal file
157
core/metadata/artwork.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"""Album artwork helpers for metadata enrichment."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import urllib.request
|
||||
|
||||
from core.imports.context import get_import_context_album
|
||||
from core.metadata.common import (
|
||||
get_config_manager,
|
||||
get_image_dimensions,
|
||||
get_mutagen_symbols,
|
||||
)
|
||||
from utils.logging_config import get_logger as _create_logger
|
||||
|
||||
__all__ = [
|
||||
"embed_album_art_metadata",
|
||||
"download_cover_art",
|
||||
]
|
||||
|
||||
|
||||
logger = _create_logger("metadata.artwork")
|
||||
|
||||
|
||||
def embed_album_art_metadata(audio_file, metadata: dict):
|
||||
cfg = get_config_manager()
|
||||
symbols = get_mutagen_symbols()
|
||||
if not symbols:
|
||||
return
|
||||
|
||||
try:
|
||||
image_data = None
|
||||
mime_type = None
|
||||
|
||||
release_mbid = metadata.get("musicbrainz_release_id")
|
||||
if release_mbid and cfg.get("metadata_enhancement.prefer_caa_art", False):
|
||||
try:
|
||||
caa_url = f"https://coverartarchive.org/release/{release_mbid}/front"
|
||||
req = urllib.request.Request(caa_url, headers={"Accept": "image/*"})
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
image_data = response.read()
|
||||
mime_type = response.info().get_content_type() or "image/jpeg"
|
||||
if not image_data or len(image_data) <= 1000:
|
||||
image_data = None
|
||||
except Exception:
|
||||
image_data = None
|
||||
|
||||
if not image_data:
|
||||
art_url = metadata.get("album_art_url")
|
||||
if not art_url:
|
||||
logger.warning("No album art URL available for embedding.")
|
||||
return
|
||||
with urllib.request.urlopen(art_url, timeout=10) as response:
|
||||
image_data = response.read()
|
||||
mime_type = response.info().get_content_type() or "image/jpeg"
|
||||
|
||||
if not image_data:
|
||||
logger.error("Failed to download album art data.")
|
||||
return
|
||||
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.tags.add(symbols.APIC(encoding=3, mime=mime_type, type=3, desc="Cover", data=image_data))
|
||||
elif isinstance(audio_file, symbols.FLAC):
|
||||
picture = symbols.Picture()
|
||||
picture.data = image_data
|
||||
picture.type = 3
|
||||
picture.mime = mime_type
|
||||
width, height = get_image_dimensions(image_data)
|
||||
picture.width = width or 640
|
||||
picture.height = height or 640
|
||||
picture.depth = 24
|
||||
audio_file.add_picture(picture)
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
fmt = symbols.MP4Cover.FORMAT_JPEG if "jpeg" in mime_type else symbols.MP4Cover.FORMAT_PNG
|
||||
audio_file["covr"] = [symbols.MP4Cover(image_data, imageformat=fmt)]
|
||||
|
||||
logger.info("Album art successfully embedded.")
|
||||
except Exception as exc:
|
||||
logger.error("Error embedding album art: %s", exc)
|
||||
|
||||
|
||||
def download_cover_art(album_info: dict, target_dir: str, context: dict = None):
|
||||
cfg = get_config_manager()
|
||||
if cfg.get("metadata_enhancement.cover_art_download", True) is False:
|
||||
return
|
||||
|
||||
try:
|
||||
cover_path = os.path.join(target_dir, "cover.jpg")
|
||||
album_info = album_info or {}
|
||||
release_mbid = album_info.get("musicbrainz_release_id")
|
||||
prefer_caa = cfg.get("metadata_enhancement.prefer_caa_art", False)
|
||||
|
||||
if os.path.exists(cover_path):
|
||||
if release_mbid and prefer_caa:
|
||||
try:
|
||||
existing_size = os.path.getsize(cover_path)
|
||||
if existing_size > 200_000:
|
||||
return
|
||||
is_upgrade = True
|
||||
except Exception:
|
||||
return
|
||||
else:
|
||||
return
|
||||
else:
|
||||
is_upgrade = False
|
||||
|
||||
image_data = None
|
||||
if release_mbid and prefer_caa:
|
||||
try:
|
||||
caa_url = f"https://coverartarchive.org/release/{release_mbid}/front"
|
||||
req = urllib.request.Request(caa_url, headers={"Accept": "image/*"})
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
image_data = response.read()
|
||||
if not image_data or len(image_data) <= 1000:
|
||||
image_data = None
|
||||
except Exception:
|
||||
image_data = None
|
||||
|
||||
if is_upgrade and not image_data:
|
||||
logger.error("CAA upgrade failed - keeping existing cover.jpg")
|
||||
return
|
||||
|
||||
if not image_data:
|
||||
art_url = album_info.get("album_image_url")
|
||||
if not art_url and context:
|
||||
album_ctx = get_import_context_album(context)
|
||||
art_url = album_ctx.get("image_url")
|
||||
if not art_url and album_ctx.get("images"):
|
||||
images = album_ctx.get("images", [])
|
||||
if images and isinstance(images[0], dict):
|
||||
art_url = images[0].get("url", "")
|
||||
if art_url:
|
||||
logger.info("Using cover art URL from album context")
|
||||
if art_url and "i.scdn.co" in art_url:
|
||||
try:
|
||||
from core.spotify_client import _upgrade_spotify_image_url
|
||||
|
||||
art_url = _upgrade_spotify_image_url(art_url)
|
||||
except Exception:
|
||||
pass
|
||||
elif art_url and "mzstatic.com" in art_url:
|
||||
art_url = re.sub(r"\d+x\d+bb", "3000x3000bb", art_url)
|
||||
if not art_url:
|
||||
logger.warning("No cover art URL available for download.")
|
||||
return
|
||||
with urllib.request.urlopen(art_url, timeout=10) as response:
|
||||
image_data = response.read()
|
||||
|
||||
if not image_data:
|
||||
return
|
||||
|
||||
with open(cover_path, "wb") as handle:
|
||||
handle.write(image_data)
|
||||
logger.info("Cover art downloaded to: %s", cover_path)
|
||||
except Exception as exc:
|
||||
logger.error("Error downloading cover.jpg: %s", exc)
|
||||
267
core/metadata/common.py
Normal file
267
core/metadata/common.py
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
"""Shared low-level helpers for metadata enrichment."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import weakref
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from utils.logging_config import get_logger as _create_logger
|
||||
|
||||
|
||||
logger = _create_logger("metadata.common")
|
||||
|
||||
__all__ = [
|
||||
"get_logger",
|
||||
"get_config_manager",
|
||||
"get_mutagen_symbols",
|
||||
"get_file_lock",
|
||||
"is_ogg_opus",
|
||||
"is_vorbis_like",
|
||||
"save_audio_file",
|
||||
"get_image_dimensions",
|
||||
"strip_all_non_audio_tags",
|
||||
"verify_metadata_written",
|
||||
"wipe_source_tags",
|
||||
]
|
||||
|
||||
_FILE_LOCKS: "weakref.WeakValueDictionary[str, threading.Lock]" = weakref.WeakValueDictionary()
|
||||
_FILE_LOCKS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
class _NullConfigManager:
|
||||
def get(self, _key: str, default: Any = None) -> Any:
|
||||
return default
|
||||
|
||||
|
||||
def get_logger():
|
||||
return logger
|
||||
|
||||
|
||||
def get_config_manager():
|
||||
try:
|
||||
from config.settings import config_manager as settings_config_manager
|
||||
|
||||
return settings_config_manager
|
||||
except Exception:
|
||||
return _NullConfigManager()
|
||||
|
||||
|
||||
def get_mutagen_symbols():
|
||||
"""Lazy mutagen import so tests can monkeypatch this without the package installed."""
|
||||
try:
|
||||
from mutagen import File as MutagenFile
|
||||
from mutagen.apev2 import APEv2, APENoHeaderError
|
||||
from mutagen.flac import FLAC, Picture
|
||||
from mutagen.id3 import (
|
||||
APIC,
|
||||
ID3,
|
||||
TBPM,
|
||||
TCOP,
|
||||
TDOR,
|
||||
TDRC,
|
||||
TCON,
|
||||
TIT2,
|
||||
TALB,
|
||||
TPE1,
|
||||
TPE2,
|
||||
TPOS,
|
||||
TPUB,
|
||||
TRCK,
|
||||
TSRC,
|
||||
TXXX,
|
||||
UFID,
|
||||
TMED,
|
||||
)
|
||||
from mutagen.mp4 import MP4, MP4Cover, MP4FreeForm
|
||||
from mutagen.oggvorbis import OggVorbis
|
||||
try:
|
||||
from mutagen.oggopus import OggOpus
|
||||
except Exception:
|
||||
OggOpus = None
|
||||
except Exception as exc:
|
||||
logger.debug("Mutagen unavailable for metadata enrichment: %s", exc)
|
||||
return None
|
||||
|
||||
return SimpleNamespace(
|
||||
File=MutagenFile,
|
||||
APEv2=APEv2,
|
||||
APENoHeaderError=APENoHeaderError,
|
||||
FLAC=FLAC,
|
||||
Picture=Picture,
|
||||
ID3=ID3,
|
||||
APIC=APIC,
|
||||
TBPM=TBPM,
|
||||
TCOP=TCOP,
|
||||
TDOR=TDOR,
|
||||
TDRC=TDRC,
|
||||
TCON=TCON,
|
||||
TIT2=TIT2,
|
||||
TALB=TALB,
|
||||
TPE1=TPE1,
|
||||
TPE2=TPE2,
|
||||
TPOS=TPOS,
|
||||
TPUB=TPUB,
|
||||
TRCK=TRCK,
|
||||
TSRC=TSRC,
|
||||
TXXX=TXXX,
|
||||
UFID=UFID,
|
||||
TMED=TMED,
|
||||
MP4=MP4,
|
||||
MP4Cover=MP4Cover,
|
||||
MP4FreeForm=MP4FreeForm,
|
||||
OggVorbis=OggVorbis,
|
||||
OggOpus=OggOpus,
|
||||
)
|
||||
|
||||
|
||||
def get_file_lock(file_path: str) -> threading.Lock:
|
||||
# Keep a per-path lock while it is actively referenced, but let it
|
||||
# fall out of the cache once nobody is using it anymore.
|
||||
with _FILE_LOCKS_LOCK:
|
||||
lock = _FILE_LOCKS.get(file_path)
|
||||
if lock is None:
|
||||
lock = threading.Lock()
|
||||
_FILE_LOCKS[file_path] = lock
|
||||
return lock
|
||||
|
||||
|
||||
def is_ogg_opus(audio_file: Any) -> bool:
|
||||
return type(audio_file).__name__ == "OggOpus"
|
||||
|
||||
|
||||
def is_vorbis_like(audio_file: Any, symbols: Any) -> bool:
|
||||
vorbis_classes = tuple(
|
||||
cls for cls in (
|
||||
getattr(symbols, "FLAC", None),
|
||||
getattr(symbols, "OggVorbis", None),
|
||||
) if cls is not None
|
||||
)
|
||||
return bool(vorbis_classes) and isinstance(audio_file, vorbis_classes) or is_ogg_opus(audio_file)
|
||||
|
||||
|
||||
def save_audio_file(audio_file: Any, symbols: Any) -> None:
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.save(v1=0, v2_version=4)
|
||||
elif isinstance(audio_file, symbols.FLAC):
|
||||
audio_file.save(deleteid3=True)
|
||||
else:
|
||||
audio_file.save()
|
||||
|
||||
|
||||
def get_image_dimensions(data: bytes):
|
||||
try:
|
||||
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
import struct
|
||||
|
||||
w, h = struct.unpack(">II", data[16:24])
|
||||
return w, h
|
||||
if data[:2] == b"\xff\xd8":
|
||||
import struct
|
||||
|
||||
i = 2
|
||||
while i < len(data) - 9:
|
||||
if data[i] != 0xFF:
|
||||
break
|
||||
marker = data[i + 1]
|
||||
if marker in (0xC0, 0xC2):
|
||||
h, w = struct.unpack(">HH", data[i + 5 : i + 9])
|
||||
return w, h
|
||||
length = struct.unpack(">H", data[i + 2 : i + 4])[0]
|
||||
i += 2 + length
|
||||
except Exception:
|
||||
pass
|
||||
return None, None
|
||||
|
||||
|
||||
def strip_all_non_audio_tags(file_path: str) -> dict:
|
||||
summary = {"apev2_stripped": False, "apev2_tag_count": 0}
|
||||
if os.path.splitext(file_path)[1].lower() != ".mp3":
|
||||
return summary
|
||||
|
||||
symbols = get_mutagen_symbols()
|
||||
if not symbols:
|
||||
return summary
|
||||
|
||||
try:
|
||||
apev2_tags = symbols.APEv2(file_path)
|
||||
tag_count = len(apev2_tags)
|
||||
tag_keys = list(apev2_tags.keys())
|
||||
apev2_tags.delete(file_path)
|
||||
summary["apev2_stripped"] = True
|
||||
summary["apev2_tag_count"] = tag_count
|
||||
logger.info("Stripped %s APEv2 tags: %s", tag_count, ", ".join(tag_keys[:10]))
|
||||
except symbols.APENoHeaderError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.error("Could not strip APEv2 tags (non-fatal): %s", exc)
|
||||
return summary
|
||||
|
||||
|
||||
def verify_metadata_written(file_path: str) -> bool:
|
||||
symbols = get_mutagen_symbols()
|
||||
if not symbols:
|
||||
return False
|
||||
|
||||
try:
|
||||
check = symbols.File(file_path)
|
||||
if check is None or check.tags is None:
|
||||
logger.info("[VERIFY] Tags are None after save: %s", file_path)
|
||||
return False
|
||||
|
||||
title_found = False
|
||||
artist_found = False
|
||||
if isinstance(check.tags, symbols.ID3):
|
||||
title_found = bool(check.tags.getall("TIT2"))
|
||||
artist_found = bool(check.tags.getall("TPE1"))
|
||||
try:
|
||||
symbols.APEv2(file_path)
|
||||
logger.info("[VERIFY] APEv2 tags still present after processing!")
|
||||
return False
|
||||
except symbols.APENoHeaderError:
|
||||
pass
|
||||
elif is_vorbis_like(check, symbols):
|
||||
title_found = bool(check.get("title"))
|
||||
artist_found = bool(check.get("artist"))
|
||||
elif isinstance(check, symbols.MP4):
|
||||
title_found = bool(check.get("\xa9nam"))
|
||||
artist_found = bool(check.get("\xa9ART"))
|
||||
|
||||
if not title_found or not artist_found:
|
||||
logger.warning("[VERIFY] Missing metadata - title:%s artist:%s", title_found, artist_found)
|
||||
return False
|
||||
|
||||
logger.info("[VERIFY] Metadata verified OK")
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("[VERIFY] Verification error (non-fatal): %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def wipe_source_tags(file_path: str) -> bool:
|
||||
try:
|
||||
strip_all_non_audio_tags(file_path)
|
||||
symbols = get_mutagen_symbols()
|
||||
if not symbols:
|
||||
return False
|
||||
|
||||
audio = symbols.File(file_path)
|
||||
if audio is None:
|
||||
return False
|
||||
if hasattr(audio, "clear_pictures"):
|
||||
audio.clear_pictures()
|
||||
if audio.tags is not None:
|
||||
tag_count = len(audio.tags)
|
||||
audio.tags.clear()
|
||||
else:
|
||||
audio.add_tags()
|
||||
tag_count = 0
|
||||
save_audio_file(audio, symbols)
|
||||
if tag_count > 0:
|
||||
logger.info("[Tag Wipe] Stripped %s source tags from: %s", tag_count, os.path.basename(file_path))
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("[Tag Wipe] Failed (non-fatal): %s", exc)
|
||||
return False
|
||||
478
core/metadata/completion.py
Normal file
478
core/metadata/completion.py
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
"""Completion helpers for metadata lookups."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.metadata import registry as metadata_registry
|
||||
from core.metadata.album_tracks import get_album_tracks_for_source
|
||||
from core.metadata.discography import _extract_release_artist_name
|
||||
from core.metadata.lookup import MetadataLookupOptions
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("metadata.completion")
|
||||
|
||||
__all__ = [
|
||||
"check_album_completion",
|
||||
"check_artist_discography_completion",
|
||||
"check_single_completion",
|
||||
"iter_artist_discography_completion_events",
|
||||
]
|
||||
|
||||
|
||||
def _extract_track_items(api_tracks: Any) -> List[Dict[str, Any]]:
|
||||
if not api_tracks:
|
||||
return []
|
||||
if isinstance(api_tracks, dict):
|
||||
return api_tracks.get('items') or []
|
||||
if isinstance(api_tracks, list):
|
||||
return api_tracks
|
||||
return []
|
||||
|
||||
|
||||
def _extract_lookup_value(value: Any, *names: str, default: Any = None) -> Any:
|
||||
if value is None:
|
||||
return default
|
||||
|
||||
for name in names:
|
||||
if isinstance(value, dict):
|
||||
if name in value and value[name] is not None:
|
||||
return value[name]
|
||||
else:
|
||||
candidate = getattr(value, name, None)
|
||||
if candidate is not None:
|
||||
return candidate
|
||||
return default
|
||||
|
||||
|
||||
def _get_completion_source_chain(source_override: Optional[str] = None) -> List[str]:
|
||||
primary_source = metadata_registry.get_primary_source()
|
||||
source_chain = list(metadata_registry.get_source_priority(primary_source))
|
||||
|
||||
override = (source_override or '').strip().lower()
|
||||
if override:
|
||||
source_chain = [override] + [source for source in source_chain if source != override]
|
||||
|
||||
return source_chain
|
||||
|
||||
|
||||
def _resolve_completion_artist_name(
|
||||
discography: Dict[str, Any],
|
||||
artist_name: str,
|
||||
) -> str:
|
||||
resolved_name = (artist_name or '').strip()
|
||||
if resolved_name and resolved_name.lower() != 'unknown artist':
|
||||
return resolved_name
|
||||
|
||||
release_items = list((discography or {}).get('albums', []) or []) + list((discography or {}).get('singles', []) or [])
|
||||
if not release_items:
|
||||
return resolved_name or 'Unknown Artist'
|
||||
|
||||
release_artist_name = _extract_release_artist_name(release_items[0])
|
||||
if release_artist_name:
|
||||
logger.debug("Using release artist metadata '%s' for completion", release_artist_name)
|
||||
return release_artist_name
|
||||
|
||||
return resolved_name or 'Unknown Artist'
|
||||
|
||||
|
||||
def _resolve_completion_track_total(release: Dict[str, Any], source_chain: List[str]) -> int:
|
||||
total_tracks = _extract_lookup_value(release, 'total_tracks', default=0) or 0
|
||||
if total_tracks:
|
||||
return int(total_tracks)
|
||||
|
||||
release_id = _extract_lookup_value(release, 'id', 'album_id', 'release_id')
|
||||
if not release_id:
|
||||
return 0
|
||||
|
||||
for source in source_chain:
|
||||
try:
|
||||
api_tracks = get_album_tracks_for_source(source, str(release_id))
|
||||
items = _extract_track_items(api_tracks)
|
||||
if items:
|
||||
logger.debug("Resolved track count for release %s from %s", release_id, source)
|
||||
return len(items)
|
||||
except Exception as exc:
|
||||
logger.debug("Could not resolve track count for release %s from %s: %s", release_id, source, exc)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def check_album_completion(
|
||||
db,
|
||||
album_data: Dict[str, Any],
|
||||
artist_name: str,
|
||||
source_override: Optional[str] = None,
|
||||
source_chain: Optional[List[str]] = None,
|
||||
candidate_albums: Optional[List[Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Check completion status for a single album."""
|
||||
try:
|
||||
source_chain = source_chain or _get_completion_source_chain(source_override)
|
||||
album_name = album_data.get('name', '')
|
||||
total_tracks = _resolve_completion_track_total(album_data, source_chain)
|
||||
album_id = album_data.get('id', '')
|
||||
|
||||
# If total_tracks is 0 (Discogs masters don't include track counts),
|
||||
# try to fetch the real count from the prioritized metadata sources.
|
||||
if total_tracks == 0 and album_id:
|
||||
logger.debug("No track count found for '%s' (%s)", album_name, album_id)
|
||||
|
||||
logger.debug(f"Checking album: '{album_name}' ({total_tracks} tracks)")
|
||||
|
||||
formats = []
|
||||
try:
|
||||
from config.settings import config_manager
|
||||
|
||||
active_server = config_manager.get_active_media_server()
|
||||
db_album, confidence, owned_tracks, expected_tracks, is_complete, formats = db.check_album_exists_with_completeness(
|
||||
title=album_name,
|
||||
artist=artist_name,
|
||||
expected_track_count=total_tracks if total_tracks > 0 else None,
|
||||
confidence_threshold=0.7,
|
||||
server_source=active_server,
|
||||
candidate_albums=candidate_albums,
|
||||
)
|
||||
except Exception as db_error:
|
||||
logger.error(f"Database error for album '{album_name}': {db_error}")
|
||||
return {
|
||||
"id": album_id,
|
||||
"name": album_name,
|
||||
"status": "error",
|
||||
"owned_tracks": 0,
|
||||
"expected_tracks": total_tracks,
|
||||
"completion_percentage": 0,
|
||||
"confidence": 0.0,
|
||||
"found_in_db": False,
|
||||
"error_message": str(db_error),
|
||||
"formats": [],
|
||||
}
|
||||
|
||||
if expected_tracks > 0:
|
||||
completion_percentage = (owned_tracks / expected_tracks) * 100
|
||||
elif total_tracks > 0:
|
||||
completion_percentage = (owned_tracks / total_tracks) * 100
|
||||
else:
|
||||
completion_percentage = 100 if owned_tracks > 0 else 0
|
||||
|
||||
if owned_tracks > 0 and owned_tracks >= (expected_tracks or total_tracks):
|
||||
status = "completed"
|
||||
elif owned_tracks > 0:
|
||||
status = "partial"
|
||||
else:
|
||||
status = "missing"
|
||||
|
||||
logger.debug(
|
||||
"Album completion result: owned=%s expected=%s total=%s completion=%.1f status=%s",
|
||||
owned_tracks,
|
||||
expected_tracks or total_tracks,
|
||||
total_tracks,
|
||||
completion_percentage,
|
||||
status,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": album_id,
|
||||
"name": album_name,
|
||||
"status": status,
|
||||
"owned_tracks": owned_tracks,
|
||||
"expected_tracks": expected_tracks or total_tracks,
|
||||
"completion_percentage": round(completion_percentage, 1),
|
||||
"confidence": round(confidence, 2) if confidence else 0.0,
|
||||
"found_in_db": db_album is not None,
|
||||
"formats": formats,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking album completion for '{album_data.get('name', 'Unknown')}': {e}")
|
||||
return {
|
||||
"id": album_data.get('id', ''),
|
||||
"name": album_data.get('name', 'Unknown'),
|
||||
"status": "error",
|
||||
"owned_tracks": 0,
|
||||
"expected_tracks": album_data.get('total_tracks', 0),
|
||||
"completion_percentage": 0,
|
||||
"confidence": 0.0,
|
||||
"found_in_db": False,
|
||||
"formats": [],
|
||||
}
|
||||
|
||||
|
||||
def check_single_completion(
|
||||
db,
|
||||
single_data: Dict[str, Any],
|
||||
artist_name: str,
|
||||
source_override: Optional[str] = None,
|
||||
source_chain: Optional[List[str]] = None,
|
||||
candidate_albums: Optional[List[Any]] = None,
|
||||
candidate_tracks: Optional[List[Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Check completion status for a single/EP."""
|
||||
try:
|
||||
source_chain = source_chain or _get_completion_source_chain(source_override)
|
||||
single_name = single_data.get('name', '')
|
||||
raw_total_tracks = single_data.get('total_tracks', 1)
|
||||
total_tracks = raw_total_tracks if raw_total_tracks is not None else 1
|
||||
single_id = single_data.get('id', '')
|
||||
album_type = single_data.get('album_type', 'single')
|
||||
formats = []
|
||||
|
||||
if total_tracks == 0:
|
||||
total_tracks = _resolve_completion_track_total(single_data, source_chain) or 1
|
||||
|
||||
logger.debug(
|
||||
"Checking %s: name=%r tracks=%s",
|
||||
album_type,
|
||||
single_name,
|
||||
total_tracks,
|
||||
)
|
||||
|
||||
if album_type == 'ep' or total_tracks > 1:
|
||||
try:
|
||||
from config.settings import config_manager
|
||||
|
||||
active_server = config_manager.get_active_media_server()
|
||||
db_album, confidence, owned_tracks, expected_tracks, is_complete, formats = db.check_album_exists_with_completeness(
|
||||
title=single_name,
|
||||
artist=artist_name,
|
||||
expected_track_count=total_tracks,
|
||||
confidence_threshold=0.7,
|
||||
server_source=active_server,
|
||||
candidate_albums=candidate_albums,
|
||||
)
|
||||
except Exception as db_error:
|
||||
logger.error(f"Database error for EP '{single_name}': {db_error}")
|
||||
owned_tracks, expected_tracks, confidence = 0, total_tracks, 0.0
|
||||
db_album = None
|
||||
|
||||
if expected_tracks > 0:
|
||||
completion_percentage = (owned_tracks / expected_tracks) * 100
|
||||
else:
|
||||
completion_percentage = (owned_tracks / total_tracks) * 100
|
||||
|
||||
if owned_tracks > 0 and owned_tracks >= (expected_tracks or total_tracks):
|
||||
status = "completed"
|
||||
elif owned_tracks > 0:
|
||||
status = "partial"
|
||||
else:
|
||||
status = "missing"
|
||||
|
||||
logger.debug(
|
||||
"EP completion result: owned=%s expected=%s total=%s completion=%.1f status=%s",
|
||||
owned_tracks,
|
||||
expected_tracks or total_tracks,
|
||||
total_tracks,
|
||||
completion_percentage,
|
||||
status,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": single_id,
|
||||
"name": single_name,
|
||||
"status": status,
|
||||
"owned_tracks": owned_tracks,
|
||||
"expected_tracks": expected_tracks or total_tracks,
|
||||
"completion_percentage": round(completion_percentage, 1),
|
||||
"confidence": round(confidence, 2) if confidence else 0.0,
|
||||
"found_in_db": db_album is not None,
|
||||
"type": album_type,
|
||||
"formats": formats,
|
||||
}
|
||||
else:
|
||||
try:
|
||||
from config.settings import config_manager
|
||||
|
||||
active_server = config_manager.get_active_media_server()
|
||||
db_track, confidence = db.check_track_exists(
|
||||
title=single_name,
|
||||
artist=artist_name,
|
||||
confidence_threshold=0.7,
|
||||
server_source=active_server,
|
||||
candidate_tracks=candidate_tracks,
|
||||
)
|
||||
except Exception as db_error:
|
||||
logger.error(f"Database error for single '{single_name}': {db_error}")
|
||||
db_track, confidence = None, 0.0
|
||||
|
||||
owned_tracks = 1 if db_track else 0
|
||||
expected_tracks = 1
|
||||
completion_percentage = 100 if db_track else 0
|
||||
status = "completed" if db_track else "missing"
|
||||
|
||||
if db_track and db_track.file_path:
|
||||
import os
|
||||
|
||||
ext = os.path.splitext(db_track.file_path)[1].lstrip('.').upper()
|
||||
if ext == 'MP3' and db_track.bitrate:
|
||||
formats = [f"MP3-{db_track.bitrate}"]
|
||||
elif ext:
|
||||
formats = [ext]
|
||||
|
||||
logger.debug(
|
||||
"Single completion result: owned=%s expected=1 completion=%.1f status=%s",
|
||||
owned_tracks,
|
||||
completion_percentage,
|
||||
status,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": single_id,
|
||||
"name": single_name,
|
||||
"status": status,
|
||||
"owned_tracks": owned_tracks,
|
||||
"expected_tracks": expected_tracks,
|
||||
"completion_percentage": round(completion_percentage, 1),
|
||||
"confidence": round(confidence, 2) if confidence else 0.0,
|
||||
"found_in_db": db_track is not None,
|
||||
"type": album_type,
|
||||
"formats": formats,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking single/EP completion for '{single_data.get('name', 'Unknown')}': {e}")
|
||||
return {
|
||||
"id": single_data.get('id', ''),
|
||||
"name": single_data.get('name', 'Unknown'),
|
||||
"status": "error",
|
||||
"owned_tracks": 0,
|
||||
"expected_tracks": single_data.get('total_tracks', 1),
|
||||
"completion_percentage": 0,
|
||||
"confidence": 0.0,
|
||||
"found_in_db": False,
|
||||
"type": single_data.get('album_type', 'single'),
|
||||
"formats": [],
|
||||
}
|
||||
|
||||
|
||||
def iter_artist_discography_completion_events(
|
||||
discography: Dict[str, Any],
|
||||
artist_name: str = 'Unknown Artist',
|
||||
source_override: Optional[str] = None,
|
||||
db=None,
|
||||
):
|
||||
"""Yield completion-stream events for artist discography ownership checks."""
|
||||
if db is None:
|
||||
from database.music_database import get_database
|
||||
|
||||
db = get_database()
|
||||
source_chain = _get_completion_source_chain(source_override)
|
||||
resolved_artist_name = _resolve_completion_artist_name(discography or {}, artist_name)
|
||||
|
||||
albums = list((discography or {}).get('albums', []) or [])
|
||||
singles = list((discography or {}).get('singles', []) or [])
|
||||
total_items = len(albums) + len(singles)
|
||||
processed_count = 0
|
||||
|
||||
import time as _time_metadata
|
||||
|
||||
candidate_albums = None
|
||||
candidate_tracks = None
|
||||
try:
|
||||
from config.settings import config_manager as _cm_metadata
|
||||
|
||||
_active_server = _cm_metadata.get_active_media_server()
|
||||
_t0 = _time_metadata.perf_counter()
|
||||
candidate_albums = db.get_candidate_albums_for_artist(resolved_artist_name, server_source=_active_server)
|
||||
_t1 = _time_metadata.perf_counter()
|
||||
print(f"[artist-completion-stream] Pre-fetched {len(candidate_albums) if candidate_albums is not None else 0} library albums for '{resolved_artist_name}' in {(_t1 - _t0) * 1000:.0f}ms")
|
||||
if candidate_albums:
|
||||
_t2 = _time_metadata.perf_counter()
|
||||
candidate_tracks = db.get_candidate_tracks_for_albums([a.id for a in candidate_albums])
|
||||
_t3 = _time_metadata.perf_counter()
|
||||
print(f"[artist-completion-stream] Pre-fetched {len(candidate_tracks) if candidate_tracks is not None else 0} library tracks in {(_t3 - _t2) * 1000:.0f}ms")
|
||||
except Exception as _pre_err:
|
||||
print(f"[artist-completion-stream] Failed to pre-fetch candidates for '{resolved_artist_name}': {_pre_err}")
|
||||
candidate_albums = None
|
||||
candidate_tracks = None
|
||||
|
||||
yield {
|
||||
'type': 'start',
|
||||
'total_items': total_items,
|
||||
'artist_name': resolved_artist_name,
|
||||
}
|
||||
|
||||
_loop_start = _time_metadata.perf_counter()
|
||||
for album in albums:
|
||||
try:
|
||||
completion_data = check_album_completion(
|
||||
db,
|
||||
album,
|
||||
resolved_artist_name,
|
||||
source_override=source_override,
|
||||
source_chain=source_chain,
|
||||
candidate_albums=candidate_albums,
|
||||
)
|
||||
completion_data['type'] = 'album_completion'
|
||||
completion_data['container_type'] = 'albums'
|
||||
processed_count += 1
|
||||
completion_data['progress'] = round((processed_count / total_items) * 100, 1) if total_items else 100
|
||||
yield completion_data
|
||||
except Exception as e:
|
||||
yield {
|
||||
'type': 'error',
|
||||
'container_type': 'albums',
|
||||
'id': album.get('id', ''),
|
||||
'name': album.get('name', 'Unknown'),
|
||||
'error': str(e),
|
||||
}
|
||||
|
||||
for single in singles:
|
||||
try:
|
||||
completion_data = check_single_completion(
|
||||
db,
|
||||
single,
|
||||
resolved_artist_name,
|
||||
source_override=source_override,
|
||||
source_chain=source_chain,
|
||||
candidate_albums=candidate_albums,
|
||||
candidate_tracks=candidate_tracks,
|
||||
)
|
||||
completion_data['type'] = 'single_completion'
|
||||
completion_data['container_type'] = 'singles'
|
||||
processed_count += 1
|
||||
completion_data['progress'] = round((processed_count / total_items) * 100, 1) if total_items else 100
|
||||
yield completion_data
|
||||
except Exception as e:
|
||||
yield {
|
||||
'type': 'error',
|
||||
'container_type': 'singles',
|
||||
'id': single.get('id', ''),
|
||||
'name': single.get('name', 'Unknown'),
|
||||
'error': str(e),
|
||||
}
|
||||
|
||||
_loop_elapsed = _time_metadata.perf_counter() - _loop_start
|
||||
print(f"[artist-completion-stream] Processed {total_items} items for '{resolved_artist_name}' in {_loop_elapsed * 1000:.0f}ms")
|
||||
|
||||
yield {
|
||||
'type': 'complete',
|
||||
'processed_count': processed_count,
|
||||
'artist_name': resolved_artist_name,
|
||||
}
|
||||
|
||||
|
||||
def check_artist_discography_completion(
|
||||
discography: Dict[str, Any],
|
||||
artist_name: str = 'Unknown Artist',
|
||||
source_override: Optional[str] = None,
|
||||
db=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return completion results for an artist discography without streaming."""
|
||||
albums_completion = []
|
||||
singles_completion = []
|
||||
|
||||
for event in iter_artist_discography_completion_events(
|
||||
discography,
|
||||
artist_name=artist_name,
|
||||
source_override=source_override,
|
||||
db=db,
|
||||
):
|
||||
if event.get('type') == 'album_completion':
|
||||
albums_completion.append(event)
|
||||
elif event.get('type') == 'single_completion':
|
||||
singles_completion.append(event)
|
||||
|
||||
return {
|
||||
'albums': albums_completion,
|
||||
'singles': singles_completion,
|
||||
}
|
||||
435
core/metadata/discography.py
Normal file
435
core/metadata/discography.py
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
"""Discography lookup helpers for metadata API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.metadata import registry as metadata_registry
|
||||
from core.metadata.album_tracks import get_artist_albums_for_source
|
||||
from core.metadata.lookup import MetadataLookupOptions
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("metadata.discography")
|
||||
|
||||
|
||||
def _extract_lookup_value(value: Any, *names: str, default: Any = None) -> Any:
|
||||
if value is None:
|
||||
return default
|
||||
|
||||
for name in names:
|
||||
if isinstance(value, dict):
|
||||
if name in value and value[name] is not None:
|
||||
return value[name]
|
||||
else:
|
||||
candidate = getattr(value, name, None)
|
||||
if candidate is not None:
|
||||
return candidate
|
||||
return default
|
||||
|
||||
|
||||
def _get_source_chain_for_lookup(options: MetadataLookupOptions) -> List[str]:
|
||||
primary_source = metadata_registry.get_primary_source()
|
||||
source_chain = list(metadata_registry.get_source_priority(primary_source))
|
||||
override = (options.source_override or '').strip().lower()
|
||||
|
||||
if override:
|
||||
source_chain = [override] + [source for source in source_chain if source != override]
|
||||
|
||||
if not options.allow_fallback:
|
||||
source_chain = source_chain[:1]
|
||||
|
||||
return source_chain
|
||||
|
||||
|
||||
def _normalize_artist_name(value: Any) -> str:
|
||||
return (value or '').strip().casefold()
|
||||
|
||||
|
||||
def _search_artists_for_source(source: str, client: Any, artist_name: str, limit: int = 5) -> List[Any]:
|
||||
if not client or not hasattr(client, 'search_artists'):
|
||||
return []
|
||||
|
||||
try:
|
||||
kwargs = {'limit': limit}
|
||||
if source == 'spotify':
|
||||
kwargs['allow_fallback'] = False
|
||||
return client.search_artists(artist_name, **kwargs) or []
|
||||
except Exception as exc:
|
||||
logger.debug("Could not search %s for %s: %s", source, artist_name, exc)
|
||||
return []
|
||||
|
||||
|
||||
def _search_albums_for_source(source: str, client: Any, query: str, limit: int = 5) -> List[Any]:
|
||||
if not client or not hasattr(client, 'search_albums'):
|
||||
return []
|
||||
|
||||
try:
|
||||
kwargs = {'limit': limit}
|
||||
if source == 'spotify':
|
||||
kwargs['allow_fallback'] = False
|
||||
return client.search_albums(query, **kwargs) or []
|
||||
except Exception as exc:
|
||||
logger.debug("Could not search %s for %s: %s", source, query, exc)
|
||||
return []
|
||||
|
||||
|
||||
def _pick_best_artist_match(search_results: List[Any], artist_name: str) -> Optional[Any]:
|
||||
"""Prefer an exact artist-name match, otherwise use the first result."""
|
||||
if not search_results:
|
||||
return None
|
||||
|
||||
target_name = _normalize_artist_name(artist_name)
|
||||
for artist in search_results:
|
||||
candidate_name = _normalize_artist_name(
|
||||
_extract_lookup_value(artist, 'name', 'artist_name', 'title')
|
||||
)
|
||||
if candidate_name == target_name:
|
||||
return artist
|
||||
|
||||
return search_results[0]
|
||||
|
||||
|
||||
def _build_discography_release_dict(release: Any, artist_id: str) -> Optional[Dict[str, Any]]:
|
||||
release_id = _extract_lookup_value(release, 'id', 'album_id', 'release_id')
|
||||
if not release_id:
|
||||
return None
|
||||
|
||||
album_type = _extract_lookup_value(release, 'album_type', default='album') or 'album'
|
||||
release_date = _extract_lookup_value(release, 'release_date')
|
||||
|
||||
return {
|
||||
'id': release_id,
|
||||
'name': _extract_lookup_value(release, 'name', 'title', default=release_id),
|
||||
'artist_name': _extract_release_artist_name(release),
|
||||
'release_date': release_date,
|
||||
'album_type': album_type,
|
||||
'image_url': _extract_lookup_value(release, 'image_url', 'thumb_url', 'cover_image'),
|
||||
'total_tracks': _extract_lookup_value(release, 'total_tracks', default=0) or 0,
|
||||
'external_urls': _extract_lookup_value(release, 'external_urls', default={}) or {},
|
||||
}
|
||||
|
||||
|
||||
def _extract_release_artist_name(release: Any) -> str:
|
||||
artist_name = _extract_lookup_value(release, 'artist_name', 'artist', default='') or ''
|
||||
artist_name = str(artist_name).strip()
|
||||
if artist_name:
|
||||
return artist_name
|
||||
|
||||
artists = _extract_lookup_value(release, 'artists', default=[]) or []
|
||||
if isinstance(artists, (str, bytes)):
|
||||
return str(artists).strip()
|
||||
if isinstance(artists, dict):
|
||||
return str(_extract_lookup_value(artists, 'name', 'artist_name', 'title', default='') or '').strip()
|
||||
|
||||
try:
|
||||
artists = list(artists)
|
||||
except TypeError:
|
||||
artists = [artists]
|
||||
|
||||
if not artists:
|
||||
return ''
|
||||
|
||||
first_artist = artists[0]
|
||||
inferred_name = _extract_lookup_value(first_artist, 'name', 'artist_name', 'title')
|
||||
if not inferred_name and isinstance(first_artist, str):
|
||||
inferred_name = first_artist
|
||||
|
||||
return str(inferred_name).strip() if inferred_name else ''
|
||||
|
||||
|
||||
def _sort_discography_releases(releases: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
def get_release_year(item):
|
||||
if item.get('release_date'):
|
||||
try:
|
||||
return int(str(item['release_date'])[:4])
|
||||
except (ValueError, IndexError, TypeError):
|
||||
return 0
|
||||
return 0
|
||||
|
||||
return sorted(releases, key=get_release_year, reverse=True)
|
||||
|
||||
|
||||
def _dedup_variant_releases(releases: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Collapse obvious edition variants into a single canonical release card.
|
||||
|
||||
This keeps a clean UI while still preserving distinct releases when the
|
||||
cleaned titles diverge enough that they are likely not variants.
|
||||
"""
|
||||
if not releases:
|
||||
return []
|
||||
|
||||
import re
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
variant_suffix_pattern = re.compile(
|
||||
r'\s*[\(\[][^()\[\]]*\b(?:edition|editions|deluxe|remaster|remastered|'
|
||||
r'explicit|clean|version|anniversary|collector|expanded|redux)\b[^()\[\]]*[\)\]]\s*$',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
legacy_suffix_pattern = re.compile(
|
||||
r'\s*-\s*(explicit|clean|deluxe edition|single)\s*$',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
variant_keyword_pattern = re.compile(
|
||||
r'\b(?:edition|editions|deluxe|remaster|remastered|explicit|clean|version|'
|
||||
r'anniversary|collector|expanded|redux)\b',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
def _clean_title(title: Any) -> str:
|
||||
cleaned = str(title or '').strip().lower()
|
||||
while True:
|
||||
new_cleaned = variant_suffix_pattern.sub('', cleaned).strip()
|
||||
new_cleaned = legacy_suffix_pattern.sub('', new_cleaned).strip()
|
||||
if new_cleaned == cleaned:
|
||||
break
|
||||
cleaned = new_cleaned
|
||||
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
|
||||
return cleaned
|
||||
|
||||
def _has_variant_suffix(title: Any) -> bool:
|
||||
raw = str(title or '').strip()
|
||||
return bool(re.search(r'[\(\[][^\)\]]*' + variant_keyword_pattern.pattern + r'[^\)\]]*[\)\]]\s*$', raw, flags=re.IGNORECASE))
|
||||
|
||||
def _is_compilation(release: Dict[str, Any]) -> bool:
|
||||
title = str(_extract_lookup_value(release, 'name', 'title', default='') or '').lower()
|
||||
album_type = str(_extract_lookup_value(release, 'album_type', default='') or '').lower()
|
||||
return (
|
||||
album_type == 'compilation'
|
||||
or 'best of' in title
|
||||
or 'greatest hits' in title
|
||||
or 'collection' in title
|
||||
or 'anthology' in title
|
||||
or 'essential' in title
|
||||
)
|
||||
|
||||
def _variant_score(release: Dict[str, Any]) -> tuple:
|
||||
title = str(_extract_lookup_value(release, 'name', 'title', default='') or '').lower()
|
||||
has_explicit = 'explicit' in title
|
||||
has_clean = 'clean' in title and not has_explicit
|
||||
track_count = int(_extract_lookup_value(release, 'track_count', 'total_tracks', default=0) or 0)
|
||||
release_date = str(_extract_lookup_value(release, 'release_date', default='') or '')
|
||||
has_variant_suffix = _has_variant_suffix(title)
|
||||
|
||||
# Higher is better.
|
||||
return (
|
||||
1 if not _is_compilation(release) else 0,
|
||||
1 if not has_variant_suffix else 0,
|
||||
2 if has_explicit else (1 if not has_clean else 0),
|
||||
track_count,
|
||||
release_date,
|
||||
)
|
||||
|
||||
grouped: Dict[tuple, Dict[str, Any]] = {}
|
||||
ordered_keys: List[tuple] = []
|
||||
|
||||
for release in releases:
|
||||
title = _extract_lookup_value(release, 'name', 'title', default='') or ''
|
||||
release_date = _extract_lookup_value(release, 'release_date')
|
||||
year = _extract_lookup_value(release, 'year')
|
||||
if not year and release_date:
|
||||
year = str(release_date)[:4]
|
||||
year = str(year) if year is not None else ''
|
||||
|
||||
cleaned_title = _clean_title(title) or str(title).strip().lower()
|
||||
key = (cleaned_title, year)
|
||||
|
||||
existing = grouped.get(key)
|
||||
if existing is None:
|
||||
grouped[key] = release
|
||||
ordered_keys.append(key)
|
||||
continue
|
||||
|
||||
# If the cleaned titles are still materially different, keep both.
|
||||
existing_clean = _clean_title(_extract_lookup_value(existing, 'name', 'title', default='') or '')
|
||||
if SequenceMatcher(None, cleaned_title, existing_clean).ratio() < 0.85:
|
||||
alt_key = (str(title).strip().lower(), year)
|
||||
if alt_key not in grouped:
|
||||
grouped[alt_key] = release
|
||||
ordered_keys.append(alt_key)
|
||||
continue
|
||||
|
||||
if _variant_score(release) > _variant_score(existing):
|
||||
grouped[key] = release
|
||||
|
||||
return [grouped[key] for key in ordered_keys]
|
||||
|
||||
|
||||
def get_artist_discography(
|
||||
artist_id: str,
|
||||
artist_name: str = '',
|
||||
options: Optional[MetadataLookupOptions] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Get a normalized artist discography with source resolution and fallback.
|
||||
|
||||
Each provider uses the same lookup flow:
|
||||
1. try the requested artist ID
|
||||
2. if that misses, search by artist name
|
||||
3. retry with the provider-specific artist ID from the search result
|
||||
"""
|
||||
options = options or MetadataLookupOptions()
|
||||
source_priority = _get_source_chain_for_lookup(options)
|
||||
source_artist_ids = options.artist_source_ids or {}
|
||||
|
||||
albums: List[Any] = []
|
||||
active_source: Optional[str] = None
|
||||
|
||||
if not albums:
|
||||
for source in source_priority:
|
||||
client = metadata_registry.get_client_for_source(source)
|
||||
if not client:
|
||||
continue
|
||||
|
||||
source_artist_id = (source_artist_ids.get(source) or '').strip()
|
||||
lookup_artist_id = source_artist_id if source_artist_id else (artist_id if not source_artist_ids else '')
|
||||
if source_artist_id:
|
||||
logger.debug("Using %s artist id %s for discography lookup", source, source_artist_id)
|
||||
|
||||
try:
|
||||
albums = get_artist_albums_for_source(
|
||||
source,
|
||||
lookup_artist_id,
|
||||
artist_name=artist_name,
|
||||
limit=options.limit,
|
||||
skip_cache=options.skip_cache,
|
||||
max_pages=options.max_pages,
|
||||
) or []
|
||||
except Exception as exc:
|
||||
logger.debug("%s direct lookup failed for artist %s: %s", source, artist_id, exc)
|
||||
albums = []
|
||||
|
||||
if albums:
|
||||
active_source = source
|
||||
logger.info("Got %s albums from %s for artist %s", len(albums), source, artist_id)
|
||||
break
|
||||
|
||||
album_list: List[Dict[str, Any]] = []
|
||||
singles_list: List[Dict[str, Any]] = []
|
||||
seen_albums = set()
|
||||
|
||||
for release in albums or []:
|
||||
release_data = _build_discography_release_dict(release, artist_id)
|
||||
if not release_data:
|
||||
continue
|
||||
|
||||
release_id = release_data['id']
|
||||
if release_id in seen_albums:
|
||||
continue
|
||||
seen_albums.add(release_id)
|
||||
|
||||
album_type = release_data.get('album_type') or 'album'
|
||||
if album_type in ['single', 'ep']:
|
||||
singles_list.append(release_data)
|
||||
else:
|
||||
album_list.append(release_data)
|
||||
|
||||
album_list = _sort_discography_releases(album_list)
|
||||
singles_list = _sort_discography_releases(singles_list)
|
||||
|
||||
logger.debug(
|
||||
"Total albums returned for artist %s: %s (source=%s)",
|
||||
artist_id,
|
||||
len(album_list) + len(singles_list),
|
||||
active_source,
|
||||
)
|
||||
|
||||
return {
|
||||
'albums': album_list,
|
||||
'singles': singles_list,
|
||||
'source': active_source or (source_priority[0] if source_priority else 'unknown'),
|
||||
'source_priority': source_priority,
|
||||
}
|
||||
|
||||
|
||||
def _build_artist_detail_release_card(release: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
release_id = _extract_lookup_value(release, 'id', 'album_id', 'release_id')
|
||||
if not release_id:
|
||||
return None
|
||||
|
||||
album_type = (_extract_lookup_value(release, 'album_type', default='album') or 'album').lower()
|
||||
release_date = _extract_lookup_value(release, 'release_date')
|
||||
release_year = None
|
||||
if release_date:
|
||||
try:
|
||||
release_year = str(release_date)[:4]
|
||||
except Exception:
|
||||
release_year = None
|
||||
if not release_year:
|
||||
release_year = _extract_lookup_value(release, 'year')
|
||||
if release_year is not None:
|
||||
release_year = str(release_year)
|
||||
|
||||
card = {
|
||||
'id': release_id,
|
||||
'name': _extract_lookup_value(release, 'name', 'title', default=release_id),
|
||||
'title': _extract_lookup_value(release, 'name', 'title', default=release_id),
|
||||
'album_type': album_type,
|
||||
'image_url': _extract_lookup_value(release, 'image_url', 'thumb_url', 'cover_image'),
|
||||
'year': release_year,
|
||||
'track_count': _extract_lookup_value(release, 'track_count', 'total_tracks', default=0) or 0,
|
||||
'owned': None,
|
||||
'track_completion': 'checking',
|
||||
}
|
||||
|
||||
if release_date:
|
||||
card['release_date'] = release_date
|
||||
elif release_year:
|
||||
card['release_date'] = f"{release_year}-01-01"
|
||||
|
||||
return card
|
||||
|
||||
|
||||
def get_artist_detail_discography(
|
||||
artist_id: str,
|
||||
artist_name: str = '',
|
||||
options: Optional[MetadataLookupOptions] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Get artist-detail-ready discography cards from the source-priority lookup flow."""
|
||||
source_discography = get_artist_discography(
|
||||
artist_id,
|
||||
artist_name=artist_name,
|
||||
options=options,
|
||||
)
|
||||
|
||||
albums: List[Dict[str, Any]] = []
|
||||
eps: List[Dict[str, Any]] = []
|
||||
singles: List[Dict[str, Any]] = []
|
||||
seen_ids = set()
|
||||
|
||||
for release in list(source_discography.get('albums', []) or []) + list(source_discography.get('singles', []) or []):
|
||||
card = _build_artist_detail_release_card(release)
|
||||
if not card:
|
||||
continue
|
||||
|
||||
release_id = card['id']
|
||||
if release_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(release_id)
|
||||
|
||||
album_type = (card.get('album_type') or 'album').lower()
|
||||
if album_type == 'ep':
|
||||
eps.append(card)
|
||||
elif album_type == 'single':
|
||||
singles.append(card)
|
||||
else:
|
||||
albums.append(card)
|
||||
|
||||
if options is None or options.dedup_variants:
|
||||
albums = _dedup_variant_releases(albums)
|
||||
eps = _dedup_variant_releases(eps)
|
||||
singles = _dedup_variant_releases(singles)
|
||||
|
||||
albums = _sort_discography_releases(albums)
|
||||
eps = _sort_discography_releases(eps)
|
||||
singles = _sort_discography_releases(singles)
|
||||
|
||||
has_releases = bool(albums or eps or singles)
|
||||
return {
|
||||
'success': has_releases,
|
||||
'albums': albums,
|
||||
'eps': eps,
|
||||
'singles': singles,
|
||||
'source': source_discography.get('source', 'unknown'),
|
||||
'source_priority': source_discography.get('source_priority', []),
|
||||
'error': None if has_releases else f'No releases found for artist "{artist_name or artist_id}"',
|
||||
}
|
||||
194
core/metadata/enrichment.py
Normal file
194
core/metadata/enrichment.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
"""Compatibility facade and orchestration for metadata enrichment."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from core.metadata.artwork import embed_album_art_metadata
|
||||
from core.metadata.common import (
|
||||
get_config_manager,
|
||||
get_file_lock,
|
||||
get_mutagen_symbols,
|
||||
is_vorbis_like,
|
||||
save_audio_file,
|
||||
strip_all_non_audio_tags,
|
||||
verify_metadata_written,
|
||||
)
|
||||
from core.metadata.source import embed_source_ids, extract_source_metadata
|
||||
from utils.logging_config import get_logger as _create_logger
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_metadata_enrichment_runtime",
|
||||
"enhance_file_metadata",
|
||||
"extract_source_metadata",
|
||||
"embed_source_ids",
|
||||
]
|
||||
|
||||
|
||||
logger = _create_logger("metadata.enrichment")
|
||||
|
||||
|
||||
def build_metadata_enrichment_runtime(
|
||||
*,
|
||||
mb_worker: Any | None = None,
|
||||
deezer_worker: Any | None = None,
|
||||
audiodb_worker: Any | None = None,
|
||||
tidal_client: Any | None = None,
|
||||
qobuz_enrichment_worker: Any | None = None,
|
||||
lastfm_worker: Any | None = None,
|
||||
genius_worker: Any | None = None,
|
||||
spotify_enrichment_worker: Any | None = None,
|
||||
itunes_enrichment_worker: Any | None = None,
|
||||
) -> SimpleNamespace:
|
||||
"""Build the runtime object consumed by core.metadata.enrichment/source."""
|
||||
return SimpleNamespace(
|
||||
mb_worker=mb_worker,
|
||||
deezer_worker=deezer_worker,
|
||||
audiodb_worker=audiodb_worker,
|
||||
tidal_client=tidal_client,
|
||||
qobuz_enrichment_worker=qobuz_enrichment_worker,
|
||||
lastfm_worker=lastfm_worker,
|
||||
genius_worker=genius_worker,
|
||||
spotify_enrichment_worker=spotify_enrichment_worker,
|
||||
itunes_enrichment_worker=itunes_enrichment_worker,
|
||||
)
|
||||
|
||||
|
||||
def enhance_file_metadata(file_path: str, context: dict, artist: dict, album_info: dict, runtime=None) -> bool:
|
||||
cfg = get_config_manager()
|
||||
if cfg.get("metadata_enhancement.enabled", True) is False:
|
||||
logger.warning("Metadata enhancement disabled in config.")
|
||||
return True
|
||||
|
||||
if album_info is None:
|
||||
album_info = {}
|
||||
|
||||
symbols = get_mutagen_symbols()
|
||||
if not symbols:
|
||||
logger.error("Mutagen is unavailable, cannot enhance metadata.")
|
||||
return False
|
||||
|
||||
file_lock = get_file_lock(file_path)
|
||||
with file_lock:
|
||||
logger.info("Enhancing metadata for: %s", os.path.basename(file_path))
|
||||
try:
|
||||
strip_all_non_audio_tags(file_path)
|
||||
audio_file = symbols.File(file_path)
|
||||
if audio_file is None:
|
||||
logger.error("Could not load audio file with Mutagen: %s", file_path)
|
||||
return False
|
||||
|
||||
if hasattr(audio_file, "clear_pictures"):
|
||||
audio_file.clear_pictures()
|
||||
|
||||
if audio_file.tags is not None:
|
||||
if len(audio_file.tags) > 0:
|
||||
tag_keys = list(audio_file.tags.keys())[:15]
|
||||
logger.info("Clearing %s existing tags: %s", len(audio_file.tags), ", ".join(str(k) for k in tag_keys))
|
||||
audio_file.tags.clear()
|
||||
else:
|
||||
audio_file.add_tags()
|
||||
|
||||
save_audio_file(audio_file, symbols)
|
||||
|
||||
metadata = extract_source_metadata(context, artist, album_info)
|
||||
if not metadata:
|
||||
logger.error("Could not extract source metadata, saving with cleared tags.")
|
||||
save_audio_file(audio_file, symbols)
|
||||
return True
|
||||
|
||||
track_num_str = f"{metadata.get('track_number', 1)}/{metadata.get('total_tracks', 1)}"
|
||||
write_multi = cfg.get("metadata_enhancement.tags.write_multi_artist", False)
|
||||
artists_list = metadata.get("_artists_list", [])
|
||||
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
if metadata.get("title"):
|
||||
audio_file.tags.add(symbols.TIT2(encoding=3, text=[metadata["title"]]))
|
||||
if metadata.get("artist"):
|
||||
audio_file.tags.add(symbols.TPE1(encoding=3, text=[metadata["artist"]]))
|
||||
if write_multi and len(artists_list) > 1:
|
||||
audio_file.tags.add(symbols.TPE1(encoding=3, text=artists_list))
|
||||
if metadata.get("album_artist"):
|
||||
audio_file.tags.add(symbols.TPE2(encoding=3, text=[metadata["album_artist"]]))
|
||||
if metadata.get("album"):
|
||||
audio_file.tags.add(symbols.TALB(encoding=3, text=[metadata["album"]]))
|
||||
if metadata.get("date"):
|
||||
audio_file.tags.add(symbols.TDRC(encoding=3, text=[metadata["date"]]))
|
||||
if metadata.get("genre"):
|
||||
audio_file.tags.add(symbols.TCON(encoding=3, text=[metadata["genre"]]))
|
||||
audio_file.tags.add(symbols.TRCK(encoding=3, text=[track_num_str]))
|
||||
if metadata.get("disc_number"):
|
||||
audio_file.tags.add(symbols.TPOS(encoding=3, text=[str(metadata["disc_number"])]))
|
||||
elif is_vorbis_like(audio_file, symbols):
|
||||
if metadata.get("title"):
|
||||
audio_file["title"] = [metadata["title"]]
|
||||
if metadata.get("artist"):
|
||||
audio_file["artist"] = [metadata["artist"]]
|
||||
if write_multi and len(artists_list) > 1:
|
||||
audio_file["artists"] = artists_list
|
||||
if metadata.get("album_artist"):
|
||||
audio_file["albumartist"] = [metadata["album_artist"]]
|
||||
if metadata.get("album"):
|
||||
audio_file["album"] = [metadata["album"]]
|
||||
if metadata.get("date"):
|
||||
audio_file["date"] = [metadata["date"]]
|
||||
if metadata.get("genre"):
|
||||
audio_file["genre"] = [metadata["genre"]]
|
||||
audio_file["tracknumber"] = [track_num_str]
|
||||
if metadata.get("disc_number"):
|
||||
audio_file["discnumber"] = [str(metadata["disc_number"])]
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
if metadata.get("title"):
|
||||
audio_file["\xa9nam"] = [metadata["title"]]
|
||||
if metadata.get("artist"):
|
||||
audio_file["\xa9ART"] = artists_list if (write_multi and len(artists_list) > 1) else [metadata["artist"]]
|
||||
if metadata.get("album_artist"):
|
||||
audio_file["aART"] = [metadata["album_artist"]]
|
||||
if metadata.get("album"):
|
||||
audio_file["\xa9alb"] = [metadata["album"]]
|
||||
if metadata.get("date"):
|
||||
audio_file["\xa9day"] = [metadata["date"]]
|
||||
if metadata.get("genre"):
|
||||
audio_file["\xa9gen"] = [metadata["genre"]]
|
||||
audio_file["trkn"] = [(metadata.get("track_number", 1), metadata.get("total_tracks", 1))]
|
||||
if metadata.get("disc_number"):
|
||||
audio_file["disk"] = [(metadata["disc_number"], 0)]
|
||||
|
||||
embed_source_ids(audio_file, metadata, context, runtime=runtime)
|
||||
|
||||
if album_info is not None and metadata.get("musicbrainz_release_id"):
|
||||
album_info["musicbrainz_release_id"] = metadata["musicbrainz_release_id"]
|
||||
|
||||
if cfg.get("metadata_enhancement.embed_album_art", True):
|
||||
embed_album_art_metadata(audio_file, metadata)
|
||||
|
||||
quality = context.get("_audio_quality", "")
|
||||
if quality and cfg.get("metadata_enhancement.tags.quality_tag", True) is not False:
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.tags.add(symbols.TXXX(encoding=3, desc="QUALITY", text=[quality]))
|
||||
elif is_vorbis_like(audio_file, symbols):
|
||||
audio_file["quality"] = [quality]
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
audio_file["----:com.apple.iTunes:QUALITY"] = [symbols.MP4FreeForm(quality.encode("utf-8"))]
|
||||
|
||||
save_audio_file(audio_file, symbols)
|
||||
|
||||
verified = verify_metadata_written(file_path)
|
||||
if verified:
|
||||
logger.info("Metadata enhanced successfully.")
|
||||
else:
|
||||
logger.info("Metadata saved but verification found issues (see above).")
|
||||
return True
|
||||
except Exception as exc:
|
||||
import traceback
|
||||
|
||||
logger.error("Error enhancing metadata for %s: %s", file_path, exc)
|
||||
logger.error("[Metadata Debug] Exception type: %s", type(exc).__name__)
|
||||
logger.info("[Metadata Debug] File exists: %s", os.path.exists(file_path))
|
||||
logger.warning("[Metadata Debug] Artist: %s", artist.get("name", "MISSING") if artist else "None")
|
||||
logger.warning("[Metadata Debug] Album info: %s", album_info.get("album_name", "MISSING") if album_info else "None")
|
||||
logger.error("[Metadata Debug] Traceback:\n%s", traceback.format_exc())
|
||||
return False
|
||||
22
core/metadata/lookup.py
Normal file
22
core/metadata/lookup.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""Shared metadata lookup policy objects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional
|
||||
|
||||
__all__ = ["MetadataLookupOptions"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetadataLookupOptions:
|
||||
"""Generic metadata lookup policy shared by metadata services."""
|
||||
|
||||
source_override: Optional[str] = None
|
||||
allow_fallback: bool = True
|
||||
skip_cache: bool = False
|
||||
max_pages: int = 0
|
||||
limit: int = 50
|
||||
artist_source_ids: Optional[Dict[str, str]] = None
|
||||
dedup_variants: bool = True
|
||||
|
||||
70
core/metadata/lyrics.py
Normal file
70
core/metadata/lyrics.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""Lyrics export helpers for metadata enrichment."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.imports.context import (
|
||||
get_import_clean_album,
|
||||
get_import_clean_title,
|
||||
get_import_context_album,
|
||||
get_import_original_search,
|
||||
normalize_import_context,
|
||||
)
|
||||
from core.metadata.common import get_config_manager
|
||||
from utils.logging_config import get_logger as _create_logger
|
||||
|
||||
__all__ = [
|
||||
"generate_lrc_file",
|
||||
]
|
||||
|
||||
|
||||
logger = _create_logger("metadata.lyrics")
|
||||
|
||||
|
||||
def generate_lrc_file(file_path: str, context: dict, artist: dict, album_info: dict) -> bool:
|
||||
cfg = get_config_manager()
|
||||
if cfg.get("metadata_enhancement.lrclib_enabled", True) is False:
|
||||
return False
|
||||
|
||||
try:
|
||||
from core.lyrics_client import lyrics_client
|
||||
|
||||
context = normalize_import_context(context)
|
||||
original_search = get_import_original_search(context)
|
||||
album_context = get_import_context_album(context)
|
||||
track_name = get_import_clean_title(context, default=original_search.get("title", "Unknown Track"))
|
||||
|
||||
if isinstance(artist, dict):
|
||||
artist_name = artist.get("name", "Unknown Artist")
|
||||
elif hasattr(artist, "name"):
|
||||
artist_name = artist.name
|
||||
else:
|
||||
artist_name = str(artist) if artist else "Unknown Artist"
|
||||
|
||||
album_name = None
|
||||
duration_seconds = None
|
||||
if album_info and album_info.get("is_album"):
|
||||
album_name = (
|
||||
get_import_clean_album(context, album_info=album_info, default="")
|
||||
or album_info.get("album_name")
|
||||
or album_context.get("name")
|
||||
)
|
||||
|
||||
if original_search.get("duration_ms"):
|
||||
duration_seconds = int(original_search["duration_ms"] / 1000)
|
||||
|
||||
success = lyrics_client.create_lrc_file(
|
||||
audio_file_path=file_path,
|
||||
track_name=track_name,
|
||||
artist_name=artist_name,
|
||||
album_name=album_name,
|
||||
duration_seconds=duration_seconds,
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info("LRC file generated for: %s", track_name)
|
||||
else:
|
||||
logger.warning("No lyrics found for: %s", track_name)
|
||||
return success
|
||||
except Exception as exc:
|
||||
logger.error("Error generating LRC file for %s: %s", file_path, exc)
|
||||
return False
|
||||
374
core/metadata/registry.py
Normal file
374
core/metadata/registry.py
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
"""Metadata client registry and source selection.
|
||||
|
||||
Owns shared metadata client singletons, runtime client registration, and
|
||||
canonical source selection. Package-internal code should use this module
|
||||
instead of importing `web_server`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import hashlib
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("metadata.registry")
|
||||
|
||||
MetadataClientFactory = Callable[[], Any]
|
||||
|
||||
METADATA_SOURCE_PRIORITY = ("deezer", "itunes", "spotify", "discogs", "hydrabase")
|
||||
METADATA_SOURCE_LABELS = {
|
||||
"spotify": "Spotify",
|
||||
"itunes": "iTunes",
|
||||
"deezer": "Deezer",
|
||||
"discogs": "Discogs",
|
||||
"hydrabase": "Hydrabase",
|
||||
}
|
||||
|
||||
_UNSET = object()
|
||||
_client_cache_lock = threading.RLock()
|
||||
_client_cache: Dict[str, Any] = {}
|
||||
|
||||
_runtime_clients_lock = threading.RLock()
|
||||
_runtime_clients: Dict[str, Any] = {
|
||||
"spotify": None,
|
||||
"hydrabase": None,
|
||||
}
|
||||
_dev_mode_enabled_provider: Callable[[], bool] = lambda: False
|
||||
_profile_spotify_credentials_provider: Callable[[int], Any] = lambda profile_id: None
|
||||
|
||||
|
||||
def register_runtime_clients(
|
||||
*,
|
||||
spotify_client: Any = _UNSET,
|
||||
hydrabase_client: Any = _UNSET,
|
||||
dev_mode_enabled_provider: Optional[Callable[[], bool]] = _UNSET,
|
||||
) -> None:
|
||||
"""Register app-owned runtime clients.
|
||||
|
||||
`None` is a valid value and clears the registered client. Omitted
|
||||
arguments leave the current registration unchanged.
|
||||
"""
|
||||
global _dev_mode_enabled_provider
|
||||
with _runtime_clients_lock:
|
||||
if spotify_client is not _UNSET:
|
||||
_runtime_clients["spotify"] = spotify_client
|
||||
if hydrabase_client is not _UNSET:
|
||||
_runtime_clients["hydrabase"] = hydrabase_client
|
||||
if dev_mode_enabled_provider is not _UNSET:
|
||||
_dev_mode_enabled_provider = dev_mode_enabled_provider or (lambda: False)
|
||||
|
||||
|
||||
def register_profile_spotify_credentials_provider(
|
||||
provider: Optional[Callable[[int], Any]] = _UNSET,
|
||||
) -> None:
|
||||
"""Register a callable that returns per-profile Spotify credentials."""
|
||||
global _profile_spotify_credentials_provider
|
||||
with _runtime_clients_lock:
|
||||
if provider is not _UNSET:
|
||||
_profile_spotify_credentials_provider = provider or (lambda profile_id: None)
|
||||
|
||||
|
||||
def get_registered_runtime_client(name: str) -> Any:
|
||||
with _runtime_clients_lock:
|
||||
return _runtime_clients.get(name)
|
||||
|
||||
|
||||
def clear_cached_metadata_clients() -> None:
|
||||
"""Clear lazily-created client singletons.
|
||||
|
||||
Runtime clients registered by the host app stay in place.
|
||||
"""
|
||||
with _client_cache_lock:
|
||||
_client_cache.clear()
|
||||
|
||||
|
||||
def clear_cached_metadata_client(cache_key: str) -> None:
|
||||
"""Clear one lazily-created client singleton by cache key."""
|
||||
with _client_cache_lock:
|
||||
_client_cache.pop(cache_key, None)
|
||||
|
||||
|
||||
def clear_cached_profile_spotify_client(profile_id: int) -> None:
|
||||
"""Clear any cached Spotify client for a specific profile."""
|
||||
prefix = f"spotify_profile::{profile_id}::"
|
||||
with _client_cache_lock:
|
||||
for key in [key for key in _client_cache if key.startswith(prefix)]:
|
||||
_client_cache.pop(key, None)
|
||||
|
||||
|
||||
def _get_config_value(key: str, default: Any = None) -> Any:
|
||||
try:
|
||||
from config.settings import config_manager
|
||||
|
||||
return config_manager.get(key, default)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _get_spotify_factory(client_factory: Optional[MetadataClientFactory]) -> MetadataClientFactory:
|
||||
if client_factory is not None:
|
||||
return client_factory
|
||||
from core.spotify_client import SpotifyClient
|
||||
|
||||
return SpotifyClient
|
||||
|
||||
|
||||
def _get_itunes_factory(client_factory: Optional[MetadataClientFactory]) -> MetadataClientFactory:
|
||||
if client_factory is not None:
|
||||
return client_factory
|
||||
from core.itunes_client import iTunesClient
|
||||
|
||||
return iTunesClient
|
||||
|
||||
|
||||
def _get_deezer_factory(client_factory: Optional[MetadataClientFactory]) -> MetadataClientFactory:
|
||||
if client_factory is not None:
|
||||
return client_factory
|
||||
from core.deezer_client import DeezerClient
|
||||
|
||||
return DeezerClient
|
||||
|
||||
|
||||
def _get_discogs_factory(client_factory: Optional[MetadataClientFactory]) -> MetadataClientFactory:
|
||||
if client_factory is not None:
|
||||
return client_factory
|
||||
from core.discogs_client import DiscogsClient
|
||||
|
||||
return DiscogsClient
|
||||
|
||||
|
||||
def get_spotify_client(client_factory: Optional[MetadataClientFactory] = None):
|
||||
"""Get shared Spotify client.
|
||||
|
||||
Prefers the app-registered runtime client. Falls back to a lazily
|
||||
cached singleton if no runtime client was registered.
|
||||
"""
|
||||
runtime_client = get_registered_runtime_client("spotify")
|
||||
if runtime_client is not None:
|
||||
return runtime_client
|
||||
|
||||
cache_key = "spotify"
|
||||
factory = _get_spotify_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 _build_profile_spotify_cache_key(profile_id: int, creds: Dict[str, Any]) -> str:
|
||||
fingerprint = hashlib.sha256(
|
||||
f"{profile_id}:{creds.get('client_id', '')}:{creds.get('client_secret', '')}:{creds.get('redirect_uri', '')}".encode(
|
||||
"utf-8"
|
||||
)
|
||||
).hexdigest()
|
||||
return f"spotify_profile::{profile_id}::{fingerprint}"
|
||||
|
||||
|
||||
def get_spotify_client_for_profile(profile_id: Optional[int] = None):
|
||||
"""Get a profile-specific Spotify client or fall back to the global one."""
|
||||
if profile_id is None or profile_id == 1:
|
||||
return get_spotify_client()
|
||||
|
||||
try:
|
||||
creds = _profile_spotify_credentials_provider(profile_id)
|
||||
if not creds or not creds.get("client_id"):
|
||||
return get_spotify_client()
|
||||
except Exception:
|
||||
return get_spotify_client()
|
||||
|
||||
cache_key = _build_profile_spotify_cache_key(profile_id, creds)
|
||||
with _client_cache_lock:
|
||||
client = _client_cache.get(cache_key)
|
||||
if client is not None and getattr(client, "sp", None) is not None:
|
||||
return client
|
||||
|
||||
try:
|
||||
from core.spotify_client import SpotifyClient
|
||||
from spotipy.oauth2 import SpotifyOAuth
|
||||
import spotipy
|
||||
|
||||
auth_manager = SpotifyOAuth(
|
||||
client_id=creds["client_id"],
|
||||
client_secret=creds["client_secret"],
|
||||
redirect_uri=creds.get("redirect_uri", "http://127.0.0.1:8888/callback"),
|
||||
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read",
|
||||
cache_path=f"config/.spotify_cache_profile_{profile_id}",
|
||||
state=f"profile_{profile_id}",
|
||||
)
|
||||
|
||||
profile_client = SpotifyClient()
|
||||
profile_client.sp = spotipy.Spotify(auth_manager=auth_manager, retries=0, requests_timeout=15)
|
||||
profile_client.user_id = None
|
||||
|
||||
with _client_cache_lock:
|
||||
_client_cache[cache_key] = profile_client
|
||||
|
||||
logger.info("Created per-profile Spotify client for profile %s", profile_id)
|
||||
return profile_client
|
||||
except Exception as e:
|
||||
logger.error("Failed to create per-profile Spotify client for profile %s: %s", profile_id, e)
|
||||
return get_spotify_client()
|
||||
|
||||
|
||||
def get_deezer_client(client_factory: Optional[MetadataClientFactory] = None):
|
||||
"""Get cached Deezer client keyed by current access token."""
|
||||
current_token = _get_config_value("deezer.access_token", None)
|
||||
cache_key = f"deezer::{current_token or ''}"
|
||||
factory = _get_deezer_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 get_itunes_client(client_factory: Optional[MetadataClientFactory] = None):
|
||||
"""Get cached iTunes client."""
|
||||
cache_key = "itunes"
|
||||
factory = _get_itunes_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 get_discogs_client(
|
||||
token: Optional[str] = None,
|
||||
client_factory: Optional[MetadataClientFactory] = None,
|
||||
):
|
||||
"""Get cached Discogs client keyed by token."""
|
||||
if token is None:
|
||||
current_token = _get_config_value("discogs.token", "") or ""
|
||||
else:
|
||||
current_token = token or ""
|
||||
|
||||
cache_key = f"discogs::{current_token}"
|
||||
factory = _get_discogs_factory(client_factory)
|
||||
with _client_cache_lock:
|
||||
client = _client_cache.get(cache_key)
|
||||
if client is None:
|
||||
client = factory(token=current_token or None) # type: ignore[misc]
|
||||
_client_cache[cache_key] = client
|
||||
return client
|
||||
|
||||
|
||||
def is_hydrabase_enabled() -> bool:
|
||||
"""Return True when Hydrabase is connected and app-enabled."""
|
||||
try:
|
||||
client = get_registered_runtime_client("hydrabase")
|
||||
if not client or not client.is_connected():
|
||||
return False
|
||||
return bool(_dev_mode_enabled_provider())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_hydrabase_client(allow_fallback: bool = True, require_enabled: bool = True):
|
||||
"""Return registered Hydrabase client or iTunes fallback."""
|
||||
try:
|
||||
client = get_registered_runtime_client("hydrabase")
|
||||
if client and client.is_connected():
|
||||
if not require_enabled or bool(_dev_mode_enabled_provider()):
|
||||
return client
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if allow_fallback:
|
||||
return get_itunes_client()
|
||||
return None
|
||||
|
||||
|
||||
def get_primary_source(spotify_client_factory: Optional[MetadataClientFactory] = None) -> str:
|
||||
"""Return configured primary metadata source."""
|
||||
source = _get_config_value("metadata.fallback_source", "deezer") or "deezer"
|
||||
|
||||
if source == "spotify":
|
||||
try:
|
||||
spotify = get_spotify_client(client_factory=spotify_client_factory)
|
||||
if not spotify or not spotify.is_spotify_authenticated():
|
||||
return "deezer"
|
||||
except Exception:
|
||||
return "deezer"
|
||||
|
||||
return source
|
||||
|
||||
|
||||
def get_spotify_disconnect_source(configured_source: Optional[str] = None) -> str:
|
||||
"""Return the active metadata source after Spotify is disconnected."""
|
||||
source = configured_source if configured_source is not None else _get_config_value("metadata.fallback_source", "deezer")
|
||||
source = source or "deezer"
|
||||
return "deezer" if source == "spotify" else source
|
||||
|
||||
|
||||
def get_metadata_source_label(source: str) -> str:
|
||||
"""Return a human-readable label for a metadata source."""
|
||||
return METADATA_SOURCE_LABELS.get(source, "Unmapped")
|
||||
|
||||
|
||||
def get_source_priority(preferred_source: str):
|
||||
"""Return source priority with preferred source first."""
|
||||
ordered = []
|
||||
if preferred_source in METADATA_SOURCE_PRIORITY:
|
||||
ordered.append(preferred_source)
|
||||
|
||||
for source in METADATA_SOURCE_PRIORITY:
|
||||
if source not in ordered:
|
||||
ordered.append(source)
|
||||
return ordered
|
||||
|
||||
|
||||
def get_primary_client(
|
||||
*,
|
||||
spotify_client_factory: Optional[MetadataClientFactory] = None,
|
||||
itunes_client_factory: Optional[MetadataClientFactory] = None,
|
||||
deezer_client_factory: Optional[MetadataClientFactory] = None,
|
||||
discogs_client_factory: Optional[MetadataClientFactory] = None,
|
||||
):
|
||||
"""Return client for configured primary source."""
|
||||
return get_client_for_source(
|
||||
get_primary_source(spotify_client_factory=spotify_client_factory),
|
||||
spotify_client_factory=spotify_client_factory,
|
||||
itunes_client_factory=itunes_client_factory,
|
||||
deezer_client_factory=deezer_client_factory,
|
||||
discogs_client_factory=discogs_client_factory,
|
||||
)
|
||||
|
||||
|
||||
def get_client_for_source(
|
||||
source: str,
|
||||
*,
|
||||
spotify_client_factory: Optional[MetadataClientFactory] = None,
|
||||
itunes_client_factory: Optional[MetadataClientFactory] = None,
|
||||
deezer_client_factory: Optional[MetadataClientFactory] = None,
|
||||
discogs_client_factory: Optional[MetadataClientFactory] = None,
|
||||
):
|
||||
"""Return exact client for a source, or None if unavailable."""
|
||||
if source == "spotify":
|
||||
try:
|
||||
client = get_spotify_client(client_factory=spotify_client_factory)
|
||||
if client and client.is_spotify_authenticated():
|
||||
return client
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
if source == "deezer":
|
||||
return get_deezer_client(client_factory=deezer_client_factory)
|
||||
|
||||
if source == "discogs":
|
||||
return get_discogs_client(client_factory=discogs_client_factory)
|
||||
|
||||
if source == "hydrabase":
|
||||
return get_hydrabase_client(allow_fallback=False)
|
||||
|
||||
if source == "itunes":
|
||||
return get_itunes_client(client_factory=itunes_client_factory)
|
||||
|
||||
return None
|
||||
174
core/metadata/service.py
Normal file
174
core/metadata/service.py
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
"""Compatibility metadata service facade.
|
||||
|
||||
The modern lookup code prefers standalone functions and shared registry
|
||||
helpers, but the legacy `MetadataService` wrapper remains available for
|
||||
call sites that still expect an object.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Literal
|
||||
|
||||
from core.metadata.registry import (
|
||||
get_client_for_source,
|
||||
get_primary_source,
|
||||
get_spotify_client,
|
||||
)
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("metadata_service")
|
||||
|
||||
MetadataProvider = Literal["spotify", "itunes", "auto"]
|
||||
|
||||
|
||||
class MetadataService:
|
||||
"""
|
||||
Unified metadata service that seamlessly switches between Spotify and
|
||||
the configured fallback source.
|
||||
"""
|
||||
|
||||
def __init__(self, preferred_provider: MetadataProvider = "auto"):
|
||||
self.preferred_provider = preferred_provider
|
||||
try:
|
||||
self.spotify = get_spotify_client()
|
||||
except Exception:
|
||||
self.spotify = None
|
||||
self._fallback_source = get_primary_source()
|
||||
try:
|
||||
self.itunes = get_client_for_source(self._fallback_source)
|
||||
except Exception:
|
||||
self.itunes = None
|
||||
self._log_initialization()
|
||||
|
||||
def _log_initialization(self):
|
||||
spotify_status = "Authenticated" if self.spotify and self.spotify.is_spotify_authenticated() else "Not authenticated"
|
||||
fallback_status = "Available" if self.itunes and getattr(self.itunes, "is_authenticated", lambda: False)() else "Not available"
|
||||
|
||||
logger.info(
|
||||
"MetadataService initialized - Spotify: %s, %s: %s",
|
||||
spotify_status,
|
||||
self._fallback_source.capitalize(),
|
||||
fallback_status,
|
||||
)
|
||||
logger.info("Preferred provider: %s", self.preferred_provider)
|
||||
|
||||
def get_active_provider(self) -> str:
|
||||
if self.preferred_provider == "spotify":
|
||||
return "spotify"
|
||||
if self.preferred_provider == "itunes":
|
||||
return self._fallback_source
|
||||
return get_primary_source()
|
||||
|
||||
def _get_client(self):
|
||||
provider = self.get_active_provider()
|
||||
if provider == "spotify":
|
||||
if not self.spotify or not self.spotify.is_spotify_authenticated():
|
||||
logger.warning(
|
||||
"Spotify requested but not authenticated, falling back to %s",
|
||||
self._fallback_source,
|
||||
)
|
||||
return self.itunes
|
||||
return self.spotify
|
||||
return self.itunes
|
||||
|
||||
def search_tracks(self, query: str, limit: int = 20) -> List:
|
||||
client = self._get_client()
|
||||
provider = self.get_active_provider()
|
||||
logger.debug("Searching tracks with %s: %r", provider, query)
|
||||
return client.search_tracks(query, limit)
|
||||
|
||||
def search_artists(self, query: str, limit: int = 20) -> List:
|
||||
client = self._get_client()
|
||||
provider = self.get_active_provider()
|
||||
logger.debug("Searching artists with %s: %r", provider, query)
|
||||
return client.search_artists(query, limit)
|
||||
|
||||
def search_albums(self, query: str, limit: int = 20) -> List:
|
||||
client = self._get_client()
|
||||
provider = self.get_active_provider()
|
||||
logger.debug("Searching albums with %s: %r", provider, query)
|
||||
return client.search_albums(query, limit)
|
||||
|
||||
def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]:
|
||||
client = self._get_client()
|
||||
return client.get_track_details(track_id)
|
||||
|
||||
def get_album(self, album_id: str) -> Optional[Dict[str, Any]]:
|
||||
client = self._get_client()
|
||||
return client.get_album(album_id)
|
||||
|
||||
def get_album_tracks(self, album_id: str) -> Optional[Dict[str, Any]]:
|
||||
client = self._get_client()
|
||||
provider = self.get_active_provider()
|
||||
logger.debug("Fetching album tracks with %s: %s", provider, album_id)
|
||||
return client.get_album_tracks(album_id)
|
||||
|
||||
def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]:
|
||||
client = self._get_client()
|
||||
return client.get_artist(artist_id)
|
||||
|
||||
def get_artist_albums(self, artist_id: str, album_type: str = "album,single", limit: int = 50) -> List:
|
||||
client = self._get_client()
|
||||
provider = self.get_active_provider()
|
||||
logger.debug("Fetching artist albums with %s: %s", provider, artist_id)
|
||||
return client.get_artist_albums(artist_id, album_type, limit)
|
||||
|
||||
def get_track_features(self, track_id: str) -> Optional[Dict[str, Any]]:
|
||||
client = self._get_client()
|
||||
return client.get_track_features(track_id)
|
||||
|
||||
def get_user_playlists(self) -> List:
|
||||
if self.spotify and self.spotify.is_spotify_authenticated():
|
||||
return self.spotify.get_user_playlists()
|
||||
logger.warning("User playlists only available with Spotify authentication")
|
||||
return []
|
||||
|
||||
def get_saved_tracks(self) -> List:
|
||||
if self.spotify and self.spotify.is_spotify_authenticated():
|
||||
return self.spotify.get_saved_tracks()
|
||||
logger.warning("Saved tracks only available with Spotify authentication")
|
||||
return []
|
||||
|
||||
def get_saved_tracks_count(self) -> int:
|
||||
if self.spotify and self.spotify.is_spotify_authenticated():
|
||||
return self.spotify.get_saved_tracks_count()
|
||||
return 0
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
return bool(self.spotify and self.spotify.is_spotify_authenticated()) or bool(
|
||||
self.itunes and getattr(self.itunes, "is_authenticated", lambda: False)()
|
||||
)
|
||||
|
||||
def get_provider_info(self) -> Dict[str, Any]:
|
||||
spotify_authenticated = bool(self.spotify and self.spotify.is_spotify_authenticated())
|
||||
itunes_available = bool(self.itunes and getattr(self.itunes, "is_authenticated", lambda: False)())
|
||||
return {
|
||||
"active_provider": self.get_active_provider(),
|
||||
"spotify_authenticated": spotify_authenticated,
|
||||
"itunes_available": itunes_available,
|
||||
"fallback_source": self._fallback_source,
|
||||
"preferred_provider": self.preferred_provider,
|
||||
"can_access_user_data": spotify_authenticated,
|
||||
}
|
||||
|
||||
def reload_config(self):
|
||||
logger.info("Reloading metadata service configuration")
|
||||
if self.spotify and hasattr(self.spotify, "reload_config"):
|
||||
self.spotify.reload_config()
|
||||
new_source = get_primary_source()
|
||||
self._fallback_source = new_source
|
||||
try:
|
||||
self.itunes = get_client_for_source(new_source)
|
||||
except Exception:
|
||||
self.itunes = None
|
||||
self._log_initialization()
|
||||
|
||||
|
||||
_metadata_service_instance: Optional[MetadataService] = None
|
||||
|
||||
|
||||
def get_metadata_service() -> MetadataService:
|
||||
global _metadata_service_instance
|
||||
if _metadata_service_instance is None:
|
||||
_metadata_service_instance = MetadataService()
|
||||
return _metadata_service_instance
|
||||
342
core/metadata/similar_artists.py
Normal file
342
core/metadata/similar_artists.py
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
"""MusicMap similar-artist helpers for metadata API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from core.metadata import registry as metadata_registry
|
||||
from core.metadata.artist_image import _extract_artist_image_url
|
||||
from core.metadata.discography import (
|
||||
_extract_lookup_value,
|
||||
_normalize_artist_name,
|
||||
_pick_best_artist_match,
|
||||
_search_artists_for_source,
|
||||
)
|
||||
from core.metadata.lookup import MetadataLookupOptions
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("metadata.similar_artists")
|
||||
|
||||
__all__ = [
|
||||
"get_musicmap_similar_artists",
|
||||
"iter_musicmap_similar_artist_events",
|
||||
]
|
||||
|
||||
|
||||
def _get_source_chain_for_lookup(options: MetadataLookupOptions) -> List[str]:
|
||||
primary_source = metadata_registry.get_primary_source()
|
||||
source_chain = list(metadata_registry.get_source_priority(primary_source))
|
||||
override = (options.source_override or '').strip().lower()
|
||||
|
||||
if override:
|
||||
source_chain = [override] + [source for source in source_chain if source != override]
|
||||
|
||||
if not options.allow_fallback:
|
||||
source_chain = source_chain[:1]
|
||||
|
||||
return source_chain
|
||||
|
||||
|
||||
def _fetch_musicmap_similar_artist_names(artist_name: str) -> List[str]:
|
||||
"""Fetch similar artist names from MusicMap."""
|
||||
if not (artist_name or '').strip():
|
||||
raise ValueError('Artist name is required')
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
url_artist = quote_plus(artist_name.strip())
|
||||
musicmap_url = f'https://www.music-map.com/{url_artist}'
|
||||
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
}
|
||||
|
||||
logger.debug("Fetching MusicMap: %s", musicmap_url)
|
||||
response = requests.get(musicmap_url, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
gnod_map = soup.find(id='gnodMap')
|
||||
if not gnod_map:
|
||||
raise ValueError('Could not find artist map on MusicMap')
|
||||
|
||||
searched_artist_lower = _normalize_artist_name(artist_name)
|
||||
similar_artist_names: List[str] = []
|
||||
seen_names = set()
|
||||
|
||||
for anchor in gnod_map.find_all('a'):
|
||||
artist_text = anchor.get_text(strip=True)
|
||||
normalized_name = _normalize_artist_name(artist_text)
|
||||
if not normalized_name or normalized_name == searched_artist_lower or normalized_name in seen_names:
|
||||
continue
|
||||
seen_names.add(normalized_name)
|
||||
similar_artist_names.append(artist_text)
|
||||
|
||||
logger.debug("Found %s similar artists from MusicMap", len(similar_artist_names))
|
||||
return similar_artist_names
|
||||
|
||||
|
||||
def _build_similar_artist_payload(artist_data: Any, source: str) -> Optional[Dict[str, Any]]:
|
||||
artist_id = _extract_lookup_value(artist_data, 'id', 'artist_id', 'spotify_id', 'itunes_id', 'deezer_id')
|
||||
if not artist_id:
|
||||
return None
|
||||
|
||||
if isinstance(artist_data, dict):
|
||||
name = artist_data.get('name') or artist_data.get('artist_name') or artist_data.get('title')
|
||||
genres = artist_data.get('genres') or []
|
||||
popularity = artist_data.get('popularity') or artist_data.get('rank') or 0
|
||||
else:
|
||||
name = (
|
||||
getattr(artist_data, 'name', None)
|
||||
or getattr(artist_data, 'artist_name', None)
|
||||
or getattr(artist_data, 'title', None)
|
||||
)
|
||||
genres = getattr(artist_data, 'genres', None) or []
|
||||
popularity = getattr(artist_data, 'popularity', None) or getattr(artist_data, 'rank', None) or 0
|
||||
|
||||
if isinstance(genres, str):
|
||||
genres = [genres]
|
||||
elif not isinstance(genres, list):
|
||||
try:
|
||||
genres = list(genres)
|
||||
except TypeError:
|
||||
genres = []
|
||||
|
||||
try:
|
||||
popularity = int(popularity or 0)
|
||||
except Exception:
|
||||
popularity = 0
|
||||
|
||||
return {
|
||||
'id': str(artist_id),
|
||||
'name': str(name or artist_id),
|
||||
'image_url': _extract_artist_image_url(artist_data),
|
||||
'genres': genres,
|
||||
'popularity': popularity,
|
||||
'source': source,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_musicmap_artist_source_ids(artist_name: str, source_chain: List[str]) -> Dict[str, Optional[str]]:
|
||||
searched_source_ids: Dict[str, Optional[str]] = {}
|
||||
|
||||
for source in source_chain:
|
||||
client = metadata_registry.get_client_for_source(source)
|
||||
if not client:
|
||||
searched_source_ids[source] = None
|
||||
continue
|
||||
|
||||
search_results = _search_artists_for_source(source, client, artist_name, limit=1)
|
||||
searched_source_ids[source] = _extract_lookup_value(search_results[0], 'id', 'artist_id') if search_results else None
|
||||
|
||||
return searched_source_ids
|
||||
|
||||
|
||||
def _match_musicmap_similar_artist(
|
||||
candidate_name: str,
|
||||
source_chain: List[str],
|
||||
searched_artist_name: str,
|
||||
searched_source_ids: Dict[str, Optional[str]],
|
||||
) -> tuple[Optional[str], Optional[Dict[str, Any]]]:
|
||||
target_name = _normalize_artist_name(candidate_name)
|
||||
searched_name = _normalize_artist_name(searched_artist_name)
|
||||
|
||||
for source in source_chain:
|
||||
client = metadata_registry.get_client_for_source(source)
|
||||
if not client:
|
||||
continue
|
||||
|
||||
search_results = _search_artists_for_source(source, client, candidate_name, limit=1)
|
||||
if not search_results:
|
||||
continue
|
||||
|
||||
matched_artist = _pick_best_artist_match(search_results, candidate_name)
|
||||
if not matched_artist:
|
||||
continue
|
||||
|
||||
matched_name = _normalize_artist_name(
|
||||
_extract_lookup_value(matched_artist, 'name', 'artist_name', 'title')
|
||||
)
|
||||
if matched_name and matched_name == searched_name:
|
||||
continue
|
||||
|
||||
matched_id = _extract_lookup_value(matched_artist, 'id', 'artist_id')
|
||||
if not matched_id:
|
||||
continue
|
||||
|
||||
if str(matched_id) == str(searched_source_ids.get(source) or ''):
|
||||
continue
|
||||
|
||||
payload = _build_similar_artist_payload(matched_artist, source)
|
||||
if not payload:
|
||||
continue
|
||||
|
||||
if source == 'itunes' and not payload.get('image_url') and hasattr(client, 'get_artist'):
|
||||
try:
|
||||
full_artist = client.get_artist(str(matched_id))
|
||||
image_url = _extract_artist_image_url(full_artist)
|
||||
if image_url:
|
||||
payload['image_url'] = image_url
|
||||
elif hasattr(client, '_get_artist_image_from_albums'):
|
||||
album_image_url = client._get_artist_image_from_albums(str(matched_id))
|
||||
if album_image_url:
|
||||
payload['image_url'] = album_image_url
|
||||
except Exception as exc:
|
||||
logger.debug("Could not enrich iTunes image for %s: %s", matched_id, exc)
|
||||
|
||||
if target_name and _normalize_artist_name(payload['name']) == searched_name:
|
||||
continue
|
||||
|
||||
return source, payload
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def iter_musicmap_similar_artist_events(
|
||||
artist_name: str,
|
||||
limit: int = 20,
|
||||
source_override: Optional[str] = None,
|
||||
):
|
||||
"""Yield MusicMap similar-artist events using source priority."""
|
||||
try:
|
||||
source_chain = _get_source_chain_for_lookup(
|
||||
MetadataLookupOptions(source_override=source_override, allow_fallback=True)
|
||||
)
|
||||
available_sources = [source for source in source_chain if metadata_registry.get_client_for_source(source)]
|
||||
if not available_sources:
|
||||
yield {
|
||||
'type': 'error',
|
||||
'error': 'No metadata providers available for similar artist matching',
|
||||
'status_code': 503,
|
||||
}
|
||||
return
|
||||
|
||||
similar_artist_names = _fetch_musicmap_similar_artist_names(artist_name)
|
||||
searched_source_ids = _resolve_musicmap_artist_source_ids(artist_name, source_chain)
|
||||
|
||||
yield {
|
||||
'type': 'start',
|
||||
'artist_name': artist_name,
|
||||
'total_found': len(similar_artist_names),
|
||||
'source_priority': source_chain,
|
||||
}
|
||||
|
||||
matched_count = 0
|
||||
seen_names = set()
|
||||
seen_ids = set()
|
||||
|
||||
for candidate_name in similar_artist_names[:limit]:
|
||||
normalized_candidate = _normalize_artist_name(candidate_name)
|
||||
if not normalized_candidate or normalized_candidate in seen_names:
|
||||
continue
|
||||
|
||||
source, payload = _match_musicmap_similar_artist(
|
||||
candidate_name,
|
||||
source_chain,
|
||||
artist_name,
|
||||
searched_source_ids,
|
||||
)
|
||||
if not payload:
|
||||
continue
|
||||
|
||||
payload_id = str(payload.get('id') or '')
|
||||
if payload_id in seen_ids:
|
||||
continue
|
||||
|
||||
seen_names.add(normalized_candidate)
|
||||
seen_ids.add(payload_id)
|
||||
matched_count += 1
|
||||
|
||||
yield {
|
||||
'type': 'artist',
|
||||
'artist': payload,
|
||||
'source': source,
|
||||
}
|
||||
|
||||
yield {
|
||||
'type': 'complete',
|
||||
'complete': True,
|
||||
'total': matched_count,
|
||||
'total_found': len(similar_artist_names),
|
||||
'artist_name': artist_name,
|
||||
'source_priority': source_chain,
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as exc:
|
||||
logger.debug("Error fetching MusicMap for %s: %s", artist_name, exc)
|
||||
yield {
|
||||
'type': 'error',
|
||||
'error': f'Failed to fetch from MusicMap: {exc}',
|
||||
'status_code': 502,
|
||||
}
|
||||
except ValueError as exc:
|
||||
status_code = 404 if 'Could not find artist map on MusicMap' in str(exc) else 400
|
||||
yield {
|
||||
'type': 'error',
|
||||
'error': str(exc),
|
||||
'status_code': status_code,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.error("Error streaming similar artists for %s: %s", artist_name, exc)
|
||||
yield {
|
||||
'type': 'error',
|
||||
'error': str(exc),
|
||||
'status_code': 500,
|
||||
}
|
||||
|
||||
|
||||
def get_musicmap_similar_artists(
|
||||
artist_name: str,
|
||||
limit: int = 20,
|
||||
source_override: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return matched MusicMap similar artists as a single payload."""
|
||||
artists: List[Dict[str, Any]] = []
|
||||
total_found = 0
|
||||
error_message = None
|
||||
status_code = 500
|
||||
source_priority: List[str] = []
|
||||
|
||||
for event in iter_musicmap_similar_artist_events(
|
||||
artist_name,
|
||||
limit=limit,
|
||||
source_override=source_override,
|
||||
):
|
||||
if event.get('type') == 'start':
|
||||
total_found = event.get('total_found', 0)
|
||||
source_priority = event.get('source_priority', [])
|
||||
elif event.get('type') == 'artist' and event.get('artist'):
|
||||
artists.append(event['artist'])
|
||||
elif event.get('type') == 'complete':
|
||||
total_found = event.get('total_found', total_found)
|
||||
source_priority = event.get('source_priority', source_priority)
|
||||
elif event.get('type') == 'error':
|
||||
error_message = event.get('error', 'Unknown error')
|
||||
status_code = int(event.get('status_code') or status_code or 500)
|
||||
break
|
||||
|
||||
if error_message:
|
||||
return {
|
||||
'success': False,
|
||||
'error': error_message,
|
||||
'status_code': status_code,
|
||||
'artist': artist_name,
|
||||
'similar_artists': [],
|
||||
'total_found': total_found,
|
||||
'total_matched': 0,
|
||||
'source_priority': source_priority,
|
||||
}
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'artist': artist_name,
|
||||
'similar_artists': artists,
|
||||
'total_found': total_found,
|
||||
'total_matched': len(artists),
|
||||
'source_priority': source_priority,
|
||||
}
|
||||
999
core/metadata/source.py
Normal file
999
core/metadata/source.py
Normal file
|
|
@ -0,0 +1,999 @@
|
|||
"""Source metadata extraction and source-ID embedding helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Dict
|
||||
|
||||
import requests
|
||||
|
||||
from core.imports.context import (
|
||||
extract_artist_name,
|
||||
get_import_clean_artist,
|
||||
get_import_clean_title,
|
||||
get_import_context_album,
|
||||
get_import_original_search,
|
||||
get_import_source,
|
||||
get_import_source_ids,
|
||||
get_import_track_info,
|
||||
get_source_tag_names,
|
||||
normalize_import_context,
|
||||
)
|
||||
from core.metadata.registry import get_itunes_client
|
||||
from database.music_database import get_database
|
||||
from core.metadata.common import (
|
||||
get_config_manager,
|
||||
get_mutagen_symbols,
|
||||
is_vorbis_like,
|
||||
)
|
||||
from utils.logging_config import get_logger as _create_logger
|
||||
|
||||
__all__ = [
|
||||
"extract_source_metadata",
|
||||
"embed_source_ids",
|
||||
"normalize_album_cache_key",
|
||||
"mb_release_cache",
|
||||
"mb_release_cache_lock",
|
||||
"mb_release_detail_cache",
|
||||
"mb_release_detail_cache_lock",
|
||||
]
|
||||
|
||||
_MB_RELEASE_CACHE_MAX_ENTRIES = 4096
|
||||
_MB_RELEASE_DETAIL_CACHE_MAX_ENTRIES = 4096
|
||||
|
||||
mb_release_cache: "OrderedDict[tuple, str]" = OrderedDict()
|
||||
mb_release_cache_lock = threading.RLock()
|
||||
mb_release_detail_cache: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
|
||||
mb_release_detail_cache_lock = threading.RLock()
|
||||
logger = _create_logger("metadata.source")
|
||||
|
||||
_SOURCE_NETWORK_EXCEPTIONS = (requests.RequestException, socket.timeout, TimeoutError)
|
||||
|
||||
_EDITION_PAREN_RE = re.compile(
|
||||
r'\s*[\(\[]\s*(?:deluxe|expanded|remaster(?:ed)?|anniversary|special|collector|'
|
||||
r'limited|bonus|platinum|gold|super\s*deluxe|standard)'
|
||||
r'(?:\s+(?:edition|version))?[^)\]]*[\)\]]',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_EDITION_BARE_RE = re.compile(
|
||||
r'\s+(?:-\s+)?(?:deluxe|expanded|remaster(?:ed)?|anniversary|special|collector|'
|
||||
r'limited|bonus|platinum|gold|super\s*deluxe|standard)'
|
||||
r'(?:\s+(?:edition|version))?\s*$',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def normalize_album_cache_key(album_name: str) -> str:
|
||||
result = _EDITION_PAREN_RE.sub("", album_name or "")
|
||||
result = _EDITION_BARE_RE.sub("", result)
|
||||
return result.lower().strip()
|
||||
|
||||
|
||||
def _bounded_cache_get(cache, key):
|
||||
value = cache.get(key)
|
||||
if value is not None and hasattr(cache, "move_to_end"):
|
||||
cache.move_to_end(key)
|
||||
return value
|
||||
|
||||
|
||||
def _bounded_cache_set(cache, key, value, max_entries: int) -> None:
|
||||
cache[key] = value
|
||||
if hasattr(cache, "move_to_end"):
|
||||
cache.move_to_end(key)
|
||||
while len(cache) > max_entries:
|
||||
cache.popitem(last=False)
|
||||
|
||||
|
||||
def _call_source_lookup(label: str, func, *args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except _SOURCE_NETWORK_EXCEPTIONS as exc:
|
||||
logger.warning("%s lookup failed (network): %s", label, exc)
|
||||
return None
|
||||
|
||||
|
||||
SOURCE_TAG_CONFIG = {
|
||||
"SPOTIFY_TRACK_ID": "spotify.tags.track_id",
|
||||
"SPOTIFY_ARTIST_ID": "spotify.tags.artist_id",
|
||||
"SPOTIFY_ALBUM_ID": "spotify.tags.album_id",
|
||||
"ITUNES_TRACK_ID": "itunes.tags.track_id",
|
||||
"ITUNES_ARTIST_ID": "itunes.tags.artist_id",
|
||||
"ITUNES_ALBUM_ID": "itunes.tags.album_id",
|
||||
"MUSICBRAINZ_RECORDING_ID": "musicbrainz.tags.recording_id",
|
||||
"MUSICBRAINZ_ARTIST_ID": "musicbrainz.tags.artist_id",
|
||||
"MUSICBRAINZ_RELEASE_ID": "musicbrainz.tags.release_id",
|
||||
"MUSICBRAINZ_RELEASEGROUPID": "musicbrainz.tags.release_group_id",
|
||||
"MUSICBRAINZ_ALBUMARTISTID": "musicbrainz.tags.album_artist_id",
|
||||
"MUSICBRAINZ_RELEASETRACKID": "musicbrainz.tags.release_track_id",
|
||||
"RELEASETYPE": "musicbrainz.tags.release_type",
|
||||
"ORIGINALDATE": "musicbrainz.tags.original_date",
|
||||
"RELEASESTATUS": "musicbrainz.tags.release_status",
|
||||
"RELEASECOUNTRY": "musicbrainz.tags.release_country",
|
||||
"BARCODE": "musicbrainz.tags.barcode",
|
||||
"MEDIA": "musicbrainz.tags.media",
|
||||
"TOTALDISCS": "musicbrainz.tags.total_discs",
|
||||
"CATALOGNUMBER": "musicbrainz.tags.catalog_number",
|
||||
"SCRIPT": "musicbrainz.tags.script",
|
||||
"ASIN": "musicbrainz.tags.asin",
|
||||
"DEEZER_TRACK_ID": "deezer.tags.track_id",
|
||||
"DEEZER_ARTIST_ID": "deezer.tags.artist_id",
|
||||
"AUDIODB_TRACK_ID": "audiodb.tags.track_id",
|
||||
"TIDAL_TRACK_ID": "tidal.tags.track_id",
|
||||
"TIDAL_ARTIST_ID": "tidal.tags.artist_id",
|
||||
"QOBUZ_TRACK_ID": "qobuz.tags.track_id",
|
||||
"QOBUZ_ARTIST_ID": "qobuz.tags.artist_id",
|
||||
"GENIUS_TRACK_ID": "genius.tags.track_id",
|
||||
}
|
||||
|
||||
DEFAULT_SOURCE_ORDER = ["musicbrainz", "deezer", "audiodb", "tidal", "qobuz", "lastfm", "genius"]
|
||||
|
||||
ID3_TAG_MAP = {
|
||||
"MUSICBRAINZ_RECORDING_ID": ("UFID", "http://musicbrainz.org"),
|
||||
"MUSICBRAINZ_ARTIST_ID": ("TXXX", "MusicBrainz Artist Id"),
|
||||
"MUSICBRAINZ_RELEASE_ID": ("TXXX", "MusicBrainz Album Id"),
|
||||
"MUSICBRAINZ_RELEASEGROUPID": ("TXXX", "MusicBrainz Release Group Id"),
|
||||
"MUSICBRAINZ_ALBUMARTISTID": ("TXXX", "MusicBrainz Album Artist Id"),
|
||||
"MUSICBRAINZ_RELEASETRACKID": ("TXXX", "MusicBrainz Release Track Id"),
|
||||
"RELEASETYPE": ("TXXX", "MusicBrainz Album Type"),
|
||||
"RELEASESTATUS": ("TXXX", "MusicBrainz Album Status"),
|
||||
"RELEASECOUNTRY": ("TXXX", "MusicBrainz Album Release Country"),
|
||||
"ORIGINALDATE": ("TDOR", None),
|
||||
"MEDIA": ("TMED", None),
|
||||
}
|
||||
|
||||
VORBIS_TAG_MAP = {
|
||||
"MUSICBRAINZ_RECORDING_ID": "MUSICBRAINZ_TRACKID",
|
||||
"MUSICBRAINZ_ARTIST_ID": "MUSICBRAINZ_ARTISTID",
|
||||
"MUSICBRAINZ_RELEASE_ID": "MUSICBRAINZ_ALBUMID",
|
||||
"MUSICBRAINZ_RELEASEGROUPID": "MUSICBRAINZ_RELEASEGROUPID",
|
||||
"MUSICBRAINZ_ALBUMARTISTID": "MUSICBRAINZ_ALBUMARTISTID",
|
||||
"MUSICBRAINZ_RELEASETRACKID": "MUSICBRAINZ_RELEASETRACKID",
|
||||
}
|
||||
|
||||
MP4_TAG_MAP = {
|
||||
"MUSICBRAINZ_RECORDING_ID": "MusicBrainz Track Id",
|
||||
"MUSICBRAINZ_ARTIST_ID": "MusicBrainz Artist Id",
|
||||
"MUSICBRAINZ_RELEASE_ID": "MusicBrainz Album Id",
|
||||
"MUSICBRAINZ_RELEASEGROUPID": "MusicBrainz Release Group Id",
|
||||
"MUSICBRAINZ_ALBUMARTISTID": "MusicBrainz Album Artist Id",
|
||||
"MUSICBRAINZ_RELEASETRACKID": "MusicBrainz Release Track Id",
|
||||
"RELEASETYPE": "MusicBrainz Album Type",
|
||||
"RELEASESTATUS": "MusicBrainz Album Status",
|
||||
"RELEASECOUNTRY": "MusicBrainz Album Release Country",
|
||||
}
|
||||
|
||||
|
||||
def _tag_enabled(cfg, path: str) -> bool:
|
||||
return cfg.get(path, True) is not False
|
||||
|
||||
|
||||
def _names_match(a: str, b: str, threshold: float = 0.75) -> bool:
|
||||
if not a or not b:
|
||||
return False
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
norm = lambda s: re.sub(r"[^a-z0-9 ]", "", re.sub(r"\(.*?\)", "", s).lower()).strip()
|
||||
return SequenceMatcher(None, norm(a), norm(b)).ratio() >= threshold
|
||||
|
||||
|
||||
def _collect_source_ids(metadata: dict, cfg) -> dict:
|
||||
source_ids = {}
|
||||
source = (metadata.get("source") or "").strip().lower()
|
||||
if source:
|
||||
source_tag_names = get_source_tag_names(source)
|
||||
source_track_id = metadata.get("source_track_id")
|
||||
source_artist_id = metadata.get("source_artist_id")
|
||||
source_album_id = metadata.get("source_album_id")
|
||||
if cfg.get(f"{source}.embed_tags", True) is not False:
|
||||
if source_tag_names.get("track") and source_track_id:
|
||||
source_ids[source_tag_names["track"]] = source_track_id
|
||||
if source_tag_names.get("artist") and source_artist_id:
|
||||
source_ids[source_tag_names["artist"]] = source_artist_id
|
||||
if source_tag_names.get("album") and source_album_id:
|
||||
source_ids[source_tag_names["album"]] = source_album_id
|
||||
|
||||
if not source_ids:
|
||||
if cfg.get("spotify.embed_tags", True) is not False:
|
||||
if metadata.get("spotify_track_id"):
|
||||
source_ids["SPOTIFY_TRACK_ID"] = metadata["spotify_track_id"]
|
||||
if metadata.get("spotify_artist_id"):
|
||||
source_ids["SPOTIFY_ARTIST_ID"] = metadata["spotify_artist_id"]
|
||||
if metadata.get("spotify_album_id"):
|
||||
source_ids["SPOTIFY_ALBUM_ID"] = metadata["spotify_album_id"]
|
||||
if cfg.get("itunes.embed_tags", True) is not False:
|
||||
if metadata.get("itunes_track_id"):
|
||||
source_ids["ITUNES_TRACK_ID"] = metadata["itunes_track_id"]
|
||||
if metadata.get("itunes_artist_id"):
|
||||
source_ids["ITUNES_ARTIST_ID"] = metadata["itunes_artist_id"]
|
||||
if metadata.get("itunes_album_id"):
|
||||
source_ids["ITUNES_ALBUM_ID"] = metadata["itunes_album_id"]
|
||||
|
||||
return source_ids
|
||||
|
||||
|
||||
def _process_musicbrainz_source(pp: dict, metadata: dict, cfg, runtime, track_title: str, artist_name: str) -> None:
|
||||
if cfg.get("musicbrainz.embed_tags", True) is False:
|
||||
return
|
||||
if not track_title or not artist_name:
|
||||
return
|
||||
|
||||
mb_worker = getattr(runtime, "mb_worker", None)
|
||||
mb_service = mb_worker.mb_service if mb_worker else None
|
||||
if not mb_service:
|
||||
return
|
||||
|
||||
result = _call_source_lookup("MusicBrainz recording", mb_service.match_recording, track_title, artist_name)
|
||||
if result and result.get("mbid"):
|
||||
pp["recording_mbid"] = result["mbid"]
|
||||
pp["id_tags"]["MUSICBRAINZ_RECORDING_ID"] = pp["recording_mbid"]
|
||||
details = _call_source_lookup(
|
||||
"MusicBrainz recording details",
|
||||
mb_service.mb_client.get_recording,
|
||||
pp["recording_mbid"],
|
||||
includes=["isrcs", "genres"],
|
||||
)
|
||||
if details:
|
||||
isrcs = details.get("isrcs", [])
|
||||
if isrcs:
|
||||
pp["isrc"] = isrcs[0]
|
||||
pp["mb_genres"] = [g["name"] for g in sorted(details.get("genres", []), key=lambda x: x.get("count", 0), reverse=True)]
|
||||
|
||||
track_artist_name = metadata.get("artist", "") or artist_name
|
||||
if ", " in track_artist_name:
|
||||
track_artist_name = track_artist_name.split(", ")[0]
|
||||
artist_result = _call_source_lookup("MusicBrainz artist", mb_service.match_artist, track_artist_name)
|
||||
if artist_result and artist_result.get("mbid"):
|
||||
pp["artist_mbid"] = artist_result["mbid"]
|
||||
pp["id_tags"]["MUSICBRAINZ_ARTIST_ID"] = pp["artist_mbid"]
|
||||
|
||||
album_name_for_mb = metadata.get("album", "")
|
||||
if album_name_for_mb:
|
||||
artist_key = (pp.get("batch_artist_name") or artist_name).lower().strip()
|
||||
rc_key_norm = (normalize_album_cache_key(album_name_for_mb), artist_key)
|
||||
rc_key_exact = (album_name_for_mb.lower().strip(), artist_key)
|
||||
release_mbid = None
|
||||
with mb_release_cache_lock:
|
||||
cached = _bounded_cache_get(mb_release_cache, rc_key_norm)
|
||||
if cached is None:
|
||||
cached = _bounded_cache_get(mb_release_cache, rc_key_exact)
|
||||
if cached:
|
||||
release_mbid = cached
|
||||
else:
|
||||
rc_result = _call_source_lookup("MusicBrainz release", mb_service.match_release, album_name_for_mb, artist_name)
|
||||
if rc_result and rc_result.get("mbid"):
|
||||
release_mbid = rc_result["mbid"]
|
||||
if release_mbid:
|
||||
_bounded_cache_set(mb_release_cache, rc_key_norm, release_mbid, _MB_RELEASE_CACHE_MAX_ENTRIES)
|
||||
_bounded_cache_set(mb_release_cache, rc_key_exact, release_mbid, _MB_RELEASE_CACHE_MAX_ENTRIES)
|
||||
pp["release_mbid"] = release_mbid or ""
|
||||
if pp["release_mbid"]:
|
||||
pp["id_tags"]["MUSICBRAINZ_RELEASE_ID"] = pp["release_mbid"]
|
||||
|
||||
if pp["release_mbid"]:
|
||||
with mb_release_detail_cache_lock:
|
||||
release_detail = _bounded_cache_get(mb_release_detail_cache, pp["release_mbid"])
|
||||
if release_detail is None:
|
||||
release_detail = _call_source_lookup(
|
||||
"MusicBrainz release details",
|
||||
mb_service.mb_client.get_release,
|
||||
pp["release_mbid"],
|
||||
includes=["release-groups", "labels", "media", "artist-credits", "recordings", "genres"],
|
||||
) or {}
|
||||
with mb_release_detail_cache_lock:
|
||||
_bounded_cache_set(mb_release_detail_cache, pp["release_mbid"], release_detail, _MB_RELEASE_DETAIL_CACHE_MAX_ENTRIES)
|
||||
if release_detail:
|
||||
rg = release_detail.get("release-group", {})
|
||||
if rg.get("id"):
|
||||
pp["id_tags"]["MUSICBRAINZ_RELEASEGROUPID"] = rg["id"]
|
||||
ac = release_detail.get("artist-credit", [])
|
||||
if ac and isinstance(ac[0], dict):
|
||||
aa = ac[0].get("artist", {})
|
||||
if aa.get("id"):
|
||||
pp["id_tags"]["MUSICBRAINZ_ALBUMARTISTID"] = aa["id"]
|
||||
if rg.get("primary-type"):
|
||||
pp["id_tags"]["RELEASETYPE"] = rg["primary-type"]
|
||||
if rg.get("first-release-date"):
|
||||
pp["id_tags"]["ORIGINALDATE"] = rg["first-release-date"]
|
||||
if not pp["release_year"] and len(rg["first-release-date"]) >= 4:
|
||||
year = rg["first-release-date"][:4]
|
||||
if year.isdigit():
|
||||
pp["release_year"] = year
|
||||
if release_detail.get("status"):
|
||||
pp["id_tags"]["RELEASESTATUS"] = release_detail["status"]
|
||||
if release_detail.get("country"):
|
||||
pp["id_tags"]["RELEASECOUNTRY"] = release_detail["country"]
|
||||
if release_detail.get("barcode"):
|
||||
pp["id_tags"]["BARCODE"] = release_detail["barcode"]
|
||||
media_list = release_detail.get("media", [])
|
||||
if media_list:
|
||||
fmt = media_list[0].get("format", "")
|
||||
if fmt:
|
||||
pp["id_tags"]["MEDIA"] = fmt
|
||||
pp["id_tags"]["TOTALDISCS"] = str(len(media_list))
|
||||
label_info = release_detail.get("label-info", [])
|
||||
if label_info and isinstance(label_info[0], dict):
|
||||
cat = label_info[0].get("catalog-number", "")
|
||||
if cat:
|
||||
pp["id_tags"]["CATALOGNUMBER"] = cat
|
||||
text_rep = release_detail.get("text-representation", {})
|
||||
if isinstance(text_rep, dict) and text_rep.get("script"):
|
||||
pp["id_tags"]["SCRIPT"] = text_rep["script"]
|
||||
if release_detail.get("asin"):
|
||||
pp["id_tags"]["ASIN"] = release_detail["asin"]
|
||||
track_num = metadata.get("track_number")
|
||||
disc_num = metadata.get("disc_number") or 1
|
||||
if track_num and media_list:
|
||||
try:
|
||||
track_num_int = int(track_num)
|
||||
disc_num_int = int(disc_num)
|
||||
for medium in media_list:
|
||||
if medium.get("position", 1) == disc_num_int:
|
||||
for mtrack in (medium.get("tracks") or medium.get("track-list", [])):
|
||||
if mtrack.get("position") == track_num_int:
|
||||
if mtrack.get("id"):
|
||||
pp["id_tags"]["MUSICBRAINZ_RELEASETRACKID"] = mtrack["id"]
|
||||
release_recording = mtrack.get("recording", {})
|
||||
if release_recording.get("id"):
|
||||
pp["recording_mbid"] = release_recording["id"]
|
||||
pp["id_tags"]["MUSICBRAINZ_RECORDING_ID"] = release_recording["id"]
|
||||
break
|
||||
break
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Genre fallback chain: most MusicBrainz recordings don't carry genres at
|
||||
# the track level, but releases and artists usually do. If the recording
|
||||
# came back empty, try the release; if that's empty too, fetch the artist
|
||||
# with `includes=['genres']` and use that.
|
||||
_release_detail_for_genres = locals().get("release_detail")
|
||||
if not pp["mb_genres"] and _release_detail_for_genres:
|
||||
pp["mb_genres"] = [
|
||||
g["name"] for g in sorted(
|
||||
_release_detail_for_genres.get("genres", []), key=lambda x: x.get("count", 0), reverse=True,
|
||||
)
|
||||
]
|
||||
if not pp["mb_genres"] and pp.get("artist_mbid"):
|
||||
artist_detail = _call_source_lookup(
|
||||
"MusicBrainz artist details",
|
||||
mb_service.mb_client.get_artist,
|
||||
pp["artist_mbid"],
|
||||
includes=["genres"],
|
||||
)
|
||||
if artist_detail:
|
||||
pp["mb_genres"] = [
|
||||
g["name"] for g in sorted(
|
||||
artist_detail.get("genres", []), key=lambda x: x.get("count", 0), reverse=True,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _process_deezer_source(pp: dict, metadata: dict, cfg, runtime, track_title: str, artist_name: str) -> None:
|
||||
if cfg.get("deezer.embed_tags", True) is False:
|
||||
return
|
||||
if not track_title or not artist_name:
|
||||
return
|
||||
|
||||
deezer_worker = getattr(runtime, "deezer_worker", None)
|
||||
dz_client = deezer_worker.client if deezer_worker else None
|
||||
if not dz_client:
|
||||
return
|
||||
dz_result = _call_source_lookup("Deezer track", dz_client.search_track, artist_name, track_title)
|
||||
if dz_result and _names_match(dz_result.get("title", ""), track_title) and _names_match(dz_result.get("artist", {}).get("name", ""), artist_name):
|
||||
dz_track_id = dz_result["id"]
|
||||
pp["id_tags"]["DEEZER_TRACK_ID"] = str(dz_track_id)
|
||||
dz_artist_id = dz_result.get("artist", {}).get("id")
|
||||
if dz_artist_id:
|
||||
pp["id_tags"]["DEEZER_ARTIST_ID"] = str(dz_artist_id)
|
||||
dz_details = _call_source_lookup("Deezer track details", dz_client.get_track_details, dz_track_id)
|
||||
if dz_details:
|
||||
bpm_val = dz_details.get("bpm")
|
||||
if bpm_val and bpm_val > 0:
|
||||
pp["deezer_bpm"] = bpm_val
|
||||
dz_isrc = dz_details.get("isrc")
|
||||
if dz_isrc:
|
||||
pp["deezer_isrc"] = dz_isrc
|
||||
if not pp["release_year"]:
|
||||
dz_album = dz_result.get("album", {})
|
||||
dz_release = (dz_album.get("release_date", "") if isinstance(dz_album, dict) else "") or ""
|
||||
if len(dz_release) >= 4 and dz_release[:4].isdigit():
|
||||
pp["release_year"] = dz_release[:4]
|
||||
|
||||
|
||||
def _process_audiodb_source(pp: dict, metadata: dict, cfg, runtime, track_title: str, artist_name: str) -> None:
|
||||
if cfg.get("audiodb.embed_tags", True) is False:
|
||||
return
|
||||
if not track_title or not artist_name:
|
||||
return
|
||||
|
||||
audiodb_worker = getattr(runtime, "audiodb_worker", None)
|
||||
adb_client = audiodb_worker.client if audiodb_worker else None
|
||||
if not adb_client:
|
||||
return
|
||||
adb_result = _call_source_lookup("AudioDB track", adb_client.search_track, artist_name, track_title)
|
||||
if adb_result and _names_match(adb_result.get("strTrack", ""), track_title) and _names_match(adb_result.get("strArtist", ""), artist_name):
|
||||
adb_track_id = adb_result.get("idTrack")
|
||||
if adb_track_id:
|
||||
pp["id_tags"]["AUDIODB_TRACK_ID"] = str(adb_track_id)
|
||||
adb_mb_track = adb_result.get("strMusicBrainzID")
|
||||
if adb_mb_track and "MUSICBRAINZ_RECORDING_ID" not in pp["id_tags"]:
|
||||
pp["id_tags"]["MUSICBRAINZ_RECORDING_ID"] = adb_mb_track
|
||||
pp["recording_mbid"] = adb_mb_track
|
||||
adb_mb_artist = adb_result.get("strMusicBrainzArtistID")
|
||||
if adb_mb_artist and "MUSICBRAINZ_ARTIST_ID" not in pp["id_tags"]:
|
||||
pp["id_tags"]["MUSICBRAINZ_ARTIST_ID"] = adb_mb_artist
|
||||
pp["artist_mbid"] = adb_mb_artist
|
||||
pp["audiodb_mood"] = adb_result.get("strMood") or None
|
||||
pp["audiodb_style"] = adb_result.get("strStyle") or None
|
||||
pp["audiodb_genre"] = adb_result.get("strGenre") or None
|
||||
|
||||
|
||||
def _process_tidal_source(pp: dict, metadata: dict, cfg, runtime, track_title: str, artist_name: str) -> None:
|
||||
if cfg.get("tidal.embed_tags", True) is False:
|
||||
return
|
||||
if not track_title or not artist_name:
|
||||
return
|
||||
|
||||
tidal_client = getattr(runtime, "tidal_client", None)
|
||||
if not (tidal_client and tidal_client.is_authenticated()):
|
||||
return
|
||||
td_result = _call_source_lookup("Tidal track", tidal_client.search_track, artist_name, track_title)
|
||||
if td_result and _names_match(td_result.get("title", ""), track_title):
|
||||
td_track_id = td_result.get("id")
|
||||
if td_track_id:
|
||||
pp["id_tags"]["TIDAL_TRACK_ID"] = str(td_track_id)
|
||||
td_artist = td_result.get("artist", {})
|
||||
if isinstance(td_artist, dict) and td_artist.get("id"):
|
||||
pp["id_tags"]["TIDAL_ARTIST_ID"] = str(td_artist["id"])
|
||||
if td_track_id:
|
||||
td_details = _call_source_lookup("Tidal track details", tidal_client.get_track, str(td_track_id))
|
||||
if td_details:
|
||||
pp["tidal_isrc"] = td_details.get("isrc")
|
||||
td_copyright = td_details.get("copyright")
|
||||
if isinstance(td_copyright, dict):
|
||||
td_copyright = td_copyright.get("text", td_copyright.get("name", ""))
|
||||
pp["tidal_copyright"] = td_copyright or None
|
||||
if not pp["release_year"]:
|
||||
td_album = td_result.get("album", {})
|
||||
td_release = ""
|
||||
if isinstance(td_album, dict):
|
||||
td_release = str(td_album.get("release_date", "") or td_album.get("releaseDate", "") or "")
|
||||
if len(td_release) >= 4 and td_release[:4].isdigit():
|
||||
pp["release_year"] = td_release[:4]
|
||||
|
||||
|
||||
def _process_qobuz_source(pp: dict, metadata: dict, cfg, runtime, track_title: str, artist_name: str) -> None:
|
||||
if cfg.get("qobuz.embed_tags", True) is False:
|
||||
return
|
||||
if not track_title or not artist_name:
|
||||
return
|
||||
|
||||
qobuz_worker = getattr(runtime, "qobuz_enrichment_worker", None)
|
||||
qz_client = qobuz_worker.client if qobuz_worker else None
|
||||
if not (qz_client and qz_client.is_authenticated()):
|
||||
return
|
||||
qz_result = _call_source_lookup("Qobuz track", qz_client.search_track, artist_name, track_title)
|
||||
if qz_result:
|
||||
qz_performer = qz_result.get("performer") or {}
|
||||
if not isinstance(qz_performer, dict):
|
||||
qz_performer = {}
|
||||
qz_artist_name = qz_performer.get("name", "")
|
||||
if _names_match(qz_result.get("title", ""), track_title) and _names_match(qz_artist_name, artist_name):
|
||||
qz_track_id = qz_result.get("id")
|
||||
if qz_track_id:
|
||||
pp["id_tags"]["QOBUZ_TRACK_ID"] = str(qz_track_id)
|
||||
if qz_performer.get("id"):
|
||||
pp["id_tags"]["QOBUZ_ARTIST_ID"] = str(qz_performer["id"])
|
||||
qz_isrc = qz_result.get("isrc")
|
||||
if isinstance(qz_isrc, dict):
|
||||
qz_isrc = qz_isrc.get("value", qz_isrc.get("id", ""))
|
||||
if qz_isrc:
|
||||
pp["qobuz_isrc"] = qz_isrc
|
||||
qz_copyright = qz_result.get("copyright")
|
||||
if isinstance(qz_copyright, dict):
|
||||
qz_copyright = qz_copyright.get("text", qz_copyright.get("name", ""))
|
||||
if isinstance(qz_copyright, str):
|
||||
pp["qobuz_copyright"] = qz_copyright
|
||||
qz_album = qz_result.get("album", {})
|
||||
if isinstance(qz_album, dict):
|
||||
qz_label_info = qz_album.get("label", {})
|
||||
if isinstance(qz_label_info, dict) and qz_label_info.get("name"):
|
||||
pp["qobuz_label"] = qz_label_info["name"]
|
||||
if not pp["release_year"]:
|
||||
qz_release = str(qz_album.get("release_date_original", "") or "")
|
||||
if not qz_release:
|
||||
qz_ts = qz_album.get("released_at")
|
||||
if qz_ts and isinstance(qz_ts, (int, float)) and qz_ts > 0:
|
||||
import datetime as _dt
|
||||
|
||||
qz_release = str(_dt.datetime.utcfromtimestamp(qz_ts).year)
|
||||
if len(qz_release) >= 4 and qz_release[:4].isdigit():
|
||||
pp["release_year"] = qz_release[:4]
|
||||
|
||||
|
||||
def _process_lastfm_source(pp: dict, metadata: dict, cfg, runtime, track_title: str, artist_name: str) -> None:
|
||||
if cfg.get("lastfm.embed_tags", True) is False:
|
||||
return
|
||||
if not track_title or not artist_name:
|
||||
return
|
||||
|
||||
lastfm_worker = getattr(runtime, "lastfm_worker", None)
|
||||
lf_client = lastfm_worker.client if lastfm_worker else None
|
||||
if not lf_client:
|
||||
return
|
||||
lf_result = _call_source_lookup("Last.fm track", lf_client.get_track_info, artist_name, track_title)
|
||||
if lf_result:
|
||||
lf_url = lf_result.get("url")
|
||||
if lf_url:
|
||||
pp["lastfm_url"] = lf_url
|
||||
lf_toptags = lf_result.get("toptags", {})
|
||||
if isinstance(lf_toptags, dict):
|
||||
tag_list = lf_toptags.get("tag", [])
|
||||
if isinstance(tag_list, list):
|
||||
pp["lastfm_tags"] = [tag.get("name", "") for tag in tag_list if isinstance(tag, dict) and tag.get("name")]
|
||||
elif isinstance(tag_list, dict) and tag_list.get("name"):
|
||||
pp["lastfm_tags"] = [tag_list["name"]]
|
||||
|
||||
|
||||
def _process_genius_source(pp: dict, metadata: dict, cfg, runtime, track_title: str, artist_name: str) -> None:
|
||||
if cfg.get("genius.embed_tags", True) is False:
|
||||
return
|
||||
if not track_title or not artist_name:
|
||||
return
|
||||
|
||||
import core.genius_client as _genius_module
|
||||
|
||||
if time.time() < _genius_module._rate_limit_until:
|
||||
logger.info("Genius rate-limited, skipping (non-blocking)")
|
||||
return
|
||||
genius_worker = getattr(runtime, "genius_worker", None)
|
||||
g_client = genius_worker.client if genius_worker else None
|
||||
if not g_client:
|
||||
return
|
||||
g_result = _call_source_lookup("Genius track", g_client.search_song, artist_name, track_title)
|
||||
if g_result:
|
||||
g_id = g_result.get("id")
|
||||
if g_id:
|
||||
pp["id_tags"]["GENIUS_TRACK_ID"] = str(g_id)
|
||||
g_url = g_result.get("url")
|
||||
if g_url:
|
||||
pp["genius_url"] = g_url
|
||||
|
||||
|
||||
def _process_source_enrichment(source_name: str, pp: dict, metadata: dict, cfg, runtime, track_title: str, artist_name: str) -> None:
|
||||
if source_name == "musicbrainz":
|
||||
_process_musicbrainz_source(pp, metadata, cfg, runtime, track_title, artist_name)
|
||||
elif source_name == "deezer":
|
||||
_process_deezer_source(pp, metadata, cfg, runtime, track_title, artist_name)
|
||||
elif source_name == "audiodb":
|
||||
_process_audiodb_source(pp, metadata, cfg, runtime, track_title, artist_name)
|
||||
elif source_name == "tidal":
|
||||
_process_tidal_source(pp, metadata, cfg, runtime, track_title, artist_name)
|
||||
elif source_name == "qobuz":
|
||||
_process_qobuz_source(pp, metadata, cfg, runtime, track_title, artist_name)
|
||||
elif source_name == "lastfm":
|
||||
_process_lastfm_source(pp, metadata, cfg, runtime, track_title, artist_name)
|
||||
elif source_name == "genius":
|
||||
_process_genius_source(pp, metadata, cfg, runtime, track_title, artist_name)
|
||||
|
||||
|
||||
def _write_embedded_metadata(audio_file, metadata: dict, pp: dict, cfg, symbols):
|
||||
filtered_tags: Dict[str, str] = {}
|
||||
for tag_name, value in pp["id_tags"].items():
|
||||
config_path = SOURCE_TAG_CONFIG.get(tag_name)
|
||||
if config_path and not _tag_enabled(cfg, config_path):
|
||||
continue
|
||||
filtered_tags[tag_name] = value
|
||||
|
||||
written = []
|
||||
release_year = pp["release_year"]
|
||||
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
for tag_name, value in filtered_tags.items():
|
||||
spec = ID3_TAG_MAP.get(tag_name)
|
||||
if spec:
|
||||
frame_type, desc = spec
|
||||
if frame_type == "UFID":
|
||||
audio_file.tags.add(symbols.UFID(owner=desc, data=str(value).encode("ascii")))
|
||||
written.append(f"UFID:{desc}")
|
||||
elif frame_type == "TDOR":
|
||||
audio_file.tags.add(symbols.TDOR(encoding=3, text=[value]))
|
||||
written.append("TDOR")
|
||||
elif frame_type == "TMED":
|
||||
audio_file.tags.add(symbols.TMED(encoding=3, text=[value]))
|
||||
written.append("TMED")
|
||||
else:
|
||||
audio_file.tags.add(symbols.TXXX(encoding=3, desc=desc, text=[value]))
|
||||
written.append(f"TXXX:{desc}")
|
||||
else:
|
||||
audio_file.tags.add(symbols.TXXX(encoding=3, desc=tag_name, text=[str(value)]))
|
||||
written.append(f"TXXX:{tag_name}")
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
for tag_name, value in filtered_tags.items():
|
||||
key = f"----:com.apple.iTunes:{MP4_TAG_MAP.get(tag_name, tag_name)}"
|
||||
audio_file[key] = [symbols.MP4FreeForm(str(value).encode("utf-8"))]
|
||||
written.append(key)
|
||||
elif is_vorbis_like(audio_file, symbols):
|
||||
for tag_name, value in filtered_tags.items():
|
||||
audio_file[VORBIS_TAG_MAP.get(tag_name, tag_name)] = [str(value)]
|
||||
written.append(VORBIS_TAG_MAP.get(tag_name, tag_name))
|
||||
|
||||
if written:
|
||||
logger.info("Embedded IDs: %s", ", ".join(written))
|
||||
|
||||
if release_year and not metadata.get("date"):
|
||||
metadata["date"] = release_year
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.tags.add(symbols.TDRC(encoding=3, text=[release_year]))
|
||||
elif is_vorbis_like(audio_file, symbols):
|
||||
audio_file["date"] = [release_year]
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
audio_file["\xa9day"] = [release_year]
|
||||
logger.info("Date tag: %s", release_year)
|
||||
|
||||
if _tag_enabled(cfg, "deezer.tags.bpm") and pp["deezer_bpm"] and pp["deezer_bpm"] > 0:
|
||||
bpm_int = int(pp["deezer_bpm"])
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.tags.add(symbols.TBPM(encoding=3, text=[str(bpm_int)]))
|
||||
elif is_vorbis_like(audio_file, symbols):
|
||||
audio_file["BPM"] = [str(bpm_int)]
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
audio_file["tmpo"] = [bpm_int]
|
||||
logger.info("BPM: %s", bpm_int)
|
||||
|
||||
if _tag_enabled(cfg, "audiodb.tags.mood") and pp["audiodb_mood"]:
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.tags.add(symbols.TXXX(encoding=3, desc="MOOD", text=[pp["audiodb_mood"]]))
|
||||
elif is_vorbis_like(audio_file, symbols):
|
||||
audio_file["MOOD"] = [pp["audiodb_mood"]]
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
audio_file["----:com.apple.iTunes:MOOD"] = [symbols.MP4FreeForm(pp["audiodb_mood"].encode("utf-8"))]
|
||||
|
||||
if _tag_enabled(cfg, "audiodb.tags.style") and pp["audiodb_style"]:
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.tags.add(symbols.TXXX(encoding=3, desc="STYLE", text=[pp["audiodb_style"]]))
|
||||
elif is_vorbis_like(audio_file, symbols):
|
||||
audio_file["STYLE"] = [pp["audiodb_style"]]
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
audio_file["----:com.apple.iTunes:STYLE"] = [symbols.MP4FreeForm(pp["audiodb_style"].encode("utf-8"))]
|
||||
|
||||
if _tag_enabled(cfg, "metadata_enhancement.tags.genre_merge"):
|
||||
enrichment_genres = []
|
||||
if _tag_enabled(cfg, "musicbrainz.tags.genres"):
|
||||
enrichment_genres += pp["mb_genres"]
|
||||
if pp["audiodb_genre"] and _tag_enabled(cfg, "audiodb.tags.genre"):
|
||||
enrichment_genres.append(pp["audiodb_genre"])
|
||||
if _tag_enabled(cfg, "lastfm.tags.genres"):
|
||||
enrichment_genres += pp["lastfm_tags"]
|
||||
if enrichment_genres:
|
||||
from core.genre_filter import filter_genres as _filter_genres
|
||||
|
||||
enrichment_genres = _filter_genres(enrichment_genres, cfg)
|
||||
source_genres = [g.strip() for g in str(metadata.get("genre", "")).split(",") if g.strip()]
|
||||
seen = set()
|
||||
merged = []
|
||||
for genre in source_genres + enrichment_genres:
|
||||
key = genre.strip().lower()
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
merged.append(genre.strip().title())
|
||||
if len(merged) >= 5:
|
||||
break
|
||||
if merged:
|
||||
genre_string = ", ".join(merged)
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.tags.add(symbols.TCON(encoding=3, text=[genre_string]))
|
||||
elif is_vorbis_like(audio_file, symbols):
|
||||
audio_file["GENRE"] = [genre_string]
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
audio_file["\xa9gen"] = [genre_string]
|
||||
logger.info("Genres merged: %s", genre_string)
|
||||
|
||||
isrc_candidates = []
|
||||
if pp["isrc"] and _tag_enabled(cfg, "musicbrainz.tags.isrc"):
|
||||
isrc_candidates.append(("MusicBrainz", pp["isrc"]))
|
||||
if pp["deezer_isrc"] and _tag_enabled(cfg, "deezer.tags.isrc"):
|
||||
isrc_candidates.append(("Deezer", pp["deezer_isrc"]))
|
||||
if pp["tidal_isrc"] and _tag_enabled(cfg, "tidal.tags.isrc"):
|
||||
isrc_candidates.append(("Tidal", pp["tidal_isrc"]))
|
||||
if pp["qobuz_isrc"] and _tag_enabled(cfg, "qobuz.tags.isrc"):
|
||||
isrc_candidates.append(("Qobuz", pp["qobuz_isrc"]))
|
||||
if isrc_candidates:
|
||||
isrc_source, final_isrc = isrc_candidates[0]
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.tags.add(symbols.TSRC(encoding=3, text=[final_isrc]))
|
||||
elif is_vorbis_like(audio_file, symbols):
|
||||
audio_file["ISRC"] = [final_isrc]
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
audio_file["----:com.apple.iTunes:ISRC"] = [symbols.MP4FreeForm(final_isrc.encode("utf-8"))]
|
||||
logger.info("ISRC (%s): %s", isrc_source, final_isrc)
|
||||
|
||||
copyright_candidates = []
|
||||
if pp["tidal_copyright"] and _tag_enabled(cfg, "tidal.tags.copyright"):
|
||||
copyright_candidates.append(("Tidal", pp["tidal_copyright"]))
|
||||
if pp["qobuz_copyright"] and _tag_enabled(cfg, "qobuz.tags.copyright"):
|
||||
copyright_candidates.append(("Qobuz", pp["qobuz_copyright"]))
|
||||
if copyright_candidates:
|
||||
copyright_source, final_copyright = copyright_candidates[0]
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.tags.add(symbols.TCOP(encoding=3, text=[final_copyright]))
|
||||
elif is_vorbis_like(audio_file, symbols):
|
||||
audio_file["COPYRIGHT"] = [final_copyright]
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
audio_file["cprt"] = [final_copyright]
|
||||
logger.info("Copyright (%s): %s", copyright_source, final_copyright[:60])
|
||||
|
||||
if _tag_enabled(cfg, "qobuz.tags.label") and pp["qobuz_label"]:
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.tags.add(symbols.TPUB(encoding=3, text=[pp["qobuz_label"]]))
|
||||
elif is_vorbis_like(audio_file, symbols):
|
||||
audio_file["LABEL"] = [pp["qobuz_label"]]
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
audio_file["----:com.apple.iTunes:LABEL"] = [symbols.MP4FreeForm(pp["qobuz_label"].encode("utf-8"))]
|
||||
|
||||
if _tag_enabled(cfg, "lastfm.tags.url") and pp["lastfm_url"]:
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.tags.add(symbols.TXXX(encoding=3, desc="LASTFM_URL", text=[pp["lastfm_url"]]))
|
||||
elif is_vorbis_like(audio_file, symbols):
|
||||
audio_file["LASTFM_URL"] = [pp["lastfm_url"]]
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
audio_file["----:com.apple.iTunes:LASTFM_URL"] = [symbols.MP4FreeForm(pp["lastfm_url"].encode("utf-8"))]
|
||||
|
||||
if _tag_enabled(cfg, "genius.tags.url") and pp["genius_url"]:
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.tags.add(symbols.TXXX(encoding=3, desc="GENIUS_URL", text=[pp["genius_url"]]))
|
||||
elif is_vorbis_like(audio_file, symbols):
|
||||
audio_file["GENIUS_URL"] = [pp["genius_url"]]
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
audio_file["----:com.apple.iTunes:GENIUS_URL"] = [symbols.MP4FreeForm(pp["genius_url"].encode("utf-8"))]
|
||||
|
||||
return release_year
|
||||
|
||||
|
||||
def _update_album_year_in_database(db, metadata: dict, release_year) -> None:
|
||||
if db is None:
|
||||
return
|
||||
|
||||
try:
|
||||
album_name_for_db = metadata.get("album", "")
|
||||
album_artist_for_db = metadata.get("album_artist", "") or metadata.get("artist", "")
|
||||
if album_name_for_db and album_artist_for_db:
|
||||
conn = db._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE albums SET year = ?
|
||||
WHERE (year IS NULL OR year = 0)
|
||||
AND id IN (
|
||||
SELECT al.id FROM albums al
|
||||
JOIN artists ar ON ar.id = al.artist_id
|
||||
WHERE LOWER(al.title) = LOWER(?) AND LOWER(ar.name) = LOWER(?)
|
||||
)
|
||||
""",
|
||||
(int(release_year), album_name_for_db, album_artist_for_db),
|
||||
)
|
||||
if cursor.rowcount > 0:
|
||||
conn.commit()
|
||||
logger.info("Updated album year to %s in database", release_year)
|
||||
else:
|
||||
conn.rollback()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
logger.error("Could not update album year in DB: %s", exc)
|
||||
|
||||
|
||||
def extract_source_metadata(context: dict, artist: dict, album_info: dict) -> dict:
|
||||
if album_info is None:
|
||||
album_info = {}
|
||||
|
||||
cfg = get_config_manager()
|
||||
context = normalize_import_context(context)
|
||||
original_search = get_import_original_search(context)
|
||||
album_ctx = get_import_context_album(context)
|
||||
track_info = get_import_track_info(context)
|
||||
source = get_import_source(context)
|
||||
source_ids = get_import_source_ids(context)
|
||||
|
||||
artist_dict = artist if isinstance(artist, dict) else {
|
||||
"name": extract_artist_name(artist),
|
||||
"id": getattr(artist, "id", ""),
|
||||
"genres": list(getattr(artist, "genres", []) or []),
|
||||
}
|
||||
|
||||
metadata: Dict[str, Any] = {
|
||||
"source": source,
|
||||
"source_track_id": source_ids["track_id"],
|
||||
"source_artist_id": source_ids["artist_id"],
|
||||
"source_album_id": source_ids["album_id"],
|
||||
}
|
||||
|
||||
metadata["title"] = get_import_clean_title(context, album_info=album_info, default=original_search.get("title", ""))
|
||||
if original_search.get("clean_title"):
|
||||
logger.info("Metadata: Using clean title: '%s'", metadata["title"])
|
||||
elif album_info.get("clean_track_name"):
|
||||
logger.info("Metadata: Using album info clean name: '%s'", metadata["title"])
|
||||
else:
|
||||
logger.warning("Metadata: Using original title as fallback: '%s'", metadata["title"])
|
||||
|
||||
artists = original_search.get("artists")
|
||||
if isinstance(artists, list) and artists:
|
||||
all_artists = []
|
||||
for artist_item in artists:
|
||||
if isinstance(artist_item, dict) and artist_item.get("name"):
|
||||
all_artists.append(artist_item["name"])
|
||||
elif isinstance(artist_item, str):
|
||||
all_artists.append(artist_item)
|
||||
else:
|
||||
all_artists.append(str(artist_item))
|
||||
metadata["artist"] = ", ".join(all_artists)
|
||||
logger.info("Metadata: Using all artists: '%s'", metadata["artist"])
|
||||
else:
|
||||
metadata["artist"] = artist_dict.get("name", "") or get_import_clean_artist(context)
|
||||
logger.info("Metadata: Using primary artist: '%s'", metadata["artist"])
|
||||
|
||||
raw_album_artist = artist_dict.get("name", "") or metadata["artist"]
|
||||
track_info_ctx = track_info or {}
|
||||
explicit_artist = track_info_ctx.get("_explicit_artist_context") if isinstance(track_info_ctx, dict) else None
|
||||
album_artists_for_collab = None
|
||||
|
||||
if isinstance(explicit_artist, dict) and explicit_artist.get("name"):
|
||||
raw_album_artist = explicit_artist["name"]
|
||||
album_artists_for_collab = [explicit_artist]
|
||||
elif isinstance(explicit_artist, str) and explicit_artist:
|
||||
raw_album_artist = explicit_artist
|
||||
album_artists_for_collab = [{"name": explicit_artist}]
|
||||
elif album_ctx and isinstance(album_ctx, dict):
|
||||
album_artists = album_ctx.get("artists", [])
|
||||
if album_artists:
|
||||
first_album_artist = album_artists[0]
|
||||
if isinstance(first_album_artist, dict) and first_album_artist.get("name"):
|
||||
raw_album_artist = first_album_artist["name"]
|
||||
elif isinstance(first_album_artist, str) and first_album_artist:
|
||||
raw_album_artist = first_album_artist
|
||||
album_artists_for_collab = album_artists
|
||||
|
||||
collab_mode = cfg.get("file_organization.collab_artist_mode", "first")
|
||||
if collab_mode == "first" and raw_album_artist:
|
||||
context_artists = album_artists_for_collab or original_search.get("artists") or track_info_ctx.get("artists") or []
|
||||
if len(context_artists) > 1:
|
||||
first = context_artists[0]
|
||||
raw_album_artist = first.get("name", first) if isinstance(first, dict) else str(first)
|
||||
elif len(context_artists) == 1 and ("," in raw_album_artist or " & " in raw_album_artist):
|
||||
artist_id = str(artist_dict.get("id", ""))
|
||||
if source == "itunes" and artist_id.isdigit():
|
||||
try:
|
||||
itunes_client = get_itunes_client()
|
||||
if itunes_client and hasattr(itunes_client, "resolve_primary_artist"):
|
||||
resolved = itunes_client.resolve_primary_artist(artist_id)
|
||||
if resolved and resolved != raw_album_artist:
|
||||
raw_album_artist = resolved
|
||||
except Exception:
|
||||
pass
|
||||
metadata["album_artist"] = raw_album_artist
|
||||
|
||||
if album_info.get("is_album"):
|
||||
metadata["album"] = album_info.get("album_name", "Unknown Album")
|
||||
metadata["track_number"] = album_info.get("track_number", 1)
|
||||
metadata["total_tracks"] = album_ctx.get("total_tracks", 1) if album_ctx else 1
|
||||
logger.info("[METADATA] Album track - track_number: %s, album: %s", metadata["track_number"], metadata["album"])
|
||||
else:
|
||||
if album_ctx and album_ctx.get("name"):
|
||||
logger.info("[SAFEGUARD] Using album context name instead of track title for album metadata")
|
||||
metadata["album"] = album_ctx["name"]
|
||||
metadata["track_number"] = album_info.get("track_number", 1) if album_info else 1
|
||||
metadata["total_tracks"] = album_ctx.get("total_tracks", 1)
|
||||
else:
|
||||
metadata["album"] = metadata["title"]
|
||||
metadata["track_number"] = 1
|
||||
metadata["total_tracks"] = 1
|
||||
|
||||
disc_num = original_search.get("disc_number")
|
||||
if disc_num is None and album_info:
|
||||
disc_num = album_info.get("disc_number")
|
||||
metadata["disc_number"] = disc_num if disc_num is not None else 1
|
||||
|
||||
if album_ctx and album_ctx.get("release_date"):
|
||||
metadata["date"] = album_ctx["release_date"][:4]
|
||||
|
||||
genres = artist_dict.get("genres") or []
|
||||
if genres:
|
||||
from core.genre_filter import filter_genres
|
||||
|
||||
filtered = filter_genres(list(genres[:2]), cfg)
|
||||
if filtered:
|
||||
metadata["genre"] = ", ".join(filtered)
|
||||
|
||||
metadata["album_art_url"] = album_info.get("album_image_url") if album_info else None
|
||||
if not metadata["album_art_url"] and album_ctx:
|
||||
album_image = album_ctx.get("image_url")
|
||||
if not album_image and album_ctx.get("images"):
|
||||
first_image = album_ctx["images"][0]
|
||||
album_image = first_image.get("url") if isinstance(first_image, dict) else None
|
||||
metadata["album_art_url"] = album_image
|
||||
|
||||
logger.info(
|
||||
"[Metadata Summary] title='%s' | artist='%s' | album_artist='%s' | album='%s' | track=%s/%s | disc=%s",
|
||||
metadata.get("title"),
|
||||
metadata.get("artist"),
|
||||
metadata.get("album_artist"),
|
||||
metadata.get("album"),
|
||||
metadata.get("track_number"),
|
||||
metadata.get("total_tracks"),
|
||||
metadata.get("disc_number"),
|
||||
)
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
def embed_source_ids(audio_file, metadata: dict, context: dict = None, runtime=None):
|
||||
cfg = get_config_manager()
|
||||
symbols = get_mutagen_symbols()
|
||||
if not symbols:
|
||||
return
|
||||
|
||||
try:
|
||||
context = normalize_import_context(context)
|
||||
source_ids = _collect_source_ids(metadata, cfg)
|
||||
|
||||
track_title = metadata.get("title", "")
|
||||
artist_name = metadata.get("album_artist", "") or metadata.get("artist", "")
|
||||
track_info = get_import_track_info(context)
|
||||
explicit_artist = (track_info or {}).get("_explicit_artist_context") if isinstance(track_info, dict) else None
|
||||
batch_artist_name = None
|
||||
if isinstance(explicit_artist, dict) and explicit_artist.get("name"):
|
||||
batch_artist_name = explicit_artist["name"]
|
||||
elif isinstance(explicit_artist, str) and explicit_artist:
|
||||
batch_artist_name = explicit_artist
|
||||
|
||||
pp = {
|
||||
"id_tags": source_ids,
|
||||
"track_title": track_title,
|
||||
"artist_name": artist_name,
|
||||
"batch_artist_name": batch_artist_name,
|
||||
"metadata": metadata,
|
||||
"recording_mbid": None,
|
||||
"artist_mbid": None,
|
||||
"release_mbid": "",
|
||||
"mb_genres": [],
|
||||
"isrc": None,
|
||||
"deezer_bpm": None,
|
||||
"deezer_isrc": None,
|
||||
"audiodb_mood": None,
|
||||
"audiodb_style": None,
|
||||
"audiodb_genre": None,
|
||||
"tidal_isrc": None,
|
||||
"tidal_copyright": None,
|
||||
"qobuz_isrc": None,
|
||||
"qobuz_copyright": None,
|
||||
"qobuz_label": None,
|
||||
"lastfm_tags": [],
|
||||
"lastfm_url": None,
|
||||
"genius_url": None,
|
||||
"release_year": None,
|
||||
}
|
||||
|
||||
source_order = cfg.get("metadata_enhancement.post_process_order", None)
|
||||
if not isinstance(source_order, list) or not source_order:
|
||||
source_order = DEFAULT_SOURCE_ORDER
|
||||
|
||||
db = get_database()
|
||||
|
||||
for source_name in source_order:
|
||||
_process_source_enrichment(source_name, pp, metadata, cfg, runtime, track_title, artist_name)
|
||||
|
||||
if not pp["id_tags"] and not pp["deezer_bpm"] and not pp["deezer_isrc"] and not pp["audiodb_mood"] and not pp["audiodb_style"]:
|
||||
return
|
||||
|
||||
release_year = _write_embedded_metadata(audio_file, metadata, pp, cfg, symbols)
|
||||
release_id = pp["release_mbid"]
|
||||
if release_id:
|
||||
metadata["musicbrainz_release_id"] = release_id
|
||||
_update_album_year_in_database(db, metadata, release_year)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Error embedding source IDs (non-fatal): %s", exc)
|
||||
File diff suppressed because it is too large
Load diff
0
core/playlists/__init__.py
Normal file
0
core/playlists/__init__.py
Normal file
363
core/playlists/explorer.py
Normal file
363
core/playlists/explorer.py
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
"""Playlist explorer build-tree route.
|
||||
|
||||
`playlist_explorer_build_tree(deps)` is the body of the
|
||||
`POST /api/playlist-explorer/build-tree` route. It builds a discovery
|
||||
tree from a mirrored playlist and streams the result as NDJSON
|
||||
(one JSON object per artist line + a final 'complete' line).
|
||||
|
||||
Works with Spotify (preferred), iTunes, or Deezer as the metadata
|
||||
source. Uses and populates the metadata cache to avoid redundant API
|
||||
calls per discography fetch.
|
||||
|
||||
Two operating modes:
|
||||
- `albums`: only show releases that overlap with the playlist's tracks.
|
||||
- `discographies`: show the full discography of every artist in the
|
||||
playlist, with `in_playlist` flag on the matching releases.
|
||||
|
||||
Per-artist flow inside the streaming generator:
|
||||
1. Resolve discography via `_fetch_artist_discography` (cache → fall
|
||||
through to live API search).
|
||||
2. Tag each release with `in_playlist` based on title-similarity match
|
||||
against the playlist's track/album names.
|
||||
3. Apply mode filter, sort by in-playlist-first then year DESC.
|
||||
4. Yield one JSON line per artist.
|
||||
|
||||
The route returns Flask's streaming `Response` wrapper around the NDJSON
|
||||
generator. Early-exit cases (bad request, playlist not found, top-level
|
||||
exception) yield via Flask's standard `jsonify(...), status` shape.
|
||||
|
||||
Lifted verbatim from web_server.py. Wide dependency surface (Flask
|
||||
`request` + `Response`, Spotify client, multiple metadata helpers,
|
||||
DB access, metadata cache) all injected via `PlaylistExplorerDeps`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaylistExplorerDeps:
|
||||
"""Bundle of cross-cutting deps the playlist explorer needs."""
|
||||
request: Any # flask.request proxy
|
||||
flask_response: Any # flask.Response constructor
|
||||
flask_jsonify: Any # flask.jsonify
|
||||
spotify_client: Any
|
||||
get_database: Callable[[], Any]
|
||||
get_active_discovery_source: Callable[[], str]
|
||||
get_metadata_fallback_client: Callable[[], Any]
|
||||
get_metadata_fallback_source: Callable[[], str]
|
||||
get_metadata_cache: Callable[[], Any]
|
||||
|
||||
|
||||
def playlist_explorer_build_tree(deps: PlaylistExplorerDeps):
|
||||
"""Build a discovery tree from a mirrored playlist.
|
||||
Streams NDJSON: one line per artist with their albums.
|
||||
Works with Spotify, iTunes, or Deezer as the metadata source.
|
||||
Uses and populates the metadata cache to avoid redundant API calls."""
|
||||
try:
|
||||
data = deps.request.get_json()
|
||||
if not data:
|
||||
return deps.flask_jsonify({"success": False, "error": "No data provided"}), 400
|
||||
|
||||
playlist_id = data.get('playlist_id')
|
||||
mode = data.get('mode', 'albums') # 'albums' or 'discographies'
|
||||
|
||||
if not playlist_id:
|
||||
return deps.flask_jsonify({"success": False, "error": "playlist_id is required"}), 400
|
||||
if mode not in ('albums', 'discographies'):
|
||||
return deps.flask_jsonify({"success": False, "error": "mode must be 'albums' or 'discographies'"}), 400
|
||||
|
||||
database = deps.get_database()
|
||||
playlist = database.get_mirrored_playlist(playlist_id)
|
||||
if not playlist:
|
||||
return deps.flask_jsonify({"success": False, "error": "Playlist not found"}), 404
|
||||
|
||||
tracks = database.get_mirrored_playlist_tracks(playlist_id)
|
||||
if not tracks:
|
||||
return deps.flask_jsonify({"success": False, "error": "Playlist has no tracks"}), 400
|
||||
|
||||
# Determine active metadata source — respect user's configured primary
|
||||
source_name = deps.get_active_discovery_source()
|
||||
if source_name == 'spotify' and deps.spotify_client and deps.spotify_client.is_spotify_authenticated():
|
||||
active_client = deps.spotify_client
|
||||
else:
|
||||
active_client = deps.get_metadata_fallback_client()
|
||||
source_name = deps.get_metadata_fallback_source()
|
||||
|
||||
cache = deps.get_metadata_cache()
|
||||
|
||||
# Parse extra_data and group tracks by artist using discovered data
|
||||
artist_groups = {}
|
||||
for t in tracks:
|
||||
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
|
||||
|
||||
# Only use discovery data if it matches the active metadata source
|
||||
is_discovered = extra.get('discovered', False)
|
||||
provider = (extra.get('provider') or '').lower()
|
||||
source_matches = provider == source_name or (provider in ('itunes', 'apple') and source_name == 'itunes')
|
||||
|
||||
matched = extra.get('matched_data', {}) if (is_discovered and source_matches) else {}
|
||||
artists_list = matched.get('artists', [])
|
||||
primary_artist = artists_list[0] if artists_list else None
|
||||
# Artists can be dicts {"name": "X", "id": "Y"} or plain strings "X"
|
||||
if isinstance(primary_artist, dict):
|
||||
artist_name = primary_artist.get('name') or (t.get('artist_name') or '').strip()
|
||||
artist_id = primary_artist.get('id') or None
|
||||
elif isinstance(primary_artist, str):
|
||||
artist_name = primary_artist or (t.get('artist_name') or '').strip()
|
||||
artist_id = None
|
||||
else:
|
||||
artist_name = (t.get('artist_name') or '').strip()
|
||||
artist_id = None
|
||||
|
||||
if not artist_name:
|
||||
continue
|
||||
|
||||
key = artist_name.lower()
|
||||
if key not in artist_groups:
|
||||
artist_groups[key] = {
|
||||
'name': artist_name,
|
||||
'artist_id': artist_id, # Pre-resolved from discovery
|
||||
'tracks': [],
|
||||
'album_names': set(),
|
||||
'discovered': extra.get('discovered', False),
|
||||
}
|
||||
# If we get an artist_id from a later track but didn't have one before, fill it in
|
||||
if artist_id and not artist_groups[key].get('artist_id'):
|
||||
artist_groups[key]['artist_id'] = artist_id
|
||||
|
||||
artist_groups[key]['tracks'].append(t.get('track_name', ''))
|
||||
# Get album name from discovered data or playlist field
|
||||
album_name = ''
|
||||
album_data = matched.get('album')
|
||||
if isinstance(album_data, dict) and album_data.get('name'):
|
||||
album_name = album_data['name']
|
||||
elif (t.get('album_name') or '').strip():
|
||||
album_name = t['album_name'].strip()
|
||||
if album_name:
|
||||
artist_groups[key]['album_names'].add(album_name)
|
||||
|
||||
def _normalize_for_match(title):
|
||||
import re
|
||||
return re.sub(r'\s*[\(\[][^)\]]*[\)\]]', '', title).strip().lower()
|
||||
|
||||
def _fetch_artist_discography(artist_name, known_artist_id=None):
|
||||
"""Fetch discography using the active client. Checks cache first, stores results after.
|
||||
If known_artist_id is provided (from discovery cache), skips the name search."""
|
||||
# Check cache for this artist's discography
|
||||
cache_key = f"explorer_disco_{artist_name.lower().strip()}"
|
||||
cached = cache.get_entity(source_name, 'artist_discography', cache_key) if cache else None
|
||||
if cached and isinstance(cached, dict) and cached.get('albums'):
|
||||
logger.debug(f"Explorer: cache hit for '{artist_name}' discography")
|
||||
return cached
|
||||
|
||||
artist_id = known_artist_id
|
||||
artist_image = None
|
||||
|
||||
if artist_id:
|
||||
# Already have the ID from discovery — just fetch the artist image
|
||||
try:
|
||||
artist_info = active_client.get_artist(artist_id)
|
||||
if artist_info:
|
||||
if isinstance(artist_info, dict):
|
||||
images = artist_info.get('images') or []
|
||||
artist_image = images[0].get('url') if images else None
|
||||
elif hasattr(artist_info, 'image_url'):
|
||||
artist_image = artist_info.image_url
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# No pre-resolved ID — search by name
|
||||
try:
|
||||
search_results = active_client.search_artists(artist_name, limit=5)
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': f'Search failed: {e}'}
|
||||
|
||||
if not search_results:
|
||||
return {'success': False, 'error': f'"{artist_name}" not found'}
|
||||
|
||||
# Find best match (exact first, then fuzzy)
|
||||
best = None
|
||||
for a in search_results:
|
||||
if a.name.lower().strip() == artist_name.lower().strip():
|
||||
best = a
|
||||
break
|
||||
if not best:
|
||||
best = search_results[0]
|
||||
|
||||
artist_id = best.id
|
||||
artist_image = best.image_url if hasattr(best, 'image_url') else None
|
||||
|
||||
# Fetch albums
|
||||
try:
|
||||
# skip_cache only supported by spotify_client — other clients don't cache this call
|
||||
_skip = {'skip_cache': True} if hasattr(active_client, 'sp') else {}
|
||||
all_albums = active_client.get_artist_albums(artist_id, album_type='album,single', **_skip)
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': f'Album fetch failed: {e}'}
|
||||
|
||||
if not all_albums:
|
||||
return {'success': False, 'error': 'No albums found'}
|
||||
|
||||
# Check which albums the user already owns
|
||||
owned_titles = set()
|
||||
try:
|
||||
db = deps.get_database()
|
||||
with db._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
# Find all artists in DB matching this name
|
||||
cursor.execute("SELECT id FROM artists WHERE LOWER(name) = LOWER(?)", (artist_name,))
|
||||
artist_rows = cursor.fetchall()
|
||||
for ar in artist_rows:
|
||||
cursor.execute("SELECT title FROM albums WHERE artist_id = ?", (ar['id'],))
|
||||
for alb_row in cursor.fetchall():
|
||||
owned_titles.add((alb_row['title'] or '').strip().lower())
|
||||
except Exception:
|
||||
pass # Non-critical — owned badges just won't show
|
||||
|
||||
# Build release list
|
||||
releases = []
|
||||
for album in all_albums:
|
||||
# Skip albums where this artist isn't primary
|
||||
if hasattr(album, 'artist_ids') and album.artist_ids and album.artist_ids[0] != artist_id:
|
||||
continue
|
||||
releases.append({
|
||||
'title': album.name,
|
||||
'year': album.release_date[:4] if album.release_date else None,
|
||||
'image_url': album.image_url,
|
||||
'spotify_id': album.id,
|
||||
'track_count': album.total_tracks,
|
||||
'album_type': (album.album_type or 'album').lower(),
|
||||
'owned': (album.name or '').strip().lower() in owned_titles,
|
||||
})
|
||||
|
||||
result = {
|
||||
'success': True,
|
||||
'name': artist_name, # Required for metadata cache validation
|
||||
'albums': releases,
|
||||
'artist_image': artist_image,
|
||||
'artist_id': artist_id,
|
||||
'artist_name': artist_name,
|
||||
}
|
||||
|
||||
# Store in cache
|
||||
if cache and releases:
|
||||
try:
|
||||
cache.store_entity(source_name, 'artist_discography', cache_key, result)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
def generate():
|
||||
yield json.dumps({
|
||||
"type": "meta",
|
||||
"playlist_name": playlist.get('name', 'Unknown Playlist'),
|
||||
"playlist_image": playlist.get('image_url', ''),
|
||||
"total_artists": len(artist_groups),
|
||||
"total_tracks": len(tracks),
|
||||
"source": source_name,
|
||||
}) + '\n'
|
||||
|
||||
total_albums = 0
|
||||
|
||||
for idx, (_key, group) in enumerate(artist_groups.items()):
|
||||
artist_name = group['name']
|
||||
playlist_track_names = group['tracks']
|
||||
playlist_album_names = group['album_names']
|
||||
|
||||
try:
|
||||
disco = _fetch_artist_discography(artist_name, group.get('artist_id'))
|
||||
|
||||
if not disco.get('success'):
|
||||
yield json.dumps({
|
||||
"type": "artist",
|
||||
"name": artist_name,
|
||||
"artist_id": None,
|
||||
"image_url": None,
|
||||
"playlist_tracks": playlist_track_names,
|
||||
"albums": [],
|
||||
"error": disco.get('error', 'Not found'),
|
||||
}) + '\n'
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
|
||||
# Tag each release with in_playlist flag
|
||||
# If no album names available, fall back to matching track names against single titles
|
||||
match_names = playlist_album_names
|
||||
if not match_names:
|
||||
match_names = set(playlist_track_names)
|
||||
|
||||
all_releases = []
|
||||
for release in disco.get('albums', []):
|
||||
r = dict(release)
|
||||
norm_title = _normalize_for_match(r['title'])
|
||||
r['in_playlist'] = any(
|
||||
_normalize_for_match(a) == norm_title or
|
||||
norm_title in _normalize_for_match(a) or
|
||||
_normalize_for_match(a) in norm_title
|
||||
for a in match_names
|
||||
)
|
||||
all_releases.append(r)
|
||||
|
||||
# Filter based on mode
|
||||
if mode == 'albums':
|
||||
filtered = [r for r in all_releases if r['in_playlist']]
|
||||
else:
|
||||
filtered = all_releases
|
||||
|
||||
filtered.sort(key=lambda r: (not r.get('in_playlist', False), -(int(r.get('year') or 0))))
|
||||
total_albums += len(filtered)
|
||||
|
||||
yield json.dumps({
|
||||
"type": "artist",
|
||||
"name": disco.get('artist_name', artist_name),
|
||||
"artist_id": disco.get('artist_id'),
|
||||
"image_url": disco.get('artist_image'),
|
||||
"playlist_tracks": playlist_track_names,
|
||||
"albums": filtered,
|
||||
}) + '\n'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Explorer: error processing artist '{artist_name}': {e}")
|
||||
yield json.dumps({
|
||||
"type": "artist",
|
||||
"name": artist_name,
|
||||
"artist_id": None,
|
||||
"image_url": None,
|
||||
"playlist_tracks": playlist_track_names,
|
||||
"albums": [],
|
||||
"error": str(e),
|
||||
}) + '\n'
|
||||
|
||||
# Rate limit protection between artists
|
||||
if idx < len(artist_groups) - 1:
|
||||
time.sleep(0.2)
|
||||
|
||||
deps.get_database().mark_mirrored_playlist_explored(playlist_id)
|
||||
yield json.dumps({"type": "complete", "total_artists": len(artist_groups), "total_albums": total_albums}) + '\n'
|
||||
|
||||
return deps.flask_response(generate(), mimetype='application/x-ndjson', headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Accel-Buffering': 'no',
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Playlist Explorer build-tree error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return deps.flask_jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
"""Duplicate Track Detector Job — finds potential duplicate tracks in the library."""
|
||||
|
||||
import os
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
from core.imports.file_ops import _strip_slskd_dedup_suffix
|
||||
from core.repair_jobs import register_job
|
||||
from core.repair_jobs.base import JobContext, JobResult, RepairJob
|
||||
from utils.logging_config import get_logger
|
||||
|
|
@ -106,21 +108,97 @@ class DuplicateDetectorJob(RepairJob):
|
|||
|
||||
# Find duplicates within each bucket
|
||||
found_groups = set() # Track IDs already in a group
|
||||
processed = 0
|
||||
processed_holder = {'count': 0}
|
||||
|
||||
if context.report_progress:
|
||||
context.report_progress(phase=f'Comparing {total} tracks...', total=total)
|
||||
|
||||
# Pass 1 — bucket by normalized-title prefix (existing behavior).
|
||||
for _bucket_key, bucket_tracks in buckets.items():
|
||||
if context.check_stop():
|
||||
return result
|
||||
self._scan_bucket(
|
||||
bucket_tracks=bucket_tracks,
|
||||
require_metadata_match=True,
|
||||
title_threshold=title_threshold,
|
||||
artist_threshold=artist_threshold,
|
||||
ignore_cross_album=ignore_cross_album,
|
||||
found_groups=found_groups,
|
||||
processed_holder=processed_holder,
|
||||
total=total,
|
||||
result=result,
|
||||
context=context,
|
||||
)
|
||||
|
||||
for i, t1 in enumerate(bucket_tracks):
|
||||
# Pass 2 — re-bucket leftover tracks by canonical filename stem
|
||||
# (slskd dedup suffix stripped). Catches dupes whose tag metadata
|
||||
# disagrees because some copies were never properly tagged after
|
||||
# download — e.g. ``Song.flac`` and ``Song_<19-digit-ts>.flac``
|
||||
# land in the library with identical filenames sans the slskd
|
||||
# dedup tail but get inconsistent ID3 titles from the media-server
|
||||
# rescan. Pass-1 buckets them apart by title so they never get
|
||||
# compared. Discord-reported scenario: 7 copies of one OST track
|
||||
# accumulating in one folder, only 1 caught by the detector.
|
||||
filename_buckets = self._build_filename_buckets(
|
||||
buckets=buckets,
|
||||
found_groups=found_groups,
|
||||
)
|
||||
for _fname_key, fname_tracks in filename_buckets.items():
|
||||
if context.check_stop():
|
||||
return result
|
||||
# Filename match is itself strong evidence — a shared canonical
|
||||
# stem means the files came from the same source download.
|
||||
# Drop the metadata gates so dedup orphans get caught even
|
||||
# when their tag titles disagree.
|
||||
self._scan_bucket(
|
||||
bucket_tracks=fname_tracks,
|
||||
require_metadata_match=False,
|
||||
title_threshold=title_threshold,
|
||||
artist_threshold=artist_threshold,
|
||||
ignore_cross_album=ignore_cross_album,
|
||||
found_groups=found_groups,
|
||||
processed_holder=processed_holder,
|
||||
total=total,
|
||||
result=result,
|
||||
context=context,
|
||||
)
|
||||
|
||||
processed += 1
|
||||
if context.update_progress:
|
||||
context.update_progress(total, total)
|
||||
|
||||
logger.info("Duplicate scan: %d tracks checked, %d duplicate groups found",
|
||||
result.scanned, result.findings_created)
|
||||
return result
|
||||
|
||||
def _scan_bucket(
|
||||
self,
|
||||
*,
|
||||
bucket_tracks,
|
||||
require_metadata_match,
|
||||
title_threshold,
|
||||
artist_threshold,
|
||||
ignore_cross_album,
|
||||
found_groups,
|
||||
processed_holder,
|
||||
total,
|
||||
result,
|
||||
context,
|
||||
) -> None:
|
||||
"""Compare every pair within a bucket; emit duplicate groups.
|
||||
|
||||
``require_metadata_match`` gates the title / artist similarity
|
||||
thresholds and the cross-album guard. Pass ``False`` for buckets
|
||||
whose grouping is already strong evidence (e.g. shared canonical
|
||||
filename) so that dedup orphans with broken / missing tags still
|
||||
get caught.
|
||||
"""
|
||||
for i, t1 in enumerate(bucket_tracks):
|
||||
if context.check_stop():
|
||||
return
|
||||
|
||||
processed_holder['count'] += 1
|
||||
result.scanned += 1
|
||||
processed = processed_holder['count']
|
||||
|
||||
if context.report_progress and processed % 100 == 0:
|
||||
context.report_progress(
|
||||
|
|
@ -140,24 +218,47 @@ class DuplicateDetectorJob(RepairJob):
|
|||
if t2['id'] in found_groups:
|
||||
continue
|
||||
|
||||
# Compare titles
|
||||
if require_metadata_match:
|
||||
title_sim = SequenceMatcher(None, t1['norm_title'], t2['norm_title']).ratio()
|
||||
if title_sim < title_threshold:
|
||||
continue
|
||||
|
||||
# Compare artists
|
||||
artist_sim = SequenceMatcher(None, t1['norm_artist'], t2['norm_artist']).ratio()
|
||||
if artist_sim < artist_threshold:
|
||||
continue
|
||||
|
||||
# Skip cross-album duplicates — same song on different albums is intentional
|
||||
if ignore_cross_album and t1['album'] and t2['album'] and t1['album'] != t2['album']:
|
||||
continue
|
||||
else:
|
||||
# Filename-bucket pass: filename agreement is strong but
|
||||
# not infallible — two different songs that happen to
|
||||
# share a canonical filename (``Yellow.mp3`` by Coldplay
|
||||
# vs by Bob's Album) would get grouped without a sanity
|
||||
# check. Require duration agreement (within 3s) when
|
||||
# both rows have it; same source download = identical
|
||||
# duration. If either side is missing duration data,
|
||||
# fall back to a relaxed artist similarity check so we
|
||||
# don't blindly group strangers.
|
||||
if t1['duration'] and t2['duration']:
|
||||
if abs(t1['duration'] - t2['duration']) > 3.0:
|
||||
continue
|
||||
elif t1['norm_artist'] and t2['norm_artist']:
|
||||
artist_sim = SequenceMatcher(None, t1['norm_artist'], t2['norm_artist']).ratio()
|
||||
if artist_sim < 0.6:
|
||||
continue
|
||||
# else: both durations missing AND at least one artist
|
||||
# is blank — too little signal, skip to avoid false
|
||||
# positives.
|
||||
elif not t1['norm_artist'] or not t2['norm_artist']:
|
||||
continue
|
||||
|
||||
if _is_same_physical_file(
|
||||
t1['file_path'], t2['file_path'],
|
||||
t1['duration'], t2['duration'],
|
||||
):
|
||||
continue
|
||||
|
||||
group.append(t2)
|
||||
|
||||
if len(group) >= 2:
|
||||
# Found a duplicate group
|
||||
for t in group:
|
||||
found_groups.add(t['id'])
|
||||
|
||||
|
|
@ -169,9 +270,7 @@ class DuplicateDetectorJob(RepairJob):
|
|||
|
||||
if context.create_finding:
|
||||
try:
|
||||
# Sort group by quality (highest bitrate first)
|
||||
group.sort(key=lambda t: (t['bitrate'] or 0), reverse=True)
|
||||
|
||||
context.create_finding(
|
||||
job_id=self.job_id,
|
||||
finding_type='duplicate_tracks',
|
||||
|
|
@ -201,15 +300,33 @@ class DuplicateDetectorJob(RepairJob):
|
|||
logger.debug("Error creating duplicate finding: %s", e)
|
||||
result.errors += 1
|
||||
|
||||
if context.update_progress and processed % 200 == 0:
|
||||
context.update_progress(processed, total)
|
||||
if context.update_progress and processed_holder['count'] % 200 == 0:
|
||||
context.update_progress(processed_holder['count'], total)
|
||||
|
||||
if context.update_progress:
|
||||
context.update_progress(total, total)
|
||||
def _build_filename_buckets(self, *, buckets, found_groups):
|
||||
"""Re-bucket all tracks by canonical filename stem.
|
||||
|
||||
logger.info("Duplicate scan: %d tracks checked, %d duplicate groups found",
|
||||
result.scanned, result.findings_created)
|
||||
return result
|
||||
The slskd dedup suffix (``_<19+ digit timestamp>``) is stripped so
|
||||
``Song.flac`` and ``Song_639122324339578022.flac`` collapse to the
|
||||
same key. Singleton buckets (only one track) are dropped — they
|
||||
carry no comparison value.
|
||||
"""
|
||||
filename_buckets = defaultdict(list)
|
||||
for bucket_tracks in buckets.values():
|
||||
for track in bucket_tracks:
|
||||
if track['id'] in found_groups:
|
||||
continue
|
||||
fp = track.get('file_path') or ''
|
||||
if not fp:
|
||||
continue
|
||||
basename = os.path.basename(str(fp).replace('\\', '/'))
|
||||
stem, ext = os.path.splitext(basename)
|
||||
if not stem:
|
||||
continue
|
||||
canonical = _strip_slskd_dedup_suffix(stem)
|
||||
key = (canonical.lower(), ext.lower())
|
||||
filename_buckets[key].append(track)
|
||||
return {k: v for k, v in filename_buckets.items() if len(v) >= 2}
|
||||
|
||||
def _get_settings(self, context: JobContext) -> dict:
|
||||
if not context.config_manager:
|
||||
|
|
@ -229,3 +346,43 @@ def _normalize(text: str) -> str:
|
|||
t = text.lower()
|
||||
t = re.sub(r'[^a-z0-9() ]', '', t)
|
||||
return t.strip()
|
||||
|
||||
|
||||
def _is_same_physical_file(p1, p2, dur1, dur2) -> bool:
|
||||
"""Detect when two DB rows point at the same file mounted at different paths.
|
||||
|
||||
When a user binds the same host music directory into both SoulSync
|
||||
(e.g. ``/app/Transfer``) and a media server like Plex (e.g.
|
||||
``/media/Music``), the SoulSync scan and the media-server library
|
||||
sync each create a track row pointing at the same physical file
|
||||
via different mount paths. The two rows then look like a fuzzy-
|
||||
match duplicate to this job.
|
||||
|
||||
Returns True when:
|
||||
- Both paths share the last 3 segments (filename + album + artist
|
||||
folder), so they really are the same release on disk;
|
||||
- The leading mount-root segments differ, ruling out the case
|
||||
where one row is just a re-scan of the other path; and
|
||||
- When both rows carry a duration, the durations agree within 1
|
||||
second (defensive — different files at parallel paths would
|
||||
almost always disagree on duration even slightly).
|
||||
"""
|
||||
if not p1 or not p2:
|
||||
return False
|
||||
norm1 = str(p1).replace('\\', '/').rstrip('/')
|
||||
norm2 = str(p2).replace('\\', '/').rstrip('/')
|
||||
parts1 = [x for x in norm1.split('/') if x]
|
||||
parts2 = [x for x in norm2.split('/') if x]
|
||||
if len(parts1) < 3 or len(parts2) < 3:
|
||||
return False
|
||||
tail1 = [s.lower() for s in parts1[-3:]]
|
||||
tail2 = [s.lower() for s in parts2[-3:]]
|
||||
if tail1 != tail2:
|
||||
return False
|
||||
# Confirm mount roots actually differ, otherwise we'd skip
|
||||
# legitimate duplicates that happen to share the trailing path.
|
||||
if parts1[:-3] == parts2[:-3]:
|
||||
return False
|
||||
if dur1 and dur2 and abs(dur1 - dur2) > 1.0:
|
||||
return False
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ class RepairWorker:
|
|||
def metadata_cache(self):
|
||||
if self._metadata_cache is None:
|
||||
try:
|
||||
from core.metadata_cache import get_metadata_cache
|
||||
from core.metadata.cache import get_metadata_cache
|
||||
self._metadata_cache = get_metadata_cache()
|
||||
except Exception as e:
|
||||
logger.error("Failed to get metadata cache: %s", e)
|
||||
|
|
|
|||
81
core/runtime_state.py
Normal file
81
core/runtime_state.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
"""Shared runtime state and tiny helpers for the app."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from functools import wraps
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
matched_context_lock = threading.Lock()
|
||||
matched_downloads_context: Dict[str, Dict[str, Any]] = {}
|
||||
tasks_lock = threading.Lock()
|
||||
download_tasks: Dict[str, Dict[str, Any]] = {}
|
||||
download_batches: Dict[str, Dict[str, Any]] = {}
|
||||
batch_locks: Dict[str, threading.Lock] = {}
|
||||
processed_download_ids = set()
|
||||
post_process_locks: Dict[str, threading.Lock] = {}
|
||||
post_process_locks_lock = threading.Lock()
|
||||
|
||||
activity_feed = []
|
||||
activity_feed_lock = threading.Lock()
|
||||
_activity_toast_emitter = None
|
||||
|
||||
|
||||
def caller_must_hold_tasks_lock(func):
|
||||
"""Best-effort guard for helpers that mutate download_tasks in place."""
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not tasks_lock.locked():
|
||||
raise RuntimeError(f"{func.__name__}() requires tasks_lock to be held by the caller")
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def set_activity_toast_emitter(emitter) -> None:
|
||||
"""Set the WebSocket-style emitter used by add_activity_item."""
|
||||
global _activity_toast_emitter
|
||||
_activity_toast_emitter = emitter
|
||||
|
||||
|
||||
def add_activity_item(icon, title, subtitle, time_ago="Now", show_toast=True):
|
||||
"""Append an activity item and emit a toast if an emitter is configured."""
|
||||
activity_item = {
|
||||
"icon": icon,
|
||||
"title": title,
|
||||
"subtitle": subtitle,
|
||||
"time": time_ago,
|
||||
"timestamp": time.time(),
|
||||
"show_toast": show_toast,
|
||||
}
|
||||
with activity_feed_lock:
|
||||
activity_feed.append(activity_item)
|
||||
if len(activity_feed) > 20:
|
||||
activity_feed.pop(0)
|
||||
|
||||
if show_toast and _activity_toast_emitter is not None:
|
||||
try:
|
||||
_activity_toast_emitter("dashboard:toast", activity_item)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return activity_item
|
||||
|
||||
|
||||
@caller_must_hold_tasks_lock
|
||||
def mark_task_completed(task_id: str, track_info: Optional[Dict[str, Any]] = None) -> bool:
|
||||
"""Mark a download task as completed.
|
||||
|
||||
Callers must already hold `tasks_lock`.
|
||||
"""
|
||||
task = download_tasks.get(task_id)
|
||||
if not task:
|
||||
return False
|
||||
|
||||
task["status"] = "completed"
|
||||
task["stream_processed"] = True
|
||||
task["status_change_time"] = time.time()
|
||||
if track_info is not None:
|
||||
task["track_info"] = track_info
|
||||
return True
|
||||
8
core/search/__init__.py
Normal file
8
core/search/__init__.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
"""Search API helpers package.
|
||||
|
||||
Lifted from web_server.py /api/search and /api/enhanced-search/* routes.
|
||||
Each module exposes pure-ish functions that take dependencies (database,
|
||||
clients, config_manager, matching_engine) as arguments. Route handlers in
|
||||
web_server.py stay thin — they parse requests, call into these helpers,
|
||||
and return jsonify / streaming responses.
|
||||
"""
|
||||
44
core/search/basic.py
Normal file
44
core/search/basic.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""Basic Soulseek file search — flat list of file results sorted by quality.
|
||||
|
||||
Used by the Soulseek source icon in the unified search UI and by direct
|
||||
/api/search calls. Synchronous wrapper around the async soulseek client.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_basic_soulseek_search(
|
||||
query: str,
|
||||
soulseek_client,
|
||||
run_async: Callable,
|
||||
) -> list[dict]:
|
||||
"""Search Soulseek for `query`, normalize albums + tracks to one sorted list.
|
||||
|
||||
Returns dicts with `result_type` set to "album" or "track" and sorted by
|
||||
`quality_score` descending. Empty list on any failure (caller logs).
|
||||
"""
|
||||
tracks, albums = run_async(soulseek_client.search(query))
|
||||
|
||||
processed_albums = []
|
||||
for album in albums:
|
||||
album_dict = album.__dict__.copy()
|
||||
album_dict['tracks'] = [track.__dict__ for track in album.tracks]
|
||||
album_dict['result_type'] = 'album'
|
||||
processed_albums.append(album_dict)
|
||||
|
||||
processed_tracks = []
|
||||
for track in tracks:
|
||||
track_dict = track.__dict__.copy()
|
||||
track_dict['result_type'] = 'track'
|
||||
processed_tracks.append(track_dict)
|
||||
|
||||
return sorted(
|
||||
processed_albums + processed_tracks,
|
||||
key=lambda x: x.get('quality_score', 0),
|
||||
reverse=True,
|
||||
)
|
||||
108
core/search/cache.py
Normal file
108
core/search/cache.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"""TTL'd in-memory cache for enhanced-search responses.
|
||||
|
||||
The cache key blends the normalized query with the active media server,
|
||||
configured fallback metadata source, hydrabase-active flag, and the
|
||||
explicit single-source request (if any). This prevents responses from
|
||||
colliding when a user changes settings or switches single-source mode.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable, Optional, Tuple
|
||||
|
||||
CacheKey = Tuple[str, str, str, bool, str]
|
||||
|
||||
CACHE_TTL_SECONDS = 600
|
||||
CACHE_MAX_ENTRIES = 100
|
||||
|
||||
|
||||
class EnhancedSearchCache:
|
||||
"""Thread-safe LRU+TTL cache for enhanced-search response payloads.
|
||||
|
||||
A single shared instance lives in this module (`_cache`). The module-level
|
||||
helpers (`get_cache_key`, `get_cached_response`, `set_cached_response`)
|
||||
operate on it.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl: float = CACHE_TTL_SECONDS, max_entries: int = CACHE_MAX_ENTRIES):
|
||||
self._ttl = ttl
|
||||
self._max_entries = max_entries
|
||||
self._store: "collections.OrderedDict[CacheKey, dict]" = collections.OrderedDict()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def get(self, key: CacheKey) -> Optional[dict]:
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
entry = self._store.get(key)
|
||||
if not entry:
|
||||
return None
|
||||
if now - entry['timestamp'] < self._ttl:
|
||||
self._store.move_to_end(key)
|
||||
return entry['data']
|
||||
self._store.pop(key, None)
|
||||
return None
|
||||
|
||||
def set(self, key: CacheKey, data: dict) -> None:
|
||||
with self._lock:
|
||||
self._store[key] = {'timestamp': time.time(), 'data': data}
|
||||
self._store.move_to_end(key)
|
||||
while len(self._store) > self._max_entries:
|
||||
self._store.popitem(last=False)
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._store.clear()
|
||||
|
||||
|
||||
_cache = EnhancedSearchCache()
|
||||
|
||||
|
||||
def get_cache_key(
|
||||
query: str,
|
||||
requested_source: Optional[str],
|
||||
*,
|
||||
active_server_provider: Callable[[], str],
|
||||
fallback_source_provider: Callable[[], str],
|
||||
hydrabase_active_provider: Callable[[], bool],
|
||||
) -> CacheKey:
|
||||
"""Build a cache key for an enhanced-search query.
|
||||
|
||||
Each provider arg is a zero-arg callable so the cache key reflects the
|
||||
LIVE config state at lookup time, not the state at app startup. Each
|
||||
provider is wrapped in try/except: failures resolve to a sentinel value
|
||||
so a misconfigured client never breaks search.
|
||||
"""
|
||||
normalized_query = (query or '').strip().lower()
|
||||
|
||||
try:
|
||||
active_server = active_server_provider()
|
||||
except Exception:
|
||||
active_server = 'unknown'
|
||||
|
||||
try:
|
||||
fallback_source = fallback_source_provider()
|
||||
except Exception:
|
||||
fallback_source = 'unknown'
|
||||
|
||||
try:
|
||||
hydrabase_active = hydrabase_active_provider()
|
||||
except Exception:
|
||||
hydrabase_active = False
|
||||
|
||||
source_tag = (requested_source or '').strip().lower() or 'auto'
|
||||
return (normalized_query, active_server, fallback_source, hydrabase_active, source_tag)
|
||||
|
||||
|
||||
def get_cached_response(key: CacheKey) -> Optional[dict]:
|
||||
return _cache.get(key)
|
||||
|
||||
|
||||
def set_cached_response(key: CacheKey, data: Any) -> None:
|
||||
_cache.set(key, data)
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
_cache.clear()
|
||||
123
core/search/library_check.py
Normal file
123
core/search/library_check.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""Batch library presence check for search results.
|
||||
|
||||
Given a list of `albums` and `tracks` from a metadata search, return per-row
|
||||
booleans (and matched-row metadata for tracks) indicating whether each
|
||||
result is already in the user's library or wishlist. Plex relative-path
|
||||
thumb URLs are rewritten to absolute URLs with token.
|
||||
|
||||
Called async from the frontend after the main search renders, so the user
|
||||
sees results immediately and "in library" badges fade in once the check
|
||||
completes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from core.wishlist.presence import load_wishlist_keys as _load_wishlist_keys_shared
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_plex_thumb(thumb: str, plex_base: str, plex_token: str) -> str:
|
||||
"""Rewrite a Plex relative thumb path to an absolute URL with token."""
|
||||
if not thumb or thumb.startswith('http') or not plex_base or not thumb.startswith('/'):
|
||||
return thumb
|
||||
if plex_token:
|
||||
return f"{plex_base}{thumb}?X-Plex-Token={plex_token}"
|
||||
return f"{plex_base}{thumb}"
|
||||
|
||||
|
||||
def _resolve_plex_credentials(plex_client, config_manager) -> tuple[str, str]:
|
||||
"""Pull (base_url, token) for the active Plex server.
|
||||
|
||||
Prefers the live `plex_client.server` attrs; falls back to config_manager
|
||||
if the live client isn't connected yet. Mirrors original web_server.py
|
||||
inline logic byte-for-byte.
|
||||
"""
|
||||
base, token = '', ''
|
||||
if plex_client and plex_client.server:
|
||||
base = getattr(plex_client.server, '_baseurl', '') or ''
|
||||
token = getattr(plex_client.server, '_token', '') or ''
|
||||
if not base:
|
||||
cfg = config_manager.get_plex_config()
|
||||
base = (cfg.get('base_url', '') or '').rstrip('/')
|
||||
token = token or cfg.get('token', '')
|
||||
return base, token
|
||||
|
||||
|
||||
def _load_wishlist_keys(cursor, profile_id: int) -> set[str]:
|
||||
return _load_wishlist_keys_shared(cursor, profile_id)
|
||||
|
||||
|
||||
def check_library_presence(
|
||||
database,
|
||||
plex_client,
|
||||
config_manager,
|
||||
profile_id: int,
|
||||
albums: list[dict],
|
||||
tracks: list[dict],
|
||||
) -> dict:
|
||||
"""Return `{albums: [bool], tracks: [{...}]}` for the given search results.
|
||||
|
||||
- `albums` returns one bool per input row.
|
||||
- `tracks` returns one dict per input row. Matched rows get the full
|
||||
track metadata + resolved thumb URL; unmatched rows get
|
||||
`{in_library: False, in_wishlist: bool}`.
|
||||
"""
|
||||
conn = database._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"SELECT LOWER(al.title) || '|||' || LOWER(ar.name) "
|
||||
"FROM albums al JOIN artists ar ON ar.id = al.artist_id"
|
||||
)
|
||||
owned_albums = {r[0] for r in cursor.fetchall()}
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT LOWER(t.title) || '|||' || LOWER(a.name), t.id, t.file_path,
|
||||
t.title, a.name, al.title, al.thumb_url
|
||||
FROM tracks t
|
||||
JOIN artists a ON a.id = t.artist_id
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
"""
|
||||
)
|
||||
owned_tracks: dict[str, dict] = {}
|
||||
for r in cursor.fetchall():
|
||||
if r[0] not in owned_tracks: # keep first match only
|
||||
owned_tracks[r[0]] = {
|
||||
'track_id': r[1],
|
||||
'file_path': r[2],
|
||||
'title': r[3],
|
||||
'artist_name': r[4],
|
||||
'album_title': r[5],
|
||||
'album_thumb_url': r[6],
|
||||
}
|
||||
|
||||
wishlist_keys = _load_wishlist_keys(cursor, profile_id)
|
||||
|
||||
album_results: list[bool] = []
|
||||
for a in albums:
|
||||
key = (a.get('name', '').lower() + '|||' + a.get('artist', '').split(',')[0].strip().lower())
|
||||
album_results.append(key in owned_albums)
|
||||
|
||||
plex_base, plex_token = _resolve_plex_credentials(plex_client, config_manager)
|
||||
|
||||
track_results: list[dict] = []
|
||||
for t in tracks:
|
||||
key = (t.get('name', '').lower() + '|||' + t.get('artist', '').split(',')[0].strip().lower())
|
||||
in_wishlist = key in wishlist_keys
|
||||
match = owned_tracks.get(key)
|
||||
if match:
|
||||
thumb = match.get('album_thumb_url') or ''
|
||||
match['album_thumb_url'] = _resolve_plex_thumb(thumb, plex_base, plex_token)
|
||||
track_results.append({'in_library': True, 'in_wishlist': in_wishlist, **match})
|
||||
else:
|
||||
track_results.append({'in_library': False, 'in_wishlist': in_wishlist})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {'albums': album_results, 'tracks': track_results}
|
||||
347
core/search/orchestrator.py
Normal file
347
core/search/orchestrator.py
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
"""Enhanced-search orchestration.
|
||||
|
||||
Two routes funnel through here:
|
||||
- `/api/enhanced-search` → `run_enhanced_search`
|
||||
- Always returns library DB matches
|
||||
- Single-source mode (request body has `source: "spotify"` etc) skips fan-out
|
||||
- Default mode resolves a primary source, runs it synchronously, and
|
||||
returns the list of alternate sources for the frontend to fetch async
|
||||
- `/api/enhanced-search/source/<src>` → `stream_source_search` (generator)
|
||||
- NDJSON: yields one line per kind (artists / albums / tracks) as each
|
||||
finishes, plus a final `{"type":"done"}` line
|
||||
- Has its own special-case for `youtube_videos` which uses yt-dlp
|
||||
|
||||
The route layer wraps the generator in a Flask `Response(...,
|
||||
mimetype='application/x-ndjson')`. Everything else is plain Python.
|
||||
|
||||
Cross-cutting deps are passed in as a `SearchDeps` dataclass to keep the
|
||||
function signatures readable. Each field is a live reference (not a
|
||||
snapshot) so callers see config changes without restart.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Iterator, Optional
|
||||
|
||||
from . import sources
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VALID_SOURCES = (
|
||||
'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz',
|
||||
)
|
||||
|
||||
VALID_STREAM_SOURCES = VALID_SOURCES + ('youtube_videos',)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchDeps:
|
||||
"""Bundle of cross-cutting deps used by the orchestrator.
|
||||
|
||||
All fields are lazily evaluated where possible (live providers, not
|
||||
cached values) so settings changes take effect without restart.
|
||||
"""
|
||||
database: Any
|
||||
config_manager: Any
|
||||
spotify_client: Any
|
||||
hydrabase_client: Any
|
||||
hydrabase_worker: Any
|
||||
soulseek_client: Any
|
||||
fix_artist_image_url: Callable[[Optional[str]], Optional[str]]
|
||||
is_hydrabase_active: Callable[[], bool]
|
||||
get_metadata_fallback_source: Callable[[], str]
|
||||
get_metadata_fallback_client: Callable[[], Any]
|
||||
get_itunes_client: Callable[[], Any]
|
||||
get_deezer_client: Callable[[], Any]
|
||||
get_discogs_client: Callable[[Optional[str]], Any]
|
||||
run_background_comparison: Callable[..., None]
|
||||
run_async: Callable
|
||||
dev_mode_enabled_provider: Callable[[], bool]
|
||||
|
||||
|
||||
def resolve_client(source_name: str, deps: SearchDeps) -> tuple[Any, bool]:
|
||||
"""Return (client, is_available) for an explicit metadata source request."""
|
||||
if source_name == 'spotify':
|
||||
if deps.spotify_client and deps.spotify_client.is_spotify_authenticated():
|
||||
return deps.spotify_client, True
|
||||
return None, False
|
||||
if source_name == 'itunes':
|
||||
return deps.get_itunes_client(), True
|
||||
if source_name == 'deezer':
|
||||
return deps.get_deezer_client(), True
|
||||
if source_name == 'discogs':
|
||||
token = deps.config_manager.get('discogs.token', '')
|
||||
if not token:
|
||||
return None, False
|
||||
return deps.get_discogs_client(token), True
|
||||
if source_name == 'hydrabase':
|
||||
if deps.hydrabase_client and deps.hydrabase_client.is_connected():
|
||||
return deps.hydrabase_client, True
|
||||
return None, False
|
||||
if source_name == 'musicbrainz':
|
||||
try:
|
||||
from core.musicbrainz_search import MusicBrainzSearchClient
|
||||
return MusicBrainzSearchClient(), True
|
||||
except Exception as e:
|
||||
logger.warning(f"MusicBrainz search client init failed: {e}")
|
||||
return None, False
|
||||
return None, False
|
||||
|
||||
|
||||
def _build_db_artists(query: str, deps: SearchDeps) -> list[dict]:
|
||||
active_server = deps.config_manager.get_active_media_server()
|
||||
artist_objs = deps.database.search_artists(query, limit=5, server_source=active_server)
|
||||
out: list[dict] = []
|
||||
for artist in artist_objs:
|
||||
image_url = None
|
||||
if hasattr(artist, 'thumb_url') and artist.thumb_url:
|
||||
image_url = deps.fix_artist_image_url(artist.thumb_url)
|
||||
out.append({
|
||||
'id': artist.id,
|
||||
'name': artist.name,
|
||||
'image_url': image_url,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _short_query_response(db_artists: list[dict], requested_source: str, deps: SearchDeps) -> dict:
|
||||
"""Skip the remote search for queries shorter than 3 chars."""
|
||||
short_source = requested_source or deps.get_metadata_fallback_source()
|
||||
return {
|
||||
'db_artists': db_artists,
|
||||
'spotify_artists': [],
|
||||
'spotify_albums': [],
|
||||
'spotify_tracks': [],
|
||||
'metadata_source': short_source,
|
||||
'primary_source': short_source,
|
||||
'alternate_sources': [],
|
||||
'sources': {},
|
||||
}
|
||||
|
||||
|
||||
def _single_source_response(
|
||||
query: str,
|
||||
db_artists: list[dict],
|
||||
requested_source: str,
|
||||
deps: SearchDeps,
|
||||
) -> dict:
|
||||
"""Run a single-source search — bypasses the fan-out."""
|
||||
client, available = resolve_client(requested_source, deps)
|
||||
if not client:
|
||||
return {
|
||||
'db_artists': db_artists,
|
||||
'spotify_artists': [],
|
||||
'spotify_albums': [],
|
||||
'spotify_tracks': [],
|
||||
'metadata_source': requested_source,
|
||||
'primary_source': requested_source,
|
||||
'alternate_sources': [],
|
||||
'source_available': False,
|
||||
}
|
||||
|
||||
try:
|
||||
source_results = sources.search_source(query, client, requested_source)
|
||||
except Exception as e:
|
||||
logger.warning(f"Single-source search ({requested_source}) failed: {e}")
|
||||
source_results = {'artists': [], 'albums': [], 'tracks': [], 'available': False}
|
||||
|
||||
logger.info(
|
||||
f"Enhanced search [source={requested_source}] results: "
|
||||
f"{len(db_artists)} DB, {len(source_results['artists'])} artists, "
|
||||
f"{len(source_results['albums'])} albums, {len(source_results['tracks'])} tracks"
|
||||
)
|
||||
|
||||
return {
|
||||
'db_artists': db_artists,
|
||||
'spotify_artists': source_results['artists'],
|
||||
'spotify_albums': source_results['albums'],
|
||||
'spotify_tracks': source_results['tracks'],
|
||||
'metadata_source': requested_source,
|
||||
'primary_source': requested_source,
|
||||
'alternate_sources': [],
|
||||
'source_available': True,
|
||||
}
|
||||
|
||||
|
||||
def _alternate_sources(primary_source: str, deps: SearchDeps) -> list[str]:
|
||||
"""Build the list of alternate sources the frontend should fetch async."""
|
||||
spotify_available = bool(deps.spotify_client and deps.spotify_client.is_spotify_authenticated())
|
||||
hydrabase_available = bool(deps.hydrabase_client and deps.hydrabase_client.is_connected())
|
||||
discogs_available = bool(deps.config_manager.get('discogs.token', ''))
|
||||
|
||||
alts: list[str] = []
|
||||
if primary_source != 'spotify' and spotify_available:
|
||||
alts.append('spotify')
|
||||
if primary_source != 'itunes':
|
||||
alts.append('itunes')
|
||||
if primary_source != 'deezer':
|
||||
alts.append('deezer')
|
||||
if primary_source != 'discogs' and discogs_available:
|
||||
alts.append('discogs')
|
||||
if primary_source != 'hydrabase' and hydrabase_available:
|
||||
alts.append('hydrabase')
|
||||
alts.append('youtube_videos') # always available (yt-dlp, no auth)
|
||||
alts.append('musicbrainz') # always available (public API)
|
||||
return alts
|
||||
|
||||
|
||||
def _fan_out_response(query: str, db_artists: list[dict], deps: SearchDeps) -> dict:
|
||||
"""Default flow: pick a primary source, run it, list alternates."""
|
||||
# Per-request empty marker — used for identity check at the spotify-fallback
|
||||
# gate below. Local (not module-level) so a future caller can't accidentally
|
||||
# mutate it across requests.
|
||||
empty_source = {"artists": [], "albums": [], "tracks": [], "available": False}
|
||||
|
||||
primary_source = 'spotify'
|
||||
primary_results = empty_source
|
||||
|
||||
if deps.is_hydrabase_active():
|
||||
primary_source = 'hydrabase'
|
||||
try:
|
||||
primary_results = sources.search_source(query, deps.hydrabase_client)
|
||||
deps.run_background_comparison(query, hydrabase_counts={
|
||||
'tracks': len(primary_results['tracks']),
|
||||
'artists': len(primary_results['artists']),
|
||||
'albums': len(primary_results['albums']),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Hydrabase search failed: {e}")
|
||||
primary_source = 'spotify'
|
||||
primary_results = empty_source
|
||||
|
||||
if primary_source != 'hydrabase':
|
||||
if deps.hydrabase_worker and deps.dev_mode_enabled_provider():
|
||||
deps.hydrabase_worker.enqueue(query, 'tracks')
|
||||
deps.hydrabase_worker.enqueue(query, 'albums')
|
||||
deps.hydrabase_worker.enqueue(query, 'artists')
|
||||
|
||||
fb_source = deps.get_metadata_fallback_source()
|
||||
try:
|
||||
primary_results = sources.search_source(query, deps.get_metadata_fallback_client(), fb_source)
|
||||
primary_source = fb_source
|
||||
except Exception as e:
|
||||
logger.debug(f"Primary source ({fb_source}) search failed: {e}")
|
||||
|
||||
if primary_results is empty_source and fb_source != 'spotify':
|
||||
if deps.spotify_client and deps.spotify_client.is_spotify_authenticated():
|
||||
try:
|
||||
primary_results = sources.search_source(query, deps.spotify_client, 'spotify')
|
||||
primary_source = 'spotify'
|
||||
except Exception as e:
|
||||
logger.debug(f"Spotify fallback search failed: {e}")
|
||||
|
||||
alternate_sources = _alternate_sources(primary_source, deps)
|
||||
|
||||
logger.info(
|
||||
f"Enhanced search results ({primary_source}): {len(db_artists)} DB artists, "
|
||||
f"{len(primary_results['artists'])} artists, "
|
||||
f"{len(primary_results['albums'])} albums, "
|
||||
f"{len(primary_results['tracks'])} tracks | "
|
||||
f"Alt sources available: {alternate_sources}"
|
||||
)
|
||||
|
||||
return {
|
||||
'db_artists': db_artists,
|
||||
'spotify_artists': primary_results['artists'],
|
||||
'spotify_albums': primary_results['albums'],
|
||||
'spotify_tracks': primary_results['tracks'],
|
||||
'metadata_source': primary_source,
|
||||
'primary_source': primary_source,
|
||||
'alternate_sources': alternate_sources,
|
||||
}
|
||||
|
||||
|
||||
def empty_response() -> dict:
|
||||
"""Response shape for an empty query — preserves the legacy spotify-default keys."""
|
||||
return {
|
||||
'db_artists': [],
|
||||
'spotify_artists': [],
|
||||
'spotify_albums': [],
|
||||
'spotify_tracks': [],
|
||||
'sources': {},
|
||||
'primary_source': 'spotify',
|
||||
'metadata_source': 'spotify',
|
||||
}
|
||||
|
||||
|
||||
def run_enhanced_search(query: str, requested_source: str, deps: SearchDeps) -> dict:
|
||||
"""Main flow: build db_artists, then dispatch to the right strategy.
|
||||
|
||||
Caller is responsible for cache lookup / store and request shape; this
|
||||
function returns a plain dict.
|
||||
"""
|
||||
db_artists = _build_db_artists(query, deps)
|
||||
|
||||
if len(query) < 3:
|
||||
return _short_query_response(db_artists, requested_source, deps)
|
||||
|
||||
if requested_source:
|
||||
return _single_source_response(query, db_artists, requested_source, deps)
|
||||
|
||||
return _fan_out_response(query, db_artists, deps)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NDJSON streaming for /api/enhanced-search/source/<src>
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def resolve_youtube_videos_client(deps: SearchDeps):
|
||||
"""Return the soulseek_client.youtube subclient or None when unavailable."""
|
||||
if not deps.soulseek_client:
|
||||
return None
|
||||
return getattr(deps.soulseek_client, 'youtube', None)
|
||||
|
||||
|
||||
def stream_youtube_videos(query: str, youtube_client, run_async: Callable) -> Iterator[str]:
|
||||
"""yt-dlp video search generator — yields one videos chunk + done marker.
|
||||
|
||||
Caller is responsible for verifying youtube_client is not None.
|
||||
"""
|
||||
try:
|
||||
video_query = f"{query} official music video"
|
||||
results = run_async(youtube_client.search_videos(video_query, max_results=20))
|
||||
videos = []
|
||||
for v in (results or []):
|
||||
videos.append({
|
||||
'video_id': v.video_id,
|
||||
'title': v.title,
|
||||
'channel': v.channel,
|
||||
'duration': v.duration,
|
||||
'thumbnail': v.thumbnail,
|
||||
'url': v.url,
|
||||
'view_count': v.view_count,
|
||||
'upload_date': v.upload_date,
|
||||
})
|
||||
yield json.dumps({'type': 'videos', 'data': videos}) + '\n'
|
||||
except Exception as e:
|
||||
logger.error(f"YouTube music video search failed: {e}")
|
||||
yield json.dumps({'type': 'videos', 'data': []}) + '\n'
|
||||
yield json.dumps({'type': 'done'}) + '\n'
|
||||
|
||||
|
||||
def stream_metadata_source(source_name: str, query: str, client) -> Iterator[str]:
|
||||
"""Fan three search-kinds out and yield each as it lands.
|
||||
|
||||
Caller is responsible for resolving and validating the client.
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
with ThreadPoolExecutor(max_workers=3) as executor:
|
||||
futures = {
|
||||
executor.submit(sources.search_kind, client, query, 'artists', source_name): 'artists',
|
||||
executor.submit(sources.search_kind, client, query, 'albums', source_name): 'albums',
|
||||
executor.submit(sources.search_kind, client, query, 'tracks', source_name): 'tracks',
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
kind = futures[future]
|
||||
try:
|
||||
payload = future.result()
|
||||
except Exception as e:
|
||||
logger.warning(f"{kind.title()} search failed for {source_name}: {e}", exc_info=True)
|
||||
payload = []
|
||||
yield json.dumps({'type': kind, 'data': payload}) + '\n'
|
||||
|
||||
yield json.dumps({'type': 'done'}) + '\n'
|
||||
113
core/search/sources.py
Normal file
113
core/search/sources.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""Per-source metadata search.
|
||||
|
||||
Two public functions:
|
||||
|
||||
- `search_kind(client, query, kind, source_name=None)` — search a single
|
||||
result type (artists | albums | tracks) on one client and normalize the
|
||||
result to a list of plain dicts.
|
||||
|
||||
- `search_source(query, client, source_name=None)` — fan three
|
||||
search_kind calls out across a thread pool and return the merged dict.
|
||||
|
||||
Both swallow per-kind exceptions — search reliability matters more than
|
||||
strict error propagation, and the route layer cannot do anything useful
|
||||
with a single-kind failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def search_kind(client, query: str, kind: str, source_name: Optional[str] = None) -> list:
|
||||
"""Search one result type from a metadata source and normalize it."""
|
||||
source_label = source_name or type(client).__name__
|
||||
|
||||
if kind == "artists":
|
||||
artists = []
|
||||
try:
|
||||
artist_objs = client.search_artists(query, limit=10)
|
||||
for artist in artist_objs:
|
||||
artists.append({
|
||||
"id": artist.id,
|
||||
"name": artist.name,
|
||||
"image_url": artist.image_url,
|
||||
"external_urls": artist.external_urls or {},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"Artist search failed for {source_label}: {e}")
|
||||
return artists
|
||||
|
||||
if kind == "albums":
|
||||
albums = []
|
||||
try:
|
||||
album_objs = client.search_albums(query, limit=10)
|
||||
for album in album_objs:
|
||||
artist_name = ', '.join(album.artists) if album.artists else 'Unknown Artist'
|
||||
albums.append({
|
||||
"id": album.id,
|
||||
"name": album.name,
|
||||
"artist": artist_name,
|
||||
"image_url": album.image_url,
|
||||
"release_date": album.release_date,
|
||||
"total_tracks": album.total_tracks,
|
||||
"album_type": album.album_type,
|
||||
"external_urls": album.external_urls or {},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Album search failed for {source_label}: {e}", exc_info=True)
|
||||
return albums
|
||||
|
||||
if kind == "tracks":
|
||||
tracks = []
|
||||
try:
|
||||
track_objs = client.search_tracks(query, limit=10)
|
||||
for track in track_objs:
|
||||
artist_name = ', '.join(track.artists) if track.artists else 'Unknown Artist'
|
||||
tracks.append({
|
||||
"id": track.id,
|
||||
"name": track.name,
|
||||
"artist": artist_name,
|
||||
"album": track.album,
|
||||
"duration_ms": track.duration_ms,
|
||||
"image_url": track.image_url,
|
||||
"release_date": track.release_date,
|
||||
"external_urls": track.external_urls or {},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Track search failed for {source_label}: {e}", exc_info=True)
|
||||
return tracks
|
||||
|
||||
raise ValueError(f"Unknown metadata search kind: {kind}")
|
||||
|
||||
|
||||
def search_source(query: str, client, source_name: Optional[str] = None) -> dict:
|
||||
"""Run all three search-kinds against a single client in parallel."""
|
||||
results: dict[str, Any] = {"artists": [], "albums": [], "tracks": []}
|
||||
with ThreadPoolExecutor(max_workers=3) as executor:
|
||||
futures = {
|
||||
executor.submit(search_kind, client, query, "artists", source_name): "artists",
|
||||
executor.submit(search_kind, client, query, "albums", source_name): "albums",
|
||||
executor.submit(search_kind, client, query, "tracks", source_name): "tracks",
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
kind = futures[future]
|
||||
try:
|
||||
results[kind] = future.result()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"{kind.title()} search failed for {source_name or type(client).__name__}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
results[kind] = []
|
||||
|
||||
return {
|
||||
"artists": results["artists"],
|
||||
"albums": results["albums"],
|
||||
"tracks": results["tracks"],
|
||||
"available": True,
|
||||
}
|
||||
160
core/search/stream.py
Normal file
160
core/search/stream.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""Single-track stream search — finds the best Soulseek result for a track
|
||||
play preview.
|
||||
|
||||
Builds a small ordered list of search query variants (artist+title,
|
||||
artist+cleaned title; or title-only when the stream source is Soulseek
|
||||
itself) and walks them until one returns a usable match through the
|
||||
matching engine.
|
||||
|
||||
Stream source resolution:
|
||||
- If `download_source.stream_source` is "youtube" (default), use the
|
||||
YouTube downloader for previews — instant, no auth pressure on the
|
||||
download stack.
|
||||
- If it's "active", mirror the user's download mode (tidal / qobuz /
|
||||
hifi / deezer_dl / lidarr) — but coerce Soulseek to YouTube because
|
||||
Soulseek is too slow for streaming previews.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_effective_stream_mode(config_manager) -> str:
|
||||
"""Pick the streaming source based on settings."""
|
||||
stream_source = config_manager.get('download_source.stream_source', 'youtube')
|
||||
download_mode = config_manager.get('download_source.mode', 'hybrid')
|
||||
|
||||
if stream_source == 'youtube':
|
||||
return 'youtube'
|
||||
|
||||
hybrid_order = config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek'])
|
||||
hybrid_first = hybrid_order[0] if hybrid_order else config_manager.get('download_source.hybrid_primary', 'hifi')
|
||||
|
||||
if download_mode == 'soulseek' or (download_mode == 'hybrid' and hybrid_first == 'soulseek'):
|
||||
logger.info("Stream source is 'active' but primary is Soulseek — falling back to YouTube")
|
||||
return 'youtube'
|
||||
if download_mode == 'hybrid':
|
||||
return hybrid_first
|
||||
return download_mode
|
||||
|
||||
|
||||
def _build_stream_queries(track_name: str, artist_name: str, effective_mode: str) -> list[str]:
|
||||
"""Build an ordered, deduped list of search queries to try."""
|
||||
queries: list[str] = []
|
||||
|
||||
is_streaming_source = effective_mode in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr')
|
||||
|
||||
if is_streaming_source:
|
||||
if artist_name and track_name:
|
||||
queries.append(f"{artist_name} {track_name}".strip())
|
||||
|
||||
cleaned_name = re.sub(r'\s*\([^)]*\)', '', track_name).strip()
|
||||
cleaned_name = re.sub(r'\s*\[[^\]]*\]', '', cleaned_name).strip()
|
||||
if cleaned_name and cleaned_name.lower() != track_name.lower():
|
||||
queries.append(f"{artist_name} {cleaned_name}".strip())
|
||||
else:
|
||||
if track_name.strip():
|
||||
queries.append(track_name.strip())
|
||||
cleaned_name = re.sub(r'\s*\([^)]*\)', '', track_name).strip()
|
||||
cleaned_name = re.sub(r'\s*\[[^\]]*\]', '', cleaned_name).strip()
|
||||
if cleaned_name and cleaned_name.lower() != track_name.lower():
|
||||
queries.append(cleaned_name.strip())
|
||||
|
||||
seen: set[str] = set()
|
||||
deduped: list[str] = []
|
||||
for q in queries:
|
||||
if q and q.lower() not in seen:
|
||||
deduped.append(q)
|
||||
seen.add(q.lower())
|
||||
return deduped
|
||||
|
||||
|
||||
def _result_to_dict(best_result) -> dict:
|
||||
return {
|
||||
"username": best_result.username,
|
||||
"filename": best_result.filename,
|
||||
"size": best_result.size,
|
||||
"bitrate": best_result.bitrate,
|
||||
"duration": best_result.duration,
|
||||
"quality": best_result.quality,
|
||||
"free_upload_slots": best_result.free_upload_slots,
|
||||
"upload_speed": best_result.upload_speed,
|
||||
"queue_length": best_result.queue_length,
|
||||
"result_type": "track",
|
||||
}
|
||||
|
||||
|
||||
def stream_search_track(
|
||||
*,
|
||||
track_name: str,
|
||||
artist_name: str,
|
||||
album_name: Optional[str],
|
||||
duration_ms: int,
|
||||
config_manager,
|
||||
soulseek_client,
|
||||
matching_engine,
|
||||
run_async: Callable,
|
||||
) -> Optional[dict]:
|
||||
"""Find the best Soulseek/stream-source result for a single track.
|
||||
|
||||
Returns the matched result dict on success, or `None` if no query
|
||||
variant produced a usable match. The route layer turns `None` into a
|
||||
404 response.
|
||||
"""
|
||||
temp_track = type('TempTrack', (), {
|
||||
'name': track_name,
|
||||
'artists': [artist_name],
|
||||
'album': album_name if album_name else None,
|
||||
'duration_ms': duration_ms,
|
||||
})()
|
||||
|
||||
effective_mode = _resolve_effective_stream_mode(config_manager)
|
||||
logger.info(f"Stream source effective mode: {effective_mode}")
|
||||
|
||||
queries = _build_stream_queries(track_name, artist_name, effective_mode)
|
||||
|
||||
stream_clients = {
|
||||
'youtube': soulseek_client.youtube,
|
||||
'tidal': soulseek_client.tidal,
|
||||
'qobuz': soulseek_client.qobuz,
|
||||
'hifi': soulseek_client.hifi,
|
||||
'deezer_dl': soulseek_client.deezer_dl,
|
||||
'lidarr': soulseek_client.lidarr,
|
||||
}
|
||||
stream_client = stream_clients.get(effective_mode)
|
||||
use_direct_client = stream_client is not None
|
||||
|
||||
max_peer_queue = config_manager.get('soulseek.max_peer_queue', 0) or 0
|
||||
|
||||
for query_index, query in enumerate(queries):
|
||||
logger.info(f"Stream query {query_index + 1}/{len(queries)}: '{query}'")
|
||||
try:
|
||||
if use_direct_client:
|
||||
tracks_result, _ = run_async(stream_client.search(query, timeout=15))
|
||||
else:
|
||||
tracks_result, _ = run_async(soulseek_client.search(query, timeout=15))
|
||||
|
||||
if not tracks_result:
|
||||
logger.info(f"No results for query '{query}', trying next...")
|
||||
continue
|
||||
|
||||
best_matches = matching_engine.find_best_slskd_matches_enhanced(
|
||||
temp_track, tracks_result, max_peer_queue=max_peer_queue
|
||||
)
|
||||
if best_matches:
|
||||
best = best_matches[0]
|
||||
logger.info(f"Stream match for '{query}': {best.filename} ({best.quality})")
|
||||
return _result_to_dict(best)
|
||||
|
||||
logger.info(f"No suitable matches for query '{query}', trying next...")
|
||||
except Exception as e:
|
||||
logger.warning(f"Stream search failed for query '{query}': {e}")
|
||||
continue
|
||||
|
||||
logger.warning(f"No stream match found after {len(queries)} queries")
|
||||
return None
|
||||
256
core/socketio_cors.py
Normal file
256
core/socketio_cors.py
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
"""Socket.IO CORS allow-list resolution + rejection logging.
|
||||
|
||||
Three concerns lifted out of `web_server.py`:
|
||||
|
||||
- :func:`resolve_cors_origins` — read the user's
|
||||
``security.cors_origins`` config setting (string, list, or unset) and
|
||||
return what to hand to Flask-SocketIO's ``cors_allowed_origins``
|
||||
parameter: ``None`` (engineio same-origin default — the secure
|
||||
default), the literal ``'*'`` (wildcard, opt-in), or a list of
|
||||
explicit origin URLs.
|
||||
|
||||
- :func:`will_reject` — predict whether engineio's CORS check will
|
||||
reject a request, given the resolved allow-list, the request's
|
||||
``Origin`` header, and the request's ``Host`` header. Used to log a
|
||||
helpful warning *before* engineio silently 403s a WebSocket upgrade.
|
||||
(Without this, the user just sees a half-broken UI with no live
|
||||
updates and nothing in the logs explaining why.)
|
||||
|
||||
- :class:`RejectionLogger` — threadsafe dedup wrapper around the warning
|
||||
emitter. Each unique origin is logged once per process so a malicious
|
||||
site repeatedly hammering the WS endpoint can't spam logs.
|
||||
|
||||
Pure logic, no Flask app dependency. Web_server.py imports these and
|
||||
wires them into the SocketIO init + a Flask ``before_request`` hook.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any, List, Optional, Set, Union
|
||||
|
||||
|
||||
# What ``cors_allowed_origins`` accepts and what we hand to Flask-SocketIO:
|
||||
#
|
||||
# - ``None`` → engineio's same-origin default. engineio computes the
|
||||
# allowed origin list from the request itself: ``scheme://HTTP_HOST``
|
||||
# plus ``X-Forwarded-Proto://X-Forwarded-Host`` when those headers are
|
||||
# present. Reverse proxies that set X-Forwarded-Host (Nginx with
|
||||
# ``proxy_set_header X-Forwarded-Host`` — and Caddy/Traefik by default)
|
||||
# work transparently. THE SECURE DEFAULT.
|
||||
#
|
||||
# - ``'*'`` → allow any origin. Insecure; opt-in only.
|
||||
#
|
||||
# - ``[origin, ...]`` → explicit allow-list. For setups whose Origin
|
||||
# matches neither the backend's Host nor any forwarded header.
|
||||
#
|
||||
# IMPORTANT: do NOT use ``[]``. In engineio that means "disable CORS
|
||||
# handling entirely" (server.py:202: ``if cors_allowed_origins != []:``)
|
||||
# which is identical to the ``'*'`` wildcard from a security standpoint.
|
||||
ResolvedOrigins = Union[List[str], str, None]
|
||||
|
||||
|
||||
def resolve_cors_origins(config_manager: Any) -> ResolvedOrigins:
|
||||
"""Resolve the configured Socket.IO allow-list.
|
||||
|
||||
Reads ``security.cors_origins`` from ``config_manager`` and normalizes
|
||||
whatever shape the user typed (or didn't) into one of three values:
|
||||
|
||||
- ``None`` (the secure default). Hand to Flask-SocketIO and engineio
|
||||
enforces same-origin, with automatic support for X-Forwarded-Host
|
||||
so reverse-proxy users don't need to configure anything.
|
||||
- ``'*'`` — literal wildcard. Allows any origin. Insecure; opt-in.
|
||||
- ``[origin, ...]`` — list of explicit origin URLs. For users behind
|
||||
a proxy that doesn't send the forwarded headers OR for custom
|
||||
contexts (Electron wrappers, browser extensions).
|
||||
|
||||
Accepts the config value as either a string (comma OR newline
|
||||
separated, since the settings UI is a textarea) or a list. Anything
|
||||
else falls back to ``None`` — the secure default.
|
||||
"""
|
||||
raw = config_manager.get('security.cors_origins', None) if config_manager else None
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, str):
|
||||
if not raw.strip():
|
||||
return None
|
||||
parts = [p.strip() for p in raw.replace('\n', ',').split(',')]
|
||||
elif isinstance(raw, (list, tuple)):
|
||||
# Drop non-string entries instead of stringifying — `[None]` would
|
||||
# otherwise coerce to ``['None']`` and become a junk allow-list entry.
|
||||
parts = [p.strip() for p in raw if isinstance(p, str)]
|
||||
else:
|
||||
return None
|
||||
parts = [p for p in parts if p]
|
||||
if not parts:
|
||||
return None
|
||||
if any(p == '*' for p in parts):
|
||||
return '*'
|
||||
return parts
|
||||
|
||||
|
||||
def will_reject(
|
||||
allowed: ResolvedOrigins,
|
||||
origin: Optional[str],
|
||||
host: str,
|
||||
request_scheme: str = '',
|
||||
forwarded_host: str = '',
|
||||
forwarded_proto: str = '',
|
||||
) -> bool:
|
||||
"""Predict whether engineio's CORS check will reject this request.
|
||||
|
||||
Mirrors engineio's allow-list / same-origin logic so callers can log
|
||||
a helpful warning *before* the rejection happens. Returns ``True``
|
||||
when the request will be rejected.
|
||||
|
||||
Same-origin check: engineio builds full ``{scheme}://{host}`` strings
|
||||
from the request URL — and adds a second candidate from the
|
||||
forwarded headers when EITHER ``X-Forwarded-Proto`` OR
|
||||
``X-Forwarded-Host`` is present (engineio falls back to the request
|
||||
Host / scheme for whichever forwarded header is missing). We mirror
|
||||
that exactly. Comparing scheme matters: a TLS-terminating proxy can
|
||||
leave the backend seeing ``http://soulsync.foo`` while the browser's
|
||||
Origin is ``https://soulsync.foo`` — engineio treats those as
|
||||
different strings and rejects, so we should too.
|
||||
|
||||
Defensive against ``None`` / empty origin: returns ``False`` (allow),
|
||||
matching engineio's actual behavior (server.py:207: ``if origin:``
|
||||
skips the validation block entirely when no Origin header is sent).
|
||||
Browsers always send Origin for WebSocket upgrades, so this only
|
||||
matters for non-browser clients like ``curl`` — which engineio
|
||||
intentionally permits.
|
||||
|
||||
``request_scheme`` is required for an accurate same-origin match —
|
||||
engineio compares full ``{scheme}://{host}`` strings, so callers
|
||||
that omit it default to ``'http'``. Production wires Flask's
|
||||
``request.scheme`` here, which WSGI guarantees to be non-empty.
|
||||
"""
|
||||
if allowed == '*':
|
||||
return False
|
||||
if not origin:
|
||||
return False # Engineio skips CORS validation when no Origin header
|
||||
if isinstance(allowed, list) and origin in allowed:
|
||||
return False
|
||||
|
||||
# Engineio's same-origin check builds full {scheme}://{host} strings.
|
||||
# Build the candidate set from the request + any forwarded headers.
|
||||
candidates = []
|
||||
if host:
|
||||
scheme = request_scheme or 'http'
|
||||
candidates.append(f"{scheme}://{host}")
|
||||
if forwarded_host or forwarded_proto:
|
||||
# Mirror engineio: when EITHER forwarded header is present, build
|
||||
# a candidate from both, falling back to the request value for
|
||||
# whichever is missing. (engineio/base_server.py:_cors_allowed_origins.)
|
||||
f_host = forwarded_host.split(',')[0].strip() if forwarded_host else host
|
||||
if f_host:
|
||||
f_scheme = (forwarded_proto.split(',')[0].strip()
|
||||
if forwarded_proto
|
||||
else (request_scheme or 'http'))
|
||||
candidates.append(f"{f_scheme}://{f_host}")
|
||||
return origin not in candidates
|
||||
|
||||
|
||||
class RejectionLogger:
|
||||
"""Threadsafe dedup wrapper that logs each rejected origin only once.
|
||||
|
||||
Engineio silently 403s WebSocket upgrades from disallowed origins.
|
||||
Without a log line the user sees a half-broken UI (no live progress,
|
||||
no toasts) and has no idea what's wrong. This class watches incoming
|
||||
requests via :meth:`maybe_log` and emits a clear warning the first
|
||||
time each unique origin appears, telling the user where to add it.
|
||||
|
||||
The dedup set is capped (default 100 unique origins) so a hostile
|
||||
actor opening connections from many distinct fake origins can't grow
|
||||
memory unbounded. When the cap is hit, a single overflow warning is
|
||||
emitted and further rejections are silently dropped until the next
|
||||
process restart (or :meth:`reset_for_tests` for tests).
|
||||
"""
|
||||
|
||||
DEFAULT_DEDUP_CAP = 100
|
||||
|
||||
def __init__(self, logger: Any, dedup_cap: int = DEFAULT_DEDUP_CAP):
|
||||
self._logger = logger
|
||||
self._seen: Set[str] = set()
|
||||
self._lock = threading.Lock()
|
||||
try:
|
||||
self._cap = max(1, int(dedup_cap))
|
||||
except (TypeError, ValueError):
|
||||
self._cap = self.DEFAULT_DEDUP_CAP
|
||||
self._overflow_warned = False
|
||||
|
||||
def maybe_log(
|
||||
self,
|
||||
allowed: ResolvedOrigins,
|
||||
origin: Optional[str],
|
||||
host: str,
|
||||
request_scheme: str = '',
|
||||
forwarded_host: str = '',
|
||||
forwarded_proto: str = '',
|
||||
) -> bool:
|
||||
"""Log a rejection warning if applicable, deduped.
|
||||
|
||||
Returns ``True`` if a warning was emitted this call. Designed to
|
||||
be safe to call from a Flask ``before_request`` hook on every
|
||||
Socket.IO request — it short-circuits early on requests that
|
||||
won't be rejected (no Origin header, allowed origin, same-origin
|
||||
match against Host / X-Forwarded-Host with proper scheme).
|
||||
"""
|
||||
if not will_reject(allowed, origin, host, request_scheme,
|
||||
forwarded_host, forwarded_proto):
|
||||
return False
|
||||
|
||||
# Pick the message to emit (or bail) under the lock. Actual
|
||||
# logger.warning() call happens AFTER the lock releases — keeps
|
||||
# the critical section minimal and avoids holding our lock while
|
||||
# the logging framework acquires its own internal locks.
|
||||
msg: Optional[str] = None
|
||||
with self._lock:
|
||||
if origin in self._seen:
|
||||
return False
|
||||
if len(self._seen) >= self._cap:
|
||||
if self._overflow_warned:
|
||||
return False # Already emitted overflow notice; suppress.
|
||||
self._overflow_warned = True
|
||||
msg = (
|
||||
f"[Socket.IO] Rejection-log dedup cache hit cap "
|
||||
f"({self._cap} unique origins). Suppressing further "
|
||||
f"rejection warnings this session — likely indicates "
|
||||
f"hostile traffic or a misconfigured client. Restart "
|
||||
f"to reset the cache."
|
||||
)
|
||||
else:
|
||||
self._seen.add(origin)
|
||||
msg = (
|
||||
f"[Socket.IO] Rejecting WebSocket connection from origin "
|
||||
f"'{origin}' (request Host='{host}'). If this is your "
|
||||
f"reverse-proxy or custom domain, add it to "
|
||||
f"Settings → Security → Allowed WebSocket Origins."
|
||||
)
|
||||
self._logger.warning(msg)
|
||||
return True
|
||||
|
||||
def reset_for_tests(self) -> None:
|
||||
"""Clear the dedup cache. Test-only."""
|
||||
with self._lock:
|
||||
self._seen.clear()
|
||||
self._overflow_warned = False
|
||||
|
||||
|
||||
def log_startup_status(allowed: ResolvedOrigins, logger: Any) -> None:
|
||||
"""Emit a one-shot startup log line describing the resolved policy.
|
||||
|
||||
- For ``'*'`` (wildcard) → warning, since it's a security risk.
|
||||
- For a non-empty list → info, so the user can confirm their config
|
||||
took effect.
|
||||
- For ``None`` (same-origin default) → silent. That's the default;
|
||||
nothing noteworthy.
|
||||
"""
|
||||
if allowed == '*':
|
||||
logger.warning(
|
||||
"[Socket.IO] cors_allowed_origins is set to '*' — any website can open "
|
||||
"a WebSocket to this instance. Set Settings → Security → Allowed Origins "
|
||||
"to a specific list (or leave empty for same-origin only) to lock this down."
|
||||
)
|
||||
elif allowed:
|
||||
logger.info(f"[Socket.IO] Allowed cross-origin connections from: {allowed}")
|
||||
|
|
@ -8,6 +8,7 @@ import time
|
|||
from pathlib import Path
|
||||
from utils.logging_config import get_logger
|
||||
from config.settings import config_manager
|
||||
from core.imports.filename import parse_filename_metadata
|
||||
|
||||
logger = get_logger("soulseek_client")
|
||||
|
||||
|
|
@ -87,60 +88,17 @@ class TrackResult(SearchResult):
|
|||
|
||||
def _parse_filename_metadata(self):
|
||||
"""Extract artist, title, album from filename patterns"""
|
||||
import re
|
||||
import os
|
||||
|
||||
# Get just the filename without extension and path
|
||||
base_name = os.path.splitext(os.path.basename(self.filename))[0]
|
||||
|
||||
# Common patterns for track naming
|
||||
patterns = [
|
||||
r'^(\d+)\s*[-\.]\s*(.+?)\s*[-–]\s*(.+)$', # "01 - Artist - Title" or "01. Artist - Title"
|
||||
r'^(.+?)\s*[-–]\s*(.+)$', # "Artist - Title"
|
||||
r'^(\d+)\s*[-\.]\s*(.+)$', # "01 - Title" or "01. Title"
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.match(pattern, base_name)
|
||||
if match:
|
||||
groups = match.groups()
|
||||
if len(groups) == 3: # Track number, artist, title
|
||||
try:
|
||||
self.track_number = int(groups[0])
|
||||
self.artist = self.artist or groups[1].strip()
|
||||
self.title = self.title or groups[2].strip()
|
||||
except ValueError:
|
||||
# First group might not be a number
|
||||
self.artist = self.artist or groups[0].strip()
|
||||
self.title = self.title or f"{groups[1]} - {groups[2]}".strip()
|
||||
elif len(groups) == 2:
|
||||
if groups[0].isdigit(): # Track number and title
|
||||
try:
|
||||
self.track_number = int(groups[0])
|
||||
self.title = self.title or groups[1].strip()
|
||||
except ValueError:
|
||||
pass
|
||||
else: # Artist and title
|
||||
self.artist = self.artist or groups[0].strip()
|
||||
self.title = self.title or groups[1].strip()
|
||||
break
|
||||
|
||||
# Fallback: use filename as title if nothing was extracted
|
||||
if not self.title:
|
||||
self.title = base_name
|
||||
|
||||
# Try to extract album from directory path
|
||||
if not self.album and '/' in self.filename:
|
||||
path_parts = self.filename.split('/')
|
||||
if len(path_parts) >= 2:
|
||||
# Look for album-like directory names
|
||||
for part in reversed(path_parts[:-1]): # Exclude filename
|
||||
if part and not part.startswith('@'): # Skip system directories
|
||||
# Clean up common patterns
|
||||
cleaned = re.sub(r'^\d+\s*[-\.]\s*', '', part) # Remove leading numbers
|
||||
if len(cleaned) > 3: # Must be substantial
|
||||
self.album = cleaned
|
||||
break
|
||||
parsed = parse_filename_metadata(self.filename)
|
||||
if not self.artist and parsed.get("artist"):
|
||||
self.artist = parsed["artist"]
|
||||
if not self.title and parsed.get("title"):
|
||||
self.title = parsed["title"]
|
||||
if not self.album and parsed.get("album"):
|
||||
self.album = parsed["album"]
|
||||
if self.track_number is None:
|
||||
track_number = parsed.get("track_number")
|
||||
if track_number is not None:
|
||||
self.track_number = track_number
|
||||
|
||||
@dataclass
|
||||
class AlbumResult:
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from functools import wraps
|
|||
from dataclasses import dataclass
|
||||
from utils.logging_config import get_logger
|
||||
from config.settings import config_manager
|
||||
from core.metadata_cache import get_metadata_cache
|
||||
from core.metadata.cache import get_metadata_cache
|
||||
|
||||
logger = get_logger("spotify_client")
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue