Merge branch 'dev' into telegram_thread_id

This commit is contained in:
Skowll 2026-05-24 12:41:35 +02:00 committed by GitHub
commit 8450645c52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
290 changed files with 49298 additions and 6288 deletions

View file

@ -9,9 +9,9 @@ on:
workflow_dispatch:
inputs:
version_tag:
description: 'Version tag (e.g. 2.5.2)'
description: 'Version tag (e.g. 2.6.0)'
required: true
default: '2.5.2'
default: '2.6.0'
jobs:
build-and-push:

1
.gitignore vendored
View file

@ -17,6 +17,7 @@ database/music_library.db-shm
database/music_library.db-wal
database/music_library.db.backup_*
database/api_call_history.json
storage/image_cache/
logs/*.log
logs/*.log.*

View file

@ -73,8 +73,14 @@ COPY --chown=soulsync:soulsync --from=webui-builder /app/webui/static/dist /app/
# pre-baked directory would crash the container into a restart loop on
# rootless Docker/Podman where in-container "root" can't write to /app.
# Pre-baking the dir here makes the entrypoint mkdir a guaranteed no-op.
RUN mkdir -p /app/config /app/data /app/logs /app/downloads /app/Transfer /app/Staging /app/MusicVideos /app/scripts && \
chown soulsync:soulsync /app/config /app/data /app/logs /app/downloads /app/Transfer /app/Staging /app/MusicVideos /app/scripts
# NOTE: /app/Stream is the transient single-file streaming cache used by
# the basic-search "Play" flow (cleared per use, never persistent). It's
# created lazily by `core/streaming/prepare.py` via `os.makedirs`, which
# fails silently on rootless Docker where the soulsync UID can't write
# to /app — playback then errors out with no obvious cause. Pre-baking
# at build time (when the layer is owned by root) avoids that path.
RUN mkdir -p /app/config /app/data /app/logs /app/downloads /app/Transfer /app/Staging /app/Stream /app/MusicVideos /app/scripts && \
chown soulsync:soulsync /app/config /app/data /app/logs /app/downloads /app/Transfer /app/Staging /app/Stream /app/MusicVideos /app/scripts
# Create defaults directory and copy template files
# These will be used by entrypoint.sh to initialize empty volumes

View file

@ -8,7 +8,7 @@
> **IMPORTANT**: Configure file sharing in slskd to avoid Soulseek bans. Set up shared folders at `http://localhost:5030/shares`.
**Community**: [Discord](https://discord.gg/wGvKqVQwmy) | [Reddit](https://old.reddit.com/r/ssync/) | **Website**: [ssync.net](https://www.ssync.net/) | **Support**: [GitHub Issues](https://github.com/Nezreka/SoulSync/issues) | **Donate**: [Ko-fi](https://ko-fi.com/boulderbadgedad)
**Community**: [Discord](https://discord.gg/wGvKqVQwmy) | **Website**: [ssync.net](https://www.ssync.net/) | **Support**: [GitHub Issues](https://github.com/Nezreka/SoulSync/issues) | **Donate**: [Ko-fi](https://ko-fi.com/boulderbadgedad)
---
@ -279,13 +279,34 @@ The template points at `boulderbadgedad/soulsync:latest` (stable) by default. To
git clone https://github.com/Nezreka/SoulSync
cd SoulSync
python -m pip install -r requirements.txt
# Build the React WebUI bundle used by the Python server.
# Docker does this automatically; Python installs must do it manually.
cd webui
npm ci
npm run build
cd ..
gunicorn -c gunicorn.conf.py wsgi:application
# Open http://localhost:8008
```
When updating a Python/no-Docker install with `git pull`, rebuild the WebUI before restarting SoulSync:
```bash
cd webui
npm ci
npm run build
cd ..
```
If `webui/static/dist/.vite/manifest.json` is missing or stale, React-owned routes and route handoffs may not load correctly.
### Local Development
Use two terminals so the backend and Vite stay independent:
This is only for contributors working on the WebUI with hot reload. Normal Python/no-Docker installs should build once with `npm run build` as shown above, then run only Gunicorn.
For active frontend development, use two terminals so the backend and Vite stay independent:
1. Backend
```bash

View file

@ -125,6 +125,9 @@ def register_routes(bp):
if not soulseek:
return api_error("NOT_AVAILABLE", "Soulseek client not configured.", 503)
current_app.logger.info(
f"[CancelTrigger:api.public_cancel] download_id={download_id} username={username}"
)
ok = run_async(soulseek.cancel_download(download_id, username, remove=True))
if ok:
return api_success({"message": "Download cancelled."})

View file

@ -368,6 +368,7 @@ def serialize_similar_artist(obj, fields: Optional[Set[str]] = None) -> dict:
"source_artist_id": d.get("source_artist_id"),
"similar_artist_spotify_id": d.get("similar_artist_spotify_id"),
"similar_artist_itunes_id": d.get("similar_artist_itunes_id"),
"similar_artist_musicbrainz_id": d.get("similar_artist_musicbrainz_id"),
"similar_artist_name": d.get("similar_artist_name"),
"similarity_rank": d.get("similarity_rank"),
"occurrence_count": d.get("occurrence_count"),

View file

@ -87,6 +87,10 @@ class ConfigManager:
'soulseek.api_key',
'deezer_download.arl',
'lidarr_download.api_key',
'prowlarr.api_key',
'torrent_client.password',
'usenet_client.api_key',
'usenet_client.password',
# Enrichment services
'listenbrainz.token',
'acoustid.api_key',
@ -476,11 +480,19 @@ class ConfigManager:
"search_min_delay_seconds": 0,
},
"download_source": {
"mode": "soulseek", # Options: "soulseek", "youtube", "tidal", "qobuz", "hifi", "hybrid"
"mode": "soulseek", # Options: "soulseek", "youtube", "tidal", "qobuz", "hifi", "hybrid", "torrent", "usenet"
"hybrid_primary": "soulseek", # Legacy: primary source for hybrid mode
"hybrid_secondary": "youtube", # Legacy: fallback source for hybrid mode
"hybrid_order": [], # Ordered list of sources for hybrid mode (overrides primary/secondary)
"stream_source": "youtube", # Options: "youtube" (instant, default), "active" (use download source; falls back to youtube if soulseek)
# Album-bundle (torrent / usenet single-source) poll tuning.
# Downloader is polled every N seconds until the release
# lands; whole job aborts at the timeout. Defaults match
# the previous hard-coded constants. Users on slow private
# trackers / large box sets can extend the timeout without
# editing source.
"album_bundle_poll_interval_seconds": 2.0,
"album_bundle_timeout_seconds": 6 * 60 * 60, # 6 hours
},
"tidal_download": {
"quality": "lossless", # Options: "low", "high", "lossless", "hires"
@ -519,6 +531,37 @@ class ConfigManager:
"quality_profile": "Any",
"cleanup_after_import": True,
},
# Prowlarr — indexer aggregator. Feeds the torrent / usenet
# download plugins. Not a standalone source.
"prowlarr": {
"url": "",
"api_key": "",
# Comma-separated list of indexer IDs to limit searches to.
# Empty = search all enabled indexers.
"indexer_ids": "",
},
# Torrent client — receives .torrent / magnet URIs from the
# torrent download plugin. ``type`` picks which adapter to
# instantiate (qbittorrent | transmission | deluge).
"torrent_client": {
"type": "qbittorrent",
"url": "",
"username": "",
"password": "",
"category": "soulsync",
"save_path": "",
},
# Usenet client — receives .nzb URLs / payloads. ``type``
# picks the adapter (sabnzbd | nzbget). SABnzbd uses an
# API key; NZBGet uses username + password.
"usenet_client": {
"type": "sabnzbd",
"url": "",
"api_key": "",
"username": "",
"password": "",
"category": "soulsync",
},
"soundcloud_download": {
# Anonymous-only for now — SoundCloud Go+ OAuth tier could be
# added later, with credentials living under a "session" subkey
@ -552,6 +595,13 @@ class ConfigManager:
"path": os.environ.get('DATABASE_PATH', 'database/music_library.db'),
"max_workers": 5
},
"image_cache": {
"enabled": True,
"path": "storage/image_cache",
"ttl_seconds": 2592000,
"failed_ttl_seconds": 21600,
"max_download_mb": 15
},
"metadata_enhancement": {
"enabled": True,
"embed_album_art": True,

View file

@ -16,6 +16,7 @@ from enum import Enum
from utils.logging_config import get_logger
from core.acoustid_client import AcoustIDClient
from core.matching_engine import MusicMatchingEngine
from core.matching.version_mismatch import is_acceptable_version_mismatch
from core.musicbrainz_client import MusicBrainzClient
logger = get_logger("acoustid.verification")
@ -485,12 +486,33 @@ class AcoustIDVerification:
expected_version = _detect_title_version(expected_track_name)
matched_version = _detect_title_version(matched_title)
if expected_version != matched_version:
msg = (
f"Version mismatch: expected '{expected_track_name}' ({expected_version}) "
f"but file is '{matched_title}' ({matched_version})"
)
logger.warning(f"AcoustID verification FAILED (version mismatch) - {msg}")
return VerificationResult.FAIL, msg
# Issue #607 (AfonsoG6): MusicBrainz often stores live
# recordings with bare titles ("Clarity") while the
# release entry carries the venue annotation ("Clarity
# (Live at Blossom Music Center, ...)"). The fingerprint
# correctly identifies the LIVE recording; only the
# title text is bare. Helper accepts the one-sided bare
# case when fingerprint + bare-title + artist all agree.
# Two-sided version mismatches (live vs remix etc) stay
# strict — those are genuinely different recordings.
if is_acceptable_version_mismatch(
expected_version, matched_version,
fingerprint_score=best_score,
title_similarity=title_sim,
artist_similarity=artist_sim,
):
logger.info(
f"AcoustID version annotation differs (expected={expected_version}, "
f"matched={matched_version}) but fingerprint+title+artist all match — "
f"accepting (likely MB metadata gap on a live/version-annotated recording)"
)
else:
msg = (
f"Version mismatch: expected '{expected_track_name}' ({expected_version}) "
f"but file is '{matched_title}' ({matched_version})"
)
logger.warning(f"AcoustID verification FAILED (version mismatch) - {msg}")
return VerificationResult.FAIL, msg
# Step 5: Decide pass/fail based on similarity
if title_sim >= TITLE_MATCH_THRESHOLD and artist_sim >= ARTIST_MATCH_THRESHOLD:
@ -593,12 +615,19 @@ class AcoustIDVerification:
# downloading Mr. Morale: three tracks (Rich Interlude, Savior
# Interlude, Savior) all received the wrong R.O.T.C audio file
# because of this leak.
top = recordings[0]
top_title = top.get('title', '?') or ''
top_artist = top.get('artist', '?') or ''
# Use the BEST matching recording's strings here (not
# `recordings[0]`) so the failure message reports the same
# candidate the title/artist similarity scores came from.
# Issue #607 (AfonsoG6) example 1: the prior code mixed
# `recordings[0]`'s strings (which can be empty) with
# `best_rec`'s scores, producing nonsense reasons like
# "file identified as '' by '' (artist=100%)" when a later
# recording in the list scored well on artist.
display_title = matched_title or '?'
display_artist = matched_artist or '?'
has_non_ascii = (
any(ord(c) > 127 for c in (expected_track_name or ''))
or any(ord(c) > 127 for c in top_title)
or any(ord(c) > 127 for c in display_title)
)
language_script_skip = (
best_score >= 0.95
@ -618,18 +647,16 @@ class AcoustIDVerification:
)
msg = (
f"Title/artist mismatch but fingerprint confidence very high ({best_score:.2f}): "
f"AcoustID='{top_title}' by '{top_artist}', "
f"AcoustID='{display_title}' by '{display_artist}', "
f"expected '{expected_track_name}' by '{expected_artist_name}'"
f"{reason}"
)
logger.info(f"AcoustID verification SKIPPED (high confidence) - {msg}")
return VerificationResult.SKIP, msg
# Low fingerprint score + no metadata match — file is likely wrong
# `top`, `top_title`, `top_artist` already resolved above for the
# skip-eligibility check.
# Low fingerprint score + no metadata match — file is likely wrong.
msg = (
f"Audio mismatch: file identified as '{top_title}' by '{top_artist}', "
f"Audio mismatch: file identified as '{display_title}' by '{display_artist}', "
f"expected '{expected_track_name}' by '{expected_artist_name}' "
f"(title={title_sim:.0%}, artist={artist_sim:.0%})"
)

786
core/amazon_client.py Normal file
View file

@ -0,0 +1,786 @@
"""Amazon Music metadata client backed by a T2Tunes proxy instance.
T2Tunes exposes the Amazon Music catalog (search, album metadata, stream info)
through a simple REST API. This module wraps those endpoints and normalises the
responses into the same Track / Artist / Album dataclass shape used by
DeezerClient and iTunesClient.
Endpoints used:
GET /api/status
GET /api/amazon-music/search
GET /api/amazon-music/metadata
GET /api/amazon-music/media-from-asin
Config keys (all optional fall back to public defaults):
amazon.base_url T2Tunes instance URL (default: https://t2tunes.site)
amazon.country ISO-3166 country code (default: US)
amazon.preferred_codec Preferred audio codec (default: flac)
"""
from __future__ import annotations
import re
import threading
import time
from dataclasses import dataclass
from typing import Any, Dict, Iterator, List, Optional
from urllib.parse import urljoin
import requests
from config.settings import config_manager
from core.api_call_tracker import api_call_tracker
from utils.logging_config import get_logger
logger = get_logger("amazon_client")
# Strips featuring credits like "Artist feat. X", "Artist ft. Y" so artist
# deduplication works on the primary artist name only.
_FEAT_RE = re.compile(r'\s+(?:feat(?:uring)?\.?|ft\.?)\s+.*', re.IGNORECASE)
# Strips the Explicit marker — explicit is treated as the default version.
# Clean/Edited/Censored stay in the name so users can distinguish them.
_EDITION_RE = re.compile(r'\s*[\[\(]explicit[\]\)]', re.IGNORECASE)
def _primary_artist(name: str) -> str:
return _FEAT_RE.sub('', name).strip()
def _strip_edition(name: str) -> str:
return _EDITION_RE.sub('', name).strip()
def _unslugify(name: str) -> str:
"""Convert a slug-form artist ID (e.g. 'kendrick_lamar') to a search name."""
return name.replace('_', ' ')
DEFAULT_BASE_URL = "https://t2tunes.site"
DEFAULT_COUNTRY = "US"
DEFAULT_CODEC = "flac"
MIN_API_INTERVAL = 0.5 # seconds — T2Tunes has no published rate limit
_last_api_call: float = 0.0
_api_call_lock = threading.Lock()
_META_CACHE_TTL = 300 # seconds
_meta_cache: Dict[str, tuple] = {} # asin -> (fetched_at, meta_dict)
_meta_cache_lock = threading.Lock()
class AmazonClientError(RuntimeError):
"""Raised on unrecoverable T2Tunes API errors."""
# ---------------------------------------------------------------------------
# Dataclasses — field layout matches DeezerClient / iTunesClient exactly
# ---------------------------------------------------------------------------
@dataclass
class Track:
id: str # Amazon track ASIN
name: str
artists: List[str]
album: str
duration_ms: int
popularity: int
preview_url: Optional[str] = None
external_urls: Optional[Dict[str, str]] = None
image_url: Optional[str] = None
release_date: Optional[str] = None
track_number: Optional[int] = None
disc_number: Optional[int] = None
album_type: Optional[str] = None
total_tracks: Optional[int] = None
isrc: Optional[str] = None
@classmethod
def from_search_hit(cls, doc: Dict[str, Any]) -> "Track":
return cls(
id=str(doc.get("asin") or ""),
name=str(doc.get("title") or ""),
artists=[str(doc.get("artistName") or "Unknown Artist")],
album=str(doc.get("albumName") or ""),
duration_ms=int(doc.get("duration") or 0) * 1000,
popularity=0,
isrc=str(doc.get("isrc") or "") or None,
)
@classmethod
def from_stream_info(
cls,
stream: "T2TunesStreamInfo",
album_meta: Optional[Dict[str, Any]] = None,
) -> "Track":
album = album_meta or {}
return cls(
id=stream.asin,
name=stream.title,
artists=[stream.artist] if stream.artist else ["Unknown Artist"],
album=stream.album,
duration_ms=0,
popularity=0,
image_url=album.get("image"),
release_date=album.get("release_date"),
total_tracks=album.get("trackCount"),
isrc=stream.isrc or None,
)
@dataclass
class Artist:
id: str # Slugified artist name — T2Tunes exposes no artist IDs
name: str
popularity: int
genres: List[str]
followers: int
image_url: Optional[str] = None
external_urls: Optional[Dict[str, str]] = None
@classmethod
def from_name(cls, name: str) -> "Artist":
slug = name.lower().replace(" ", "_")
return cls(id=slug, name=name, popularity=0, genres=[], followers=0)
@dataclass
class Album:
id: str # Amazon album ASIN
name: str
artists: List[str]
release_date: str
total_tracks: int
album_type: str
image_url: Optional[str] = None
external_urls: Optional[Dict[str, str]] = None
explicit: Optional[bool] = None
@classmethod
def from_search_hit(cls, doc: Dict[str, Any]) -> "Album":
return cls(
id=str(doc.get("albumAsin") or doc.get("asin") or ""),
name=str(doc.get("albumName") or doc.get("title") or ""),
artists=[str(doc.get("artistName") or "Unknown Artist")],
release_date="",
total_tracks=0,
album_type="album",
)
@classmethod
def from_metadata(cls, album_meta: Dict[str, Any], asin: str = "") -> "Album":
return cls(
id=str(album_meta.get("asin") or asin or ""),
name=str(album_meta.get("title") or ""),
artists=[str(album_meta.get("artistName") or "Unknown Artist")],
release_date=str(album_meta.get("release_date") or album_meta.get("releaseDate") or ""),
total_tracks=int(album_meta.get("trackCount") or 0),
album_type="album",
image_url=album_meta.get("image"),
explicit=album_meta.get("explicit"),
)
# ---------------------------------------------------------------------------
# Internal dataclasses for raw T2Tunes response parsing
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class T2TunesSearchItem:
asin: str
title: str
artist_name: str
item_type: str
album_name: str = ""
album_asin: str = ""
duration_seconds: int = 0
isrc: str = ""
@property
def is_album(self) -> bool:
return "album" in self.item_type.lower()
@property
def is_track(self) -> bool:
return "track" in self.item_type.lower()
@dataclass(frozen=True)
class T2TunesStreamInfo:
asin: str
streamable: bool
codec: str
format: str
sample_rate: Optional[int]
stream_url: str
decryption_key: Optional[str] # hex-encoded AES key; None when stream is clear
title: str = ""
artist: str = ""
album: str = ""
isrc: str = ""
cover_url: str = ""
track_number: Optional[int] = None
disc_number: Optional[int] = None
genre: str = ""
label: str = ""
date: str = ""
@property
def has_decryption_key(self) -> bool:
return bool(self.decryption_key)
# ---------------------------------------------------------------------------
# Rate-limit enforcement
# ---------------------------------------------------------------------------
def _rate_limit() -> None:
global _last_api_call
with _api_call_lock:
elapsed = time.monotonic() - _last_api_call
if elapsed < MIN_API_INTERVAL:
time.sleep(MIN_API_INTERVAL - elapsed)
_last_api_call = time.monotonic()
api_call_tracker.record_call("amazon")
# ---------------------------------------------------------------------------
# Main client
# ---------------------------------------------------------------------------
class AmazonClient:
"""T2Tunes-backed Amazon Music metadata and stream-info client."""
def __init__(
self,
base_url: Optional[str] = None,
country: Optional[str] = None,
preferred_codec: Optional[str] = None,
timeout: int = 30,
session: Optional[Any] = None,
) -> None:
self.base_url = (base_url or config_manager.get("amazon.base_url", DEFAULT_BASE_URL)).rstrip("/")
self.country = (country or config_manager.get("amazon.country", DEFAULT_COUNTRY)).upper()
self.preferred_codec = (
preferred_codec or config_manager.get("amazon.preferred_codec", DEFAULT_CODEC)
).lower()
self.timeout = timeout
self.session: Any = session or requests.Session()
if isinstance(self.session, requests.Session):
self.session.headers.update({
"Accept": "application/json",
"User-Agent": "SoulSync/1.0",
"Referer": self.base_url,
})
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def reload_config(self) -> None:
self.base_url = config_manager.get("amazon.base_url", DEFAULT_BASE_URL).rstrip("/")
self.country = config_manager.get("amazon.country", DEFAULT_COUNTRY).upper()
self.preferred_codec = config_manager.get("amazon.preferred_codec", DEFAULT_CODEC).lower()
def is_authenticated(self) -> bool:
"""Return True when the T2Tunes instance reports Amazon Music as up."""
try:
return str(self.status().get("amazonMusic", "")).lower() == "up"
except AmazonClientError:
return False
# ------------------------------------------------------------------
# Low-level API wrappers
# ------------------------------------------------------------------
def status(self) -> Dict[str, Any]:
return self._get_json("/api/status")
def search_raw(self, query: str, *, types: str = "track,album") -> List[T2TunesSearchItem]:
data = self._get_json(
"/api/amazon-music/search",
params={"query": query, "types": types, "country": self.country},
)
return list(self._iter_search_items(data))
def album_metadata(self, asin: str) -> Dict[str, Any]:
return self._get_json(
"/api/amazon-music/metadata",
params={"asin": asin, "country": self.country},
)
def media_from_asin(self, asin: str, codec: Optional[str] = None) -> List[T2TunesStreamInfo]:
effective_codec = (codec or self.preferred_codec).lower()
data = self._get_json(
"/api/amazon-music/media-from-asin",
params={"asin": asin, "country": self.country, "codec": effective_codec},
)
if isinstance(data, list):
return [self._parse_stream_info(item) for item in data if isinstance(item, dict)]
if isinstance(data, dict):
return [self._parse_stream_info(data)]
raise AmazonClientError(f"Unexpected media-from-asin response type: {type(data).__name__}")
# ------------------------------------------------------------------
# Metadata interface — mirrors DeezerClient / iTunesClient signatures
# ------------------------------------------------------------------
def search_tracks(self, query: str, limit: int = 20) -> List[Track]:
_rate_limit()
items = self.search_raw(query, types="track")
track_pairs: List[tuple] = [] # (Track, album_asin)
seen_album_asins: List[str] = []
for item in items:
if not item.is_track:
continue
track = Track.from_search_hit({
"asin": item.asin,
"title": _strip_edition(item.title),
"artistName": _primary_artist(item.artist_name),
"albumName": _strip_edition(item.album_name),
"albumAsin": item.album_asin,
"duration": item.duration_seconds,
"isrc": item.isrc,
})
track_pairs.append((track, item.album_asin))
if item.album_asin and item.album_asin not in seen_album_asins:
seen_album_asins.append(item.album_asin)
if len(track_pairs) >= limit:
break
album_metas = self._fetch_album_metas(seen_album_asins[:5])
tracks: List[Track] = []
for track, album_asin in track_pairs:
if album_asin and album_asin in album_metas:
meta = album_metas[album_asin]
track.image_url = meta.get("image")
track.release_date = str(meta.get("release_date") or meta.get("releaseDate") or "")
track.total_tracks = meta.get("trackCount")
tracks.append(track)
return tracks
def search_artists(self, query: str, limit: int = 20) -> List[Artist]:
_rate_limit()
items = self.search_raw(query, types="track")
seen: Dict[str, Artist] = {}
artist_album_asin: Dict[str, str] = {} # artist name → first album ASIN seen
for item in items:
name = _primary_artist(item.artist_name)
if not name:
continue
if name not in seen:
seen[name] = Artist.from_name(name)
if name not in artist_album_asin and item.album_asin:
artist_album_asin[name] = item.album_asin
if len(seen) >= limit:
break
# T2Tunes has no artist images — use an album cover as stand-in.
unique_asins = list({v for v in artist_album_asin.values()})[:5]
album_metas = self._fetch_album_metas(unique_asins)
for name, artist in seen.items():
asin = artist_album_asin.get(name)
if asin and asin in album_metas:
artist.image_url = album_metas[asin].get("image")
return list(seen.values())
def search_albums(self, query: str, limit: int = 20) -> List[Album]:
_rate_limit()
items = self.search_raw(query, types="track")
album_candidates: List[tuple] = [] # (Album, asin)
seen_keys: set = set()
for item in items:
album_asin = item.album_asin
raw_name = item.album_name
if not raw_name:
continue
display_name = _strip_edition(raw_name)
artist = _primary_artist(item.artist_name)
# Collapse Explicit/Clean variants: same normalised name + artist = same album
dedup_key = (display_name.lower(), artist.lower())
if dedup_key in seen_keys:
continue
seen_keys.add(dedup_key)
album = Album.from_search_hit({
"albumAsin": album_asin,
"albumName": display_name,
"artistName": artist,
})
album_candidates.append((album, album_asin))
if len(album_candidates) >= limit:
break
album_metas = self._fetch_album_metas([a for _, a in album_candidates[:10]])
albums: List[Album] = []
for album, asin in album_candidates:
if asin in album_metas:
meta = album_metas[asin]
album.image_url = meta.get("image")
album.release_date = str(meta.get("release_date") or meta.get("releaseDate") or "")
album.total_tracks = int(meta.get("trackCount") or 0)
albums.append(album)
return albums
def get_track_details(self, asin: str) -> Optional[Dict[str, Any]]:
"""Return a Spotify-compatible dict for a single track ASIN."""
_rate_limit()
try:
streams = self.media_from_asin(asin)
except AmazonClientError:
return None
if not streams:
return None
s = streams[0]
album_data: Dict[str, Any] = {}
try:
meta = self.album_metadata(asin)
albums = meta.get("albumList")
if isinstance(albums, list) and albums and isinstance(albums[0], dict):
album_data = albums[0]
except AmazonClientError:
pass
return {
"id": s.asin,
"name": _strip_edition(s.title),
"artists": [{"name": _primary_artist(s.artist), "id": ""}],
"album": {
"id": album_data.get("asin", ""),
"name": _strip_edition(s.album),
"images": [{"url": album_data["image"]}] if album_data.get("image") else [],
"release_date": album_data.get("release_date") or album_data.get("releaseDate") or s.date or "",
"total_tracks": album_data.get("trackCount", 0),
},
"duration_ms": 0,
"popularity": 0,
"external_urls": {"amazon": f"https://music.amazon.com/albums/{asin}"},
"track_number": s.track_number,
"disc_number": s.disc_number,
"isrc": s.isrc,
"is_album_track": True,
"raw_data": {
"codec": s.codec,
"format": s.format,
"sample_rate": s.sample_rate,
"streamable": s.streamable,
"has_decryption_key": s.has_decryption_key,
},
}
def get_album(self, asin: str, include_tracks: bool = True) -> Optional[Dict[str, Any]]:
"""Return a Spotify-compatible album dict."""
_rate_limit()
try:
meta = self.album_metadata(asin)
except AmazonClientError:
return None
albums = meta.get("albumList")
if not isinstance(albums, list) or not albums:
return None
album = albums[0] if isinstance(albums[0], dict) else {}
result: Dict[str, Any] = {
"id": asin,
"name": _strip_edition(album.get("title", "")),
"artists": [{"name": _primary_artist(album.get("artistName", "")), "id": ""}],
"release_date": album.get("release_date") or album.get("releaseDate") or "",
"total_tracks": album.get("trackCount", 0),
"album_type": "album",
"images": [{"url": album["image"]}] if album.get("image") else [],
"external_urls": {"amazon": f"https://music.amazon.com/albums/{asin}"},
"label": album.get("label", ""),
}
if include_tracks:
tracks_data = self.get_album_tracks(asin) or {
"items": [], "total": 0, "limit": 50, "next": None,
}
result["tracks"] = tracks_data
# Backfill release_date from stream tags when album metadata lacks it.
if not result["release_date"]:
items = tracks_data.get("items") or []
for item in items:
rd = item.get("release_date") or ""
if rd and len(rd) >= 4:
result["release_date"] = rd
break
return result
def get_album_tracks(self, asin: str) -> Optional[Dict[str, Any]]:
"""Return album tracks in Spotify pagination format."""
_rate_limit()
try:
streams = self.media_from_asin(asin)
except AmazonClientError:
return None
# media_from_asin has no duration — enrich from search results which do.
duration_map: Dict[str, int] = {} # track asin → duration_ms
if streams:
album_name = _strip_edition(streams[0].album)
artist_name = _primary_artist(streams[0].artist)
try:
search_items = self.search_raw(
f"{album_name} {artist_name}", types="track"
)
for item in search_items:
if item.album_asin == asin and item.duration_seconds:
duration_map[item.asin] = item.duration_seconds * 1000
except Exception as exc:
logger.debug("Duration backfill failed for ASIN %s: %s", asin, exc)
items = [
{
"id": s.asin,
"name": _strip_edition(s.title),
"artists": [{"name": _primary_artist(s.artist), "id": ""}],
"duration_ms": duration_map.get(s.asin, 0),
"track_number": s.track_number if s.track_number is not None else idx + 1,
"disc_number": s.disc_number,
"release_date": s.date or "",
"isrc": s.isrc,
}
for idx, s in enumerate(streams)
]
return {"items": items, "total": len(items), "limit": 50, "next": None}
def get_artist(self, artist_name: str) -> Optional[Dict[str, Any]]:
"""Return a Spotify-compatible artist dict inferred from search results."""
_rate_limit()
search_name = _unslugify(artist_name)
try:
items = self.search_raw(search_name, types="track")
except AmazonClientError:
return None
name_lower = search_name.lower()
match = next(
(i for i in items if _primary_artist(i.artist_name).lower() == name_lower),
next((i for i in items if name_lower in _primary_artist(i.artist_name).lower()), None),
)
if not match:
return None
return {
"id": match.artist_name.lower().replace(" ", "_"),
"name": match.artist_name,
"genres": [],
"popularity": 0,
"followers": {"total": 0},
"images": [],
"external_urls": {},
}
def get_artist_albums(
self,
artist_name: str,
album_type: str = "album,single",
limit: int = 200,
) -> List[Album]:
"""Return albums for an artist inferred from search results."""
_rate_limit()
search_name = _unslugify(artist_name)
try:
items = self.search_raw(f"{search_name} album", types="track")
except AmazonClientError:
return []
album_candidates: List[tuple] = [] # (Album, asin)
seen_asins: set = set()
name_lower = search_name.lower()
for item in items:
if not item.is_album:
continue
if _primary_artist(item.artist_name).lower() != name_lower:
continue
album_asin = item.album_asin or item.asin
if album_asin in seen_asins:
continue
seen_asins.add(album_asin)
album = Album.from_search_hit({
"albumAsin": album_asin,
"albumName": _strip_edition(item.album_name or item.title),
"artistName": _primary_artist(item.artist_name),
})
album_candidates.append((album, album_asin))
if len(album_candidates) >= limit:
break
# Fetch metadata for art, release_date, track_count, and type inference.
# No explicit cap here — search_raw naturally bounds results to ~20 items,
# so album_candidates is already small.
asins_to_fetch = [a for _, a in album_candidates]
metas = self._fetch_album_metas(asins_to_fetch)
albums: List[Album] = []
for album, asin in album_candidates:
meta = metas.get(asin, {})
if meta:
album.image_url = meta.get("image")
album.release_date = str(meta.get("release_date") or meta.get("releaseDate") or "")
total = int(meta.get("trackCount") or 0)
album.total_tracks = total
# T2Tunes doesn't expose release type — infer from track count.
# 1-track releases are singles; keep default "album" otherwise.
if total == 1:
album.album_type = "single"
albums.append(album)
return albums
def get_track_features(self, track_id: str) -> Optional[Dict[str, Any]]:
"""Not available from Amazon Music — returns None for compatibility."""
return None
def _get_artist_image_from_albums(self, artist_id: str) -> Optional[str]:
"""Return an album cover as artist image stand-in (T2Tunes has no artist images)."""
search_name = _unslugify(artist_id)
try:
items = self.search_raw(search_name, types="track")
except AmazonClientError:
return None
name_lower = search_name.lower()
for item in items:
if _primary_artist(item.artist_name).lower() != name_lower:
continue
asin = item.album_asin or item.asin
if not asin:
continue
metas = self._fetch_album_metas([asin])
if asin in metas and metas[asin].get("image"):
return metas[asin]["image"]
return None
# ==================== Interface Aliases (match DeezerClient method names) ====================
get_album_metadata = get_album
get_artist_info = get_artist
get_artist_albums_list = get_artist_albums
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
def _fetch_album_metas(self, asins: List[str]) -> Dict[str, Dict[str, Any]]:
"""Parallel-fetch album metadata for up to N ASINs. Returns {asin: albumList[0]}."""
if not asins:
return {}
metas: Dict[str, Dict[str, Any]] = {}
def _fetch(asin: str) -> None:
now = time.monotonic()
with _meta_cache_lock:
entry = _meta_cache.get(asin)
if entry and (now - entry[0]) < _META_CACHE_TTL:
metas[asin] = entry[1]
return
_rate_limit()
try:
raw = self.album_metadata(asin)
lst = raw.get("albumList")
if isinstance(lst, list) and lst and isinstance(lst[0], dict):
meta = lst[0]
with _meta_cache_lock:
_meta_cache[asin] = (time.monotonic(), meta)
metas[asin] = meta
except Exception as exc:
logger.debug("Album metadata fetch failed for ASIN %s: %s", asin, exc)
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=min(len(asins), 5)) as pool:
list(pool.map(_fetch, asins))
return metas
def _get_json(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
url = urljoin(f"{self.base_url}/", path.lstrip("/"))
last_exc: Optional[Exception] = None
for attempt in range(3):
if attempt:
time.sleep(1.0 * attempt)
try:
resp = self.session.get(url, params=params, timeout=self.timeout)
resp.raise_for_status()
except requests.HTTPError as exc:
body = exc.response.text[:500].replace("\n", " ")
# T2Tunes returns 400 for transient Amazon-side failures — retry those.
if exc.response.status_code == 400 and "Failed to search" in exc.response.text:
logger.debug("T2Tunes transient 400 on attempt %d, retrying: %s", attempt + 1, url)
last_exc = AmazonClientError(
f"HTTP {exc.response.status_code} for {url} — body: {body!r}"
)
continue
raise AmazonClientError(
f"HTTP {exc.response.status_code} for {url} — body: {body!r}"
) from exc
except requests.RequestException as exc:
raise AmazonClientError(f"Request failed for {url}: {exc}") from exc
try:
return resp.json()
except ValueError as exc:
preview = resp.text[:200].replace("\n", " ")
raise AmazonClientError(f"Response not JSON for {url}: {preview!r}") from exc
raise last_exc # type: ignore[misc]
@staticmethod
def _iter_search_items(response: Any) -> Iterator[T2TunesSearchItem]:
if not isinstance(response, dict):
raise AmazonClientError(
f"Unexpected search response type: {type(response).__name__}"
)
for result in response.get("results") or []:
if not isinstance(result, dict):
continue
for hit in result.get("hits") or []:
if not isinstance(hit, dict):
continue
doc = hit.get("document")
if not isinstance(doc, dict):
continue
asin = str(doc.get("asin") or "")
if not asin:
continue
yield T2TunesSearchItem(
asin=asin,
title=str(doc.get("title") or ""),
artist_name=str(doc.get("artistName") or ""),
item_type=str(doc.get("__type") or ""),
album_name=str(doc.get("albumName") or ""),
album_asin=str(doc.get("albumAsin") or ""),
duration_seconds=int(doc.get("duration") or 0),
isrc=str(doc.get("isrc") or ""),
)
@staticmethod
def _parse_stream_info(item: Dict[str, Any]) -> T2TunesStreamInfo:
stream_info = item.get("streamInfo") if isinstance(item.get("streamInfo"), dict) else {}
tags = item.get("tags") if isinstance(item.get("tags"), dict) else {}
# T2Tunes API has a typo: "stremeable" in some responses
streamable = item.get("streamable")
if streamable is None:
streamable = item.get("stremeable")
raw_key = item.get("decryptionKey")
decryption_key = str(raw_key) if raw_key else None
def _int_tag(key: str) -> Optional[int]:
v = tags.get(key)
try:
return int(v) if v is not None else None
except (TypeError, ValueError):
return None
return T2TunesStreamInfo(
asin=str(item.get("asin") or ""),
streamable=bool(streamable),
codec=str(stream_info.get("codec") or ""),
format=str(stream_info.get("format") or ""),
sample_rate=(
stream_info.get("sampleRate")
if isinstance(stream_info.get("sampleRate"), int)
else None
),
stream_url=str(stream_info.get("streamUrl") or ""),
decryption_key=decryption_key,
title=str(tags.get("title") or ""),
artist=str(tags.get("artist") or ""),
album=str(tags.get("album") or ""),
isrc=str(tags.get("isrc") or ""),
cover_url=str(item.get("coverUrl") or ""),
track_number=_int_tag("trackNumber"),
disc_number=_int_tag("discNumber"),
genre=str(tags.get("genre") or ""),
label=str(tags.get("label") or ""),
date=str(tags.get("date") or ""),
)

View file

@ -0,0 +1,452 @@
"""Amazon Music download source plugin backed by a T2Tunes proxy.
NOT yet wired into the app registry validated in isolation only.
See tests/tools/test_amazon_download_client.py.
Filename encoding (the DownloadSourcePlugin dispatch contract):
"{asin}||{display_name}"
e.g. "B09XYZ1234||Kendrick Lamar - Not Like Us"
Codec preference order: FLAC Opus EAC3.
Download flow (from Tubifarry reference implementation):
1. GET stream_url encrypted bytes on disk
2. FFmpeg -decryption_key <hex> to decrypt in place
3. Embed metadata tags (handled by the app's standard post-processing)
"""
from __future__ import annotations
import os
import shutil
import subprocess
import threading
import time
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import requests as http_requests
from config.settings import config_manager
from core.amazon_client import AmazonClient, AmazonClientError
from core.download_plugins.base import DownloadSourcePlugin
from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult
from utils.logging_config import get_logger
logger = get_logger("amazon_download_client")
# Quality / codec helpers
CODEC_PREFERENCE = ["flac", "opus", "eac3"]
_CODEC_EXTENSIONS: Dict[str, str] = {
"flac": "flac",
"ogg_vorbis": "ogg",
"opus": "opus",
"eac3": "eac3",
"mp4": "m4a",
"aac": "m4a",
"mp3": "mp3",
}
MIN_AUDIO_BYTES = 512 * 1024 # 512 KB — anything smaller is a broken stream
def _codec_key(codec: str) -> str:
return codec.lower().replace("-", "_").replace(" ", "_")
def _file_extension(codec: str) -> str:
return _CODEC_EXTENSIONS.get(_codec_key(codec), "bin")
def _quality_label(codec: str, sample_rate: Optional[int] = None) -> str:
ck = _codec_key(codec)
if ck == "flac":
if sample_rate and sample_rate > 48000:
return "Hi-Res"
return "Lossless"
return "Lossy"
class AmazonDownloadClient(DownloadSourcePlugin):
"""DownloadSourcePlugin — Amazon Music via T2Tunes proxy."""
def __init__(self, download_path: Optional[str] = None) -> None:
if download_path is None:
download_path = config_manager.get("soulseek.download_path", "./downloads")
self.download_path = Path(download_path)
self.download_path.mkdir(parents=True, exist_ok=True)
self._quality = config_manager.get("amazon_download.quality", "flac")
self._allow_fallback = config_manager.get("amazon_download.allow_fallback", True)
self._client = AmazonClient(preferred_codec=self._quality)
self.session = http_requests.Session()
self.session.headers.update({
"User-Agent": "SoulSync/1.0",
"Accept": "*/*",
})
self._engine: Any = None
self.shutdown_check: Any = None
# ------------------------------------------------------------------
# DownloadSourcePlugin — lifecycle
# ------------------------------------------------------------------
def set_engine(self, engine) -> None:
"""Engine callback — wires the central thread worker + state store."""
self._engine = engine
def set_shutdown_check(self, check_callable) -> None:
self.shutdown_check = check_callable
def is_configured(self) -> bool:
# T2Tunes has a public default instance; no credentials required.
# Return True unconditionally so the source shows as available.
return True
async def check_connection(self) -> bool:
try:
return self._client.is_authenticated()
except Exception:
return False
# ------------------------------------------------------------------
# DownloadSourcePlugin — search
# ------------------------------------------------------------------
async def search(
self,
query: str,
timeout: Optional[int] = None,
progress_callback: Any = None,
) -> Tuple[List[TrackResult], List[AlbumResult]]:
try:
items = self._client.search_raw(query, types="track,album")
except AmazonClientError as exc:
logger.warning(f"Amazon search failed for {query!r}: {exc}")
return [], []
track_results: List[TrackResult] = []
album_map: Dict[str, AlbumResult] = {}
album_order: List[str] = []
preferred = self._client.preferred_codec
for item in items:
quality = _quality_label(preferred)
if item.is_track:
track_results.append(TrackResult(
username="amazon",
filename=f"{item.asin}||{item.artist_name} - {item.title}",
size=0,
bitrate=None,
duration=item.duration_seconds * 1000 if item.duration_seconds else None,
quality=quality,
free_upload_slots=999,
upload_speed=999_999,
queue_length=0,
artist=item.artist_name,
title=item.title,
album=item.album_name,
_source_metadata={
"asin": item.asin,
"album_asin": item.album_asin,
"isrc": item.isrc,
},
))
elif item.is_album:
album_asin = item.album_asin or item.asin
if album_asin not in album_map:
placeholder = TrackResult(
username="amazon",
filename=f"{item.asin}||{item.artist_name} - {item.title}",
size=0,
bitrate=None,
duration=None,
quality=quality,
free_upload_slots=999,
upload_speed=999_999,
queue_length=0,
artist=item.artist_name,
title=item.title,
album=item.album_name,
)
album_map[album_asin] = AlbumResult(
username="amazon",
album_path=album_asin,
album_title=item.album_name or item.title,
artist=item.artist_name,
track_count=0,
total_size=0,
tracks=[placeholder],
dominant_quality=quality,
)
album_order.append(album_asin)
return track_results, [album_map[k] for k in album_order]
# ------------------------------------------------------------------
# DownloadSourcePlugin — download dispatch
# ------------------------------------------------------------------
async def download(
self,
username: str,
filename: str,
file_size: int = 0,
) -> Optional[str]:
if "||" not in filename:
logger.error(f"Invalid Amazon filename format (no '||'): {filename!r}")
return None
if self._engine is None:
raise RuntimeError(
"AmazonDownloadClient._engine is not set — "
"client not connected to download infrastructure"
)
asin, display_name = filename.split("||", 1)
asin = asin.strip()
display_name = display_name.strip()
return self._engine.worker.dispatch(
source_name="amazon",
target_id=asin,
display_name=display_name,
original_filename=filename,
impl_callable=self._download_sync,
)
def _download_sync(
self,
download_id: str,
target_id: str,
display_name: str,
) -> Optional[str]:
asin = str(target_id)
codecs = CODEC_PREFERENCE if self._allow_fallback else [self._quality]
for codec in codecs:
try:
streams = self._client.media_from_asin(asin, codec=codec)
except AmazonClientError as exc:
logger.warning(f"media_from_asin({asin!r}, {codec}) failed: {exc}")
continue
stream = next(
(s for s in streams if s.streamable and s.stream_url),
None,
)
if not stream:
logger.debug(f"No streamable result for {asin} at codec={codec}")
continue
ext = _file_extension(stream.codec or codec)
safe = "".join(
ch if ch.isalnum() or ch in " -_." else "_"
for ch in display_name
)[:80]
# T2Tunes always serves audio in an encrypted MP4 container.
# The codec extension (.flac, .opus, .eac3) is only for the
# final decrypted output.
enc_ext = "mp4" if stream.decryption_key else ext
enc_path = self._unique_path(self.download_path / f"{safe}.enc.{enc_ext}")
out_path = self._unique_path(self.download_path / f"{safe}.{ext}")
if self._engine is not None:
self._engine.update_record(
"amazon", download_id, {"state": "downloading", "progress": 0.0}
)
try:
downloaded = self._stream_to_file(stream.stream_url, enc_path, download_id)
except Exception as exc:
logger.warning(f"Stream download failed for {asin} ({codec}): {exc}")
enc_path.unlink(missing_ok=True)
continue
if downloaded < MIN_AUDIO_BYTES:
logger.warning(
f"File too small ({downloaded} B) for {asin} at {codec} — trying next"
)
enc_path.unlink(missing_ok=True)
continue
if stream.decryption_key:
if self._engine is not None:
self._engine.update_record(
"amazon", download_id, {"state": "decrypting", "progress": 1.0}
)
try:
self._decrypt_with_ffmpeg(enc_path, out_path, stream.decryption_key)
enc_path.unlink(missing_ok=True)
except Exception as exc:
logger.error(f"Decryption failed for {asin} ({codec}): {exc}")
enc_path.unlink(missing_ok=True)
out_path.unlink(missing_ok=True)
continue
else:
enc_path.rename(out_path)
final_size = out_path.stat().st_size
logger.info(
f"Amazon download complete ({codec}): {out_path} "
f"({final_size / (1024 * 1024):.1f} MB)"
)
# Sync size == transferred so the download monitor's bytes-incomplete
# guard doesn't block post-processing. The throttled updates in
# _stream_to_file leave transferred < size after the last 0.5s tick;
# other streaming clients avoid this by not tracking bytes at all
# (size stays 0, the guard is skipped). Writing the final output size
# here restores parity.
if self._engine is not None:
self._engine.update_record(
"amazon", download_id, {'size': final_size, 'transferred': final_size}
)
return str(out_path)
logger.error(f"All codec tiers exhausted for '{display_name}' ({asin})")
return None
def _decrypt_with_ffmpeg(
self, enc_path: Path, out_path: Path, hex_key: str
) -> None:
"""Decrypt a T2Tunes encrypted audio file using FFmpeg -decryption_key.
T2Tunes uses CENC (Common Encryption) for DRM-protected tracks.
FFmpeg supports decryption via the -decryption_key flag when the
key is provided in hex.
"""
ffmpeg = shutil.which("ffmpeg")
if not ffmpeg:
tools_dir = Path(__file__).parent.parent / "tools"
candidate = tools_dir / ("ffmpeg.exe" if os.name == "nt" else "ffmpeg")
if candidate.exists():
ffmpeg = str(candidate)
else:
raise RuntimeError(
"ffmpeg is required for Amazon Music decryption. "
"Install ffmpeg and ensure it is on your PATH."
)
cmd = [
ffmpeg,
"-y",
"-hide_banner",
"-loglevel", "error",
"-decryption_key", hex_key,
"-i", str(enc_path),
"-map", "0:a:0", # extract first audio stream (FLAC/Opus/EAC3 inside MP4)
"-c", "copy",
str(out_path),
]
logger.debug(f"Decrypting {enc_path.name}{out_path.name}")
result = subprocess.run(cmd, capture_output=True)
if result.returncode != 0:
stderr = result.stderr.decode("utf-8", errors="replace").strip()
raise RuntimeError(f"FFmpeg decryption failed (exit {result.returncode}): {stderr}")
def _stream_to_file(self, url: str, out_path: Path, download_id: str) -> int:
resp = self.session.get(url, stream=True, timeout=60)
resp.raise_for_status()
total = int(resp.headers.get("content-length", 0))
downloaded = 0
last_report = time.monotonic()
shutdown_triggered = False
with out_path.open("wb") as fh:
for chunk in resp.iter_content(chunk_size=64 * 1024):
if not chunk:
continue
if self.shutdown_check and self.shutdown_check():
shutdown_triggered = True
break
fh.write(chunk)
downloaded += len(chunk)
now = time.monotonic()
if self._engine and now - last_report >= 0.5:
self._engine.update_record(
"amazon",
download_id,
{
"transferred": downloaded,
"size": total,
"progress": downloaded / total if total else 0.0,
},
)
last_report = now
if shutdown_triggered:
out_path.unlink(missing_ok=True)
raise RuntimeError("Shutdown requested mid-download")
return downloaded
# ------------------------------------------------------------------
# DownloadSourcePlugin — status interface
# ------------------------------------------------------------------
async def get_all_downloads(self) -> List[DownloadStatus]:
if self._engine is None:
return []
return [
self._record_to_status(record)
for record in self._engine.iter_records_for_source('amazon')
]
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
if self._engine is None:
return None
record = self._engine.get_record('amazon', download_id)
return self._record_to_status(record) if record is not None else None
async def cancel_download(
self,
download_id: str,
username: Optional[str] = None,
remove: bool = False,
) -> bool:
if self._engine is None:
return False
if self._engine.get_record('amazon', download_id) is None:
return False
self._engine.update_record('amazon', download_id, {'state': 'Cancelled'})
if remove:
self._engine.remove_record('amazon', download_id)
return True
async def clear_all_completed_downloads(self) -> bool:
if self._engine is None:
return True
terminal = {'Completed, Succeeded', 'Cancelled', 'Errored', 'Aborted'}
for record in list(self._engine.iter_records_for_source('amazon')):
if record.get('state') in terminal:
self._engine.remove_record('amazon', record['id'])
return True
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
@staticmethod
def _unique_path(path: Path) -> Path:
if not path.exists():
return path
stem, suffix = path.stem, path.suffix
for i in range(1, 100):
candidate = path.with_name(f"{stem} ({i}){suffix}")
if not candidate.exists():
return candidate
return path.with_name(f"{stem}_{uuid.uuid4().hex[:8]}{suffix}")
@staticmethod
def _record_to_status(rec: Dict[str, Any]) -> DownloadStatus:
return DownloadStatus(
id=str(rec.get('id', '')),
filename=str(rec.get('filename', '')),
username='amazon',
state=str(rec.get('state', 'queued')),
progress=float(rec.get('progress', 0.0)),
size=int(rec.get('size', 0)),
transferred=int(rec.get('transferred', 0)),
speed=int(rec.get('speed', 0)),
time_remaining=rec.get('time_remaining'),
file_path=rec.get('file_path'),
)

602
core/amazon_worker.py Normal file
View file

@ -0,0 +1,602 @@
import re
import threading
import time
from difflib import SequenceMatcher
from typing import Optional, Dict, Any
from datetime import datetime, timedelta
from utils.logging_config import get_logger
from database.music_database import MusicDatabase
from core.amazon_client import AmazonClient
from core.worker_utils import interruptible_sleep, set_album_api_track_count
from core.enrichment.manual_match_honoring import honor_stored_match
logger = get_logger("amazon_worker")
class AmazonWorker:
"""Background worker for enriching library artists, albums, and tracks with Amazon Music metadata."""
def __init__(self, database: MusicDatabase):
self.db = database
self.client = AmazonClient()
self._amazon_schema_checked = False
self.running = False
self.paused = False
self.should_stop = False
self.thread = None
self._stop_event = threading.Event()
self.current_item = None
self.stats = {
'matched': 0,
'not_found': 0,
'pending': 0,
'errors': 0,
}
self.retry_days = 30
self.name_similarity_threshold = 0.80
logger.info("Amazon background worker initialized")
def _ensure_amazon_schema(self, cursor) -> None:
"""Ensure upgraded installs have the Amazon enrichment columns.
MusicDatabase normally runs this migration during startup, but the
worker should still be defensive because it is the code path that
repeatedly queries these columns in the background.
"""
if self._amazon_schema_checked:
return
table_columns = {
'artists': ('amazon_id', 'amazon_match_status', 'amazon_last_attempted'),
'albums': ('amazon_id', 'amazon_match_status', 'amazon_last_attempted'),
'tracks': ('amazon_id', 'amazon_match_status', 'amazon_last_attempted'),
}
for table, columns in table_columns.items():
cursor.execute(f"PRAGMA table_info({table})")
existing = {row[1] for row in cursor.fetchall()}
for column in columns:
if column not in existing:
column_type = 'TIMESTAMP' if column == 'amazon_last_attempted' else 'TEXT'
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {column_type}")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_amazon_id ON artists (amazon_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_amazon_status ON artists (amazon_match_status)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_amazon_id ON albums (amazon_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_amazon_status ON albums (amazon_match_status)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_amazon_id ON tracks (amazon_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_amazon_status ON tracks (amazon_match_status)")
cursor.connection.commit()
self._amazon_schema_checked = True
def start(self):
if self.running:
logger.warning("Worker already running")
return
self.running = True
self.should_stop = False
self._stop_event.clear()
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
logger.info("Amazon background worker started")
def stop(self):
if not self.running:
return
logger.info("Stopping Amazon worker...")
self.should_stop = True
self.running = False
self._stop_event.set()
if self.thread:
self.thread.join(timeout=1)
logger.info("Amazon worker stopped")
def pause(self):
if not self.running:
logger.warning("Worker not running, cannot pause")
return
self.paused = True
logger.info("Amazon worker paused")
def resume(self):
if not self.running:
logger.warning("Worker not running, start it first")
return
self.paused = False
logger.info("Amazon worker resumed")
def get_stats(self) -> Dict[str, Any]:
self.stats['pending'] = self._count_pending_items()
progress = self._get_progress_breakdown()
is_actually_running = self.running and (self.thread is not None and self.thread.is_alive())
is_idle = is_actually_running and not self.paused and self.stats['pending'] == 0 and self.current_item is None
return {
'enabled': True,
'running': is_actually_running and not self.paused,
'paused': self.paused,
'idle': is_idle,
'current_item': self.current_item,
'stats': self.stats.copy(),
'progress': progress,
}
def _run(self):
logger.info("Amazon worker thread started")
while not self.should_stop:
try:
if self.paused:
interruptible_sleep(self._stop_event, 1)
continue
self.current_item = None
item = self._get_next_item()
if not item:
logger.debug("No pending items, sleeping...")
interruptible_sleep(self._stop_event, 10)
continue
self.current_item = item
item_id = item.get('id') or item.get('artist_id') or item.get('album_id')
if item_id is None:
logger.warning(f"Skipping {item.get('type', 'unknown')} with NULL id: {item.get('name', '?')}")
continue
self._process_item(item)
interruptible_sleep(self._stop_event, 2)
except Exception as e:
logger.error(f"Error in worker loop: {e}")
interruptible_sleep(self._stop_event, 5)
logger.info("Amazon worker thread finished")
def _get_next_item(self) -> Optional[Dict[str, Any]]:
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
self._ensure_amazon_schema(cursor)
# Priority 1: Unattempted artists
cursor.execute("""
SELECT id, name FROM artists
WHERE amazon_match_status IS NULL AND id IS NOT NULL
ORDER BY id ASC LIMIT 1
""")
row = cursor.fetchone()
if row:
return {'type': 'artist', 'id': row[0], 'name': row[1]}
# Priority 2: Unattempted albums
cursor.execute("""
SELECT a.id, a.title, ar.name AS artist_name, ar.amazon_id AS artist_amazon_id
FROM albums a
JOIN artists ar ON a.artist_id = ar.id
WHERE a.amazon_match_status IS NULL AND a.id IS NOT NULL
ORDER BY a.id ASC LIMIT 1
""")
row = cursor.fetchone()
if row:
return {'type': 'album', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_amazon_id': row[3]}
# Priority 3: Unattempted tracks
cursor.execute("""
SELECT t.id, t.title, ar.name AS artist_name, ar.amazon_id AS artist_amazon_id
FROM tracks t
JOIN artists ar ON t.artist_id = ar.id
WHERE t.amazon_match_status IS NULL AND t.id IS NOT NULL
ORDER BY t.id ASC LIMIT 1
""")
row = cursor.fetchone()
if row:
return {'type': 'track', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_amazon_id': row[3]}
not_found_cutoff = datetime.now() - timedelta(days=self.retry_days)
# Priority 4: Retry not_found artists
cursor.execute("""
SELECT id, name FROM artists
WHERE amazon_match_status = 'not_found' AND amazon_last_attempted < ?
ORDER BY amazon_last_attempted ASC LIMIT 1
""", (not_found_cutoff,))
row = cursor.fetchone()
if row:
logger.info(f"Retrying artist '{row[1]}' (last attempted before cutoff)")
return {'type': 'artist', 'id': row[0], 'name': row[1]}
# Priority 5: Retry not_found albums
cursor.execute("""
SELECT a.id, a.title, ar.name AS artist_name, ar.amazon_id AS artist_amazon_id
FROM albums a
JOIN artists ar ON a.artist_id = ar.id
WHERE a.amazon_match_status = 'not_found' AND a.amazon_last_attempted < ?
ORDER BY a.amazon_last_attempted ASC LIMIT 1
""", (not_found_cutoff,))
row = cursor.fetchone()
if row:
return {'type': 'album', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_amazon_id': row[3]}
# Priority 6: Retry not_found tracks
cursor.execute("""
SELECT t.id, t.title, ar.name AS artist_name, ar.amazon_id AS artist_amazon_id
FROM tracks t
JOIN artists ar ON t.artist_id = ar.id
WHERE t.amazon_match_status = 'not_found' AND t.amazon_last_attempted < ?
ORDER BY t.amazon_last_attempted ASC LIMIT 1
""", (not_found_cutoff,))
row = cursor.fetchone()
if row:
return {'type': 'track', 'id': row[0], 'name': row[1], 'artist': row[2], 'artist_amazon_id': row[3]}
return None
except Exception as e:
logger.error(f"Error getting next item: {e}")
return None
finally:
if conn:
conn.close()
def _normalize_name(self, name: str) -> str:
name = name.lower().strip()
name = re.sub(r'\s+[-–—]\s+.*$', '', name)
name = re.sub(r'\s*\(.*?\)\s*', ' ', name)
name = re.sub(r'[^\w\s]', '', name)
name = re.sub(r'\s+', ' ', name).strip()
return name
def _name_matches(self, query_name: str, result_name: str) -> bool:
norm_query = self._normalize_name(query_name)
norm_result = self._normalize_name(result_name)
similarity = SequenceMatcher(None, norm_query, norm_result).ratio()
logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}")
return similarity >= self.name_similarity_threshold
def _process_item(self, item: Dict[str, Any]):
try:
item_type = item['type']
item_id = item['id']
item_name = item['name']
logger.debug(f"Processing {item_type} #{item_id}: {item_name}")
if item_type == 'artist':
self._process_artist(item_id, item_name)
elif item_type == 'album':
self._process_album(item_id, item_name, item.get('artist', ''), item)
elif item_type == 'track':
self._process_track(item_id, item_name, item.get('artist', ''), item)
except Exception as e:
logger.error(f"Error processing {item['type']} #{item['id']}: {e}")
self.stats['errors'] += 1
try:
self._mark_status(item['type'], item['id'], 'error')
except Exception as e2:
logger.error(f"Error updating item status: {e2}")
def _get_existing_id(self, entity_type: str, entity_id: int) -> Optional[str]:
table_map = {'artist': 'artists', 'album': 'albums', 'track': 'tracks'}
table = table_map.get(entity_type)
if not table:
return None
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
cursor.execute(f"SELECT amazon_id FROM {table} WHERE id = ?", (entity_id,))
row = cursor.fetchone()
return row[0] if row and row[0] else None
except Exception:
return None
finally:
if conn:
conn.close()
def _process_artist(self, artist_id: int, artist_name: str):
existing_id = self._get_existing_id('artist', artist_id)
if existing_id:
logger.debug(f"Preserving existing Amazon ID for artist '{artist_name}': {existing_id}")
return
results = self.client.search_artists(artist_name, limit=5)
if results:
result = results[0]
if self._name_matches(artist_name, result.name):
self._update_artist(artist_id, result)
self.stats['matched'] += 1
logger.info(f"Matched artist '{artist_name}' -> Amazon ID: {result.id}")
else:
self._mark_status('artist', artist_id, 'not_found')
self.stats['not_found'] += 1
logger.debug(f"Name mismatch for artist '{artist_name}' (got '{result.name}')")
else:
self._mark_status('artist', artist_id, 'not_found')
self.stats['not_found'] += 1
logger.debug(f"No match for artist '{artist_name}'")
def _refresh_album_via_stored_id(self, album_id, stored_id, api_data):
self._update_album(album_id, api_data, stored_id)
def _refresh_track_via_stored_id(self, track_id, stored_id, api_data):
self._update_track(track_id, api_data, stored_id)
def _process_album(self, album_id: int, album_name: str, artist_name: str, item: Dict[str, Any]):
if honor_stored_match(
db=self.db, entity_table='albums', entity_id=album_id,
id_column='amazon_id',
client_fetch_fn=lambda asin: self.client.get_album(asin, include_tracks=False),
on_match_fn=self._refresh_album_via_stored_id,
log_prefix='Amazon',
):
self.stats['matched'] += 1
return
query = f"{artist_name} {album_name}"
results = self.client.search_albums(query, limit=10)
if results:
result = results[0]
if self._name_matches(album_name, result.name):
full_album = None
if result.id:
try:
full_album = self.client.get_album(result.id, include_tracks=False)
except Exception as e:
logger.warning(f"Failed to fetch full album '{album_name}' (ASIN: {result.id}): {e}")
if full_album is None:
self._mark_status('album', album_id, 'error')
self.stats['errors'] += 1
logger.warning(f"Album '{album_name}' matched but full details unavailable, will retry")
return
self._update_album(album_id, full_album, result.id)
self.stats['matched'] += 1
logger.info(f"Matched album '{album_name}' -> Amazon ASIN: {result.id}")
else:
self._mark_status('album', album_id, 'not_found')
self.stats['not_found'] += 1
logger.debug(f"Name mismatch for album '{album_name}' (got '{result.name}')")
else:
self._mark_status('album', album_id, 'not_found')
self.stats['not_found'] += 1
logger.debug(f"No match for album '{album_name}'")
def _process_track(self, track_id: int, track_name: str, artist_name: str, item: Dict[str, Any]):
if honor_stored_match(
db=self.db, entity_table='tracks', entity_id=track_id,
id_column='amazon_id',
client_fetch_fn=self.client.get_track_details,
on_match_fn=self._refresh_track_via_stored_id,
log_prefix='Amazon',
):
self.stats['matched'] += 1
return
query = f"{artist_name} {track_name}"
results = self.client.search_tracks(query, limit=10)
if results:
result = results[0]
if self._name_matches(track_name, result.name):
full_track = None
if result.id:
try:
full_track = self.client.get_track_details(result.id)
except Exception as e:
logger.warning(f"Failed to fetch full track '{track_name}' (ASIN: {result.id}): {e}")
if full_track is None:
self._mark_status('track', track_id, 'error')
self.stats['errors'] += 1
logger.warning(f"Track '{track_name}' matched but full details unavailable, will retry")
return
self._update_track(track_id, full_track, result.id)
self.stats['matched'] += 1
logger.info(f"Matched track '{track_name}' -> Amazon ASIN: {result.id}")
else:
self._mark_status('track', track_id, 'not_found')
self.stats['not_found'] += 1
logger.debug(f"Name mismatch for track '{track_name}' (got '{result.name}')")
else:
self._mark_status('track', track_id, 'not_found')
self.stats['not_found'] += 1
logger.debug(f"No match for track '{track_name}'")
def _update_artist(self, artist_id: int, result):
"""Store Amazon metadata for an artist. ``result`` is an Artist dataclass."""
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
cursor.execute("""
UPDATE artists SET
amazon_id = ?,
amazon_match_status = 'matched',
amazon_last_attempted = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (str(result.id), artist_id))
# Backfill thumb_url from album cover stand-in when artist has no image
image_url = result.image_url
if not image_url:
try:
image_url = self.client._get_artist_image_from_albums(result.id)
except Exception as exc:
logger.debug("Artist image from albums failed for %s: %s", result.id, exc)
if image_url:
cursor.execute("""
UPDATE artists SET thumb_url = ?
WHERE id = ? AND (thumb_url IS NULL OR thumb_url = '')
""", (image_url, artist_id))
conn.commit()
except Exception as e:
logger.error(f"Error updating artist #{artist_id} with Amazon data: {e}")
raise
finally:
if conn:
conn.close()
def _update_album(self, album_id: int, full_data: Dict[str, Any], asin: str):
"""Store Amazon metadata for an album. ``full_data`` is a get_album() dict."""
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
cursor.execute("""
UPDATE albums SET
amazon_id = ?,
amazon_match_status = 'matched',
amazon_last_attempted = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (asin, album_id))
# Backfill label when missing
label = full_data.get('label')
if label:
cursor.execute("""
UPDATE albums SET label = ?
WHERE id = ? AND (label IS NULL OR label = '')
""", (label, album_id))
# Backfill thumb_url
images = full_data.get('images') or []
thumb_url = images[0].get('url') if images else None
if thumb_url:
cursor.execute("""
UPDATE albums SET thumb_url = ?
WHERE id = ? AND (thumb_url IS NULL OR thumb_url = '')
""", (thumb_url, album_id))
# Cache authoritative track count for completeness repair
total_tracks = full_data.get('total_tracks') or (
full_data.get('tracks', {}).get('total') if isinstance(full_data.get('tracks'), dict) else None
)
set_album_api_track_count(cursor, album_id, total_tracks)
conn.commit()
except Exception as e:
logger.error(f"Error updating album #{album_id} with Amazon data: {e}")
raise
finally:
if conn:
conn.close()
def _update_track(self, track_id: int, full_data: Dict[str, Any], asin: str):
"""Store Amazon metadata for a track. ``full_data`` is a get_track_details() dict."""
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
cursor.execute("""
UPDATE tracks SET
amazon_id = ?,
amazon_match_status = 'matched',
amazon_last_attempted = CURRENT_TIMESTAMP
WHERE id = ?
""", (asin, track_id))
conn.commit()
except Exception as e:
logger.error(f"Error updating track #{track_id} with Amazon data: {e}")
raise
finally:
if conn:
conn.close()
def _mark_status(self, entity_type: str, entity_id: int, status: str):
table_map = {'artist': 'artists', 'album': 'albums', 'track': 'tracks'}
table = table_map.get(entity_type)
if not table:
logger.error(f"Unknown entity type: {entity_type}")
return
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
cursor.execute(f"""
UPDATE {table} SET
amazon_match_status = ?,
amazon_last_attempted = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (status, entity_id))
conn.commit()
except Exception as e:
logger.error(f"Error marking {entity_type} #{entity_id} status: {e}")
finally:
if conn:
conn.close()
def _count_pending_items(self) -> int:
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
self._ensure_amazon_schema(cursor)
cursor.execute("""
SELECT
(SELECT COUNT(*) FROM artists WHERE amazon_match_status IS NULL AND id IS NOT NULL) +
(SELECT COUNT(*) FROM albums WHERE amazon_match_status IS NULL AND id IS NOT NULL) +
(SELECT COUNT(*) FROM tracks WHERE amazon_match_status IS NULL AND id IS NOT NULL)
AS pending
""")
row = cursor.fetchone()
return row[0] if row else 0
except Exception as e:
logger.error(f"Error counting pending items: {e}")
return 0
finally:
if conn:
conn.close()
def _get_progress_breakdown(self) -> Dict[str, Dict[str, int]]:
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
self._ensure_amazon_schema(cursor)
progress = {}
for table in ('artists', 'albums', 'tracks'):
cursor.execute(f"""
SELECT
COUNT(*) AS total,
SUM(CASE WHEN amazon_match_status IS NOT NULL THEN 1 ELSE 0 END) AS processed
FROM {table}
""")
row = cursor.fetchone()
if row:
total, processed = row[0], row[1] or 0
progress[table] = {
'matched': processed,
'total': total,
'percent': int((processed / total * 100) if total > 0 else 0),
}
return progress
except Exception as e:
logger.error(f"Error getting progress breakdown: {e}")
return {}
finally:
if conn:
conn.close()

View file

@ -30,6 +30,7 @@ RATE_LIMITS = {
'tidal': 120, # MIN_API_INTERVAL=0.5s → ~120/min
'qobuz': 60, # Variable throttle, ~60/min estimate
'discogs': 60, # MIN_API_INTERVAL=1.0s with auth → ~60/min
'amazon': 120, # MIN_API_INTERVAL=0.5s → ~120/min (T2Tunes proxy)
}
# Display names for UI
@ -44,12 +45,13 @@ SERVICE_LABELS = {
'tidal': 'Tidal',
'qobuz': 'Qobuz',
'discogs': 'Discogs',
'amazon': 'Amazon Music',
}
# Display order
SERVICE_ORDER = [
'spotify', 'itunes', 'deezer', 'lastfm', 'genius',
'musicbrainz', 'audiodb', 'tidal', 'qobuz', 'discogs',
'musicbrainz', 'audiodb', 'tidal', 'qobuz', 'discogs', 'amazon',
]

238
core/archive_pipeline.py Normal file
View file

@ -0,0 +1,238 @@
"""Archive extraction + audio-file discovery for torrent / usenet downloads.
The torrent and usenet download plugins need a uniform way to:
1. Walk the downloader's save directory and find every audio file in it.
2. If the directory contains an archive (``.zip`` / ``.rar`` / ``.tar`` /
``.7z``), extract it first so the audio files inside become walkable.
This module is intentionally narrow no matching, no tagging, no
import. The download plugin layer composes this with the existing
post-processing / matching pipeline. Lidarr does NOT use this module:
Lidarr extracts archives in its own import step before SoulSync sees
the files at all. Usenet downloaders (SABnzbd, NZBGet) also auto-
extract by default. Torrents are the main case where SoulSync may
need to do the extract step itself most music torrents ship loose,
but some bundle the album in a ``.rar`` archive.
``rarfile`` is an optional dependency. If it isn't installed, archives
with ``.rar`` content are skipped with a single warning rather than
crashing the download.
"""
from __future__ import annotations
import tarfile
import zipfile
from pathlib import Path
from typing import List, Optional
from utils.logging_config import get_logger
logger = get_logger("archive_pipeline")
# Same audio-extension set as ``core/imports/file_ops.py`` ``quality_tiers``.
# Keep them in sync — if a new format is added to file_ops, add it here too
# or the walker will skip it and the download plugin will mark the download
# failed even when files arrived.
AUDIO_EXTENSIONS = frozenset([
# lossless
'.flac', '.ape', '.wav', '.alac', '.dsf', '.dff', '.aiff', '.aif',
# high lossy
'.opus', '.ogg',
# standard lossy
'.m4a', '.aac',
# low lossy
'.mp3', '.wma',
])
ARCHIVE_EXTENSIONS = frozenset(['.zip', '.rar', '.tar', '.tar.gz', '.tgz', '.7z'])
def is_archive(path: Path) -> bool:
"""True if the file extension looks like a supported archive.
Compound extensions (``.tar.gz``, ``.tar.bz2``) are detected by
checking the last two suffixes joined together Path.suffix
only returns the final suffix.
"""
if not path.is_file():
return False
name = path.name.lower()
if name.endswith(('.tar.gz', '.tar.bz2', '.tar.xz')):
return True
return path.suffix.lower() in ARCHIVE_EXTENSIONS
def walk_audio_files(directory: Path) -> List[Path]:
"""Recursively scan ``directory`` for audio files. Returns
a sorted list of absolute paths. Empty list if the directory
doesn't exist or contains no audio.
"""
if not directory or not directory.exists() or not directory.is_dir():
return []
out: List[Path] = []
for child in directory.rglob('*'):
if not child.is_file():
continue
if child.suffix.lower() in AUDIO_EXTENSIONS:
out.append(child.resolve())
out.sort()
return out
def find_archives_in_dir(directory: Path) -> List[Path]:
"""Find every archive file directly inside ``directory`` (one
level deep torrents normally put the archive at the root of
their folder; we don't search nested dirs to avoid extracting
something we shouldn't).
"""
if not directory or not directory.exists() or not directory.is_dir():
return []
return sorted(p for p in directory.iterdir() if is_archive(p))
def extract_archive(archive_path: Path, extract_to: Optional[Path] = None) -> Optional[Path]:
"""Extract a single archive in-place (or to ``extract_to`` if
given). Returns the directory the archive was extracted into,
or ``None`` on failure.
Supports ``.zip``, ``.tar``/``.tar.gz``/``.tar.bz2``/``.tar.xz``,
and ``.rar`` (only when the optional ``rarfile`` library is
installed). ``.7z`` is recognised but extraction requires
``py7zr``; without it, the call logs and returns None.
"""
if not archive_path or not archive_path.exists():
logger.warning("archive_pipeline: %s does not exist", archive_path)
return None
dest = extract_to or archive_path.parent
dest.mkdir(parents=True, exist_ok=True)
name = archive_path.name.lower()
try:
if name.endswith('.zip'):
with zipfile.ZipFile(archive_path) as zf:
_safe_extract_zip(zf, dest)
return dest
if name.endswith(('.tar', '.tar.gz', '.tgz', '.tar.bz2', '.tar.xz')):
with tarfile.open(archive_path) as tf:
_safe_extract_tar(tf, dest)
return dest
if name.endswith('.rar'):
return _extract_rar(archive_path, dest)
if name.endswith('.7z'):
return _extract_7z(archive_path, dest)
except (zipfile.BadZipFile, tarfile.TarError, OSError) as e:
logger.error("archive_pipeline: failed to extract %s: %s", archive_path, e)
return None
logger.warning("archive_pipeline: unknown archive type for %s", archive_path)
return None
def extract_all_in_dir(directory: Path) -> List[Path]:
"""Find every archive in ``directory`` and extract each in place.
Returns the list of directories archives were extracted into
(usually all the same ``directory`` itself). Archives that
failed to extract are skipped silently after a warning.
"""
out: List[Path] = []
for archive in find_archives_in_dir(directory):
result = extract_archive(archive)
if result is not None:
out.append(result)
return out
def collect_audio_after_extraction(directory: Path) -> List[Path]:
"""One-shot helper for the download plugins: extract any archives
in the directory, then return the walked audio file list. This is
the common pattern torrent / usenet plugin gets a save_path,
calls this, hands the resulting files to the matching pipeline.
"""
extract_all_in_dir(directory)
return walk_audio_files(directory)
# ---------------------------------------------------------------------------
# Safety helpers
# ---------------------------------------------------------------------------
def _safe_extract_zip(zf: zipfile.ZipFile, dest: Path) -> None:
"""Extract a zipfile after rejecting any member whose resolved
path escapes ``dest`` (path traversal protection).
"""
dest = dest.resolve()
for member in zf.namelist():
target = (dest / member).resolve()
if dest not in target.parents and target != dest:
logger.error("archive_pipeline: refusing path-traversal member %r", member)
return
zf.extractall(dest)
def _safe_extract_tar(tf: tarfile.TarFile, dest: Path) -> None:
"""Same path-traversal protection for tarfiles."""
dest = dest.resolve()
for member in tf.getmembers():
target = (dest / member.name).resolve()
if dest not in target.parents and target != dest:
logger.error("archive_pipeline: refusing path-traversal member %r", member.name)
return
# ``filter='data'`` is the Python 3.12+ safe extractor; fall back
# to the legacy call on older runtimes.
try:
tf.extractall(dest, filter='data') # type: ignore[call-arg]
except TypeError:
tf.extractall(dest)
def _extract_rar(archive_path: Path, dest: Path) -> Optional[Path]:
try:
import rarfile # type: ignore[import-untyped]
except ImportError:
logger.warning(
"archive_pipeline: cannot extract %s — rarfile library not installed. "
"Install with: pip install rarfile (and ensure unrar is on PATH).",
archive_path,
)
return None
try:
with rarfile.RarFile(archive_path) as rf:
dest_resolved = dest.resolve()
for name in rf.namelist():
target = (dest_resolved / name).resolve()
if dest_resolved not in target.parents and target != dest_resolved:
logger.error("archive_pipeline: refusing path-traversal rar member %r", name)
return None
rf.extractall(dest)
return dest
except Exception as e:
logger.error("archive_pipeline: rar extract failed for %s: %s", archive_path, e)
return None
def _extract_7z(archive_path: Path, dest: Path) -> Optional[Path]:
try:
import py7zr # type: ignore[import-untyped]
except ImportError:
logger.warning(
"archive_pipeline: cannot extract %s — py7zr library not installed. "
"Install with: pip install py7zr.",
archive_path,
)
return None
try:
with py7zr.SevenZipFile(archive_path, 'r') as sz:
dest_resolved = dest.resolve()
for name in sz.getnames():
target = (dest_resolved / name).resolve()
if dest_resolved not in target.parents and target != dest_resolved:
logger.error("archive_pipeline: refusing path-traversal 7z member %r", name)
return None
sz.extractall(path=dest)
return dest
except Exception as e:
logger.error("archive_pipeline: 7z extract failed for %s: %s", archive_path, e)
return None

View file

@ -43,6 +43,7 @@ def build_source_only_artist_detail(
deezer_client: Optional[Any] = None,
itunes_client: Optional[Any] = None,
discogs_client: Optional[Any] = None,
amazon_client: Optional[Any] = None,
lastfm_api_key: Optional[str] = None,
) -> Tuple[Dict[str, Any], int]:
"""Build the artist-detail payload for a source-only artist.
@ -51,7 +52,7 @@ def build_source_only_artist_detail(
``jsonify`` or equivalent. Status is 200 on success, 404 when the
source's discography lookup returned no releases.
"""
resolved_name = (artist_name or artist_id or "").strip()
resolved_name = (artist_name or "").strip()
# 1. Image URL via the same helper /api/artist/<id>/image uses.
image_url: Optional[str] = None
@ -67,6 +68,8 @@ def build_source_only_artist_detail(
if source == "spotify" and spotify_client is not None:
sp_artist = spotify_client.get_artist(artist_id, allow_fallback=False)
if sp_artist:
if not artist_name and sp_artist.get("name"):
resolved_name = sp_artist["name"]
source_genres = sp_artist.get("genres") or []
source_followers = (sp_artist.get("followers") or {}).get("total")
if not image_url and sp_artist.get("images"):
@ -74,16 +77,41 @@ def build_source_only_artist_detail(
elif source == "deezer" and deezer_client is not None:
dz_artist = deezer_client.get_artist_info(artist_id)
if dz_artist:
if not artist_name and dz_artist.get("name"):
resolved_name = dz_artist["name"]
source_genres = dz_artist.get("genres") or []
source_followers = (dz_artist.get("followers") or {}).get("total")
elif source == "itunes" and itunes_client is not None:
it_artist = itunes_client.get_artist(artist_id)
if it_artist:
if not artist_name and it_artist.get("name"):
resolved_name = it_artist["name"]
source_genres = it_artist.get("genres") or []
elif source == "discogs" and discogs_client is not None:
dc_artist = discogs_client.get_artist(artist_id)
if dc_artist:
if not artist_name and dc_artist.get("name"):
resolved_name = dc_artist["name"]
source_genres = dc_artist.get("genres") or []
elif source == "amazon" and amazon_client is not None:
az_artist = amazon_client.get_artist(resolved_name or artist_id)
if az_artist:
if not artist_name and az_artist.get("name"):
resolved_name = az_artist["name"]
source_genres = az_artist.get("genres") or []
if not image_url and az_artist.get("images"):
image_url = az_artist["images"][0].get("url")
elif source == "musicbrainz":
try:
from core.musicbrainz_search import MusicBrainzSearchClient
mb = MusicBrainzSearchClient()
mb_artist = mb.get_artist(artist_id)
if mb_artist:
if not artist_name and mb_artist.get("name"):
resolved_name = mb_artist["name"]
source_genres = mb_artist.get("genres") or []
except Exception as e:
logger.debug(f"MusicBrainz artist info lookup failed for {artist_id}: {e}")
except Exception as e:
logger.debug(f"Source-side artist info lookup failed for {source}:{artist_id}: {e}")

View file

@ -25,7 +25,7 @@ logger = logging.getLogger("artist_source_lookup")
SOURCE_ONLY_ARTIST_SOURCES = frozenset({
"spotify", "itunes", "deezer", "discogs", "hydrabase", "musicbrainz",
"spotify", "itunes", "deezer", "discogs", "hydrabase", "musicbrainz", "amazon",
})
@ -36,6 +36,7 @@ SOURCE_ID_FIELD = {
"discogs": "discogs_id",
"hydrabase": "soul_id",
"musicbrainz": "musicbrainz_id",
"amazon": "amazon_id",
}

View file

@ -12,6 +12,7 @@ from core.metadata.registry import (
get_deezer_client,
get_discogs_client,
get_itunes_client,
get_musicbrainz_client,
get_spotify_client,
)
@ -33,6 +34,11 @@ def _get_discogs_client(token=None):
return get_discogs_client(token)
def _get_musicbrainz_client():
"""Mirror of web_server._get_musicbrainz_client — delegates to registry."""
return get_musicbrainz_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
@ -65,6 +71,7 @@ def _match_liked_artists_to_all_sources(database, profile_id: int):
'itunes': 'itunes_artist_id',
'deezer': 'deezer_artist_id',
'discogs': 'discogs_artist_id',
'musicbrainz': 'musicbrainz_artist_id',
}
id_cols = list(source_cols.values())
@ -103,6 +110,10 @@ def _match_liked_artists_to_all_sources(database, profile_id: int):
search_clients['discogs'] = dc
except Exception as e:
logger.debug("discogs client init failed: %s", e)
try:
search_clients['musicbrainz'] = _get_musicbrainz_client()
except Exception as e:
logger.debug("musicbrainz client init failed: %s", e)
# Reuse watchlist scanner's fuzzy matching logic
from core.watchlist_scanner import WatchlistScanner
@ -234,7 +245,7 @@ def _match_liked_artists_to_all_sources(database, profile_id: int):
# Determine best active source/ID — prefer Spotify, then iTunes, Deezer, Discogs
resolved_source = None
resolved_id = None
for src in ('spotify', 'itunes', 'deezer', 'discogs'):
for src in ('spotify', 'itunes', 'deezer', 'discogs', 'musicbrainz'):
col = source_cols[src]
if col in harvested_ids:
resolved_source = src

View file

@ -20,10 +20,14 @@ logger = logging.getLogger(__name__)
def get_current_profile_id() -> int:
"""Mirror of web_server.get_current_profile_id — uses Flask g."""
"""Mirror of web_server.get_current_profile_id — uses Flask g.
Catches RuntimeError too because reading `g` outside a request
context raises that (not AttributeError) happens when this is
called from background threads (sync, automation, scanners)."""
try:
return g.profile_id
except AttributeError:
except (AttributeError, RuntimeError):
return 1
@ -136,7 +140,7 @@ def get_artist_map_data():
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,
similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_musicbrainz_id,
similarity_rank, occurrence_count, image_url, genres, popularity
FROM similar_artists
WHERE profile_id = ? AND source_artist_id IN ({placeholders})
@ -169,6 +173,7 @@ def get_artist_map_data():
'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 '',
'musicbrainz_id': r.get('similar_artist_musicbrainz_id') or '',
'rank': r.get('similarity_rank', 5),
'occurrence': r.get('occurrence_count', 1),
'popularity': r.get('popularity', 0),
@ -241,7 +246,7 @@ def get_artist_map_data():
}
# Apply cache data to nodes
source_id_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id'}
source_id_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id', 'musicbrainz': 'musicbrainz_id'}
for n in nodes:
nn = _norm(n['name'])
cached = cache_by_name.get(nn)
@ -258,7 +263,7 @@ def get_artist_map_data():
break
# Backfill genres if missing
if not n.get('genres') or len(n.get('genres', [])) == 0:
for source in ('spotify', 'deezer', 'itunes', 'discogs'):
for source in ('spotify', 'deezer', 'itunes', 'discogs', 'musicbrainz'):
if source in cached and cached[source].get('genres'):
n['genres'] = cached[source]['genres'][:5]
break
@ -365,14 +370,14 @@ def get_artist_map_genres():
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):
def _add(name, image_url=None, genres=None, spotify_id=None, itunes_id=None, deezer_id=None, discogs_id=None, musicbrainz_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': '',
'spotify_id': '', 'itunes_id': '', 'deezer_id': '', 'discogs_id': '', 'musicbrainz_id': '',
'sources': set(), 'popularity': 0
}
a = artists_by_name[n]
@ -390,6 +395,8 @@ def get_artist_map_genres():
a['deezer_id'] = str(deezer_id)
if discogs_id and not a['discogs_id']:
a['discogs_id'] = str(discogs_id)
if musicbrainz_id and not a['musicbrainz_id']:
a['musicbrainz_id'] = str(musicbrainz_id)
if popularity > a['popularity']:
a['popularity'] = popularity
a['sources'].add(source)
@ -406,14 +413,14 @@ def get_artist_map_genres():
genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
except Exception as e:
logger.debug("cache artist genres parse failed: %s", e)
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id'}
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id', 'musicbrainz': 'musicbrainz_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
similar_artist_deezer_id, similar_artist_musicbrainz_id, image_url, genres, popularity
FROM similar_artists WHERE profile_id = ?
""", (profile_id,))
for r in cursor.fetchall():
@ -425,7 +432,9 @@ def get_artist_map_genres():
logger.debug("similar artist genres parse failed: %s", e)
_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)
deezer_id=r['similar_artist_deezer_id'],
musicbrainz_id=r['similar_artist_musicbrainz_id'] if 'similar_artist_musicbrainz_id' in r.keys() else None,
source='similar', popularity=r['popularity'] or 0)
# 3. Watchlist artists
cursor.execute("""
@ -479,6 +488,7 @@ def get_artist_map_genres():
'itunes_id': a['itunes_id'],
'deezer_id': a['deezer_id'],
'discogs_id': a['discogs_id'],
'musicbrainz_id': a['musicbrainz_id'],
'popularity': a['popularity'],
'type': 'watchlist' if 'watchlist' in a['sources'] else 'similar',
})
@ -622,7 +632,7 @@ def get_artist_map_explore():
# Find the center artist
center_name = artist_name
center_image = ''
center_ids = {'spotify_id': '', 'itunes_id': '', 'deezer_id': '', 'discogs_id': ''}
center_ids = {'spotify_id': '', 'itunes_id': '', 'deezer_id': '', 'discogs_id': '', 'musicbrainz_id': ''}
center_genres = []
# Search metadata cache for the center artist
@ -644,7 +654,7 @@ def get_artist_map_explore():
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'}
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id', 'musicbrainz': 'musicbrainz_id'}
k = src_map.get(row['source'], 'spotify_id')
center_ids[k] = row['entity_id']
if row['genres']:
@ -655,14 +665,14 @@ def get_artist_map_explore():
# 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,))
cursor.execute("SELECT artist_name, image_url, spotify_artist_id, itunes_artist_id, deezer_artist_id, discogs_artist_id, musicbrainz_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')]:
for k, col in [('spotify_id', 'spotify_artist_id'), ('itunes_id', 'itunes_artist_id'), ('deezer_id', 'deezer_artist_id'), ('discogs_id', 'discogs_artist_id'), ('musicbrainz_id', 'musicbrainz_artist_id')]:
if wr[col]:
center_ids[k] = str(wr[col])
else:
@ -713,7 +723,7 @@ def get_artist_map_explore():
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'}
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id', 'musicbrainz': 'musicbrainz_id'}
k = src_map.get(r['source'], 'spotify_id')
if not center_ids.get(k):
center_ids[k] = r['entity_id']
@ -742,7 +752,7 @@ def get_artist_map_explore():
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,
similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_musicbrainz_id,
image_url, genres, popularity, similarity_rank
FROM similar_artists
WHERE source_artist_id IN ({placeholders}) AND profile_id = ?
@ -753,7 +763,7 @@ def get_artist_map_explore():
# 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.similar_artist_itunes_id, sa.similar_artist_deezer_id, sa.similar_artist_musicbrainz_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))
@ -785,7 +795,8 @@ def get_artist_map_explore():
image_url=sa.get('image_url'),
genres=sa.get('genres'),
popularity=sa.get('popularity', 0),
similar_artist_deezer_id=sa.get('deezer_id')
similar_artist_deezer_id=sa.get('deezer_id'),
similar_artist_musicbrainz_id=sa.get('musicbrainz_id'),
)
except Exception as e:
logger.debug("similar artist insert failed: %s", e)
@ -794,7 +805,7 @@ def get_artist_map_explore():
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,
similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_musicbrainz_id,
image_url, genres, popularity, similarity_rank
FROM similar_artists
WHERE source_artist_id IN ({placeholders}) AND profile_id = ?
@ -805,7 +816,7 @@ def get_artist_map_explore():
# 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,
similar_artist_itunes_id, similar_artist_deezer_id, similar_artist_musicbrainz_id,
image_url, genres, popularity, similarity_rank
FROM similar_artists
WHERE source_artist_id = ? AND profile_id = ?
@ -837,6 +848,7 @@ def get_artist_map_explore():
'spotify_id': r['similar_artist_spotify_id'] or '',
'itunes_id': r['similar_artist_itunes_id'] or '',
'deezer_id': r['similar_artist_deezer_id'] or '',
'musicbrainz_id': r['similar_artist_musicbrainz_id'] if 'similar_artist_musicbrainz_id' in r.keys() else '',
'discogs_id': '',
'popularity': r['popularity'] or 0,
'rank': r['similarity_rank'] or 5,
@ -857,7 +869,8 @@ def get_artist_map_explore():
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
similar_artist_deezer_id, similar_artist_musicbrainz_id,
image_url, genres, popularity, similarity_rank
FROM similar_artists
WHERE source_artist_id IN ({placeholders}) AND profile_id = ?
ORDER BY similarity_rank ASC
@ -898,6 +911,7 @@ def get_artist_map_explore():
'spotify_id': r['similar_artist_spotify_id'] or '',
'itunes_id': r['similar_artist_itunes_id'] or '',
'deezer_id': r['similar_artist_deezer_id'] or '',
'musicbrainz_id': r['similar_artist_musicbrainz_id'] if 'similar_artist_musicbrainz_id' in r.keys() else '',
'discogs_id': '',
'popularity': r['popularity'] or 0,
'rank': r['similarity_rank'] or 5,
@ -931,7 +945,7 @@ def get_artist_map_explore():
except Exception as e:
logger.debug("explorer node genres parse failed: %s", e)
# Harvest missing IDs from cache
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id'}
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id', 'musicbrainz': 'musicbrainz_id'}
k = src_map.get(cr['source'])
if k and not n.get(k):
n[k] = cr['entity_id']

View file

@ -1,7 +1,11 @@
"""Automation API + progress tracking helpers package.
"""Automation API + progress + handlers package.
Lifted from web_server.py /api/automations/* routes and progress
emitters. The action handler registration (`_register_automation_handlers`)
stays in web_server.py because each handler closure is tightly coupled
to other application features.
Lifted from web_server.py:
- `/api/automations/*` route helpers `api.py`
- block library used by the trigger/action UI `blocks.py`
- progress tracker (init / update / finish) `progress.py`
- cross-handler signal bus `signals.py`
- per-action handler functions `handlers/` subpackage (with
`deps.py` defining the dependency-injection surface so handlers
stay testable in isolation)
"""

View file

@ -146,6 +146,15 @@ ACTIONS: list[dict] = [
{"key": "all", "type": "checkbox", "label": "Process all mirrored playlists", "default": False},
{"key": "skip_wishlist", "type": "checkbox", "label": "Skip wishlist processing", "default": False},
]},
{"type": "personalized_pipeline", "label": "Personalized Playlist Pipeline", "icon": "sparkles",
"description": "Sync personalized / discover-page playlists (Hidden Gems, Time Machine, Fresh Tape, etc.) to your media server + queue missing tracks for download.",
"available": True,
"config_fields": [
{"key": "kinds", "type": "personalized_playlist_select", "label": "Playlists to sync",
"description": "Multi-select: Hidden Gems, Discovery Shuffle, Time Machine (per decade), Genre playlists, Fresh Tape, The Archives, Seasonal Mix (per season)"},
{"key": "refresh_first", "type": "checkbox", "label": "Refresh playlists before sync (regenerate snapshots)", "default": False},
{"key": "skip_wishlist", "type": "checkbox", "label": "Skip wishlist processing", "default": False},
]},
{"type": "notify_only", "label": "Notify Only", "icon": "bell", "description": "No action — just send notification", "available": True},
# Phase 3 actions
{"type": "start_database_update", "label": "Update Database", "icon": "database",

146
core/automation/deps.py Normal file
View file

@ -0,0 +1,146 @@
"""Dependency-injection surface for automation handlers.
Each handler in ``core.automation.handlers`` is a top-level pure
function that accepts ``(config: dict, deps: AutomationDeps)`` instead
of reaching for module-level globals in ``web_server``. The deps
namespace bundles every callable, client, and mutable-state container
the handlers need.
Construction happens once at app startup in ``web_server.py``:
from core.automation.deps import AutomationDeps, AutomationState
state = AutomationState()
deps = AutomationDeps(
engine=automation_engine,
state=state,
get_database=get_database,
spotify_client=spotify_client,
...
)
register_all(deps)
Tests construct a fake ``AutomationDeps`` with stub callables every
handler is then exercisable without spinning up Flask, the DB, or
the real media-server clients.
"""
from __future__ import annotations
import threading
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
@dataclass
class AutomationState:
"""Mutable flags shared across handler invocations.
Pre-refactor each was a ``global`` or ``nonlocal`` variable inside
the registration closure. Lifted here so handlers + their guards
can read/write a single object instead of importing globals.
All mutations should hold ``lock``; the helper methods below do
so for the common get/set patterns.
"""
scan_library_automation_id: Optional[str] = None
db_update_automation_id: Optional[str] = None
pipeline_running: bool = False
lock: threading.Lock = field(default_factory=threading.Lock)
def is_scan_library_active(self) -> bool:
with self.lock:
return self.scan_library_automation_id is not None
def is_pipeline_running(self) -> bool:
with self.lock:
return self.pipeline_running
def set_scan_library_id(self, automation_id: Optional[str]) -> None:
with self.lock:
self.scan_library_automation_id = automation_id
def set_pipeline_running(self, value: bool) -> None:
with self.lock:
self.pipeline_running = value
@dataclass
class AutomationDeps:
"""Bundle of every callable + client an automation handler may need.
Add fields as new handlers are extracted. Every field is required
at construction (no defaults) so a missing dep fails loudly at
startup, not silently mid-handler.
"""
# --- Engine + shared state ---
engine: Any # AutomationEngine instance
state: AutomationState
config_manager: Any # config.settings.ConfigManager singleton
update_progress: Callable[..., None] # _update_automation_progress
logger: Any # module-level logger from utils.logging_config
# --- Service clients (each may be None depending on user config) ---
get_database: Callable[[], Any] # late-binding so tests don't need DB
spotify_client: Any
tidal_client: Any
web_scan_manager: Any
# --- Background-task entry points ---
process_wishlist_automatically: Callable[..., Any]
process_watchlist_scan_automatically: Callable[..., Any]
is_wishlist_actually_processing: Callable[[], bool]
is_watchlist_actually_scanning: Callable[[], bool]
get_watchlist_scan_state: Callable[[], dict] # accessor returns the live mutable dict
# --- Playlist pipeline entry points ---
run_playlist_discovery_worker: Callable[..., Any]
run_sync_task: Callable[..., Any]
load_sync_status_file: Callable[[], dict]
get_deezer_client: Callable[[], Any]
parse_youtube_playlist: Callable[[str], Any]
get_sync_states: Callable[[], dict] # accessor returns the live dict shared with the sync UI
# --- Database update + quality scanner (shared state + executors) ---
set_db_update_automation_id: Callable[[Optional[str]], None] # syncs the legacy `_db_update_automation_id` global so the live DB-update progress callbacks (which still read the global directly) keep firing for the active automation
get_db_update_state: Callable[[], dict]
db_update_lock: Any # threading.Lock
db_update_executor: Any # ThreadPoolExecutor
run_db_update_task: Callable[..., Any]
run_deep_scan_task: Callable[..., Any]
get_duplicate_cleaner_state: Callable[[], dict]
duplicate_cleaner_lock: Any
duplicate_cleaner_executor: Any
run_duplicate_cleaner: Callable[..., Any]
get_quality_scanner_state: Callable[[], dict]
quality_scanner_lock: Any
quality_scanner_executor: Any
run_quality_scanner: Callable[..., Any]
# --- Download orchestrator + queue accessors ---
download_orchestrator: Any
run_async: Callable[..., Any]
tasks_lock: Any
get_download_batches: Callable[[], dict]
get_download_tasks: Callable[[], dict]
sweep_empty_download_directories: Callable[[], int]
get_staging_path: Callable[[], str]
# --- Maintenance helpers ---
docker_resolve_path: Callable[[str], str]
get_current_profile_id: Callable[[], int]
get_watchlist_scanner: Callable[[Any], Any]
get_app: Callable[[], Any] # Flask app for test_client (beatport refresh)
get_beatport_data_cache: Callable[[], dict]
# --- Progress + history callbacks (used by register_all to wire
# the engine's progress callback hooks). ---
init_automation_progress: Callable[..., Any]
record_progress_history: Callable[..., Any]
# --- Personalized playlist pipeline ---
# Lazy builder so the pipeline handler can construct a fresh
# PersonalizedPlaylistManager per run (cheap accessors inside,
# no caching needed yet).
build_personalized_manager: Callable[[], Any]

View file

@ -0,0 +1,64 @@
"""Per-action automation handlers.
Each module in this subpackage exposes one top-level handler function
(or a small cluster of related handlers) of the form::
def auto_<action_name>(config: dict, deps: AutomationDeps) -> dict
The ``register_all`` helper in :mod:`registration` wires every handler
to the engine in one place. ``web_server.py`` calls
``register_all(deps)`` once at startup.
"""
from core.automation.handlers.process_wishlist import auto_process_wishlist
from core.automation.handlers.scan_watchlist import auto_scan_watchlist
from core.automation.handlers.scan_library import auto_scan_library
from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored
from core.automation.handlers.sync_playlist import auto_sync_playlist
from core.automation.handlers.discover_playlist import auto_discover_playlist
from core.automation.handlers.playlist_pipeline import auto_playlist_pipeline
from core.automation.handlers.personalized_pipeline import auto_personalized_pipeline
from core.automation.handlers.database_update import auto_start_database_update, auto_deep_scan_library
from core.automation.handlers.duplicate_cleaner import auto_run_duplicate_cleaner
from core.automation.handlers.quality_scanner import auto_start_quality_scan
from core.automation.handlers.maintenance import (
auto_clear_quarantine,
auto_cleanup_wishlist,
auto_update_discovery_pool,
auto_backup_database,
auto_refresh_beatport_cache,
)
from core.automation.handlers.download_cleanup import (
auto_clean_search_history,
auto_clean_completed_downloads,
auto_full_cleanup,
)
from core.automation.handlers.run_script import auto_run_script
from core.automation.handlers.search_and_download import auto_search_and_download
from core.automation.handlers.registration import register_all
__all__ = [
'auto_process_wishlist',
'auto_scan_watchlist',
'auto_scan_library',
'auto_refresh_mirrored',
'auto_sync_playlist',
'auto_discover_playlist',
'auto_playlist_pipeline',
'auto_personalized_pipeline',
'auto_start_database_update',
'auto_deep_scan_library',
'auto_run_duplicate_cleaner',
'auto_start_quality_scan',
'auto_clear_quarantine',
'auto_cleanup_wishlist',
'auto_update_discovery_pool',
'auto_backup_database',
'auto_refresh_beatport_cache',
'auto_clean_search_history',
'auto_clean_completed_downloads',
'auto_full_cleanup',
'auto_run_script',
'auto_search_and_download',
'register_all',
]

View file

@ -0,0 +1,201 @@
"""Shared helpers between mirrored + personalized playlist pipelines.
Both pipelines end in the same shape:
1. SYNC each playlist to the active media server.
2. WISHLIST: trigger the wishlist processor for missing tracks.
The differing prefix (mirrored = REFRESH external sources + DISCOVER
metadata; personalized = SNAPSHOT manager-backed playlists) is owned
by each pipeline. This module owns the SYNC + WISHLIST tail so both
pipelines stay consistent + DRY.
"""
from __future__ import annotations
import time
from typing import Any, Callable, Dict, List, Optional
from core.automation.deps import AutomationDeps
# Per-playlist sync poll cap (mirrored side already used this).
_SYNC_PER_PLAYLIST_TIMEOUT_SECONDS = 600
# Sync-status final-state markers.
_SYNC_TERMINAL_STATUSES = ('finished', 'complete', 'error', 'failed')
def run_sync_and_wishlist(
deps: AutomationDeps,
automation_id: Optional[str],
playlists: List[Dict[str, Any]],
*,
sync_one_fn: Callable[[Dict[str, Any]], Dict[str, Any]],
sync_id_for_fn: Callable[[Dict[str, Any]], str],
skip_wishlist: bool = False,
progress_start: int = 56,
progress_end: int = 85,
sync_phase_label: str = 'Phase: Syncing to server...',
sync_phase_start_log: str = 'Sync',
wishlist_phase_label: str = 'Phase: Processing wishlist...',
wishlist_phase_start_log: str = 'Wishlist',
) -> Dict[str, int]:
"""Run the SYNC + WISHLIST tail of a playlist pipeline.
The caller supplies:
- ``playlists``: list of playlist payload dicts. Each must have at
least a ``name`` key (used in progress logs). The shape beyond
``name`` is opaque to the helper ``sync_one_fn`` receives the
payload and returns a sync_result dict.
- ``sync_one_fn(payload) -> sync_result``: launches sync for one
playlist. Result dict must carry ``status`` ``('started',
'skipped', 'error')`` and may carry ``reason``.
- ``sync_id_for_fn(payload) -> str``: returns the sync-state key
the helper polls on (so we can wait for the background sync
thread to complete + read the matched_tracks count).
Returns ``{'synced': int, 'skipped': int, 'errors': int,
'wishlist_queued': int}`` so the caller can stitch it into its
final status.
"""
deps.update_progress(
automation_id,
progress=progress_start,
phase=sync_phase_label,
log_line=sync_phase_start_log,
log_type='info',
)
total_synced = 0
total_skipped = 0
sync_errors = 0
sync_states = deps.get_sync_states()
n_playlists = max(1, len(playlists))
progress_span = max(1, progress_end - progress_start - 1)
for pl_idx, pl in enumerate(playlists):
pl_name = pl.get('name', '')
sync_result = sync_one_fn(pl)
sync_status = sync_result.get('status', '')
if sync_status == 'started':
sync_id = sync_id_for_fn(pl)
sync_poll_start = time.time()
while time.time() - sync_poll_start < _SYNC_PER_PLAYLIST_TIMEOUT_SECONDS:
if (sync_id in sync_states
and sync_states[sync_id].get('status') in _SYNC_TERMINAL_STATUSES):
break
time.sleep(2)
elapsed = int(time.time() - sync_poll_start)
sub_progress = progress_start + 1 + ((pl_idx + 1) / n_playlists) * progress_span
deps.update_progress(
automation_id,
progress=min(int(sub_progress), progress_end - 1),
phase=f'{sync_phase_label.rstrip(".")}"{pl_name}" ({elapsed}s)',
)
ss = sync_states.get(sync_id, {})
ss_result = ss.get('result', ss.get('progress', {}))
matched = ss_result.get('matched_tracks', 0) if isinstance(ss_result, dict) else 0
total_synced += int(matched) if matched else 0
deps.update_progress(
automation_id,
log_line=f'Synced "{pl_name}": {matched} tracks matched',
log_type='success',
)
elif sync_status == 'skipped':
total_skipped += 1
reason = sync_result.get('reason', 'unchanged')
deps.update_progress(
automation_id,
log_line=f'Skipped "{pl_name}": {reason}',
log_type='skip',
)
elif sync_status == 'error':
sync_errors += 1
deps.update_progress(
automation_id,
log_line=f'Sync error "{pl_name}": {sync_result.get("reason", "unknown")}',
log_type='error',
)
deps.update_progress(
automation_id,
progress=progress_end,
phase=f'{sync_phase_label.rstrip(".")} complete',
log_line=f'Sync done: {total_synced} matched, {total_skipped} skipped, {sync_errors} errors',
log_type='success' if sync_errors == 0 else 'warning',
)
wishlist_queued = run_wishlist_phase(
deps, automation_id,
skip=skip_wishlist,
progress_pct=progress_end + 1,
wishlist_phase_label=wishlist_phase_label,
wishlist_phase_start_log=wishlist_phase_start_log,
)
return {
'synced': total_synced,
'skipped': total_skipped,
'errors': sync_errors,
'wishlist_queued': wishlist_queued,
}
def run_wishlist_phase(
deps: AutomationDeps,
automation_id: Optional[str],
*,
skip: bool,
progress_pct: int,
wishlist_phase_label: str = 'Phase: Processing wishlist...',
wishlist_phase_start_log: str = 'Wishlist',
) -> int:
"""Trigger the wishlist processor unless skipped or already running.
Returns 1 when the processor was triggered, 0 otherwise. Errors are
logged but never raised wishlist failure should not abort the
pipeline."""
if skip:
deps.update_progress(
automation_id,
progress=progress_pct,
log_line=f'{wishlist_phase_start_log}: skipped (disabled)',
log_type='skip',
)
return 0
deps.update_progress(
automation_id,
progress=progress_pct,
phase=wishlist_phase_label,
log_line=wishlist_phase_start_log,
log_type='info',
)
try:
if not deps.is_wishlist_actually_processing():
deps.process_wishlist_automatically(automation_id=None)
deps.update_progress(
automation_id,
log_line='Wishlist processing triggered',
log_type='success',
)
return 1
deps.update_progress(
automation_id,
log_line='Wishlist already running — skipped',
log_type='skip',
)
return 0
except Exception as e: # noqa: BLE001 — wishlist failure must never abort pipeline
deps.update_progress(
automation_id,
log_line=f'Wishlist error: {e}',
log_type='warning',
)
return 0
__all__ = ['run_sync_and_wishlist', 'run_wishlist_phase']

View file

@ -0,0 +1,136 @@
"""Automation handlers: ``start_database_update`` and
``deep_scan_library`` actions.
Lifted from ``web_server._register_automation_handlers`` (the
``_auto_start_database_update`` and ``_auto_deep_scan_library``
closures). Both share the same ``db_update_state`` / executor / lock
infrastructure -- the only difference is which task they submit
(``run_db_update_task`` vs ``run_deep_scan_task``).
Pattern: pre-set state to running, submit task to executor, then
poll the state dict until it transitions away from ``running``.
Stall-detection emits a warning every 10 minutes when progress
hasn't budged. 2-hour outer timeout caps the worst case.
"""
from __future__ import annotations
import time
from typing import Any, Dict
from core.automation.deps import AutomationDeps
_TIMEOUT_SECONDS = 7200 # 2 hours — covers the worst large-library case
_STALL_WARNING_SECONDS = 600 # 10 minutes without progress = stall
_POLL_INTERVAL_SECONDS = 3
_INITIAL_DELAY_SECONDS = 1
def auto_start_database_update(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
"""Run a full or incremental DB update via ``run_db_update_task``."""
return _run_with_progress(
config, deps,
task=deps.run_db_update_task,
task_args=(config.get('full_refresh', False), deps.config_manager.get_active_media_server()),
initial_phase='Initializing...',
stall_label='Database update',
finished_extras=lambda: {'full_refresh': str(config.get('full_refresh', False))},
timeout_label='Database update timed out after 2 hours',
)
def auto_deep_scan_library(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
"""Run a deep library scan via ``run_deep_scan_task``."""
return _run_with_progress(
config, deps,
task=deps.run_deep_scan_task,
task_args=(deps.config_manager.get_active_media_server(),),
initial_phase='Deep scan: Initializing...',
stall_label='Deep scan',
finished_extras=lambda: {},
timeout_label='Deep scan timed out after 2 hours',
)
def _run_with_progress(
config: Dict[str, Any],
deps: AutomationDeps,
*,
task,
task_args: tuple,
initial_phase: str,
stall_label: str,
finished_extras,
timeout_label: str,
) -> Dict[str, Any]:
"""Shared poll-and-wait body for both DB-update handlers."""
automation_id = config.get('_automation_id')
state = deps.get_db_update_state()
if state.get('status') == 'running':
return {'status': 'skipped', 'reason': 'Database update already running'}
deps.state.db_update_automation_id = automation_id
# Sync legacy module global so the DB-update progress callbacks
# (still living in web_server.py) emit against this automation.
deps.set_db_update_automation_id(automation_id)
with deps.db_update_lock:
state.update({
'status': 'running', 'phase': initial_phase,
'progress': 0, 'current_item': '', 'processed': 0, 'total': 0,
'error_message': '',
})
deps.db_update_executor.submit(task, *task_args)
# Monitor progress (callbacks handle card updates, we just block until done).
time.sleep(_INITIAL_DELAY_SECONDS)
poll_start = time.time()
last_progress_time = time.time()
last_progress_val = 0
while time.time() - poll_start < _TIMEOUT_SECONDS:
time.sleep(_POLL_INTERVAL_SECONDS)
with deps.db_update_lock:
current_status = state.get('status', 'idle')
current_progress = state.get('progress', 0)
if current_status != 'running':
break
# Stall detection — if no progress change in 10 minutes, warn.
if current_progress != last_progress_val:
last_progress_val = current_progress
last_progress_time = time.time()
elif time.time() - last_progress_time > _STALL_WARNING_SECONDS:
deps.update_progress(
automation_id,
log_line=f'{stall_label} appears stalled — waiting...',
log_type='warning',
)
last_progress_time = time.time() # Reset so warning repeats every 10 min.
else:
# 2-hour timeout reached.
deps.update_progress(
automation_id, status='error',
phase='Timed out', log_line=timeout_label, log_type='error',
)
return {'status': 'error', 'reason': 'Timed out', '_manages_own_progress': True}
# Finished/error callback already updated the card — return matching status.
with deps.db_update_lock:
final_status = state.get('status', 'unknown')
if final_status == 'error':
return {
'status': 'error',
'reason': state.get('error_message', 'Unknown error'),
'_manages_own_progress': True,
}
with deps.db_update_lock:
stats = {
'status': 'completed', '_manages_own_progress': True,
'artists': state.get('total', 0),
'albums': state.get('total_albums', 0),
'tracks': state.get('total_tracks', 0),
'removed_artists': state.get('removed_artists', 0),
'removed_albums': state.get('removed_albums', 0),
'removed_tracks': state.get('removed_tracks', 0),
}
stats.update(finished_extras())
return stats

View file

@ -0,0 +1,48 @@
"""Automation handler: ``discover_playlist`` action.
Lifted from ``web_server._register_automation_handlers`` (the
``_auto_discover_playlist`` closure). Kicks off background discovery
of official Spotify / iTunes metadata for mirrored playlist tracks.
The worker runs in a daemon thread and emits its own progress; this
handler returns immediately after launching it (``_manages_own_progress``).
"""
from __future__ import annotations
import threading
from typing import Any, Dict
from core.automation.deps import AutomationDeps
def auto_discover_playlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
"""Discover official Spotify/iTunes metadata for mirrored
playlist tracks. Runs the worker in a background thread."""
db = deps.get_database()
playlist_id = config.get('playlist_id')
discover_all = config.get('all', False)
if discover_all:
playlists = db.get_mirrored_playlists()
elif playlist_id:
p = db.get_mirrored_playlist(int(playlist_id))
playlists = [p] if p else []
else:
return {'status': 'error', 'reason': 'No playlist specified'}
if not playlists:
return {'status': 'error', 'reason': 'No playlists found'}
threading.Thread(
target=deps.run_playlist_discovery_worker,
args=(playlists, config.get('_automation_id')),
daemon=True,
name='auto-discover-playlist',
).start()
names = ', '.join(p['name'] for p in playlists[:3])
return {
'status': 'started',
'playlist_count': str(len(playlists)),
'playlists': names,
'_manages_own_progress': True,
}

View file

@ -0,0 +1,267 @@
"""Automation handlers: download-queue cleanup actions.
Lifted from ``web_server._register_automation_handlers``:
- ``clean_search_history`` :func:`auto_clean_search_history`
- ``clean_completed_downloads`` :func:`auto_clean_completed_downloads`
- ``full_cleanup`` :func:`auto_full_cleanup`
All three share the download-orchestrator + tasks_lock /
download_batches / download_tasks accessors. ``full_cleanup`` is a
multi-step orchestration that pulls in quarantine purge + staging
sweep on top of the queue cleanup -- kept as one big handler since
its phases share state-detection logic.
"""
from __future__ import annotations
import os
import shutil as _shutil
from typing import Any, Dict
from core.automation.deps import AutomationDeps
# ─── clean_search_history ────────────────────────────────────────────
def auto_clean_search_history(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
"""Remove old searches from Soulseek when configured."""
automation_id = config.get('_automation_id')
# Skip if soulseek is not the active download source or in hybrid order.
dl_mode = deps.config_manager.get('download_source.mode', 'hybrid')
hybrid_order = deps.config_manager.get(
'download_source.hybrid_order', ['hifi', 'youtube', 'soulseek'],
)
soulseek_active = (
dl_mode == 'soulseek'
or (dl_mode == 'hybrid' and 'soulseek' in hybrid_order)
)
# Reach the underlying SoulseekClient via the orchestrator's
# generic accessor.
slskd = deps.download_orchestrator.client('soulseek') if deps.download_orchestrator else None
if not soulseek_active or not slskd or not slskd.base_url:
deps.update_progress(automation_id, log_line='Soulseek not active — skipped', log_type='skip')
return {'status': 'skipped'}
if not deps.config_manager.get('soulseek.auto_clear_searches', True):
deps.update_progress(
automation_id, log_line='Auto-clear disabled in settings', log_type='skip',
)
return {'status': 'skipped'}
try:
success = deps.run_async(deps.download_orchestrator.maintain_search_history_with_buffer(
keep_searches=50, trigger_threshold=200,
))
if success:
deps.update_progress(
automation_id,
log_line='Search history maintenance completed',
log_type='success',
)
return {'status': 'completed'}
else:
deps.update_progress(automation_id, log_line='No cleanup needed', log_type='skip')
return {'status': 'completed'}
except Exception as e: # noqa: BLE001 — automation handlers must never raise
return {'status': 'error', 'error': str(e)}
# ─── clean_completed_downloads ───────────────────────────────────────
def auto_clean_completed_downloads(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
"""Clear completed downloads + sweep empty download directories.
Skips when active batches or post-processing is in flight."""
automation_id = config.get('_automation_id')
try:
has_active_batches = False
has_post_processing = False
with deps.tasks_lock:
batches = deps.get_download_batches()
for batch_data in batches.values():
if batch_data.get('phase') not in ['complete', 'error', 'cancelled', None]:
has_active_batches = True
break
if not has_active_batches:
tasks = deps.get_download_tasks()
for task_data in tasks.values():
if task_data.get('status') == 'post_processing':
has_post_processing = True
break
if has_active_batches:
deps.update_progress(
automation_id, log_line='Skipped — downloads active', log_type='skip',
)
return {'status': 'completed'}
deps.run_async(deps.download_orchestrator.clear_all_completed_downloads())
if not has_post_processing:
deps.sweep_empty_download_directories()
deps.update_progress(
automation_id, log_line='Download cleanup completed', log_type='success',
)
return {'status': 'completed'}
except Exception as e: # noqa: BLE001 — automation handlers must never raise
return {'status': 'error', 'reason': str(e)}
# ─── full_cleanup ────────────────────────────────────────────────────
def auto_full_cleanup(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
"""Run all cleanup tasks: quarantine purge → download queue clear
empty-dir sweep staging sweep search history."""
automation_id = config.get('_automation_id')
steps = []
# --- 1. Clear quarantine ---
deps.update_progress(automation_id, phase='Clearing quarantine...', progress=0)
quarantine_path = os.path.join(
deps.docker_resolve_path(deps.config_manager.get('soulseek.download_path', './downloads')),
'ss_quarantine',
)
q_removed = 0
if os.path.exists(quarantine_path):
for f in os.listdir(quarantine_path):
fp = os.path.join(quarantine_path, f)
try:
if os.path.isfile(fp):
os.remove(fp)
q_removed += 1
elif os.path.isdir(fp):
_shutil.rmtree(fp)
q_removed += 1
except Exception as e: # noqa: BLE001 — best-effort purge
deps.logger.debug("quarantine entry purge failed: %s", e)
steps.append(f'Quarantine: removed {q_removed} items')
deps.update_progress(
automation_id,
log_line=f'Quarantine: removed {q_removed} items',
log_type='success' if q_removed else 'info',
)
# --- 2. Clear completed/errored/cancelled downloads from Soulseek queue ---
deps.update_progress(automation_id, phase='Clearing download queue...', progress=20)
has_active_batches = False
has_post_processing = False
with deps.tasks_lock:
batches = deps.get_download_batches()
for batch_data in batches.values():
if batch_data.get('phase') not in ['complete', 'error', 'cancelled', None]:
has_active_batches = True
break
if not has_active_batches:
tasks = deps.get_download_tasks()
for task_data in tasks.values():
if task_data.get('status') == 'post_processing':
has_post_processing = True
break
if has_active_batches:
steps.append('Download queue: skipped (active batches)')
deps.update_progress(
automation_id,
log_line='Download queue: skipped (active batches)',
log_type='skip',
)
else:
try:
deps.run_async(deps.download_orchestrator.clear_all_completed_downloads())
steps.append('Download queue: cleared')
deps.update_progress(
automation_id, log_line='Download queue: cleared', log_type='success',
)
except Exception as e: # noqa: BLE001 — per-step best-effort
steps.append(f'Download queue: error ({e})')
deps.update_progress(
automation_id,
log_line=f'Download queue: error ({e})',
log_type='error',
)
# --- 3. Sweep empty download directories ---
deps.update_progress(automation_id, phase='Sweeping empty directories...', progress=40)
if has_active_batches or has_post_processing:
reason = 'active batches' if has_active_batches else 'post-processing active'
steps.append(f'Empty directories: skipped ({reason})')
deps.update_progress(
automation_id,
log_line=f'Empty directories: skipped ({reason})',
log_type='skip',
)
else:
dirs_removed = deps.sweep_empty_download_directories()
steps.append(f'Empty directories: removed {dirs_removed}')
deps.update_progress(
automation_id,
log_line=f'Empty directories: removed {dirs_removed}',
log_type='success' if dirs_removed else 'info',
)
# --- 4. Sweep empty staging directories ---
deps.update_progress(automation_id, phase='Sweeping import folder...', progress=60)
staging_path = deps.get_staging_path()
s_removed = 0
if os.path.isdir(staging_path):
for dirpath, _dirnames, _filenames in os.walk(staging_path, topdown=False):
if os.path.normpath(dirpath) == os.path.normpath(staging_path):
continue
try:
entries = os.listdir(dirpath)
except OSError:
continue
visible = [e for e in entries if not e.startswith('.')]
if not visible:
for hidden in entries:
try:
os.remove(os.path.join(dirpath, hidden))
except Exception as e: # noqa: BLE001 — best-effort
deps.logger.debug("hidden file cleanup failed: %s", e)
try:
os.rmdir(dirpath)
s_removed += 1
except OSError:
pass
steps.append(f'Staging: removed {s_removed} empty directories')
deps.update_progress(
automation_id,
log_line=f'Staging: removed {s_removed} empty directories',
log_type='success' if s_removed else 'info',
)
# --- 5. Clean search history (if enabled) ---
deps.update_progress(automation_id, phase='Cleaning search history...', progress=80)
try:
if not deps.config_manager.get('soulseek.auto_clear_searches', True):
steps.append('Search cleanup: disabled in settings')
deps.update_progress(
automation_id, log_line='Search cleanup: disabled in settings', log_type='skip',
)
else:
deps.run_async(deps.download_orchestrator.maintain_search_history_with_buffer(
keep_searches=50, trigger_threshold=200,
))
steps.append('Search history: cleaned')
deps.update_progress(
automation_id, log_line='Search history: cleaned', log_type='success',
)
except Exception as e: # noqa: BLE001 — per-step best-effort
steps.append(f'Search history: error ({e})')
deps.update_progress(
automation_id, log_line=f'Search history: error ({e})', log_type='error',
)
total_removed = q_removed + s_removed
deps.update_progress(
automation_id, status='finished', progress=100,
phase='Complete',
log_line=f'Full cleanup complete — {total_removed} items removed',
log_type='success',
)
return {
'status': 'completed',
'quarantine_removed': str(q_removed),
'staging_removed': str(s_removed),
'total_removed': str(total_removed),
'steps': steps,
'_manages_own_progress': True,
}

View file

@ -0,0 +1,87 @@
"""Automation handler: ``run_duplicate_cleaner`` action.
Lifted from ``web_server._register_automation_handlers`` (the
``_auto_run_duplicate_cleaner`` closure). Submits the duplicate
cleaner to its executor, then polls the shared state dict until
the worker transitions away from ``running``.
"""
from __future__ import annotations
import time
from typing import Any, Dict
from core.automation.deps import AutomationDeps
_TIMEOUT_SECONDS = 7200 # 2 hours
_POLL_INTERVAL_SECONDS = 3
_INITIAL_DELAY_SECONDS = 1
def auto_run_duplicate_cleaner(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
"""Kick off the duplicate cleaner and report final stats."""
automation_id = config.get('_automation_id')
state = deps.get_duplicate_cleaner_state()
if state.get('status') == 'running':
return {'status': 'skipped', 'reason': 'Duplicate cleaner already running'}
# Pre-set status before submit so the polling loop doesn't see a
# stale 'finished' from a previous run.
with deps.duplicate_cleaner_lock:
state['status'] = 'running'
deps.duplicate_cleaner_executor.submit(deps.run_duplicate_cleaner)
deps.update_progress(automation_id, log_line='Duplicate cleaner started', log_type='info')
# Monitor progress (max 2 hours).
time.sleep(_INITIAL_DELAY_SECONDS)
poll_start = time.time()
while time.time() - poll_start < _TIMEOUT_SECONDS:
time.sleep(_POLL_INTERVAL_SECONDS)
current_status = state.get('status', 'idle')
if current_status not in ('running',):
break
deps.update_progress(
automation_id,
phase=state.get('phase', 'Scanning...'),
progress=state.get('progress', 0),
processed=state.get('files_scanned', 0),
total=state.get('total_files', 0),
)
else:
# 2-hour timeout reached.
deps.update_progress(
automation_id, status='error',
phase='Timed out',
log_line='Duplicate cleaner timed out after 2 hours',
log_type='error',
)
return {'status': 'error', 'reason': 'Timed out', '_manages_own_progress': True}
# Check actual exit status (could be 'finished' or 'error').
final_status = state.get('status', 'idle')
if final_status == 'error':
err = state.get('error_message', 'Unknown error')
deps.update_progress(
automation_id, status='error', progress=100,
phase='Error', log_line=err, log_type='error',
)
return {'status': 'error', 'reason': err, '_manages_own_progress': True}
dupes = state.get('duplicates_found', 0)
removed = state.get('deleted', 0)
space_freed = state.get('space_freed', 0)
scanned = state.get('files_scanned', 0)
deps.update_progress(
automation_id, status='finished', progress=100,
phase='Complete',
log_line=f'Found {dupes} duplicates, removed {removed} files',
log_type='success',
)
return {
'status': 'completed', '_manages_own_progress': True,
'files_scanned': scanned,
'duplicates_found': dupes,
'files_deleted': removed,
'space_freed_mb': round(space_freed / (1024 * 1024), 1),
}

View file

@ -0,0 +1,213 @@
"""Automation handlers: short maintenance actions.
Lifted from ``web_server._register_automation_handlers``:
- ``clear_quarantine`` :func:`auto_clear_quarantine`
- ``cleanup_wishlist`` :func:`auto_cleanup_wishlist`
- ``update_discovery_pool`` :func:`auto_update_discovery_pool`
- ``backup_database`` :func:`auto_backup_database`
- ``refresh_beatport_cache`` :func:`auto_refresh_beatport_cache`
Each is a thin wrapper around an existing service / helper. Grouped
in one module because every body is short and they share no state
between them splitting into per-handler files would just add
import noise.
"""
from __future__ import annotations
import glob as _glob
import os
import shutil as _shutil
import sqlite3
import time
from datetime import datetime
from typing import Any, Dict
from core.automation.deps import AutomationDeps
# ─── clear_quarantine ────────────────────────────────────────────────
def auto_clear_quarantine(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
"""Purge every file/folder under the configured ss_quarantine path."""
automation_id = config.get('_automation_id')
quarantine_path = os.path.join(
deps.docker_resolve_path(deps.config_manager.get('soulseek.download_path', './downloads')),
'ss_quarantine',
)
if not os.path.exists(quarantine_path):
deps.update_progress(automation_id, log_line='No quarantine folder found', log_type='info')
return {'status': 'completed', 'removed': '0'}
removed = 0
for f in os.listdir(quarantine_path):
fp = os.path.join(quarantine_path, f)
try:
if os.path.isfile(fp):
os.remove(fp)
removed += 1
elif os.path.isdir(fp):
_shutil.rmtree(fp)
removed += 1
except Exception as e: # noqa: BLE001 — best-effort purge
deps.logger.debug("quarantine entry purge failed: %s", e)
deps.update_progress(
automation_id,
log_line=f'Removed {removed} quarantined items',
log_type='success' if removed > 0 else 'info',
)
return {'status': 'completed', 'removed': str(removed)}
# ─── cleanup_wishlist ────────────────────────────────────────────────
def auto_cleanup_wishlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
"""Drop duplicate entries from the wishlist for the active profile."""
automation_id = config.get('_automation_id')
db = deps.get_database()
removed = db.remove_wishlist_duplicates(deps.get_current_profile_id())
deps.update_progress(
automation_id,
log_line=f'Removed {removed or 0} duplicate wishlist entries',
log_type='success' if removed else 'info',
)
return {'status': 'completed', 'removed': str(removed or 0)}
# ─── update_discovery_pool ───────────────────────────────────────────
def auto_update_discovery_pool(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
"""Run an incremental refresh of the discovery pool via the
watchlist scanner."""
automation_id = config.get('_automation_id')
try:
scanner = deps.get_watchlist_scanner(deps.spotify_client)
deps.update_progress(automation_id, log_line='Updating discovery pool...', log_type='info')
scanner.update_discovery_pool_incremental(deps.get_current_profile_id())
deps.update_progress(
automation_id, status='finished', progress=100,
phase='Complete', log_line='Discovery pool updated', log_type='success',
)
return {'status': 'completed', '_manages_own_progress': True}
except Exception as e: # noqa: BLE001 — automation handlers must never raise
deps.update_progress(
automation_id, status='error',
phase='Error', log_line=str(e), log_type='error',
)
return {'status': 'error', 'reason': str(e), '_manages_own_progress': True}
# ─── backup_database ─────────────────────────────────────────────────
_MAX_BACKUPS = 5
def auto_backup_database(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
"""Create a hot SQLite backup, then prune old backups so only the
newest ``_MAX_BACKUPS`` remain."""
automation_id = config.get('_automation_id')
db_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')
if not os.path.exists(db_path):
return {'status': 'error', 'reason': 'Database file not found'}
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_path = f"{db_path}.backup_{timestamp}"
# Use SQLite backup API for a safe hot-copy of an active database.
src = sqlite3.connect(db_path)
dst = sqlite3.connect(backup_path)
src.backup(dst)
dst.close()
src.close()
size_mb = round(os.path.getsize(backup_path) / (1024 * 1024), 1)
# Rolling cleanup — keep only the newest N backups.
existing = sorted(_glob.glob(f"{db_path}.backup_*"), key=os.path.getmtime)
while len(existing) > _MAX_BACKUPS:
try:
os.remove(existing.pop(0))
except Exception as e: # noqa: BLE001 — best-effort cleanup
deps.logger.debug("rolling backup cleanup failed: %s", e)
deps.update_progress(
automation_id,
log_line=f'Backup created: {size_mb}MB ({os.path.basename(backup_path)})',
log_type='success',
)
return {'status': 'completed', 'backup_path': backup_path, 'size_mb': str(size_mb)}
# ─── refresh_beatport_cache ──────────────────────────────────────────
_BEATPORT_SECTIONS = (
('hero_tracks', '/api/beatport/hero-tracks', 'Hero Tracks'),
('new_releases', '/api/beatport/new-releases', 'New Releases'),
('featured_charts', '/api/beatport/featured-charts', 'Featured Charts'),
('dj_charts', '/api/beatport/dj-charts', 'DJ Charts'),
('top_10_lists', '/api/beatport/homepage/top-10-lists', 'Top 10 Lists'),
('top_10_releases', '/api/beatport/homepage/top-10-releases-cards', 'Top 10 Releases'),
('hype_picks', '/api/beatport/hype-picks', 'Hype Picks'),
)
def auto_refresh_beatport_cache(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
"""Refresh Beatport homepage cache by calling each endpoint internally
via Flask's ``test_client``. Invalidates the homepage cache first
so endpoints re-scrape rather than returning stale data."""
automation_id = config.get('_automation_id')
cache = deps.get_beatport_data_cache()
# Invalidate all homepage cache timestamps so endpoints re-scrape.
with cache['cache_lock']:
for key in cache['homepage']:
cache['homepage'][key]['timestamp'] = 0
cache['homepage'][key]['data'] = None
refreshed = 0
errors = []
app = deps.get_app()
with app.test_client() as client:
for idx, (_, endpoint, label) in enumerate(_BEATPORT_SECTIONS):
deps.update_progress(
automation_id,
progress=(idx / len(_BEATPORT_SECTIONS)) * 100,
phase=f'Scraping: {label}',
current_item=label,
)
try:
resp = client.get(endpoint)
if resp.status_code == 200:
refreshed += 1
deps.update_progress(
automation_id, log_line=f'{label}: cached', log_type='success',
)
else:
errors.append(label)
deps.update_progress(
automation_id,
log_line=f'{label}: HTTP {resp.status_code}',
log_type='error',
)
except Exception as e: # noqa: BLE001 — per-section best-effort
errors.append(label)
deps.update_progress(
automation_id,
log_line=f'{label}: {str(e)}',
log_type='error',
)
if idx < len(_BEATPORT_SECTIONS) - 1:
time.sleep(2)
deps.update_progress(
automation_id, status='finished', progress=100,
phase='Complete',
log_line=f'Refreshed {refreshed}/{len(_BEATPORT_SECTIONS)} sections',
log_type='success',
)
return {
'status': 'completed',
'refreshed': str(refreshed),
'errors': str(len(errors)),
'_manages_own_progress': True,
}

View file

@ -0,0 +1,356 @@
"""Personalized Playlist Pipeline automation handler.
Sibling to ``auto_playlist_pipeline`` (mirrored). Where the mirrored
pipeline runs REFRESH external sources DISCOVER metadata SYNC
WISHLIST, the personalized pipeline is simpler:
SNAPSHOT SYNC WISHLIST
SNAPSHOT reads the persisted track list from
``PersonalizedPlaylistManager``. When ``refresh_first=True`` (config),
each playlist is refreshed BEFORE syncing useful when the user
wants the cron to capture a fresh-each-run view (e.g. "give me a new
Hidden Gems set every night"). Default is to sync the existing
snapshot, on the assumption the user / a separate cron has already
refreshed when they wanted to.
Config schema:
{
'kinds': [
{'kind': 'hidden_gems'},
{'kind': 'time_machine', 'variant': '1980s'},
{'kind': 'seasonal_mix', 'variant': 'halloween'},
...
],
'refresh_first': bool, # default false
'skip_wishlist': bool, # default false
}
Each kind dict has at minimum ``kind``; ``variant`` is required for
kinds that need it (time_machine, genre_playlist, daily_mix,
seasonal_mix). Singleton kinds (hidden_gems, discovery_shuffle,
popular_picks, fresh_tape, archives) ignore variant.
Pipeline-running flag (``deps.state.pipeline_running``) is shared
with the mirrored pipeline so the two can't overlap. (One sync
queue, one wishlist worker overlapping triggers would step on
each other.)"""
from __future__ import annotations
import json
import threading
import time
from typing import Any, Dict, List, Optional
from core.automation.deps import AutomationDeps
from core.automation.handlers._pipeline_shared import run_sync_and_wishlist
# Sync state key prefix so personalized syncs don't collide with
# mirrored ones (`auto_mirror_<id>`).
_SYNC_ID_PREFIX = 'auto_personalized'
def auto_personalized_pipeline(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
"""Run SNAPSHOT → SYNC → WISHLIST for selected personalized playlists."""
deps.state.set_pipeline_running(True)
automation_id = config.get('_automation_id')
pipeline_start = time.time()
try:
kinds_config = config.get('kinds') or []
if not isinstance(kinds_config, list) or not kinds_config:
deps.state.set_pipeline_running(False)
return {
'status': 'error',
'error': 'No personalized playlist kinds selected',
}
refresh_first = bool(config.get('refresh_first', False))
skip_wishlist = bool(config.get('skip_wishlist', False))
manager = deps.build_personalized_manager()
deps.update_progress(
automation_id,
progress=2,
phase=f'Personalized pipeline: {len(kinds_config)} playlist(s)',
log_line=f'Starting pipeline for {len(kinds_config)} playlist(s)',
log_type='info',
)
# ── PHASE 1: SNAPSHOT (optionally refresh) ──────────────────
deps.update_progress(
automation_id,
progress=3,
phase='Phase 1/2: Loading snapshots...' if not refresh_first
else 'Phase 1/2: Refreshing snapshots...',
log_line='Phase 1: Snapshot' + (' (with refresh)' if refresh_first else ''),
log_type='info',
)
profile_id = deps.get_current_profile_id()
playload_payloads = _build_payloads_for_kinds(
deps, manager, kinds_config, profile_id,
automation_id=automation_id,
refresh_first=refresh_first,
)
if not playload_payloads:
deps.state.set_pipeline_running(False)
deps.update_progress(
automation_id,
status='finished', progress=100,
phase='No playlists to sync',
log_line='No personalized playlists had tracks to sync',
log_type='warning',
)
return {
'status': 'completed',
'_manages_own_progress': True,
'playlists_synced': '0',
'tracks_synced': '0',
'duration_seconds': str(int(time.time() - pipeline_start)),
}
deps.update_progress(
automation_id,
progress=50,
phase='Phase 1/2: Snapshot complete',
log_line=f'Phase 1 done: {len(playload_payloads)} playlist(s) ready to sync',
log_type='success',
)
# ── PHASE 2: SYNC + WISHLIST (shared helper) ────────────────
sync_summary = run_sync_and_wishlist(
deps,
automation_id,
playload_payloads,
sync_one_fn=lambda pl: _sync_personalized_playlist(deps, pl),
sync_id_for_fn=lambda pl: pl['sync_id'],
skip_wishlist=skip_wishlist,
progress_start=51,
progress_end=90,
sync_phase_label='Phase 2/2: Syncing to server...',
sync_phase_start_log='Phase 2: Sync',
wishlist_phase_label='Phase 2/2: Processing wishlist...',
wishlist_phase_start_log='Wishlist',
)
# ── COMPLETE ────────────────────────────────────────────────
duration = int(time.time() - pipeline_start)
deps.update_progress(
automation_id,
status='finished', progress=100,
phase='Pipeline complete',
log_line=f'Personalized pipeline finished in {duration // 60}m {duration % 60}s',
log_type='success',
)
deps.state.set_pipeline_running(False)
return {
'status': 'completed',
'_manages_own_progress': True,
'playlists_synced': str(len(playload_payloads)),
'tracks_synced': str(sync_summary['synced']),
'sync_skipped': str(sync_summary['skipped']),
'wishlist_queued': str(sync_summary['wishlist_queued']),
'duration_seconds': str(duration),
}
except Exception as e: # noqa: BLE001 — automation handlers must never raise into engine
deps.state.set_pipeline_running(False)
deps.update_progress(
automation_id,
status='error', progress=100,
phase='Pipeline error',
log_line=f'Personalized pipeline failed: {e}',
log_type='error',
)
return {'status': 'error', 'error': str(e), '_manages_own_progress': True}
def _build_payloads_for_kinds(
deps: AutomationDeps,
manager: Any,
kinds_config: List[Dict[str, Any]],
profile_id: int,
*,
automation_id: Optional[str],
refresh_first: bool,
) -> List[Dict[str, Any]]:
"""Resolve each requested kind+variant into a sync-payload dict.
Each payload has: ``{'name', 'kind', 'variant', 'tracks_json',
'image_url', 'sync_id'}``. Playlists with no tracks (e.g. a
seasonal mix that hasn't been populated yet) are omitted from
the result so the sync loop doesn't waste time on empty pushes.
"""
payloads: List[Dict[str, Any]] = []
for entry in kinds_config:
if not isinstance(entry, dict):
continue
kind = entry.get('kind')
variant = entry.get('variant') or ''
if not kind:
continue
try:
# Refresh when ANY of:
# - explicit user flag (cron use case: regenerate each run)
# - snapshot marked stale by upstream data refresher
# - playlist was never generated yet (auto-created by
# ensure_playlist; track_count=0, last_generated_at=NULL).
# Without this branch, a first-run pipeline reads the
# empty snapshot and silently skips — user picks a kind,
# hits run, gets "No tracks to sync" with no clue why.
if refresh_first:
record = manager.refresh_playlist(kind, variant, profile_id)
else:
existing = manager.ensure_playlist(kind, variant, profile_id)
needs_first_gen = existing.last_generated_at is None
if existing.is_stale or needs_first_gen:
record = manager.refresh_playlist(kind, variant, profile_id)
else:
record = existing
except Exception as exc: # noqa: BLE001 — log + continue with next kind
deps.update_progress(
automation_id,
log_line=f'Skipping {kind}{("/" + variant) if variant else ""}: {exc}',
log_type='warning',
)
continue
tracks = manager.get_playlist_tracks(record.id)
if not tracks:
deps.update_progress(
automation_id,
log_line=f'No tracks in {record.name} — skipping sync',
log_type='skip',
)
continue
tracks_json = [_track_to_sync_shape(t) for t in tracks]
payloads.append({
'name': record.name,
'kind': record.kind,
'variant': record.variant,
'tracks_json': tracks_json,
'image_url': '', # personalized playlists don't have a cover image yet
'sync_id': f'{_SYNC_ID_PREFIX}_{record.kind}_{record.variant or "_"}',
})
return payloads
def _track_to_sync_shape(track: Any) -> Dict[str, Any]:
"""Convert a personalized.types.Track into the dict shape
`_run_sync_task` expects. Mirrors what the mirrored pipeline
builds from extra_data.matched_data, preserving enriched metadata
from personalized snapshots when available."""
primary_id = track.spotify_track_id or track.itunes_track_id or track.deezer_track_id or ''
rich_data = _coerce_track_data_json(getattr(track, 'track_data_json', None))
if not rich_data:
album = {'name': track.album_name or ''}
cover_url = getattr(track, 'album_cover_url', None)
if cover_url:
album['images'] = [{'url': cover_url}]
return {
'name': track.track_name,
'artists': [{'name': track.artist_name}],
'album': album,
'duration_ms': int(track.duration_ms or 0),
'id': primary_id,
}
payload = dict(rich_data)
cover_url = (
getattr(track, 'album_cover_url', None)
or payload.get('album_cover_url')
or payload.get('image_url')
)
payload['id'] = payload.get('id') or primary_id
payload['name'] = payload.get('name') or track.track_name
payload['artists'] = _normalize_artists(payload.get('artists'), track.artist_name)
payload['album'] = _normalize_album(payload.get('album'), track, cover_url=cover_url)
payload['duration_ms'] = int(payload.get('duration_ms') or track.duration_ms or 0)
if 'popularity' not in payload and getattr(track, 'popularity', None) is not None:
payload['popularity'] = int(track.popularity or 0)
if cover_url and not payload.get('image_url'):
payload['image_url'] = cover_url
return payload
def _coerce_track_data_json(value: Any) -> Dict[str, Any]:
if isinstance(value, dict):
return value
if isinstance(value, str) and value.strip():
try:
loaded = json.loads(value)
except (TypeError, ValueError):
return {}
return loaded if isinstance(loaded, dict) else {}
return {}
def _normalize_artists(artists: Any, fallback_artist: str) -> List[Dict[str, Any]]:
if not artists:
return [{'name': fallback_artist or 'Unknown Artist'}]
if isinstance(artists, list):
normalized = []
for artist in artists:
if isinstance(artist, dict):
normalized.append(artist if artist.get('name') else {'name': str(artist)})
elif isinstance(artist, str):
normalized.append({'name': artist})
else:
normalized.append({'name': str(artist)})
return normalized or [{'name': fallback_artist or 'Unknown Artist'}]
if isinstance(artists, dict):
return [artists if artists.get('name') else {'name': str(artists)}]
return [{'name': str(artists)}]
def _normalize_album(album: Any, track: Any, cover_url: Optional[str] = None) -> Dict[str, Any]:
if isinstance(album, dict):
normalized = dict(album)
else:
normalized = {'name': str(album) if album else (track.album_name or '')}
normalized['name'] = normalized.get('name') or track.album_name or ''
images = normalized.get('images')
if not isinstance(images, list):
images = []
if cover_url and not images:
images = [{'url': cover_url}]
if images:
normalized['images'] = images
return normalized
def _sync_personalized_playlist(deps: AutomationDeps, payload: Dict[str, Any]) -> Dict[str, Any]:
"""Launch a personalized playlist sync via _run_sync_task on a
daemon thread + return immediately with status='started'.
Mirrors the mirrored ``auto_sync_playlist`` return contract so the
shared helper can poll on ``sync_states[sync_id]`` and aggregate
results identically."""
sync_id = payload['sync_id']
name = payload['name']
tracks_json = payload['tracks_json']
profile_id = deps.get_current_profile_id()
threading.Thread(
target=deps.run_sync_task,
args=(sync_id, name, tracks_json, None, profile_id, payload.get('image_url', '')),
daemon=True,
name=f'auto-personalized-{sync_id}',
).start()
return {
'status': 'started',
'playlist_name': name,
'_manages_own_progress': True,
}

View file

@ -0,0 +1,227 @@
"""Automation handler: ``playlist_pipeline`` action.
Lifted from ``web_server._register_automation_handlers`` (the
``_auto_playlist_pipeline`` closure). Runs the full playlist
lifecycle in a single trigger:
Phase 1: REFRESH -- pull fresh track lists from sources
Phase 2: DISCOVER -- look up official Spotify/iTunes metadata
Phase 3: SYNC -- push the result to the active media server
Phase 4: WISHLIST -- queue any missing tracks for download
Each phase emits its own progress range so the trigger card shows
useful per-phase percentages instead of "loading...". Phase 4 is
optional via ``skip_wishlist`` config.
Composition: this handler invokes ``auto_refresh_mirrored`` and
``auto_sync_playlist`` directly (passing ``_automation_id: None`` so
the sub-handlers don't hijack pipeline progress) instead of going
through the engine keeps the four phases observable as one
trigger from the user's perspective. Pipeline-level guard
(``state.pipeline_running``) prevents overlapping runs.
"""
from __future__ import annotations
import threading
import time
from typing import Any, Dict
from core.automation.deps import AutomationDeps
from core.automation.handlers._pipeline_shared import run_sync_and_wishlist
from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored
from core.automation.handlers.sync_playlist import auto_sync_playlist
# Per-playlist sync poll cap inside Phase 3.
# Discovery poll cap inside Phase 2.
_DISCOVERY_TIMEOUT_SECONDS = 3600
def auto_playlist_pipeline(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
"""Run REFRESH → DISCOVER → SYNC → WISHLIST in sequence.
Sets / clears ``deps.state.pipeline_running`` around the whole
run so the registration guard can short-circuit overlapping
triggers.
"""
deps.state.set_pipeline_running(True)
automation_id = config.get('_automation_id')
pipeline_start = time.time()
try:
db = deps.get_database()
playlist_id = config.get('playlist_id')
process_all = config.get('all', False)
skip_wishlist = config.get('skip_wishlist', False)
# Resolve playlists.
if process_all:
playlists = db.get_mirrored_playlists()
elif playlist_id:
p = db.get_mirrored_playlist(int(playlist_id))
playlists = [p] if p else []
else:
deps.state.set_pipeline_running(False)
return {'status': 'error', 'error': 'No playlist specified'}
playlists = [pl for pl in playlists if pl.get('source', '') not in ('file', 'beatport')]
if not playlists:
deps.state.set_pipeline_running(False)
return {'status': 'error', 'error': 'No refreshable playlists found'}
pl_names = ', '.join(p.get('name', '?') for p in playlists[:3])
if len(playlists) > 3:
pl_names += f' (+{len(playlists) - 3} more)'
deps.update_progress(
automation_id,
progress=2,
phase=f'Pipeline: {len(playlists)} playlist(s)',
log_line=f'Starting pipeline for: {pl_names}',
log_type='info',
)
# ── PHASE 1: REFRESH ──────────────────────────────────────────
deps.update_progress(
automation_id,
progress=3,
phase='Phase 1/4: Refreshing playlists...',
log_line='Phase 1: Refresh',
log_type='info',
)
refresh_config = dict(config)
refresh_config['_automation_id'] = None # Don't let sub-handler hijack pipeline progress.
refresh_result = auto_refresh_mirrored(refresh_config, deps)
refreshed = int(refresh_result.get('refreshed', 0))
refresh_errors = int(refresh_result.get('errors', 0))
deps.update_progress(
automation_id,
progress=25,
phase='Phase 1/4: Refresh complete',
log_line=f'Phase 1 done: {refreshed} refreshed, {refresh_errors} errors',
log_type='success' if refresh_errors == 0 else 'warning',
)
# ── PHASE 2: DISCOVER ─────────────────────────────────────────
deps.update_progress(
automation_id,
progress=26,
phase='Phase 2/4: Discovering metadata...',
log_line='Phase 2: Discover',
log_type='info',
)
# Reload playlists (refresh may have updated them).
if process_all:
disc_playlists = db.get_mirrored_playlists()
else:
disc_playlists = [db.get_mirrored_playlist(int(playlist_id))]
disc_playlists = [p for p in disc_playlists if p]
# Run discovery in a thread and wait for it.
disc_done = threading.Event()
def _disc_wrapper(pls):
try:
# The worker updates automation_progress internally,
# but we pass None so it doesn't conflict with our
# pipeline progress.
deps.run_playlist_discovery_worker(pls, automation_id=None)
except Exception as e:
deps.logger.error(f"[Pipeline] Discovery error: {e}")
finally:
disc_done.set()
threading.Thread(
target=_disc_wrapper, args=(disc_playlists,),
daemon=True, name='pipeline-discover',
).start()
# Poll for completion with progress updates.
poll_start = time.time()
while not disc_done.wait(timeout=3):
elapsed = int(time.time() - poll_start)
deps.update_progress(
automation_id,
progress=min(26 + elapsed // 4, 54),
phase=f'Phase 2/4: Discovering... ({elapsed}s)',
)
if elapsed > _DISCOVERY_TIMEOUT_SECONDS:
deps.update_progress(
automation_id,
log_line='Discovery timed out after 1 hour',
log_type='warning',
)
break
deps.update_progress(
automation_id,
progress=55,
phase='Phase 2/4: Discovery complete',
log_line='Phase 2 done: discovery complete',
log_type='success',
)
# ── PHASE 3 + 4: SYNC + WISHLIST (delegated to shared helper) ──
# Each mirrored playlist payload only needs `id` + `name` for
# the helper; `auto_sync_playlist` reads the rest from the
# mirrored DB by id.
sync_summary = run_sync_and_wishlist(
deps,
automation_id,
[pl for pl in playlists if pl.get('id')],
sync_one_fn=lambda pl: auto_sync_playlist(
{'playlist_id': str(pl['id']), '_automation_id': None},
deps,
),
sync_id_for_fn=lambda pl: f"auto_mirror_{pl['id']}",
skip_wishlist=skip_wishlist,
progress_start=56,
progress_end=85,
sync_phase_label='Phase 3/4: Syncing to server...',
sync_phase_start_log='Phase 3: Sync',
wishlist_phase_label='Phase 4/4: Processing wishlist...',
wishlist_phase_start_log='Phase 4: Wishlist',
)
total_synced = sync_summary['synced']
total_skipped = sync_summary['skipped']
sync_errors = sync_summary['errors']
wishlist_queued = sync_summary['wishlist_queued']
# ── COMPLETE ──────────────────────────────────────────────────
duration = int(time.time() - pipeline_start)
deps.update_progress(
automation_id,
status='finished',
progress=100,
phase='Pipeline complete',
log_line=f'Pipeline finished in {duration // 60}m {duration % 60}s',
log_type='success',
)
deps.state.set_pipeline_running(False)
return {
'status': 'completed',
'_manages_own_progress': True,
'playlists_refreshed': str(refreshed),
'tracks_discovered': 'completed',
'tracks_synced': str(total_synced),
'sync_skipped': str(total_skipped),
'wishlist_queued': str(wishlist_queued),
'duration_seconds': str(duration),
}
except Exception as e:
deps.state.set_pipeline_running(False)
deps.update_progress(
automation_id,
status='error',
progress=100,
phase='Pipeline error',
log_line=f'Pipeline failed: {e}',
log_type='error',
)
return {'status': 'error', 'error': str(e), '_manages_own_progress': True}

View file

@ -0,0 +1,27 @@
"""Automation handler: ``process_wishlist`` action.
Lifted from ``web_server._register_automation_handlers`` (the
``_auto_process_wishlist`` closure). Wishlist processing is async
the helper submits a batch to an executor and returns immediately;
per-track stats arrive later via batch-completion callbacks.
"""
from __future__ import annotations
from typing import Any, Dict
from core.automation.deps import AutomationDeps
def auto_process_wishlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
"""Kick off the wishlist processor for an automation trigger.
Returns immediately after submission; the wishlist worker emits
per-batch progress via its own callbacks. We only report
``status: completed`` to mark the trigger fired successfully.
"""
try:
deps.process_wishlist_automatically(automation_id=config.get('_automation_id'))
return {'status': 'completed'}
except Exception as e: # noqa: BLE001 — automation handlers must never raise into the engine
return {'status': 'error', 'error': str(e)}

View file

@ -0,0 +1,89 @@
"""Progress + history callbacks the automation engine invokes around
each handler run.
Lifted from the closures at the bottom of
``web_server._register_automation_handlers``:
- ``_progress_init`` :func:`progress_init`
- ``_progress_finish`` :func:`progress_finish`
- ``_record_automation_history`` :func:`record_history`
- ``_on_library_scan_completed`` :func:`on_library_scan_completed`
The engine accepts four callables via
``register_progress_callbacks(init, finish, update, history)``;
``registration.register_all`` wires these here. The
``library_scan_completed`` callback is registered separately on the
``web_scan_manager`` (when one is available) -- see
``register_library_scan_completed_emitter``.
"""
from __future__ import annotations
from typing import Any, Dict
from core.automation.deps import AutomationDeps
def progress_init(aid: Any, name: str, action_type: str, deps: AutomationDeps) -> None:
"""Initialize per-automation progress state when the engine starts
a handler. Thin wrapper so the engine receives a closure that
delegates into the live progress tracker."""
deps.init_automation_progress(aid, name, action_type)
def progress_finish(aid: Any, result: Dict[str, Any], deps: AutomationDeps) -> None:
"""Emit the final progress update when a handler returns.
Skipped for handlers that manage their own progress lifecycle
(they call ``update_progress(status='finished')`` themselves and
set ``_manages_own_progress: True`` in the returned dict).
Otherwise translates the handler's status into a finished/error
progress emit with a status-appropriate phase + log line.
"""
if result.get('_manages_own_progress'):
return
result_status = result.get('status', '')
status = 'error' if result_status == 'error' else 'finished'
msg = result.get('error', result.get('reason', result_status or 'done'))
deps.update_progress(
aid,
status=status,
progress=100,
phase='Error' if status == 'error' else 'Complete',
log_line=msg,
log_type='error' if status == 'error' else 'success',
)
def record_history(aid: Any, result: Dict[str, Any], deps: AutomationDeps) -> None:
"""Capture progress state into run history before the engine's
cleanup pass clears it. Thin wrapper so the engine sees a stable
callable."""
deps.record_progress_history(aid, result, deps.get_database())
def on_library_scan_completed(deps: AutomationDeps) -> None:
"""Emit the ``library_scan_completed`` automation event with the
active media-server type. Replaces the hard-coded
``scan_completion_callback trigger_automatic_database_update``
chain so any automation can listen for scan completion as a
trigger."""
if not deps.engine:
return
server_type = (
getattr(deps.web_scan_manager, '_current_server_type', None)
or 'unknown'
)
deps.engine.emit('library_scan_completed', {
'server_type': server_type,
})
def register_library_scan_completed_emitter(deps: AutomationDeps) -> None:
"""Wire :func:`on_library_scan_completed` to the
``web_scan_manager``'s scan-completion callback list. No-op when
no scan manager is configured (e.g. headless / test contexts)."""
if not deps.web_scan_manager:
return
deps.web_scan_manager.add_scan_completion_callback(
lambda: on_library_scan_completed(deps),
)

View file

@ -0,0 +1,83 @@
"""Automation handler: ``start_quality_scan`` action.
Lifted from ``web_server._register_automation_handlers`` (the
``_auto_start_quality_scan`` closure). Submits the quality scanner
to its executor with the configured scope (default: ``watchlist``)
then polls the shared state dict.
"""
from __future__ import annotations
import time
from typing import Any, Dict
from core.automation.deps import AutomationDeps
_TIMEOUT_SECONDS = 7200 # 2 hours
_POLL_INTERVAL_SECONDS = 3
_INITIAL_DELAY_SECONDS = 1
def auto_start_quality_scan(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
automation_id = config.get('_automation_id')
state = deps.get_quality_scanner_state()
if state.get('status') == 'running':
return {'status': 'skipped', 'reason': 'Quality scan already running'}
scope = config.get('scope', 'watchlist')
# Pre-set status before submit so the polling loop doesn't see a
# stale 'finished' from a previous run.
with deps.quality_scanner_lock:
state['status'] = 'running'
deps.quality_scanner_executor.submit(deps.run_quality_scanner, scope, deps.get_current_profile_id())
deps.update_progress(
automation_id, log_line=f'Quality scan started (scope: {scope})', log_type='info',
)
# Monitor progress (max 2 hours).
time.sleep(_INITIAL_DELAY_SECONDS)
poll_start = time.time()
while time.time() - poll_start < _TIMEOUT_SECONDS:
time.sleep(_POLL_INTERVAL_SECONDS)
current_status = state.get('status', 'idle')
if current_status not in ('running',):
break
deps.update_progress(
automation_id,
phase=state.get('phase', 'Scanning...'),
progress=state.get('progress', 0),
processed=state.get('processed', 0),
total=state.get('total', 0),
)
else:
deps.update_progress(
automation_id, status='error',
phase='Timed out', log_line='Quality scan timed out after 2 hours',
log_type='error',
)
return {'status': 'error', 'reason': 'Timed out', '_manages_own_progress': True}
final_status = state.get('status', 'idle')
if final_status == 'error':
err = state.get('error_message', 'Unknown error')
deps.update_progress(
automation_id, status='error', progress=100,
phase='Error', log_line=err, log_type='error',
)
return {'status': 'error', 'reason': err, '_manages_own_progress': True}
issues = state.get('low_quality', 0)
deps.update_progress(
automation_id, status='finished', progress=100,
phase='Complete',
log_line=f'Quality scan complete — {issues} issues found',
log_type='success',
)
return {
'status': 'completed', 'scope': scope, '_manages_own_progress': True,
'tracks_scanned': state.get('processed', 0),
'quality_met': state.get('quality_met', 0),
'low_quality': issues,
'matched': state.get('matched', 0),
}

View file

@ -0,0 +1,308 @@
"""Automation handler: ``refresh_mirrored`` action.
Lifted from ``web_server._register_automation_handlers`` (the
``_auto_refresh_mirrored`` closure). Re-pulls track lists from each
mirrored playlist's source (Spotify / Tidal / Deezer / YouTube),
updates the local mirror DB, and emits a ``playlist_changed``
automation event when the track set actually shifts.
Source-specific branches (Spotify auth + public-embed fallback,
``spotify_public`` URLID resolution, Deezer / Tidal / YouTube)
remain identical to the pre-extraction closure this is a
mechanical lift, not a redesign.
"""
from __future__ import annotations
import json
from typing import Any, Dict
from core.automation.deps import AutomationDeps
def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
"""Refresh mirrored playlist(s) from source.
Returns ``{'status': 'completed', 'refreshed': '<int>',
'errors': '<int>'}`` on success (counts stringified to match the
automation engine's stat-rendering convention).
"""
db = deps.get_database()
playlist_id = config.get('playlist_id')
refresh_all = config.get('all', False)
auto_id = config.get('_automation_id')
if refresh_all:
playlists = db.get_mirrored_playlists()
elif playlist_id:
p = db.get_mirrored_playlist(int(playlist_id))
playlists = [p] if p else []
else:
return {'status': 'error', 'reason': 'No playlist specified'}
# Filter out sources that can't be refreshed (no external API).
playlists = [pl for pl in playlists if pl.get('source', '') not in ('file', 'beatport')]
refreshed = 0
errors = []
for idx, pl in enumerate(playlists):
try:
source = pl.get('source', '')
source_id = pl.get('source_playlist_id', '')
deps.update_progress(
auto_id,
progress=(idx / max(1, len(playlists))) * 100,
phase=f'Refreshing: "{pl.get("name", "")}"',
current_item=pl.get('name', ''),
)
tracks = None
if source == 'spotify':
# Try authenticated API first, fall back to public embed scraper.
if deps.spotify_client and deps.spotify_client.is_spotify_authenticated():
playlist_obj = deps.spotify_client.get_playlist_by_id(source_id)
if playlist_obj and playlist_obj.tracks:
tracks = []
for t in playlist_obj.tracks:
artist_name = t.artists[0] if t.artists else ''
track_dict = {
'track_name': t.name or '',
'artist_name': str(artist_name),
'album_name': t.album or '',
'duration_ms': t.duration_ms or 0,
'source_track_id': t.id or '',
}
# Spotify data IS official — auto-mark as discovered.
if t.id:
_album_obj = {'name': t.album or ''}
if getattr(t, 'image_url', None):
_album_obj['images'] = [{'url': t.image_url, 'height': 600, 'width': 600}]
track_dict['extra_data'] = json.dumps({
'discovered': True,
'provider': 'spotify',
'confidence': 1.0,
'matched_data': {
'id': t.id,
'name': t.name or '',
'artists': [{'name': str(a)} for a in (t.artists or [])],
'album': _album_obj,
'duration_ms': t.duration_ms or 0,
'image_url': getattr(t, 'image_url', None),
}
})
tracks.append(track_dict)
# Fallback: public embed scraper (no auth needed).
if tracks is None:
try:
from core.spotify_public_scraper import scrape_spotify_embed
embed_data = scrape_spotify_embed('playlist', source_id)
if embed_data and not embed_data.get('error') and embed_data.get('tracks'):
embed_album = embed_data.get('name', '') if embed_data.get('type') == 'album' else ''
tracks = []
for t in embed_data['tracks']:
artist_names = [a['name'] for a in t.get('artists', [])]
artist_name = artist_names[0] if artist_names else ''
track_dict = {
'track_name': t.get('name', ''),
'artist_name': artist_name,
'album_name': embed_album,
'duration_ms': t.get('duration_ms', 0),
'source_track_id': t.get('id', ''),
}
# Store Spotify track ID hint but don't mark discovered —
# Discover step needs to run for proper album art.
if t.get('id'):
track_dict['extra_data'] = json.dumps({
'discovered': False,
'spotify_hint': {
'id': t['id'],
'name': t.get('name', ''),
'artists': t.get('artists', []),
}
})
tracks.append(track_dict)
except Exception as e:
deps.logger.warning(f"Spotify public scraper fallback failed for {source_id}: {e}")
elif source == 'spotify_public':
# source_playlist_id is an MD5 hash; extract actual Spotify ID from stored description (URL).
try:
from core.spotify_public_scraper import parse_spotify_url, scrape_spotify_embed
spotify_url = pl.get('description', '')
parsed = parse_spotify_url(spotify_url) if spotify_url else None
# If Spotify is authenticated, use the full API (auto-discovers with album art).
if (parsed and parsed.get('type') == 'playlist'
and deps.spotify_client and deps.spotify_client.is_spotify_authenticated()):
playlist_obj = deps.spotify_client.get_playlist_by_id(parsed['id'])
if playlist_obj and playlist_obj.tracks:
tracks = []
for t in playlist_obj.tracks:
artist_name = t.artists[0] if t.artists else ''
track_dict = {
'track_name': t.name or '',
'artist_name': str(artist_name),
'album_name': t.album or '',
'duration_ms': t.duration_ms or 0,
'source_track_id': t.id or '',
}
if t.id:
_album_obj = {'name': t.album or ''}
if getattr(t, 'image_url', None):
_album_obj['images'] = [{'url': t.image_url, 'height': 600, 'width': 600}]
track_dict['extra_data'] = json.dumps({
'discovered': True,
'provider': 'spotify',
'confidence': 1.0,
'matched_data': {
'id': t.id,
'name': t.name or '',
'artists': [{'name': str(a)} for a in (t.artists or [])],
'album': _album_obj,
'duration_ms': t.duration_ms or 0,
'image_url': getattr(t, 'image_url', None),
}
})
tracks.append(track_dict)
# Fallback: public embed scraper (no auth or album-type URL).
if tracks is None and parsed:
embed_data = scrape_spotify_embed(parsed['type'], parsed['id'])
if embed_data and not embed_data.get('error') and embed_data.get('tracks'):
embed_album = embed_data.get('name', '') if embed_data.get('type') == 'album' else ''
tracks = []
for t in embed_data['tracks']:
artist_names = [a['name'] for a in t.get('artists', [])]
artist_name = artist_names[0] if artist_names else ''
tracks.append({
'track_name': t.get('name', ''),
'artist_name': artist_name,
'album_name': embed_album,
'duration_ms': t.get('duration_ms', 0),
'source_track_id': t.get('id', ''),
})
# No extra_data — let preservation code keep existing discovery data.
except Exception as e:
deps.logger.warning(f"Spotify public playlist refresh failed for {source_id}: {e}")
elif source == 'deezer':
try:
deezer = deps.get_deezer_client()
playlist_data = deezer.get_playlist(source_id)
if playlist_data and playlist_data.get('tracks'):
tracks = []
for t in playlist_data['tracks']:
artist_name = t['artists'][0] if t.get('artists') else ''
tracks.append({
'track_name': t.get('name', ''),
'artist_name': str(artist_name),
'album_name': t.get('album', ''),
'duration_ms': t.get('duration_ms', 0),
'source_track_id': str(t.get('id', '')),
})
except Exception as e:
deps.logger.warning(f"Deezer playlist refresh failed for {source_id}: {e}")
elif source == 'tidal':
if not deps.tidal_client or not deps.tidal_client.is_authenticated():
deps.logger.warning(f"Tidal not authenticated — skipping refresh for '{pl.get('name', '')}'")
deps.update_progress(
auto_id,
log_line=f'Skipped "{pl.get("name", "")}" — Tidal not authenticated',
log_type='skip',
)
continue
full_playlist = deps.tidal_client.get_playlist(source_id)
if full_playlist and full_playlist.tracks:
tracks = []
for t in full_playlist.tracks:
artist_name = t.artists[0] if t.artists else ''
tracks.append({
'track_name': t.name or '',
'artist_name': str(artist_name),
'album_name': t.album or '',
'duration_ms': t.duration_ms or 0,
'source_track_id': t.id or '',
})
elif source == 'youtube':
# source_playlist_id is now a deterministic hash; use stored description (original URL) for refresh.
yt_url = pl.get('description', '') or f"https://www.youtube.com/playlist?list={source_id}"
playlist_data = deps.parse_youtube_playlist(yt_url)
if playlist_data and playlist_data.get('tracks'):
tracks = []
for t in playlist_data['tracks']:
artist_name = t['artists'][0] if t.get('artists') else ''
tracks.append({
'track_name': t.get('name', ''),
'artist_name': str(artist_name),
'album_name': '',
'duration_ms': t.get('duration_ms', 0),
'source_track_id': t.get('id', ''),
})
if tracks is not None:
# Compare old vs new track IDs to detect changes.
old_tracks = db.get_mirrored_playlist_tracks(pl['id']) if pl.get('id') else []
old_ids = {t.get('source_track_id') for t in old_tracks if t.get('source_track_id')}
new_ids = {t.get('source_track_id') for t in tracks if t.get('source_track_id')}
# Preserve existing discovery extra_data for tracks that still exist.
old_extra_map = db.get_mirrored_tracks_extra_data_map(pl['id']) if pl.get('id') else {}
for t in tracks:
sid = t.get('source_track_id', '')
if sid and sid in old_extra_map and 'extra_data' not in t:
t['extra_data'] = old_extra_map[sid]
db.mirror_playlist(
source=source,
source_playlist_id=source_id,
name=pl['name'],
tracks=tracks,
profile_id=pl.get('profile_id', 1),
owner=pl.get('owner'),
image_url=pl.get('image_url'),
)
refreshed += 1
# Emit playlist_changed if tracks actually changed.
if old_ids != new_ids:
added_count = len(new_ids - old_ids)
removed_count = len(old_ids - new_ids)
deps.logger.info(
f"[AUTOMATION] Playlist changed: '{pl.get('name', '')}'"
f"{added_count} added, {removed_count} removed (old={len(old_ids)}, new={len(new_ids)})"
)
deps.update_progress(
auto_id,
log_line=f'"{pl.get("name", "")}"{added_count} added, {removed_count} removed',
log_type='success',
)
try:
if deps.engine:
deps.engine.emit('playlist_changed', {
'playlist_name': pl.get('name', ''),
'playlist_id': str(pl.get('id', '')),
'old_count': str(len(old_ids)),
'new_count': str(len(new_ids)),
'added': str(added_count),
'removed': str(removed_count),
})
except Exception as e:
deps.logger.debug("playlist_synced automation emit failed: %s", e)
else:
deps.logger.warning(f"[AUTOMATION] No changes: '{pl.get('name', '')}' (tracks={len(old_ids)})")
deps.update_progress(
auto_id,
log_line=f'No changes: "{pl.get("name", "")}"',
log_type='skip',
)
except Exception as e:
errors.append(f"{pl.get('name', '?')}: {str(e)}")
deps.update_progress(
auto_id,
log_line=f'Error: {pl.get("name", "?")}{str(e)}',
log_type='error',
)
return {'status': 'completed', 'refreshed': str(refreshed), 'errors': str(len(errors))}

View file

@ -0,0 +1,184 @@
"""One-stop registration of every extracted automation handler.
``web_server`` builds the deps once at startup and calls
:func:`register_all` here. Each new handler module gets one line in
this file when it lands.
"""
from __future__ import annotations
from core.automation.deps import AutomationDeps
from core.automation.handlers.process_wishlist import auto_process_wishlist
from core.automation.handlers.scan_watchlist import auto_scan_watchlist
from core.automation.handlers.scan_library import auto_scan_library
from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored
from core.automation.handlers.sync_playlist import auto_sync_playlist
from core.automation.handlers.discover_playlist import auto_discover_playlist
from core.automation.handlers.playlist_pipeline import auto_playlist_pipeline
from core.automation.handlers.personalized_pipeline import auto_personalized_pipeline
from core.automation.handlers.database_update import (
auto_start_database_update, auto_deep_scan_library,
)
from core.automation.handlers.duplicate_cleaner import auto_run_duplicate_cleaner
from core.automation.handlers.quality_scanner import auto_start_quality_scan
from core.automation.handlers.maintenance import (
auto_clear_quarantine,
auto_cleanup_wishlist,
auto_update_discovery_pool,
auto_backup_database,
auto_refresh_beatport_cache,
)
from core.automation.handlers.download_cleanup import (
auto_clean_search_history,
auto_clean_completed_downloads,
auto_full_cleanup,
)
from core.automation.handlers.run_script import auto_run_script
from core.automation.handlers.search_and_download import auto_search_and_download
from core.automation.handlers.progress_callbacks import (
progress_init,
progress_finish,
record_history,
register_library_scan_completed_emitter,
)
def register_all(deps: AutomationDeps) -> None:
"""Wire every extracted handler to the engine.
Each ``register_action_handler`` call binds the action name (the
string the trigger uses to look up its action) to a thin lambda
that injects ``deps`` and forwards the engine-supplied config.
Guards stay alongside their handler so duplicate-run prevention
behaves identically to the pre-extraction code.
"""
engine = deps.engine
# Self-guards prevent duplicate runs of the SAME operation, but
# different operations can run concurrently — wishlist downloads
# use bandwidth, watchlist scans use API calls, library scans use
# media-server CPU. Different resources, no contention.
engine.register_action_handler(
'process_wishlist',
lambda config: auto_process_wishlist(config, deps),
guard_fn=deps.is_wishlist_actually_processing,
)
engine.register_action_handler(
'scan_watchlist',
lambda config: auto_scan_watchlist(config, deps),
guard_fn=deps.is_watchlist_actually_scanning,
)
engine.register_action_handler(
'scan_library',
lambda config: auto_scan_library(config, deps),
deps.state.is_scan_library_active,
)
# Playlist lifecycle handlers. The pipeline composes refresh +
# sync + discover (it imports them directly), so all four ship
# together. The pipeline guard prevents an in-flight pipeline
# from being re-triggered mid-run.
engine.register_action_handler(
'refresh_mirrored',
lambda config: auto_refresh_mirrored(config, deps),
)
engine.register_action_handler(
'sync_playlist',
lambda config: auto_sync_playlist(config, deps),
)
engine.register_action_handler(
'discover_playlist',
lambda config: auto_discover_playlist(config, deps),
)
engine.register_action_handler(
'playlist_pipeline',
lambda config: auto_playlist_pipeline(config, deps),
deps.state.is_pipeline_running,
)
# Personalized pipeline shares the pipeline_running flag with the
# mirrored pipeline so the two can't overlap (single sync queue,
# single wishlist worker).
engine.register_action_handler(
'personalized_pipeline',
lambda config: auto_personalized_pipeline(config, deps),
deps.state.is_pipeline_running,
)
# Database update + deep scan share the db_update_state guard —
# only one operation can mutate that state at a time.
engine.register_action_handler(
'start_database_update',
lambda config: auto_start_database_update(config, deps),
lambda: deps.get_db_update_state().get('status') == 'running',
)
engine.register_action_handler(
'deep_scan_library',
lambda config: auto_deep_scan_library(config, deps),
lambda: deps.get_db_update_state().get('status') == 'running',
)
engine.register_action_handler(
'run_duplicate_cleaner',
lambda config: auto_run_duplicate_cleaner(config, deps),
lambda: deps.get_duplicate_cleaner_state().get('status') == 'running',
)
engine.register_action_handler(
'clear_quarantine',
lambda config: auto_clear_quarantine(config, deps),
)
engine.register_action_handler(
'cleanup_wishlist',
lambda config: auto_cleanup_wishlist(config, deps),
)
engine.register_action_handler(
'update_discovery_pool',
lambda config: auto_update_discovery_pool(config, deps),
)
engine.register_action_handler(
'start_quality_scan',
lambda config: auto_start_quality_scan(config, deps),
lambda: deps.get_quality_scanner_state().get('status') == 'running',
)
engine.register_action_handler(
'backup_database',
lambda config: auto_backup_database(config, deps),
)
engine.register_action_handler(
'refresh_beatport_cache',
lambda config: auto_refresh_beatport_cache(config, deps),
)
engine.register_action_handler(
'clean_search_history',
lambda config: auto_clean_search_history(config, deps),
)
engine.register_action_handler(
'clean_completed_downloads',
lambda config: auto_clean_completed_downloads(config, deps),
)
engine.register_action_handler(
'full_cleanup',
lambda config: auto_full_cleanup(config, deps),
)
engine.register_action_handler(
'run_script',
lambda config: auto_run_script(config, deps),
)
engine.register_action_handler(
'search_and_download',
lambda config: auto_search_and_download(config, deps),
)
# Progress + history callbacks: the engine invokes these around
# each handler run. Lift the closures from
# `web_server._register_automation_handlers` into thin lambdas
# that delegate into the extracted top-level functions.
engine.register_progress_callbacks(
lambda aid, name, action_type: progress_init(aid, name, action_type, deps),
lambda aid, result: progress_finish(aid, result, deps),
deps.update_progress,
lambda aid, result: record_history(aid, result, deps),
)
# `library_scan_completed` event: when the media-server scan
# manager finishes a scan, emit the event so any automation can
# trigger off it. No-op when no scan manager is configured.
register_library_scan_completed_emitter(deps)

View file

@ -0,0 +1,103 @@
"""Automation handler: ``run_script`` action.
Lifted from ``web_server._register_automation_handlers`` (the
``_auto_run_script`` closure). Runs a user-provided shell or Python
script from the configured scripts directory with bounded timeout +
captured stdout/stderr. Path-traversal guard ensures users can't
escape the scripts directory.
Environment variables exposed to the script:
- ``SOULSYNC_EVENT``: triggering event type (when fired by an event)
- ``SOULSYNC_AUTOMATION``: automation name
- ``SOULSYNC_SCRIPTS_DIR``: absolute path to the scripts dir
"""
from __future__ import annotations
import os
import subprocess as _sp
from typing import Any, Dict
from core.automation.deps import AutomationDeps
_MAX_TIMEOUT_SECONDS = 300 # Hard cap on user-supplied timeout config.
def auto_run_script(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
script_name = config.get('script_name', '')
timeout = min(int(config.get('timeout', 60)), _MAX_TIMEOUT_SECONDS)
automation_id = config.get('_automation_id')
if not script_name:
return {'status': 'error', 'error': 'No script selected'}
scripts_dir = deps.docker_resolve_path(deps.config_manager.get('scripts.path', './scripts'))
if not scripts_dir or not os.path.isdir(scripts_dir):
os.makedirs(scripts_dir, exist_ok=True)
return {
'status': 'error',
'error': 'Scripts directory is empty. Add scripts to the scripts/ folder.',
}
script_path = os.path.join(scripts_dir, script_name)
script_path = os.path.realpath(script_path)
# Security: block path traversal — script must resolve under
# the scripts dir, no symlinks/.. tricks allowed out.
if not script_path.startswith(os.path.realpath(scripts_dir)):
return {'status': 'error', 'error': 'Script path traversal blocked'}
if not os.path.isfile(script_path):
return {'status': 'error', 'error': f'Script not found: {script_name}'}
deps.update_progress(automation_id, phase=f'Running {script_name}...', progress=10)
# Build environment with SoulSync context.
env = os.environ.copy()
event_data = config.get('_event_data') or {}
env['SOULSYNC_EVENT'] = str(event_data.get('type', ''))
env['SOULSYNC_AUTOMATION'] = config.get('_automation_name', '')
env['SOULSYNC_SCRIPTS_DIR'] = scripts_dir
try:
# Determine how to run the script.
if script_path.endswith('.py'):
cmd = ['python', script_path]
elif script_path.endswith('.sh'):
cmd = ['bash', script_path]
else:
cmd = [script_path]
result = _sp.run(
cmd,
capture_output=True, text=True, timeout=timeout,
cwd=scripts_dir, env=env,
)
deps.update_progress(automation_id, phase='Script completed', progress=100)
stdout = result.stdout[:2000] if result.stdout else ''
stderr = result.stderr[:1000] if result.stderr else ''
if result.returncode == 0:
deps.logger.info(f"Script '{script_name}' completed (exit 0)")
else:
deps.logger.warning(f"Script '{script_name}' exited with code {result.returncode}")
return {
'status': 'completed' if result.returncode == 0 else 'error',
'exit_code': str(result.returncode),
'stdout': stdout,
'stderr': stderr,
'script': script_name,
}
except _sp.TimeoutExpired:
deps.update_progress(automation_id, phase='Script timed out', progress=100)
return {
'status': 'error',
'error': f'Script timed out after {timeout}s',
'script': script_name,
}
except Exception as e: # noqa: BLE001 — automation handlers must never raise
return {'status': 'error', 'error': str(e), 'script': script_name}

View file

@ -0,0 +1,158 @@
"""Automation handler: ``scan_library`` action.
Lifted from ``web_server._register_automation_handlers`` (the
``_auto_scan_library`` closure). The handler triggers a media-server
scan via ``web_scan_manager``, then polls the manager's status until
the scan completes (or a 30-minute timeout fires). Progress phases
are emitted via :func:`AutomationDeps.update_progress` so the
trigger card stays current throughout the run.
The handler manages its own progress reporting (it sets
``_manages_own_progress: True`` in the result) so the engine doesn't
overwrite the live phase string with a generic 'completed' label.
"""
from __future__ import annotations
import time
from typing import Any, Dict
from core.automation.deps import AutomationDeps
# Outer poll cap — covers extreme worst case (long Plex scans on
# huge libraries). Past this point we surface a clear timeout error
# so users notice rather than letting the trigger hang forever.
_SCAN_TIMEOUT_SECONDS = 1800
# Per-phase poll intervals.
_POLL_SCHEDULED_SECONDS = 2
_POLL_SCANNING_SECONDS = 5
_POLL_UNKNOWN_SECONDS = 2
# Progress percentage waypoints.
_PROGRESS_SCHEDULED_MAX = 14
_PROGRESS_SCAN_START = 15
_PROGRESS_SCAN_MAX = 95
def auto_scan_library(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
"""Run a media-server library scan and stream progress to the
trigger card.
Returns one of:
- ``{'status': 'completed', '_manages_own_progress': True, ...}``
- ``{'status': 'skipped', 'reason': 'Scan already being tracked'}``
- ``{'status': 'error', 'reason': '...', '_manages_own_progress': True}``
"""
automation_id = config.get('_automation_id')
if not deps.web_scan_manager:
return {'status': 'error', 'reason': 'Scan manager not available'}
# If another automation is already tracking the scan, just forward
# the request — the original tracker keeps emitting progress.
if deps.state.is_scan_library_active():
deps.web_scan_manager.request_scan('Automation trigger (additional batch)')
return {'status': 'skipped', 'reason': 'Scan already being tracked'}
deps.state.set_scan_library_id(automation_id)
try:
result = deps.web_scan_manager.request_scan('Automation trigger')
scan_status_val = result.get('status', 'unknown')
if scan_status_val == 'queued':
deps.update_progress(
automation_id,
log_line='Scan already in progress — waiting for completion',
log_type='info',
)
else:
delay = result.get('delay_seconds', 60)
deps.update_progress(
automation_id,
log_line=f'Scan scheduled (debounce: {delay}s)',
log_type='info',
)
# Unified polling loop — handles debounce → scanning → idle.
poll_start = time.time()
scan_started = (scan_status_val == 'queued')
while time.time() - poll_start < _SCAN_TIMEOUT_SECONDS:
status = deps.web_scan_manager.get_scan_status()
st = status.get('status')
if st == 'idle':
break # Scan completed (or finished before we polled)
if st == 'scheduled':
elapsed = int(time.time() - poll_start)
deps.update_progress(
automation_id,
phase=f'Waiting for scan to start... ({elapsed}s)',
progress=min(int(elapsed / 60 * 10), _PROGRESS_SCHEDULED_MAX),
)
time.sleep(_POLL_SCHEDULED_SECONDS)
continue
if st == 'scanning':
if not scan_started:
scan_started = True
deps.update_progress(
automation_id,
progress=_PROGRESS_SCAN_START,
log_line='Scan triggered on media server',
log_type='success',
)
elapsed = status.get('elapsed_seconds', 0)
max_time = status.get('max_time_seconds', 300)
pct = min(_PROGRESS_SCAN_START + int(elapsed / max_time * 80), _PROGRESS_SCAN_MAX)
mins, secs = divmod(elapsed, 60)
deps.update_progress(
automation_id,
phase=f'Library scan in progress... ({mins}m {secs}s)',
progress=pct,
)
time.sleep(_POLL_SCANNING_SECONDS)
continue
time.sleep(_POLL_UNKNOWN_SECONDS)
else:
# 30-min timeout reached
deps.update_progress(
automation_id,
status='error',
phase='Timed out',
log_line='Library scan timed out after 30 minutes',
log_type='error',
)
return {'status': 'error', 'reason': 'Timed out', '_manages_own_progress': True}
elapsed = round(time.time() - poll_start, 1)
deps.update_progress(
automation_id,
status='finished',
progress=100,
phase='Complete',
log_line='Library scan completed',
log_type='success',
)
return {
'status': 'completed',
'_manages_own_progress': True,
'scan_duration_seconds': elapsed,
}
except Exception as e: # noqa: BLE001 — automation handlers must never raise into the engine
deps.update_progress(
automation_id,
status='error',
phase='Error',
log_line=str(e),
log_type='error',
)
return {'status': 'error', 'error': str(e), '_manages_own_progress': True}
finally:
deps.state.set_scan_library_id(None)

View file

@ -0,0 +1,46 @@
"""Automation handler: ``scan_watchlist`` action.
Lifted from ``web_server._register_automation_handlers`` (the
``_auto_scan_watchlist`` closure). The watchlist scanner returns
summary stats for the trigger card only when a fresh scan actually
ran detected by snapshotting ``id(state_dict)`` before/after, since
the live processor reassigns the dict on each new scan.
"""
from __future__ import annotations
from typing import Any, Dict
from core.automation.deps import AutomationDeps
def auto_scan_watchlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
"""Run a watchlist scan when the automation triggers.
Pre-scan we capture ``id(watchlist_scan_state)`` so we can tell
afterwards whether the worker ran (and reassigned the state dict)
or short-circuited (kept the same dict). Only fresh scans report
summary stats repeat triggers without an intervening run return
a bare ``completed``.
"""
try:
pre_state = deps.get_watchlist_scan_state()
pre_state_id = id(pre_state)
deps.process_watchlist_scan_automatically(
automation_id=config.get('_automation_id'),
profile_id=config.get('_profile_id'),
)
post_state = deps.get_watchlist_scan_state()
# Fresh scan = state dict was reassigned mid-run.
if id(post_state) != pre_state_id:
summary = post_state.get('summary', {}) if isinstance(post_state, dict) else {}
return {
'status': 'completed',
'artists_scanned': summary.get('total_artists', 0),
'successful_scans': summary.get('successful_scans', 0),
'new_tracks_found': summary.get('new_tracks_found', 0),
'tracks_added_to_wishlist': summary.get('tracks_added_to_wishlist', 0),
}
return {'status': 'completed'}
except Exception as e: # noqa: BLE001 — automation handlers must never raise into the engine
return {'status': 'error', 'error': str(e)}

View file

@ -0,0 +1,57 @@
"""Automation handler: ``search_and_download`` action.
Lifted from ``web_server._register_automation_handlers`` (the
``_auto_search_and_download`` closure). Searches for a track by
name/artist string and dispatches the best match through the
download orchestrator. Query can come from the trigger config
(direct value) or from event data (e.g. webhook payload).
"""
from __future__ import annotations
from typing import Any, Dict
from core.automation.deps import AutomationDeps
def auto_search_and_download(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
automation_id = config.get('_automation_id')
query = config.get('query', '').strip()
# Event-triggered: pull query from event data (e.g. webhook_received).
if not query:
event_data = config.get('_event_data', {})
query = (event_data.get('query', '') or '').strip()
if not query:
if automation_id:
deps.update_progress(
automation_id, log_line='No search query provided', log_type='error',
)
return {'status': 'error', 'error': 'No search query provided'}
try:
if automation_id:
deps.update_progress(
automation_id, phase='Searching',
log_line=f'Searching: {query}', log_type='info',
)
result = deps.run_async(deps.download_orchestrator.search_and_download_best(query))
if result:
if automation_id:
deps.update_progress(
automation_id,
log_line=f'Download started for: {query}',
log_type='success',
)
return {'status': 'completed', 'query': query, 'download_id': result}
if automation_id:
deps.update_progress(
automation_id,
log_line=f'No match found for: {query}',
log_type='warning',
)
return {'status': 'not_found', 'query': query, 'error': 'No match found'}
except Exception as e: # noqa: BLE001 — automation handlers must never raise
if automation_id:
deps.update_progress(
automation_id, log_line=f'Error: {e}', log_type='error',
)
return {'status': 'error', 'query': query, 'error': str(e)}

View file

@ -0,0 +1,195 @@
"""Automation handler: ``sync_playlist`` action.
Lifted from ``web_server._register_automation_handlers`` (the
``_auto_sync_playlist`` closure). Syncs a mirrored playlist to the
configured media server, using discovered metadata when available
and skipping undiscovered tracks. When triggered on a schedule with
no track changes since the last sync, short-circuits with
``status: skipped`` (saves Plex / Jellyfin / Navidrome from
needless rewrites)."""
from __future__ import annotations
import hashlib
import json
import threading
from typing import Any, Dict
from core.automation.deps import AutomationDeps
def auto_sync_playlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
"""Sync a mirrored playlist to the active media server.
Behavior:
- Tracks with discovered metadata (extra_data.discovered + matched_data)
are routed via the official metadata.
- Tracks with a Spotify hint (real Spotify ID from the embed
scraper) are included so they can still hit Soulseek + the
wishlist.
- Tracks with neither are counted as ``skipped_tracks``.
- Empty result ``status: skipped`` with the skipped count.
- Same track set as last sync (matched_tracks unchanged)
``status: skipped`` (no-op).
- Otherwise spawns a daemon thread running ``run_sync_task`` and
returns ``status: started`` with ``_manages_own_progress: True``.
"""
auto_id = config.get('_automation_id')
playlist_id = config.get('playlist_id')
if not playlist_id:
return {'status': 'error', 'reason': 'No playlist specified'}
db = deps.get_database()
pl = db.get_mirrored_playlist(int(playlist_id))
if not pl:
return {'status': 'error', 'reason': 'Playlist not found'}
tracks = db.get_mirrored_playlist_tracks(int(playlist_id))
if not tracks:
return {'status': 'error', 'reason': 'No tracks in playlist'}
# Convert mirrored tracks to format expected by run_sync_task.
# Use discovered metadata when available, fall back to Spotify
# hint or raw playlist fields when not.
tracks_json = []
skipped_count = 0
for t in tracks:
# Parse extra_data for discovery info.
extra = {}
if t.get('extra_data'):
try:
extra = json.loads(t['extra_data']) if isinstance(t['extra_data'], str) else t['extra_data']
except (json.JSONDecodeError, TypeError):
pass
if extra.get('discovered') and extra.get('matched_data'):
# Use official discovered metadata.
md = extra['matched_data']
album_raw = md.get('album', '')
album_obj = album_raw if isinstance(album_raw, dict) else {'name': album_raw or ''}
_track_entry = {
'name': md.get('name', ''),
'artists': md.get('artists', [{'name': t.get('artist_name', '')}]),
'album': album_obj,
'duration_ms': md.get('duration_ms', 0),
'id': md.get('id', ''),
}
if md.get('track_number'):
_track_entry['track_number'] = md['track_number']
if md.get('disc_number'):
_track_entry['disc_number'] = md['disc_number']
tracks_json.append(_track_entry)
else:
# NOT discovered — try to include using available metadata so
# the track can still be searched on Soulseek and added to
# wishlist. Without this, failed discovery blocks the entire
# download pipeline.
#
# Priority: spotify_hint (has real Spotify ID from embed
# scraper) > raw playlist fields (only if source_track_id
# is valid).
hint = extra.get('spotify_hint', {})
# Build album object with cover art from the mirrored playlist track.
track_image = (t.get('image_url') or '').strip()
album_obj = {
'name': (t.get('album_name') or '').strip(),
'images': [{'url': track_image, 'height': 300, 'width': 300}] if track_image else [],
}
if hint.get('id') and hint.get('name'):
# spotify_hint has proper Spotify track ID + metadata from embed scraper.
hint_artists = hint.get('artists', [])
if hint_artists and isinstance(hint_artists[0], str):
hint_artists = [{'name': a} for a in hint_artists]
elif hint_artists and isinstance(hint_artists[0], dict):
pass # Already in correct format
else:
hint_artists = [{'name': t.get('artist_name', '')}]
tracks_json.append({
'name': hint['name'],
'artists': hint_artists,
'album': album_obj,
'duration_ms': t.get('duration_ms', 0),
'id': hint['id'],
})
elif t.get('source_track_id') and (t.get('track_name') or '').strip():
# Has a valid source ID and track name — usable for wishlist.
tracks_json.append({
'name': t['track_name'].strip(),
'artists': [{'name': (t.get('artist_name') or '').strip() or 'Unknown Artist'}],
'album': album_obj,
'duration_ms': t.get('duration_ms', 0),
'id': t['source_track_id'],
})
else:
skipped_count += 1 # No usable ID or name — truly can't process.
if not tracks_json:
deps.update_progress(
auto_id,
log_line=f'No discovered tracks — {skipped_count} need discovery first',
log_type='skip',
)
return {
'status': 'skipped',
'reason': f'No discovered tracks to sync ({skipped_count} tracks need discovery first)',
'skipped_tracks': str(skipped_count),
}
# Preflight: hash the track list and compare against last sync.
# Skip if the exact same set of tracks was already synced and
# everything matched (no-op preserves Plex / Jellyfin / Navidrome
# from needless rewrites).
track_ids_str = ','.join(sorted(t.get('id', '') for t in tracks_json))
tracks_hash = hashlib.md5(track_ids_str.encode()).hexdigest()
sync_id_key = f"auto_mirror_{playlist_id}"
try:
sync_statuses = deps.load_sync_status_file()
last_status = sync_statuses.get(sync_id_key, {})
last_hash = last_status.get('tracks_hash', '')
last_matched = last_status.get('matched_tracks', -1)
if last_hash == tracks_hash and last_matched >= len(tracks_json):
# Exact same tracks, all matched last time — nothing to do.
deps.update_progress(
auto_id,
log_line=f'All {len(tracks_json)} tracks unchanged since last sync — skipping',
log_type='skip',
)
return {
'status': 'skipped',
'reason': f'All {len(tracks_json)} tracks unchanged since last sync',
}
except Exception as e:
deps.logger.debug("mirror sync last-status read: %s", e)
deps.update_progress(
auto_id,
progress=50,
phase=f'Syncing "{pl["name"]}"',
log_line=f'{len(tracks_json)} discovered, {skipped_count} skipped',
log_type='info',
)
sync_id = f"auto_mirror_{playlist_id}"
deps.update_progress(
auto_id,
progress=90,
log_line=f'Starting sync: {len(tracks_json)} tracks',
log_type='success',
)
threading.Thread(
target=deps.run_sync_task,
args=(sync_id, pl['name'], tracks_json, auto_id, 1, pl.get('image_url', '')),
daemon=True,
name=f'auto-sync-{playlist_id}',
).start()
return {
'status': 'started',
'playlist_name': pl['name'],
'discovered_tracks': str(len(tracks_json)),
'skipped_tracks': str(skipped_count),
'_manages_own_progress': True,
}

View file

@ -191,6 +191,12 @@ def run_service_test(service, test_config):
'tidal': "Tidal download source ready.",
'qobuz': "Qobuz download source ready.",
'hifi': "HiFi download source ready.",
'deezer_dl': "Deezer download source ready.",
'amazon': "Amazon download source ready.",
'lidarr': "Lidarr download source ready.",
'soundcloud': "SoundCloud download source ready.",
'torrent': "Torrent download source ready.",
'usenet': "Usenet download source ready.",
'hybrid': "Download sources ready (Hybrid mode)."
}
message = mode_messages.get(download_mode, "Download source connected.")
@ -203,6 +209,12 @@ def run_service_test(service, test_config):
'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.",
'deezer_dl': "Deezer download source not available. Check authentication.",
'amazon': "Amazon download source not available.",
'lidarr': "Lidarr download source not available. Check Lidarr URL and API key.",
'soundcloud': "SoundCloud download source not available.",
'torrent': "Torrent download source not available. Check Prowlarr and torrent client settings.",
'usenet': "Usenet download source not available. Check Prowlarr and usenet client settings.",
'hybrid': "Could not connect to download sources. Check configuration."
}
error = mode_errors.get(download_mode, "Download source connection failed.")
@ -305,6 +317,61 @@ def run_service_test(service, test_config):
return False, "Invalid Genius access token."
except Exception as e:
return False, f"Genius connection error: {str(e)}"
elif service == "usenet_client":
client_type = (config_manager.get('usenet_client.type', '') or '').strip().lower()
url = config_manager.get('usenet_client.url', '')
if not url:
return False, "Usenet client URL is required."
if not client_type:
return False, "Pick a usenet client (SABnzbd or NZBGet)."
try:
from core.usenet_clients import adapter_for_type as _usenet_adapter_for_type
adapter = _usenet_adapter_for_type(client_type)
if adapter is None:
return False, f"Unknown usenet client type: {client_type}"
if not adapter.is_configured():
if client_type == "sabnzbd":
return False, "SABnzbd needs both URL and API key."
return False, "NZBGet needs URL, username, and password."
if run_async(adapter.check_connection()):
return True, f"Connected to {client_type}"
return False, f"{client_type} probe failed — check URL, credentials, and that the client is running."
except Exception as e:
return False, f"Usenet client connection error: {str(e)}"
elif service == "torrent_client":
client_type = (config_manager.get('torrent_client.type', '') or '').strip().lower()
url = config_manager.get('torrent_client.url', '')
if not url:
return False, "Torrent client URL is required."
if not client_type:
return False, "Pick a torrent client (qBittorrent, Transmission, or Deluge)."
try:
from core.torrent_clients import adapter_for_type
adapter = adapter_for_type(client_type)
if adapter is None:
return False, f"Unknown torrent client type: {client_type}"
if not adapter.is_configured():
return False, "Torrent client missing required credentials."
if run_async(adapter.check_connection()):
return True, f"Connected to {client_type}"
return False, f"{client_type} probe failed — check URL, credentials, and that the client is running."
except Exception as e:
return False, f"Torrent client connection error: {str(e)}"
elif service == "prowlarr":
url = config_manager.get('prowlarr.url', '')
api_key = config_manager.get('prowlarr.api_key', '')
if not url or not api_key:
return False, "Prowlarr 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 Prowlarr v{version}"
return False, f"Prowlarr returned HTTP {resp.status_code}"
except Exception as e:
return False, f"Prowlarr 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', '')
@ -379,6 +446,16 @@ def run_service_test(service, test_config):
return False, "Hydrabase not connected. Configure URL + API key and click Connect."
except Exception as e:
return False, f"Hydrabase connection error: {str(e)}"
elif service == "musicbrainz":
try:
from core.metadata.registry import get_musicbrainz_client
mb = get_musicbrainz_client()
results = mb.search_artists("radiohead", limit=1)
if results:
return True, "MusicBrainz reachable"
return False, "MusicBrainz returned no results — may be rate-limited or unreachable."
except Exception as e:
return False, f"MusicBrainz connection error: {str(e)}"
elif service == "soundcloud":
# Anonymous SoundCloud has no auth, so "test" really means
# "is yt-dlp installed and can it reach SoundCloud right now."

View file

@ -89,6 +89,33 @@ def _upgrade_deezer_cover_url(url: str, target_size: int = _DEEZER_MAX_COVER_SIZ
return _DEEZER_CDN_SIZE_PATTERN.sub(f'/{target_size}x{target_size}-', url, count=1)
def _is_full_track_payload(payload: Optional[Dict[str, Any]]) -> bool:
"""Distinguish a full `/track/<id>` cache hit from partial album-tracks data.
Three Deezer endpoints feed the per-track cache:
- `/track/<id>` full record, includes both `track_position` AND
`contributors` (the multi-artist list the contributors-upgrade
path reads).
- `/album/<id>/tracks` partial; includes `track_position` but
omits `contributors`.
- `/search/track` minimal; lacks `track_position`.
Pre-fix `get_track_details` only checked `track_position`, so
partial album-tracks payloads were treated as full hits and the
contributors-upgrade silently fell back to single-artist tagging
whenever an album had been fetched before its individual tracks
were post-processed (issue #588).
`contributors` key presence is the load-bearing distinction
`[]` is a valid value for genuinely single-artist tracks fetched
via the per-track endpoint, so test for key membership not
truthiness.
"""
if not isinstance(payload, dict):
return False
return 'track_position' in payload and 'contributors' in payload
# ==================== Dataclasses (match iTunesClient / SpotifyClient format) ====================
@dataclass
@ -546,14 +573,9 @@ class DeezerClient:
"""Get detailed track info — returns Spotify-compatible dict (metadata source interface)"""
cache = get_metadata_cache()
cached = cache.get_entity('deezer', 'track', str(track_id))
if cached and cached.get('title'):
# Search results are cached with minimal data (no track_position).
# Only use cache if it has track_position — the key field from /track/{id}.
# Search results include 'isrc' and 'release_date' but NOT track_position,
# so those fields alone are not sufficient to distinguish full from partial data.
if 'track_position' in cached:
return self._build_enhanced_track(cached)
# Otherwise fall through to fetch full data from API
if cached and cached.get('title') and _is_full_track_payload(cached):
return self._build_enhanced_track(cached)
# Otherwise fall through to fetch full data from API
data = self._api_get(f'track/{track_id}')
if not data:

View file

@ -17,10 +17,14 @@ logger = logging.getLogger(__name__)
def get_current_profile_id() -> int:
"""Mirror of web_server.get_current_profile_id — uses Flask g."""
"""Mirror of web_server.get_current_profile_id — uses Flask g.
Catches RuntimeError too because reading `g` outside a request
context raises that (not AttributeError) happens when this is
called from background threads (sync, automation, scanners)."""
try:
return g.profile_id
except AttributeError:
except (AttributeError, RuntimeError):
return 1
@ -92,6 +96,8 @@ def get_discover_hero():
artist_id = artist.spotify_artist_id
elif active_source == 'deezer':
artist_id = getattr(artist, 'deezer_artist_id', None) or artist.itunes_artist_id
elif active_source == 'musicbrainz':
artist_id = getattr(artist, 'musicbrainz_artist_id', None) or artist.itunes_artist_id
else:
artist_id = artist.itunes_artist_id
if not artist_id:
@ -121,7 +127,7 @@ def get_discover_hero():
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:
if active_source in ('itunes', 'deezer', 'musicbrainz') 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:
@ -131,13 +137,20 @@ def get_discover_hero():
continue
# Try to resolve ID by name
try:
search_results = itunes_client.search_artists(artist.similar_artist_name, limit=1)
resolve_client = itunes_client
if active_source == 'musicbrainz':
from core.metadata.registry import get_musicbrainz_client
resolve_client = get_musicbrainz_client()
search_results = resolve_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
elif active_source == 'musicbrainz':
database.update_similar_artist_musicbrainz_id(artist.id, resolved_id)
artist.similar_artist_musicbrainz_id = resolved_id
else:
database.update_similar_artist_itunes_id(artist.id, resolved_id)
artist.similar_artist_itunes_id = resolved_id
@ -169,12 +182,15 @@ def get_discover_hero():
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
elif active_source == 'musicbrainz':
artist_id = getattr(artist, 'similar_artist_musicbrainz_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,
"musicbrainz_artist_id": getattr(artist, 'similar_artist_musicbrainz_id', None),
"artist_id": artist_id,
"artist_name": artist.similar_artist_name,
"occurrence_count": artist.occurrence_count,
@ -203,11 +219,19 @@ def get_discover_hero():
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
elif active_source in ('itunes', 'deezer', 'musicbrainz'):
if active_source == 'deezer':
fb_artist_id = getattr(artist, 'similar_artist_deezer_id', None) or artist.similar_artist_itunes_id
fetch_client = itunes_client
elif active_source == 'musicbrainz':
fb_artist_id = getattr(artist, 'similar_artist_musicbrainz_id', None)
from core.metadata.registry import get_musicbrainz_client
fetch_client = get_musicbrainz_client()
else:
fb_artist_id = artist.similar_artist_itunes_id
fetch_client = itunes_client
if fb_artist_id:
fb_artist_data = itunes_client.get_artist(fb_artist_id)
fb_artist_data = fetch_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

299
core/discovery/qobuz.py Normal file
View file

@ -0,0 +1,299 @@
"""Background worker for Qobuz playlist discovery.
`run_qobuz_discovery_worker(playlist_id, deps)` is the function the
Qobuz discovery start-endpoint submits to its executor to match each
Qobuz playlist track against Spotify (preferred) or the configured
fallback metadata source (iTunes / Deezer / Discogs / MusicBrainz).
Mirrors `core/discovery/deezer.py` exactly Qobuz playlists arrive as
dicts (not dataclasses) from `core/qobuz_client.py:get_playlist`, so
this worker uses dict-style access on track data and wraps each entry
in a SimpleNamespace before handing it to the shared
`_search_spotify_for_tidal_track` helper.
"""
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 QobuzDiscoveryDeps:
"""Bundle of cross-cutting deps the Qobuz discovery worker needs."""
qobuz_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_qobuz_discovery_worker(playlist_id, deps: QobuzDiscoveryDeps):
"""Background worker for Qobuz discovery process (Spotify preferred, fallback metadata source)."""
_ew_state = {}
try:
_ew_state = deps.pause_enrichment_workers('Qobuz discovery')
state = deps.qobuz_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 Qobuz 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, qobuz_track in enumerate(tracks):
if state.get('cancelled', False):
break
try:
track_name = qobuz_track['name']
track_artists = qobuz_track['artists']
track_id = qobuz_track['id']
track_album = qobuz_track.get('album', '')
track_duration_ms = qobuz_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)}")
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 = {
'qobuz_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}")
# SimpleNamespace duck-type 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
)
track_result = deps.search_spotify_for_tidal_track(
track_ns,
use_spotify=use_spotify,
itunes_client=itunes_client_instance
)
result = {
'qobuz_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):
track_obj, raw_track_data, match_confidence = track_result
album_obj = raw_track_data.get('album', {}) if raw_track_data else {}
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}
if isinstance(album_obj, dict) and not album_obj.get('release_date'):
album_obj['release_date'] = getattr(track_obj, 'release_date', '') or ''
_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'
}
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):
match_confidence = track_result.pop('confidence', 0.80)
match_data = track_result
match_data['source'] = discovery_source
_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':
qobuz_t = result.get('qobuz_track', {})
stub = deps.build_discovery_wing_it_stub(
qobuz_t.get('name', ''),
', '.join(qobuz_t.get('artists', [])),
qobuz_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'] = qobuz_t.get('name', '')
result['spotify_artist'] = ', '.join(qobuz_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)
time.sleep(0.1)
except Exception as e:
logger.error(f"Error processing track {i+1}: {e}")
result = {
'qobuz_track': {
'name': qobuz_track.get('name', 'Unknown'),
'artists': qobuz_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
source_label = discovery_source.upper()
deps.add_activity_item("", f"Qobuz Discovery Complete ({source_label})", f"'{playlist['name']}' - {successful_discoveries}/{len(tracks)} tracks found", "Now")
logger.info(f"Qobuz discovery complete ({source_label}): {successful_discoveries}/{len(tracks)} tracks found")
deps.sync_discovery_results_to_mirrored('qobuz', playlist_id, state.get('discovery_results', []), discovery_source, profile_id=state.get('_profile_id', 1))
except Exception as e:
logger.error(f"Error in Qobuz discovery worker: {e}")
if playlist_id in deps.qobuz_discovery_states:
deps.qobuz_discovery_states[playlist_id]['phase'] = 'error'
deps.qobuz_discovery_states[playlist_id]['status'] = f'error: {str(e)}'
finally:
deps.resume_enrichment_workers(_ew_state, 'Qobuz discovery')

View file

@ -397,9 +397,12 @@ def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, p
}
logger.info(f"Sync finished for {playlist_id} - state updated")
# Set playlist poster image if available (Plex, Jellyfin, Emby)
# Set playlist poster image if available (Plex, Jellyfin, Emby).
# Don't log the URL itself — it may carry an auth token (Plex
# X-Plex-Token / Jellyfin X-Emby-Token / Subsonic auth) that we
# don't want persisted to app.log.
_synced = getattr(result, 'synced_tracks', 0)
logger.info(f"[PLAYLIST IMAGE] image_url={playlist_image_url!r}, synced_tracks={_synced}")
logger.info(f"[PLAYLIST IMAGE] has_image={bool(playlist_image_url)}, synced_tracks={_synced}")
if playlist_image_url and _synced > 0:
try:
active_server = deps.config_manager.get_active_media_server()

View file

@ -104,6 +104,31 @@ class DownloadOrchestrator:
deezer_dl.reconnect(deezer_arl)
deezer_dl._quality = config_manager.get('deezer_download.quality', 'flac')
# Reload Amazon quality preference (T2Tunes needs no reconnect — public proxy)
amazon = self.client('amazon')
if amazon:
quality = config_manager.get('amazon_download.quality', 'flac')
amazon._quality = quality
amazon._allow_fallback = config_manager.get('amazon_download.allow_fallback', True)
if hasattr(amazon, '_client') and amazon._client:
amazon._client.preferred_codec = quality
# Let registry-backed plugins refresh any config they cache at
# construction time. This covers Prowlarr-backed torrent / usenet
# clients without rebuilding the registry and losing active downloads.
for name, client in self.registry.all_plugins():
if not hasattr(client, 'reload_settings'):
continue
try:
client.reload_settings()
logger.info("%s client settings reloaded", self.registry.display_name(name))
except Exception as exc:
logger.warning(
"%s client settings reload failed: %s",
self.registry.display_name(name),
exc,
)
# Reload download path for all clients that cache it.
# Soulseek owns the path config and is reloaded above; every
# other source mirrors that path so files all land in one
@ -277,11 +302,25 @@ class DownloadOrchestrator:
chain = ['soulseek']
return chain
async def search(self, query: str, timeout: int = None, progress_callback=None) -> Tuple[List[TrackResult], List[AlbumResult]]:
async def search(self, query: str, timeout: int = None, progress_callback=None,
exclude_sources=None) -> Tuple[List[TrackResult], List[AlbumResult]]:
"""Search for tracks using configured source(s). Single-source
modes route directly; hybrid mode delegates to
``engine.search_with_fallback`` which tries the chain in order."""
``engine.search_with_fallback`` which tries the chain in order.
``exclude_sources`` (optional) is an iterable of source names
the caller wants filtered out of the hybrid chain. Used by the
per-track download worker to skip torrent / usenet for album-
context batches those sources are release-level and don't
score meaningfully on per-track titles; the album-bundle flow
on the master worker handles them separately when they're the
single active source."""
if self.mode != 'hybrid':
# Single-source mode is opt-in; honour the user's choice even
# if it's torrent/usenet on an album batch (the master worker
# routes those through the album-bundle flow before per-track
# tasks ever fire, so search() being called here would be a
# non-album / wishlist / basic-search use case).
client = self._client(self.mode)
if not client:
logger.error(f"{self.registry.display_name(self.mode)} client not available (failed to initialize)")
@ -290,6 +329,19 @@ class DownloadOrchestrator:
return await client.search(query, timeout, progress_callback)
chain = self._resolve_source_chain()
if exclude_sources:
blocked = {s.lower() for s in exclude_sources if s}
filtered = [s for s in chain if s.lower() not in blocked]
if filtered != chain:
logger.info(
"Hybrid search: excluding %s for this query (chain %s -> %s)",
sorted(blocked & {s.lower() for s in chain}),
"".join(chain), "".join(filtered) if filtered else "(empty)",
)
chain = filtered
if not chain:
logger.warning("Hybrid search exhausted: no eligible sources after exclusion filter")
return [], []
logger.info(f"Hybrid search ({''.join(chain)}): {query}")
return await self.engine.search_with_fallback(query, chain, timeout, progress_callback)
@ -319,7 +371,7 @@ class DownloadOrchestrator:
return None
# 2. Filter and validate results
_streaming_sources = ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud')
_streaming_sources = ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud', 'amazon')
is_streaming = tracks[0].username in _streaming_sources if tracks else False
if is_streaming and expected_track:

View file

@ -0,0 +1,217 @@
"""Shared helpers for the album-bundle download flow.
The torrent and usenet download plugins both implement a
``download_album_to_staging`` method that searches Prowlarr for a
whole release, hands it to the active downloader, walks the
resulting audio files, and copies them into the staging folder. The
two implementations share the same release-picker heuristic and the
same staging-path collision logic.
Pulled out of ``core/download_plugins/torrent.py`` so the usenet
plugin doesn't have to import private helpers from a sibling
plugin (Cin's "no leaky module boundaries" standard).
Also exposes ``atomic_copy_to_staging`` the audio file is copied
to a ``.tmp.<random>`` sidecar first and atomically renamed onto its
final extension. The Auto-Import worker filters by audio extension
so the in-flight ``.tmp`` file is never picked up mid-copy, closing
the race between the album-bundle copy loop and Auto-Import's
folder scan.
"""
from __future__ import annotations
import shutil
import time
import uuid
from pathlib import Path
from typing import Iterable, Optional
from config.settings import config_manager
from utils.logging_config import get_logger
logger = get_logger("download_plugins.album_bundle")
# Album-pick size floor / ceiling. Single-track torrents (~10 MB)
# are rejected when bigger candidates exist; anything past 3 GB is
# treated as suspicious (multi-disc box-set + scans + extras).
ALBUM_PICK_MIN_BYTES = 40 * 1024 * 1024
ALBUM_PICK_MAX_BYTES = 3 * 1024 * 1024 * 1024
# Quality-score weights for the album-pick heuristic. Mirrors the
# tier order in ``core/imports/file_ops.py``'s ``quality_tiers`` —
# higher number = preferred.
_QUALITY_SCORE = {'flac': 4, 'ogg': 3, 'aac': 2, 'mp3': 1}
# Default poll cadence + timeout for the album-download poll loop.
# Both are overridable through config so users with slow trackers
# / large box-sets can extend the deadline without editing code.
DEFAULT_POLL_INTERVAL_SECONDS = 2.0
DEFAULT_POLL_TIMEOUT_SECONDS = 6 * 60 * 60
def get_poll_interval() -> float:
"""Return the per-poll sleep duration (seconds). Configurable via
``download_source.album_bundle_poll_interval_seconds``."""
raw = config_manager.get('download_source.album_bundle_poll_interval_seconds',
DEFAULT_POLL_INTERVAL_SECONDS)
try:
value = float(raw)
if value > 0:
return value
except (TypeError, ValueError):
pass
return DEFAULT_POLL_INTERVAL_SECONDS
def get_poll_timeout() -> float:
"""Return the total deadline for an album-bundle download
(seconds). Configurable via
``download_source.album_bundle_timeout_seconds``."""
raw = config_manager.get('download_source.album_bundle_timeout_seconds',
DEFAULT_POLL_TIMEOUT_SECONDS)
try:
value = float(raw)
if value > 0:
return value
except (TypeError, ValueError):
pass
return DEFAULT_POLL_TIMEOUT_SECONDS
def quality_score(title: str, quality_guess) -> int:
"""Map a release title's inferred quality to a sortable integer.
``quality_guess`` is the function from each plugin that maps a
title string to a quality string ('flac' / 'mp3' / etc.) passed
in so this module doesn't have to import either plugin and risk
a circular import."""
return _QUALITY_SCORE.get(quality_guess(title) or '', 0)
def pick_best_album_release(candidates, quality_guess) -> Optional[object]:
"""Pick the single best torrent / NZB for an album-bundle download.
Heuristic, in priority order:
1. Reasonable album-ish size (40 MB 3 GB) drops single-track
releases that snuck in and quarantines suspicious giants.
2. Higher seeders > lower (dead torrents = dead downloads).
Usenet releases use ``grabs`` as a popularity proxy when
seeders is None.
3. Higher quality (FLAC > AAC > MP3) inferred from title.
4. Larger size as tiebreaker (often = higher bitrate).
"""
if not candidates:
return None
sized = [c for c in candidates
if ALBUM_PICK_MIN_BYTES <= (c.size or 0) <= ALBUM_PICK_MAX_BYTES]
pool = sized or list(candidates)
if not pool:
return None
def _score(c) -> tuple:
seeders = c.seeders if c.seeders is not None else (c.grabs or 0)
return (seeders, quality_score(c.title or '', quality_guess), c.size or 0)
return max(pool, key=_score)
def unique_staging_path(staging_dir: Path, src: Path) -> Path:
"""Return a destination path inside ``staging_dir`` that doesn't
collide with an existing file. Appends ``_1``, ``_2``, ... before
the extension when needed; gives up after 1000 candidates and
returns the unsuffixed path so the caller will overwrite (better
than infinite loop or crash)."""
dest = staging_dir / src.name
if not dest.exists():
return dest
stem = dest.stem
suffix = dest.suffix
for i in range(1, 1000):
candidate = staging_dir / f"{stem}_{i}{suffix}"
if not candidate.exists():
return candidate
return dest
def atomic_copy_to_staging(src: Path, dest: Path) -> bool:
"""Copy ``src`` to ``dest`` without exposing a partial file to
folder scanners.
The Auto-Import worker filters by audio extension when scanning
Staging see ``AUDIO_EXTENSIONS`` in ``core/auto_import_worker.py``.
Naming the in-flight file ``<dest>.tmp.<random>`` keeps it
invisible until the rename atomically swings it to its final
extension. ``os.replace`` (used by ``Path.rename`` on Python 3.x)
is atomic on the same filesystem, so Auto-Import either sees the
file at its final name (complete) or doesn't see it at all
(in flight).
Returns True on success, False on copy / rename failure. Caller
is expected to log the failure case so we don't double-log here.
"""
tmp = dest.with_name(f"{dest.name}.tmp.{uuid.uuid4().hex[:8]}")
try:
shutil.copy2(src, tmp)
except Exception:
# Best-effort cleanup of the partial file. If unlink fails
# (locked, permissions) we leave it — Auto-Import ignores it
# anyway because of the .tmp extension.
try:
if tmp.exists():
tmp.unlink()
except Exception as cleanup_exc:
logger.debug("album_bundle tmp cleanup failed: %s", cleanup_exc)
raise
try:
tmp.replace(dest)
return True
except Exception:
try:
tmp.unlink(missing_ok=True)
except Exception as cleanup_exc:
logger.debug("album_bundle tmp cleanup failed: %s", cleanup_exc)
raise
def copy_audio_files_atomically(
sources: Iterable[Path], staging_dir: Path,
) -> list:
"""Convenience wrapper: pick a non-colliding staging path for
each source, copy via ``atomic_copy_to_staging``. Returns the
list of final destination paths (as strings). Files that fail
to copy are logged and skipped; the caller decides what to do
with a partial result."""
staging_dir.mkdir(parents=True, exist_ok=True)
out: list = []
for src in sources:
dest = unique_staging_path(staging_dir, src)
try:
atomic_copy_to_staging(src, dest)
out.append(str(dest))
except Exception as e:
logger.warning("[album_bundle] Failed to stage %s -> %s: %s", src, dest, e)
return out
# Re-export so callers don't have to remember which module owns
# what. The ``time`` import is kept so plugins can ``from
# core.download_plugins.album_bundle import time`` if they want to,
# avoiding a second std-lib import line for a single use.
__all__ = [
"ALBUM_PICK_MIN_BYTES",
"ALBUM_PICK_MAX_BYTES",
"DEFAULT_POLL_INTERVAL_SECONDS",
"DEFAULT_POLL_TIMEOUT_SECONDS",
"atomic_copy_to_staging",
"copy_audio_files_atomically",
"get_poll_interval",
"get_poll_timeout",
"pick_best_album_release",
"quality_score",
"time",
"unique_staging_path",
]

View file

@ -38,7 +38,10 @@ from core.download_plugins.base import DownloadSourcePlugin
# than the legacy module-top imports here. Importing everything at
# registry-load time pins the bindings the same way the legacy
# orchestrator did.
from core.amazon_download_client import AmazonDownloadClient
from core.deezer_download_client import DeezerDownloadClient
from core.download_plugins.torrent import TorrentDownloadPlugin
from core.download_plugins.usenet import UsenetDownloadPlugin
from core.hifi_client import HiFiClient
from core.lidarr_download_client import LidarrDownloadClient
from core.qobuz_client import QobuzClient
@ -176,6 +179,7 @@ def build_default_registry() -> DownloadPluginRegistry:
"""
registry = DownloadPluginRegistry()
registry.register(PluginSpec(name='amazon', factory=AmazonDownloadClient, display_name='Amazon Music'))
registry.register(PluginSpec(name='soulseek', factory=SoulseekClient, display_name='Soulseek'))
registry.register(PluginSpec(name='youtube', factory=YouTubeClient, display_name='YouTube'))
registry.register(PluginSpec(name='tidal', factory=TidalDownloadClient, display_name='Tidal'))
@ -188,5 +192,7 @@ def build_default_registry() -> DownloadPluginRegistry:
aliases=('deezer_dl',)))
registry.register(PluginSpec(name='lidarr', factory=LidarrDownloadClient, display_name='Lidarr'))
registry.register(PluginSpec(name='soundcloud',factory=SoundcloudClient, display_name='SoundCloud'))
registry.register(PluginSpec(name='torrent', factory=TorrentDownloadPlugin, display_name='Torrent (Prowlarr)'))
registry.register(PluginSpec(name='usenet', factory=UsenetDownloadPlugin, display_name='Usenet (Prowlarr)'))
return registry

View file

@ -0,0 +1,666 @@
"""TorrentDownloadPlugin — composes Prowlarr search + torrent client
adapter + archive_pipeline into a uniform download source.
Two flows:
**Per-track flow** (basic search, single-track wishlist)
1. ``search(query)`` calls ``ProwlarrClient.search`` filtered to
``protocol='torrent'`` results, projects releases into
``TrackResult`` / ``AlbumResult`` shaped objects the existing
search UI already understands. Encodes the indexer's
``downloadUrl`` (or magnet URI) into the filename so
``download()`` can recover it.
2. ``download(username, filename, ...)`` decodes the URL, asks the
active torrent adapter (qBittorrent, Transmission, or Deluge per
user's settings) to add it, spawns a background thread that
polls the adapter for completion.
3. On completion the thread walks the adapter-reported save path
via ``archive_pipeline.collect_audio_after_extraction`` and
exposes the full audio-file list. Post-processing can then pick
the requested track from a completed release instead of importing
the first file blindly.
**Album-bundle flow** (album-context batch downloads wired in
``core/downloads/master.py``)
4. ``download_album_to_staging(album, artist, staging_dir)`` does
ONE Prowlarr search for the whole release, picks the best
torrent (prefers FLAC, decent seeders, reasonable size),
downloads it, extracts archives if needed, copies every audio
file into the staging directory. The existing per-track
``try_staging_match`` flow then finds + imports each track by
fuzzy title match against the staged files. Per-track Prowlarr
queries never fire track titles like "Luther (with SZA)"
would match album torrents like "GNX (2024) [FLAC]" at near-
zero confidence and break the per-track dispatch.
Limitations:
- ``save_path`` is the torrent client's view of the disk. If
SoulSync runs on a different host than qBit / Trans / Deluge,
the post-processing pipeline can't see those files. The plugin
works fine for the all-on-one-box case (most users); remote
setups will need a future sync step (rclone / SMB / Docker
bind mount).
- Track-level metadata isn't available until after download.
Search results carry only the release title + indexer metadata;
individual track names are populated when the matching pipeline
walks the extracted audio files.
"""
from __future__ import annotations
import asyncio
import re
import threading
import time
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from config.settings import config_manager
from core.archive_pipeline import collect_audio_after_extraction
from core.download_plugins.album_bundle import (
copy_audio_files_atomically,
get_poll_interval,
get_poll_timeout,
pick_best_album_release,
)
from core.download_plugins.base import DownloadSourcePlugin
from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult
from core.prowlarr_client import (
DEFAULT_MUSIC_CATEGORIES,
ProwlarrClient,
ProwlarrSearchResult,
)
from core.torrent_clients import get_active_adapter as get_active_torrent_adapter
from utils.async_helpers import run_async
from utils.logging_config import get_logger
logger = get_logger("download_plugins.torrent")
# Separator used to encode the download URL inside the filename
# field. Same convention Lidarr / YouTube use for embedding their
# own opaque identifiers — ``<download_url>||<display>``.
_FILENAME_SEP = '||'
# Adapter states that count as the download being on-disk and
# safe to walk. ``seeding`` and ``completed`` both mean the
# bits are there; the user can pause seeding manually if they
# don't want to keep sharing.
_COMPLETE_STATES = frozenset(['seeding', 'completed'])
# Poll cadence / timeout — both pull from config via the shared
# album_bundle helpers so users can extend the deadline for slow
# trackers without editing source. Kept as module aliases so the
# per-track flow at the bottom of this file can still import them
# under the legacy names without re-reading config every loop.
_POLL_TIMEOUT_SECONDS = get_poll_timeout()
_POLL_INTERVAL_SECONDS = get_poll_interval()
class TorrentDownloadPlugin(DownloadSourcePlugin):
"""Torrent download source backed by Prowlarr + an active
torrent client adapter."""
def __init__(self) -> None:
self._prowlarr = ProwlarrClient()
# Track every download we've kicked off. Keyed by our own
# uuid — NOT the adapter's hash — because the orchestrator
# owns the lifecycle and we need a stable id even before
# the adapter has assigned one.
self.active_downloads: Dict[str, Dict[str, Any]] = {}
self._lock = threading.Lock()
self.shutdown_check = None
def set_shutdown_check(self, check_callable):
self.shutdown_check = check_callable
def reload_settings(self) -> None:
self._prowlarr.reload_settings()
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def is_configured(self) -> bool:
if not self._prowlarr.is_configured():
return False
adapter = get_active_torrent_adapter()
return bool(adapter and adapter.is_configured())
async def check_connection(self) -> bool:
if not self._prowlarr.is_configured():
return False
adapter = get_active_torrent_adapter()
if not adapter or not adapter.is_configured():
return False
# Probe both sides. A torrent download is useless if either
# the indexer or the downloader is unreachable.
prowlarr_ok = await self._prowlarr.check_connection()
if not prowlarr_ok:
return False
return await adapter.check_connection()
# ------------------------------------------------------------------
# Search
# ------------------------------------------------------------------
async def search(
self,
query: str,
timeout: Optional[int] = None,
progress_callback=None,
) -> Tuple[List[TrackResult], List[AlbumResult]]:
if not self._prowlarr.is_configured():
return ([], [])
try:
indexer_ids = _parse_indexer_id_filter()
results = await self._prowlarr.search(
query,
categories=DEFAULT_MUSIC_CATEGORIES,
indexer_ids=indexer_ids,
)
except Exception as e:
logger.error("Torrent plugin search failed: %s", e)
return ([], [])
return self._project_results(results)
def _project_results(
self, results: List[ProwlarrSearchResult]
) -> Tuple[List[TrackResult], List[AlbumResult]]:
"""Turn Prowlarr releases into TrackResult / AlbumResult
shaped objects. One TrackResult + one AlbumResult per
release Prowlarr search hits are at the release level,
not the track level, so we can't synthesise track listings
without downloading the actual torrent."""
tracks: List[TrackResult] = []
albums: List[AlbumResult] = []
for result in results:
if result.protocol != 'torrent':
continue
download_url = result.magnet_uri or result.download_url
if not download_url:
continue
filename = f"{download_url}{_FILENAME_SEP}{result.title}"
quality = _guess_quality_from_title(result.title)
parsed_artist, parsed_title = _parse_release_title(result.title)
tr = TrackResult(
username='torrent',
filename=filename,
size=result.size,
bitrate=None,
duration=None,
quality=quality,
# Torrent results don't have per-uploader slot / queue
# data the way Soulseek does. Fill with neutral values
# so the quality_score doesn't punish them artificially.
free_upload_slots=max(1, result.seeders or 0),
upload_speed=0,
queue_length=0,
# Pre-fill artist + title so TrackResult.__post_init__
# doesn't auto-parse the filename — our filename starts
# with the indexer download URL, which would otherwise
# show up as "by download?apikey=..." in the UI.
artist=parsed_artist or result.indexer_name or 'Torrent',
title=parsed_title or result.title,
album=parsed_title or None,
track_number=None,
_source_metadata={
'indexer': result.indexer_name,
'indexer_id': result.indexer_id,
'seeders': result.seeders,
'leechers': result.leechers,
'grabs': result.grabs,
'protocol': 'torrent',
},
)
tracks.append(tr)
albums.append(AlbumResult(
username='torrent',
album_path=f"torrent/{result.guid}",
album_title=parsed_title or result.title,
artist=parsed_artist or None,
track_count=1, # unknown until download finishes
total_size=result.size,
tracks=[tr],
dominant_quality=quality,
year=None,
))
return tracks, albums
# ------------------------------------------------------------------
# Download
# ------------------------------------------------------------------
async def download(
self,
username: str,
filename: str,
file_size: int = 0,
) -> Optional[str]:
if not self.is_configured():
return None
download_url, display_name = _decode_filename(filename)
if not download_url:
logger.error("Torrent download missing URL in filename: %r", filename)
return None
download_id = str(uuid.uuid4())
with self._lock:
self.active_downloads[download_id] = {
'id': download_id,
'filename': filename,
'username': 'torrent',
'display_name': display_name,
'state': 'Initializing',
'progress': 0.0,
'size': file_size,
'transferred': 0,
'speed': 0,
'file_path': None,
'audio_files': [],
'torrent_hash': None,
'error': None,
}
thread = threading.Thread(
target=self._download_thread,
args=(download_id, download_url, display_name),
daemon=True,
name=f'torrent-dl-{download_id[:8]}',
)
thread.start()
return download_id
def _download_thread(self, download_id: str, download_url: str, display_name: str) -> None:
"""Background worker: hand the URL to the active adapter,
poll until done, then walk the resulting directory."""
adapter = get_active_torrent_adapter()
if adapter is None or not adapter.is_configured():
self._mark_error(download_id, "No torrent client configured")
return
try:
torrent_hash = run_async(adapter.add_torrent(download_url))
except Exception as e:
self._mark_error(download_id, f"add_torrent failed: {e}")
return
if not torrent_hash:
self._mark_error(download_id, "Torrent client refused the URL")
return
with self._lock:
row = self.active_downloads.get(download_id)
if row is not None:
row['torrent_hash'] = torrent_hash
row['state'] = 'InProgress, Downloading'
deadline = time.monotonic() + _POLL_TIMEOUT_SECONDS
last_save_path: Optional[str] = None
while time.monotonic() < deadline:
if self.shutdown_check and self.shutdown_check():
return
try:
status = run_async(adapter.get_status(torrent_hash))
except Exception as e:
logger.warning("Torrent poll error for %s: %s", torrent_hash, e)
status = None
if status is None:
# Adapter forgot about the torrent — probably user-removed.
self._mark_error(download_id, "Torrent disappeared from client")
return
with self._lock:
row = self.active_downloads.get(download_id)
if row is not None:
row['progress'] = status.progress * 100.0
row['transferred'] = status.downloaded
row['speed'] = status.download_speed
row['size'] = status.size or row.get('size', 0)
row['state'] = _adapter_state_to_display(status.state)
row['error'] = status.error
if status.save_path:
last_save_path = status.save_path
if status.state in _COMPLETE_STATES:
self._finalize_download(download_id, last_save_path)
return
if status.state == 'error':
self._mark_error(download_id, status.error or "Torrent client reported error")
return
time.sleep(_POLL_INTERVAL_SECONDS)
self._mark_error(download_id, "Torrent download timed out")
def _finalize_download(self, download_id: str, save_path: Optional[str]) -> None:
"""Adapter said complete. Walk the directory + pick the
first audio file as the canonical ``file_path``."""
if not save_path:
self._mark_error(download_id, "Torrent completed but no save_path reported")
return
try:
audio_files = collect_audio_after_extraction(Path(save_path))
except Exception as e:
self._mark_error(download_id, f"Post-extract walk failed: {e}")
return
if not audio_files:
self._mark_error(download_id, f"No audio files found in {save_path}")
return
primary = audio_files[0]
with self._lock:
row = self.active_downloads.get(download_id)
if row is not None:
row['state'] = 'Completed, Succeeded'
row['progress'] = 100.0
row['file_path'] = str(primary)
row['audio_files'] = [str(path) for path in audio_files]
logger.info("Torrent download complete: %s -> %s (%d audio files)",
download_id[:8], primary.name, len(audio_files))
def _mark_error(self, download_id: str, message: str) -> None:
logger.error("Torrent download %s failed: %s", download_id[:8], message)
with self._lock:
row = self.active_downloads.get(download_id)
if row is not None:
row['state'] = 'Completed, Errored'
row['error'] = message
# ------------------------------------------------------------------
# Status / lifecycle
# ------------------------------------------------------------------
async def get_all_downloads(self) -> List[DownloadStatus]:
with self._lock:
rows = list(self.active_downloads.values())
return [_row_to_status(r) for r in rows]
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
with self._lock:
row = self.active_downloads.get(download_id)
if row is None:
return None
return _row_to_status(row)
async def cancel_download(
self,
download_id: str,
username: Optional[str] = None,
remove: bool = False,
) -> bool:
adapter = get_active_torrent_adapter()
with self._lock:
row = self.active_downloads.get(download_id)
torrent_hash = row.get('torrent_hash') if row else None
if adapter and torrent_hash:
try:
await adapter.remove(torrent_hash, delete_files=remove)
except Exception as e:
logger.warning("Torrent cancel via adapter failed: %s", e)
with self._lock:
if remove:
self.active_downloads.pop(download_id, None)
else:
row = self.active_downloads.get(download_id)
if row is not None:
row['state'] = 'Cancelled'
return True
async def clear_all_completed_downloads(self) -> bool:
with self._lock:
for did in list(self.active_downloads.keys()):
state = self.active_downloads[did].get('state', '')
if state.startswith('Completed') or state == 'Cancelled':
self.active_downloads.pop(did, None)
return True
# ------------------------------------------------------------------
# Album-bundle flow
# ------------------------------------------------------------------
def download_album_to_staging(
self,
album_name: str,
artist_name: str,
staging_dir: str,
progress_callback=None,
) -> Dict[str, Any]:
"""One-shot album download: search Prowlarr for the whole
release, pick the best torrent, fetch it, extract if needed,
copy every audio file into ``staging_dir`` so the existing
``try_staging_match`` flow can hand each track off to the
post-processing pipeline.
``progress_callback`` is called with a dict on each state
change so the batch UI can show download progress without
waiting for the whole thing.
Returns ``{'success': bool, 'files': [paths], 'error': str|None}``.
"""
result: Dict[str, Any] = {'success': False, 'files': [], 'error': None}
if not self.is_configured():
result['error'] = 'Torrent source not configured'
return result
adapter = get_active_torrent_adapter()
if adapter is None or not adapter.is_configured():
result['error'] = 'No active torrent client'
return result
def _emit(state: str, **extra) -> None:
if progress_callback:
payload = {'state': state, **extra}
try:
progress_callback(payload)
except Exception as cb_exc:
logger.debug("[Torrent album] progress callback failed: %s", cb_exc)
# Phase 1: search Prowlarr for the album.
query = f"{artist_name} {album_name}".strip()
_emit('searching', query=query)
try:
search_results = run_async(self._prowlarr.search(
query, categories=DEFAULT_MUSIC_CATEGORIES,
indexer_ids=_parse_indexer_id_filter(),
))
except Exception as e:
result['error'] = f'Prowlarr search failed: {e}'
return result
candidates = [r for r in search_results
if r.protocol == 'torrent' and (r.magnet_uri or r.download_url)]
if not candidates:
result['error'] = f'No torrent results found for "{query}"'
return result
picked = pick_best_album_release(candidates, _guess_quality_from_title)
if picked is None:
result['error'] = 'No suitable torrent candidate after filtering'
return result
download_url = picked.magnet_uri or picked.download_url
logger.info("[Torrent album] Picked '%s' (size=%.1fMB seeders=%s indexer=%s)",
picked.title, picked.size / 1_048_576, picked.seeders, picked.indexer_name)
_emit('queued', release=picked.title, size=picked.size, seeders=picked.seeders)
# Phase 2: hand to adapter.
try:
torrent_id = run_async(adapter.add_torrent(download_url))
except Exception as e:
result['error'] = f'Torrent client refused the release: {e}'
return result
if not torrent_id:
result['error'] = 'Torrent client refused the release'
return result
# Phase 3: poll until complete.
_emit('downloading', release=picked.title)
save_path = self._poll_album_download(adapter, torrent_id, picked.title, _emit)
if save_path is None:
result['error'] = 'Torrent download failed or timed out'
return result
# Phase 4: extract + walk + copy to staging.
_emit('staging', release=picked.title)
try:
audio_files = collect_audio_after_extraction(Path(save_path))
except Exception as e:
result['error'] = f'Failed to walk audio files: {e}'
return result
if not audio_files:
result['error'] = f'No audio files found in {save_path}'
return result
copied = copy_audio_files_atomically(audio_files, Path(staging_dir))
if not copied:
result['error'] = 'No audio files copied to staging'
return result
logger.info("[Torrent album] Staged %d audio files for '%s'", len(copied), album_name)
_emit('staged', count=len(copied))
result['success'] = True
result['files'] = copied
return result
def _poll_album_download(self, adapter, torrent_id, title, emit) -> Optional[str]:
"""Poll the adapter until the torrent is complete. Returns
the save path or ``None`` on timeout / failure."""
deadline = time.monotonic() + _POLL_TIMEOUT_SECONDS
last_save_path: Optional[str] = None
while time.monotonic() < deadline:
if self.shutdown_check and self.shutdown_check():
return None
try:
status = run_async(adapter.get_status(torrent_id))
except Exception as e:
logger.warning("[Torrent album] Poll error: %s", e)
status = None
if status is None:
logger.error("[Torrent album] '%s' disappeared from client", title)
return None
emit('downloading', progress=status.progress, downloaded=status.downloaded,
speed=status.download_speed)
if status.save_path:
last_save_path = status.save_path
if status.state in _COMPLETE_STATES:
return last_save_path
if status.state == 'error':
logger.error("[Torrent album] '%s' errored: %s", title, status.error)
return None
time.sleep(_POLL_INTERVAL_SECONDS)
logger.error("[Torrent album] '%s' timed out", title)
return None
# ---------------------------------------------------------------------------
# Module-level helpers (pure functions — easy to unit-test)
# ---------------------------------------------------------------------------
def _decode_filename(filename: str) -> Tuple[Optional[str], str]:
"""Pull the encoded download URL out of the ``filename`` string.
Returns ``(url, display_name)``. ``url`` is None when the string
has no separator."""
if not filename or _FILENAME_SEP not in filename:
return (None, filename or '')
url, display = filename.split(_FILENAME_SEP, 1)
return (url, display)
def _parse_release_title(title: str) -> Tuple[str, str]:
"""Split a release title into ``(artist, title)`` using the
``Artist - Title`` / ``Artist - Album`` convention almost every
indexer follows. Returns ``('', title)`` when no dash is found.
Without this, ``TrackResult.__post_init__`` runs the bare
filename through ``parse_filename_metadata`` and our filename
starts with the indexer's download URL, so the auto-parser
extracts garbage like ``download?apikey=...`` as the artist
and shows it in the search-result UI's "by" line. Pre-filling
the artist field short-circuits the auto-parse.
"""
if not title:
return ('', '')
# Strip common quality / format tags so the dash split doesn't
# eat them — "Artist - Album [FLAC] (2020)" → "Artist", "Album".
cleaned = re.sub(r'\s*[\[\(][^\]\)]*[\]\)]\s*$', '', title.strip())
# Look for the FIRST " - " (or "-" surrounded by content). Some
# release titles have multiple dashes (subtitle dashes); the
# first split is the artist/work boundary.
parts = re.split(r'\s+-\s+|\s+-(?=\S)|(?<=\S)-\s+', cleaned, maxsplit=1)
if len(parts) == 2:
artist = parts[0].strip()
rest = parts[1].strip()
# Reject obvious non-artist prefixes (URLs, hashes, single
# punctuation) so we don't propagate garbage.
if artist and not re.match(r'^https?:|^[a-f0-9]{32,}$', artist):
return (artist, rest or cleaned)
return ('', cleaned)
def _guess_quality_from_title(title: str) -> str:
"""Read the quality hint from a release title — most music
torrents put the encoding right in the name (FLAC, MP3 320,
etc.). Falls back to ``'mp3'`` so quality_score doesn't crash."""
if not title:
return 'mp3'
lower = title.lower()
if 'flac' in lower:
return 'flac'
if re.search(r'\b24[\s-]?bit\b', lower) or 'hi-?res' in lower:
return 'flac'
if 'aac' in lower:
return 'aac'
if 'ogg' in lower:
return 'ogg'
return 'mp3'
def _parse_indexer_id_filter() -> List[int]:
"""Read the comma-separated indexer-ID allowlist from config.
Empty list = search every enabled indexer."""
raw = (config_manager.get('prowlarr.indexer_ids', '') or '').strip()
if not raw:
return []
out: List[int] = []
for chunk in raw.split(','):
chunk = chunk.strip()
if not chunk:
continue
try:
out.append(int(chunk))
except ValueError:
continue
return out
def _adapter_state_to_display(state: str) -> str:
"""Translate the adapter-uniform state strings into the
``'InProgress, Downloading'`` / ``'Completed, Succeeded'``
style the existing UI expects (matches Soulseek + Lidarr)."""
mapping = {
'queued': 'Queued',
'downloading': 'InProgress, Downloading',
'stalled': 'InProgress, Stalled',
'seeding': 'Completed, Succeeded',
'completed': 'Completed, Succeeded',
'paused': 'Paused',
'error': 'Completed, Errored',
}
return mapping.get(state, state.title())
def _row_to_status(row: Dict[str, Any]) -> DownloadStatus:
return DownloadStatus(
id=row['id'],
filename=row['filename'],
username=row['username'],
state=row.get('state', 'Unknown'),
progress=float(row.get('progress', 0.0)),
size=int(row.get('size', 0)),
transferred=int(row.get('transferred', 0)),
speed=int(row.get('speed', 0)),
time_remaining=None,
file_path=row.get('file_path'),
audio_files=row.get('audio_files') or None,
)

View file

@ -197,3 +197,4 @@ class DownloadStatus:
speed: int
time_remaining: Optional[int] = None
file_path: Optional[str] = None
audio_files: Optional[List[str]] = None

View file

@ -0,0 +1,461 @@
"""UsenetDownloadPlugin — composes Prowlarr search + usenet client
adapter + archive_pipeline into a uniform download source.
Mirrors ``TorrentDownloadPlugin`` in shape and lifecycle (see that
module's docstring for the full pipeline rationale). Differences:
- Search filters Prowlarr results to ``protocol='usenet'``.
- ``add_nzb`` replaces ``add_torrent``; for NZBs we usually have
a direct HTTP URL the indexer exposes via Prowlarr.
- Usenet clients (SABnzbd, NZBGet) typically auto-extract during
post-processing, so ``archive_pipeline.collect_audio_after_extraction``
usually has nothing to extract and just walks loose files.
"""
from __future__ import annotations
import threading
import time
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from core.archive_pipeline import collect_audio_after_extraction
from core.download_plugins.album_bundle import (
copy_audio_files_atomically,
pick_best_album_release,
)
from core.download_plugins.base import DownloadSourcePlugin
from core.download_plugins.torrent import (
_adapter_state_to_display,
_decode_filename,
_guess_quality_from_title,
_parse_indexer_id_filter,
_parse_release_title,
_row_to_status,
_COMPLETE_STATES,
_FILENAME_SEP,
_POLL_INTERVAL_SECONDS,
_POLL_TIMEOUT_SECONDS,
)
from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult
from core.prowlarr_client import (
DEFAULT_MUSIC_CATEGORIES,
ProwlarrClient,
ProwlarrSearchResult,
)
from core.usenet_clients import get_active_adapter as get_active_usenet_adapter
from utils.async_helpers import run_async
from utils.logging_config import get_logger
logger = get_logger("download_plugins.usenet")
class UsenetDownloadPlugin(DownloadSourcePlugin):
"""Usenet download source backed by Prowlarr + an active usenet
client adapter (SABnzbd or NZBGet)."""
def __init__(self) -> None:
self._prowlarr = ProwlarrClient()
self.active_downloads: Dict[str, Dict[str, Any]] = {}
self._lock = threading.Lock()
self.shutdown_check = None
def set_shutdown_check(self, check_callable):
self.shutdown_check = check_callable
def reload_settings(self) -> None:
self._prowlarr.reload_settings()
def is_configured(self) -> bool:
if not self._prowlarr.is_configured():
return False
adapter = get_active_usenet_adapter()
return bool(adapter and adapter.is_configured())
async def check_connection(self) -> bool:
if not self._prowlarr.is_configured():
return False
adapter = get_active_usenet_adapter()
if not adapter or not adapter.is_configured():
return False
if not await self._prowlarr.check_connection():
return False
return await adapter.check_connection()
# ------------------------------------------------------------------
# Search
# ------------------------------------------------------------------
async def search(
self,
query: str,
timeout: Optional[int] = None,
progress_callback=None,
) -> Tuple[List[TrackResult], List[AlbumResult]]:
if not self._prowlarr.is_configured():
return ([], [])
try:
indexer_ids = _parse_indexer_id_filter()
results = await self._prowlarr.search(
query,
categories=DEFAULT_MUSIC_CATEGORIES,
indexer_ids=indexer_ids,
)
except Exception as e:
logger.error("Usenet plugin search failed: %s", e)
return ([], [])
return self._project_results(results)
def _project_results(
self, results: List[ProwlarrSearchResult]
) -> Tuple[List[TrackResult], List[AlbumResult]]:
tracks: List[TrackResult] = []
albums: List[AlbumResult] = []
for result in results:
if result.protocol != 'usenet':
continue
if not result.download_url:
continue
filename = f"{result.download_url}{_FILENAME_SEP}{result.title}"
quality = _guess_quality_from_title(result.title)
parsed_artist, parsed_title = _parse_release_title(result.title)
tr = TrackResult(
username='usenet',
filename=filename,
size=result.size,
bitrate=None,
duration=None,
quality=quality,
# Usenet doesn't expose per-uploader concurrency the way
# Soulseek does; fill in neutral non-punishing values.
free_upload_slots=1,
upload_speed=0,
queue_length=0,
# Pre-fill artist + title so TrackResult.__post_init__
# doesn't auto-parse the filename — same URL-in-filename
# gotcha as the torrent plugin.
artist=parsed_artist or result.indexer_name or 'Usenet',
title=parsed_title or result.title,
album=parsed_title or None,
track_number=None,
_source_metadata={
'indexer': result.indexer_name,
'indexer_id': result.indexer_id,
'grabs': result.grabs,
'protocol': 'usenet',
},
)
tracks.append(tr)
albums.append(AlbumResult(
username='usenet',
album_path=f"usenet/{result.guid}",
album_title=parsed_title or result.title,
artist=parsed_artist or None,
track_count=1,
total_size=result.size,
tracks=[tr],
dominant_quality=quality,
year=None,
))
return tracks, albums
# ------------------------------------------------------------------
# Download
# ------------------------------------------------------------------
async def download(
self,
username: str,
filename: str,
file_size: int = 0,
) -> Optional[str]:
if not self.is_configured():
return None
nzb_url, display_name = _decode_filename(filename)
if not nzb_url:
logger.error("Usenet download missing URL in filename: %r", filename)
return None
download_id = str(uuid.uuid4())
with self._lock:
self.active_downloads[download_id] = {
'id': download_id,
'filename': filename,
'username': 'usenet',
'display_name': display_name,
'state': 'Initializing',
'progress': 0.0,
'size': file_size,
'transferred': 0,
'speed': 0,
'file_path': None,
'audio_files': [],
'job_id': None,
'error': None,
}
thread = threading.Thread(
target=self._download_thread,
args=(download_id, nzb_url),
daemon=True,
name=f'usenet-dl-{download_id[:8]}',
)
thread.start()
return download_id
def _download_thread(self, download_id: str, nzb_url: str) -> None:
adapter = get_active_usenet_adapter()
if adapter is None or not adapter.is_configured():
self._mark_error(download_id, "No usenet client configured")
return
try:
job_id = run_async(adapter.add_nzb(nzb_url))
except Exception as e:
self._mark_error(download_id, f"add_nzb failed: {e}")
return
if not job_id:
self._mark_error(download_id, "Usenet client refused the NZB")
return
with self._lock:
row = self.active_downloads.get(download_id)
if row is not None:
row['job_id'] = job_id
row['state'] = 'InProgress, Downloading'
deadline = time.monotonic() + _POLL_TIMEOUT_SECONDS
last_save_path: Optional[str] = None
while time.monotonic() < deadline:
if self.shutdown_check and self.shutdown_check():
return
try:
status = run_async(adapter.get_status(job_id))
except Exception as e:
logger.warning("Usenet poll error for %s: %s", job_id, e)
status = None
if status is None:
self._mark_error(download_id, "Usenet job disappeared from client")
return
with self._lock:
row = self.active_downloads.get(download_id)
if row is not None:
row['progress'] = status.progress * 100.0
row['transferred'] = status.downloaded
row['speed'] = status.download_speed
row['size'] = status.size or row.get('size', 0)
row['state'] = _adapter_state_to_display(status.state)
row['error'] = status.error
if status.save_path:
last_save_path = status.save_path
if status.state in _COMPLETE_STATES:
self._finalize_download(download_id, last_save_path)
return
if status.state == 'failed':
self._mark_error(download_id, status.error or "Usenet client reported failure")
return
time.sleep(_POLL_INTERVAL_SECONDS)
self._mark_error(download_id, "Usenet download timed out")
def _finalize_download(self, download_id: str, save_path: Optional[str]) -> None:
if not save_path:
self._mark_error(download_id, "Usenet job completed but no save_path reported")
return
try:
audio_files = collect_audio_after_extraction(Path(save_path))
except Exception as e:
self._mark_error(download_id, f"Post-extract walk failed: {e}")
return
if not audio_files:
self._mark_error(download_id, f"No audio files found in {save_path}")
return
primary = audio_files[0]
with self._lock:
row = self.active_downloads.get(download_id)
if row is not None:
row['state'] = 'Completed, Succeeded'
row['progress'] = 100.0
row['file_path'] = str(primary)
row['audio_files'] = [str(path) for path in audio_files]
logger.info("Usenet download complete: %s -> %s (%d audio files)",
download_id[:8], primary.name, len(audio_files))
def _mark_error(self, download_id: str, message: str) -> None:
logger.error("Usenet download %s failed: %s", download_id[:8], message)
with self._lock:
row = self.active_downloads.get(download_id)
if row is not None:
row['state'] = 'Completed, Errored'
row['error'] = message
# ------------------------------------------------------------------
# Status / lifecycle
# ------------------------------------------------------------------
async def get_all_downloads(self) -> List[DownloadStatus]:
with self._lock:
rows = list(self.active_downloads.values())
return [_row_to_status(r) for r in rows]
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
with self._lock:
row = self.active_downloads.get(download_id)
if row is None:
return None
return _row_to_status(row)
async def cancel_download(
self,
download_id: str,
username: Optional[str] = None,
remove: bool = False,
) -> bool:
adapter = get_active_usenet_adapter()
with self._lock:
row = self.active_downloads.get(download_id)
job_id = row.get('job_id') if row else None
if adapter and job_id:
try:
await adapter.remove(job_id, delete_files=remove)
except Exception as e:
logger.warning("Usenet cancel via adapter failed: %s", e)
with self._lock:
if remove:
self.active_downloads.pop(download_id, None)
else:
row = self.active_downloads.get(download_id)
if row is not None:
row['state'] = 'Cancelled'
return True
async def clear_all_completed_downloads(self) -> bool:
with self._lock:
for did in list(self.active_downloads.keys()):
state = self.active_downloads[did].get('state', '')
if state.startswith('Completed') or state == 'Cancelled':
self.active_downloads.pop(did, None)
return True
# ------------------------------------------------------------------
# Album-bundle flow
# ------------------------------------------------------------------
def download_album_to_staging(
self,
album_name: str,
artist_name: str,
staging_dir: str,
progress_callback=None,
) -> Dict[str, Any]:
"""Usenet sibling of ``TorrentDownloadPlugin.download_album_to_staging``.
See that method's docstring for the contract."""
result: Dict[str, Any] = {'success': False, 'files': [], 'error': None}
if not self.is_configured():
result['error'] = 'Usenet source not configured'
return result
adapter = get_active_usenet_adapter()
if adapter is None or not adapter.is_configured():
result['error'] = 'No active usenet client'
return result
def _emit(state: str, **extra) -> None:
if progress_callback:
try:
progress_callback({'state': state, **extra})
except Exception as cb_exc:
logger.debug("[Usenet album] progress callback failed: %s", cb_exc)
query = f"{artist_name} {album_name}".strip()
_emit('searching', query=query)
try:
search_results = run_async(self._prowlarr.search(
query, categories=DEFAULT_MUSIC_CATEGORIES,
indexer_ids=_parse_indexer_id_filter(),
))
except Exception as e:
result['error'] = f'Prowlarr search failed: {e}'
return result
candidates = [r for r in search_results
if r.protocol == 'usenet' and r.download_url]
if not candidates:
result['error'] = f'No usenet results found for "{query}"'
return result
picked = pick_best_album_release(candidates, _guess_quality_from_title)
if picked is None:
result['error'] = 'No suitable NZB candidate after filtering'
return result
logger.info("[Usenet album] Picked '%s' (size=%.1fMB grabs=%s indexer=%s)",
picked.title, picked.size / 1_048_576, picked.grabs, picked.indexer_name)
_emit('queued', release=picked.title, size=picked.size, grabs=picked.grabs)
try:
job_id = run_async(adapter.add_nzb(picked.download_url))
except Exception as e:
result['error'] = f'Usenet client refused the NZB: {e}'
return result
if not job_id:
result['error'] = 'Usenet client refused the NZB'
return result
_emit('downloading', release=picked.title)
save_path = self._poll_album_download(adapter, job_id, picked.title, _emit)
if save_path is None:
result['error'] = 'Usenet download failed or timed out'
return result
_emit('staging', release=picked.title)
try:
audio_files = collect_audio_after_extraction(Path(save_path))
except Exception as e:
result['error'] = f'Failed to walk audio files: {e}'
return result
if not audio_files:
result['error'] = f'No audio files found in {save_path}'
return result
copied = copy_audio_files_atomically(audio_files, Path(staging_dir))
if not copied:
result['error'] = 'No audio files copied to staging'
return result
logger.info("[Usenet album] Staged %d audio files for '%s'", len(copied), album_name)
_emit('staged', count=len(copied))
result['success'] = True
result['files'] = copied
return result
def _poll_album_download(self, adapter, job_id, title, emit) -> Optional[str]:
deadline = time.monotonic() + _POLL_TIMEOUT_SECONDS
last_save_path: Optional[str] = None
while time.monotonic() < deadline:
if self.shutdown_check and self.shutdown_check():
return None
try:
status = run_async(adapter.get_status(job_id))
except Exception as e:
logger.warning("[Usenet album] Poll error: %s", e)
status = None
if status is None:
logger.error("[Usenet album] '%s' disappeared from client", title)
return None
emit('downloading', progress=status.progress, downloaded=status.downloaded,
speed=status.download_speed)
if status.save_path:
last_save_path = status.save_path
if status.state in _COMPLETE_STATES:
return last_save_path
if status.state == 'failed':
logger.error("[Usenet album] '%s' failed: %s", title, status.error)
return None
time.sleep(_POLL_INTERVAL_SECONDS)
logger.error("[Usenet album] '%s' timed out", title)
return None

View file

@ -0,0 +1,209 @@
"""Album-bundle dispatch for torrent / usenet single-source downloads.
Lifted from ``run_full_missing_tracks_process`` so the master
worker doesn't carry a 90-line inline branch and so the gate logic
can be unit-tested in isolation.
The gate fires only when ALL conditions hold:
- Batch is an album-context download (``is_album_download`` flag).
- Active download source is ``torrent``, ``usenet``, or ``soulseek``.
In hybrid mode the caller may pass the first configured source as a
source override; later hybrid sources stay per-track to preserve fallback.
- Both album-name and artist-name are populated in batch context.
- The resolved plugin exposes ``download_album_to_staging``.
When the gate engages it runs the plugin synchronously (the master
worker is already on a thread-pool executor) and mirrors the
plugin's lifecycle payloads into the batch state so the Downloads
page can render meaningful progress before per-track tasks exist.
Return semantics: ``True`` means the gate handled the batch the
master worker should stop and not run per-track analysis. ``False``
means the gate didn't engage (or engaged-and-fell-back) — caller
continues the normal per-track flow.
The ``BatchStateAccess`` Protocol exists so this module doesn't
import ``download_batches`` from runtime_state directly. The
caller (master worker) injects accessors so this module stays
testable without touching live runtime state.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, Callable, Optional, Protocol
logger = logging.getLogger(__name__)
class BatchStateAccess(Protocol):
"""Narrow shim around the batch-state dict ops the dispatch needs.
Two methods to keep the surface small:
- ``update_fields(batch_id, fields)`` atomic merge into the
batch dict under tasks_lock.
- ``mark_failed(batch_id, error)`` convenience for the failure
path (sets phase + error + album_bundle_state in one shot).
"""
def update_fields(self, batch_id: str, fields: dict) -> None: ...
def mark_failed(self, batch_id: str, error: str) -> None: ...
# Fields the album-bundle progress callback may carry. Anything in
# this set gets mirrored onto the batch row as ``album_bundle_<key>``
# so the Downloads page can render it without coupling to the
# specific payload shape.
_MIRRORED_KEYS = ('progress', 'release', 'speed', 'downloaded',
'size', 'seeders', 'grabs', 'count', 'failed')
def is_eligible(
*,
mode: str,
is_album: bool,
album_name: str,
artist_name: str,
) -> bool:
"""Pure predicate: does this batch even qualify for the album
flow? Separate from the resolution+run step so tests can pin
the gate logic without standing up a plugin."""
if not is_album:
return False
if (mode or '').lower() not in ('torrent', 'usenet', 'soulseek'):
return False
if not (album_name or '').strip():
return False
if not (artist_name or '').strip():
return False
return True
def try_dispatch(
*,
batch_id: str,
is_album: bool,
album_context: Optional[dict],
artist_context: Optional[dict],
config_get: Callable[..., Any],
plugin_resolver: Callable[[str], Optional[Any]],
state: BatchStateAccess,
source_override: Optional[str] = None,
plugin_kwargs: Optional[dict] = None,
) -> bool:
"""Attempt the album-bundle flow. Returns ``True`` iff the
master worker should return early (gate engaged and completed
success OR failure). ``False`` means fall through to the
normal per-track flow.
``config_get`` is a callable shaped like ``config_manager.get``;
``plugin_resolver`` resolves a source-name string to an
initialised plugin instance (or None); ``state`` is the
BatchStateAccess shim. Injecting these keeps the module
dependency-light + unit-testable.
"""
mode = (source_override or config_get('download_source.mode', 'soulseek') or 'soulseek').lower()
album_name = (album_context or {}).get('name') or ''
artist_name = (artist_context or {}).get('name') or ''
if not is_eligible(mode=mode, is_album=is_album,
album_name=album_name, artist_name=artist_name):
return False
album_name = album_name.strip()
artist_name = artist_name.strip()
plugin = None
try:
plugin = plugin_resolver(mode)
except Exception as exc:
logger.warning("[Album Bundle] Could not resolve %s plugin: %s", mode, exc)
if plugin is None or not hasattr(plugin, 'download_album_to_staging'):
logger.warning(
"[Album Bundle] Gate matched but plugin / context unavailable "
"(mode=%s album=%r artist=%r plugin=%s) — falling back to per-track flow",
mode, album_name, artist_name,
type(plugin).__name__ if plugin else None,
)
return False
staging_root = config_get(
'download_source.album_bundle_staging_path',
'storage/album_bundle_staging',
) or 'storage/album_bundle_staging'
staging_dir = str(Path(staging_root) / _safe_batch_dirname(batch_id))
logger.info(
"[Album Bundle] Engaging %s album flow for '%s' by '%s' -> %s",
mode, album_name, artist_name, staging_dir,
)
state.update_fields(batch_id, {
'phase': 'album_downloading',
'album_bundle_state': 'searching',
'album_bundle_source': mode,
'album_bundle_staging_path': staging_dir,
'album_bundle_private_staging': True,
})
def _emit(payload):
"""Mirror plugin lifecycle into batch state for UI rendering."""
try:
fields = {'album_bundle_state': payload.get('state', '')}
for key in _MIRRORED_KEYS:
if key in payload:
fields[f'album_bundle_{key}'] = payload[key]
state.update_fields(batch_id, fields)
except Exception as exc:
logger.debug("[Album Bundle] emit failed: %s", exc)
try:
outcome = plugin.download_album_to_staging(
album_name, artist_name, staging_dir, _emit,
**(plugin_kwargs or {}),
)
except Exception as exc:
logger.exception("[Album Bundle] %s plugin raised: %s", mode, exc)
outcome = {'success': False, 'error': f'Plugin error: {exc}'}
if not outcome.get('success'):
err = outcome.get('error', 'Album bundle download failed')
if outcome.get('fallback'):
logger.warning(
"[Album Bundle] %s flow could not commit for '%s': %s — falling back to per-track flow",
mode, album_name, err,
)
state.update_fields(batch_id, {
'phase': 'analysis',
'album_bundle_state': 'fallback',
'album_bundle_error': err,
})
return False
logger.error("[Album Bundle] %s flow failed for '%s': %s",
mode, album_name, err)
state.mark_failed(batch_id, err)
return True
logger.info(
"[Album Bundle] %s staged %d files for '%s' — handing off to per-track staging matcher",
mode, len(outcome.get('files', [])), album_name,
)
state.update_fields(batch_id, {
'phase': 'analysis',
'album_bundle_state': 'staged',
'album_bundle_partial': bool(outcome.get('partial')),
'album_bundle_expected_count': outcome.get('expected_count'),
'album_bundle_completed_count': outcome.get('completed_count', len(outcome.get('files', []))),
})
# Engaged-and-succeeded: we DON'T early-return because the
# per-track flow needs to run to create + complete the per-track
# task rows. Those tasks will hit try_staging_match and pull the
# files we just staged.
return False
def _safe_batch_dirname(batch_id: str) -> str:
safe = ''.join(ch if ch.isalnum() or ch in ('-', '_') else '_' for ch in str(batch_id or 'batch'))
return safe or 'batch'

View file

@ -45,6 +45,9 @@ _TERMINAL_STATUSES = {
def cancel_single_download(download_orchestrator, run_async: Callable,
download_id: str, username: str) -> bool:
"""Cancel one specific slskd download (with `remove=True`)."""
logger.info(
f"[CancelTrigger:api.manual_cancel_single] download_id={download_id} username={username}"
)
return run_async(download_orchestrator.cancel_download(download_id, username, remove=True))

View file

@ -63,8 +63,21 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=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)
# Sort candidates by match confidence first, then peer quality. Upstream
# Soulseek validation already considers peer speed/slots/queue when scores
# are close; preserve that signal here instead of flattening ties back to
# arbitrary slskd response order.
candidates.sort(
key=lambda r: (
getattr(r, 'confidence', 0) or 0,
getattr(r, 'quality_score', 0) or 0,
getattr(r, 'upload_speed', 0) or 0,
-(getattr(r, 'queue_length', 0) or 0),
getattr(r, 'free_upload_slots', 0) or 0,
getattr(r, 'size', 0) or 0,
),
reverse=True,
)
with tasks_lock:
task = download_tasks.get(task_id)
@ -235,7 +248,7 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
# 1. Try track_info (from frontend, has album track data)
tn = track_info.get('track_number', 0) if isinstance(track_info, dict) else 0
dn = track_info.get('disc_number', 1) if isinstance(track_info, dict) else 1
dn = (track_info.get('disc_number') or 1) if isinstance(track_info, dict) else 1
if tn and tn > 0:
enhanced_payload['track_number'] = tn
enhanced_payload['disc_number'] = dn
@ -255,7 +268,7 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
detailed_track = deps.spotify_client.get_track_details(track.id)
if detailed_track and detailed_track.get('track_number'):
enhanced_payload['track_number'] = detailed_track['track_number']
enhanced_payload['disc_number'] = detailed_track.get('disc_number', 1)
enhanced_payload['disc_number'] = detailed_track.get('disc_number') or 1
got_track_number = True
logger.info(f"[Context] Added track_number from API: {detailed_track['track_number']}, disc_number: {enhanced_payload['disc_number']}")
@ -330,6 +343,10 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
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:
logger.info(
f"[CancelTrigger:candidates.worker_cancelled_during_download] "
f"download_id={download_id} username={username} task_id={task_id}"
)
deps.run_async(deps.download_orchestrator.cancel_download(download_id, username, remove=True))
logger.warning(f"Successfully cancelled active download {download_id}")
except Exception as cancel_error:

View file

@ -26,9 +26,11 @@ Lifted verbatim from web_server.py. Dependencies injected via
from __future__ import annotations
import logging
import shutil
import time
import traceback
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Optional
from core.downloads.history import record_sync_history_completion
@ -42,6 +44,47 @@ from core.runtime_state import (
logger = logging.getLogger(__name__)
def _safe_batch_dirname(batch_id: str) -> str:
safe = ''.join(ch if ch.isalnum() or ch in ('-', '_') else '_' for ch in str(batch_id or 'batch'))
return safe or 'batch'
def _cleanup_private_album_bundle_staging(batch_id: str, batch: dict) -> None:
"""Best-effort cleanup for torrent/usenet private staging copies.
The torrent/usenet clients keep their own completed download folders.
This only removes SoulSync's per-batch copy under album-bundle staging.
"""
if not batch.get('album_bundle_private_staging'):
return
if (batch.get('album_bundle_source') or '').lower() not in ('torrent', 'usenet'):
return
staging_path = batch.get('album_bundle_staging_path')
if not staging_path:
return
path = Path(staging_path)
expected_name = _safe_batch_dirname(batch_id)
if path.name != expected_name:
logger.warning(
"[Album Bundle] Refusing to clean private staging path with unexpected name: %s",
staging_path,
)
return
if not path.exists():
return
if not path.is_dir():
logger.warning("[Album Bundle] Refusing to clean non-directory staging path: %s", staging_path)
return
try:
shutil.rmtree(path)
logger.info("[Album Bundle] Cleaned private staging folder for batch %s: %s", batch_id, staging_path)
except Exception as exc:
logger.warning("[Album Bundle] Could not clean private staging folder %s: %s", staging_path, exc)
@dataclass
class LifecycleDeps:
"""Bundle of cross-cutting deps the batch lifecycle needs."""
@ -173,7 +216,7 @@ def on_download_completed(batch_id: str, task_id: str, success: bool, deps: Life
# 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)
# (e.g. status polling and post-processing both observe the same terminal task)
# 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'.
@ -398,6 +441,7 @@ def on_download_completed(batch_id: str, task_id: str, success: bool, deps: Life
logger.info(f"[Batch Manager] Batch {batch_id} complete - stopping monitor")
deps.download_monitor.stop_monitoring(batch_id)
_cleanup_private_album_bundle_staging(batch_id, batch)
# M3U REGENERATION: Regenerate M3U with real library paths now that
# all post-processing (tagging, moving, DB writes) is complete.
@ -606,6 +650,7 @@ def check_batch_completion_v2(batch_id: str, deps: LifecycleDeps) -> Optional[bo
logger.info(f"[Completion Check V2] Batch {batch_id} complete - stopping monitor")
deps.download_monitor.stop_monitoring(batch_id)
_cleanup_private_album_bundle_staging(batch_id, batch)
# REPAIR: Scan all album folders from this batch for track number issues
if deps.repair_worker:

View file

@ -31,13 +31,244 @@ import re
import time
import uuid
from dataclasses import dataclass
from difflib import SequenceMatcher
from pathlib import Path
from typing import Any, Callable
from core.downloads import album_bundle_dispatch as _album_bundle_dispatch
from core.runtime_state import download_batches, download_tasks, tasks_lock
logger = logging.getLogger(__name__)
_ALBUM_PREFLIGHT_MIN_SCORE = 0.62
_EDITION_WORDS = {
'deluxe', 'expanded', 'anniversary', 'special', 'platinum', 'bonus',
'remaster', 'remastered', 'edition', 'version',
}
_VARIANT_WORDS = {
'remix', 'rmx', 'acapella', 'a cappella', 'instrumental', 'karaoke',
'live', 'demo', 'extended',
}
_ALBUM_BUNDLE_SOURCES = frozenset(('torrent', 'usenet', 'soulseek'))
def _norm_text(value: Any) -> str:
text = str(value or '').lower()
text = re.sub(r'[_./\\|()[\]{}:;,+]', ' ', text)
text = re.sub(r'[^a-z0-9\s-]', '', text)
text = re.sub(r'\s+', ' ', text).strip()
return text
def _similarity(left: Any, right: Any) -> float:
a = _norm_text(left)
b = _norm_text(right)
if not a or not b:
return 0.0
if a == b:
return 1.0
if a in b or b in a:
return min(len(a), len(b)) / max(len(a), len(b))
return SequenceMatcher(None, a, b).ratio()
def _track_title_from_candidate(candidate: Any) -> str:
title = getattr(candidate, 'title', None)
if title:
return str(title)
filename = getattr(candidate, 'filename', '') or ''
stem = Path(filename.replace('\\', '/')).stem
stem = re.sub(r'^\s*(?:disc\s*)?\d+[-_.\s]+', '', stem, flags=re.IGNORECASE)
return stem
def _track_number_from_track(track_data: dict) -> int:
value = track_data.get('track_number') or track_data.get('trackNumber') or 0
try:
return int(str(value).split('/')[0])
except (TypeError, ValueError):
return 0
def _track_number_from_candidate(candidate: Any) -> int:
value = getattr(candidate, 'track_number', None) or 0
try:
return int(str(value).split('/')[0])
except (TypeError, ValueError):
return 0
def _folder_variant_penalty(expected_album_name: str, folder_text: str) -> float:
expected = _norm_text(expected_album_name)
folder = _norm_text(folder_text)
if not folder:
return 0.0
penalty = 0.0
for word in _VARIANT_WORDS:
if word in folder and word not in expected:
penalty += 0.12
for word in _EDITION_WORDS:
if word in folder and word not in expected:
penalty += 0.06
return min(penalty, 0.30)
def _source_quality_score(source: Any) -> float:
score = getattr(source, 'quality_score', None)
if callable(score):
try:
return float(score())
except Exception:
return 0.0
try:
return float(score or 0.0)
except (TypeError, ValueError):
return 0.0
def _album_context_richness(album_ctx: dict) -> int:
if not isinstance(album_ctx, dict):
return 0
fields = ('id', 'name', 'release_date', 'total_tracks', 'album_type')
score = sum(1 for field in fields if album_ctx.get(field))
images = album_ctx.get('images')
if images:
score += 1
artists = album_ctx.get('artists')
if artists:
score += 1
return score
def _score_album_folder(album_result: Any, album_context: dict, artist_context: dict,
tracks_json: list[dict], filtered_track_count: int) -> float:
"""Score one slskd folder as a whole release, not as isolated tracks."""
expected_album = str((album_context or {}).get('name') or '')
expected_artist = str((artist_context or {}).get('name') or '')
expected_count = int((album_context or {}).get('total_tracks') or len(tracks_json) or 0)
expected_year = str((album_context or {}).get('release_date') or '')[:4]
folder_text = ' '.join(
str(getattr(album_result, attr, '') or '')
for attr in ('album_title', 'album_path')
)
album_score = max(
_similarity(expected_album, getattr(album_result, 'album_title', '')),
_similarity(expected_album, getattr(album_result, 'album_path', '')),
)
artist_score = max(
_similarity(expected_artist, getattr(album_result, 'artist', '')),
_similarity(expected_artist, getattr(album_result, 'album_path', '')),
)
actual_count = int(getattr(album_result, 'track_count', 0) or len(getattr(album_result, 'tracks', []) or []))
if expected_count > 0 and actual_count > 0:
diff = abs(actual_count - expected_count)
if diff == 0:
count_score = 1.0
elif diff <= 2:
count_score = 0.75
elif diff <= 5:
count_score = 0.35
else:
count_score = 0.0
else:
count_score = 0.4
candidate_tracks = list(getattr(album_result, 'tracks', []) or [])
matched = 0
expected_tracks = [
(track_data, _norm_text(track_data.get('name', '')))
for track_data in tracks_json
if track_data.get('name')
]
for track_data, expected_title in expected_tracks:
expected_number = _track_number_from_track(track_data)
best = 0.0
for candidate in candidate_tracks:
cand_title = _norm_text(_track_title_from_candidate(candidate))
title_sim = _similarity(expected_title, cand_title)
cand_number = _track_number_from_candidate(candidate)
if expected_number and cand_number and expected_number == cand_number:
title_sim = min(1.0, title_sim + 0.12)
best = max(best, title_sim)
if best >= 0.72:
matched += 1
coverage_score = matched / max(1, len(expected_tracks))
year_score = 0.5
folder_year = str(getattr(album_result, 'year', '') or '')
if expected_year and folder_year:
year_score = 1.0 if expected_year == folder_year else 0.2
elif expected_year and expected_year in _norm_text(folder_text):
year_score = 1.0
quality_count_score = min(1.0, filtered_track_count / max(1, expected_count or actual_count or 1))
peer_score = _source_quality_score(album_result)
penalty = _folder_variant_penalty(expected_album, folder_text)
score = (
album_score * 0.24
+ artist_score * 0.16
+ count_score * 0.16
+ coverage_score * 0.28
+ year_score * 0.06
+ quality_count_score * 0.06
+ peer_score * 0.04
- penalty
)
return max(0.0, min(score, 1.0))
def _resolve_soulseek_client(download_orchestrator: Any) -> Any:
if hasattr(download_orchestrator, 'client'):
try:
client = download_orchestrator.client('soulseek')
if client:
return client
except Exception as exc:
logger.debug("Soulseek client lookup through orchestrator failed: %s", exc)
return getattr(download_orchestrator, 'soulseek', download_orchestrator)
def _soulseek_album_preflight_enabled(config_manager: Any) -> bool:
mode = config_manager.get('download_source.mode', 'hybrid')
if mode == 'soulseek':
return True
if mode != 'hybrid':
return False
order = config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek'])
if order:
return order[0] == 'soulseek'
primary = config_manager.get('download_source.hybrid_primary', '')
return primary == 'soulseek'
def _resolve_album_bundle_source(config_manager: Any) -> str:
"""Return the album-bundle source for this batch.
In single-source mode, the active source may own the whole album if
it supports album bundles. In hybrid mode, only the first source in
the configured order may claim the whole album; later sources remain
per-track fallback.
"""
mode = (config_manager.get('download_source.mode', 'soulseek') or 'soulseek').lower()
if mode in _ALBUM_BUNDLE_SOURCES:
return mode
if mode != 'hybrid':
return ''
order = config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek'])
first = ''
if order:
first = str(order[0] or '').lower()
else:
first = str(config_manager.get('download_source.hybrid_primary', '') or '').lower()
return first if first in _ALBUM_BUNDLE_SOURCES else ''
@dataclass
class MasterDeps:
"""Bundle of cross-cutting deps the master worker needs."""
@ -64,6 +295,26 @@ class MasterDeps:
reset_wishlist_auto_processing: Callable[[], None]
class _BatchStateAccessImpl:
"""Concrete ``BatchStateAccess`` for the runtime ``download_batches``
dict wraps the lock + the existing-batch check so the album-
bundle dispatcher stays decoupled from runtime_state."""
def update_fields(self, batch_id: str, fields: dict) -> None:
with tasks_lock:
row = download_batches.get(batch_id)
if row is not None:
row.update(fields)
def mark_failed(self, batch_id: str, error: str) -> None:
with tasks_lock:
row = download_batches.get(batch_id)
if row is not None:
row['phase'] = 'failed'
row['error'] = error
row['album_bundle_state'] = 'failed'
def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: MasterDeps):
"""
A master worker that handles the entire missing tracks process:
@ -79,25 +330,52 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
download_batches[batch_id]['analysis_processed'] = 0
from database.music_database import MusicDatabase
from core.library import manual_library_match as _mlm
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
ignore_manual_matches = False
batch_album_context = None
batch_artist_context = None
batch_is_album = False
batch_profile_id = 1
batch_source = 'spotify'
with tasks_lock:
if batch_id in download_batches:
force_download_all = download_batches[batch_id].get('force_download_all', False)
ignore_manual_matches = download_batches[batch_id].get('ignore_manual_matches', 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')
batch_profile_id = download_batches[batch_id].get('profile_id', 1) or 1
batch_source = download_batches[batch_id].get('batch_source', 'spotify') or 'spotify'
if force_download_all:
logger.warning(f"[Force Download] Force download mode enabled for batch {batch_id} - treating all tracks as missing")
# Album-bundle gate for torrent / usenet single-source mode.
# See ``core/downloads/album_bundle_dispatch`` for the full
# narrow-gate rationale. Returns True iff the master worker
# should stop (gate fired and failed); False = engaged-and-
# succeeded OR didn't engage, both fall through to per-track.
_bundle_state = _BatchStateAccessImpl()
_album_bundle_source = _resolve_album_bundle_source(deps.config_manager)
if _album_bundle_source and _album_bundle_source != 'soulseek':
if _album_bundle_dispatch.try_dispatch(
batch_id=batch_id,
is_album=batch_is_album,
album_context=batch_album_context,
artist_context=batch_artist_context,
config_get=deps.config_manager.get,
plugin_resolver=deps.download_orchestrator.client,
state=_bundle_state,
source_override=_album_bundle_source,
):
return
# 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)
@ -167,6 +445,26 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
artists = track_data.get('artists', [])
found, confidence = False, 0.0
# Manual library matches are authoritative unless the user explicitly
# requested a force re-download from the normal download modal.
_stid = track_data.get('spotify_track_id') or track_data.get('source_track_id') or track_data.get('id', '')
if not ignore_manual_matches and _stid and _mlm.get_match_for_track(
db, batch_profile_id, track_data, default_source=batch_source
):
logger.info(f"[Manual Match] '{track_name}' already matched in library — skipping download")
try:
deps.check_and_remove_track_from_wishlist_by_metadata(track_data)
except Exception as _wl_err:
logger.debug(f"[Manual Match] Wishlist removal attempt failed: {_wl_err}")
analysis_results.append({
'track_index': track_index,
'track': track_data,
'found': True,
'confidence': 1.0,
'match_reason': 'manual_library_match',
})
continue
# 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")
@ -174,14 +472,33 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
elif album_tracks_map:
# Album-scoped matching: check against known album tracks first
track_name_lower = track_name.lower().strip()
# Direct title match
# Issue #589 — strip suffixes that just repeat the album
# context (e.g. "Shy Away (MTV Unplugged Live)" on a
# "MTV Unplugged" album → "Shy Away") so album-owned
# tracks don't false-miss when the local DB stored the
# base title. Only fires inside the album-confirmed
# scope; global matching elsewhere is unchanged.
from core.matching.album_context_title import strip_redundant_album_suffix
_album_name_for_strip = (batch_album_context or {}).get('name', '')
_normalized_source_title = strip_redundant_album_suffix(
track_name, _album_name_for_strip
).lower().strip()
# Direct title match (try both raw and normalized)
if track_name_lower in album_tracks_map:
found, confidence = True, 1.0
elif _normalized_source_title and _normalized_source_title in album_tracks_map:
found, confidence = True, 1.0
else:
# Fuzzy match against album tracks using string similarity
# Fuzzy match against album tracks using string similarity.
# Compare BOTH the raw and normalized source titles —
# whichever scores higher wins. Preserves strict
# matching when the album doesn't imply version
# context (helper returns the input unchanged).
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)
sim_raw = db._string_similarity(track_name_lower, db_title_lower)
sim_norm = db._string_similarity(_normalized_source_title, db_title_lower) if _normalized_source_title else 0.0
sim = max(sim_raw, sim_norm)
if sim > best_sim:
best_sim = sim
if best_sim >= 0.7:
@ -350,6 +667,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
batch_album_context = batch.get('album_context')
batch_artist_context = batch.get('artist_context')
batch_is_album = batch.get('is_album_download', False)
batch_private_album_bundle = bool(batch.get('album_bundle_private_staging'))
batch_playlist_folder_mode = batch.get('playlist_folder_mode', False)
batch_playlist_name = batch.get('playlist_name', 'Unknown Playlist')
@ -357,13 +675,9 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
# 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:
soulseek_is_source = _soulseek_album_preflight_enabled(deps.config_manager)
if (batch_is_album and batch_album_context and batch_artist_context
and soulseek_is_source and not batch_private_album_bundle):
artist_name = batch_artist_context.get('name', '')
album_name = batch_album_context.get('name', '')
if artist_name and album_name:
@ -372,7 +686,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
_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.download_orchestrator.client('soulseek') if hasattr(deps.download_orchestrator, 'client') else deps.download_orchestrator
slsk = _resolve_soulseek_client(deps.download_orchestrator)
# Try multiple query variations (banned keywords in artist/album name can return 0 results)
album_queries = [f"{artist_name} {album_name}"]
@ -386,29 +700,57 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
album_results = []
track_results = []
album_results_by_source = {}
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}'")
for ar in album_results:
key = (getattr(ar, 'username', ''), getattr(ar, 'album_path', ''))
if key[0] and key[1] and key not in album_results_by_source:
album_results_by_source[key] = ar
else:
_sr.info(f"[Album Pre-flight] No album results for query: '{aq}'")
album_results = list(album_results_by_source.values())
if album_results:
# Filter by quality preference
quality_filtered = []
# Score complete folders as releases before falling back to per-track search.
scored_albums = []
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)))
folder_score = _score_album_folder(
ar,
batch_album_context,
batch_artist_context,
tracks_json,
len(filtered_tracks),
)
scored_albums.append((ar, len(filtered_tracks), folder_score))
_sr.info(
f"[Album Pre-flight] Candidate {ar.username}:{ar.album_path} "
f"score={folder_score:.3f}, tracks={ar.track_count}, "
f"quality_tracks={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]
best_album = None
best_score = 0.0
if scored_albums:
scored_albums.sort(key=lambda x: (x[2], x[1], x[0].quality_score), reverse=True)
best_album, _best_filtered_count, best_score = scored_albums[0]
if best_score < _ALBUM_PREFLIGHT_MIN_SCORE:
_sr.info(
f"[Album Pre-flight] Best folder score {best_score:.3f} below "
f"threshold {_ALBUM_PREFLIGHT_MIN_SCORE:.2f}; falling back"
)
logger.warning("[Album Pre-flight] No Soulseek folder passed album-level validation")
best_album = None
if best_album:
_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})")
f"({best_album.track_count} tracks, quality={best_album.dominant_quality}, score={best_score:.3f})")
logger.info(f"[Album Pre-flight] Found album folder: {best_album.username}"
f"{best_album.track_count} tracks ({best_album.dominant_quality})")
@ -437,7 +779,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
}
preflight_tracks = best_album.tracks
logger.info(f"[Album Pre-flight] Using {len(best_album.tracks)} tracks from search results (browse unavailable)")
else:
elif not scored_albums:
_sr.info("[Album Pre-flight] No album results passed quality filter")
logger.warning("[Album Pre-flight] No album results matched quality preferences")
else:
@ -448,13 +790,44 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
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}")
# Soulseek album bundles run after analysis so an already-owned
# album does not get downloaded just because the source supports a
# whole-folder flow. When preflight selected a folder, pass that
# exact source into the bundle downloader so we keep the richer
# tracklist-aware scoring instead of doing a weaker second pick.
_bundle_state = _BatchStateAccessImpl()
_album_bundle_source = _resolve_album_bundle_source(deps.config_manager)
if _album_bundle_source == 'soulseek':
if _album_bundle_dispatch.try_dispatch(
batch_id=batch_id,
is_album=batch_is_album,
album_context=batch_album_context,
artist_context=batch_artist_context,
config_get=deps.config_manager.get,
plugin_resolver=deps.download_orchestrator.client,
state=_bundle_state,
source_override=_album_bundle_source,
plugin_kwargs={
'preferred_source': preflight_source,
'preferred_tracks': preflight_tracks,
} if preflight_source and preflight_tracks else None,
):
return
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:
# unless the Soulseek album-bundle path already staged a private
# release. Task workers check source reuse before staging match, so
# preloading here would make the staged happy path re-download.
if (
preflight_source
and preflight_tracks
and not download_batches[batch_id].get('album_bundle_private_staging')
):
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()
@ -464,7 +837,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
# Use ALL tracks (tracks_json), not just missing ones, to correctly detect multi-disc
# even when only one disc has missing tracks
if batch_is_album and batch_album_context:
total_discs = max((t.get('disc_number', 1) for t in tracks_json), default=1)
total_discs = max((t.get('disc_number') or 1 for t in tracks_json), default=1)
batch_album_context['total_discs'] = total_discs
if total_discs > 1:
logger.info(f"[Multi-Disc] Detected {total_discs} discs for album '{batch_album_context.get('name')}'")
@ -473,6 +846,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
# 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)
wishlist_album_context_map = {} # album_id -> richest shared album context
if playlist_id == 'wishlist':
import json as _json
# First pass: collect disc_number and resolve ONE artist per album
@ -488,11 +862,15 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
# Fallback album key: use album name when ID is missing (e.g. mirrored playlist tracks)
if not album_id and isinstance(album_val, dict) and album_val.get('name'):
album_id = f"_name_{album_val['name'].lower().strip()}"
disc_num = sp_data.get('disc_number', t.get('disc_number', 1))
disc_num = sp_data.get('disc_number') or t.get('disc_number') or 1
if album_id:
wishlist_album_disc_counts[album_id] = max(
wishlist_album_disc_counts.get(album_id, 1), disc_num
)
if isinstance(album_val, dict):
existing_album_ctx = wishlist_album_context_map.get(album_id, {})
if _album_context_richness(album_val) > _album_context_richness(existing_album_ctx):
wishlist_album_context_map[album_id] = dict(album_val)
# 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 {}
@ -583,17 +961,20 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
_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)
# Construct a shared album context from the richest track in
# this album group so release_date/year and artwork do not
# vary per track and split folders.
album_id = s_album.get('id', 'wishlist_album')
shared_album = wishlist_album_context_map.get(album_id_for_lookup, s_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
'name': shared_album.get('name') or s_album.get('name'),
'release_date': shared_album.get('release_date', ''),
'total_tracks': shared_album.get('total_tracks') or s_album.get('total_tracks', 1),
'total_discs': wishlist_album_disc_counts.get(album_id_for_lookup, 1),
'album_type': shared_album.get('album_type') or s_album.get('album_type', 'album'),
'images': shared_album.get('images') or s_album.get('images', []),
'artists': shared_album.get('artists') or s_album.get('artists', []),
}
track_info['_explicit_album_context'] = album_ctx

View file

@ -34,6 +34,32 @@ _start_next_batch_of_downloads = None
_orphaned_download_keys = None
missing_download_executor = None
download_orchestrator = None
_RELEASE_SOURCE_NAMES = frozenset(('torrent', 'usenet'))
def _download_id_key(download_id):
return f"download_id::{download_id}" if download_id else None
def _is_release_task(task):
ti = task.get('track_info') if isinstance(task.get('track_info'), dict) else {}
username = task.get('username') or ti.get('username')
return username in _RELEASE_SOURCE_NAMES
def _lookup_live_info(task, live_transfers_lookup):
ti = task.get('track_info') if isinstance(task.get('track_info'), dict) else {}
download_id = task.get('download_id')
if _is_release_task(task):
by_id = live_transfers_lookup.get(_download_id_key(download_id))
if by_id:
return by_id
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 None
return live_transfers_lookup.get(_make_context_key(task_username, task_filename))
def init(
@ -139,55 +165,64 @@ class WebUIDownloadMonitor:
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']:
if not task:
continue
release_recoverable = (
_is_release_task(task)
and task.get('download_id')
and task.get('status') in ['failed', 'not_found']
)
if task['status'] not in ['downloading', 'queued'] and not release_recoverable:
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)
retry_exhausted = False
if not release_recoverable:
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')
# ENHANCED: Check for successful completions (especially YouTube).
# Release-style sources can report a completed audio file
# name that differs from the original indexer URL/title
# stored on the task, so prefer the stable download_id.
live_info = _lookup_live_info(task, live_transfers_lookup)
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))
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("Monitor: %s state=%s but bytes incomplete (%s/%s) - waiting", task_id, state, transferred, expected_size)
task['_incomplete_warned'] = True
continue
if has_completion and not has_error and (
task['status'] == 'downloading' or release_recoverable
):
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:
@ -197,8 +232,21 @@ class WebUIDownloadMonitor:
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}")
# Issue #648 diagnostic — `op` now carries a trigger
# label (4-tuple, was 3-tuple) so the next log dump
# tells us WHICH path in `_should_retry_task` is
# firing for users seeing "Tidal downloads failed to
# start" mass-cancels. Label format pinned in commit
# message for grep-ability.
if len(op) >= 4:
_, download_id, username, trigger = op[0], op[1], op[2], op[3]
else:
_, download_id, username = op
trigger = 'unlabeled'
logger.info(
f"[CancelTrigger:monitor.{trigger}] download_id={download_id} "
f"username={username}"
)
run_async(download_orchestrator.cancel_download(download_id, username, remove=True))
logger.debug(f"[Deferred] Successfully cancelled download {download_id}")
elif op[0] == 'cleanup_orphan':
@ -214,18 +262,28 @@ class WebUIDownloadMonitor:
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)
# Handle completed transfers outside the lock. The transfer engine's
# "complete" state only means the remote download finished; the
# post-processing worker still has to find, verify, tag, and move the
# file before it can report real batch success or failure.
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}")
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['status'] = 'failed'
download_tasks[task_id]['error_message'] = f'Post-processing could not be scheduled: {e}'
try:
_on_download_completed(batch_id, task_id, success=False)
except Exception as completion_error:
logger.error(
f"[Monitor] Error marking failed post-processing submit for task {task_id}: {completion_error}"
)
# Handle exhausted retry tasks outside the lock to prevent deadlock
for batch_id, task_id in exhausted_tasks:
try:
@ -285,7 +343,7 @@ class WebUIDownloadMonitor:
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] = {
transfer_row = {
'id': download.id,
'filename': download.filename,
'username': download.username,
@ -295,6 +353,10 @@ class WebUIDownloadMonitor:
'bytesTransferred': download.transferred,
'averageSpeed': download.speed,
}
live_transfers[key] = transfer_row
id_key = _download_id_key(download.id)
if id_key:
live_transfers[id_key] = transfer_row
except Exception as yt_error:
logger.error(f"Monitor: Could not fetch streaming source downloads: {yt_error}")
@ -329,7 +391,7 @@ class WebUIDownloadMonitor:
return False
lookup_key = _make_context_key(task_username, task_filename)
live_info = live_transfers_lookup.get(lookup_key)
live_info = _lookup_live_info(task, live_transfers_lookup)
if not live_info:
# User-initiated manual pick — skip auto-retry. The status
@ -353,7 +415,8 @@ class WebUIDownloadMonitor:
# Defer slskd cancel to outside the lock
if task_username and download_id:
deferred_ops.append(('cancel_download', download_id, task_username))
deferred_ops.append(('cancel_download', download_id, task_username,
'not_in_live_transfers_90s'))
# Mark current source as used (full filename to match worker format)
if task_username and task_filename:
@ -424,7 +487,8 @@ class WebUIDownloadMonitor:
# Defer slskd cancel to outside the lock
if username and download_id:
deferred_ops.append(('cancel_download', download_id, username))
deferred_ops.append(('cancel_download', download_id, username,
'errored_state_retry'))
# Mark current source as used to prevent retry loops
# CRITICAL: Use full filename (not basename) to match worker's source_key format
@ -527,7 +591,8 @@ class WebUIDownloadMonitor:
# Defer slskd cancel to outside the lock
if username and download_id:
deferred_ops.append(('cancel_download', download_id, username))
deferred_ops.append(('cancel_download', download_id, username,
'queued_state_timeout'))
# UNIFIED RETRY LOGIC: Handle timeout retry exactly like error retry
# Mark current source as used to prevent retry loops
@ -615,7 +680,8 @@ class WebUIDownloadMonitor:
# Defer slskd cancel to outside the lock
if username and download_id:
deferred_ops.append(('cancel_download', download_id, username))
deferred_ops.append(('cancel_download', download_id, username,
'stuck_at_0pct_timeout'))
# UNIFIED RETRY LOGIC: Handle 0% timeout retry exactly like error retry
# Mark current source as used to prevent retry loops
@ -704,7 +770,8 @@ class WebUIDownloadMonitor:
download_id = task.get('download_id')
if username and download_id:
deferred_ops.append(('cancel_download', download_id, username))
deferred_ops.append(('cancel_download', download_id, username,
'unknown_state_no_progress_timeout'))
if username and filename:
used_sources = task.get('used_sources', set())

View file

@ -19,9 +19,11 @@ from __future__ import annotations
import logging
import os
import shutil
import time
import traceback
from dataclasses import dataclass
from difflib import SequenceMatcher
from pathlib import Path
from typing import Any, Callable, Optional
@ -34,7 +36,7 @@ from core.imports.context import (
get_import_original_search,
normalize_import_context,
)
from core.imports.filename import extract_track_number_from_filename
from core.imports.filename import extract_track_number_from_filename, parse_filename_metadata
from core.metadata import enrichment as metadata_enrichment
from core.runtime_state import (
download_tasks,
@ -46,6 +48,85 @@ from core.runtime_state import (
logger = logging.getLogger(__name__)
_AUDIO_EXTENSIONS = {
'.flac', '.ape', '.wav', '.alac', '.dsf', '.dff', '.aiff', '.aif',
'.opus', '.ogg', '.m4a', '.aac', '.mp3', '.wma',
}
def _is_audio_file(path: str) -> bool:
return Path(str(path or '')).suffix.lower() in _AUDIO_EXTENSIONS
def _reject_non_audio_found_file(found_file: Optional[str], file_location: Optional[str]) -> tuple[Optional[str], Optional[str]]:
if found_file and not _is_audio_file(found_file):
logger.warning(
"[Post-Processing] Ignoring non-audio candidate found during file search: %s",
found_file,
)
return None, None
return found_file, file_location
def _normalize_match_text(value: str) -> str:
return ''.join(ch.lower() for ch in str(value or '') if ch.isalnum())
def _release_audio_match_score(path: str, expected_title: str, expected_artist: str) -> float:
parsed = parse_filename_metadata(path)
parsed_title = parsed.get('title') or Path(path).stem
parsed_artist = parsed.get('artist') or ''
expected_title_norm = _normalize_match_text(expected_title)
parsed_title_norm = _normalize_match_text(parsed_title)
if expected_title_norm and (
expected_title_norm in parsed_title_norm or parsed_title_norm in expected_title_norm
):
title_score = 1.0
else:
title_score = SequenceMatcher(
None,
expected_title_norm,
parsed_title_norm,
).ratio()
if expected_artist and parsed_artist:
artist_score = SequenceMatcher(
None,
_normalize_match_text(expected_artist),
_normalize_match_text(parsed_artist),
).ratio()
return (title_score * 0.75) + (artist_score * 0.25)
return title_score
def _track_title_from_task(track_info: Any, context: Optional[dict]) -> str:
if isinstance(track_info, dict):
title = track_info.get('name') or track_info.get('title')
if title:
return str(title)
return get_import_clean_title(context or {}, default='')
def _copy_release_audio_to_transfer(source_path: str, transfer_dir: str) -> Optional[str]:
try:
src = Path(source_path)
if not src.exists() or not src.is_file():
return None
dest_dir = Path(transfer_dir)
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / src.name
if dest.exists():
stem, suffix = src.stem, src.suffix
counter = 1
while dest.exists():
dest = dest_dir / f"{stem}_release_{counter}{suffix}"
counter += 1
shutil.copy2(src, dest)
return str(dest)
except Exception as exc:
logger.warning("[Post-Processing] Could not copy release audio to transfer: %s", exc)
return None
@dataclass
class PostProcessDeps:
"""Bundle of dependencies the post-processing worker needs.
@ -190,14 +271,71 @@ def run_post_processing_worker(task_id: str, batch_id: str, deps: PostProcessDep
# 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.
# Torrent/usenet also use opaque "url||display" filenames. Their completed
# job may contain a full release, so choose the best matching audio file
# and copy it to transfer before importing instead of moving the client's
# original completed download.
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}")
logger.info(f"[Post-Processing] Detected engine-backed 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.download_orchestrator.get_download_status(actual_download_id))
if status and status.file_path:
if status and task.get('username') in ('torrent', 'usenet'):
audio_files = list(getattr(status, 'audio_files', None) or [])
if not audio_files and getattr(status, 'file_path', None):
audio_files = [status.file_path]
expected_title = _track_title_from_task(track_info, context)
expected_artist = ''
if isinstance(track_info, dict):
artists = track_info.get('artists', [])
if isinstance(artists, list) and artists:
first_artist = artists[0]
expected_artist = first_artist.get('name', '') if isinstance(first_artist, dict) else str(first_artist)
elif isinstance(artists, str):
expected_artist = artists
if not expected_artist and context:
artist_ctx = get_import_context_artist(context)
expected_artist = artist_ctx.get('name', '') if isinstance(artist_ctx, dict) else ''
scored_files = [
(_release_audio_match_score(path, expected_title, expected_artist), path)
for path in audio_files
if _is_audio_file(path)
]
scored_files.sort(reverse=True)
if scored_files:
best_score, best_path = scored_files[0]
logger.info(
"[Post-Processing] Best %s release file for '%s': %s (score %.2f)",
task.get('username'), expected_title, best_path, best_score,
)
if best_score >= 0.80:
copied_path = _copy_release_audio_to_transfer(best_path, transfer_dir)
if copied_path:
found_file = copied_path
file_location = 'download'
logger.info(
"[Post-Processing] Copied matched %s release file to transfer: %s",
task.get('username'), copied_path,
)
else:
logger.warning(
"[Post-Processing] No %s release file met match threshold for '%s' (best %.2f)",
task.get('username'), expected_title, best_score,
)
if not found_file:
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['status'] = 'failed'
download_tasks[task_id]['error_message'] = (
f"No matching audio file found in {task.get('username')} release"
)
deps.on_download_completed(batch_id, task_id, False)
return
if status and status.file_path and not found_file and task.get('username') not in ('torrent', 'usenet'):
real_path = status.file_path
if os.path.exists(real_path):
# Determine if it's in download or transfer directory
@ -227,11 +365,12 @@ def run_post_processing_worker(task_id: str, batch_id: str, deps: PostProcessDep
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}")
logger.warning(f"[Post-Processing] Engine status reported path but file missing: {real_path}")
else:
logger.warning(f"[Post-Processing] YouTube status returned no file_path for task {task_id}")
if not found_file:
logger.warning(f"[Post-Processing] Engine 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}")
logger.error(f"[Post-Processing] Failed to retrieve engine task status: {e}")
_file_search_max_retries = 5
for retry_count in range(_file_search_max_retries):
@ -257,6 +396,7 @@ def run_post_processing_worker(task_id: str, batch_id: str, deps: PostProcessDep
# 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)
found_file, file_location = _reject_non_audio_found_file(found_file, file_location)
if found_file:
logger.info(f"[Post-Processing] Strategy 1 SUCCESS: Found file with original filename in {file_location}: {found_file}")
@ -269,7 +409,11 @@ def run_post_processing_worker(task_id: str, batch_id: str, deps: PostProcessDep
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}")
found_file, file_location = _reject_non_audio_found_file(found_file, file_location)
if found_file:
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 resolved to a non-audio file")
else:
logger.error("[Post-Processing] Strategy 2 FAILED: Expected final filename not found in transfer folder")
elif not expected_final_filename:

View file

@ -34,9 +34,12 @@ from __future__ import annotations
import logging
import os
import re
from dataclasses import dataclass
from typing import Any, Callable
from core.imports.filename import extract_track_number_from_filename
# `shutil` and `SequenceMatcher` are imported inline inside try_staging_match()
# to keep the lift byte-identical with the original web_server.py function body.
@ -50,6 +53,56 @@ from core.runtime_state import (
logger = logging.getLogger(__name__)
def _coerce_positive_int(value: Any, default: int = 0) -> int:
try:
coerced = int(str(value).split('/')[0])
except (TypeError, ValueError):
return default
return coerced if coerced > 0 else default
def _staging_title_variants(title: Any, normalize: Callable[[str], str]) -> list[str]:
"""Return conservative title variants for release-file matching.
Torrent / usenet release files often encode featured artists in the
filename/title while streaming metadata keeps them in the artist credit.
Strip only feature/bonus noise here; keep version words like remix,
extended, live, acoustic, etc. so distinct recordings do not collapse.
"""
raw = str(title or '').strip()
if not raw:
return []
compacted_separators = re.sub(r'[_]+', ' ', raw)
compacted_separators = re.sub(r'\s+', ' ', compacted_separators).strip()
without_feat = re.sub(
r'\s*[\(\[]\s*(?:feat\.?|ft\.?|featuring)\s+[^)\]]*[\)\]]',
'',
compacted_separators,
flags=re.IGNORECASE,
)
without_feat = re.sub(
r'\s+(?:feat\.?|ft\.?|featuring)\s+.*$',
'',
without_feat,
flags=re.IGNORECASE,
)
without_bonus = re.sub(
r'\s*[\(\[]\s*bonus\s+track\s*[\)\]]',
'',
without_feat,
flags=re.IGNORECASE,
)
variants: list[str] = []
for candidate in (raw, compacted_separators, without_feat, without_bonus):
normalized = normalize(candidate)
if normalized and normalized not in variants:
variants.append(normalized)
return variants
@dataclass
class StagingDeps:
"""Bundle of cross-cutting deps the staging-match helper needs."""
@ -58,6 +111,12 @@ class StagingDeps:
get_staging_file_cache: Callable[[str], list]
docker_resolve_path: Callable[[str], str]
post_process_matched_download_with_verification: Callable
# Optional batch-field accessor. Returns ``download_batches[batch_id].get(field)``
# when the runtime state is available, ``None`` otherwise. Injected so
# this module doesn't have to import from runtime_state directly —
# keeps the dep surface explicit and the function unit-testable
# without a live batch dict.
get_batch_field: Callable[[str, str], Any] = None # type: ignore[assignment]
def try_staging_match(task_id, batch_id, track, deps: StagingDeps):
@ -80,19 +139,24 @@ def try_staging_match(task_id, batch_id, track, deps: StagingDeps):
normalize = deps.matching_engine.normalize_string
norm_title = normalize(track_title)
norm_artist = normalize(track_artist)
title_variants = _staging_title_variants(track_title, normalize) or [norm_title]
best_match = None
best_score = 0.0
for sf in staging_files:
sf_norm_title = normalize(sf['title'])
sf_title_variants = _staging_title_variants(sf['title'], normalize)
sf_norm_artist = normalize(sf['artist'])
if not sf_norm_title:
if not sf_title_variants:
continue
# Title similarity (primary)
title_sim = SequenceMatcher(None, norm_title, sf_norm_title).ratio()
title_sim = max(
SequenceMatcher(None, expected, candidate).ratio()
for expected in title_variants
for candidate in sf_title_variants
)
if title_sim < 0.80:
continue
@ -141,20 +205,51 @@ def try_staging_match(task_id, batch_id, track, deps: StagingDeps):
shutil.copy2(best_match['full_path'], dest_path)
logger.info(f"[Staging] Copied to transfer: {dest_path}")
# Mark task as completed with staging context
# Mark task as completed with staging context.
# If the batch was populated by the torrent / usenet album-bundle
# flow, prefer that provenance label over generic 'staging' so the
# download history reflects the real source. The accessor is
# injected via StagingDeps so this module doesn't reach into
# runtime_state directly (see deps.get_batch_field docstring).
_provenance_override = None
if batch_id and deps.get_batch_field is not None:
try:
_provenance_override = deps.get_batch_field(batch_id, 'album_bundle_source')
except Exception as _exc:
logger.debug("get_batch_field failed: %s", _exc)
_provenance_username = _provenance_override or 'staging'
_private_album_bundle_staging = False
if batch_id and deps.get_batch_field is not None:
try:
_private_album_bundle_staging = bool(
deps.get_batch_field(batch_id, 'album_bundle_private_staging')
)
except Exception as _exc:
logger.debug("get_batch_field failed: %s", _exc)
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]['username'] = _provenance_username
download_tasks[task_id]['staging_match'] = True
if _private_album_bundle_staging:
try:
os.remove(best_match['full_path'])
logger.debug("[Staging] Removed private album-bundle staging file: %s", best_match['full_path'])
except FileNotFoundError:
pass
except Exception as _exc:
logger.debug("[Staging] Could not remove private album-bundle staging file: %s", _exc)
# 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 = {}
else:
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
@ -223,20 +318,37 @@ def try_staging_match(task_id, batch_id, track, deps: StagingDeps):
)
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
file_track_number = (
_coerce_positive_int(best_match.get('track_number'), 0) or
extract_track_number_from_filename(best_match.get('full_path', ''))
)
file_disc_number = _coerce_positive_int(best_match.get('disc_number'), 1)
if _private_album_bundle_staging:
track_number = file_track_number
disc_number = file_disc_number
else:
track_number = (
_coerce_positive_int(track_info.get('track_number'), 0) or
_coerce_positive_int(track_info.get('trackNumber'), 0) or
_coerce_positive_int(getattr(track, 'track_number', 0), 0) or
file_track_number
)
disc_number = (
_coerce_positive_int(track_info.get('disc_number'), 0) or
_coerce_positive_int(track_info.get('discNumber'), 0) or
_coerce_positive_int(getattr(track, 'disc_number', 0), 0) or
file_disc_number
)
track_info['track_number'] = track_number
track_info['disc_number'] = disc_number
context = {
'track_info': track_info,
'spotify_artist': spotify_artist_ctx,
'spotify_album': spotify_album_ctx,
'original_search_result': {
'username': _provenance_username,
'filename': best_match.get('full_path', ''),
'title': track_title,
'artist': track_artist,
'spotify_clean_title': track_title,

View file

@ -87,8 +87,10 @@ class StatusDeps:
# Streaming sources the engine fallback applies to. Soulseek goes through
# slskd's live_transfers path and must NOT hit the engine fallback.
_STREAMING_SOURCE_NAMES = frozenset((
'youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud',
'youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud', 'amazon',
'torrent', 'usenet',
))
_RELEASE_SOURCE_NAMES = frozenset(('torrent', 'usenet'))
# Keep these in sync with the engine plugins' state strings.
_ENGINE_FAILURE_STATES = ('Errored', 'Failed', 'Rejected', 'TimedOut', 'Aborted')
@ -136,21 +138,12 @@ def _apply_engine_state_fallback(
Mutates ``task`` in place (status / error_message) the same way the
Soulseek branch does, so the next status poll sees the new state.
Submits post-processing on terminal success and fires
``on_download_completed`` on terminal failure to free the worker
slot.
Submits post-processing on terminal success. Manual-pick failures
are completed here; automatic failures keep the existing retry path
in charge.
"""
if deps.download_orchestrator is None or deps.run_async is None:
return
if task.get('status') in ('completed', 'failed', 'cancelled', 'not_found', 'post_processing'):
return
# Scope this fallback to user-initiated manual picks. Auto attempts
# already flow through the live_transfers_lookup IF branch (the engine
# pre-populates non-Soulseek records via get_all_downloads), and on
# failure the monitor's existing retry path picks the next candidate.
# Marking auto attempts failed here would short-circuit that fallback.
if not task.get('_user_manual_pick'):
return
download_id = task.get('download_id')
if not download_id:
return
@ -158,6 +151,17 @@ def _apply_engine_state_fallback(
username = task.get('username') or ti.get('username')
if username not in _STREAMING_SOURCE_NAMES:
return
manual_pick = bool(task.get('_user_manual_pick'))
if not manual_pick and username not in _RELEASE_SOURCE_NAMES:
return
release_source = username in _RELEASE_SOURCE_NAMES
terminal_status = task.get('status') in ('completed', 'failed', 'cancelled', 'not_found', 'post_processing')
if terminal_status and not (
release_source
and task.get('status') in ('failed', 'not_found')
and download_id
):
return
try:
record = deps.run_async(
@ -189,6 +193,14 @@ def _apply_engine_state_fallback(
return
if any(s in state_str for s in _ENGINE_FAILURE_STATES):
if not manual_pick:
task_status['status'] = task.get('status', 'downloading')
task_status['progress'] = _engine_progress_pct(record)
logger.info(
"[Engine Fallback] Task %s engine reports '%s' for auto %s attempt; leaving retry handling to monitor",
task_id, state_str, username,
)
return
if task['status'] != 'failed':
task['status'] = 'failed'
err = getattr(record, 'error_message', None) or getattr(record, 'error', None) or ''
@ -247,6 +259,9 @@ def build_batch_status_data(batch_id: str, batch: dict, live_transfers_lookup: d
"playlist_id": batch.get('playlist_id'), # Include playlist_id for rehydration
"playlist_name": batch.get('playlist_name'), # Include playlist_name for reference
}
album_bundle = _build_album_bundle_status(batch)
if album_bundle:
response_data['album_bundle'] = album_bundle
if response_data["phase"] == 'analysis':
response_data['analysis_progress'] = {
@ -312,11 +327,23 @@ def build_batch_status_data(batch_id: str, batch: dict, live_transfers_lookup: d
'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
'quarantine_entry_id': task.get('quarantine_entry_id'),
'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_username in _RELEASE_SOURCE_NAMES
and task['status'] not in ['completed', 'cancelled', 'post_processing']
and task.get('download_id')
):
_apply_engine_state_fallback(
task_id, task, task_status, batch_id, deps,
)
batch_tasks.append(task_status)
continue
if task_filename and task_username:
lookup_key = deps.make_context_key(task_username, task_filename)
@ -609,7 +636,7 @@ def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict:
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({
summary = {
'batch_id': bid,
'playlist_id': batch.get('playlist_id', ''),
'batch_name': batch.get('playlist_name') or batch.get('album_name') or '',
@ -620,7 +647,11 @@ def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict:
'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')),
})
}
album_bundle = _build_album_bundle_status(batch)
if album_bundle:
summary['album_bundle'] = album_bundle
batch_summaries.append(summary)
return {
'success': True,
@ -629,3 +660,38 @@ def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict:
'batches': batch_summaries,
'timestamp': time.time(),
}
def _build_album_bundle_status(batch: dict) -> dict:
"""Return public batch-level status for torrent/Usenet album-bundle work."""
state = batch.get('album_bundle_state')
if not state:
return {}
status = {
'state': state,
'source': batch.get('album_bundle_source'),
'release': batch.get('album_bundle_release'),
'progress': batch.get('album_bundle_progress'),
'progress_percent': _album_bundle_progress_percent(
batch.get('album_bundle_progress')
),
'speed': batch.get('album_bundle_speed'),
'downloaded': batch.get('album_bundle_downloaded'),
'size': batch.get('album_bundle_size'),
'seeders': batch.get('album_bundle_seeders'),
'grabs': batch.get('album_bundle_grabs'),
'count': batch.get('album_bundle_count'),
}
return {key: value for key, value in status.items() if value is not None}
def _album_bundle_progress_percent(value: Any) -> int:
try:
progress = float(value)
except (TypeError, ValueError):
return 0
if progress <= 1:
progress *= 100
return max(0, min(100, int(round(progress))))

View file

@ -25,12 +25,49 @@ import traceback
from dataclasses import dataclass
from typing import Any, Callable, Optional
from core.runtime_state import download_tasks, tasks_lock
from core.runtime_state import download_batches, download_tasks, tasks_lock
from core.spotify_client import Track as SpotifyTrack
logger = logging.getLogger(__name__)
def _private_album_bundle_staging_miss_reason(batch_id: Optional[str], deps: Any) -> Optional[str]:
"""Return a user-facing miss reason when per-track search should stop.
Torrent / usenet / Soulseek album batches first download one private staged release,
then each track claims the matching staged file. If that claim fails after
the release is already staged, falling through to the normal per-track
search only retries release-level sources N times and can keep re-adding
the same torrent. Treat the staged release as authoritative for this pass.
"""
if not batch_id:
return None
batch = download_batches.get(batch_id)
if not isinstance(batch, dict):
return None
source = (batch.get('album_bundle_source') or '').lower()
mode = (getattr(deps.download_orchestrator, 'mode', '') or '').lower()
hybrid_first = ''
if mode == 'hybrid':
order = getattr(deps.download_orchestrator, 'hybrid_order', None) or []
if order:
hybrid_first = str(order[0] or '').lower()
else:
hybrid_first = str(getattr(deps.download_orchestrator, 'hybrid_primary', '') or '').lower()
if (
batch.get('album_bundle_private_staging')
and batch.get('album_bundle_state') == 'staged'
and not batch.get('album_bundle_partial')
and source in ('torrent', 'usenet', 'soulseek')
and (mode == source or (mode == 'hybrid' and hybrid_first == source))
):
return f'Track was not found in the staged {source} album release'
return None
@dataclass
class TaskWorkerDeps:
"""Bundle of cross-cutting deps the per-task download worker needs."""
@ -128,6 +165,21 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
# === STAGING CHECK: Check staging folder for existing file before searching ===
if deps.try_staging_match(task_id, batch_id, track):
return
staging_miss_reason = _private_album_bundle_staging_miss_reason(batch_id, deps)
if staging_miss_reason:
logger.warning(
"[Modal Worker] %s for '%s'; skipping redundant per-track %s search",
staging_miss_reason,
track.name,
getattr(deps.download_orchestrator, 'mode', 'release-source'),
)
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['status'] = 'not_found'
download_tasks[task_id]['error_message'] = staging_miss_reason
if batch_id:
deps.on_download_completed(batch_id, task_id, False)
return
# Initialize task state tracking (like GUI's parallel_search_tracking)
with tasks_lock:
@ -147,6 +199,27 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
artist_name = track.artists[0] if track.artists else None
track_name = track.name
release_queries = []
try:
_download_mode = (getattr(deps.download_orchestrator, 'mode', '') or '').lower()
_track_album = (getattr(track, 'album', '') or '').strip()
_track_title = (getattr(track, 'name', '') or '').strip()
_track_artists = list(getattr(track, 'artists', []) or [])
_first_artist = _track_artists[0] if _track_artists else ''
_primary_artist = (
(_first_artist.get('name', '') if isinstance(_first_artist, dict) else str(_first_artist))
or ''
).strip()
if (
_download_mode in ('torrent', 'usenet')
and _primary_artist
and _track_album
and _track_album.lower() not in ('unknown album', _track_title.lower())
):
release_queries.append(f"{_primary_artist} {_track_album}".strip())
except Exception as _release_query_exc:
logger.debug("[Modal Worker] release query hint failed: %s", _release_query_exc)
# Start with matching engine queries
search_queries = deps.matching_engine.generate_download_queries(track)
@ -175,8 +248,14 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
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
# Combine enhanced queries with legacy fallbacks.
#
# Torrent / usenet can use full album releases as a fallback for
# single-track requests, but trying the album release first makes
# playlist batches download whole albums before checking whether a
# track-shaped release exists. Keep release queries last so singles
# stay light when the indexer has a direct result.
all_queries = search_queries + legacy_queries + release_queries
# Remove duplicates while preserving order
unique_queries = []
@ -209,8 +288,30 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
logger.debug(f"About to call soulseek search for task {task_id}")
try:
# Hybrid + album-context batches must skip torrent / usenet during
# the per-track loop — they're release-level sources, can't match
# individual tracks meaningfully, and album-bundle handling only
# fires in single-source mode (see core/downloads/master.py). The
# exclusion lets the hybrid chain fall through to per-track-
# compatible sources (soulseek / streaming) instead of attempting
# N redundant Prowlarr searches that all download the same album
# torrent and rely on the auto-import sweep to clean up.
_exclude_for_hybrid_album = None
try:
_batch_is_album = False
if batch_id:
from core.runtime_state import download_batches as _db
_b = _db.get(batch_id)
if isinstance(_b, dict):
_batch_is_album = bool(_b.get('is_album_download'))
if _batch_is_album and getattr(deps.download_orchestrator, 'mode', '') == 'hybrid':
_exclude_for_hybrid_album = ['torrent', 'usenet']
except Exception as _exc_filter_err:
logger.debug("[Modal Worker] album-source-exclusion check failed: %s", _exc_filter_err)
# Perform search with timeout
tracks_result, _ = deps.run_async(deps.download_orchestrator.search(query, timeout=30))
tracks_result, _ = deps.run_async(deps.download_orchestrator.search(
query, timeout=30, exclude_sources=_exclude_for_hybrid_album,
))
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
@ -294,7 +395,7 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
source_clients = {
name: orch.client(name)
for name in ('soulseek', 'youtube', 'tidal', 'qobuz',
'hifi', 'deezer_dl', 'lidarr', 'soundcloud')
'hifi', 'deezer_dl', 'lidarr', 'soundcloud', 'amazon')
}
# The orchestrator tried sources in order but stopped at the first with results.

View file

@ -9,6 +9,7 @@ import logging
import re
from config.settings import config_manager
from core.imports.file_integrity import resolve_duration_tolerance
logger = logging.getLogger(__name__)
@ -24,6 +25,20 @@ def init(matching_engine_obj, download_orchestrator_obj):
download_orchestrator = download_orchestrator_obj
def _torrent_usenet_artist_is_fallback(result):
"""True when a release result has no parsed artist, only indexer filler."""
if getattr(result, 'username', None) not in ('torrent', 'usenet'):
return False
artist = (getattr(result, 'artist', None) or '').strip()
if not artist:
return True
metadata = getattr(result, '_source_metadata', None) or {}
indexer = str(metadata.get('indexer') or '').strip()
if artist.lower() in ('torrent', 'usenet'):
return True
return bool(indexer and artist.lower() == indexer.lower())
def filter_soundcloud_previews(results, expected_track):
"""Drop SoundCloud preview snippets so they never reach the cache,
the modal, or the auto-download attempt.
@ -61,6 +76,24 @@ def filter_soundcloud_previews(results, expected_track):
return [r for r in results if not _is_preview(r)]
def _duration_tolerance_seconds(expected_duration_ms):
override = resolve_duration_tolerance(
config_manager.get('post_processing.duration_tolerance_seconds', 0)
)
if override is not None:
return override
expected_seconds = expected_duration_ms / 1000.0
return 5.0 if expected_seconds > 600.0 else 3.0
def _duration_mismatch_exceeds_integrity_tolerance(expected_duration_ms, candidate_duration_ms):
if not expected_duration_ms or not candidate_duration_ms:
return False
tolerance = _duration_tolerance_seconds(expected_duration_ms)
drift = abs((candidate_duration_ms / 1000.0) - (expected_duration_ms / 1000.0))
return drift > tolerance
def get_valid_candidates(results, spotify_track, query):
"""
This function is a direct port from sync.py. It scores and filters
@ -78,8 +111,12 @@ def get_valid_candidates(results, spotify_track, query):
return []
# Streaming sources (YouTube, Tidal, Qobuz, HiFi, Deezer, SoundCloud) return structured API results
# with proper artist/title metadata — score using the same matching engine as Soulseek
_streaming_sources = ("youtube", "tidal", "qobuz", "hifi", "deezer_dl", "soundcloud")
# with proper artist/title metadata — score using the same matching engine as Soulseek.
# Torrent / usenet results also belong here: their filename field is a download URL, not
# a slskd-style ``Artist/Album/Track.flac`` path, so the Soulseek matcher would extract
# garbage segments from it. Routing them through the streaming path means score_track_match
# reads ``r.title`` and ``r.artist`` directly (which the torrent/usenet projections pre-fill).
_streaming_sources = ("youtube", "tidal", "qobuz", "hifi", "deezer_dl", "soundcloud", "amazon", "torrent", "usenet")
if results[0].username in _streaming_sources:
source_label = results[0].username.replace('_dl', '').title()
expected_artists = spotify_track.artists if spotify_track else []
@ -98,17 +135,70 @@ def get_valid_candidates(results, spotify_track, query):
expected_is_version = any(kw in expected_title_lower for kw in _version_keywords)
scored = []
_strict_duration_sources = {'tidal', 'qobuz', 'hifi', 'deezer_dl', 'amazon'}
for r in results:
# Score using matching engine's generic scorer (same weights as Soulseek)
if (
r.username in _strict_duration_sources
and _duration_mismatch_exceeds_integrity_tolerance(expected_duration, r.duration or 0)
):
logger.info(
"[%s] Rejecting candidate due to duration mismatch before download: "
"expected %.1fs, candidate %.1fs",
source_label,
expected_duration / 1000.0,
(r.duration or 0) / 1000.0,
)
continue
# Score using matching engine's generic scorer (same weights as Soulseek).
# Torrent/usenet release projections sometimes only have the indexer name
# in the artist field when a title did not parse as "Artist - Release".
# Treat that as unknown artist, not as a real mismatch.
has_only_fallback_artist = _torrent_usenet_artist_is_fallback(r)
candidate_artists = [] if has_only_fallback_artist else ([r.artist] if r.artist else [])
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_artists=candidate_artists,
candidate_duration_ms=r.duration or 0,
)
# Album-name fallback for torrent / usenet per-track results.
#
# When this fallback runs: hybrid mode + non-album batch (single
# track wishlist / playlist of singles). Album-context batches
# never reach here — the album-bundle gate in
# core/downloads/album_bundle_dispatch.py engages the bulk-
# download flow in single-source mode, and the hybrid chain
# filter in core/downloads/task_worker.py strips torrent /
# usenet from album batches in hybrid mode. What's left is the
# single-track-in-hybrid case where a user is searching for one
# track and the only torrent / usenet result is the album that
# contains it.
#
# Without this fallback, "Luther (with SZA)" against a
# candidate titled "GNX (2024) [FLAC]" scores ~0 on track-title
# alone — even though the album torrent does in fact contain
# the wanted track. Scoring the candidate title against the
# wanted track's ALBUM name and taking the max gives album-
# level releases a fair shot. The Auto-Import sweep then picks
# the right file out of the downloaded album folder.
expected_album = getattr(spotify_track, 'album', None) if spotify_track else None
if r.username in ('torrent', 'usenet') and expected_album:
album_conf, _ = matching_engine.score_track_match(
source_title=expected_album,
source_artists=expected_artists,
source_duration_ms=0, # albums don't have one duration
candidate_title=r.title or '',
candidate_artists=candidate_artists,
candidate_duration_ms=0,
)
if album_conf > confidence:
confidence = album_conf
match_type = 'album_release'
# Version detection penalty — reject live/remix/acoustic when expecting original
r_title_lower = (r.title or '').lower()
is_wrong_version = False
@ -129,8 +219,10 @@ def get_valid_candidates(results, spotify_track, query):
# 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':
# YouTube stays excluded because video-title parsing is unreliable.
# Torrent/usenet must also pass this gate so title-only matches
# from the wrong artist do not get downloaded.
if r.username != 'youtube' and not has_only_fallback_artist:
from difflib import SequenceMatcher
import re as _re
_cand_artist_raw = r.artist or ''
@ -162,6 +254,16 @@ def get_valid_candidates(results, spotify_track, query):
# 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 r.username in ('torrent', 'usenet') and _best_artist < 0.5:
logger.info(
"[%s] Rejecting candidate due to artist mismatch: "
"expected=%s candidate=%r title=%r",
source_label,
list(expected_artists),
_cand_artist_raw,
r.title or '',
)
continue
if _best_artist < 0.5 and confidence < 0.85:
continue

View file

@ -23,6 +23,8 @@ import re
import uuid
import time
import shutil
import json
import base64
import subprocess
import threading
from typing import List, Optional, Dict, Any, Tuple
@ -75,6 +77,13 @@ HLS_QUALITY_MAP = {
HLS_MAP_TAG_RE = re.compile(r'#EXT-X-MAP:.*URI="([^"]+)"')
TRACK_ENDPOINT_QUALITY_MAP = {
'hires': 'HI_RES_LOSSLESS',
'lossless': 'LOSSLESS',
'high': 'HIGH',
'low': 'LOW',
}
# Default public hifi-api instances (ordered by preference)
DEFAULT_INSTANCES = [
'https://triton.squid.wtf',
@ -244,6 +253,122 @@ class HiFiClient(DownloadSourcePlugin):
return data.get('version') or data.get('data', {}).get('version')
return None
@staticmethod
def _extract_manifest_uri(data: Any) -> Optional[str]:
try:
inner = data.get('data', data) if isinstance(data, dict) else data
attrs = inner.get('data', {}).get('attributes', {})
return attrs.get('uri')
except (AttributeError, KeyError):
return None
@staticmethod
def _extract_track_manifest_urls(data: Any) -> List[str]:
try:
inner = data.get('data', data) if isinstance(data, dict) else data
manifest_b64 = inner.get('manifest')
if not manifest_b64:
return []
manifest = json.loads(base64.b64decode(manifest_b64))
if manifest.get('encryptionType') not in (None, 'NONE'):
return []
urls = manifest.get('urls') or []
return [url for url in urls if isinstance(url, str) and url]
except Exception as e:
logger.debug("Failed to extract legacy HiFi track manifest URLs: %s", e)
return []
@staticmethod
def _extension_from_track_manifest(data: Any, fallback: str) -> str:
try:
inner = data.get('data', data) if isinstance(data, dict) else data
manifest = json.loads(base64.b64decode(inner.get('manifest') or ''))
mime = (manifest.get('mimeType') or '').lower()
codecs = (manifest.get('codecs') or '').lower()
if 'flac' in mime or 'flac' in codecs:
return 'flac'
if 'mp4' in mime or 'aac' in codecs:
return 'm4a'
except Exception as e:
logger.debug("Failed to infer legacy HiFi track manifest extension: %s", e)
return fallback
def check_instance_capabilities(self, url: str, timeout: int = 5) -> Dict[str, Any]:
"""Probe one public HiFi instance using the endpoints SoulSync needs."""
entry = {
'url': url,
'status': 'unknown',
'version': None,
'can_search': False,
'can_download': False,
}
try:
root = self.session.get(
f'{url}/',
timeout=timeout,
headers={'Accept': 'application/json'},
)
if not root.ok:
entry['status'] = f'error (HTTP {root.status_code})'
return entry
data = root.json()
entry['version'] = data.get('version') or data.get('data', {}).get('version')
entry['status'] = 'online'
search = self.session.get(
f'{url}/search/',
params={'s': 'test', 'limit': 1},
timeout=timeout,
)
entry['can_search'] = search.ok
manifest = self.session.get(
f'{url}/trackManifests/',
params={
'id': '1550546',
'formats': 'FLAC',
'usage': 'DOWNLOAD',
'manifestType': 'HLS',
'adaptive': 'true',
'uriScheme': 'HTTPS',
},
timeout=timeout,
)
entry['can_download'] = (
manifest.ok
and bool(self._extract_manifest_uri(manifest.json()))
)
if not manifest.ok:
entry['download_error'] = f'HTTP {manifest.status_code}'
elif not entry['can_download']:
legacy = self.session.get(
f'{url}/track/',
params={'id': '1550546', 'quality': 'LOSSLESS'},
timeout=timeout,
)
entry['can_download'] = (
legacy.ok
and bool(self._extract_track_manifest_urls(legacy.json()))
)
if not legacy.ok:
entry['download_error'] = f'HTTP {legacy.status_code}'
elif not entry['can_download']:
entry['download_error'] = 'No playable manifest URL'
else:
entry['download_probe'] = 'track'
else:
entry['download_probe'] = 'trackManifests'
except http_requests.exceptions.SSLError:
entry['status'] = 'ssl_error'
except http_requests.exceptions.ConnectTimeout:
entry['status'] = 'timeout'
except http_requests.exceptions.ConnectionError:
entry['status'] = 'offline'
except Exception as e:
entry['status'] = f'error ({type(e).__name__})'
return entry
def search_tracks(self, title: str = None, artist: str = None,
album: str = None, limit: int = 20) -> List[Dict]:
params = {'limit': limit}
@ -433,15 +558,12 @@ class HiFiClient(DownloadSourcePlugin):
data = self._api_get('/trackManifests/', params=params, timeout=20)
if not data:
return None
return self._get_legacy_track_manifest(track_id, quality)
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
uri = self._extract_manifest_uri(data)
if uri is None:
logger.warning("Failed to extract playlist URI from manifest response")
return self._get_legacy_track_manifest(track_id, quality)
if not uri:
logger.warning(f"No playlist URI in manifest for track {track_id}")
@ -488,6 +610,28 @@ class HiFiClient(DownloadSourcePlugin):
'quality': quality,
}
def _get_legacy_track_manifest(self, track_id: int, quality: str = 'lossless') -> Optional[Dict]:
q_info = HLS_QUALITY_MAP.get(quality, HLS_QUALITY_MAP['lossless'])
api_quality = TRACK_ENDPOINT_QUALITY_MAP.get(quality, 'LOSSLESS')
data = self._api_get('/track/', params={'id': track_id, 'quality': api_quality}, timeout=20)
if not data:
return None
direct_urls = self._extract_track_manifest_urls(data)
if not direct_urls:
logger.warning(f"No playable URL in legacy HiFi manifest for track {track_id}")
return None
extension = self._extension_from_track_manifest(data, q_info['extension'])
logger.info(f"HiFi legacy track manifest for track {track_id}: "
f"{len(direct_urls)} direct URL(s) ({quality})")
return {
'direct_urls': direct_urls,
'extension': 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:
@ -619,7 +763,13 @@ class HiFiClient(DownloadSourcePlugin):
return None
manifest_info = self._get_hls_manifest(track_id, quality=q_key)
if not manifest_info or not manifest_info.get('segment_uris'):
if (
not manifest_info
or (
not manifest_info.get('segment_uris')
and not manifest_info.get('direct_urls')
)
):
logger.warning(f"No HLS manifest at quality {q_key}, trying next")
continue
@ -628,16 +778,17 @@ class HiFiClient(DownloadSourcePlugin):
out_filename = f"{safe_name}.{extension}"
out_path = self.download_path / out_filename
is_flac = q_key in ('hires', 'lossless')
is_direct = bool(manifest_info.get('direct_urls'))
is_flac = q_key in ('hires', 'lossless') and not is_direct
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']
segment_uris = manifest_info.get('segment_uris') or manifest_info.get('direct_urls') or []
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)")
f"({total_segments} {'URL(s)' if is_direct else 'segments'})")
downloaded = 0
speed_start = time.time()

342
core/image_cache.py Normal file
View file

@ -0,0 +1,342 @@
"""Disk-backed image cache for browser-facing artwork URLs."""
from __future__ import annotations
import hashlib
import mimetypes
import os
import sqlite3
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional
from urllib.parse import urlparse
import requests
from config.settings import config_manager
from core.metadata.artwork import is_internal_image_host
from utils.logging_config import get_logger
logger = get_logger("image_cache")
DEFAULT_TTL_SECONDS = 30 * 24 * 60 * 60
DEFAULT_FAILED_TTL_SECONDS = 6 * 60 * 60
DEFAULT_MAX_DOWNLOAD_BYTES = 15 * 1024 * 1024
class ImageCacheError(Exception):
"""Raised when an image cannot be served from the cache."""
@dataclass
class CachedImage:
key: str
path: Path
mime_type: str
size: int
status: str
class ImageCache:
def __init__(
self,
cache_dir: str | os.PathLike[str],
*,
ttl_seconds: int = DEFAULT_TTL_SECONDS,
failed_ttl_seconds: int = DEFAULT_FAILED_TTL_SECONDS,
max_download_bytes: int = DEFAULT_MAX_DOWNLOAD_BYTES,
fetcher: Optional[Callable[..., requests.Response]] = None,
):
self.cache_dir = Path(cache_dir)
self.ttl_seconds = int(ttl_seconds)
self.failed_ttl_seconds = int(failed_ttl_seconds)
self.max_download_bytes = int(max_download_bytes)
self.fetcher = fetcher or requests.get
self.db_path = self.cache_dir / "image_cache.sqlite3"
self._db_lock = threading.RLock()
self._key_locks: dict[str, threading.Lock] = {}
self._key_locks_lock = threading.Lock()
self.cache_dir.mkdir(parents=True, exist_ok=True)
self._init_db()
def cache_url_for(self, url: str | None) -> str | None:
"""Register a URL and return its browser-facing cached path."""
if not url:
return None
if str(url).startswith("/api/image-cache/"):
return str(url)
if not self.is_cacheable_url(str(url)):
return str(url)
key = self.key_for_url(str(url))
now = time.time()
with self._db_lock:
with self._connect() as conn:
conn.execute(
"""
INSERT INTO image_cache
(key, original_url, status, created_at, updated_at, last_accessed,
expires_at, size, mime_type, file_path, last_error)
VALUES (?, ?, 'pending', ?, ?, ?, 0, 0, '', '', '')
ON CONFLICT(key) DO UPDATE SET
original_url=excluded.original_url,
last_accessed=excluded.last_accessed
""",
(key, str(url), now, now, now),
)
return f"/api/image-cache/{key}"
def get(self, key: str) -> CachedImage:
row = self._get_row(key)
if not row:
raise ImageCacheError("Image cache key not found")
return self.get_url(row["original_url"])
def get_url(self, url: str) -> CachedImage:
if not self.is_cacheable_url(url):
raise ImageCacheError("URL is not cacheable")
key = self.key_for_url(url)
lock = self._lock_for_key(key)
with lock:
row = self._get_row(key)
now = time.time()
if row and row["status"] == "ok" and row["file_path"]:
path = Path(row["file_path"])
if path.exists():
self._touch(key, now)
if float(row["expires_at"] or 0) > now:
return CachedImage(key, path, row["mime_type"] or "image/jpeg", int(row["size"] or 0), "hit")
try:
return self._fetch_and_store(url, key, now)
except Exception as exc:
if row and row["status"] == "ok" and row["file_path"]:
stale_path = Path(row["file_path"])
if stale_path.exists():
logger.warning("Serving stale cached image for %s after refresh failed: %s", key, exc)
self._record_error(key, str(exc), now, keep_status=True)
return CachedImage(
key,
stale_path,
row["mime_type"] or "image/jpeg",
int(row["size"] or 0),
"stale",
)
self._record_error(key, str(exc), now)
raise ImageCacheError(str(exc)) from exc
@staticmethod
def key_for_url(url: str) -> str:
return hashlib.sha256(url.encode("utf-8")).hexdigest()
@staticmethod
def is_cacheable_url(url: str) -> bool:
try:
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"}:
return False
if parsed.username or parsed.password:
return False
if not parsed.hostname:
return False
return True
except Exception:
return False
def _fetch_and_store(self, url: str, key: str, now: float) -> CachedImage:
if not self._is_fetch_allowed(url):
raise ImageCacheError("Image host is not allowed")
response = self.fetcher(
url,
timeout=10,
stream=True,
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": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
"Referer": "https://www.deezer.com/",
},
)
try:
if response.status_code != 200:
raise ImageCacheError(f"Upstream image returned HTTP {response.status_code}")
mime_type = (response.headers.get("Content-Type") or "image/jpeg").split(";", 1)[0].strip()
if not mime_type.startswith("image/"):
raise ImageCacheError(f"Upstream response is not an image: {mime_type}")
declared_size = response.headers.get("Content-Length")
try:
if declared_size and int(declared_size) > self.max_download_bytes:
raise ImageCacheError("Image exceeds configured size limit")
except ValueError:
pass
ext = mimetypes.guess_extension(mime_type) or ".img"
if ext == ".jpe":
ext = ".jpg"
path = self._path_for_key(key, ext)
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(path.suffix + ".tmp")
total = 0
try:
with open(tmp_path, "wb") as handle:
for chunk in response.iter_content(chunk_size=64 * 1024):
if not chunk:
continue
total += len(chunk)
if total > self.max_download_bytes:
raise ImageCacheError("Image exceeds configured size limit")
handle.write(chunk)
except Exception:
try:
tmp_path.unlink(missing_ok=True)
except Exception as cleanup_exc:
logger.debug("image_cache tmp cleanup failed: %s", cleanup_exc)
raise
if total <= 0:
raise ImageCacheError("Image response was empty")
os.replace(tmp_path, path)
expires_at = now + self.ttl_seconds
with self._db_lock:
with self._connect() as conn:
conn.execute(
"""
INSERT INTO image_cache
(key, original_url, status, created_at, updated_at, last_accessed,
expires_at, size, mime_type, file_path, last_error)
VALUES (?, ?, 'ok', ?, ?, ?, ?, ?, ?, ?, '')
ON CONFLICT(key) DO UPDATE SET
original_url=excluded.original_url,
status='ok',
updated_at=excluded.updated_at,
last_accessed=excluded.last_accessed,
expires_at=excluded.expires_at,
size=excluded.size,
mime_type=excluded.mime_type,
file_path=excluded.file_path,
last_error=''
""",
(key, url, now, now, now, expires_at, total, mime_type, str(path)),
)
return CachedImage(key, path, mime_type, total, "miss")
finally:
response.close()
def _path_for_key(self, key: str, extension: str) -> Path:
return self.cache_dir / key[:2] / key[2:4] / f"{key}{extension}"
def _is_fetch_allowed(self, url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"}:
return False
if parsed.username or parsed.password:
return False
if not parsed.hostname:
return False
# Internal hosts are explicitly supported because Plex/Jellyfin/Navidrome
# artwork often lives behind Docker/LAN-only URLs. Public hosts are allowed
# as image-only responses with size limits.
return bool(parsed.hostname) or is_internal_image_host(url)
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
def _init_db(self) -> None:
with self._db_lock:
with self._connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS image_cache (
key TEXT PRIMARY KEY,
original_url TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
last_accessed REAL NOT NULL,
expires_at REAL NOT NULL DEFAULT 0,
size INTEGER NOT NULL DEFAULT 0,
mime_type TEXT NOT NULL DEFAULT '',
file_path TEXT NOT NULL DEFAULT '',
last_error TEXT NOT NULL DEFAULT ''
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_image_cache_accessed ON image_cache(last_accessed)")
def _get_row(self, key: str) -> Optional[sqlite3.Row]:
with self._db_lock:
with self._connect() as conn:
return conn.execute("SELECT * FROM image_cache WHERE key = ?", (key,)).fetchone()
def _touch(self, key: str, now: float) -> None:
with self._db_lock:
with self._connect() as conn:
conn.execute("UPDATE image_cache SET last_accessed = ? WHERE key = ?", (now, key))
def _record_error(self, key: str, error: str, now: float, *, keep_status: bool = False) -> None:
status_sql = "status" if keep_status else "'failed'"
with self._db_lock:
with self._connect() as conn:
conn.execute(
f"""
UPDATE image_cache
SET status = {status_sql},
updated_at = ?,
last_accessed = ?,
expires_at = ?,
last_error = ?
WHERE key = ?
""",
(now, now, now + self.failed_ttl_seconds, error[:500], key),
)
def _lock_for_key(self, key: str) -> threading.Lock:
with self._key_locks_lock:
lock = self._key_locks.get(key)
if lock is None:
lock = threading.Lock()
self._key_locks[key] = lock
return lock
_image_cache: Optional[ImageCache] = None
_image_cache_lock = threading.Lock()
def get_image_cache() -> ImageCache:
global _image_cache
with _image_cache_lock:
if _image_cache is None:
cache_dir = config_manager.get("image_cache.path", "storage/image_cache")
if not os.path.isabs(cache_dir):
cache_dir = str(config_manager.base_dir / cache_dir)
_image_cache = ImageCache(
cache_dir,
ttl_seconds=int(config_manager.get("image_cache.ttl_seconds", DEFAULT_TTL_SECONDS)),
failed_ttl_seconds=int(config_manager.get("image_cache.failed_ttl_seconds", DEFAULT_FAILED_TTL_SECONDS)),
max_download_bytes=int(config_manager.get("image_cache.max_download_mb", 15)) * 1024 * 1024,
)
return _image_cache
def cached_image_url(url: str | None) -> str | None:
if not url or config_manager.get("image_cache.enabled", True) is False:
return url
try:
return get_image_cache().cache_url_for(url)
except Exception as exc:
logger.debug("image cache registration failed: %s", exc)
return url

View file

@ -52,6 +52,37 @@ _DEFAULT_LENGTH_TOLERANCE_S = 3.0
_LENGTH_TOLERANCE_LONG_TRACK_S = 5.0
_LONG_TRACK_THRESHOLD_S = 600.0 # 10 minutes
# Upper bound for the user-configurable override. Anything past 60s
# means the check is effectively off — cap defends against accidental
# nonsense like 9999 making logs misleading. Users who genuinely want
# to disable the check can set 60.
_MAX_USER_TOLERANCE_S = 60.0
def resolve_duration_tolerance(value: Any) -> Optional[float]:
"""Coerce a user-configured tolerance value to a float override.
Returns:
- None when value is missing / 0 / negative / unparseable, so
callers fall back to the auto-scaled defaults (3s/5s).
- float in (0, _MAX_USER_TOLERANCE_S] when value is a positive
numeric string or float clamped to the upper bound.
Pure helper. No I/O. Drives the `length_tolerance_s` override on
`check_audio_integrity`.
"""
if value is None:
return None
try:
parsed = float(value)
except (TypeError, ValueError):
return None
if parsed <= 0:
return None
if parsed > _MAX_USER_TOLERANCE_S:
return _MAX_USER_TOLERANCE_S
return parsed
@dataclass
class IntegrityResult:

View file

@ -29,8 +29,22 @@ 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."""
def move_to_quarantine(file_path: str, context: dict, reason: str, automation_engine=None, *, trigger: str = "unknown") -> str:
"""Move a file to the quarantine folder and write a metadata sidecar.
`trigger` identifies which check fired (`integrity` / `acoustid` /
`bit_depth` / `unknown`) and is persisted in the sidecar so
one-click Approve can set the matching `_skip_quarantine_check`
bypass when re-running the pipeline.
Sidecar also persists a JSON-safe snapshot of the full `context`
dict via `serialize_quarantine_context`, enabling in-place approve
without losing the matched-track metadata. Legacy sidecars (written
before this expansion) lack the `context` field Approve falls
back to `recover_to_staging` for those.
"""
from core.imports.quarantine import serialize_quarantine_context
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)
@ -56,6 +70,8 @@ def move_to_quarantine(file_path: str, context: dict, reason: str, automation_en
"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"),
"trigger": trigger,
"context": serialize_quarantine_context(context),
}
try:

View file

@ -32,9 +32,10 @@ from core.imports.context import (
get_import_track_info,
normalize_import_context,
)
from core.imports.file_integrity import check_audio_integrity
from core.imports.file_integrity import check_audio_integrity, resolve_duration_tolerance
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.quarantine import entry_id_from_quarantined_filename
from core.imports.side_effects import (
emit_track_downloaded,
record_download_provenance,
@ -79,6 +80,26 @@ __all__ = [
]
def _should_skip_quarantine_check(context: dict, check_name: str) -> bool:
bypass = context.get('_skip_quarantine_check')
if bypass == 'all':
return True
if isinstance(bypass, (list, tuple, set)):
return 'all' in bypass or check_name in bypass
return bypass == check_name
def _mark_task_quarantined(context: dict, quarantine_path: str | None) -> None:
if not quarantine_path:
return
task_id = context.get('task_id')
if not task_id:
return
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['quarantine_entry_id'] = entry_id_from_quarantined_filename(quarantine_path)
def build_import_pipeline_runtime(
*,
automation_engine: Any | None = None,
@ -155,11 +176,29 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
except Exception:
_expected_duration_ms = None
try:
integrity = check_audio_integrity(file_path, _expected_duration_ms)
except Exception as integrity_error:
logger.error(f"[Integrity] Check raised unexpectedly (continuing): {integrity_error}")
# User-configurable tolerance override. None = use built-in
# auto-scaled defaults (3s normal / 5s for tracks >10min). Set
# higher (e.g. 10) when matched files routinely drift from the
# source's reported duration (live recordings, alternate
# masterings, etc).
_duration_tolerance_override = resolve_duration_tolerance(
config_manager.get('post_processing.duration_tolerance_seconds', 0)
)
# User-approved quarantine restores can bypass quarantine gates
# for this one post-processing pass.
if _should_skip_quarantine_check(context, 'integrity'):
logger.info(f"[Integrity] Skipped (user approval) for {_basename}")
integrity = None
else:
try:
integrity = check_audio_integrity(
file_path,
_expected_duration_ms,
length_tolerance_s=_duration_tolerance_override,
)
except Exception as integrity_error:
logger.error(f"[Integrity] Check raised unexpectedly (continuing): {integrity_error}")
integrity = None
if integrity is not None and not integrity.ok:
logger.error(f"[Integrity] Rejected {_basename}: {integrity.reason}")
@ -171,7 +210,9 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
context,
f"Integrity check failed: {integrity.reason}",
automation_engine,
trigger='integrity',
)
_mark_task_quarantined(context, quarantine_path)
logger.error(f"File quarantined due to integrity failure: {quarantine_path}")
except Exception as quarantine_error:
logger.error(f"Quarantine failed ({quarantine_error}), deleting broken file: {file_path}")
@ -206,7 +247,9 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
f"drift={integrity.checks.get('length_drift_s', 'n/a')})"
)
_skip_acoustid = False
_skip_acoustid = _should_skip_quarantine_check(context, 'acoustid')
if _skip_acoustid:
logger.info(f"[AcoustID] Skipped (user approval) for {_basename}")
try:
from core.acoustid_verification import AcoustIDVerification, VerificationResult
@ -248,7 +291,9 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
context,
verification_msg,
automation_engine,
trigger='acoustid',
)
_mark_task_quarantined(context, quarantine_path)
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}")
@ -420,7 +465,10 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
if context['_audio_quality']:
logger.info(f"Audio quality detected: {context['_audio_quality']}")
rejection_reason = check_flac_bit_depth(file_path, context)
_skip_bit_depth = _should_skip_quarantine_check(context, 'bit_depth')
rejection_reason = None if _skip_bit_depth else check_flac_bit_depth(file_path, context)
if _skip_bit_depth:
logger.info(f"[BitDepth] Skipped (user approval) for {_basename}")
if rejection_reason:
try:
quarantine_path = move_to_quarantine(
@ -428,7 +476,9 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
context,
rejection_reason,
automation_engine,
trigger='bit_depth',
)
_mark_task_quarantined(context, quarantine_path)
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}")
@ -548,7 +598,10 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
if context['_audio_quality']:
logger.info(f"Audio quality detected: {context['_audio_quality']}")
rejection_reason = check_flac_bit_depth(file_path, context)
_skip_bit_depth = _should_skip_quarantine_check(context, 'bit_depth')
rejection_reason = None if _skip_bit_depth else check_flac_bit_depth(file_path, context)
if _skip_bit_depth:
logger.info(f"[BitDepth] Skipped (user approval) for {_basename}")
if rejection_reason:
try:
quarantine_path = move_to_quarantine(
@ -556,7 +609,9 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
context,
rejection_reason,
automation_engine,
trigger='bit_depth',
)
_mark_task_quarantined(context, quarantine_path)
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}")

405
core/imports/quarantine.py Normal file
View file

@ -0,0 +1,405 @@
"""Quarantine entry management — pure helpers for list/delete/approve/recover.
Quarantined files live in `<download_path>/ss_quarantine/` as
`<timestamp>_<original>.<ext>.quarantined` paired with a JSON sidecar
`<timestamp>_<original>.json` written by `core.imports.guards.move_to_quarantine`.
This module provides the read/write/restore primitives. Web routes are
thin glue around these. Pipeline re-run on approval is the caller's
job (we hand back `(file_path, context, bypass_check)`).
"""
from __future__ import annotations
import json
import os
import shutil
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from utils.logging_config import get_logger
logger = get_logger("imports.quarantine")
_QUARANTINE_SUFFIX = ".quarantined"
# JSON-serializable scalar predicate. dict / list values get walked
# recursively; anything else is dropped during sidecar serialization.
_SAFE_SCALARS = (str, int, float, bool, type(None))
def serialize_quarantine_context(context: Any) -> Dict[str, Any]:
"""Walk a context dict and emit a JSON-safe copy.
Drops non-serializable values (sets, custom objects, callables,
open file handles, etc) silently sidecar must round-trip through
`json.dump` / `json.load` without raising. Lists are walked element
by element; dicts are walked recursively. Anything that isn't a
scalar / dict / list is converted to a string fallback so caller
still sees *something* (rather than a silent drop) but won't break
the JSON write.
"""
if not isinstance(context, dict):
return {}
return _coerce_dict(context)
def _coerce_value(value: Any) -> Any:
if isinstance(value, _SAFE_SCALARS):
return value
if isinstance(value, dict):
return _coerce_dict(value)
if isinstance(value, (list, tuple)):
return [_coerce_value(v) for v in value]
if isinstance(value, set):
return [_coerce_value(v) for v in value]
# Fallback — preserve via str() so caller sees the value's shape
# without breaking JSON serialization.
try:
return str(value)
except Exception:
return None
def _coerce_dict(d: Dict[str, Any]) -> Dict[str, Any]:
out: Dict[str, Any] = {}
for key, value in d.items():
if not isinstance(key, str):
try:
key = str(key)
except Exception:
continue
out[key] = _coerce_value(value)
return out
def _entry_id_from_filename(quarantined_filename: str) -> str:
"""Derive a stable entry id from the quarantined filename.
Strip the `.quarantined` suffix; strip the original file extension;
return the bare `<timestamp>_<original>` stem. Sidecar uses the
same stem with a `.json` extension, so the id pairs both sides.
"""
base = quarantined_filename
if base.endswith(_QUARANTINE_SUFFIX):
base = base[: -len(_QUARANTINE_SUFFIX)]
return Path(base).stem
def entry_id_from_quarantined_filename(quarantined_filename: str) -> str:
"""Derive a quarantine entry id from a quarantined filename or path."""
return _entry_id_from_filename(os.path.basename(quarantined_filename))
def get_quarantined_source_keys(quarantine_dir: str) -> set:
"""Return a set of ``(username, filename)`` tuples for every Soulseek
source that has been quarantined.
Used to gate the Soulseek candidate filter against re-picking the
exact same upload that already failed post-download verification.
Issue #652 — without this gate, the auto-wishlist processor's
candidate ranking is deterministic, so the same `(uploader, file)`
keeps winning the quality picker, downloading, quarantining, and
re-queueing in an infinite loop. Users wake up to hundreds of
duplicate `.quarantined` files for the same source URL.
The keys come from the sidecar JSON's
``context.original_search_result`` field which `move_to_quarantine`
persists from the originating SearchResult. Sidecars missing either
field (legacy thin sidecars written pre-Feb 2026, or orphaned
files) are skipped silently they can't gate anything anyway.
Returns an empty set when the directory doesn't exist or has no
parseable sidecars. Never raises; filesystem / JSON errors are
swallowed at debug level so a corrupt sidecar can't block the
download pipeline.
"""
keys: set = set()
if not quarantine_dir or not os.path.isdir(quarantine_dir):
return keys
try:
names = os.listdir(quarantine_dir)
except OSError as exc:
logger.debug("get_quarantined_source_keys: listdir failed: %s", exc)
return keys
for name in names:
if not name.endswith('.json'):
continue
sidecar_path = os.path.join(quarantine_dir, name)
try:
with open(sidecar_path, encoding='utf-8') as f:
sidecar = json.load(f)
except Exception as exc:
logger.debug("get_quarantined_source_keys: sidecar read failed for %s: %s", name, exc)
continue
if not isinstance(sidecar, dict):
continue
ctx = sidecar.get('context')
if not isinstance(ctx, dict):
continue
osr = ctx.get('original_search_result')
if not isinstance(osr, dict):
continue
username = osr.get('username') or ''
filename = osr.get('filename') or ''
if username and filename:
keys.add((str(username), str(filename)))
return keys
def list_quarantine_entries(quarantine_dir: str) -> List[Dict[str, Any]]:
"""Enumerate quarantined files paired with their sidecars.
Returns one dict per `.quarantined` file with: id, filename,
original_filename (from sidecar), reason, expected_track,
expected_artist, timestamp, size_bytes, has_full_context (True
when the sidecar carries a `context` field required for one-click
Approve), trigger (which check fired: integrity / acoustid /
bit_depth / unknown).
Orphaned `.quarantined` files (no sidecar) still surface caller
can delete them. Orphaned sidecars (no file) are skipped silently.
Sorted newest-first by timestamp prefix.
"""
entries: List[Dict[str, Any]] = []
if not os.path.isdir(quarantine_dir):
return entries
for name in os.listdir(quarantine_dir):
if not name.endswith(_QUARANTINE_SUFFIX):
continue
full_path = os.path.join(quarantine_dir, name)
if not os.path.isfile(full_path):
continue
entry_id = _entry_id_from_filename(name)
sidecar_path = os.path.join(quarantine_dir, f"{entry_id}.json")
sidecar: Dict[str, Any] = {}
if os.path.isfile(sidecar_path):
try:
with open(sidecar_path, encoding="utf-8") as f:
loaded = json.load(f)
if isinstance(loaded, dict):
sidecar = loaded
except Exception as exc:
logger.debug("sidecar read failed for %s: %s", entry_id, exc)
try:
size_bytes = os.path.getsize(full_path)
except OSError:
size_bytes = 0
# Issue #608 follow-up (AfonsoG6): surface the source username
# + filename that was originally downloaded, so the user can see
# at a glance which uploader the bad file came from. Lives
# under `context.original_search_result` when full context is
# persisted; absent on legacy thin sidecars.
ctx = sidecar.get("context") if isinstance(sidecar.get("context"), dict) else {}
osr = ctx.get("original_search_result") if isinstance(ctx.get("original_search_result"), dict) else {}
source_username = osr.get("username", "") if isinstance(osr, dict) else ""
source_filename = osr.get("filename", "") if isinstance(osr, dict) else ""
entries.append(
{
"id": entry_id,
"filename": name,
"original_filename": sidecar.get("original_filename", name),
"reason": sidecar.get("quarantine_reason", "Unknown reason"),
"expected_track": sidecar.get("expected_track", ""),
"expected_artist": sidecar.get("expected_artist", ""),
"timestamp": sidecar.get("timestamp", ""),
"size_bytes": size_bytes,
"has_full_context": isinstance(sidecar.get("context"), dict),
"trigger": sidecar.get("trigger", "unknown"),
"source_username": source_username,
"source_filename": source_filename,
}
)
entries.sort(key=lambda e: e["id"], reverse=True)
return entries
def _resolve_entry_paths(quarantine_dir: str, entry_id: str) -> Tuple[Optional[str], Optional[str]]:
"""Locate the `.quarantined` file + JSON sidecar for an entry id.
Returns (file_path, sidecar_path), either may be None if missing.
"""
if not os.path.isdir(quarantine_dir) or not entry_id:
return None, None
file_path: Optional[str] = None
for name in os.listdir(quarantine_dir):
if not name.endswith(_QUARANTINE_SUFFIX):
continue
if _entry_id_from_filename(name) == entry_id:
file_path = os.path.join(quarantine_dir, name)
break
sidecar_path = os.path.join(quarantine_dir, f"{entry_id}.json")
if not os.path.isfile(sidecar_path):
sidecar_path = None
return file_path, sidecar_path
def delete_quarantine_entry(quarantine_dir: str, entry_id: str) -> bool:
"""Delete the quarantined file + sidecar for the given entry id.
Returns True if at least one of the two was removed. False when
neither existed (entry already gone).
"""
file_path, sidecar_path = _resolve_entry_paths(quarantine_dir, entry_id)
removed = False
if file_path and os.path.isfile(file_path):
try:
os.remove(file_path)
removed = True
except OSError as exc:
logger.error("Failed to delete quarantine file %s: %s", file_path, exc)
if sidecar_path and os.path.isfile(sidecar_path):
try:
os.remove(sidecar_path)
removed = True
except OSError as exc:
logger.error("Failed to delete quarantine sidecar %s: %s", sidecar_path, exc)
return removed
def _restore_filename(quarantined_filename: str, sidecar_original: Optional[str] = None) -> str:
"""Resolve the filename to restore.
Sidecar's `original_filename` wins when provided — it's the
canonical record of what the file was named before quarantine.
Otherwise parse the `<YYYYMMDD_HHMMSS>_<original>.<ext>.quarantined`
convention written by `move_to_quarantine`, dropping the timestamp
prefix and `.quarantined` suffix. Final fallback returns the
quarantined filename minus the suffix unchanged.
"""
if sidecar_original:
return sidecar_original
base = quarantined_filename
if base.endswith(_QUARANTINE_SUFFIX):
base = base[: -len(_QUARANTINE_SUFFIX)]
parts = base.split("_", 2)
if len(parts) >= 3 and parts[0].isdigit() and parts[1].isdigit():
return parts[2]
return base
def approve_quarantine_entry(
quarantine_dir: str,
entry_id: str,
restore_dir: str,
) -> Optional[Tuple[str, Dict[str, Any], str]]:
"""Restore a quarantined file for re-import via the post-process pipeline.
Reads the sidecar's `context` + `trigger`, moves the file out of
quarantine to `restore_dir` (with the original filename + extension),
deletes the sidecar.
Returns `(restored_file_path, context, trigger)` so the caller can
set the appropriate `_skip_quarantine_check` bypass flag and
dispatch the post-process pipeline.
Returns None when:
- the entry doesn't exist
- the sidecar lacks a serialized `context` (legacy thin sidecar
caller should fall back to `recover_to_staging` instead)
- the file move fails
"""
file_path, sidecar_path = _resolve_entry_paths(quarantine_dir, entry_id)
if not file_path or not sidecar_path:
logger.warning("approve: entry %s missing file or sidecar", entry_id)
return None
try:
with open(sidecar_path, encoding="utf-8") as f:
sidecar = json.load(f)
except Exception as exc:
logger.error("approve: sidecar read failed for %s: %s", entry_id, exc)
return None
context = sidecar.get("context")
if not isinstance(context, dict):
logger.info("approve: entry %s has thin sidecar (no context) — caller should recover-to-staging", entry_id)
return None
trigger = str(sidecar.get("trigger", "unknown"))
original_name = sidecar.get("original_filename") or _restore_filename(os.path.basename(file_path))
os.makedirs(restore_dir, exist_ok=True)
restored_path = os.path.join(restore_dir, original_name)
restored_path = _ensure_unique_path(restored_path)
try:
shutil.move(file_path, restored_path)
except OSError as exc:
logger.error("approve: failed to restore %s -> %s: %s", file_path, restored_path, exc)
return None
try:
os.remove(sidecar_path)
except OSError as exc:
logger.warning("approve: failed to remove sidecar %s: %s", sidecar_path, exc)
return restored_path, context, trigger
def recover_to_staging(
quarantine_dir: str,
staging_dir: str,
entry_id: str,
) -> Optional[str]:
"""Move a quarantined file into Staging for manual import.
Strips the timestamp prefix + `.quarantined` suffix, drops the file
into `staging_dir` so the user can finish via the existing Import
flow. Sidecar is removed. Used as the fallback path for legacy thin
sidecars (no embedded `context`) where one-click Approve is
impossible.
"""
file_path, sidecar_path = _resolve_entry_paths(quarantine_dir, entry_id)
if not file_path:
return None
sidecar_original = None
if sidecar_path:
try:
with open(sidecar_path, encoding="utf-8") as f:
sidecar_original = json.load(f).get("original_filename")
except Exception as exc:
logger.debug("recover: sidecar read failed for %s: %s", entry_id, exc)
restored_name = _restore_filename(os.path.basename(file_path), sidecar_original)
os.makedirs(staging_dir, exist_ok=True)
target = _ensure_unique_path(os.path.join(staging_dir, restored_name))
try:
shutil.move(file_path, target)
except OSError as exc:
logger.error("recover: failed to move %s -> %s: %s", file_path, target, exc)
return None
if sidecar_path and os.path.isfile(sidecar_path):
try:
os.remove(sidecar_path)
except OSError as exc:
logger.warning("recover: failed to remove sidecar %s: %s", sidecar_path, exc)
return target
def _ensure_unique_path(target: str) -> str:
"""Append `_(2)`, `_(3)`, ... before the extension when target exists."""
if not os.path.exists(target):
return target
base, ext = os.path.splitext(target)
counter = 2
while True:
candidate = f"{base}_({counter}){ext}"
if not os.path.exists(candidate):
return candidate
counter += 1

522
core/imports/routes.py Normal file
View file

@ -0,0 +1,522 @@
"""Import/staging controller helpers for Flask-style endpoints."""
from __future__ import annotations
import os
import uuid
from concurrent.futures import as_completed
from dataclasses import dataclass
from typing import Any, Callable, Dict
from core.imports.album import build_album_import_context, build_album_import_match_payload, resolve_album_artist_context
from core.imports.context import get_import_context_artist, get_import_track_info, normalize_import_context
from core.imports.filename import parse_filename_metadata
from core.imports.staging import (
AUDIO_EXTENSIONS,
get_import_suggestions_cache,
get_primary_source as _get_primary_source,
get_staging_path as _get_staging_path,
read_staging_file_metadata as _read_staging_file_metadata,
refresh_import_suggestions_cache as _refresh_import_suggestions_cache,
search_import_albums as _search_import_albums,
search_import_tracks as _search_import_tracks,
)
from utils.logging_config import get_logger
module_logger = get_logger("imports.routes")
def _default_read_tags(file_path: str):
from mutagen import File as MutagenFile
return MutagenFile(file_path, easy=True)
def _get_single_track_import_context(*args, **kwargs):
from core.imports.resolution import get_single_track_import_context
return get_single_track_import_context(*args, **kwargs)
@dataclass
class ImportRouteRuntime:
"""Dependencies needed to service import/staging HTTP endpoints."""
get_staging_path: Callable[[], str] = _get_staging_path
read_staging_file_metadata: Callable[[str, str], Dict[str, Any]] = _read_staging_file_metadata
read_tags: Callable[[str], Any] = _default_read_tags
get_primary_source: Callable[[], str] = _get_primary_source
search_import_albums: Callable[..., list] = _search_import_albums
search_import_tracks: Callable[..., list] = _search_import_tracks
build_album_import_match_payload: Callable[..., Dict[str, Any]] = build_album_import_match_payload
resolve_album_artist_context: Callable[..., Any] = resolve_album_artist_context
build_album_import_context: Callable[..., Dict[str, Any]] = build_album_import_context
get_single_track_import_context: Callable[..., Dict[str, Any]] = _get_single_track_import_context
parse_filename_metadata: Callable[[str], Dict[str, Any]] = parse_filename_metadata
normalize_import_context: Callable[[Dict[str, Any]], Dict[str, Any]] = normalize_import_context
get_import_context_artist: Callable[[Dict[str, Any]], Dict[str, Any]] = get_import_context_artist
get_import_track_info: Callable[[Dict[str, Any]], Dict[str, Any]] = get_import_track_info
process_single_import_file: Callable[["ImportRouteRuntime", Dict[str, Any]], tuple[str, str]] | None = None
post_process_matched_download: Callable[[str, Dict[str, Any], str], Any] | None = None
add_activity_item: Callable[[Any, Any, Any, Any], Any] | None = None
refresh_import_suggestions_cache: Callable[[], Any] = _refresh_import_suggestions_cache
automation_engine: Any = None
hydrabase_worker: Any = None
dev_mode_enabled: bool = False
import_singles_executor: Any = None
logger: Any = module_logger
def staging_files(runtime: ImportRouteRuntime) -> tuple[Dict[str, Any], int]:
"""Scan the staging folder and return audio files with tag metadata."""
try:
staging_path = runtime.get_staging_path()
os.makedirs(staging_path, exist_ok=True)
files = []
for root, _dirs, filenames in os.walk(staging_path):
for fname in filenames:
ext = os.path.splitext(fname)[1].lower()
if ext not in AUDIO_EXTENSIONS:
continue
full_path = os.path.join(root, fname)
rel_path = os.path.relpath(full_path, staging_path)
meta = runtime.read_staging_file_metadata(full_path, rel_path)
files.append(
{
"filename": fname,
"rel_path": rel_path,
"full_path": full_path,
"title": meta["title"],
"artist": meta["albumartist"] or meta["artist"] or "Unknown Artist",
"album": meta["album"],
"track_number": meta["track_number"],
"disc_number": meta["disc_number"],
"extension": ext,
}
)
files.sort(key=lambda f: f["filename"].lower())
return {"success": True, "files": files, "staging_path": staging_path}, 200
except Exception as exc:
runtime.logger.error("Error scanning staging files: %s", exc)
return {"success": False, "error": str(exc)}, 500
def staging_groups(runtime: ImportRouteRuntime) -> tuple[Dict[str, Any], int]:
"""Auto-detect album groups from staging files based on their tags."""
try:
staging_path = runtime.get_staging_path()
if not os.path.isdir(staging_path):
return {"success": True, "groups": []}, 200
album_groups = {}
for root, _dirs, filenames in os.walk(staging_path):
for fname in filenames:
ext = os.path.splitext(fname)[1].lower()
if ext not in AUDIO_EXTENSIONS:
continue
full_path = os.path.join(root, fname)
rel_path = os.path.relpath(full_path, staging_path)
meta = runtime.read_staging_file_metadata(full_path, rel_path)
album = meta["album"]
artist = meta["albumartist"] or meta["artist"]
if not album or not artist:
continue
key = (album.lower().strip(), artist.lower().strip())
if key not in album_groups:
album_groups[key] = {"album": album.strip(), "artist": artist.strip(), "files": []}
album_groups[key]["files"].append(
{
"filename": fname,
"full_path": full_path,
"title": meta["title"],
"track_number": meta["track_number"],
}
)
groups = []
for group in album_groups.values():
if len(group["files"]) >= 2:
group["files"].sort(key=lambda f: f.get("track_number") or 999)
groups.append(
{
"album": group["album"],
"artist": group["artist"],
"file_count": len(group["files"]),
"files": group["files"],
"file_paths": [f["full_path"] for f in group["files"]],
}
)
groups.sort(key=lambda g: g["file_count"], reverse=True)
return {"success": True, "groups": groups}, 200
except Exception as exc:
runtime.logger.error("Error building staging groups: %s", exc)
return {"success": False, "error": str(exc)}, 500
def staging_hints(runtime: ImportRouteRuntime) -> tuple[Dict[str, Any], int]:
"""Extract album search hints from staging folder tags and folder names."""
try:
staging_path = runtime.get_staging_path()
if not os.path.isdir(staging_path):
return {"success": True, "hints": []}, 200
tag_albums = {}
folder_hints = {}
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)
try:
tags = runtime.read_tags(full_path)
if tags:
album = (tags.get("album") or [None])[0]
artist = (tags.get("artist") or (tags.get("albumartist") or [None]))[0]
if album:
key = (album.strip(), (artist or "").strip())
tag_albums[key] = tag_albums.get(key, 0) + 1
except Exception as exc:
runtime.logger.debug("tag read failed: %s", exc)
queries = []
seen_queries_lower = set()
for (album, artist), _count in sorted(tag_albums.items(), key=lambda x: -x[1]):
query = f"{album} {artist}".strip() if artist else album
if query.lower() not in seen_queries_lower:
seen_queries_lower.add(query.lower())
queries.append(query)
for folder, _count in sorted(folder_hints.items(), key=lambda x: -x[1]):
query = folder.replace("_", " ")
if query.lower() not in seen_queries_lower:
seen_queries_lower.add(query.lower())
queries.append(query)
return {"success": True, "hints": queries[:5]}, 200
except Exception as exc:
runtime.logger.error("Error getting staging hints: %s", exc)
return {"success": False, "error": str(exc)}, 500
def staging_suggestions() -> tuple[Dict[str, Any], int]:
"""Return cached import suggestions and readiness state."""
cache = get_import_suggestions_cache()
return {
"success": True,
"suggestions": cache["suggestions"],
"ready": cache["built"],
"primary_source": _get_primary_source(),
}, 200
def search_albums(runtime: ImportRouteRuntime, query: str, limit: int = 12) -> tuple[Dict[str, Any], int]:
"""Search albums for manual import using the active metadata provider."""
try:
query = (query or "").strip()
if not query:
return {"success": False, "error": "Missing query parameter"}, 400
limit = min(int(limit), 50)
primary_source = runtime.get_primary_source()
if primary_source == "hydrabase" and runtime.hydrabase_worker and runtime.dev_mode_enabled:
runtime.hydrabase_worker.enqueue(query, "albums")
albums = runtime.search_import_albums(query, limit=limit)
return {"success": True, "albums": albums, "primary_source": primary_source}, 200
except Exception as exc:
runtime.logger.error("Error searching albums for import: %s", exc)
return {"success": False, "error": str(exc)}, 500
def album_match(runtime: ImportRouteRuntime, data: Dict[str, Any]) -> tuple[Dict[str, Any], int]:
"""Match staging files to an album's tracklist."""
try:
data = data or {}
album_id = data.get("album_id")
album_name = data.get("album_name", "")
album_artist = data.get("album_artist", "")
source = str(data.get("source") or "").strip().lower()
filter_file_paths = set(data.get("file_paths", []))
if not album_id:
return {"success": False, "error": "Missing album_id"}, 400
if not source:
runtime.logger.warning(
"[Import Match] Missing 'source' on album_id=%s - lookup will "
"guess via primary-source priority chain. If this fires "
"consistently, a frontend caller is dropping source from "
"the match POST body.",
album_id,
)
payload = runtime.build_album_import_match_payload(
album_id,
album_name=album_name,
album_artist=album_artist,
file_paths=filter_file_paths,
source=source or None,
)
return payload, 200
except Exception as exc:
runtime.logger.error("Error matching album for import: %s", exc)
return {"success": False, "error": str(exc)}, 500
def album_process(runtime: ImportRouteRuntime, data: Dict[str, Any]) -> tuple[Dict[str, Any], int]:
"""Process matched album files through the post-processing pipeline."""
try:
data = data or {}
album = data.get("album", {})
matches = data.get("matches", [])
if not album or not matches:
return {"success": False, "error": "Missing album or matches data"}, 400
if runtime.post_process_matched_download is None:
return {"success": False, "error": "Import post-processing not available"}, 500
processed = 0
errors = []
album_name = album.get("name", album.get("album_name", "Unknown Album"))
artist_name = album.get("artist", album.get("artist_name", "Unknown Artist"))
album_id = album.get("id", album.get("album_id", ""))
source = str(album.get("source") or data.get("source") or "").strip().lower()
total_discs = max(
(
match.get("track", {}).get("disc_number", 1)
for match in matches
if match.get("track")
),
default=1,
)
artist_context = runtime.resolve_album_artist_context(album, source=source)
for match in matches:
staging_file = match.get("staging_file")
track = match.get("track") or {}
if not staging_file or not track:
continue
file_path = staging_file.get("full_path", "")
if not os.path.isfile(file_path):
errors.append(f"File not found: {staging_file.get('filename', '?')}")
continue
track_name = track.get("name", "Unknown Track")
track_number = track.get("track_number", 1)
context_key = f"import_album_{album_id}_{track_number}_{uuid.uuid4().hex[:8]}"
context = runtime.build_album_import_context(
album,
track,
artist_context=artist_context,
total_discs=total_discs,
source=source,
)
try:
runtime.post_process_matched_download(context_key, context, file_path)
processed += 1
runtime.logger.info("Import processed: %s. %s from %s", track_number, track_name, album_name)
except Exception as proc_err:
err_msg = f"{track_name}: {str(proc_err)}"
errors.append(err_msg)
runtime.logger.error("Import processing error: %s", err_msg)
if runtime.add_activity_item:
runtime.add_activity_item("", "Album Imported", f"{album_name} by {artist_name} ({processed}/{len(matches)} tracks)", "Now")
if processed > 0:
_emit_import_completed(
runtime,
track_count=processed,
album_name=album_name or "",
artist=artist_name or "",
playlist_name=f"Import: {album_name}" if album_name else "Import",
total_tracks=len(matches),
failed_tracks=len(errors),
log_label="album",
)
runtime.refresh_import_suggestions_cache()
return {"success": True, "processed": processed, "total": len(matches), "errors": errors}, 200
except Exception as exc:
runtime.logger.error("Error processing album import: %s", exc)
return {"success": False, "error": str(exc)}, 500
def search_tracks(runtime: ImportRouteRuntime, query: str, limit: int = 10) -> tuple[Dict[str, Any], int]:
"""Search tracks for manual single import using metadata source priority."""
try:
query = (query or "").strip()
if not query:
return {"success": False, "error": "Missing query parameter"}, 400
limit = min(int(limit), 30)
primary_source = runtime.get_primary_source()
if primary_source == "hydrabase" and runtime.hydrabase_worker and runtime.dev_mode_enabled:
runtime.hydrabase_worker.enqueue(query, "tracks")
tracks = runtime.search_import_tracks(query, limit=limit)
return {"success": True, "tracks": tracks, "primary_source": primary_source}, 200
except Exception as exc:
runtime.logger.error("Error searching tracks for import: %s", exc)
return {"success": False, "error": str(exc)}, 500
def process_single_import_file(runtime: ImportRouteRuntime, file_info: Dict[str, Any]) -> tuple[str, str]:
"""Validate, resolve metadata, and post-process one single import file."""
file_path = file_info.get("full_path", "")
if not os.path.isfile(file_path):
return ("error", f"File not found: {file_info.get('filename', '?')}")
if runtime.post_process_matched_download is None:
return ("error", "Import post-processing not available")
title = file_info.get("title", "")
artist = file_info.get("artist", "")
manual_match = file_info.get("manual_match")
if manual_match is not None and not isinstance(manual_match, dict):
manual_match = None
manual_match_source = ""
manual_match_id = None
if manual_match:
manual_match_source = str(manual_match.get("source") or "").strip().lower()
manual_match_id = str(manual_match.get("id") or "").strip()
if not manual_match_id or not manual_match_source:
return ("error", f"Malformed manual match for file: {file_info.get('filename', '?')}")
if not title and not manual_match:
parsed = runtime.parse_filename_metadata(file_info.get("filename", ""))
title = parsed.get("title") or os.path.splitext(file_info.get("filename", "Unknown"))[0]
if not artist:
artist = parsed.get("artist", "")
try:
resolved = runtime.get_single_track_import_context(
title,
artist,
override_id=manual_match_id,
override_source=manual_match_source,
)
context = runtime.normalize_import_context(resolved["context"])
artist_data = runtime.get_import_context_artist(context)
track_data = runtime.get_import_track_info(context)
final_title = track_data.get("name", title)
final_artist = artist_data.get("name", artist)
context_key = f"import_single_{uuid.uuid4().hex[:8]}"
runtime.post_process_matched_download(context_key, context, file_path)
runtime.logger.info(
"Import single processed: %s by %s (source=%s)",
final_title,
final_artist,
resolved.get("source") or "local",
)
return ("ok", final_title)
except Exception as proc_err:
err_msg = f"{title}: {str(proc_err)}"
runtime.logger.error("Import single processing error: %s", err_msg)
return ("error", err_msg)
def singles_process(runtime: ImportRouteRuntime, files: list[Dict[str, Any]]) -> tuple[Dict[str, Any], int]:
"""Process individual staging files as singles through the import pipeline."""
try:
files = files or []
if not files:
return {"success": False, "error": "No files provided"}, 400
if runtime.import_singles_executor is None:
return {"success": False, "error": "Import executor not available"}, 500
processed = 0
errors = []
process_file = runtime.process_single_import_file or process_single_import_file
future_to_filename = {
runtime.import_singles_executor.submit(process_file, runtime, file_info):
file_info.get("filename", "?")
for file_info in files
}
for future in as_completed(future_to_filename):
try:
outcome, payload = future.result()
except Exception as worker_err:
errors.append(f"{future_to_filename[future]}: worker crashed: {worker_err}")
continue
if outcome == "ok":
processed += 1
else:
errors.append(payload)
if runtime.add_activity_item:
runtime.add_activity_item("", "Singles Imported", f"{processed}/{len(files)} tracks processed", "Now")
if processed > 0:
_emit_import_completed(
runtime,
track_count=processed,
album_name="",
artist="Various",
playlist_name="Import: Singles",
total_tracks=len(files),
failed_tracks=len(errors),
log_label="singles",
)
runtime.refresh_import_suggestions_cache()
return {"success": True, "processed": processed, "total": len(files), "errors": errors}, 200
except Exception as exc:
runtime.logger.error("Error processing singles import: %s", exc)
return {"success": False, "error": str(exc)}, 500
def _emit_import_completed(
runtime: ImportRouteRuntime,
*,
track_count: int,
album_name: str,
artist: str,
playlist_name: str,
total_tracks: int,
failed_tracks: int,
log_label: str,
) -> None:
# Keep import automation on the same chain as download batches:
# batch_complete -> auto-scan -> library_scan_completed -> auto-update DB.
try:
if runtime.automation_engine:
runtime.automation_engine.emit(
"import_completed",
{
"track_count": str(track_count),
"album_name": album_name,
"artist": artist,
},
)
runtime.automation_engine.emit(
"batch_complete",
{
"playlist_name": playlist_name,
"total_tracks": str(total_tracks),
"completed_tracks": str(track_count),
"failed_tracks": str(failed_tracks),
},
)
except Exception as exc:
runtime.logger.debug("%s import automation emit failed: %s", log_label, exc)

View file

@ -198,6 +198,10 @@ def record_library_history_download(context: Dict[str, Any]) -> None:
"deezer_dl": "Deezer",
"lidarr": "Lidarr",
"soundcloud": "SoundCloud",
"amazon": "Amazon",
"staging": "Staging",
"torrent": "Torrent",
"usenet": "Usenet",
# Auto-import isn't a download source, but flows through the
# same post-process pipeline (file lands → record provenance
# + history → write to library DB). Tagging it as "Auto-Import"
@ -235,7 +239,7 @@ def record_library_history_download(context: Dict[str, Any]) -> None:
source_track_id = search_result.get("track_id", "") or search_result.get("id", "") or ti.get("id", "")
source_track_title = search_result.get("title", "") or search_result.get("name", "")
source_artist = search_result.get("artist", "")
if source_filename and "||" in source_filename and username in ("tidal", "youtube", "qobuz", "hifi", "deezer_dl", "lidarr", "soundcloud"):
if source_filename and "||" in source_filename and username in ("tidal", "youtube", "qobuz", "hifi", "deezer_dl", "lidarr", "soundcloud", "amazon"):
stream_id = source_filename.split("||")[0]
if stream_id and not source_track_id:
source_track_id = stream_id
@ -276,6 +280,7 @@ def record_download_provenance(context: Dict[str, Any]) -> None:
"deezer_dl": "deezer",
"lidarr": "lidarr",
"soundcloud": "soundcloud",
"amazon": "amazon",
# Auto-import: surfaced in provenance so the redownload modal
# can tell the user "this came from staging on <date>" instead
# of falsely listing soulseek as the source. The underlying
@ -283,6 +288,16 @@ def record_download_provenance(context: Dict[str, Any]) -> None:
# separately via the source-aware ID columns on the tracks
# row itself.
"auto_import": "auto_import",
# Generic staging-match (user dropped files manually OR a
# source we don't have a more specific label for). Better
# than defaulting to 'soulseek' which would falsely tag the
# provenance.
"staging": "staging",
# Torrent / usenet album-bundle flow — the staging matcher
# overrides 'staging' with the bundle source so the history
# shows where the files actually came from.
"torrent": "torrent",
"usenet": "usenet",
}.get(username, "soulseek")
ti = context.get("track_info") or context.get("search_result") or {}

View file

@ -0,0 +1,283 @@
"""Manual library match service.
Lets users explicitly link a source track (wishlist/sync-history candidate) to
an existing library track so SoulSync stops trying to re-download it.
"""
from __future__ import annotations
import json
from typing import Any, Optional
from utils.logging_config import get_logger
logger = get_logger("library.manual_library_match")
def save_match(
db,
profile_id: int,
source: str,
source_track_id: str,
library_track_id: int,
**meta,
) -> bool:
"""Save (insert or replace) a manual match."""
return db.save_manual_library_match(
profile_id, source, source_track_id, library_track_id, **meta
)
def get_match(
db,
profile_id: int,
source: str,
source_track_id: str,
server_source: str = "",
) -> Optional[dict]:
"""Return match row dict or None if not found."""
getter = getattr(db, "get_manual_library_match", None)
if getter is None:
return None
return getter(profile_id, source, source_track_id, server_source)
def _first_artist_name(track: dict[str, Any]) -> str:
artists = track.get("artists") or []
if isinstance(artists, list) and artists:
first = artists[0]
if isinstance(first, dict):
return (first.get("name") or "").strip()
return str(first).strip()
return (track.get("artist") or track.get("artist_name") or "").strip()
def _track_source_candidates(track: dict[str, Any], default_source: str = "") -> list[str]:
candidates = [
track.get("provider"),
track.get("source"),
default_source,
"spotify",
]
out = []
for source in candidates:
source = (source or "").strip()
if source and source not in out:
out.append(source)
return out
def _track_id_candidates(track: dict[str, Any]) -> list[str]:
candidates = [
track.get("source_track_id"),
track.get("spotify_track_id"),
track.get("track_id"),
track.get("id"),
track.get("musicbrainz_recording_id"),
track.get("deezer_id") or track.get("deezer_track_id"),
track.get("itunes_track_id"),
track.get("tidal_id") or track.get("tidal_track_id"),
track.get("qobuz_id") or track.get("qobuz_track_id"),
track.get("amazon_id") or track.get("amazon_track_id"),
]
out = []
for value in candidates:
value = str(value).strip() if value is not None else ""
if value and value not in out:
out.append(value)
return out
def get_match_for_track(
db,
profile_id: int,
track: dict[str, Any],
*,
default_source: str = "",
server_source: str = "",
) -> Optional[dict]:
"""Return a manual match for a wishlist/sync track.
Exact source+ID matches are preferred, but source labels can legitimately
change between UI surfaces (for example ``mirrored`` in sync history versus
``wishlist`` in the wishlist batch). Fall back to track ID and finally
title/artist so saved manual matches are honored consistently.
"""
if not isinstance(track, dict):
return None
sources = _track_source_candidates(track, default_source)
track_ids = _track_id_candidates(track)
for track_id in track_ids:
for source in sources:
match = get_match(db, profile_id, source, track_id, server_source)
if match:
return match
id_getter = getattr(db, "find_manual_library_match_by_source_track_id", None)
if id_getter is not None:
for track_id in track_ids:
match = id_getter(profile_id, track_id, server_source)
if match:
return match
title = (track.get("name") or track.get("title") or track.get("track_name") or "").strip()
artist = _first_artist_name(track)
metadata_getter = getattr(db, "find_manual_library_match_by_metadata", None)
if metadata_getter is not None and title and artist:
return metadata_getter(profile_id, title, artist, server_source)
return None
def delete_match(db, match_id: int, profile_id: int) -> bool:
"""Delete match by PK id, scoped to profile."""
return db.delete_manual_library_match(match_id, profile_id)
def list_matches(db, profile_id: int, limit: int = 100) -> list[dict]:
"""Return all matches for profile, most-recently-updated first."""
rows = db.list_manual_library_matches(profile_id, limit)
return [_enrich_match(row, db) for row in rows]
def search_source_candidates(db, query: str, profile_id: int, limit: int = 15) -> list[dict]:
"""Search wishlist + sync history for source track candidates matching query."""
if not query or not query.strip():
return []
q = query.strip()
like = f"%{q}%"
results: dict[tuple, dict] = {}
# 1) Wishlist tracks
try:
with db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT
json_extract(spotify_data, '$.id') AS track_id,
json_extract(spotify_data, '$.name') AS title,
json_extract(spotify_data, '$.artists[0].name') AS artist,
json_extract(spotify_data, '$.album.name') AS album,
date_added AS added_at
FROM wishlist_tracks
WHERE profile_id = ?
AND (
json_extract(spotify_data, '$.name') LIKE ?
OR json_extract(spotify_data, '$.artists[0].name') LIKE ?
)
ORDER BY date_added DESC
LIMIT ?
""", (profile_id, like, like, limit * 2))
for row in cursor.fetchall():
r = dict(row)
if not r.get("track_id"):
continue
key = ("spotify", r["track_id"])
if key not in results:
results[key] = {
"source": "spotify",
"source_track_id": r["track_id"],
"title": r["title"] or "",
"artist": r["artist"] or "",
"album": r["album"] or "",
"context": "Wishlist",
"added_at": r["added_at"] or "",
}
except Exception as exc:
logger.debug("source_candidates wishlist query failed: %s", exc)
# 2) Sync history — scan tracks_json blobs
try:
with db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT playlist_name, source, tracks_json, started_at
FROM sync_history
ORDER BY started_at DESC
LIMIT 50
""")
for row in cursor.fetchall():
sh = dict(row)
try:
tracks = json.loads(sh["tracks_json"] or "[]")
except Exception:
continue
for t in tracks:
title = t.get("name", "")
artist = ""
artists = t.get("artists", [])
if artists:
first = artists[0]
artist = first.get("name", "") if isinstance(first, dict) else str(first)
if q.lower() not in title.lower() and q.lower() not in artist.lower():
continue
src = sh["source"] or "spotify"
tid = t.get("id") or t.get("spotify_track_id") or ""
if not tid:
continue
key = (src, tid)
if key not in results:
album = ""
alb = t.get("album")
if isinstance(alb, dict):
album = alb.get("name", "")
elif isinstance(alb, str):
album = alb
results[key] = {
"source": src,
"source_track_id": tid,
"title": title,
"artist": artist,
"album": album,
"context": sh["playlist_name"] or "",
"added_at": sh["started_at"] or "",
}
if len(results) >= limit * 3:
break
except Exception as exc:
logger.debug("source_candidates sync_history query failed: %s", exc)
# Sort by recency and cap
sorted_results = sorted(results.values(), key=lambda r: r.get("added_at", ""), reverse=True)
return sorted_results[:limit]
def search_library_candidates(db, query: str, limit: int = 15) -> list[dict[str, Any]]:
"""Search library tracks using the existing api_search_tracks method."""
if not query or not query.strip():
return []
q = query.strip()
# Pass the full query as title (covers most single-field searches).
# Also try as artist in parallel and merge, deduped by track id.
title_rows = db.api_search_tracks(title=q, limit=limit)
artist_rows = db.api_search_tracks(artist=q, limit=limit)
seen: set[int] = set()
merged = []
for row in title_rows + artist_rows:
rid = row.get('id')
if rid not in seen:
seen.add(rid)
merged.append(row)
if len(merged) >= limit:
break
return merged
def _enrich_match(match_row: dict, db) -> dict:
"""Add library track details to a match row."""
out = dict(match_row)
lib_id = match_row.get("library_track_id")
if lib_id:
try:
tracks = db.api_get_tracks_by_ids([lib_id])
if tracks:
t = tracks[0]
out["library_title"] = t.get("title", "")
out["library_artist"] = t.get("artist_name", "")
out["library_album"] = t.get("album_title", "")
out["library_file_path"] = t.get("file_path", "")
out["library_bitrate"] = t.get("bitrate")
except Exception as exc:
logger.debug("enrich_match track lookup failed for id=%s: %s", lib_id, exc)
return out

View file

@ -0,0 +1,605 @@
"""Import an existing library file into a missing album slot.
This module keeps the "I Have This" behavior out of the Flask route layer:
copy the selected source file, post-process it with target album metadata,
inherit album identity tags from target siblings, and write the real DB row.
"""
from __future__ import annotations
from dataclasses import dataclass
import logging
import os
import shutil
import uuid
from typing import Any, Callable, Dict, Optional
from core.library_reorganize import _build_post_process_context
logger = logging.getLogger("soulsync.library.missing_track_import")
class MissingTrackImportError(Exception):
"""Expected import failure that should be surfaced to the API caller."""
def __init__(self, message: str, status_code: int = 400):
super().__init__(message)
self.status_code = status_code
@dataclass
class MissingTrackImportDeps:
database: Any
config_manager: Any
post_process_fn: Callable[[str, dict, str], Any]
resolve_library_file_path_fn: Callable[[Optional[str]], Optional[str]]
docker_resolve_path_fn: Callable[[str], str]
sync_tracks_to_server_fn: Optional[Callable[[list, str], Any]] = None
service_id_columns: Optional[Dict[str, Dict[str, str]]] = None
_ALBUM_IDENTITY_TAGS = {
"album",
"albumartist",
"album_artist",
"date",
"year",
"tracktotal",
"totaltracks",
"totaldiscs",
"musicbrainz_albumid",
"musicbrainz_albumartistid",
"musicbrainz_releasegroupid",
"barcode",
"catalognumber",
"originaldate",
"releasecountry",
"releasestatus",
"releasetype",
"media",
"script",
"copyright",
"spotify_album_id",
"deezer_album_id",
"tidal_album_id",
"qobuz_album_id",
"itunes_album_id",
"audiodb_album_id",
}
_ID3_STANDARD_TAGS = {
"album": "TALB",
"albumartist": "TPE2",
"album_artist": "TPE2",
"date": "TDRC",
"year": "TDRC",
}
_ID3_TXXX_DESCS = {
"musicbrainz_albumid": "MusicBrainz Album Id",
"musicbrainz_albumartistid": "MusicBrainz Album Artist Id",
"musicbrainz_releasegroupid": "MusicBrainz Release Group Id",
"barcode": "BARCODE",
"catalognumber": "CATALOGNUMBER",
"originaldate": "ORIGINALDATE",
"releasecountry": "RELEASECOUNTRY",
"releasestatus": "RELEASESTATUS",
"releasetype": "RELEASETYPE",
"media": "MEDIA",
"script": "SCRIPT",
"totaldiscs": "TOTALDISCS",
"tracktotal": "TOTALTRACKS",
"totaltracks": "TOTALTRACKS",
"spotify_album_id": "Spotify Album Id",
"deezer_album_id": "Deezer Album Id",
"tidal_album_id": "Tidal Album Id",
"qobuz_album_id": "Qobuz Album Id",
"itunes_album_id": "iTunes Album Id",
"audiodb_album_id": "AudioDB Album Id",
}
_MP4_STANDARD_TAGS = {
"album": "\xa9alb",
"albumartist": "aART",
"album_artist": "aART",
"date": "\xa9day",
"year": "\xa9day",
}
def import_existing_track_for_album_slot(album_id: str, payload: dict, deps: MissingTrackImportDeps) -> dict:
source_track_id = payload.get("source_track_id") or payload.get("linked_track_id")
expected = payload.get("expected_track") or {}
if not source_track_id:
raise MissingTrackImportError("source_track_id is required", 400)
if not expected.get("track_number") or not (expected.get("title") or expected.get("name")):
raise MissingTrackImportError("expected_track with title and track_number is required", 400)
database = deps.database
album_data, source_track = _load_album_and_source_track(database, album_id, source_track_id)
if album_data.get("server_source") and source_track.get("server_source") and album_data["server_source"] != source_track["server_source"]:
raise MissingTrackImportError("Selected track belongs to a different library source", 400)
source_path = deps.resolve_library_file_path_fn(source_track.get("file_path"))
if not source_path:
raise MissingTrackImportError(_file_not_found_message(source_track.get("file_path")), 404)
staging_path = _copy_source_to_staging(source_path, album_id, expected, deps)
metadata_source = (expected.get("source") or payload.get("source") or "").strip().lower() or "library"
expected_title = expected.get("title") or expected.get("name") or "Unknown Track"
expected_track_id = _expected_track_id(expected)
album_source_id = _album_source_id(payload, expected, album_data, album_id)
api_track = _build_api_track(expected, expected_title, expected_track_id, album_source_id, metadata_source, album_data)
context = _build_context(payload, album_data, source_path, source_track_id, api_track, album_source_id, metadata_source)
context_key = f"existing_import_{album_id}_{api_track['disc_number']}_{api_track['track_number']}_{uuid.uuid4().hex[:8]}"
deps.post_process_fn(context_key, context, staging_path)
final_path = context.get("_final_processed_path")
if not final_path or not os.path.exists(final_path):
raise MissingTrackImportError("Post-processing did not produce a final file", 500)
ensure_tracks_disc_number_column(database)
copy_album_identity_from_target_sibling(
database,
album_id,
final_path,
api_track["disc_number"],
api_track["track_number"],
deps.resolve_library_file_path_fn,
)
target_track_id = _upsert_target_track(
database,
deps,
album_id,
album_data,
source_track,
final_path,
expected_title,
expected_track_id,
metadata_source,
api_track,
)
_sync_imported_track(deps, target_track_id, expected_title, album_data)
return {
"track_id": target_track_id,
"final_path": final_path,
"artist_id": album_data.get("target_artist_id"),
}
def _load_album_and_source_track(database, album_id: str, source_track_id: str) -> tuple[dict, dict]:
with database._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT al.*, ar.name AS artist_name, ar.id AS target_artist_id
FROM albums al
JOIN artists ar ON ar.id = al.artist_id
WHERE al.id = ?
""",
(album_id,),
)
album_row = cursor.fetchone()
if not album_row:
raise MissingTrackImportError("Album not found", 404)
cursor.execute("SELECT * FROM tracks WHERE id = ?", (source_track_id,))
source_row = cursor.fetchone()
if not source_row:
raise MissingTrackImportError("Selected library track not found", 404)
return dict(album_row), dict(source_row)
def _copy_source_to_staging(source_path: str, album_id: str, expected: dict, deps: MissingTrackImportDeps) -> str:
download_dir = deps.docker_resolve_path_fn(deps.config_manager.get("soulseek.download_path", "./downloads"))
staging_root = os.path.join(download_dir, "ssync_existing_import")
os.makedirs(staging_root, exist_ok=True)
source_ext = os.path.splitext(source_path)[1] or ".audio"
staging_name = (
f"existing_{album_id}_{expected.get('disc_number') or 1}_"
f"{expected.get('track_number')}_{uuid.uuid4().hex[:8]}{source_ext}"
)
staging_path = os.path.join(staging_root, staging_name)
shutil.copy2(source_path, staging_path)
return staging_path
def _build_api_track(
expected: dict,
expected_title: str,
expected_track_id: str,
album_source_id: str,
metadata_source: str,
album_data: dict,
) -> dict:
return {
"id": expected_track_id,
"track_id": expected_track_id,
"name": expected_title,
"title": expected_title,
"track_number": int(expected.get("track_number") or 1),
"disc_number": int(expected.get("disc_number") or 1),
"duration_ms": int(expected.get("duration") or expected.get("duration_ms") or 0),
"artists": expected.get("artists") or [album_data.get("artist_name") or ""],
"source": metadata_source,
"album_id": album_source_id,
"spotify_track_id": expected.get("spotify_track_id") or "",
"deezer_id": expected.get("deezer_id") or "",
"itunes_track_id": expected.get("itunes_track_id") or "",
"musicbrainz_recording_id": expected.get("musicbrainz_recording_id") or "",
}
def _build_context(
payload: dict,
album_data: dict,
source_path: str,
source_track_id: str,
api_track: dict,
album_source_id: str,
metadata_source: str,
) -> dict:
api_album = {
"id": album_source_id,
"name": album_data.get("title") or "",
"title": album_data.get("title") or "",
"release_date": f"{album_data.get('year')}-01-01" if album_data.get("year") else "",
"total_tracks": album_data.get("api_track_count") or album_data.get("track_count") or 0,
"image_url": album_data.get("thumb_url") or "",
"source": metadata_source,
}
context = _build_post_process_context(
api_album,
api_track,
album_data.get("artist_name") or "",
album_data.get("title") or "",
int(payload.get("total_discs") or payload.get("expected_track", {}).get("total_discs") or 1),
)
context["source"] = metadata_source
context["source_service"] = "existing_library"
context["source_filename"] = os.path.basename(source_path)
context["source_size"] = os.path.getsize(source_path) if os.path.exists(source_path) else 0
context["explicit_album_context"] = True
context["from_existing_library_track"] = True
context["batch_id"] = f"existing_import_{album_data.get('id')}_{uuid.uuid4().hex[:8]}"
context["task_id"] = f"existing_import_{source_track_id}"
return context
def _upsert_target_track(
database,
deps: MissingTrackImportDeps,
album_id: str,
album_data: dict,
source_track: dict,
final_path: str,
expected_title: str,
expected_track_id: str,
metadata_source: str,
api_track: dict,
):
file_size, bitrate = _read_file_stats(final_path, source_track)
server_source = album_data.get("server_source") or source_track.get("server_source") or deps.config_manager.get_active_media_server()
with database._get_connection() as conn:
cursor = conn.cursor()
_ensure_disc_number_column(cursor, conn)
cursor.execute("SELECT id FROM tracks WHERE file_path = ? LIMIT 1", (final_path,))
existing_by_path = cursor.fetchone()
cursor.execute(
"""
SELECT id FROM tracks
WHERE album_id = ? AND COALESCE(disc_number, 1) = ? AND track_number = ?
LIMIT 1
""",
(album_id, api_track["disc_number"], api_track["track_number"]),
)
existing_target = cursor.fetchone()
if existing_by_path:
target_track_id = existing_by_path["id"]
cursor.execute(
"""
UPDATE tracks
SET album_id = ?, artist_id = ?, title = ?, track_number = ?, disc_number = ?,
duration = ?, file_path = ?, bitrate = ?, file_size = ?,
server_source = COALESCE(server_source, ?),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(
album_id,
album_data.get("target_artist_id"),
expected_title,
api_track["track_number"],
api_track["disc_number"],
api_track["duration_ms"],
final_path,
bitrate,
file_size,
server_source,
target_track_id,
),
)
elif existing_target:
target_track_id = existing_target["id"]
cursor.execute(
"""
UPDATE tracks
SET title = ?, duration = ?, file_path = ?, bitrate = ?, file_size = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(expected_title, api_track["duration_ms"], final_path, bitrate, file_size, target_track_id),
)
else:
cursor.execute("SELECT COALESCE(MAX(CAST(id AS INTEGER)), 0) + 1 AS next_id FROM tracks")
target_track_id = cursor.fetchone()["next_id"]
cursor.execute(
"""
INSERT INTO tracks (
id, album_id, artist_id, title, track_number, disc_number, duration,
file_path, bitrate, file_size, server_source, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
(
target_track_id,
album_id,
album_data.get("target_artist_id"),
expected_title,
api_track["track_number"],
api_track["disc_number"],
api_track["duration_ms"],
final_path,
bitrate,
file_size,
server_source,
),
)
track_source_col = (deps.service_id_columns or {}).get(metadata_source, {}).get("track")
if track_source_col and expected_track_id:
try:
cursor.execute(f"UPDATE tracks SET {track_source_col} = ? WHERE id = ?", (expected_track_id, target_track_id))
except Exception as source_err:
logger.debug("Imported track source-id update failed: %s", source_err)
conn.commit()
return target_track_id
def _ensure_disc_number_column(cursor, conn) -> None:
cursor.execute("PRAGMA table_info(tracks)")
track_columns = {row[1] for row in cursor.fetchall()}
if "disc_number" not in track_columns:
cursor.execute("ALTER TABLE tracks ADD COLUMN disc_number INTEGER DEFAULT 1")
conn.commit()
def ensure_tracks_disc_number_column(database) -> None:
with database._get_connection() as conn:
cursor = conn.cursor()
_ensure_disc_number_column(cursor, conn)
def _read_file_stats(final_path: str, source_track: dict) -> tuple[Optional[int], int]:
file_size = None
bitrate = source_track.get("bitrate") or 0
try:
file_size = os.path.getsize(final_path)
from mutagen import File as MutagenFile
audio = MutagenFile(final_path)
if audio and getattr(audio, "info", None) and getattr(audio.info, "bitrate", None):
bitrate = int(audio.info.bitrate / 1000)
except Exception as meta_err:
logger.debug("Existing-track import metadata read failed: %s", meta_err)
return file_size, bitrate
def _sync_imported_track(deps: MissingTrackImportDeps, track_id, expected_title: str, album_data: dict) -> None:
try:
active_server = deps.config_manager.get_active_media_server()
if deps.sync_tracks_to_server_fn and active_server in ("jellyfin", "navidrome"):
deps.sync_tracks_to_server_fn(
[
{
"id": track_id,
"title": expected_title,
"artist_name": album_data.get("artist_name"),
"album_title": album_data.get("title"),
"year": album_data.get("year"),
"server_source": album_data.get("server_source"),
}
],
active_server,
)
except Exception as sync_err:
logger.debug("Existing-track import server sync skipped/failed: %s", sync_err)
def copy_album_identity_from_target_sibling(
database,
album_id: str,
final_path: str,
target_disc: int,
target_track: int,
resolve_library_file_path_fn: Callable[[Optional[str]], Optional[str]],
) -> bool:
try:
with database._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT file_path FROM tracks
WHERE album_id = ?
AND file_path IS NOT NULL
AND file_path != ''
AND NOT (COALESCE(disc_number, 1) = ? AND track_number = ?)
ORDER BY COALESCE(disc_number, 1), track_number
LIMIT 12
""",
(album_id, target_disc, target_track),
)
sibling_rows = cursor.fetchall()
for row in sibling_rows:
sibling_path = resolve_library_file_path_fn(row["file_path"])
if not sibling_path or not os.path.exists(sibling_path):
continue
tags = read_album_identity_tags(sibling_path)
if not tags:
continue
if write_album_identity_tags(final_path, tags):
logger.info("Imported track inherited album identity tags from sibling: %s", os.path.basename(sibling_path))
return True
except Exception as exc:
logger.warning("Failed to inherit album identity tags for imported track: %s", exc)
return False
def read_album_identity_tags(file_path: str) -> dict:
try:
from mutagen import File as MutagenFile
from mutagen.id3 import ID3, TXXX
from mutagen.mp4 import MP4
audio = MutagenFile(file_path)
if not audio:
return {}
tags = {}
if isinstance(getattr(audio, "tags", None), ID3):
for tag_key, frame_id in _ID3_STANDARD_TAGS.items():
frames = audio.tags.getall(frame_id)
if frames and getattr(frames[0], "text", None):
tags[tag_key] = str(frames[0].text[0]).strip()
desc_to_key = {desc: key for key, desc in _ID3_TXXX_DESCS.items()}
for frame in audio.tags.getall("TXXX"):
if isinstance(frame, TXXX) and frame.desc in desc_to_key and frame.text:
tags[desc_to_key[frame.desc]] = str(frame.text[0]).strip()
elif isinstance(audio, MP4):
for tag_key, mp4_key in _MP4_STANDARD_TAGS.items():
value = _first_tag_value(audio, mp4_key)
if value:
tags[tag_key] = value
for tag_key, desc in _ID3_TXXX_DESCS.items():
value = _first_tag_value(audio, f"----:com.apple.iTunes:{desc}")
if value:
tags[tag_key] = value
else:
for tag_key in _ALBUM_IDENTITY_TAGS:
value = _first_tag_value(audio, tag_key)
if value:
tags[tag_key] = value
return {k: v for k, v in tags.items() if v}
except Exception as exc:
logger.debug("Failed reading album identity tags from %s: %s", file_path, exc)
return {}
def write_album_identity_tags(file_path: str, tags: dict) -> bool:
if not tags:
return False
try:
from mutagen import File as MutagenFile
from mutagen.id3 import ID3, TALB, TDRC, TPE2, TXXX
from mutagen.mp4 import MP4, MP4FreeForm
audio = MutagenFile(file_path)
if not audio:
return False
tags = {k: str(v).strip() for k, v in tags.items() if k in _ALBUM_IDENTITY_TAGS and str(v).strip()}
if not tags:
return False
if isinstance(getattr(audio, "tags", None), ID3):
standard_frames = {"TALB": TALB, "TPE2": TPE2, "TDRC": TDRC}
written_standard = set()
for tag_key, frame_id in _ID3_STANDARD_TAGS.items():
value = tags.get(tag_key)
if not value or frame_id in written_standard:
continue
audio.tags.delall(frame_id)
audio.tags.add(standard_frames[frame_id](encoding=3, text=[value]))
written_standard.add(frame_id)
for tag_key, desc in _ID3_TXXX_DESCS.items():
value = tags.get(tag_key)
if not value:
continue
for existing in list(audio.tags.getall("TXXX")):
if getattr(existing, "desc", None) == desc:
audio.tags.remove(existing.HashKey)
audio.tags.add(TXXX(encoding=3, desc=desc, text=[value]))
elif isinstance(audio, MP4):
for tag_key, mp4_key in _MP4_STANDARD_TAGS.items():
if tags.get(tag_key):
audio[mp4_key] = [tags[tag_key]]
for tag_key, desc in _ID3_TXXX_DESCS.items():
if tags.get(tag_key):
audio[f"----:com.apple.iTunes:{desc}"] = [MP4FreeForm(tags[tag_key].encode("utf-8"))]
else:
for tag_key, value in tags.items():
audio[tag_key] = [value]
audio.save()
return True
except Exception as exc:
logger.warning("Failed writing album identity tags to %s: %s", file_path, exc)
return False
def _first_tag_value(audio, key: str) -> Optional[str]:
try:
values = audio.get(key)
if not values:
return None
value = values[0] if isinstance(values, (list, tuple)) else values
if isinstance(value, bytes):
value = value.decode("utf-8", errors="ignore")
return str(value).strip() or None
except Exception:
return None
def _expected_track_id(expected: dict) -> str:
return (
expected.get("track_id")
or expected.get("id")
or expected.get("source_track_id")
or expected.get("spotify_track_id")
or expected.get("deezer_id")
or expected.get("itunes_track_id")
or expected.get("musicbrainz_recording_id")
or ""
)
def _album_source_id(payload: dict, expected: dict, album_data: dict, album_id: str) -> str:
return (
payload.get("album_source_id")
or expected.get("album_id")
or album_data.get("spotify_album_id")
or album_data.get("deezer_id")
or album_data.get("itunes_album_id")
or album_data.get("musicbrainz_release_id")
or album_data.get("discogs_id")
or album_data.get("tidal_id")
or album_data.get("qobuz_id")
or str(album_id)
)
def _file_not_found_message(file_path: Optional[str]) -> str:
if file_path:
return f"File not found: {file_path}"
return "Selected library track does not have a file path"

View file

@ -0,0 +1,327 @@
"""Build reorganize-planning metadata from a file's embedded tags
instead of from a live metadata-source API call.
Issue #592 (tacobell444): when a library has been carefully enriched
+ tagged, doing a fresh API lookup at reorganize time can introduce
inconsistencies (provider naming drift, version-mismatches, missing
album-level metadata for niche releases). The user's own embedded
tags are usually the most stable source of truth for an enriched
library and using them costs zero API calls.
This module is the pure tag-to-context adapter. It turns the dict
that ``core.library.file_tags.read_embedded_tags`` returns into the
``api_album`` / ``api_track`` shapes that
``library_reorganize._build_post_process_context`` already consumes.
That keeps the downstream pipeline path-builder, post-process
helpers, AcoustID, etc.) completely unchanged: tag-mode just produces
the same input shape via a different upstream route.
Pure helpers no IO inside the extractors so every shape is
test-pinnable. The wrapper :func:`read_album_track_from_file` does
the file IO via ``read_embedded_tags`` and then routes through the
extractors.
Returns ``None`` (extractors) / ``(None, None, reason)`` (wrapper)
when the embedded tags are missing fields essential for reorganize
(track title, album name, or track artist). The plan layer surfaces
that as an unmatched item with a clear reason same UX as when the
metadata-API call returns no candidate. No silent degradation."""
from __future__ import annotations
import os
import re
from typing import Any, Dict, List, Optional, Tuple
# Tokens we accept as valid `releasetype` / `albumtype` values.
# Mirrors the canonical set the rest of the metadata pipeline uses
# (`core/metadata/album_tracks.py:_normalize_album_type`).
_VALID_ALBUM_TYPES = frozenset({'album', 'single', 'ep', 'compilation'})
# Match a 4-digit year anywhere in a date-like string ("2020",
# "2020-01-15", "2020/01/15", "Jan 5, 2020", etc.).
_YEAR_RE = re.compile(r'(\d{4})')
# Separators we split a single artist field on to recover a list.
# Mirrors the same separator set ``core/metadata/artist_resolution.py``
# uses when normalizing soulseek matched-download artist strings.
_ARTIST_SPLIT_RE = re.compile(
r'\s*(?:,|;|/|&| feat\. | feat | ft\. | ft | featuring | x | with )\s*',
re.IGNORECASE,
)
def _stringify(value: Any) -> str:
"""Coerce an embedded-tag value into a clean string."""
if value is None:
return ''
return str(value).strip()
def _parse_int_first(value: Any) -> Optional[int]:
"""Parse a track/disc number that may arrive as ``"5"``, ``"5/12"``,
``5``, ``5.0`` or even ``"05"``. Returns the leading integer, or
``None`` when no integer is recoverable.
Defensive against the trailing-``/N`` shape ID3 stores: ``TRCK =
"5/12"`` means "track 5 of 12", and we want ``5``."""
if value is None:
return None
if isinstance(value, (int,)):
return value
if isinstance(value, float):
return int(value)
s = _stringify(value)
if not s:
return None
head = s.split('/', 1)[0].strip()
try:
return int(head)
except (TypeError, ValueError):
try:
return int(float(head))
except (TypeError, ValueError):
return None
def _parse_int_total(value: Any) -> Optional[int]:
"""Parse the trailing ``N`` of an ID3-style ``"5/12"`` value, or
return the parsed value when it's a plain integer string."""
if value is None:
return None
if isinstance(value, int):
return value
s = _stringify(value)
if not s:
return None
if '/' in s:
tail = s.split('/', 1)[1].strip()
try:
return int(tail)
except (TypeError, ValueError):
return None
try:
return int(s)
except (TypeError, ValueError):
return None
def _normalize_year(value: Any) -> str:
"""Extract a 4-digit year from a date-like field. Returns '' when
no year is extractable. Reorganize templates only use the year
portion of release dates, so we don't need to preserve the full
date string."""
s = _stringify(value)
if not s:
return ''
m = _YEAR_RE.search(s)
return m.group(1) if m else ''
def _normalize_album_type(value: Any) -> str:
"""Lowercase + validate the ``releasetype`` tag against the canonical
token set. Returns '' for unknown values so the downstream path
builder falls back to its default."""
s = _stringify(value).lower()
if s in _VALID_ALBUM_TYPES:
return s
return ''
def _split_artists(value: Any) -> List[str]:
"""Split an artist-string field into a list. Handles common
separators (``,``, ``;``, ``/``, ``&``, ``feat``, ``ft``, ``x``,
``with``). Strips whitespace, drops empties, dedupes (case-
insensitive) while preserving order."""
s = _stringify(value)
if not s:
return []
parts = _ARTIST_SPLIT_RE.split(s)
seen: set = set()
out: List[str] = []
for p in parts:
cleaned = p.strip()
if not cleaned:
continue
key = cleaned.lower()
if key in seen:
continue
seen.add(key)
out.append(cleaned)
return out
def _resolve_track_artists(tags: Dict[str, Any]) -> List[str]:
"""Resolve the per-track artist list from embedded tags. Prefers a
multi-value ``artists`` tag (TXXX:Artists / Vorbis ``artists``)
over splitting the single-string ``artist`` tag, which is exactly
the precedence the post-download enrichment uses."""
artists_value = tags.get('artists')
if artists_value:
# Multi-value tag readers may already have joined with ', '.
# Re-split to recover the list.
parts = _split_artists(artists_value)
if parts:
return parts
return _split_artists(tags.get('artist') or '')
def extract_track_meta_from_tags(tags: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Build an ``api_track``-shaped dict from embedded tags.
Returns ``None`` if essential fields are missing (title or
artist). Caller surfaces that as an unmatched plan item.
Output shape matches what ``library_reorganize._build_post_process_context``
consumes (``name`` / ``track_number`` / ``disc_number`` /
``artists`` / ``duration_ms`` / ``id``)."""
if not isinstance(tags, dict) or not tags:
return None
title = _stringify(tags.get('title'))
if not title:
return None
artists = _resolve_track_artists(tags)
if not artists:
return None
track_number = _parse_int_first(tags.get('tracknumber')) or 1
disc_number = _parse_int_first(tags.get('discnumber')) or 1
return {
'name': title,
'title': title, # belt-and-braces — both keys are read downstream
'track_number': track_number,
'disc_number': disc_number,
'artists': [{'name': a} for a in artists],
'duration_ms': 0, # not derivable from tags alone; set later from `duration`
'id': '', # tag-mode has no source ID; reorganize doesn't need one
'uri': '',
}
def extract_album_meta_from_tags(tags: Dict[str, Any]) -> Dict[str, Any]:
"""Build an ``api_album``-shaped dict from embedded tags.
Falls back to empty / zero values when fields are missing the
path builder accepts those and uses its own defaults. The album
name is the only field we can't fall back on; if missing the
caller should treat the track as unmatched (handled by
:func:`read_album_track_from_file`)."""
if not isinstance(tags, dict):
tags = {}
album_name = _stringify(tags.get('album'))
album_artist = _stringify(tags.get('albumartist') or tags.get('album_artist'))
release_date = _normalize_year(tags.get('date') or tags.get('year') or tags.get('originaldate'))
total_tracks = (
_parse_int_total(tags.get('totaltracks'))
or _parse_int_total(tags.get('tracktotal'))
or _parse_int_total(tags.get('tracknumber')) # may be "5/12"
or 0
)
album_type = _normalize_album_type(tags.get('releasetype'))
# `total_discs` only comes from explicit total signals: a
# `totaldiscs` tag, or the trailing `/N` of an ID3-style
# `discnumber = "1/2"`. A bare `discnumber = "1"` carries no total
# and must NOT be treated as one (else single-disc albums would
# claim total=1 and the path builder would still skip the
# subfolder, but partial-album cases would underreport).
total_discs = _parse_int_total(tags.get('totaldiscs')) or 0
discnumber_raw = _stringify(tags.get('discnumber'))
if '/' in discnumber_raw:
explicit_total = _parse_int_total(discnumber_raw)
if explicit_total:
total_discs = max(total_discs, explicit_total)
return {
'id': '',
'album_id': '',
'name': album_name,
'title': album_name,
'release_date': release_date,
'total_tracks': total_tracks,
'total_discs': total_discs,
'image_url': '',
'images': [],
'album_artist': album_artist,
'album_type': album_type,
}
def read_album_track_from_file(
file_path: str,
*,
read_embedded_tags_fn=None,
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]], Optional[str]]:
"""Read embedded tags from ``file_path`` and produce
``(album_meta, track_meta, error_reason)``.
Returns ``(None, None, reason)`` when the file can't be opened,
has no recognisable tags, or is missing essential fields (title
or artist). The reason string is human-readable and suitable for
surfacing directly in the reorganize preview/error UI.
Args:
file_path: Resolved on-disk path to the audio file.
read_embedded_tags_fn: Optional override for the tag reader,
used by tests to avoid real mutagen IO. Defaults to
``core.library.file_tags.read_embedded_tags``."""
if not file_path or not isinstance(file_path, str):
return None, None, 'No file path on track row.'
if read_embedded_tags_fn is None:
from core.library.file_tags import read_embedded_tags as _real_reader
read_embedded_tags_fn = _real_reader
result = read_embedded_tags_fn(file_path)
if not isinstance(result, dict) or not result.get('available'):
reason = (result or {}).get('reason') if isinstance(result, dict) else None
return None, None, reason or 'Could not read embedded tags from file.'
tags = result.get('tags') or {}
track_meta = extract_track_meta_from_tags(tags)
if track_meta is None:
return None, None, 'Embedded tags missing required title or artist.'
album_meta = extract_album_meta_from_tags(tags)
if not album_meta.get('name'):
return None, None, 'Embedded tags missing album name.'
# Promote duration from the file-info block onto the track meta
# so the path builder has a non-zero value if a downstream
# consumer wants it.
duration_seconds = result.get('duration') or 0
try:
track_meta['duration_ms'] = int(float(duration_seconds) * 1000)
except (TypeError, ValueError):
track_meta['duration_ms'] = 0
return album_meta, track_meta, None
def normalize_resolved_path(file_path: Optional[str]) -> Optional[str]:
"""Defensive wrapper: returns the input only when it points at a
real file. Saves the caller from another ``os.path.exists`` check
in already-noisy code paths."""
if not file_path:
return None
try:
if not os.path.exists(file_path):
return None
except OSError:
return None
return file_path
__all__ = [
'extract_track_meta_from_tags',
'extract_album_meta_from_tags',
'read_album_track_from_file',
'normalize_resolved_path',
]

View file

@ -37,7 +37,7 @@ import os
import traceback
from dataclasses import dataclass
from difflib import SequenceMatcher
from typing import Any, Callable
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
@ -62,6 +62,13 @@ class RetagDeps:
_get_retag_state: Callable[[], dict]
_set_retag_state: Callable[[dict], None]
get_database: Callable[[], Any]
# Discord report (Netti93) — retag was clearing the LYRICS / USLT
# tag without rewriting it, while the download pipeline calls
# `generate_lrc_file` after enrichment to refetch + embed lyrics.
# Injected here so retag mirrors the same post-enrichment step.
# Optional for backward compat with any test caller that builds
# RetagDeps without the new field — empty default no-ops the call.
generate_lrc_file: Optional[Callable] = None
@property
def retag_state(self) -> dict:
@ -230,6 +237,20 @@ def execute_retag(group_id, album_id, deps: RetagDeps):
except Exception as meta_err:
logger.error(f"[Retag] Metadata write failed for '{track_title}': {meta_err}")
# Discord report (Netti93) — `enhance_file_metadata` clears
# ALL tags (incl. USLT lyrics) and rewrites only the source
# metadata. The download pipeline calls `generate_lrc_file`
# after enrichment to refetch + embed lyrics — retag was
# missing that step and dropped the LYRICS tag with no
# rewrite. Mirroring the download path's post-enrichment
# step. Same args, same `lrclib_enabled` config gate, same
# idempotency (skip when sidecar already present).
if deps.generate_lrc_file:
try:
deps.generate_lrc_file(current_file_path, context, new_artist, album_info)
except Exception as lrc_err:
logger.debug("[Retag] generate_lrc_file failed for '%s': %s", track_title, lrc_err)
# Compute new path and move if different
file_ext = os.path.splitext(current_file_path)[1]
try:

View file

@ -19,6 +19,7 @@ tidal_enrichment_worker = None
qobuz_enrichment_worker = None
discogs_worker = None
audiodb_worker = None
amazon_worker = None
def init(
@ -31,11 +32,12 @@ def init(
qobuz_worker=None,
discogs_worker_obj=None,
audiodb_worker_obj=None,
amazon_worker_obj=None,
):
"""Bind enrichment worker handles so the lifted bodies can use them."""
global spotify_enrichment_worker, itunes_enrichment_worker, mb_worker
global lastfm_worker, genius_worker, tidal_enrichment_worker
global qobuz_enrichment_worker, discogs_worker, audiodb_worker
global qobuz_enrichment_worker, discogs_worker, audiodb_worker, amazon_worker
spotify_enrichment_worker = spotify_worker
itunes_enrichment_worker = itunes_worker
mb_worker = musicbrainz_worker
@ -45,6 +47,7 @@ def init(
qobuz_enrichment_worker = qobuz_worker
discogs_worker = discogs_worker_obj
audiodb_worker = audiodb_worker_obj
amazon_worker = amazon_worker_obj
def _detect_provider(items, client):
@ -97,12 +100,14 @@ def _search_service(service, entity_type, query):
if not mb_worker or not mb_worker.mb_service:
raise ValueError("MusicBrainz worker not initialized")
mb_client = mb_worker.mb_service.mb_client
# User-facing manual search — prefer recall (fuzzy / alias / diacritic-
# folded) over strict phrase precision. User picks correct hit from list.
if entity_type == 'artist':
items = mb_client.search_artist(query, limit=8)
items = mb_client.search_artist(query, limit=8, strict=False)
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)
items = mb_client.search_release(query, limit=8, strict=False)
results = []
for r in items:
artists = ', '.join(ac.get('name', '') for ac in r.get('artist-credit', []) if isinstance(ac, dict))
@ -112,7 +117,7 @@ def _search_service(service, entity_type, query):
'extra': f"{artists} · {r.get('date', '')} · Score: {r.get('score', '')}"})
return results
elif entity_type == 'track':
items = mb_client.search_recording(query, limit=8)
items = mb_client.search_recording(query, limit=8, strict=False)
results = []
for r in items:
artists = ', '.join(ac.get('name', '') for ac in r.get('artist-credit', []) if isinstance(ac, dict))
@ -293,4 +298,22 @@ def _search_service(service, entity_type, query):
'image': None, 'extra': f"{result.get('strArtist', '')} · {result.get('strAlbum', '')}"}]
return []
elif service == 'amazon':
if not amazon_worker or not amazon_worker.client:
raise ValueError("Amazon worker not initialized")
client = amazon_worker.client
if entity_type == 'artist':
items = client.search_artists(query, limit=8)
return [{'id': str(a.id), 'name': a.name, 'image': a.image_url,
'extra': ', '.join(a.genres[:3]) if a.genres else ''} for a in items]
elif entity_type == 'album':
items = client.search_albums(query, limit=8)
return [{'id': str(a.id), 'name': a.name, 'image': a.image_url,
'extra': f"{', '.join(a.artists)} · {a.release_date or ''}"} for a in items]
elif entity_type == 'track':
items = client.search_tracks(query, limit=8)
return [{'id': str(t.id), 'name': t.name, 'image': t.image_url,
'extra': f"{', '.join(t.artists)} · {t.album or ''}"} for t in items]
return []
return []

View file

@ -549,17 +549,139 @@ def load_album_and_tracks(db, album_id):
pass
def _plan_from_tags(
album_data: dict,
tracks: List[dict],
resolve_file_path_fn: Optional[Callable[[Optional[str]], Optional[str]]],
) -> dict:
"""Tag-mode planner: build per-track ``api_track`` shapes from each
file's own embedded metadata instead of a live source API call.
Per-track behavior:
- File missing on disk unmatched with reason.
- Tags missing essentials (title / artist / album) unmatched
with reason.
- Otherwise matched with the per-file extracted ``api_track`` and
a per-file ``api_album``. The plan stores the FIRST matched
track's album dict on the top-level ``api_album`` field for
backward compatibility with downstream callers; downstream
consumers that need the per-track album shape read it off
``items[i]['api_album']``.
Returns the same status / source / api_album / total_discs / items
shape as :func:`plan_album_reorganize`. ``source`` is the literal
string ``'tags'`` so callers can distinguish from API sources."""
if resolve_file_path_fn is None:
# Without the file-path resolver we can't read anything off
# disk. Return an unmatched plan so callers surface a clear
# error instead of silently returning empty.
reason = 'Tag-mode reorganize requires the file path resolver.'
return {
'status': 'no_source_id', 'source': None, 'api_album': None,
'total_discs': 1,
'items': [{
'track': t, 'api_track': None, 'matched': False,
'reason': reason,
} for t in tracks],
}
from core.library.reorganize_tag_source import read_album_track_from_file
items: List[dict] = []
first_album_meta: Optional[dict] = None
max_disc = 1
for track in tracks:
db_path = track.get('file_path')
resolved = resolve_file_path_fn(db_path) if db_path else None
if not resolved:
items.append({
'track': track, 'api_track': None, 'api_album': None,
'matched': False,
'reason': 'File no longer exists on disk for this track.',
})
continue
album_meta, track_meta, err = read_album_track_from_file(resolved)
if err is not None or track_meta is None or album_meta is None:
items.append({
'track': track, 'api_track': None, 'api_album': None,
'matched': False,
'reason': err or 'Could not extract metadata from embedded tags.',
})
continue
if first_album_meta is None:
first_album_meta = album_meta
try:
disc = int(track_meta.get('disc_number') or 1)
except (TypeError, ValueError):
disc = 1
if disc > max_disc:
max_disc = disc
# Respect an explicit `totaldiscs` tag (or "1/2" disc-number
# form) so a partial-album reorganize (only disc 1 present
# locally) still routes into `Disc 1/` when the file's tags
# know there are 2 discs total.
try:
tagged_total = int(album_meta.get('total_discs') or 0)
except (TypeError, ValueError):
tagged_total = 0
if tagged_total > max_disc:
max_disc = tagged_total
items.append({
'track': track,
'api_track': track_meta,
'api_album': album_meta,
'matched': True,
'reason': None,
})
if not any(it['matched'] for it in items):
return {
'status': 'no_source_id',
'source': 'tags',
'api_album': None,
'total_discs': 1,
'items': items,
}
return {
'status': 'planned',
'source': 'tags',
'api_album': first_album_meta or {},
'total_discs': max_disc,
'items': items,
}
def plan_album_reorganize(
album_data: dict,
tracks: List[dict],
primary_source: Optional[str] = None,
strict_source: bool = False,
metadata_source: str = 'api',
resolve_file_path_fn: Optional[Callable[[Optional[str]], Optional[str]]] = None,
) -> dict:
"""Compute the per-track plan for an album reorganize without doing
any file IO. Both the actual reorganize orchestrator and the preview
endpoint share this so the preview is guaranteed to match what would
happen on apply.
``metadata_source``:
- ``'api'`` (default): query the configured metadata source(s)
for the canonical tracklist (existing behavior). Issues an
API call.
- ``'tags'``: read each file's embedded tags as the source of
truth (issue #592). Zero API calls; trusts the user's
enriched library.
When ``metadata_source='tags'``, ``resolve_file_path_fn`` MUST be
provided (the planner needs to read the actual files). The
``primary_source`` and ``strict_source`` params are ignored in
tag mode.
Returns:
``{'status': 'planned' | 'no_source_id' | 'no_tracks',
'source': str | None,
@ -581,6 +703,9 @@ def plan_album_reorganize(
'total_discs': 1, 'items': [],
}
if metadata_source == 'tags':
return _plan_from_tags(album_data, tracks, resolve_file_path_fn)
if primary_source is None:
try:
primary_source = get_primary_source()
@ -720,6 +845,7 @@ def preview_album_reorganize(
build_final_path_fn: Callable,
primary_source: Optional[str] = None,
strict_source: bool = False,
metadata_source: str = 'api',
) -> dict:
"""Compute the planned destination paths for a reorganize WITHOUT
moving any files. The preview UI uses this to show users what the
@ -775,6 +901,8 @@ def preview_album_reorganize(
plan = plan_album_reorganize(
album_data, tracks,
primary_source=primary_source, strict_source=strict_source,
metadata_source=metadata_source,
resolve_file_path_fn=resolve_file_path_fn,
)
artist_name = album_data.get('artist_name') or 'Unknown Artist'
album_title = album_data.get('title') or 'Unknown Album'
@ -836,8 +964,12 @@ def preview_album_reorganize(
item['disc_number'] = int(api_track.get('disc_number') or 1)
# Build the same context the orchestrator builds so the path
# builder produces the same destination it would on apply.
# Tag-mode plan items carry per-item album metadata; fall back
# to the shared api_album in API mode (where every plan item
# shares the same one).
per_item_album = plan_item.get('api_album') or api_album
context = _build_post_process_context(
api_album, api_track, artist_name, album_title, total_discs
per_item_album, api_track, artist_name, album_title, total_discs
)
# `_build_final_path_for_track` switches between ALBUM and SINGLE
# modes based on `album_info.get('is_album')` — must be passed,
@ -1034,13 +1166,18 @@ def _stage_track(ctx: _RunContext, track_id, title, resolved_src) -> Optional[st
return staging_file
def _run_post_process_for_track(ctx: _RunContext, track_id, title, api_track, staging_file) -> Optional[str]:
def _run_post_process_for_track(ctx: _RunContext, track_id, title, api_track, staging_file, *, per_item_api_album=None) -> Optional[str]:
"""Build the per-track context, hand it to post-processing, and
return the final on-disk path it produced. Returns None on any
failure (exception, AcoustID rejection, internal skip); the caller
leaves the original file alone."""
leaves the original file alone.
``per_item_api_album`` overrides ``ctx.api_album`` for this track
used in tag-mode reorganize where each file may carry its own
embedded album metadata."""
api_album = per_item_api_album if per_item_api_album else ctx.api_album
context = _build_post_process_context(
ctx.api_album, api_track, ctx.artist_name, ctx.album_title, ctx.total_discs
api_album, api_track, ctx.artist_name, ctx.album_title, ctx.total_discs
)
context_key = f"reorganize_{ctx.album_id}_{track_id}_{uuid.uuid4().hex[:8]}"
try:
@ -1138,7 +1275,10 @@ def _process_one_track(ctx: _RunContext, plan_item: dict) -> None:
if staging_file is None:
return
new_path = _run_post_process_for_track(ctx, track_id, title, plan_item['api_track'], staging_file)
new_path = _run_post_process_for_track(
ctx, track_id, title, plan_item['api_track'], staging_file,
per_item_api_album=plan_item.get('api_album'),
)
if new_path is None:
return
@ -1180,6 +1320,7 @@ def reorganize_album(
primary_source: Optional[str] = None,
strict_source: bool = False,
stop_check: Optional[Callable[[], bool]] = None,
metadata_source: str = 'api',
) -> dict:
"""Run a single album through the post-processing pipeline.
@ -1257,10 +1398,19 @@ def reorganize_album(
plan = plan_album_reorganize(
album_data, tracks,
primary_source=primary_source, strict_source=strict_source,
metadata_source=metadata_source,
resolve_file_path_fn=resolve_file_path_fn,
)
if plan['status'] == 'no_source_id':
summary['status'] = 'no_source_id'
if _is_unknown_artist(album_data.get('artist_name')) or _looks_like_album_id_title(album_data.get('title')):
summary['source'] = plan.get('source') # 'tags' or None
if plan.get('source') == 'tags':
err_text = (
f"No tracks of '{album_data.get('title', '?')}' have readable "
"embedded tags (missing title / artist / album, or file unreadable). "
"Switch back to API mode or fix the embedded tags first."
)
elif _is_unknown_artist(album_data.get('artist_name')) or _looks_like_album_id_title(album_data.get('title')):
err_text = (
f"Album '{album_data.get('title', '?')}' has placeholder metadata "
"(Unknown Artist or numeric title) — run the 'Fix Unknown Artists' "

View file

@ -52,9 +52,29 @@ class LyricsClient:
lrc_path = os.path.splitext(audio_file_path)[0] + '.lrc'
txt_path = os.path.splitext(audio_file_path)[0] + '.txt'
# Skip if lyrics file already exists (either .lrc or .txt)
# Sidecar already exists — skip the LRClib fetch but still
# re-embed the lyrics in the audio file's tag. The retag
# flow clears all tags including USLT and then runs this
# helper to restore them; without the embed step the
# LYRICS tag stays empty even though the .lrc is right
# there next to the file (Discord report — Netti93).
if os.path.exists(lrc_path) or os.path.exists(txt_path):
logger.debug(f"Lyrics file already exists for: {os.path.basename(audio_file_path)}")
existing_path = lrc_path if os.path.exists(lrc_path) else txt_path
try:
with open(existing_path, 'r', encoding='utf-8') as f:
existing_lyrics = f.read().strip()
if existing_lyrics:
self._embed_lyrics(audio_file_path, existing_lyrics)
logger.debug(
"Re-embedded lyrics from existing %s for: %s",
os.path.basename(existing_path),
os.path.basename(audio_file_path),
)
except Exception as e:
logger.debug(
"Could not re-embed lyrics from existing sidecar %s: %s",
os.path.basename(existing_path), e,
)
return True
# Fetch lyrics from LRClib

View file

@ -0,0 +1,143 @@
"""Find a matching AcoustID candidate for an expected (title, artist).
AcoustID returns multiple recordings per fingerprint same audio can
correspond to multiple MusicBrainz recordings (different releases,
different metadata-quality entries, sample / cover-version collisions).
The "top" recording AcoustID returns isn't always the one whose
metadata matches the user's expected track.
Both the post-download verifier (`core/acoustid_verification.py`) and
the AcoustID library scanner (`core/repair_jobs/acoustid_scanner.py`)
need to ask: "given these candidates, does ANY of them match
(expected_title, expected_artist) by title+artist similarity?" The
verifier had its own inline loop; the scanner only checked the top
match false positives whenever the wrong-credited recording out-
ranked the right-credited one.
This module is the single shared boundary for that question.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, Iterable, Optional, Tuple
from utils.logging_config import get_logger
logger = get_logger("matching.acoustid_candidates")
def find_matching_recording(
recordings: Iterable[Dict[str, Any]],
expected_title: str,
expected_artist: str,
*,
title_threshold: float = 0.70,
artist_threshold: float = 0.60,
similarity: Optional[Callable[[str, str], float]] = None,
artist_similarity: Optional[Callable[[str, str], float]] = None,
skip_predicate: Optional[Callable[[Dict[str, Any]], bool]] = None,
) -> Tuple[Optional[Dict[str, Any]], float, float]:
"""Return the first AcoustID candidate whose metadata passes both
title + artist similarity thresholds.
Args:
recordings: AcoustID recording dicts. Each must carry ``title``
and ``artist`` strings; entries without both are skipped.
expected_title: The track title the caller expected.
expected_artist: The artist the caller expected.
title_threshold: Minimum title similarity to accept (default 0.70).
artist_threshold: Minimum artist similarity to accept (default 0.60).
similarity: ``(a, b) -> float`` for title comparison. Defaults
to a lowercase exact-equals stub when not supplied callers
should pass their stricter normaliser (verifier passes its
parenthetical-stripping ``_similarity``; scanner passes
its own).
artist_similarity: ``(expected, actual) -> float`` for artist
comparison. Lets callers supply alias-aware comparison
(verifier wraps ``_alias_aware_artist_sim``; scanner wraps
``artist_names_match``). Defaults to ``similarity`` if
unset.
skip_predicate: Optional ``(recording_dict) -> bool``. When
truthy, the candidate is skipped (used by the verifier to
drop wrong-version recordings instrumental vs vocal etc).
Returns:
``(recording, title_sim, artist_sim)`` for the first matching
candidate, or ``(None, best_title_sim, best_artist_sim)`` when
none match. The non-None ``best_*`` values let callers report
the closest near-miss when they need to log why nothing matched.
Iteration order matches the input order (typically AcoustID's own
fingerprint-confidence ranking). Returns on first match does NOT
score every candidate looking for the highest sim.
"""
if not expected_title or not expected_artist:
return None, 0.0, 0.0
sim = similarity or _default_similarity
asim = artist_similarity or sim
best_title_sim = 0.0
best_artist_sim = 0.0
for rec in recordings or ():
if not isinstance(rec, dict):
continue
rec_title = (rec.get('title') or '').strip()
rec_artist = (rec.get('artist') or '').strip()
if not rec_title or not rec_artist:
continue
if skip_predicate and skip_predicate(rec):
continue
title_sim = sim(expected_title, rec_title)
if title_sim > best_title_sim:
best_title_sim = title_sim
artist_sim = asim(expected_artist, rec_artist)
if artist_sim > best_artist_sim:
best_artist_sim = artist_sim
if title_sim >= title_threshold and artist_sim >= artist_threshold:
return rec, title_sim, artist_sim
return None, best_title_sim, best_artist_sim
def _default_similarity(a: str, b: str) -> float:
if not a or not b:
return 0.0
return 1.0 if a.lower().strip() == b.lower().strip() else 0.0
# ────────────────────────────────────────────────────────────────────
# Duration guard — codex item (5).
# ────────────────────────────────────────────────────────────────────
def duration_mismatches_strongly(
expected_seconds: Optional[float],
candidate_seconds: Optional[float],
*,
abs_tolerance_s: float = 60.0,
rel_tolerance: float = 0.35,
) -> bool:
"""Return True when the candidate's duration is too far from expected
to confidently treat it as the same recording.
Catches fingerprint hash collisions (the reporter's 17-minute
mashup 5-minute Japanese hiphop track case). When EITHER duration
is unknown / non-positive, returns False no behavior change.
Threshold: drift greater than max(``abs_tolerance_s``,
``rel_tolerance * expected``). The relative term scales with track
length so a 20% mismatch on a 3-minute track and a 20% mismatch on
a 30-minute mix are both treated as suspicious.
"""
if not expected_seconds or expected_seconds <= 0:
return False
if not candidate_seconds or candidate_seconds <= 0:
return False
drift = abs(float(candidate_seconds) - float(expected_seconds))
threshold = max(abs_tolerance_s, rel_tolerance * float(expected_seconds))
return drift > threshold

View file

@ -0,0 +1,195 @@
"""Strip redundant album-context suffixes from track titles.
Issue #589 — MTV Unplugged albums (and similar live-concert / session
releases) have source-side track titles like ``"Shy Away (MTV Unplugged
Live)"`` while the local DB stored title is just ``"Shy Away"``. The
album-scoped library check at ``core/downloads/master.py`` compares
the two with raw string similarity, the length asymmetry tanks the
score, and tracks the user already owns get marked missing.
This helper normalizes a track title by stripping the parenthetical
or dash suffix when its tokens are fully subsumed by the album
context: at least one version marker (live / unplugged / acoustic /
session / etc) is present in BOTH the suffix AND the album title, and
every other suffix token is either a known marker, a year, a
connecting noise word, or a word that appears in the album title.
Pure function. No I/O. Tests at the function boundary.
"""
from __future__ import annotations
import re
from typing import Iterable, Tuple
# Version-marker keywords. When the album title contains any of these,
# stripping is enabled. Singular forms — plurals get matched separately
# via stem expansion below.
_VERSION_MARKERS = (
'live',
'unplugged',
'acoustic',
'session',
'concert',
'tour',
)
# Markers that are implied "live" context — when the album mentions any
# of these, a bare ``live`` token in the suffix counts as album context
# even if the album title doesn't literally say "live". MTV Unplugged
# albums are live recordings; same for "in concert" / "tour" releases.
_IMPLIES_LIVE = ('unplugged', 'concert', 'tour', 'session')
# Connecting / filler words that don't carry meaning by themselves.
_NOISE_TOKENS = frozenset({
'version', 'edition', 'recording', 'recordings', 'remaster',
'remastered', 'mix',
'the', 'a', 'an', 'from', 'at', 'in', 'on', 'for', 'of',
'and', 'or', 'with', 'by',
'vol', 'pt', 'part', 'no',
})
_SUFFIX_PATTERNS: Tuple[re.Pattern, ...] = (
re.compile(r'\s*\(([^()]+)\)\s*$'),
re.compile(r'\s*\[([^\[\]]+)\]\s*$'),
re.compile(r'\s+-\s+(.+?)\s*$'),
)
_YEAR_RE = re.compile(r'^(?:19|20)\d{2}$')
_TOKEN_RE = re.compile(r'\w+')
def _normalize(text: str) -> str:
return (text or '').lower().strip()
def _tokenize(text: str) -> set:
return set(_TOKEN_RE.findall(_normalize(text)))
def _expand_marker_set(markers: Iterable[str]) -> set:
"""Expand each marker into its singular + plural forms."""
out = set()
for marker in markers:
out.add(marker)
if not marker.endswith('s'):
out.add(marker + 's')
return out
_EXPANDED_MARKERS = _expand_marker_set(_VERSION_MARKERS)
def album_context_markers(album_title: str) -> Tuple[str, ...]:
"""Return the version markers present in the album title (singular form)."""
if not album_title:
return ()
album_tokens = _tokenize(album_title)
found = []
for marker in _VERSION_MARKERS:
if marker in album_tokens or (marker + 's') in album_tokens:
found.append(marker)
return tuple(found)
def _suffix_is_album_redundant(
inner: str,
album_tokens: set,
album_markers: Tuple[str, ...],
) -> bool:
"""Decide whether a suffix's tokens are all subsumed by album context.
Three requirements:
1. The suffix contains at least one version-marker token. Stops
a generic "feat. X" suffix from being stripped because the
album happened to be live.
2. The shared marker matches one the album implies either
literally in the album title, OR via the implied-live set
(unplugged/concert/tour albums imply "live").
3. Every other suffix token is either a marker, a year, a
tolerated noise word, or a word that appears in the album
title. If any token falls outside, the suffix carries
info beyond album context (featured artist, different
version, etc) keep it on.
"""
if not inner:
return False
suffix_tokens = _tokenize(inner)
if not suffix_tokens:
return False
# Markers the album effectively implies (literal + implied-live).
implied_markers = set(album_markers)
if any(m in implied_markers for m in _IMPLIES_LIVE):
implied_markers.add('live')
suffix_markers = suffix_tokens & _EXPANDED_MARKERS
if not suffix_markers:
return False
# At least one marker must overlap with album-implied set. Plural
# tolerance — strip trailing 's' for the comparison.
def _stem(tok: str) -> str:
return tok[:-1] if tok.endswith('s') and len(tok) > 1 else tok
if not any(_stem(t) in implied_markers for t in suffix_markers):
return False
# Every remaining suffix token must be subsumed.
for tok in suffix_tokens:
if tok in _EXPANDED_MARKERS:
continue
if _YEAR_RE.match(tok):
continue
if tok in _NOISE_TOKENS:
continue
if tok in album_tokens:
continue
return False
return True
def strip_redundant_album_suffix(track_title: str, album_title: str) -> str:
"""Strip a trailing parenthetical/bracket/dash suffix from `track_title`
when the suffix duplicates context already implied by `album_title`.
Examples:
- ("Shy Away (MTV Unplugged Live)", "MTV Unplugged") "Shy Away"
- ("Only If For A Night (MTV Unplugged, 2012 / Live)",
"Ceremonials (Live At MTV Unplugged)") "Only If For A Night"
- ("In My Feelings (Instrumental)", "Scorpion")
unchanged (instrumental NOT implied by studio album)
- ("Hello (Live - feat. Other)", "Live At Wembley")
unchanged (suffix carries featured-artist beyond album context)
- ("Shy Away", "MTV Unplugged") unchanged (no suffix)
Pure function never raises, returns the input unchanged on any
edge / unexpected input.
"""
if not track_title:
return track_title or ''
album_markers = album_context_markers(album_title)
if not album_markers:
return track_title
album_tokens = _tokenize(album_title)
stripped = track_title
# Stacked suffixes ("Track (MTV Unplugged) [Live]") — peel one at a
# time. Bound the loop defensively.
for _ in range(4):
peeled = None
for pattern in _SUFFIX_PATTERNS:
m = pattern.search(stripped)
if not m:
continue
inner = m.group(1)
if _suffix_is_album_redundant(inner, album_tokens, album_markers):
peeled = stripped[: m.start()].rstrip()
break
if peeled is None:
return stripped
stripped = peeled
return stripped

View file

@ -0,0 +1,115 @@
"""Decide when an AcoustID version-annotation mismatch should still
pass verification.
Issue #607 (AfonsoG6): live recordings were quarantining as
"Version mismatch: expected '... (Live at Venue)' (live) but file is
'Song' (original)" because MusicBrainz often stores the recording
entity with a bare title the venue / live annotation lives on the
release entity, not the recording. The audio fingerprint correctly
identifies the live recording (live audio has its own distinct
fingerprint), but the title-text comparison flagged it as the wrong
version.
Strict version mismatching stays for genuinely-different recordings
(instrumental vs vocal, remix vs original, acoustic vs studio)
those have distinct fingerprints AND MB always annotates them in the
recording title. We only loosen for the **live** direction
specifically, since that's the asymmetry users actually hit:
- LIVE: MB often stores live recordings with bare titles. The
fingerprint correctly identifies the live recording even when the
recording's text title lacks a "(Live at ...)" annotation.
- INSTRUMENTAL / REMIX / ACOUSTIC / DEMO / etc: MB always carries
the version marker in the recording title. If AcoustID returns an
instrumental for a vocal file query (or vice versa), it's a real
wrong-recording match the fingerprint matched the instrumental
audio, which IS the wrong file.
Two-sided version mismatches stay strict "live" vs "remix" really
are different recordings even if MB titled them similarly."""
from __future__ import annotations
_BARE_VERSION = 'original'
# Versions where a one-sided annotation difference is plausibly
# explained by MB metadata gaps rather than a different recording.
# Live recordings are the primary case; venue annotations live on the
# release-track entity, not the recording entity, so the recording can
# be bare-titled even though the audio is genuinely live.
_LIVE_AWARE_VERSIONS = frozenset({'live'})
def is_acceptable_version_mismatch(
expected_version: str,
matched_version: str,
*,
fingerprint_score: float,
title_similarity: float,
artist_similarity: float,
score_threshold: float = 0.85,
title_threshold: float = 0.70,
artist_threshold: float = 0.60,
) -> bool:
"""Return True when an expected-vs-matched version annotation
difference is likely a MusicBrainz metadata gap rather than a
genuinely different recording.
Conditions (all must hold):
1. The mismatch is **one-sided AND involves a live-aware version**
exactly one side is ``'live'`` and the other is bare
``'original'``. Other version markers (instrumental, remix,
acoustic, etc) carry distinct fingerprints AND are always
annotated in MB's recording title — we don't loosen for them.
2. Fingerprint score ``>= score_threshold`` (default 0.85). The
AcoustID fingerprint is high-confidence we trust it.
3. Bare title similarity ``>= title_threshold`` (default 0.70).
After the version annotation is stripped, the underlying titles
agree.
4. Artist similarity ``>= artist_threshold`` (default 0.60). Same
artist credit.
When ``expected_version == matched_version`` returns ``True``
trivially no mismatch to decide.
Examples that ACCEPT (return True):
- expected=``'live'``, matched=``'original'``, fp=0.95, title=0.95,
artist=1.0 typical live-recording MB-bare-title case (issue
#607 example 2).
- expected=``'original'``, matched=``'live'`` same case in the
other direction.
Examples that REJECT (return False):
- expected=``'instrumental'``, matched=``'original'`` fingerprint
matched the instrumental recording; if user asked for vocal, the
file is genuinely wrong. Stays strict regardless of confidence.
- expected=``'remix'``, matched=``'original'`` same logic.
- expected=``'live'``, matched=``'remix'`` both versioned,
both different. Real mismatch.
- expected=``'live'``, matched=``'original'``, fp=0.50 one-sided
live but low confidence. Fall through to FAIL.
- expected=``'live'``, matched=``'original'``, fp=0.95 but title
sim 0.30 bare titles don't agree. Different song.
"""
if expected_version == matched_version:
return True
one_sided_live = (
(expected_version in _LIVE_AWARE_VERSIONS and matched_version == _BARE_VERSION)
or (expected_version == _BARE_VERSION and matched_version in _LIVE_AWARE_VERSIONS)
)
if not one_sided_live:
return False
return (
fingerprint_score >= score_threshold
and title_similarity >= title_threshold
and artist_similarity >= artist_threshold
)
__all__ = ['is_acceptable_version_mismatch']

View file

@ -31,6 +31,7 @@ from core.metadata.registry import (
get_discogs_client,
get_hydrabase_client,
get_itunes_client,
get_musicbrainz_client,
get_primary_client,
get_primary_source,
get_spotify_client_for_profile,
@ -82,6 +83,7 @@ __all__ = [
"get_metadata_cache",
"get_metadata_source_status",
"get_metadata_service",
"get_musicbrainz_client",
"get_musicmap_similar_artists",
"get_primary_client",
"get_primary_source",

View file

@ -0,0 +1,74 @@
"""Pure artist-list resolution for tag-write paths.
Single source of truth for "what is the canonical multi-value artists
list for this track?" Different download paths populate `context` with
different keys Deezer-direct downloads stamp `original_search.artists`
as a proper list, but Soulseek matched downloads only carry `artist`
(singular string) in `original_search_result` while the full list lives
on `track_info` (the full Spotify track object).
Resolution order:
1. `context.original_search_result.artists` (preferred already-
curated by the source path that constructed the context)
2. `context.track_info.artists` (Spotify/Deezer/Tidal full track
object always carries the artists array when matched)
3. `[artist_dict.name]` as a single-element fallback when neither
carries a list (primary-artist-only)
Each list item may be a dict with a `name` key (Spotify shape), a bare
string, or any other object the helper normalizes all three to
strings and drops empty entries.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
def _normalize_artists_iterable(items: Any) -> List[str]:
if not isinstance(items, list):
return []
result: List[str] = []
for item in items:
if isinstance(item, dict):
name = item.get("name")
if isinstance(name, str) and name.strip():
result.append(name.strip())
elif isinstance(item, str):
stripped = item.strip()
if stripped:
result.append(stripped)
elif item is not None:
text = str(item).strip()
if text:
result.append(text)
return result
def resolve_track_artists(
original_search: Optional[Dict[str, Any]],
track_info: Optional[Dict[str, Any]],
artist_dict: Optional[Dict[str, Any]],
) -> List[str]:
"""Return the canonical multi-value artists list for tag-write.
Falls through preferred track_info primary-artist fallback. Each
candidate is normalized to a list of stripped non-empty strings.
Empty list returned only when every candidate is empty/invalid.
"""
if isinstance(original_search, dict):
primary = _normalize_artists_iterable(original_search.get("artists"))
if primary:
return primary
if isinstance(track_info, dict):
secondary = _normalize_artists_iterable(track_info.get("artists"))
if secondary:
return secondary
if isinstance(artist_dict, dict):
name = artist_dict.get("name")
if isinstance(name, str) and name.strip():
return [name.strip()]
return []

View file

@ -28,6 +28,54 @@ __all__ = [
logger = _create_logger("metadata.artwork")
# Query-string keys whose values must be masked when a media-server
# URL ends up in a log line. Plex uses X-Plex-Token, Jellyfin uses
# X-Emby-Token / api_key, Navidrome's Subsonic auth uses t (token) +
# s (salt) + p (password fallback). Logs end up persisted to disk —
# leaking any of these gives full read access to the user's library.
_REDACT_QUERY_KEYS = (
'x-plex-token', 'x-emby-token', 'api_key', 'apikey',
't', 's', 'p', 'token', 'password',
)
_REDACT_KEYS_ALT = '|'.join(re.escape(k) for k in _REDACT_QUERY_KEYS)
# Plain form: `?key=value` or `&key=value`. Anchored on `?` / `&` (or
# string start) so short keys like `t` only match at parameter
# boundaries — not as a substring of `format=Jpg`.
_REDACT_QUERY_RE = re.compile(
r'(?i)(?P<lead>^|[?&])(?P<key>' + _REDACT_KEYS_ALT + r')=(?P<val>[^&\s]+)'
)
# URL-encoded form: `%3Fkey%3Dvalue` or `%26key%3Dvalue`. The image
# proxy wraps the original URL via `?url=<encoded>`, so the auth
# params end up encoded inside another URL. Without this second pass
# the encoded form survives plain redaction and ships to logs intact.
_REDACT_QUERY_RE_ENCODED = re.compile(
r'(?i)(?P<lead>%3F|%26)(?P<key>' + _REDACT_KEYS_ALT + r')%3D(?P<val>[^%&\s]+?)(?=%26|&|\s|$)'
)
def _redact_url_secrets(url: str | None) -> str:
"""Mask sensitive query parameters in a URL so the result is safe
to log. Handles both the plain form (``?token=abc``) and the URL-
encoded form (``%3Ftoken%3Dabc``) the latter shows up when an
auth-bearing URL is wrapped inside another URL's query string
(e.g. our `/api/image-proxy?url=<encoded-plex-url>` flow).
Returns ``''`` for None/empty input. Idempotent (safe to call on
already-redacted strings)."""
if not url:
return ''
out = str(url)
out = _REDACT_QUERY_RE.sub(
lambda m: f"{m.group('lead')}{m.group('key')}=***REDACTED***",
out,
)
out = _REDACT_QUERY_RE_ENCODED.sub(
lambda m: f"{m.group('lead')}{m.group('key')}%3D***REDACTED***",
out,
)
return out
def normalize_image_url(thumb_url: str | None) -> str | None:
"""Convert media-server image URLs into browser-safe URLs."""
if not thumb_url:
@ -62,7 +110,6 @@ def normalize_image_url(thumb_url: str | None) -> str | None:
plex_config = cfg.get_plex_config()
plex_base_url = plex_config.get('base_url', '')
plex_token = plex_config.get('token', '')
logger.info("Plex config - base_url: %s, token: %s...", plex_base_url, plex_token[:10])
if plex_base_url and plex_token:
# Extract the path from URL
@ -76,18 +123,13 @@ def normalize_image_url(thumb_url: str | None) -> str | None:
# Construct proper Plex URL with token
fixed_url = f"{plex_base_url.rstrip('/')}{path}?X-Plex-Token={plex_token}"
logger.info("Fixed URL: %s", fixed_url)
logger.debug("Fixed URL: %s", _redact_url_secrets(fixed_url))
return _browser_safe_image_url(fixed_url)
elif active_server == 'jellyfin':
jellyfin_config = cfg.get_jellyfin_config()
jellyfin_base_url = jellyfin_config.get('base_url', '')
jellyfin_token = jellyfin_config.get('api_key', '')
logger.info(
"Jellyfin config - base_url: %s, token: %s...",
jellyfin_base_url,
jellyfin_token[:10] if jellyfin_token else 'None',
)
if jellyfin_base_url:
# Extract the path from URL
@ -105,7 +147,7 @@ def normalize_image_url(thumb_url: str | None) -> str | None:
fixed_url = f"{jellyfin_base_url.rstrip('/')}{path}{separator}X-Emby-Token={jellyfin_token}"
else:
fixed_url = f"{jellyfin_base_url.rstrip('/')}{path}"
logger.info("Fixed URL: %s", fixed_url)
logger.debug("Fixed URL: %s", _redact_url_secrets(fixed_url))
return _browser_safe_image_url(fixed_url)
elif active_server == 'navidrome':
@ -113,7 +155,6 @@ def normalize_image_url(thumb_url: str | None) -> str | None:
navidrome_base_url = navidrome_config.get('base_url', '')
navidrome_username = navidrome_config.get('username', '')
navidrome_password = navidrome_config.get('password', '')
logger.info("Navidrome config - base_url: %s, username: %s", navidrome_base_url, navidrome_username)
if navidrome_base_url and navidrome_username and navidrome_password:
# Extract the path from URL
@ -137,7 +178,7 @@ def normalize_image_url(thumb_url: str | None) -> str | None:
# Construct proper Navidrome Subsonic URL
fixed_url = f"{navidrome_base_url.rstrip('/')}{path}{separator}{auth_params}"
logger.info("Fixed URL: %s", fixed_url)
logger.debug("Fixed URL: %s", _redact_url_secrets(fixed_url))
return _browser_safe_image_url(fixed_url)
logger.warning("No configuration found for %s or unsupported server type", active_server)
@ -146,18 +187,18 @@ def normalize_image_url(thumb_url: str | None) -> str | None:
return _browser_safe_image_url(thumb_url)
except Exception as exc:
logger.error("Error fixing image URL '%s': %s", thumb_url, exc)
logger.error("Error fixing image URL '%s': %s", _redact_url_secrets(thumb_url), exc)
return _browser_safe_image_url(thumb_url)
def is_image_proxy_url(url: str) -> bool:
"""Return True for SoulSync image-proxy URLs, absolute or relative."""
"""Return True for SoulSync image proxy/cache URLs, absolute or relative."""
if not url:
return False
try:
parsed = urlparse(url)
return parsed.path == '/api/image-proxy'
return parsed.path == '/api/image-proxy' or parsed.path.startswith('/api/image-cache/')
except Exception:
return False
@ -194,10 +235,19 @@ def _browser_safe_image_url(url: str) -> str:
if is_image_proxy_url(url):
return url
if url.startswith('/api/image-proxy?url='):
if url.startswith('/api/image-proxy?url=') or url.startswith('/api/image-cache/'):
return url
if url.startswith('http://') or url.startswith('https://'):
try:
from core.image_cache import cached_image_url
cached_url = cached_image_url(url)
if cached_url:
return cached_url
except Exception as exc:
logger.debug("image cache URL registration failed: %s", exc)
if is_internal_image_host(url):
return f"/api/image-proxy?url={quote(url, safe='')}"
return url

View file

@ -8,6 +8,7 @@ Transparent to callers: check cache before API call, store after success.
import json
import logging
import threading
import time
from datetime import datetime
from typing import Optional, Dict, List, Tuple
@ -49,6 +50,33 @@ class MetadataCache:
from database.music_database import get_database
return get_database()
@staticmethod
def _is_transient_sqlite_io_error(exc: Exception) -> bool:
return 'disk i/o error' in str(exc).lower()
def _run_maintenance_write(self, label: str, operation, default: int = 0) -> int:
"""Run a maintenance write with one retry for transient SQLite I/O."""
for attempt in range(2):
try:
db = self._get_db()
conn = db._get_connection()
try:
return operation(conn)
finally:
conn.close()
except Exception as e:
if self._is_transient_sqlite_io_error(e) and attempt == 0:
logger.warning(
"%s hit transient SQLite disk I/O error; retrying once: %s",
label,
e,
)
time.sleep(0.25)
continue
logger.error("%s error: %s", label, e)
return default
return default
# ─── Entity Methods ───────────────────────────────────────────────
def get_entity(self, source: str, entity_type: str, entity_id: str) -> Optional[dict]:
@ -566,137 +594,113 @@ class MetadataCache:
def evict_expired(self) -> int:
"""Delete entries that have exceeded their TTL. Returns count of evicted entries."""
try:
db = self._get_db()
conn = db._get_connection()
try:
cursor = conn.cursor()
def _operation(conn):
cursor = conn.cursor()
# Entities
cursor.execute("""
DELETE FROM metadata_cache_entities
WHERE julianday('now') - julianday(updated_at) > ttl_days
""")
entity_count = cursor.rowcount
# Entities
cursor.execute("""
DELETE FROM metadata_cache_entities
WHERE julianday('now') - julianday(updated_at) > ttl_days
""")
entity_count = cursor.rowcount
# Searches
cursor.execute("""
DELETE FROM metadata_cache_searches
WHERE julianday('now') - julianday(created_at) > ttl_days
""")
search_count = cursor.rowcount
# Searches
cursor.execute("""
DELETE FROM metadata_cache_searches
WHERE julianday('now') - julianday(created_at) > ttl_days
""")
search_count = cursor.rowcount
conn.commit()
total = entity_count + search_count
if total > 0:
logger.info(f"Evicted {total} expired cache entries ({entity_count} entities, {search_count} searches)")
return total
finally:
conn.close()
except Exception as e:
logger.error(f"Cache eviction error: {e}")
return 0
conn.commit()
total = entity_count + search_count
if total > 0:
logger.info(f"Evicted {total} expired cache entries ({entity_count} entities, {search_count} searches)")
return total
return self._run_maintenance_write("Cache eviction", _operation)
def clean_junk_entities(self) -> int:
"""Delete cached entities with empty/placeholder names."""
try:
db = self._get_db()
conn = db._get_connection()
try:
cursor = conn.cursor()
junk_names = "', '".join(self._JUNK_NAMES - {''}) # exclude empty, handled separately
cursor.execute(f"""
DELETE FROM metadata_cache_entities
WHERE (name IS NULL
OR TRIM(name) = ''
OR LOWER(TRIM(name)) IN ('{junk_names}'))
AND entity_id NOT LIKE '%\\_features' ESCAPE '\\'
AND entity_id NOT LIKE '%\\_tracks' ESCAPE '\\'
""")
count = cursor.rowcount
conn.commit()
if count > 0:
logger.info(f"Cleaned {count} junk entities from cache")
return count
finally:
conn.close()
except Exception as e:
logger.error(f"Junk cleanup error: {e}")
return 0
def _operation(conn):
cursor = conn.cursor()
junk_names = "', '".join(self._JUNK_NAMES - {''}) # exclude empty, handled separately
cursor.execute(f"""
DELETE FROM metadata_cache_entities
WHERE (name IS NULL
OR TRIM(name) = ''
OR LOWER(TRIM(name)) IN ('{junk_names}'))
AND entity_id NOT LIKE '%\\_features' ESCAPE '\\'
AND entity_id NOT LIKE '%\\_tracks' ESCAPE '\\'
""")
count = cursor.rowcount
conn.commit()
if count > 0:
logger.info(f"Cleaned {count} junk entities from cache")
return count
return self._run_maintenance_write("Junk cleanup", _operation)
def clean_orphaned_searches(self) -> int:
"""Delete search results where <50% of referenced entities still exist."""
try:
db = self._get_db()
conn = db._get_connection()
try:
cursor = conn.cursor()
cursor.execute("SELECT id, source, search_type, result_ids FROM metadata_cache_searches")
rows = cursor.fetchall()
def _operation(conn):
cursor = conn.cursor()
cursor.execute("SELECT id, source, search_type, result_ids FROM metadata_cache_searches")
rows = cursor.fetchall()
dead_ids = []
for row in rows:
try:
result_ids = json.loads(row['result_ids'] or '[]')
except (json.JSONDecodeError, TypeError):
dead_ids.append(row['id'])
continue
dead_ids = []
for row in rows:
try:
result_ids = json.loads(row['result_ids'] or '[]')
except (json.JSONDecodeError, TypeError):
dead_ids.append(row['id'])
continue
if not result_ids:
dead_ids.append(row['id'])
continue
if not result_ids:
dead_ids.append(row['id'])
continue
# Check how many referenced entities still exist
placeholders = ','.join('?' * len(result_ids))
cursor.execute(f"""
SELECT COUNT(*) FROM metadata_cache_entities
WHERE source = ? AND entity_type = ? AND entity_id IN ({placeholders})
""", [row['source'], row['search_type']] + list(result_ids))
found = cursor.fetchone()[0]
# Check how many referenced entities still exist
placeholders = ','.join('?' * len(result_ids))
cursor.execute(f"""
SELECT COUNT(*) FROM metadata_cache_entities
WHERE source = ? AND entity_type = ? AND entity_id IN ({placeholders})
""", [row['source'], row['search_type']] + list(result_ids))
found = cursor.fetchone()[0]
if found < len(result_ids) * 0.5:
dead_ids.append(row['id'])
if found < len(result_ids) * 0.5:
dead_ids.append(row['id'])
if dead_ids:
# Delete in chunks to stay under SQLite variable limit
for i in range(0, len(dead_ids), 400):
chunk = dead_ids[i:i + 400]
placeholders = ','.join('?' * len(chunk))
cursor.execute(f"DELETE FROM metadata_cache_searches WHERE id IN ({placeholders})", chunk)
conn.commit()
if dead_ids:
# Delete in chunks to stay under SQLite variable limit
for i in range(0, len(dead_ids), 400):
chunk = dead_ids[i:i + 400]
placeholders = ','.join('?' * len(chunk))
cursor.execute(f"DELETE FROM metadata_cache_searches WHERE id IN ({placeholders})", chunk)
conn.commit()
count = len(dead_ids)
if count > 0:
logger.info(f"Cleaned {count} orphaned search results from cache")
return count
finally:
conn.close()
except Exception as e:
logger.error(f"Orphan search cleanup error: {e}")
return 0
count = len(dead_ids)
if count > 0:
logger.info(f"Cleaned {count} orphaned search results from cache")
return count
return self._run_maintenance_write("Orphan search cleanup", _operation)
def clean_stale_musicbrainz_nulls(self, max_age_days: int = 30) -> int:
"""Delete MusicBrainz cache entries where lookup found nothing (null MBID) and age > max_age_days."""
try:
db = self._get_db()
conn = db._get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
DELETE FROM musicbrainz_cache
WHERE musicbrainz_id IS NULL
AND julianday('now') - julianday(last_updated) > ?
""", (max_age_days,))
count = cursor.rowcount
conn.commit()
if count > 0:
logger.info(f"Cleaned {count} stale MusicBrainz null entries (>{max_age_days} days)")
return count
finally:
conn.close()
except Exception as e:
logger.error(f"MusicBrainz null cleanup error: {e}")
return 0
def _operation(conn):
cursor = conn.cursor()
cursor.execute("""
DELETE FROM musicbrainz_cache
WHERE musicbrainz_id IS NULL
AND julianday('now') - julianday(last_updated) > ?
""", (max_age_days,))
count = cursor.rowcount
conn.commit()
if count > 0:
logger.info(f"Cleaned {count} stale MusicBrainz null entries (>{max_age_days} days)")
return count
return self._run_maintenance_write("MusicBrainz null cleanup", _operation)
def get_health_stats(self) -> dict:
"""Return cache health statistics for the repair dashboard.

View file

@ -132,6 +132,7 @@ def check_album_completion(
confidence_threshold=0.7,
server_source=active_server,
candidate_albums=candidate_albums,
strict_discography_match=True,
)
except Exception as db_error:
logger.error(f"Database error for album '{album_name}': {db_error}")
@ -239,6 +240,7 @@ def check_single_completion(
confidence_threshold=0.7,
server_source=active_server,
candidate_albums=candidate_albums,
strict_discography_match=True,
)
except Exception as db_error:
logger.error(f"Database error for EP '{single_name}': {db_error}")

View file

@ -102,7 +102,19 @@ def enhance_file_metadata(file_path: str, context: dict, artist: dict, album_inf
save_audio_file(audio_file, symbols)
return True
track_num_str = f"{metadata.get('track_number', 1)}/{metadata.get('total_tracks', 1)}"
# Discord report (Netti93) — many album-dict construction
# sites pass `total_tracks: 0` when source data is incomplete
# (per types.py, 0 means "unknown"). Pre-fix this serialized
# to "6/0" tags. Helper drops the `/N` suffix when total is
# unknown so the tag reads "6" instead — matches retag's
# behavior at core/tag_writer.py and ID3 spec convention.
from core.metadata.track_number_format import (
format_track_number_tag,
format_track_number_tuple,
)
track_num_str = format_track_number_tag(
metadata.get('track_number'), metadata.get('total_tracks')
)
write_multi = cfg.get("metadata_enhancement.tags.write_multi_artist", False)
artists_list = metadata.get("_artists_list", [])
@ -167,7 +179,9 @@ def enhance_file_metadata(file_path: str, context: dict, artist: dict, album_inf
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))]
audio_file["trkn"] = [format_track_number_tuple(
metadata.get("track_number"), metadata.get("total_tracks")
)]
if metadata.get("disc_number"):
audio_file["disk"] = [(metadata["disc_number"], 0)]

View file

@ -18,13 +18,14 @@ logger = get_logger("metadata.registry")
MetadataClientFactory = Callable[[], Any]
METADATA_SOURCE_PRIORITY = ("deezer", "itunes", "spotify", "discogs", "hydrabase")
METADATA_SOURCE_PRIORITY = ("deezer", "itunes", "spotify", "discogs", "hydrabase", "musicbrainz")
METADATA_SOURCE_LABELS = {
"spotify": "Spotify",
"itunes": "iTunes",
"deezer": "Deezer",
"discogs": "Discogs",
"hydrabase": "Hydrabase",
"musicbrainz": "MusicBrainz",
}
_UNSET = object()
@ -140,6 +141,22 @@ def _get_discogs_factory(client_factory: Optional[MetadataClientFactory]) -> Met
return DiscogsClient
def _get_amazon_factory(client_factory: Optional[MetadataClientFactory]) -> MetadataClientFactory:
if client_factory is not None:
return client_factory
from core.amazon_client import AmazonClient
return AmazonClient
def _get_musicbrainz_factory(client_factory: Optional[MetadataClientFactory]) -> MetadataClientFactory:
if client_factory is not None:
return client_factory
from core.musicbrainz_search import MusicBrainzSearchClient
return MusicBrainzSearchClient
def get_spotify_client(client_factory: Optional[MetadataClientFactory] = None):
"""Get shared Spotify client.
@ -260,6 +277,30 @@ def get_discogs_client(
return client
def get_amazon_client(client_factory: Optional[MetadataClientFactory] = None):
"""Get cached Amazon Music client."""
cache_key = "amazon"
factory = _get_amazon_factory(client_factory)
with _client_cache_lock:
client = _client_cache.get(cache_key)
if client is None:
client = factory()
_client_cache[cache_key] = client
return client
def get_musicbrainz_client(client_factory: Optional[MetadataClientFactory] = None):
"""Get cached MusicBrainz primary source client."""
cache_key = "musicbrainz"
factory = _get_musicbrainz_factory(client_factory)
with _client_cache_lock:
client = _client_cache.get(cache_key)
if client is None:
client = factory()
_client_cache[cache_key] = client
return client
def is_hydrabase_enabled() -> bool:
"""Return True when Hydrabase is connected and app-enabled."""
try:
@ -288,24 +329,26 @@ def get_hydrabase_client(allow_fallback: bool = True, require_enabled: bool = Tr
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"
_default = METADATA_SOURCE_PRIORITY[0]
source = _get_config_value("metadata.fallback_source", _default) or _default
if source == "spotify":
try:
spotify = get_spotify_client(client_factory=spotify_client_factory)
if not spotify or not spotify.is_spotify_authenticated():
return "deezer"
return _default
except Exception:
return "deezer"
return _default
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
_default = METADATA_SOURCE_PRIORITY[0]
source = configured_source if configured_source is not None else _get_config_value("metadata.fallback_source", _default)
source = source or _default
return _default if source == "spotify" else source
def get_metadata_source_label(source: str) -> str:
@ -331,6 +374,8 @@ def get_primary_client(
itunes_client_factory: Optional[MetadataClientFactory] = None,
deezer_client_factory: Optional[MetadataClientFactory] = None,
discogs_client_factory: Optional[MetadataClientFactory] = None,
amazon_client_factory: Optional[MetadataClientFactory] = None,
musicbrainz_client_factory: Optional[MetadataClientFactory] = None,
):
"""Return client for configured primary source."""
return get_client_for_source(
@ -339,6 +384,8 @@ def get_primary_client(
itunes_client_factory=itunes_client_factory,
deezer_client_factory=deezer_client_factory,
discogs_client_factory=discogs_client_factory,
amazon_client_factory=amazon_client_factory,
musicbrainz_client_factory=musicbrainz_client_factory,
)
@ -348,6 +395,8 @@ def get_primary_source_status(
itunes_client_factory: Optional[MetadataClientFactory] = None,
deezer_client_factory: Optional[MetadataClientFactory] = None,
discogs_client_factory: Optional[MetadataClientFactory] = None,
amazon_client_factory: Optional[MetadataClientFactory] = None,
musicbrainz_client_factory: Optional[MetadataClientFactory] = None,
) -> Dict[str, Any]:
"""Return a generic status snapshot for the active primary metadata source."""
source = _get_config_value("metadata.fallback_source", "deezer") or "deezer"
@ -361,6 +410,8 @@ def get_primary_source_status(
itunes_client_factory=itunes_client_factory,
deezer_client_factory=deezer_client_factory,
discogs_client_factory=discogs_client_factory,
amazon_client_factory=amazon_client_factory,
musicbrainz_client_factory=musicbrainz_client_factory,
)
if source == "spotify":
connected = bool(client and client.is_spotify_authenticated())
@ -387,6 +438,8 @@ def get_client_for_source(
itunes_client_factory: Optional[MetadataClientFactory] = None,
deezer_client_factory: Optional[MetadataClientFactory] = None,
discogs_client_factory: Optional[MetadataClientFactory] = None,
amazon_client_factory: Optional[MetadataClientFactory] = None,
musicbrainz_client_factory: Optional[MetadataClientFactory] = None,
):
"""Return exact client for a source, or None if unavailable."""
if source == "spotify":
@ -410,4 +463,10 @@ def get_client_for_source(
if source == "itunes":
return get_itunes_client(client_factory=itunes_client_factory)
if source == "amazon":
return get_amazon_client(client_factory=amazon_client_factory)
if source == "musicbrainz":
return get_musicbrainz_client(client_factory=musicbrainz_client_factory)
return None

View file

@ -0,0 +1,93 @@
"""Canonical mapping from raw provider release-type vocabulary to the
internal `album_type` field that drives discography binning + UI.
Why this exists
---------------
Three sites historically duplicated the same "best-effort primary-type
album_type" mapping, each with a slightly different vocabulary:
core/musicbrainz_search.py: `_map_release_type` knew about
{album, single, ep, compilation}, defaulted unknown 'album'.
core/metadata/types.py: inline `{single: single, ep: ep}.get(...)`
didn't even know about 'compilation', also defaulted → 'album'.
core/metadata/cache.py: Deezer-specific record_type validator
intentionally narrow, kept here for its provider.
Issue #650 (S-Bryce) reported that MusicBrainz tags music videos and
some legitimate singles with primary-type=`Other`, which both mappers
silently routed to `album_type='album'`. Combined with the API-level
filter at `musicbrainz_search.search_albums` (which only requested
`type=album|ep|single` from MB and dropped 'Other' entirely), users
with MB-as-primary saw entire release-groups go missing from artist
discography views, and downloaded tracks from those release-groups
appeared as orphan "ghost" tracks bound to no album card.
Fix shape: one shared mapper consumed by every provider's
`raw Album dataclass` projection. Knows about 'other' and
'broadcast' (MB's two remaining primary-type vocabulary words) and
maps them to 'single' so they land in the Singles section of the
artist detail page they're almost always single-track music
releases (music videos, broadcast singles, one-off web releases).
Falling through to 'album' was the original sin places them in
Albums view where they look misleading and clutter the proper LP
list.
"""
from __future__ import annotations
from typing import List, Optional
# MB primary-type vocabulary as of 2026 — `Album | Single | EP |
# Broadcast | Other`. Compilation is a *secondary* type; querying MB
# with type=compilation silently breaks (returns ~10% of expected
# results) — see musicbrainz_client.browse_artist_release_groups docs.
_AUDIO_OTHER_PRIMARY_TYPES = frozenset({'other', 'broadcast'})
def map_release_group_type(primary_type: Optional[str],
secondary_types: Optional[List[str]] = None) -> str:
"""Project a raw provider release-group primary-type + secondary-types
into the internal `album_type` value the UI binning expects.
Returns one of: `'album'`, `'single'`, `'ep'`, `'compilation'`.
Mapping rules:
- `single` / `ep` pass through unchanged.
- `compilation` (primary or secondary) becomes `'compilation'`. The
compilation secondary-type check is required because MB's
canonical pattern is `primary=Album, secondary=[Compilation]`.
- `other` / `broadcast` become `'single'`. Almost always
single-track music releases (music videos, one-off web drops,
broadcast singles). Placing them in Singles is the pragmatic
bucket they're not LPs, but excluding them entirely (the
pre-fix behaviour) hid legitimate tracks.
- Anything else (including empty/None) `'album'`. Matches the
pre-fix default so no existing classifications shift.
`secondary_types` is optional because the legacy types.py call site
doesn't have access to it from the same level of raw structure.
Pass `None` (or omit) for the secondary-types-unavailable path.
"""
pt = (primary_type or '').strip().lower()
if pt == 'single':
return 'single'
if pt == 'ep':
return 'ep'
if pt == 'compilation':
return 'compilation'
# Secondary-type override: MB's compilation albums always carry
# `primary=Album` with `secondary=[Compilation]`, so the primary
# check above can't catch them.
if secondary_types:
normalized = {str(s).strip().lower() for s in secondary_types if s}
if 'compilation' in normalized:
return 'compilation'
if pt in _AUDIO_OTHER_PRIMARY_TYPES:
return 'single'
return 'album'

View file

@ -23,6 +23,7 @@ from core.imports.context import (
get_source_tag_names,
normalize_import_context,
)
from core.metadata.artist_resolution import resolve_track_artists
from core.metadata.registry import get_itunes_client
from database.music_database import get_database
from core.metadata.common import (
@ -182,6 +183,27 @@ def _names_match(a: str, b: str, threshold: float = 0.75) -> bool:
return SequenceMatcher(None, norm(a), norm(b)).ratio() >= threshold
def _normalize_release_date_tag(value: Any) -> str:
"""Return a tag-safe release date without inventing missing precision."""
raw = str(value or "").strip()
if not raw:
return ""
# Source APIs commonly return ISO timestamps. Audio DATE/TDRC tags should
# receive only the date precision the source actually provided.
raw = raw.split("T", 1)[0].strip()
match = re.match(r"^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$", raw)
if not match:
return ""
year, month, day = match.groups()
if day:
return f"{year}-{month}-{day}"
if month:
return f"{year}-{month}"
return year
def _collect_source_ids(metadata: dict, cfg) -> dict:
source_ids = {}
source = (metadata.get("source") or "").strip().lower()
@ -907,17 +929,13 @@ def extract_source_metadata(context: dict, artist: dict, album_info: dict) -> di
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))
# Resolve canonical artists list. Soulseek matched-download contexts
# only carry `original_search.artist` (singular string) — the full
# contributors list lives on `track_info` (the matched Spotify/etc
# track object). Deezer-direct contexts populate `original_search.artists`
# directly. Pure helper handles all three shapes.
all_artists = resolve_track_artists(original_search, track_info, artist_dict)
if all_artists:
# Deezer upgrade path: Deezer's `/search` endpoint only returns
# the primary artist for each track. The full contributors
# array (feat., remix collaborators, producers credited as
@ -1061,7 +1079,9 @@ def extract_source_metadata(context: dict, artist: dict, album_info: dict) -> di
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]
release_date = _normalize_release_date_tag(album_ctx.get("release_date"))
if release_date:
metadata["date"] = release_date
genres = artist_dict.get("genres") or []
if genres:
@ -1080,11 +1100,12 @@ def extract_source_metadata(context: dict, artist: dict, album_info: dict) -> di
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 Summary] title='%s' | artist='%s' | album_artist='%s' | album='%s' | date=%s | track=%s/%s | disc=%s",
metadata.get("title"),
metadata.get("artist"),
metadata.get("album_artist"),
metadata.get("album"),
metadata.get("date", ""),
metadata.get("track_number"),
metadata.get("total_tracks"),
metadata.get("disc_number"),

View file

@ -0,0 +1,77 @@
"""Format track-number tags consistently across audio formats.
Discord report (Netti93): album tracks were tagged as ``TRCK = "6/0"``
instead of ``"6/13"``. Cause: many album-dict construction sites in
the codebase pass ``total_tracks: 0`` when the source data is
incomplete, and ``core/metadata/enrichment.py`` formatted the tag
unconditionally as ``f"{track_number}/{total_tracks}"`` so 0
propagated straight to disk. The retag path was unaffected because
``core/tag_writer.py`` already does the right thing.
Per ``core/metadata/types.py``, ``total_tracks = 0`` is documented
as "unknown" not an actual track count. Fix at the consumer
boundary so every album-dict constructor doesn't need to be touched.
This module provides one pure helper. Tests at the function boundary.
"""
from __future__ import annotations
from typing import Optional, Tuple
def format_track_number_tag(
track_number: Optional[int],
total_tracks: Optional[int],
) -> str:
"""Return the canonical TRCK / tracknumber tag string.
- ``track_number=6, total_tracks=13`` ``"6/13"``
- ``track_number=6, total_tracks=0`` ``"6"`` (total unknown)
- ``track_number=6, total_tracks=None`` ``"6"``
- ``track_number=None, total_tracks=13`` ``"1/13"`` (track defaults to 1)
- ``track_number=None, total_tracks=None`` ``"1"``
ID3 spec allows ``TRCK`` to be either ``"N"`` or ``"N/M"``. Vorbis
``tracknumber`` follows the same convention. Avoiding the ``/0``
suffix keeps the tag honest most media servers and taggers
interpret ``6/0`` as "track 6 of 0" which is nonsensical, while
``6`` reads as "track 6, total unknown".
"""
num = _coerce_positive_int(track_number, default=1)
total = _coerce_positive_int(total_tracks, default=0)
if total > 0:
return f"{num}/{total}"
return str(num)
def format_track_number_tuple(
track_number: Optional[int],
total_tracks: Optional[int],
) -> Tuple[int, int]:
"""Return the MP4 ``trkn`` tuple ``(track, total)``.
MP4 tag spec stores track-of as a 2-int tuple convention is
``(N, 0)`` when the total is unknown. Same coercion rules as
``format_track_number_tag``: missing / None / non-positive
``track_number`` defaults to 1, missing / 0 / negative
``total_tracks`` returns 0 (the spec's "unknown" marker).
"""
num = _coerce_positive_int(track_number, default=1)
total = _coerce_positive_int(total_tracks, default=0)
return (num, total)
def _coerce_positive_int(value, *, default: int) -> int:
"""Coerce to a non-negative int. Falls back to ``default`` for
None / non-numeric / negative input. Floats truncate.
"""
if value is None:
return default
try:
coerced = int(value)
except (TypeError, ValueError):
return default
if coerced < 0:
return default
return coerced

View file

@ -333,7 +333,52 @@ class Album:
@classmethod
def from_musicbrainz_dict(cls, raw: Dict[str, Any]) -> 'Album':
"""MusicBrainz ``/release/{mbid}`` response shape (release, not release-group)."""
"""MusicBrainz album shape.
Accepts both raw ``/release/{mbid}`` responses and the normalized
MusicBrainz search adapter shape used by app-facing metadata clients.
"""
if raw.get('name') and not raw.get('title'):
artists = raw.get('artists') or []
artist_names = []
primary_artist_id = ''
for artist in artists:
if isinstance(artist, dict):
name = _str(artist.get('name'))
if name:
artist_names.append(name)
if not primary_artist_id and artist.get('id'):
primary_artist_id = _str(artist['id'])
else:
name = _str(artist)
if name:
artist_names.append(name)
images = raw.get('images') or []
image_url = ''
if images and isinstance(images[0], dict):
image_url = _str(images[0].get('url'))
image_url = image_url or _str(raw.get('image_url'))
external_ids = {}
if raw.get('id'):
external_ids['musicbrainz'] = _str(raw['id'])
return cls(
id=_str(raw.get('id')),
name=_str(raw.get('name')),
artists=artist_names or ['Unknown Artist'],
release_date=_str(raw.get('release_date')),
total_tracks=_int(raw.get('total_tracks')),
album_type=_str(raw.get('album_type'), default='album') or 'album',
image_url=image_url or None,
artist_id=primary_artist_id or None,
genres=list(raw.get('genres') or []),
source='musicbrainz',
external_ids=external_ids,
external_urls=dict(raw.get('external_urls') or {}),
)
artist_credit = raw.get('artist-credit') or []
artist_names = []
primary_artist_id = ''
@ -355,10 +400,16 @@ class Album:
if raw.get('barcode'):
external_ids['barcode'] = _str(raw['barcode'])
# MB `release-group` carries the album-level type (album/single/ep)
# MB `release-group` carries the album-level type (album/single/ep/
# compilation/other/broadcast). Centralized mapper handles the
# full vocabulary including 'other' / 'broadcast' (issue #650 —
# music videos and one-off releases) so this projection matches
# the search-adapter projection in `core/musicbrainz_search.py`.
from core.metadata.release_type import map_release_group_type
rg = raw.get('release-group') or {}
primary_type = _str(rg.get('primary-type'), default='Album').lower()
album_type = {'single': 'single', 'ep': 'ep'}.get(primary_type, 'album')
primary_type = _str(rg.get('primary-type'), default='Album')
secondary_types = rg.get('secondary-types') or []
album_type = map_release_group_type(primary_type, secondary_types)
if rg.get('id'):
external_ids['musicbrainz_release_group'] = _str(rg['id'])

View file

@ -42,8 +42,10 @@ from core.metadata.registry import (
clear_cached_metadata_client,
clear_cached_metadata_clients,
clear_cached_profile_spotify_client,
get_amazon_client,
get_client_for_source,
get_deezer_client,
get_musicbrainz_client,
get_discogs_client,
get_hydrabase_client,
get_itunes_client,
@ -75,6 +77,8 @@ except Exception: # pragma: no cover - optional dependency fallback
__all__ = [
"METADATA_SOURCE_PRIORITY",
"get_amazon_client",
"get_musicbrainz_client",
"MetadataCache",
"MetadataLookupOptions",
"MetadataProvider",

View file

@ -128,27 +128,45 @@ class MusicBrainzClient:
return []
@rate_limited
def search_release(self, album_name: str, artist_name: Optional[str] = None, limit: int = 10) -> List[Dict[str, Any]]:
def search_release(self, album_name: str, artist_name: Optional[str] = None,
limit: int = 10, strict: bool = True) -> List[Dict[str, Any]]:
"""
Search for releases (albums) by name
Search for releases (albums) by name.
Args:
album_name: Name of the album to search for
artist_name: Optional artist name to narrow search
limit: Maximum number of results to return
strict: When True (default), builds a phrase-match Lucene query
against the `release` and `artist` fields correct for
enrichment flows where exact name+artist are known. When
False, sends a bare query (album + artist joined) so MB
hits alias / sortname indexes and folds diacritics,
dramatically improving recall for user-facing fuzzy
lookups (e.g. the manual Fix popup).
Returns:
List of release results
"""
try:
# Escape quotes and backslashes for Lucene query
safe_album = album_name.replace('\\', '\\\\').replace('"', '\\"')
query = f'release:"{safe_album}"'
if artist_name:
safe_artist = artist_name.replace('\\', '\\\\').replace('"', '\\"')
query += f' AND artist:"{safe_artist}"'
if strict:
# Escape quotes and backslashes for Lucene query
safe_album = album_name.replace('\\', '\\\\').replace('"', '\\"')
query = f'release:"{safe_album}"'
if artist_name:
safe_artist = artist_name.replace('\\', '\\\\').replace('"', '\\"')
query += f' AND artist:"{safe_artist}"'
else:
# Bare query — MB tokenizes against title + artist credit +
# alias + sortname indexes together with diacritic folding.
# Recovers cases like "Bjork" → "Björk" that strict phrase
# queries miss.
parts = [album_name]
if artist_name:
parts.append(artist_name)
query = ' '.join(p for p in parts if p)
params = {
'query': query,
'fmt': 'json',
@ -173,27 +191,44 @@ class MusicBrainzClient:
return []
@rate_limited
def search_recording(self, track_name: str, artist_name: Optional[str] = None, limit: int = 10) -> List[Dict[str, Any]]:
def search_recording(self, track_name: str, artist_name: Optional[str] = None,
limit: int = 10, strict: bool = True) -> List[Dict[str, Any]]:
"""
Search for recordings (tracks) by name
Search for recordings (tracks) by name.
Args:
track_name: Name of the track to search for
artist_name: Optional artist name to narrow search
limit: Maximum number of results to return
strict: When True (default), builds a phrase-match Lucene query
against the `recording` and `artist` fields correct for
enrichment flows where exact name+artist are known. When
False, sends a bare query (track + artist joined) so MB
hits alias / sortname indexes and folds diacritics. The
bare path also avoids the AND-clause that kills recall
when either side mis-matches (e.g. "Bjork" vs canonical
"Björk", or a track title with bracketed suffix like
"(Live)" that strict phrase match rejects).
Returns:
List of recording results
"""
try:
# Escape quotes and backslashes for Lucene query
safe_track = track_name.replace('\\', '\\\\').replace('"', '\\"')
query = f'recording:"{safe_track}"'
if artist_name:
safe_artist = artist_name.replace('\\', '\\\\').replace('"', '\\"')
query += f' AND artist:"{safe_artist}"'
if strict:
# Escape quotes and backslashes for Lucene query
safe_track = track_name.replace('\\', '\\\\').replace('"', '\\"')
query = f'recording:"{safe_track}"'
if artist_name:
safe_artist = artist_name.replace('\\', '\\\\').replace('"', '\\"')
query += f' AND artist:"{safe_artist}"'
else:
# Bare query — see search_release for rationale.
parts = [track_name]
if artist_name:
parts.append(artist_name)
query = ' '.join(p for p in parts if p)
params = {
'query': query,
'fmt': 'json',

View file

@ -114,18 +114,12 @@ def _extract_title_hint(query: str, artist_name: str) -> Optional[str]:
return None
def _map_release_type(primary_type: str, secondary_types: List[str] = None) -> str:
"""Map MusicBrainz release group type to standard album_type."""
pt = (primary_type or '').lower()
if pt == 'album':
return 'album'
elif pt == 'single':
return 'single'
elif pt == 'ep':
return 'ep'
elif pt == 'compilation' or 'compilation' in (secondary_types or []):
return 'compilation'
return 'album'
# Thin module-level alias retained so callers inside this file keep
# working without touching every call site. The canonical implementation
# (including the 'other' / 'broadcast' handling that fixes issue #650)
# lives in `core/metadata/release_type.py` so every provider's `raw →
# Album` projection shares one mapper.
from core.metadata.release_type import map_release_group_type as _map_release_type
class MusicBrainzSearchClient:
@ -365,7 +359,15 @@ class MusicBrainzSearchClient:
# the filter silently breaks. Actual compilations
# (primary-type=Album with secondary-types=[Compilation])
# are handled by the studio-preference filter below.
release_types=['album', 'ep', 'single'],
# 'other' added per issue #650 — MB tags music videos
# and one-off web/broadcast releases with primary=Other,
# and many artists (Vocaloid producers, indie acts, JP
# solo artists) have legitimate singles classified
# there. Pre-fix this filter dropped them at the API
# layer, hiding tracks the user had downloaded.
# `map_release_group_type` routes 'other' into the
# singles bucket so they appear in the right UI section.
release_types=['album', 'ep', 'single', 'other'],
limit=100,
)
@ -638,13 +640,34 @@ class MusicBrainzSearchClient:
logger.warning(f"MusicBrainz track search failed: {e}")
return []
def _search_tracks_text(self, track_name: str, artist_name: Optional[str], limit: int) -> List[Track]:
"""Fallback text-search path for structured/fuzzy track queries."""
def _search_tracks_text(self, track_name: str, artist_name: Optional[str], limit: int,
strict: bool = True, min_score: Optional[int] = None) -> List[Track]:
"""Fallback text-search path for structured/fuzzy track queries.
`strict=True` (default) keeps the field-scoped Lucene phrase match
precise enough for enrichment-style flows where the inputs are
already known-clean. `strict=False` switches to a bare-query
MB lookup that hits alias/sortname indexes with diacritic folding
needed for user-facing fuzzy surfaces (Fix popup cascade) where
recall beats precision because the user picks from the result list.
Mirrors the same toggle already on `search_recording` in
`core/musicbrainz_client.py`.
`min_score` defaults to `self._MIN_SCORE` (80) sized for the
enhanced search tab where unfiltered MB results are noisy. Pass
a lower value (or 0) when a downstream stage like
`core.metadata.relevance.rerank_tracks` will re-sort by artist
match MB's free-text score heavily favours title-text matches
("Army of Me (Bjork)" cover by HIRS Collective scores 100,
Björk's canonical "Army of Me" scores 28) so a high floor drops
the right answer.
"""
try:
results = self._client.search_recording(track_name, artist_name=artist_name, limit=limit)
# Score filter matches the artist/album logic — cuts garbage
# title collisions from unrelated recordings.
results = [r for r in results if (r.get('score', 0) or 0) >= self._MIN_SCORE]
results = self._client.search_recording(
track_name, artist_name=artist_name, limit=limit, strict=strict
)
threshold = self._MIN_SCORE if min_score is None else min_score
results = [r for r in results if (r.get('score', 0) or 0) >= threshold]
tracks = []
for r in results:
@ -656,6 +679,30 @@ class MusicBrainzSearchClient:
logger.warning(f"MusicBrainz track search failed: {e}")
return []
def search_tracks_with_artist(self, track: str, artist: str,
limit: int = 10) -> List[Track]:
"""Search MB tracks with track + artist passed as separate fields.
Powers the Fix-popup metadata cascade (`GET /api/musicbrainz/search_tracks`)
and any future surface where the caller already has the title/artist
split and wants the fuzzy-recall MB lookup without going through
`search_tracks`'s structured-query dispatch (`Artist - Track`
splitting, bare-name artist-first browse).
Uses bare-query mode (`strict=False`) diacritic-folded, hits
alias/sortname indexes, no `AND`-clause that kills recall when
either side mis-matches. Score floor lowered to 20 (vs the search
tab's 80) so MB recordings whose title doesn't literally contain
the artist name still enter the candidate pool the endpoint's
`rerank_tracks` pass then sorts by artist-match relevance. Without
this, queries like `Army of Me` + `Bjork` only surface covers
(score 73-100) and miss Björk's canonical recording (score 28).
"""
if not track and not artist:
return []
return self._search_tracks_text(track, artist or None, limit,
strict=False, min_score=20)
def _pick_representative_release(self, releases: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Pick the best release out of a release-group's editions.
@ -678,7 +725,182 @@ class MusicBrainzSearchClient:
return sorted(releases, key=_key)[0]
def get_album(self, album_mbid: str) -> Optional[Dict[str, Any]]:
def is_authenticated(self) -> bool:
return True
def reload_config(self) -> None:
pass
def get_track_features(self, track_id: str) -> None:
return None
def get_user_info(self) -> None:
return None
def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]:
"""Return Spotify-compatible track detail dict by recording MBID."""
try:
rec = self._client.get_recording(track_id, includes=['releases', 'artist-credits', 'release-groups'])
if not rec:
return None
releases = rec.get('releases', []) or []
releases.sort(key=self._release_preference_key)
first_rel = releases[0] if releases else {}
rg = first_rel.get('release-group', {}) or {}
release_id = first_rel.get('id', '')
rg_id = rg.get('id', '')
image_url = self._cached_art(release_id, rg_id)
artists = _extract_artist_credit(rec.get('artist-credit', []))
return {
'id': rec.get('id', ''),
'name': rec.get('title', ''),
'artists': [{'name': a, 'id': ''} for a in artists],
'album': {
'id': rg_id or release_id,
'name': first_rel.get('title', ''),
'images': [{'url': image_url, 'height': 250, 'width': 250}] if image_url else [],
'release_date': first_rel.get('date') or rg.get('first-release-date') or '',
},
'duration_ms': rec.get('length') or 0,
'track_number': 1,
'disc_number': 1,
'preview_url': None,
'popularity': 0,
'external_urls': {'musicbrainz': f'https://musicbrainz.org/recording/{track_id}'},
}
except Exception as e:
logger.error(f'get_track_details({track_id}) error: {e}')
return None
def get_recording_flat(self, mbid: str) -> Optional[Dict[str, Any]]:
"""Return a Fix-popup-compatible flat track dict by recording MBID.
Distinct from `get_track_details` which returns a Spotify-shaped
nested dict (artists as objects, album as nested object with
images array). The Discovery Fix popup expects the flat shape that
the spotify/deezer/itunes search endpoints produce artists as a
list of strings, album as a string, single image_url field.
Used by `GET /api/musicbrainz/recording/<mbid>` to support the
MBID-paste lookup field power-user escape hatch when fuzzy auto-
search ranks the wrong recording among many same-title versions.
Returns None when the MBID is missing or MB returns no recording.
Recording-without-release is valid (album = '', image_url = '').
"""
if not mbid:
return None
try:
rec = self._client.get_recording(
mbid, includes=['releases', 'artist-credits', 'release-groups']
)
if not rec:
return None
releases = rec.get('releases', []) or []
releases.sort(key=self._release_preference_key)
first_rel = releases[0] if releases else {}
rg = first_rel.get('release-group', {}) or {}
release_id = first_rel.get('id', '')
rg_id = rg.get('id', '')
artists = _extract_artist_credit(rec.get('artist-credit', []))
album_name = first_rel.get('title', '') or ''
image_url = self._cached_art(release_id, rg_id) if (release_id or rg_id) else None
return {
'id': rec.get('id', '') or mbid,
'name': rec.get('title', '') or '',
'artists': artists if artists else [],
'album': album_name,
'duration_ms': rec.get('length') or 0,
'image_url': image_url or '',
'external_urls': {
'musicbrainz': f'https://musicbrainz.org/recording/{mbid}'
},
}
except Exception as e:
logger.error(f'get_recording_flat({mbid}) error: {e}')
return None
def get_album_tracks(self, album_mbid: str) -> Optional[Dict[str, Any]]:
"""Return {items: [...], total: N} track listing for a release/release-group MBID."""
album = self.get_album(album_mbid, include_tracks=True)
if album is None:
return None
flat = album.get('tracks', [])
if isinstance(flat, dict):
return flat
return {'items': flat, 'total': len(flat)}
def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]:
"""Return Spotify-compatible artist detail dict."""
try:
artist = self._client.get_artist(artist_id, includes=['tags', 'url-rels'])
if not artist:
return None
genres = [t['name'] for t in (artist.get('tags') or []) if isinstance(t, dict) and t.get('name')]
return {
'id': artist.get('id', artist_id),
'name': artist.get('name', ''),
'genres': genres,
'followers': {'total': 0},
'popularity': 0,
'images': [],
'external_urls': {'musicbrainz': f'https://musicbrainz.org/artist/{artist_id}'},
}
except Exception as e:
logger.error(f'get_artist({artist_id}) error: {e}')
return None
def get_artist_top_tracks(self, artist_id: str, limit: int = 10) -> List[Dict[str, Any]]:
"""Return top recordings for an artist, deduplicated by title and sorted by year."""
try:
recs = self._client.search_recordings_by_artist_mbid(artist_id, limit=100)
for r in recs:
rels = r.get('releases') or []
if rels:
rels.sort(key=self._release_preference_key)
r['releases'] = rels
studio = [r for r in recs if self._has_studio_release(r)]
recs = studio or recs
seen: set = set()
deduped = []
for r in recs:
key = (r.get('title') or '').lower().strip()
if not key or key in seen:
continue
seen.add(key)
deduped.append(r)
results = []
for r in deduped[:limit]:
releases = r.get('releases', [])
first_rel = releases[0] if releases else {}
rg = first_rel.get('release-group', {}) or {}
release_id = first_rel.get('id', '')
rg_id = rg.get('id', '')
artists = _extract_artist_credit(r.get('artist-credit', []))
image_url = self._cached_art(release_id, rg_id)
results.append({
'id': r.get('id', ''),
'name': r.get('title', ''),
'artists': [{'name': a, 'id': ''} for a in artists],
'album': {
'id': rg_id or release_id,
'name': first_rel.get('title', ''),
'images': [{'url': image_url}] if image_url else [],
},
'duration_ms': r.get('length') or 0,
'popularity': 0,
'preview_url': None,
'external_urls': {'musicbrainz': f'https://musicbrainz.org/recording/{r.get("id", "")}'},
})
return results
except Exception as e:
logger.error(f'get_artist_top_tracks({artist_id}) error: {e}')
return []
def get_album(self, album_mbid: str, include_tracks: bool = True) -> Optional[Dict[str, Any]]:
"""Get full album details with track listing for download modal.
The MBID passed in could be either:
@ -713,10 +935,15 @@ class MusicBrainzSearchClient:
album['external_urls'] = {
'musicbrainz': f'https://musicbrainz.org/release-group/{album_mbid}'
}
if not include_tracks:
album.pop('tracks', None)
return album
# Path B: release MBID (text-search fallback path)
return self._render_release_as_album(album_mbid)
album = self._render_release_as_album(album_mbid)
if album and not include_tracks:
album.pop('tracks', None)
return album
except Exception as e:
logger.error(f"MusicBrainz album detail failed for {album_mbid}: {e}")
return None
@ -727,6 +954,11 @@ class MusicBrainzSearchClient:
shape the download modal expects. `rg_fallback` supplies release-group
metadata (type, artist credits) when resolving from a release-group
whose releases may be lightly populated."""
# NOTE: `cover-art-archive` is NOT a valid `inc` param for the
# /release resource — MB returns 400 if you pass it. The CAA flags
# (`{'front': True, 'back': True, ...}`) come back on every release
# response by default, so we read them below without requesting an
# include.
release = self._client.get_release(
release_mbid, includes=['recordings', 'artist-credits', 'release-groups']
)
@ -747,7 +979,18 @@ class MusicBrainzSearchClient:
album_type = _map_release_type(primary_type, secondary_types)
rg_mbid = rg.get('id', '')
image_url = self._cached_art(release_mbid, rg_mbid)
# Use cover-art-archive metadata to pick the right CAA scope.
# release-group scope is preferred (covers all editions), but only
# if the release itself actually has front art — otherwise that URL
# will 404. `cover-art-archive.front` is authoritative with no
# extra network call (returned as part of the release fetch above).
caa = release.get('cover-art-archive') or {}
if caa.get('front'):
image_url = _cover_art_url(release_mbid, scope='release')
elif rg_mbid:
image_url = _cover_art_url(rg_mbid, scope='release-group')
else:
image_url = None
tracks = []
total_tracks = 0
@ -789,7 +1032,7 @@ class MusicBrainzSearchClient:
'external_urls': {'musicbrainz': f'https://musicbrainz.org/release/{release_mbid}'},
}
def get_artist_albums(self, artist_mbid: str, album_type: str = 'album,single') -> List:
def get_artist_albums(self, artist_mbid: str, album_type: str = 'album,single', limit: int = 200) -> List:
"""Get artist's releases for discography view."""
try:
artist = self._client.get_artist(artist_mbid, includes=['release-groups'])
@ -814,7 +1057,7 @@ class MusicBrainzSearchClient:
image_url=image_url,
external_urls={'musicbrainz': f'https://musicbrainz.org/release-group/{rg_mbid}'},
))
return albums
return albums[:limit]
except Exception as e:
logger.warning(f"MusicBrainz artist albums failed: {e}")
return []

View file

@ -428,54 +428,52 @@ class MusicBrainzService:
return []
# Tier 3: live MB lookup. Search → fetch by MBID → cache.
try:
results = self.mb_client.search_artist(artist_name, limit=3)
except Exception as e:
logger.debug("lookup_artist_aliases: search_artist(%r) raised: %s", artist_name, e)
self._save_to_cache('artist_aliases', artist_name, None, None, {'aliases': []}, 0)
return []
if not results:
self._save_to_cache('artist_aliases', artist_name, None, None, {'aliases': []}, 0)
return []
# Score each result: combined of name-similarity + MB's own
# relevance. Score range 0.0-1.0.
scored = []
for result in results:
mb_name = result.get('name', '')
mb_score = result.get('score', 0)
sim = self._calculate_similarity(artist_name, mb_name)
combined = (sim * 0.7) + (mb_score / 100 * 0.3)
mbid = result.get('id')
if mbid:
scored.append((combined, mbid))
# Issue #586 — strict search queries `artist:"..."` only and
# MISSES alias / sortname indexes. When MB's canonical name is
# the non-Latin form (e.g. `Дмитрий Яблонский`), the user's
# Latin input ("Dmitry Yablonsky") finds nothing under strict.
# Fall back to non-strict (bare query, hits alias + sortname
# indexes) when strict returns empty OR all results fail the
# trust gate.
scored = self._search_and_score_artists(artist_name, strict=True)
if not scored or self._best_score(scored) < 0.85:
non_strict = self._search_and_score_artists(artist_name, strict=False)
if non_strict and (not scored or self._best_score(non_strict) > self._best_score(scored)):
scored = non_strict
if not scored:
self._save_to_cache('artist_aliases', artist_name, None, None, {'aliases': []}, 0)
return []
scored.sort(key=lambda x: -x[0])
best_score, best_mbid = scored[0]
best_score, best_mbid, best_mb_score = scored[0]
# Strict trust threshold: real matches for distinctive cross-
# script artists (the user-reported case) score >= 0.95.
# Anything below 0.85 is ambiguous and not worth the false-
# positive risk of pulling in aliases for the wrong artist.
if best_score < 0.85:
# Trust gate. Two ways to pass:
# 1. Combined score >= 0.85 (the historical strict bar that
# catches same-script matches)
# 2. MB's OWN score is very high (>= 95) AND the result is
# unambiguous (top result clearly leads). Bridges the
# cross-script case where local similarity is near zero
# ("Dmitry Yablonsky" vs "Дмитрий Яблонский" sim ~0)
# but MB's index found a high-confidence match.
passes_combined = best_score >= 0.85
passes_mb_only = best_mb_score >= 95 and (
len(scored) < 2 or (scored[0][2] - scored[1][2]) >= 5
)
if not (passes_combined or passes_mb_only):
logger.debug(
"lookup_artist_aliases: best match for %r below trust "
"threshold (score=%.2f)", artist_name, best_score,
"threshold (combined=%.2f, mb_score=%d)",
artist_name, best_score, best_mb_score,
)
self._save_to_cache('artist_aliases', artist_name, None, None, {'aliases': []}, 0)
return []
# Ambiguity detection: when 2+ results both score high (within
# 0.1 of the best), the search hit multiple distinct artists
# with similar names ("John Smith" returning 10 different
# John Smiths all at score 100). Pulling aliases for one of
# them could produce wrong matches. Skip + cache empty.
if len(scored) >= 2 and (scored[0][0] - scored[1][0]) < 0.1:
# 0.1 of the best combined), the search hit multiple distinct
# artists with similar names. Pulling aliases for one could
# produce wrong matches. Skip + cache empty.
if len(scored) >= 2 and (scored[0][0] - scored[1][0]) < 0.1 and not passes_mb_only:
logger.debug(
"lookup_artist_aliases: ambiguous match for %r — top "
"two results within 0.1 (%.2f / %.2f). Skipping alias lookup.",
@ -491,6 +489,40 @@ class MusicBrainzService:
)
return aliases
def _search_and_score_artists(self, artist_name: str, strict: bool):
"""Search MB for an artist and score each result.
Returns a list of (combined_score, mbid, raw_mb_score) tuples.
Combined score: 70% local similarity + 30% MB's own relevance
score (0..1). raw_mb_score preserved separately so the trust
gate can prefer high-MB-score results in cross-script cases
where local similarity is near zero.
Returns empty list on any failure.
"""
try:
results = self.mb_client.search_artist(artist_name, limit=3, strict=strict)
except Exception as e:
logger.debug(
"lookup_artist_aliases: search_artist(%r, strict=%s) raised: %s",
artist_name, strict, e,
)
return []
scored = []
for result in results or []:
mb_name = result.get('name', '')
mb_score = result.get('score', 0)
sim = self._calculate_similarity(artist_name, mb_name)
combined = (sim * 0.7) + (mb_score / 100 * 0.3)
mbid = result.get('id')
if mbid:
scored.append((combined, mbid, mb_score))
return scored
@staticmethod
def _best_score(scored):
return max((s[0] for s in scored), default=0.0) if scored else 0.0
def fetch_artist_aliases(self, mbid: str) -> list:
"""Fetch the alias list for an artist from MusicBrainz.
@ -499,6 +531,14 @@ class MusicBrainzService:
artist record. Pull them so SoulSync can recognise that
`澤野弘之` and `Hiroyuki Sawano` refer to the same artist.
Issue #586 — for some artists MB's CANONICAL `name` is the
non-Latin spelling (e.g. `Дмитрий Яблонский`) while the
Latin spelling lives in `aliases` but the inverse also
happens, where the Latin canonical name has the Cyrillic in
aliases. Either way the canonical `name` and `sort-name` are
themselves valid alternate spellings for matching purposes,
so include them alongside the explicit alias entries.
Returns the deduplicated list of alias `name` strings. Returns
empty list (NOT None) on any failure caller should treat
empty as "no aliases available, fall back to direct match" so
@ -514,24 +554,38 @@ class MusicBrainzService:
return []
if not data:
return []
raw_aliases = data.get('aliases') or []
seen = set()
cleaned = []
def _add(value):
if not isinstance(value, str):
return
text = value.strip()
if not text:
return
key = text.lower()
if key in seen:
return
seen.add(key)
cleaned.append(text)
# Canonical name + sort-name treated as aliases for matching —
# they're the strongest cross-script bridge when MB's
# canonical spelling differs from the user's input.
_add(data.get('name'))
_add(data.get('sort-name'))
# MB returns each alias as a dict with `name`, `sort-name`,
# `locale`, `primary`, `type`, etc. We only care about the
# display name — that's what `actual` artist strings will
# match against.
seen = set()
cleaned = []
for entry in raw_aliases:
# match against. Also pull alias sort-name when present
# (some entries have a different sortable form).
for entry in data.get('aliases') or []:
if not isinstance(entry, dict):
continue
name = (entry.get('name') or '').strip()
if not name:
continue
key = name.lower()
if key in seen:
continue
seen.add(key)
cleaned.append(name)
_add(entry.get('name'))
_add(entry.get('sort-name'))
return cleaned
def update_artist_aliases(self, artist_id: int, aliases: list) -> None:

View file

@ -0,0 +1,24 @@
"""Standardized personalized-playlist subsystem.
Replaces the patchwork of `PersonalizedPlaylistsService` (computed-on-
demand views, no persistence) + `discovery_curated_playlists` (ID-only
storage) + `curated_seasonal_playlists` (full storage) with a single
unified abstraction:
- ``manager.PersonalizedPlaylistManager`` owns the storage layer +
generator dispatch + refresh lifecycle.
- ``specs.PlaylistKindSpec`` one spec per playlist KIND
(``hidden_gems``, ``time_machine``, ``seasonal_mix``, etc.) with
generator function, default config, variant resolver, and display-
name template.
- ``types.Track`` / ``types.PlaylistConfig`` shared dataclasses.
The legacy ``PersonalizedPlaylistsService`` keeps its existing
generator implementations they're called BY the manager rather than
duplicated. This means:
- The improved diversity logic / popularity thresholds / blacklist
filtering all stays.
- New behavior layered on top: persistence, refresh-on-demand,
per-playlist user-tweakable config, staleness windows, listening-
history cross-reference, seeded randomization.
"""

163
core/personalized/api.py Normal file
View file

@ -0,0 +1,163 @@
"""HTTP endpoint handlers for the personalized-playlists subsystem.
Wired into the Flask app from web_server.py. Each handler is a thin
wrapper that:
1. Pulls profile id + manager from request context.
2. Calls one PersonalizedPlaylistManager method.
3. Returns a JSON-serializable shape.
Live routes (registered against the main Flask app):
- GET /api/personalized/playlists list
- GET /api/personalized/kinds registry
- GET /api/personalized/playlist/<kind> singleton
- GET /api/personalized/playlist/<kind>/<variant> variant
- POST /api/personalized/playlist/<kind>/refresh singleton
- POST /api/personalized/playlist/<kind>/<variant>/refresh variant
- PUT /api/personalized/playlist/<kind>/config singleton
- PUT /api/personalized/playlist/<kind>/<variant>/config variant
The handlers themselves are pure functions returning Python dicts so
they're testable without spinning up Flask. The wiring step in
web_server.py wraps them in `jsonify` + URL routing.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from core.personalized.manager import PersonalizedPlaylistManager
from core.personalized.specs import PlaylistKindRegistry, get_registry
from core.personalized.types import PlaylistRecord, Track
def _record_to_dict(record: PlaylistRecord) -> Dict[str, Any]:
return {
'id': record.id,
'profile_id': record.profile_id,
'kind': record.kind,
'variant': record.variant,
'name': record.name,
'config': record.config.to_json_dict(),
'track_count': record.track_count,
'last_generated_at': record.last_generated_at,
'last_synced_at': record.last_synced_at,
'last_generation_source': record.last_generation_source,
'last_generation_error': record.last_generation_error,
}
def _track_to_dict(track: Track) -> Dict[str, Any]:
return {
'spotify_track_id': track.spotify_track_id,
'itunes_track_id': track.itunes_track_id,
'deezer_track_id': track.deezer_track_id,
'track_name': track.track_name,
'artist_name': track.artist_name,
'album_name': track.album_name,
'album_cover_url': track.album_cover_url,
'duration_ms': track.duration_ms,
'popularity': track.popularity,
'track_data_json': track.track_data_json,
'source': track.source,
}
def list_kinds(
registry: Optional[PlaylistKindRegistry] = None,
manager: Optional[PersonalizedPlaylistManager] = None,
) -> Dict[str, Any]:
"""Return every registered playlist kind with metadata.
UI uses this to render the "available playlists" picker. Each
kind reports whether it requires a variant; when a manager is
supplied AND the kind has a variant_resolver, the resolved
variant list is also included so the UI can render variant
checkboxes without a second round-trip per kind."""
reg = registry or get_registry()
out = []
for spec in reg.all():
entry = {
'kind': spec.kind,
'name_template': spec.name_template,
'description': spec.description,
'requires_variant': spec.requires_variant,
'tags': list(spec.tags),
'default_config': spec.default_config.to_json_dict(),
'variants': [],
}
if manager is not None and spec.variant_resolver is not None:
try:
entry['variants'] = list(spec.variant_resolver(manager.deps) or [])
except Exception:
entry['variants'] = []
out.append(entry)
return {'success': True, 'kinds': out}
def list_playlists(manager: PersonalizedPlaylistManager, profile_id: int) -> Dict[str, Any]:
"""List every persisted playlist for a profile."""
records = manager.list_playlists(profile_id)
return {
'success': True,
'playlists': [_record_to_dict(r) for r in records],
}
def get_playlist_with_tracks(
manager: PersonalizedPlaylistManager,
kind: str,
variant: str,
profile_id: int,
) -> Dict[str, Any]:
"""Get the playlist row + its current track snapshot. Auto-creates
the row from default config if it doesn't exist (so the UI's first-
paint of an unseen kind works without a separate ensure call)."""
record = manager.ensure_playlist(kind, variant, profile_id)
tracks = manager.get_playlist_tracks(record.id)
return {
'success': True,
'playlist': _record_to_dict(record),
'tracks': [_track_to_dict(t) for t in tracks],
}
def refresh_playlist(
manager: PersonalizedPlaylistManager,
kind: str,
variant: str,
profile_id: int,
config_overrides: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Run the kind's generator and persist the snapshot. Returns the
fresh row + tracks."""
record = manager.refresh_playlist(kind, variant, profile_id, config_overrides=config_overrides)
tracks = manager.get_playlist_tracks(record.id)
return {
'success': True,
'playlist': _record_to_dict(record),
'tracks': [_track_to_dict(t) for t in tracks],
}
def update_config(
manager: PersonalizedPlaylistManager,
kind: str,
variant: str,
profile_id: int,
overrides: Dict[str, Any],
) -> Dict[str, Any]:
"""Patch the playlist's config with the provided fields."""
record = manager.update_config(kind, variant, profile_id, overrides)
return {
'success': True,
'playlist': _record_to_dict(record),
}
__all__ = [
'list_kinds',
'list_playlists',
'get_playlist_with_tracks',
'refresh_playlist',
'update_config',
]

View file

@ -0,0 +1,27 @@
"""Per-kind generators for the personalized-playlists subsystem.
Each module in this subpackage:
1. Defines a generator function ``generate(deps, variant, config)``
that returns a ``List[Track]``.
2. Calls ``get_registry().register(spec)`` at import time so the
manager auto-discovers it.
The legacy ``core.personalized_playlists.PersonalizedPlaylistsService``
keeps its existing implementations the wrappers in this package
just adapt the call surface (`PlaylistConfig` method kwargs) and
coerce results into ``Track`` instances.
To register every generator, import this package `from
core.personalized import generators` typically done once at
application startup."""
# Importing each module triggers its registration side-effect.
from core.personalized.generators import hidden_gems # noqa: F401
from core.personalized.generators import discovery_shuffle # noqa: F401
from core.personalized.generators import popular_picks # noqa: F401
from core.personalized.generators import time_machine # noqa: F401
from core.personalized.generators import genre_playlist # noqa: F401
from core.personalized.generators import daily_mix # noqa: F401
from core.personalized.generators import fresh_tape # noqa: F401
from core.personalized.generators import archives # noqa: F401
from core.personalized.generators import seasonal_mix # noqa: F401

View file

@ -0,0 +1,37 @@
"""Shared helpers for personalized-playlist generators.
Each per-kind generator module is small + mechanical it pulls the
legacy ``PersonalizedPlaylistsService`` instance off the deps object
and calls the matching method, then coerces results. This module
holds the bits every generator reuses so we don't repeat them
five times."""
from __future__ import annotations
from typing import Any, List
from core.personalized.types import Track
def get_service(deps: Any):
"""Pull the ``PersonalizedPlaylistsService`` instance from deps.
Generators access the service via ``deps.service``. Tests can
pass a fake deps namespace with a ``service`` attribute that
returns a stub. Raises a clear error if the dep isn't wired."""
service = getattr(deps, 'service', None) or (deps.get('service') if isinstance(deps, dict) else None)
if service is None:
raise RuntimeError(
"Personalized generator deps missing `service` "
"(PersonalizedPlaylistsService instance). Wire it during "
"PersonalizedPlaylistManager construction."
)
return service
def coerce_tracks(rows: List[dict]) -> List[Track]:
"""Convert legacy generator output (list of dicts) into Track
instances. Tolerates None / non-list inputs by returning []."""
if not rows:
return []
return [Track.from_dict(row) for row in rows if isinstance(row, dict)]

View file

@ -0,0 +1,35 @@
"""The Archives (Spotify Discover Weekly) generator.
Same shape as Fresh Tape read curated track-id list from
``discovery_curated_playlists`` under ``discovery_weekly_<source>``
(fallback ``discovery_weekly``), hydrate via discovery pool."""
from __future__ import annotations
from typing import Any, List
from core.personalized.generators.fresh_tape import _hydrate_curated
from core.personalized.specs import PlaylistKindSpec, get_registry
from core.personalized.types import PlaylistConfig, Track
KIND = 'archives'
def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]:
return _hydrate_curated(deps, 'discovery_weekly', config)
SPEC = PlaylistKindSpec(
kind=KIND,
name_template='The Archives',
description='Your Spotify Discover Weekly — curated discovery picks.',
default_config=PlaylistConfig(limit=50, max_per_album=5, max_per_artist=10),
generator=generate,
requires_variant=False,
tags=['curated', 'spotify'],
)
if get_registry().get(KIND) is None:
get_registry().register(SPEC)

View file

@ -0,0 +1,83 @@
"""Daily Mix generator — top library genre → discovery picks.
Variant = rank position as a string ('1' / '2' / '3' / '4'). Each
mix tracks the user's Nth top library genre and returns discovery
picks within it. Top genres recompute at refresh time, so as the
library evolves a mix's underlying genre can shift -- the playlist
metadata records which genre was used at the most recent refresh
so the UI can label the mix accurately.
Note: previously this kind ambitiously promised 50% library + 50%
discovery. The library half was a stub (`tracks` table has no
source IDs to sync), so the new generator is discovery-only.
A future enhancement can backfill source IDs into library rows
and re-add the hybrid behavior."""
from __future__ import annotations
from typing import Any, List
from core.personalized.generators._common import coerce_tracks, get_service
from core.personalized.specs import PlaylistKindSpec, get_registry
from core.personalized.types import PlaylistConfig, Track
KIND = 'daily_mix'
# Default rank set — UI surfaces 4 daily mixes by default.
_DEFAULT_RANKS = ('1', '2', '3', '4')
# Number of top library genres to consider when ranking.
_MAX_TOP_GENRES = 8
def _resolve_genre_for_rank(service, rank: int) -> str:
"""Look up the user's Nth-ranked top library genre. Returns the
genre key or '' when no genre at that rank.
Calls ``service.get_top_genres_from_library(limit=...)`` and
indexes the resulting (genre, count) tuples by 0-based rank.
"""
top = service.get_top_genres_from_library(limit=_MAX_TOP_GENRES) or []
if rank < 1 or rank > len(top):
return ''
pair = top[rank - 1]
if not pair:
return ''
# `top` is List[Tuple[str, int]] per service signature.
return pair[0] if isinstance(pair, (tuple, list)) else str(pair)
def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]:
service = get_service(deps)
try:
rank = int(variant)
except (TypeError, ValueError) as exc:
raise ValueError(f"Daily Mix variant {variant!r} must be a rank int") from exc
genre = _resolve_genre_for_rank(service, rank)
if not genre:
# User's library doesn't have enough genres for this rank.
return []
rows = service.get_genre_playlist(genre=genre, limit=config.limit)
return coerce_tracks(rows)
def variant_resolver(deps: Any) -> List[str]:
"""Return the standard rank set."""
return list(_DEFAULT_RANKS)
SPEC = PlaylistKindSpec(
kind=KIND,
name_template='Daily Mix {variant}',
description='Personalized mix based on your top library genres. One mix per top genre rank.',
default_config=PlaylistConfig(limit=50, max_per_album=2, max_per_artist=3),
generator=generate,
variant_resolver=variant_resolver,
requires_variant=True,
tags=['discovery', 'personalized'],
)
if get_registry().get(KIND) is None:
get_registry().register(SPEC)

Some files were not shown because too many files have changed in this diff Show more