Merge pull request #463 from Nezreka/dev

Dev
This commit is contained in:
BoulderBadgeDad 2026-05-01 15:19:34 -07:00 committed by GitHub
commit f7167619e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
253 changed files with 54966 additions and 26389 deletions

View file

@ -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:

View file

@ -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))

View file

@ -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")),

View file

@ -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()

View file

@ -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

View file

@ -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}")

View file

@ -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
View file

313
core/artists/liked_match.py Normal file
View 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
View 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
View 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

View 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
View 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
View 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
View 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}")

View 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)

View file

@ -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
View 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
View 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
View 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)

View file

@ -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")

View file

@ -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

View file

@ -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

View file

388
core/discovery/beatport.py Normal file
View 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
View 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
View 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

View 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
View 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')

View 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
View 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

View 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
View 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
View 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
View 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')

View file

@ -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

View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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}")

View 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
View 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
View 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(),
}

View 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}")

View 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

View 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}

View file

@ -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
View file

@ -0,0 +1 @@
"""Import flow helpers package."""

478
core/imports/album.py Normal file
View 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,
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)

View 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
View 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

View file

@ -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
View file

View 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
View 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
View 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)
})

View 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
View 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",
]

View 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,
},
}

View 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
View 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
View 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
View 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,
}

View 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
View 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
View 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
View 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
View 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
View 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

View 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
View 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

View file

363
core/playlists/explorer.py Normal file
View 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

View file

@ -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

View file

@ -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
View 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
View 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
View 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
View 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()

View 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
View 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
View 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
View 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
View 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}")

View file

@ -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:

View file

@ -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