Merge branch 'dev' into telegram_thread_id
This commit is contained in:
commit
8450645c52
290 changed files with 49298 additions and 6288 deletions
4
.github/workflows/docker-publish.yml
vendored
4
.github/workflows/docker-publish.yml
vendored
|
|
@ -9,9 +9,9 @@ on:
|
|||
workflow_dispatch:
|
||||
inputs:
|
||||
version_tag:
|
||||
description: 'Version tag (e.g. 2.5.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
1
.gitignore
vendored
|
|
@ -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.*
|
||||
|
||||
|
|
|
|||
10
Dockerfile
10
Dockerfile
|
|
@ -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
|
||||
|
|
|
|||
25
README.md
25
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."})
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
786
core/amazon_client.py
Normal 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 ""),
|
||||
)
|
||||
452
core/amazon_download_client.py
Normal file
452
core/amazon_download_client.py
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
"""Amazon Music download source plugin backed by a T2Tunes proxy.
|
||||
|
||||
NOT yet wired into the app registry — validated in isolation only.
|
||||
See tests/tools/test_amazon_download_client.py.
|
||||
|
||||
Filename encoding (the DownloadSourcePlugin dispatch contract):
|
||||
"{asin}||{display_name}"
|
||||
e.g. "B09XYZ1234||Kendrick Lamar - Not Like Us"
|
||||
|
||||
Codec preference order: FLAC → Opus → EAC3.
|
||||
|
||||
Download flow (from Tubifarry reference implementation):
|
||||
1. GET stream_url → encrypted bytes on disk
|
||||
2. FFmpeg -decryption_key <hex> to decrypt in place
|
||||
3. Embed metadata tags (handled by the app's standard post-processing)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests as http_requests
|
||||
|
||||
from config.settings import config_manager
|
||||
from core.amazon_client import AmazonClient, AmazonClientError
|
||||
from core.download_plugins.base import DownloadSourcePlugin
|
||||
from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("amazon_download_client")
|
||||
|
||||
# Quality / codec helpers
|
||||
CODEC_PREFERENCE = ["flac", "opus", "eac3"]
|
||||
|
||||
_CODEC_EXTENSIONS: Dict[str, str] = {
|
||||
"flac": "flac",
|
||||
"ogg_vorbis": "ogg",
|
||||
"opus": "opus",
|
||||
"eac3": "eac3",
|
||||
"mp4": "m4a",
|
||||
"aac": "m4a",
|
||||
"mp3": "mp3",
|
||||
}
|
||||
|
||||
MIN_AUDIO_BYTES = 512 * 1024 # 512 KB — anything smaller is a broken stream
|
||||
|
||||
|
||||
def _codec_key(codec: str) -> str:
|
||||
return codec.lower().replace("-", "_").replace(" ", "_")
|
||||
|
||||
|
||||
def _file_extension(codec: str) -> str:
|
||||
return _CODEC_EXTENSIONS.get(_codec_key(codec), "bin")
|
||||
|
||||
|
||||
def _quality_label(codec: str, sample_rate: Optional[int] = None) -> str:
|
||||
ck = _codec_key(codec)
|
||||
if ck == "flac":
|
||||
if sample_rate and sample_rate > 48000:
|
||||
return "Hi-Res"
|
||||
return "Lossless"
|
||||
return "Lossy"
|
||||
|
||||
|
||||
class AmazonDownloadClient(DownloadSourcePlugin):
|
||||
"""DownloadSourcePlugin — Amazon Music via T2Tunes proxy."""
|
||||
|
||||
def __init__(self, download_path: Optional[str] = None) -> None:
|
||||
if download_path is None:
|
||||
download_path = config_manager.get("soulseek.download_path", "./downloads")
|
||||
self.download_path = Path(download_path)
|
||||
self.download_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._quality = config_manager.get("amazon_download.quality", "flac")
|
||||
self._allow_fallback = config_manager.get("amazon_download.allow_fallback", True)
|
||||
|
||||
self._client = AmazonClient(preferred_codec=self._quality)
|
||||
self.session = http_requests.Session()
|
||||
self.session.headers.update({
|
||||
"User-Agent": "SoulSync/1.0",
|
||||
"Accept": "*/*",
|
||||
})
|
||||
|
||||
self._engine: Any = None
|
||||
self.shutdown_check: Any = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DownloadSourcePlugin — lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_engine(self, engine) -> None:
|
||||
"""Engine callback — wires the central thread worker + state store."""
|
||||
self._engine = engine
|
||||
|
||||
def set_shutdown_check(self, check_callable) -> None:
|
||||
self.shutdown_check = check_callable
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
# T2Tunes has a public default instance; no credentials required.
|
||||
# Return True unconditionally so the source shows as available.
|
||||
return True
|
||||
|
||||
async def check_connection(self) -> bool:
|
||||
try:
|
||||
return self._client.is_authenticated()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DownloadSourcePlugin — search
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
timeout: Optional[int] = None,
|
||||
progress_callback: Any = None,
|
||||
) -> Tuple[List[TrackResult], List[AlbumResult]]:
|
||||
try:
|
||||
items = self._client.search_raw(query, types="track,album")
|
||||
except AmazonClientError as exc:
|
||||
logger.warning(f"Amazon search failed for {query!r}: {exc}")
|
||||
return [], []
|
||||
|
||||
track_results: List[TrackResult] = []
|
||||
album_map: Dict[str, AlbumResult] = {}
|
||||
album_order: List[str] = []
|
||||
preferred = self._client.preferred_codec
|
||||
|
||||
for item in items:
|
||||
quality = _quality_label(preferred)
|
||||
if item.is_track:
|
||||
track_results.append(TrackResult(
|
||||
username="amazon",
|
||||
filename=f"{item.asin}||{item.artist_name} - {item.title}",
|
||||
size=0,
|
||||
bitrate=None,
|
||||
duration=item.duration_seconds * 1000 if item.duration_seconds else None,
|
||||
quality=quality,
|
||||
free_upload_slots=999,
|
||||
upload_speed=999_999,
|
||||
queue_length=0,
|
||||
artist=item.artist_name,
|
||||
title=item.title,
|
||||
album=item.album_name,
|
||||
_source_metadata={
|
||||
"asin": item.asin,
|
||||
"album_asin": item.album_asin,
|
||||
"isrc": item.isrc,
|
||||
},
|
||||
))
|
||||
elif item.is_album:
|
||||
album_asin = item.album_asin or item.asin
|
||||
if album_asin not in album_map:
|
||||
placeholder = TrackResult(
|
||||
username="amazon",
|
||||
filename=f"{item.asin}||{item.artist_name} - {item.title}",
|
||||
size=0,
|
||||
bitrate=None,
|
||||
duration=None,
|
||||
quality=quality,
|
||||
free_upload_slots=999,
|
||||
upload_speed=999_999,
|
||||
queue_length=0,
|
||||
artist=item.artist_name,
|
||||
title=item.title,
|
||||
album=item.album_name,
|
||||
)
|
||||
album_map[album_asin] = AlbumResult(
|
||||
username="amazon",
|
||||
album_path=album_asin,
|
||||
album_title=item.album_name or item.title,
|
||||
artist=item.artist_name,
|
||||
track_count=0,
|
||||
total_size=0,
|
||||
tracks=[placeholder],
|
||||
dominant_quality=quality,
|
||||
)
|
||||
album_order.append(album_asin)
|
||||
|
||||
return track_results, [album_map[k] for k in album_order]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DownloadSourcePlugin — download dispatch
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def download(
|
||||
self,
|
||||
username: str,
|
||||
filename: str,
|
||||
file_size: int = 0,
|
||||
) -> Optional[str]:
|
||||
if "||" not in filename:
|
||||
logger.error(f"Invalid Amazon filename format (no '||'): {filename!r}")
|
||||
return None
|
||||
if self._engine is None:
|
||||
raise RuntimeError(
|
||||
"AmazonDownloadClient._engine is not set — "
|
||||
"client not connected to download infrastructure"
|
||||
)
|
||||
asin, display_name = filename.split("||", 1)
|
||||
asin = asin.strip()
|
||||
display_name = display_name.strip()
|
||||
return self._engine.worker.dispatch(
|
||||
source_name="amazon",
|
||||
target_id=asin,
|
||||
display_name=display_name,
|
||||
original_filename=filename,
|
||||
impl_callable=self._download_sync,
|
||||
)
|
||||
|
||||
def _download_sync(
|
||||
self,
|
||||
download_id: str,
|
||||
target_id: str,
|
||||
display_name: str,
|
||||
) -> Optional[str]:
|
||||
asin = str(target_id)
|
||||
codecs = CODEC_PREFERENCE if self._allow_fallback else [self._quality]
|
||||
for codec in codecs:
|
||||
try:
|
||||
streams = self._client.media_from_asin(asin, codec=codec)
|
||||
except AmazonClientError as exc:
|
||||
logger.warning(f"media_from_asin({asin!r}, {codec}) failed: {exc}")
|
||||
continue
|
||||
|
||||
stream = next(
|
||||
(s for s in streams if s.streamable and s.stream_url),
|
||||
None,
|
||||
)
|
||||
if not stream:
|
||||
logger.debug(f"No streamable result for {asin} at codec={codec}")
|
||||
continue
|
||||
|
||||
ext = _file_extension(stream.codec or codec)
|
||||
safe = "".join(
|
||||
ch if ch.isalnum() or ch in " -_." else "_"
|
||||
for ch in display_name
|
||||
)[:80]
|
||||
# T2Tunes always serves audio in an encrypted MP4 container.
|
||||
# The codec extension (.flac, .opus, .eac3) is only for the
|
||||
# final decrypted output.
|
||||
enc_ext = "mp4" if stream.decryption_key else ext
|
||||
enc_path = self._unique_path(self.download_path / f"{safe}.enc.{enc_ext}")
|
||||
out_path = self._unique_path(self.download_path / f"{safe}.{ext}")
|
||||
|
||||
if self._engine is not None:
|
||||
self._engine.update_record(
|
||||
"amazon", download_id, {"state": "downloading", "progress": 0.0}
|
||||
)
|
||||
try:
|
||||
downloaded = self._stream_to_file(stream.stream_url, enc_path, download_id)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Stream download failed for {asin} ({codec}): {exc}")
|
||||
enc_path.unlink(missing_ok=True)
|
||||
continue
|
||||
|
||||
if downloaded < MIN_AUDIO_BYTES:
|
||||
logger.warning(
|
||||
f"File too small ({downloaded} B) for {asin} at {codec} — trying next"
|
||||
)
|
||||
enc_path.unlink(missing_ok=True)
|
||||
continue
|
||||
|
||||
if stream.decryption_key:
|
||||
if self._engine is not None:
|
||||
self._engine.update_record(
|
||||
"amazon", download_id, {"state": "decrypting", "progress": 1.0}
|
||||
)
|
||||
try:
|
||||
self._decrypt_with_ffmpeg(enc_path, out_path, stream.decryption_key)
|
||||
enc_path.unlink(missing_ok=True)
|
||||
except Exception as exc:
|
||||
logger.error(f"Decryption failed for {asin} ({codec}): {exc}")
|
||||
enc_path.unlink(missing_ok=True)
|
||||
out_path.unlink(missing_ok=True)
|
||||
continue
|
||||
else:
|
||||
enc_path.rename(out_path)
|
||||
|
||||
final_size = out_path.stat().st_size
|
||||
logger.info(
|
||||
f"Amazon download complete ({codec}): {out_path} "
|
||||
f"({final_size / (1024 * 1024):.1f} MB)"
|
||||
)
|
||||
# Sync size == transferred so the download monitor's bytes-incomplete
|
||||
# guard doesn't block post-processing. The throttled updates in
|
||||
# _stream_to_file leave transferred < size after the last 0.5s tick;
|
||||
# other streaming clients avoid this by not tracking bytes at all
|
||||
# (size stays 0, the guard is skipped). Writing the final output size
|
||||
# here restores parity.
|
||||
if self._engine is not None:
|
||||
self._engine.update_record(
|
||||
"amazon", download_id, {'size': final_size, 'transferred': final_size}
|
||||
)
|
||||
return str(out_path)
|
||||
|
||||
logger.error(f"All codec tiers exhausted for '{display_name}' ({asin})")
|
||||
return None
|
||||
|
||||
def _decrypt_with_ffmpeg(
|
||||
self, enc_path: Path, out_path: Path, hex_key: str
|
||||
) -> None:
|
||||
"""Decrypt a T2Tunes encrypted audio file using FFmpeg -decryption_key.
|
||||
|
||||
T2Tunes uses CENC (Common Encryption) for DRM-protected tracks.
|
||||
FFmpeg supports decryption via the -decryption_key flag when the
|
||||
key is provided in hex.
|
||||
"""
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
if not ffmpeg:
|
||||
tools_dir = Path(__file__).parent.parent / "tools"
|
||||
candidate = tools_dir / ("ffmpeg.exe" if os.name == "nt" else "ffmpeg")
|
||||
if candidate.exists():
|
||||
ffmpeg = str(candidate)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"ffmpeg is required for Amazon Music decryption. "
|
||||
"Install ffmpeg and ensure it is on your PATH."
|
||||
)
|
||||
cmd = [
|
||||
ffmpeg,
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-decryption_key", hex_key,
|
||||
"-i", str(enc_path),
|
||||
"-map", "0:a:0", # extract first audio stream (FLAC/Opus/EAC3 inside MP4)
|
||||
"-c", "copy",
|
||||
str(out_path),
|
||||
]
|
||||
logger.debug(f"Decrypting {enc_path.name} → {out_path.name}")
|
||||
result = subprocess.run(cmd, capture_output=True)
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.decode("utf-8", errors="replace").strip()
|
||||
raise RuntimeError(f"FFmpeg decryption failed (exit {result.returncode}): {stderr}")
|
||||
|
||||
def _stream_to_file(self, url: str, out_path: Path, download_id: str) -> int:
|
||||
resp = self.session.get(url, stream=True, timeout=60)
|
||||
resp.raise_for_status()
|
||||
|
||||
total = int(resp.headers.get("content-length", 0))
|
||||
downloaded = 0
|
||||
last_report = time.monotonic()
|
||||
shutdown_triggered = False
|
||||
|
||||
with out_path.open("wb") as fh:
|
||||
for chunk in resp.iter_content(chunk_size=64 * 1024):
|
||||
if not chunk:
|
||||
continue
|
||||
if self.shutdown_check and self.shutdown_check():
|
||||
shutdown_triggered = True
|
||||
break
|
||||
fh.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
now = time.monotonic()
|
||||
if self._engine and now - last_report >= 0.5:
|
||||
self._engine.update_record(
|
||||
"amazon",
|
||||
download_id,
|
||||
{
|
||||
"transferred": downloaded,
|
||||
"size": total,
|
||||
"progress": downloaded / total if total else 0.0,
|
||||
},
|
||||
)
|
||||
last_report = now
|
||||
|
||||
if shutdown_triggered:
|
||||
out_path.unlink(missing_ok=True)
|
||||
raise RuntimeError("Shutdown requested mid-download")
|
||||
|
||||
return downloaded
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DownloadSourcePlugin — status interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_all_downloads(self) -> List[DownloadStatus]:
|
||||
if self._engine is None:
|
||||
return []
|
||||
return [
|
||||
self._record_to_status(record)
|
||||
for record in self._engine.iter_records_for_source('amazon')
|
||||
]
|
||||
|
||||
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
||||
if self._engine is None:
|
||||
return None
|
||||
record = self._engine.get_record('amazon', download_id)
|
||||
return self._record_to_status(record) if record is not None else None
|
||||
|
||||
async def cancel_download(
|
||||
self,
|
||||
download_id: str,
|
||||
username: Optional[str] = None,
|
||||
remove: bool = False,
|
||||
) -> bool:
|
||||
if self._engine is None:
|
||||
return False
|
||||
if self._engine.get_record('amazon', download_id) is None:
|
||||
return False
|
||||
self._engine.update_record('amazon', download_id, {'state': 'Cancelled'})
|
||||
if remove:
|
||||
self._engine.remove_record('amazon', download_id)
|
||||
return True
|
||||
|
||||
async def clear_all_completed_downloads(self) -> bool:
|
||||
if self._engine is None:
|
||||
return True
|
||||
terminal = {'Completed, Succeeded', 'Cancelled', 'Errored', 'Aborted'}
|
||||
for record in list(self._engine.iter_records_for_source('amazon')):
|
||||
if record.get('state') in terminal:
|
||||
self._engine.remove_record('amazon', record['id'])
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _unique_path(path: Path) -> Path:
|
||||
if not path.exists():
|
||||
return path
|
||||
stem, suffix = path.stem, path.suffix
|
||||
for i in range(1, 100):
|
||||
candidate = path.with_name(f"{stem} ({i}){suffix}")
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
return path.with_name(f"{stem}_{uuid.uuid4().hex[:8]}{suffix}")
|
||||
|
||||
@staticmethod
|
||||
def _record_to_status(rec: Dict[str, Any]) -> DownloadStatus:
|
||||
return DownloadStatus(
|
||||
id=str(rec.get('id', '')),
|
||||
filename=str(rec.get('filename', '')),
|
||||
username='amazon',
|
||||
state=str(rec.get('state', 'queued')),
|
||||
progress=float(rec.get('progress', 0.0)),
|
||||
size=int(rec.get('size', 0)),
|
||||
transferred=int(rec.get('transferred', 0)),
|
||||
speed=int(rec.get('speed', 0)),
|
||||
time_remaining=rec.get('time_remaining'),
|
||||
file_path=rec.get('file_path'),
|
||||
)
|
||||
602
core/amazon_worker.py
Normal file
602
core/amazon_worker.py
Normal 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()
|
||||
|
|
@ -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
238
core/archive_pipeline.py
Normal 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
|
||||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
"""Automation API + progress tracking helpers package.
|
||||
"""Automation API + progress + handlers package.
|
||||
|
||||
Lifted from web_server.py /api/automations/* routes and progress
|
||||
emitters. The action handler registration (`_register_automation_handlers`)
|
||||
stays in web_server.py because each handler closure is tightly coupled
|
||||
to other application features.
|
||||
Lifted from web_server.py:
|
||||
- `/api/automations/*` route helpers → `api.py`
|
||||
- block library used by the trigger/action UI → `blocks.py`
|
||||
- progress tracker (init / update / finish) → `progress.py`
|
||||
- cross-handler signal bus → `signals.py`
|
||||
- per-action handler functions → `handlers/` subpackage (with
|
||||
`deps.py` defining the dependency-injection surface so handlers
|
||||
stay testable in isolation)
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -146,6 +146,15 @@ ACTIONS: list[dict] = [
|
|||
{"key": "all", "type": "checkbox", "label": "Process all mirrored playlists", "default": False},
|
||||
{"key": "skip_wishlist", "type": "checkbox", "label": "Skip wishlist processing", "default": False},
|
||||
]},
|
||||
{"type": "personalized_pipeline", "label": "Personalized Playlist Pipeline", "icon": "sparkles",
|
||||
"description": "Sync personalized / discover-page playlists (Hidden Gems, Time Machine, Fresh Tape, etc.) to your media server + queue missing tracks for download.",
|
||||
"available": True,
|
||||
"config_fields": [
|
||||
{"key": "kinds", "type": "personalized_playlist_select", "label": "Playlists to sync",
|
||||
"description": "Multi-select: Hidden Gems, Discovery Shuffle, Time Machine (per decade), Genre playlists, Fresh Tape, The Archives, Seasonal Mix (per season)"},
|
||||
{"key": "refresh_first", "type": "checkbox", "label": "Refresh playlists before sync (regenerate snapshots)", "default": False},
|
||||
{"key": "skip_wishlist", "type": "checkbox", "label": "Skip wishlist processing", "default": False},
|
||||
]},
|
||||
{"type": "notify_only", "label": "Notify Only", "icon": "bell", "description": "No action — just send notification", "available": True},
|
||||
# Phase 3 actions
|
||||
{"type": "start_database_update", "label": "Update Database", "icon": "database",
|
||||
|
|
|
|||
146
core/automation/deps.py
Normal file
146
core/automation/deps.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""Dependency-injection surface for automation handlers.
|
||||
|
||||
Each handler in ``core.automation.handlers`` is a top-level pure
|
||||
function that accepts ``(config: dict, deps: AutomationDeps)`` instead
|
||||
of reaching for module-level globals in ``web_server``. The deps
|
||||
namespace bundles every callable, client, and mutable-state container
|
||||
the handlers need.
|
||||
|
||||
Construction happens once at app startup in ``web_server.py``:
|
||||
|
||||
from core.automation.deps import AutomationDeps, AutomationState
|
||||
state = AutomationState()
|
||||
deps = AutomationDeps(
|
||||
engine=automation_engine,
|
||||
state=state,
|
||||
get_database=get_database,
|
||||
spotify_client=spotify_client,
|
||||
...
|
||||
)
|
||||
register_all(deps)
|
||||
|
||||
Tests construct a fake ``AutomationDeps`` with stub callables — every
|
||||
handler is then exercisable without spinning up Flask, the DB, or
|
||||
the real media-server clients.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class AutomationState:
|
||||
"""Mutable flags shared across handler invocations.
|
||||
|
||||
Pre-refactor each was a ``global`` or ``nonlocal`` variable inside
|
||||
the registration closure. Lifted here so handlers + their guards
|
||||
can read/write a single object instead of importing globals.
|
||||
|
||||
All mutations should hold ``lock``; the helper methods below do
|
||||
so for the common get/set patterns.
|
||||
"""
|
||||
|
||||
scan_library_automation_id: Optional[str] = None
|
||||
db_update_automation_id: Optional[str] = None
|
||||
pipeline_running: bool = False
|
||||
lock: threading.Lock = field(default_factory=threading.Lock)
|
||||
|
||||
def is_scan_library_active(self) -> bool:
|
||||
with self.lock:
|
||||
return self.scan_library_automation_id is not None
|
||||
|
||||
def is_pipeline_running(self) -> bool:
|
||||
with self.lock:
|
||||
return self.pipeline_running
|
||||
|
||||
def set_scan_library_id(self, automation_id: Optional[str]) -> None:
|
||||
with self.lock:
|
||||
self.scan_library_automation_id = automation_id
|
||||
|
||||
def set_pipeline_running(self, value: bool) -> None:
|
||||
with self.lock:
|
||||
self.pipeline_running = value
|
||||
|
||||
|
||||
@dataclass
|
||||
class AutomationDeps:
|
||||
"""Bundle of every callable + client an automation handler may need.
|
||||
|
||||
Add fields as new handlers are extracted. Every field is required
|
||||
at construction (no defaults) so a missing dep fails loudly at
|
||||
startup, not silently mid-handler.
|
||||
"""
|
||||
|
||||
# --- Engine + shared state ---
|
||||
engine: Any # AutomationEngine instance
|
||||
state: AutomationState
|
||||
config_manager: Any # config.settings.ConfigManager singleton
|
||||
update_progress: Callable[..., None] # _update_automation_progress
|
||||
logger: Any # module-level logger from utils.logging_config
|
||||
|
||||
# --- Service clients (each may be None depending on user config) ---
|
||||
get_database: Callable[[], Any] # late-binding so tests don't need DB
|
||||
spotify_client: Any
|
||||
tidal_client: Any
|
||||
web_scan_manager: Any
|
||||
|
||||
# --- Background-task entry points ---
|
||||
process_wishlist_automatically: Callable[..., Any]
|
||||
process_watchlist_scan_automatically: Callable[..., Any]
|
||||
is_wishlist_actually_processing: Callable[[], bool]
|
||||
is_watchlist_actually_scanning: Callable[[], bool]
|
||||
get_watchlist_scan_state: Callable[[], dict] # accessor returns the live mutable dict
|
||||
|
||||
# --- Playlist pipeline entry points ---
|
||||
run_playlist_discovery_worker: Callable[..., Any]
|
||||
run_sync_task: Callable[..., Any]
|
||||
load_sync_status_file: Callable[[], dict]
|
||||
get_deezer_client: Callable[[], Any]
|
||||
parse_youtube_playlist: Callable[[str], Any]
|
||||
get_sync_states: Callable[[], dict] # accessor returns the live dict shared with the sync UI
|
||||
|
||||
# --- Database update + quality scanner (shared state + executors) ---
|
||||
set_db_update_automation_id: Callable[[Optional[str]], None] # syncs the legacy `_db_update_automation_id` global so the live DB-update progress callbacks (which still read the global directly) keep firing for the active automation
|
||||
get_db_update_state: Callable[[], dict]
|
||||
db_update_lock: Any # threading.Lock
|
||||
db_update_executor: Any # ThreadPoolExecutor
|
||||
run_db_update_task: Callable[..., Any]
|
||||
run_deep_scan_task: Callable[..., Any]
|
||||
get_duplicate_cleaner_state: Callable[[], dict]
|
||||
duplicate_cleaner_lock: Any
|
||||
duplicate_cleaner_executor: Any
|
||||
run_duplicate_cleaner: Callable[..., Any]
|
||||
get_quality_scanner_state: Callable[[], dict]
|
||||
quality_scanner_lock: Any
|
||||
quality_scanner_executor: Any
|
||||
run_quality_scanner: Callable[..., Any]
|
||||
|
||||
# --- Download orchestrator + queue accessors ---
|
||||
download_orchestrator: Any
|
||||
run_async: Callable[..., Any]
|
||||
tasks_lock: Any
|
||||
get_download_batches: Callable[[], dict]
|
||||
get_download_tasks: Callable[[], dict]
|
||||
sweep_empty_download_directories: Callable[[], int]
|
||||
get_staging_path: Callable[[], str]
|
||||
|
||||
# --- Maintenance helpers ---
|
||||
docker_resolve_path: Callable[[str], str]
|
||||
get_current_profile_id: Callable[[], int]
|
||||
get_watchlist_scanner: Callable[[Any], Any]
|
||||
get_app: Callable[[], Any] # Flask app for test_client (beatport refresh)
|
||||
get_beatport_data_cache: Callable[[], dict]
|
||||
|
||||
# --- Progress + history callbacks (used by register_all to wire
|
||||
# the engine's progress callback hooks). ---
|
||||
init_automation_progress: Callable[..., Any]
|
||||
record_progress_history: Callable[..., Any]
|
||||
|
||||
# --- Personalized playlist pipeline ---
|
||||
# Lazy builder so the pipeline handler can construct a fresh
|
||||
# PersonalizedPlaylistManager per run (cheap accessors inside,
|
||||
# no caching needed yet).
|
||||
build_personalized_manager: Callable[[], Any]
|
||||
64
core/automation/handlers/__init__.py
Normal file
64
core/automation/handlers/__init__.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""Per-action automation handlers.
|
||||
|
||||
Each module in this subpackage exposes one top-level handler function
|
||||
(or a small cluster of related handlers) of the form::
|
||||
|
||||
def auto_<action_name>(config: dict, deps: AutomationDeps) -> dict
|
||||
|
||||
The ``register_all`` helper in :mod:`registration` wires every handler
|
||||
to the engine in one place. ``web_server.py`` calls
|
||||
``register_all(deps)`` once at startup.
|
||||
"""
|
||||
|
||||
from core.automation.handlers.process_wishlist import auto_process_wishlist
|
||||
from core.automation.handlers.scan_watchlist import auto_scan_watchlist
|
||||
from core.automation.handlers.scan_library import auto_scan_library
|
||||
from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored
|
||||
from core.automation.handlers.sync_playlist import auto_sync_playlist
|
||||
from core.automation.handlers.discover_playlist import auto_discover_playlist
|
||||
from core.automation.handlers.playlist_pipeline import auto_playlist_pipeline
|
||||
from core.automation.handlers.personalized_pipeline import auto_personalized_pipeline
|
||||
from core.automation.handlers.database_update import auto_start_database_update, auto_deep_scan_library
|
||||
from core.automation.handlers.duplicate_cleaner import auto_run_duplicate_cleaner
|
||||
from core.automation.handlers.quality_scanner import auto_start_quality_scan
|
||||
from core.automation.handlers.maintenance import (
|
||||
auto_clear_quarantine,
|
||||
auto_cleanup_wishlist,
|
||||
auto_update_discovery_pool,
|
||||
auto_backup_database,
|
||||
auto_refresh_beatport_cache,
|
||||
)
|
||||
from core.automation.handlers.download_cleanup import (
|
||||
auto_clean_search_history,
|
||||
auto_clean_completed_downloads,
|
||||
auto_full_cleanup,
|
||||
)
|
||||
from core.automation.handlers.run_script import auto_run_script
|
||||
from core.automation.handlers.search_and_download import auto_search_and_download
|
||||
from core.automation.handlers.registration import register_all
|
||||
|
||||
__all__ = [
|
||||
'auto_process_wishlist',
|
||||
'auto_scan_watchlist',
|
||||
'auto_scan_library',
|
||||
'auto_refresh_mirrored',
|
||||
'auto_sync_playlist',
|
||||
'auto_discover_playlist',
|
||||
'auto_playlist_pipeline',
|
||||
'auto_personalized_pipeline',
|
||||
'auto_start_database_update',
|
||||
'auto_deep_scan_library',
|
||||
'auto_run_duplicate_cleaner',
|
||||
'auto_start_quality_scan',
|
||||
'auto_clear_quarantine',
|
||||
'auto_cleanup_wishlist',
|
||||
'auto_update_discovery_pool',
|
||||
'auto_backup_database',
|
||||
'auto_refresh_beatport_cache',
|
||||
'auto_clean_search_history',
|
||||
'auto_clean_completed_downloads',
|
||||
'auto_full_cleanup',
|
||||
'auto_run_script',
|
||||
'auto_search_and_download',
|
||||
'register_all',
|
||||
]
|
||||
201
core/automation/handlers/_pipeline_shared.py
Normal file
201
core/automation/handlers/_pipeline_shared.py
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
"""Shared helpers between mirrored + personalized playlist pipelines.
|
||||
|
||||
Both pipelines end in the same shape:
|
||||
1. SYNC each playlist to the active media server.
|
||||
2. WISHLIST: trigger the wishlist processor for missing tracks.
|
||||
|
||||
The differing prefix (mirrored = REFRESH external sources + DISCOVER
|
||||
metadata; personalized = SNAPSHOT manager-backed playlists) is owned
|
||||
by each pipeline. This module owns the SYNC + WISHLIST tail so both
|
||||
pipelines stay consistent + DRY.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
# Per-playlist sync poll cap (mirrored side already used this).
|
||||
_SYNC_PER_PLAYLIST_TIMEOUT_SECONDS = 600
|
||||
# Sync-status final-state markers.
|
||||
_SYNC_TERMINAL_STATUSES = ('finished', 'complete', 'error', 'failed')
|
||||
|
||||
|
||||
def run_sync_and_wishlist(
|
||||
deps: AutomationDeps,
|
||||
automation_id: Optional[str],
|
||||
playlists: List[Dict[str, Any]],
|
||||
*,
|
||||
sync_one_fn: Callable[[Dict[str, Any]], Dict[str, Any]],
|
||||
sync_id_for_fn: Callable[[Dict[str, Any]], str],
|
||||
skip_wishlist: bool = False,
|
||||
progress_start: int = 56,
|
||||
progress_end: int = 85,
|
||||
sync_phase_label: str = 'Phase: Syncing to server...',
|
||||
sync_phase_start_log: str = 'Sync',
|
||||
wishlist_phase_label: str = 'Phase: Processing wishlist...',
|
||||
wishlist_phase_start_log: str = 'Wishlist',
|
||||
) -> Dict[str, int]:
|
||||
"""Run the SYNC + WISHLIST tail of a playlist pipeline.
|
||||
|
||||
The caller supplies:
|
||||
- ``playlists``: list of playlist payload dicts. Each must have at
|
||||
least a ``name`` key (used in progress logs). The shape beyond
|
||||
``name`` is opaque to the helper — ``sync_one_fn`` receives the
|
||||
payload and returns a sync_result dict.
|
||||
- ``sync_one_fn(payload) -> sync_result``: launches sync for one
|
||||
playlist. Result dict must carry ``status`` ∈ ``('started',
|
||||
'skipped', 'error')`` and may carry ``reason``.
|
||||
- ``sync_id_for_fn(payload) -> str``: returns the sync-state key
|
||||
the helper polls on (so we can wait for the background sync
|
||||
thread to complete + read the matched_tracks count).
|
||||
|
||||
Returns ``{'synced': int, 'skipped': int, 'errors': int,
|
||||
'wishlist_queued': int}`` so the caller can stitch it into its
|
||||
final status.
|
||||
"""
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=progress_start,
|
||||
phase=sync_phase_label,
|
||||
log_line=sync_phase_start_log,
|
||||
log_type='info',
|
||||
)
|
||||
|
||||
total_synced = 0
|
||||
total_skipped = 0
|
||||
sync_errors = 0
|
||||
sync_states = deps.get_sync_states()
|
||||
n_playlists = max(1, len(playlists))
|
||||
progress_span = max(1, progress_end - progress_start - 1)
|
||||
|
||||
for pl_idx, pl in enumerate(playlists):
|
||||
pl_name = pl.get('name', '')
|
||||
sync_result = sync_one_fn(pl)
|
||||
sync_status = sync_result.get('status', '')
|
||||
|
||||
if sync_status == 'started':
|
||||
sync_id = sync_id_for_fn(pl)
|
||||
sync_poll_start = time.time()
|
||||
while time.time() - sync_poll_start < _SYNC_PER_PLAYLIST_TIMEOUT_SECONDS:
|
||||
if (sync_id in sync_states
|
||||
and sync_states[sync_id].get('status') in _SYNC_TERMINAL_STATUSES):
|
||||
break
|
||||
time.sleep(2)
|
||||
elapsed = int(time.time() - sync_poll_start)
|
||||
sub_progress = progress_start + 1 + ((pl_idx + 1) / n_playlists) * progress_span
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=min(int(sub_progress), progress_end - 1),
|
||||
phase=f'{sync_phase_label.rstrip(".")} — "{pl_name}" ({elapsed}s)',
|
||||
)
|
||||
|
||||
ss = sync_states.get(sync_id, {})
|
||||
ss_result = ss.get('result', ss.get('progress', {}))
|
||||
matched = ss_result.get('matched_tracks', 0) if isinstance(ss_result, dict) else 0
|
||||
total_synced += int(matched) if matched else 0
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Synced "{pl_name}": {matched} tracks matched',
|
||||
log_type='success',
|
||||
)
|
||||
|
||||
elif sync_status == 'skipped':
|
||||
total_skipped += 1
|
||||
reason = sync_result.get('reason', 'unchanged')
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Skipped "{pl_name}": {reason}',
|
||||
log_type='skip',
|
||||
)
|
||||
elif sync_status == 'error':
|
||||
sync_errors += 1
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Sync error "{pl_name}": {sync_result.get("reason", "unknown")}',
|
||||
log_type='error',
|
||||
)
|
||||
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=progress_end,
|
||||
phase=f'{sync_phase_label.rstrip(".")} complete',
|
||||
log_line=f'Sync done: {total_synced} matched, {total_skipped} skipped, {sync_errors} errors',
|
||||
log_type='success' if sync_errors == 0 else 'warning',
|
||||
)
|
||||
|
||||
wishlist_queued = run_wishlist_phase(
|
||||
deps, automation_id,
|
||||
skip=skip_wishlist,
|
||||
progress_pct=progress_end + 1,
|
||||
wishlist_phase_label=wishlist_phase_label,
|
||||
wishlist_phase_start_log=wishlist_phase_start_log,
|
||||
)
|
||||
|
||||
return {
|
||||
'synced': total_synced,
|
||||
'skipped': total_skipped,
|
||||
'errors': sync_errors,
|
||||
'wishlist_queued': wishlist_queued,
|
||||
}
|
||||
|
||||
|
||||
def run_wishlist_phase(
|
||||
deps: AutomationDeps,
|
||||
automation_id: Optional[str],
|
||||
*,
|
||||
skip: bool,
|
||||
progress_pct: int,
|
||||
wishlist_phase_label: str = 'Phase: Processing wishlist...',
|
||||
wishlist_phase_start_log: str = 'Wishlist',
|
||||
) -> int:
|
||||
"""Trigger the wishlist processor unless skipped or already running.
|
||||
|
||||
Returns 1 when the processor was triggered, 0 otherwise. Errors are
|
||||
logged but never raised — wishlist failure should not abort the
|
||||
pipeline."""
|
||||
if skip:
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=progress_pct,
|
||||
log_line=f'{wishlist_phase_start_log}: skipped (disabled)',
|
||||
log_type='skip',
|
||||
)
|
||||
return 0
|
||||
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=progress_pct,
|
||||
phase=wishlist_phase_label,
|
||||
log_line=wishlist_phase_start_log,
|
||||
log_type='info',
|
||||
)
|
||||
|
||||
try:
|
||||
if not deps.is_wishlist_actually_processing():
|
||||
deps.process_wishlist_automatically(automation_id=None)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line='Wishlist processing triggered',
|
||||
log_type='success',
|
||||
)
|
||||
return 1
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line='Wishlist already running — skipped',
|
||||
log_type='skip',
|
||||
)
|
||||
return 0
|
||||
except Exception as e: # noqa: BLE001 — wishlist failure must never abort pipeline
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Wishlist error: {e}',
|
||||
log_type='warning',
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
__all__ = ['run_sync_and_wishlist', 'run_wishlist_phase']
|
||||
136
core/automation/handlers/database_update.py
Normal file
136
core/automation/handlers/database_update.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"""Automation handlers: ``start_database_update`` and
|
||||
``deep_scan_library`` actions.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers`` (the
|
||||
``_auto_start_database_update`` and ``_auto_deep_scan_library``
|
||||
closures). Both share the same ``db_update_state`` / executor / lock
|
||||
infrastructure -- the only difference is which task they submit
|
||||
(``run_db_update_task`` vs ``run_deep_scan_task``).
|
||||
|
||||
Pattern: pre-set state to running, submit task to executor, then
|
||||
poll the state dict until it transitions away from ``running``.
|
||||
Stall-detection emits a warning every 10 minutes when progress
|
||||
hasn't budged. 2-hour outer timeout caps the worst case.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
_TIMEOUT_SECONDS = 7200 # 2 hours — covers the worst large-library case
|
||||
_STALL_WARNING_SECONDS = 600 # 10 minutes without progress = stall
|
||||
_POLL_INTERVAL_SECONDS = 3
|
||||
_INITIAL_DELAY_SECONDS = 1
|
||||
|
||||
|
||||
def auto_start_database_update(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Run a full or incremental DB update via ``run_db_update_task``."""
|
||||
return _run_with_progress(
|
||||
config, deps,
|
||||
task=deps.run_db_update_task,
|
||||
task_args=(config.get('full_refresh', False), deps.config_manager.get_active_media_server()),
|
||||
initial_phase='Initializing...',
|
||||
stall_label='Database update',
|
||||
finished_extras=lambda: {'full_refresh': str(config.get('full_refresh', False))},
|
||||
timeout_label='Database update timed out after 2 hours',
|
||||
)
|
||||
|
||||
|
||||
def auto_deep_scan_library(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Run a deep library scan via ``run_deep_scan_task``."""
|
||||
return _run_with_progress(
|
||||
config, deps,
|
||||
task=deps.run_deep_scan_task,
|
||||
task_args=(deps.config_manager.get_active_media_server(),),
|
||||
initial_phase='Deep scan: Initializing...',
|
||||
stall_label='Deep scan',
|
||||
finished_extras=lambda: {},
|
||||
timeout_label='Deep scan timed out after 2 hours',
|
||||
)
|
||||
|
||||
|
||||
def _run_with_progress(
|
||||
config: Dict[str, Any],
|
||||
deps: AutomationDeps,
|
||||
*,
|
||||
task,
|
||||
task_args: tuple,
|
||||
initial_phase: str,
|
||||
stall_label: str,
|
||||
finished_extras,
|
||||
timeout_label: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Shared poll-and-wait body for both DB-update handlers."""
|
||||
automation_id = config.get('_automation_id')
|
||||
state = deps.get_db_update_state()
|
||||
if state.get('status') == 'running':
|
||||
return {'status': 'skipped', 'reason': 'Database update already running'}
|
||||
deps.state.db_update_automation_id = automation_id
|
||||
# Sync legacy module global so the DB-update progress callbacks
|
||||
# (still living in web_server.py) emit against this automation.
|
||||
deps.set_db_update_automation_id(automation_id)
|
||||
|
||||
with deps.db_update_lock:
|
||||
state.update({
|
||||
'status': 'running', 'phase': initial_phase,
|
||||
'progress': 0, 'current_item': '', 'processed': 0, 'total': 0,
|
||||
'error_message': '',
|
||||
})
|
||||
deps.db_update_executor.submit(task, *task_args)
|
||||
|
||||
# Monitor progress (callbacks handle card updates, we just block until done).
|
||||
time.sleep(_INITIAL_DELAY_SECONDS)
|
||||
poll_start = time.time()
|
||||
last_progress_time = time.time()
|
||||
last_progress_val = 0
|
||||
while time.time() - poll_start < _TIMEOUT_SECONDS:
|
||||
time.sleep(_POLL_INTERVAL_SECONDS)
|
||||
with deps.db_update_lock:
|
||||
current_status = state.get('status', 'idle')
|
||||
current_progress = state.get('progress', 0)
|
||||
if current_status != 'running':
|
||||
break
|
||||
# Stall detection — if no progress change in 10 minutes, warn.
|
||||
if current_progress != last_progress_val:
|
||||
last_progress_val = current_progress
|
||||
last_progress_time = time.time()
|
||||
elif time.time() - last_progress_time > _STALL_WARNING_SECONDS:
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'{stall_label} appears stalled — waiting...',
|
||||
log_type='warning',
|
||||
)
|
||||
last_progress_time = time.time() # Reset so warning repeats every 10 min.
|
||||
else:
|
||||
# 2-hour timeout reached.
|
||||
deps.update_progress(
|
||||
automation_id, status='error',
|
||||
phase='Timed out', log_line=timeout_label, log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'reason': 'Timed out', '_manages_own_progress': True}
|
||||
|
||||
# Finished/error callback already updated the card — return matching status.
|
||||
with deps.db_update_lock:
|
||||
final_status = state.get('status', 'unknown')
|
||||
if final_status == 'error':
|
||||
return {
|
||||
'status': 'error',
|
||||
'reason': state.get('error_message', 'Unknown error'),
|
||||
'_manages_own_progress': True,
|
||||
}
|
||||
with deps.db_update_lock:
|
||||
stats = {
|
||||
'status': 'completed', '_manages_own_progress': True,
|
||||
'artists': state.get('total', 0),
|
||||
'albums': state.get('total_albums', 0),
|
||||
'tracks': state.get('total_tracks', 0),
|
||||
'removed_artists': state.get('removed_artists', 0),
|
||||
'removed_albums': state.get('removed_albums', 0),
|
||||
'removed_tracks': state.get('removed_tracks', 0),
|
||||
}
|
||||
stats.update(finished_extras())
|
||||
return stats
|
||||
48
core/automation/handlers/discover_playlist.py
Normal file
48
core/automation/handlers/discover_playlist.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Automation handler: ``discover_playlist`` action.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers`` (the
|
||||
``_auto_discover_playlist`` closure). Kicks off background discovery
|
||||
of official Spotify / iTunes metadata for mirrored playlist tracks.
|
||||
The worker runs in a daemon thread and emits its own progress; this
|
||||
handler returns immediately after launching it (``_manages_own_progress``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
def auto_discover_playlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Discover official Spotify/iTunes metadata for mirrored
|
||||
playlist tracks. Runs the worker in a background thread."""
|
||||
db = deps.get_database()
|
||||
playlist_id = config.get('playlist_id')
|
||||
discover_all = config.get('all', False)
|
||||
|
||||
if discover_all:
|
||||
playlists = db.get_mirrored_playlists()
|
||||
elif playlist_id:
|
||||
p = db.get_mirrored_playlist(int(playlist_id))
|
||||
playlists = [p] if p else []
|
||||
else:
|
||||
return {'status': 'error', 'reason': 'No playlist specified'}
|
||||
|
||||
if not playlists:
|
||||
return {'status': 'error', 'reason': 'No playlists found'}
|
||||
|
||||
threading.Thread(
|
||||
target=deps.run_playlist_discovery_worker,
|
||||
args=(playlists, config.get('_automation_id')),
|
||||
daemon=True,
|
||||
name='auto-discover-playlist',
|
||||
).start()
|
||||
names = ', '.join(p['name'] for p in playlists[:3])
|
||||
return {
|
||||
'status': 'started',
|
||||
'playlist_count': str(len(playlists)),
|
||||
'playlists': names,
|
||||
'_manages_own_progress': True,
|
||||
}
|
||||
267
core/automation/handlers/download_cleanup.py
Normal file
267
core/automation/handlers/download_cleanup.py
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
"""Automation handlers: download-queue cleanup actions.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers``:
|
||||
- ``clean_search_history`` → :func:`auto_clean_search_history`
|
||||
- ``clean_completed_downloads`` → :func:`auto_clean_completed_downloads`
|
||||
- ``full_cleanup`` → :func:`auto_full_cleanup`
|
||||
|
||||
All three share the download-orchestrator + tasks_lock /
|
||||
download_batches / download_tasks accessors. ``full_cleanup`` is a
|
||||
multi-step orchestration that pulls in quarantine purge + staging
|
||||
sweep on top of the queue cleanup -- kept as one big handler since
|
||||
its phases share state-detection logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil as _shutil
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
# ─── clean_search_history ────────────────────────────────────────────
|
||||
|
||||
|
||||
def auto_clean_search_history(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Remove old searches from Soulseek when configured."""
|
||||
automation_id = config.get('_automation_id')
|
||||
# Skip if soulseek is not the active download source or in hybrid order.
|
||||
dl_mode = deps.config_manager.get('download_source.mode', 'hybrid')
|
||||
hybrid_order = deps.config_manager.get(
|
||||
'download_source.hybrid_order', ['hifi', 'youtube', 'soulseek'],
|
||||
)
|
||||
soulseek_active = (
|
||||
dl_mode == 'soulseek'
|
||||
or (dl_mode == 'hybrid' and 'soulseek' in hybrid_order)
|
||||
)
|
||||
# Reach the underlying SoulseekClient via the orchestrator's
|
||||
# generic accessor.
|
||||
slskd = deps.download_orchestrator.client('soulseek') if deps.download_orchestrator else None
|
||||
if not soulseek_active or not slskd or not slskd.base_url:
|
||||
deps.update_progress(automation_id, log_line='Soulseek not active — skipped', log_type='skip')
|
||||
return {'status': 'skipped'}
|
||||
if not deps.config_manager.get('soulseek.auto_clear_searches', True):
|
||||
deps.update_progress(
|
||||
automation_id, log_line='Auto-clear disabled in settings', log_type='skip',
|
||||
)
|
||||
return {'status': 'skipped'}
|
||||
try:
|
||||
success = deps.run_async(deps.download_orchestrator.maintain_search_history_with_buffer(
|
||||
keep_searches=50, trigger_threshold=200,
|
||||
))
|
||||
if success:
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line='Search history maintenance completed',
|
||||
log_type='success',
|
||||
)
|
||||
return {'status': 'completed'}
|
||||
else:
|
||||
deps.update_progress(automation_id, log_line='No cleanup needed', log_type='skip')
|
||||
return {'status': 'completed'}
|
||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise
|
||||
return {'status': 'error', 'error': str(e)}
|
||||
|
||||
|
||||
# ─── clean_completed_downloads ───────────────────────────────────────
|
||||
|
||||
|
||||
def auto_clean_completed_downloads(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Clear completed downloads + sweep empty download directories.
|
||||
Skips when active batches or post-processing is in flight."""
|
||||
automation_id = config.get('_automation_id')
|
||||
try:
|
||||
has_active_batches = False
|
||||
has_post_processing = False
|
||||
with deps.tasks_lock:
|
||||
batches = deps.get_download_batches()
|
||||
for batch_data in batches.values():
|
||||
if batch_data.get('phase') not in ['complete', 'error', 'cancelled', None]:
|
||||
has_active_batches = True
|
||||
break
|
||||
if not has_active_batches:
|
||||
tasks = deps.get_download_tasks()
|
||||
for task_data in tasks.values():
|
||||
if task_data.get('status') == 'post_processing':
|
||||
has_post_processing = True
|
||||
break
|
||||
|
||||
if has_active_batches:
|
||||
deps.update_progress(
|
||||
automation_id, log_line='Skipped — downloads active', log_type='skip',
|
||||
)
|
||||
return {'status': 'completed'}
|
||||
|
||||
deps.run_async(deps.download_orchestrator.clear_all_completed_downloads())
|
||||
if not has_post_processing:
|
||||
deps.sweep_empty_download_directories()
|
||||
deps.update_progress(
|
||||
automation_id, log_line='Download cleanup completed', log_type='success',
|
||||
)
|
||||
return {'status': 'completed'}
|
||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise
|
||||
return {'status': 'error', 'reason': str(e)}
|
||||
|
||||
|
||||
# ─── full_cleanup ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def auto_full_cleanup(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Run all cleanup tasks: quarantine purge → download queue clear
|
||||
→ empty-dir sweep → staging sweep → search history."""
|
||||
automation_id = config.get('_automation_id')
|
||||
steps = []
|
||||
|
||||
# --- 1. Clear quarantine ---
|
||||
deps.update_progress(automation_id, phase='Clearing quarantine...', progress=0)
|
||||
quarantine_path = os.path.join(
|
||||
deps.docker_resolve_path(deps.config_manager.get('soulseek.download_path', './downloads')),
|
||||
'ss_quarantine',
|
||||
)
|
||||
q_removed = 0
|
||||
if os.path.exists(quarantine_path):
|
||||
for f in os.listdir(quarantine_path):
|
||||
fp = os.path.join(quarantine_path, f)
|
||||
try:
|
||||
if os.path.isfile(fp):
|
||||
os.remove(fp)
|
||||
q_removed += 1
|
||||
elif os.path.isdir(fp):
|
||||
_shutil.rmtree(fp)
|
||||
q_removed += 1
|
||||
except Exception as e: # noqa: BLE001 — best-effort purge
|
||||
deps.logger.debug("quarantine entry purge failed: %s", e)
|
||||
steps.append(f'Quarantine: removed {q_removed} items')
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Quarantine: removed {q_removed} items',
|
||||
log_type='success' if q_removed else 'info',
|
||||
)
|
||||
|
||||
# --- 2. Clear completed/errored/cancelled downloads from Soulseek queue ---
|
||||
deps.update_progress(automation_id, phase='Clearing download queue...', progress=20)
|
||||
has_active_batches = False
|
||||
has_post_processing = False
|
||||
with deps.tasks_lock:
|
||||
batches = deps.get_download_batches()
|
||||
for batch_data in batches.values():
|
||||
if batch_data.get('phase') not in ['complete', 'error', 'cancelled', None]:
|
||||
has_active_batches = True
|
||||
break
|
||||
if not has_active_batches:
|
||||
tasks = deps.get_download_tasks()
|
||||
for task_data in tasks.values():
|
||||
if task_data.get('status') == 'post_processing':
|
||||
has_post_processing = True
|
||||
break
|
||||
if has_active_batches:
|
||||
steps.append('Download queue: skipped (active batches)')
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line='Download queue: skipped (active batches)',
|
||||
log_type='skip',
|
||||
)
|
||||
else:
|
||||
try:
|
||||
deps.run_async(deps.download_orchestrator.clear_all_completed_downloads())
|
||||
steps.append('Download queue: cleared')
|
||||
deps.update_progress(
|
||||
automation_id, log_line='Download queue: cleared', log_type='success',
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — per-step best-effort
|
||||
steps.append(f'Download queue: error ({e})')
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Download queue: error ({e})',
|
||||
log_type='error',
|
||||
)
|
||||
|
||||
# --- 3. Sweep empty download directories ---
|
||||
deps.update_progress(automation_id, phase='Sweeping empty directories...', progress=40)
|
||||
if has_active_batches or has_post_processing:
|
||||
reason = 'active batches' if has_active_batches else 'post-processing active'
|
||||
steps.append(f'Empty directories: skipped ({reason})')
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Empty directories: skipped ({reason})',
|
||||
log_type='skip',
|
||||
)
|
||||
else:
|
||||
dirs_removed = deps.sweep_empty_download_directories()
|
||||
steps.append(f'Empty directories: removed {dirs_removed}')
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Empty directories: removed {dirs_removed}',
|
||||
log_type='success' if dirs_removed else 'info',
|
||||
)
|
||||
|
||||
# --- 4. Sweep empty staging directories ---
|
||||
deps.update_progress(automation_id, phase='Sweeping import folder...', progress=60)
|
||||
staging_path = deps.get_staging_path()
|
||||
s_removed = 0
|
||||
if os.path.isdir(staging_path):
|
||||
for dirpath, _dirnames, _filenames in os.walk(staging_path, topdown=False):
|
||||
if os.path.normpath(dirpath) == os.path.normpath(staging_path):
|
||||
continue
|
||||
try:
|
||||
entries = os.listdir(dirpath)
|
||||
except OSError:
|
||||
continue
|
||||
visible = [e for e in entries if not e.startswith('.')]
|
||||
if not visible:
|
||||
for hidden in entries:
|
||||
try:
|
||||
os.remove(os.path.join(dirpath, hidden))
|
||||
except Exception as e: # noqa: BLE001 — best-effort
|
||||
deps.logger.debug("hidden file cleanup failed: %s", e)
|
||||
try:
|
||||
os.rmdir(dirpath)
|
||||
s_removed += 1
|
||||
except OSError:
|
||||
pass
|
||||
steps.append(f'Staging: removed {s_removed} empty directories')
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Staging: removed {s_removed} empty directories',
|
||||
log_type='success' if s_removed else 'info',
|
||||
)
|
||||
|
||||
# --- 5. Clean search history (if enabled) ---
|
||||
deps.update_progress(automation_id, phase='Cleaning search history...', progress=80)
|
||||
try:
|
||||
if not deps.config_manager.get('soulseek.auto_clear_searches', True):
|
||||
steps.append('Search cleanup: disabled in settings')
|
||||
deps.update_progress(
|
||||
automation_id, log_line='Search cleanup: disabled in settings', log_type='skip',
|
||||
)
|
||||
else:
|
||||
deps.run_async(deps.download_orchestrator.maintain_search_history_with_buffer(
|
||||
keep_searches=50, trigger_threshold=200,
|
||||
))
|
||||
steps.append('Search history: cleaned')
|
||||
deps.update_progress(
|
||||
automation_id, log_line='Search history: cleaned', log_type='success',
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — per-step best-effort
|
||||
steps.append(f'Search history: error ({e})')
|
||||
deps.update_progress(
|
||||
automation_id, log_line=f'Search history: error ({e})', log_type='error',
|
||||
)
|
||||
|
||||
total_removed = q_removed + s_removed
|
||||
deps.update_progress(
|
||||
automation_id, status='finished', progress=100,
|
||||
phase='Complete',
|
||||
log_line=f'Full cleanup complete — {total_removed} items removed',
|
||||
log_type='success',
|
||||
)
|
||||
return {
|
||||
'status': 'completed',
|
||||
'quarantine_removed': str(q_removed),
|
||||
'staging_removed': str(s_removed),
|
||||
'total_removed': str(total_removed),
|
||||
'steps': steps,
|
||||
'_manages_own_progress': True,
|
||||
}
|
||||
87
core/automation/handlers/duplicate_cleaner.py
Normal file
87
core/automation/handlers/duplicate_cleaner.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Automation handler: ``run_duplicate_cleaner`` action.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers`` (the
|
||||
``_auto_run_duplicate_cleaner`` closure). Submits the duplicate
|
||||
cleaner to its executor, then polls the shared state dict until
|
||||
the worker transitions away from ``running``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
_TIMEOUT_SECONDS = 7200 # 2 hours
|
||||
_POLL_INTERVAL_SECONDS = 3
|
||||
_INITIAL_DELAY_SECONDS = 1
|
||||
|
||||
|
||||
def auto_run_duplicate_cleaner(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Kick off the duplicate cleaner and report final stats."""
|
||||
automation_id = config.get('_automation_id')
|
||||
state = deps.get_duplicate_cleaner_state()
|
||||
if state.get('status') == 'running':
|
||||
return {'status': 'skipped', 'reason': 'Duplicate cleaner already running'}
|
||||
|
||||
# Pre-set status before submit so the polling loop doesn't see a
|
||||
# stale 'finished' from a previous run.
|
||||
with deps.duplicate_cleaner_lock:
|
||||
state['status'] = 'running'
|
||||
deps.duplicate_cleaner_executor.submit(deps.run_duplicate_cleaner)
|
||||
deps.update_progress(automation_id, log_line='Duplicate cleaner started', log_type='info')
|
||||
|
||||
# Monitor progress (max 2 hours).
|
||||
time.sleep(_INITIAL_DELAY_SECONDS)
|
||||
poll_start = time.time()
|
||||
while time.time() - poll_start < _TIMEOUT_SECONDS:
|
||||
time.sleep(_POLL_INTERVAL_SECONDS)
|
||||
current_status = state.get('status', 'idle')
|
||||
if current_status not in ('running',):
|
||||
break
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
phase=state.get('phase', 'Scanning...'),
|
||||
progress=state.get('progress', 0),
|
||||
processed=state.get('files_scanned', 0),
|
||||
total=state.get('total_files', 0),
|
||||
)
|
||||
else:
|
||||
# 2-hour timeout reached.
|
||||
deps.update_progress(
|
||||
automation_id, status='error',
|
||||
phase='Timed out',
|
||||
log_line='Duplicate cleaner timed out after 2 hours',
|
||||
log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'reason': 'Timed out', '_manages_own_progress': True}
|
||||
|
||||
# Check actual exit status (could be 'finished' or 'error').
|
||||
final_status = state.get('status', 'idle')
|
||||
if final_status == 'error':
|
||||
err = state.get('error_message', 'Unknown error')
|
||||
deps.update_progress(
|
||||
automation_id, status='error', progress=100,
|
||||
phase='Error', log_line=err, log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'reason': err, '_manages_own_progress': True}
|
||||
|
||||
dupes = state.get('duplicates_found', 0)
|
||||
removed = state.get('deleted', 0)
|
||||
space_freed = state.get('space_freed', 0)
|
||||
scanned = state.get('files_scanned', 0)
|
||||
deps.update_progress(
|
||||
automation_id, status='finished', progress=100,
|
||||
phase='Complete',
|
||||
log_line=f'Found {dupes} duplicates, removed {removed} files',
|
||||
log_type='success',
|
||||
)
|
||||
return {
|
||||
'status': 'completed', '_manages_own_progress': True,
|
||||
'files_scanned': scanned,
|
||||
'duplicates_found': dupes,
|
||||
'files_deleted': removed,
|
||||
'space_freed_mb': round(space_freed / (1024 * 1024), 1),
|
||||
}
|
||||
213
core/automation/handlers/maintenance.py
Normal file
213
core/automation/handlers/maintenance.py
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
"""Automation handlers: short maintenance actions.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers``:
|
||||
- ``clear_quarantine`` → :func:`auto_clear_quarantine`
|
||||
- ``cleanup_wishlist`` → :func:`auto_cleanup_wishlist`
|
||||
- ``update_discovery_pool`` → :func:`auto_update_discovery_pool`
|
||||
- ``backup_database`` → :func:`auto_backup_database`
|
||||
- ``refresh_beatport_cache`` → :func:`auto_refresh_beatport_cache`
|
||||
|
||||
Each is a thin wrapper around an existing service / helper. Grouped
|
||||
in one module because every body is short and they share no state
|
||||
between them — splitting into per-handler files would just add
|
||||
import noise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob as _glob
|
||||
import os
|
||||
import shutil as _shutil
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
# ─── clear_quarantine ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def auto_clear_quarantine(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Purge every file/folder under the configured ss_quarantine path."""
|
||||
automation_id = config.get('_automation_id')
|
||||
quarantine_path = os.path.join(
|
||||
deps.docker_resolve_path(deps.config_manager.get('soulseek.download_path', './downloads')),
|
||||
'ss_quarantine',
|
||||
)
|
||||
if not os.path.exists(quarantine_path):
|
||||
deps.update_progress(automation_id, log_line='No quarantine folder found', log_type='info')
|
||||
return {'status': 'completed', 'removed': '0'}
|
||||
removed = 0
|
||||
for f in os.listdir(quarantine_path):
|
||||
fp = os.path.join(quarantine_path, f)
|
||||
try:
|
||||
if os.path.isfile(fp):
|
||||
os.remove(fp)
|
||||
removed += 1
|
||||
elif os.path.isdir(fp):
|
||||
_shutil.rmtree(fp)
|
||||
removed += 1
|
||||
except Exception as e: # noqa: BLE001 — best-effort purge
|
||||
deps.logger.debug("quarantine entry purge failed: %s", e)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Removed {removed} quarantined items',
|
||||
log_type='success' if removed > 0 else 'info',
|
||||
)
|
||||
return {'status': 'completed', 'removed': str(removed)}
|
||||
|
||||
|
||||
# ─── cleanup_wishlist ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def auto_cleanup_wishlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Drop duplicate entries from the wishlist for the active profile."""
|
||||
automation_id = config.get('_automation_id')
|
||||
db = deps.get_database()
|
||||
removed = db.remove_wishlist_duplicates(deps.get_current_profile_id())
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Removed {removed or 0} duplicate wishlist entries',
|
||||
log_type='success' if removed else 'info',
|
||||
)
|
||||
return {'status': 'completed', 'removed': str(removed or 0)}
|
||||
|
||||
|
||||
# ─── update_discovery_pool ───────────────────────────────────────────
|
||||
|
||||
|
||||
def auto_update_discovery_pool(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Run an incremental refresh of the discovery pool via the
|
||||
watchlist scanner."""
|
||||
automation_id = config.get('_automation_id')
|
||||
try:
|
||||
scanner = deps.get_watchlist_scanner(deps.spotify_client)
|
||||
deps.update_progress(automation_id, log_line='Updating discovery pool...', log_type='info')
|
||||
scanner.update_discovery_pool_incremental(deps.get_current_profile_id())
|
||||
deps.update_progress(
|
||||
automation_id, status='finished', progress=100,
|
||||
phase='Complete', log_line='Discovery pool updated', log_type='success',
|
||||
)
|
||||
return {'status': 'completed', '_manages_own_progress': True}
|
||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise
|
||||
deps.update_progress(
|
||||
automation_id, status='error',
|
||||
phase='Error', log_line=str(e), log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'reason': str(e), '_manages_own_progress': True}
|
||||
|
||||
|
||||
# ─── backup_database ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
_MAX_BACKUPS = 5
|
||||
|
||||
|
||||
def auto_backup_database(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Create a hot SQLite backup, then prune old backups so only the
|
||||
newest ``_MAX_BACKUPS`` remain."""
|
||||
automation_id = config.get('_automation_id')
|
||||
db_path = os.environ.get('DATABASE_PATH', 'database/music_library.db')
|
||||
if not os.path.exists(db_path):
|
||||
return {'status': 'error', 'reason': 'Database file not found'}
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_path = f"{db_path}.backup_{timestamp}"
|
||||
# Use SQLite backup API for a safe hot-copy of an active database.
|
||||
src = sqlite3.connect(db_path)
|
||||
dst = sqlite3.connect(backup_path)
|
||||
src.backup(dst)
|
||||
dst.close()
|
||||
src.close()
|
||||
size_mb = round(os.path.getsize(backup_path) / (1024 * 1024), 1)
|
||||
|
||||
# Rolling cleanup — keep only the newest N backups.
|
||||
existing = sorted(_glob.glob(f"{db_path}.backup_*"), key=os.path.getmtime)
|
||||
while len(existing) > _MAX_BACKUPS:
|
||||
try:
|
||||
os.remove(existing.pop(0))
|
||||
except Exception as e: # noqa: BLE001 — best-effort cleanup
|
||||
deps.logger.debug("rolling backup cleanup failed: %s", e)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Backup created: {size_mb}MB ({os.path.basename(backup_path)})',
|
||||
log_type='success',
|
||||
)
|
||||
return {'status': 'completed', 'backup_path': backup_path, 'size_mb': str(size_mb)}
|
||||
|
||||
|
||||
# ─── refresh_beatport_cache ──────────────────────────────────────────
|
||||
|
||||
|
||||
_BEATPORT_SECTIONS = (
|
||||
('hero_tracks', '/api/beatport/hero-tracks', 'Hero Tracks'),
|
||||
('new_releases', '/api/beatport/new-releases', 'New Releases'),
|
||||
('featured_charts', '/api/beatport/featured-charts', 'Featured Charts'),
|
||||
('dj_charts', '/api/beatport/dj-charts', 'DJ Charts'),
|
||||
('top_10_lists', '/api/beatport/homepage/top-10-lists', 'Top 10 Lists'),
|
||||
('top_10_releases', '/api/beatport/homepage/top-10-releases-cards', 'Top 10 Releases'),
|
||||
('hype_picks', '/api/beatport/hype-picks', 'Hype Picks'),
|
||||
)
|
||||
|
||||
|
||||
def auto_refresh_beatport_cache(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Refresh Beatport homepage cache by calling each endpoint internally
|
||||
via Flask's ``test_client``. Invalidates the homepage cache first
|
||||
so endpoints re-scrape rather than returning stale data."""
|
||||
automation_id = config.get('_automation_id')
|
||||
cache = deps.get_beatport_data_cache()
|
||||
# Invalidate all homepage cache timestamps so endpoints re-scrape.
|
||||
with cache['cache_lock']:
|
||||
for key in cache['homepage']:
|
||||
cache['homepage'][key]['timestamp'] = 0
|
||||
cache['homepage'][key]['data'] = None
|
||||
|
||||
refreshed = 0
|
||||
errors = []
|
||||
app = deps.get_app()
|
||||
with app.test_client() as client:
|
||||
for idx, (_, endpoint, label) in enumerate(_BEATPORT_SECTIONS):
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=(idx / len(_BEATPORT_SECTIONS)) * 100,
|
||||
phase=f'Scraping: {label}',
|
||||
current_item=label,
|
||||
)
|
||||
try:
|
||||
resp = client.get(endpoint)
|
||||
if resp.status_code == 200:
|
||||
refreshed += 1
|
||||
deps.update_progress(
|
||||
automation_id, log_line=f'{label}: cached', log_type='success',
|
||||
)
|
||||
else:
|
||||
errors.append(label)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'{label}: HTTP {resp.status_code}',
|
||||
log_type='error',
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — per-section best-effort
|
||||
errors.append(label)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'{label}: {str(e)}',
|
||||
log_type='error',
|
||||
)
|
||||
if idx < len(_BEATPORT_SECTIONS) - 1:
|
||||
time.sleep(2)
|
||||
|
||||
deps.update_progress(
|
||||
automation_id, status='finished', progress=100,
|
||||
phase='Complete',
|
||||
log_line=f'Refreshed {refreshed}/{len(_BEATPORT_SECTIONS)} sections',
|
||||
log_type='success',
|
||||
)
|
||||
return {
|
||||
'status': 'completed',
|
||||
'refreshed': str(refreshed),
|
||||
'errors': str(len(errors)),
|
||||
'_manages_own_progress': True,
|
||||
}
|
||||
356
core/automation/handlers/personalized_pipeline.py
Normal file
356
core/automation/handlers/personalized_pipeline.py
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
"""Personalized Playlist Pipeline automation handler.
|
||||
|
||||
Sibling to ``auto_playlist_pipeline`` (mirrored). Where the mirrored
|
||||
pipeline runs REFRESH external sources → DISCOVER metadata → SYNC →
|
||||
WISHLIST, the personalized pipeline is simpler:
|
||||
|
||||
SNAPSHOT → SYNC → WISHLIST
|
||||
|
||||
SNAPSHOT reads the persisted track list from
|
||||
``PersonalizedPlaylistManager``. When ``refresh_first=True`` (config),
|
||||
each playlist is refreshed BEFORE syncing — useful when the user
|
||||
wants the cron to capture a fresh-each-run view (e.g. "give me a new
|
||||
Hidden Gems set every night"). Default is to sync the existing
|
||||
snapshot, on the assumption the user / a separate cron has already
|
||||
refreshed when they wanted to.
|
||||
|
||||
Config schema:
|
||||
{
|
||||
'kinds': [
|
||||
{'kind': 'hidden_gems'},
|
||||
{'kind': 'time_machine', 'variant': '1980s'},
|
||||
{'kind': 'seasonal_mix', 'variant': 'halloween'},
|
||||
...
|
||||
],
|
||||
'refresh_first': bool, # default false
|
||||
'skip_wishlist': bool, # default false
|
||||
}
|
||||
|
||||
Each kind dict has at minimum ``kind``; ``variant`` is required for
|
||||
kinds that need it (time_machine, genre_playlist, daily_mix,
|
||||
seasonal_mix). Singleton kinds (hidden_gems, discovery_shuffle,
|
||||
popular_picks, fresh_tape, archives) ignore variant.
|
||||
|
||||
Pipeline-running flag (``deps.state.pipeline_running``) is shared
|
||||
with the mirrored pipeline so the two can't overlap. (One sync
|
||||
queue, one wishlist worker — overlapping triggers would step on
|
||||
each other.)"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
from core.automation.handlers._pipeline_shared import run_sync_and_wishlist
|
||||
|
||||
|
||||
# Sync state key prefix so personalized syncs don't collide with
|
||||
# mirrored ones (`auto_mirror_<id>`).
|
||||
_SYNC_ID_PREFIX = 'auto_personalized'
|
||||
|
||||
|
||||
def auto_personalized_pipeline(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Run SNAPSHOT → SYNC → WISHLIST for selected personalized playlists."""
|
||||
deps.state.set_pipeline_running(True)
|
||||
automation_id = config.get('_automation_id')
|
||||
pipeline_start = time.time()
|
||||
|
||||
try:
|
||||
kinds_config = config.get('kinds') or []
|
||||
if not isinstance(kinds_config, list) or not kinds_config:
|
||||
deps.state.set_pipeline_running(False)
|
||||
return {
|
||||
'status': 'error',
|
||||
'error': 'No personalized playlist kinds selected',
|
||||
}
|
||||
|
||||
refresh_first = bool(config.get('refresh_first', False))
|
||||
skip_wishlist = bool(config.get('skip_wishlist', False))
|
||||
|
||||
manager = deps.build_personalized_manager()
|
||||
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=2,
|
||||
phase=f'Personalized pipeline: {len(kinds_config)} playlist(s)',
|
||||
log_line=f'Starting pipeline for {len(kinds_config)} playlist(s)',
|
||||
log_type='info',
|
||||
)
|
||||
|
||||
# ── PHASE 1: SNAPSHOT (optionally refresh) ──────────────────
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=3,
|
||||
phase='Phase 1/2: Loading snapshots...' if not refresh_first
|
||||
else 'Phase 1/2: Refreshing snapshots...',
|
||||
log_line='Phase 1: Snapshot' + (' (with refresh)' if refresh_first else ''),
|
||||
log_type='info',
|
||||
)
|
||||
|
||||
profile_id = deps.get_current_profile_id()
|
||||
playload_payloads = _build_payloads_for_kinds(
|
||||
deps, manager, kinds_config, profile_id,
|
||||
automation_id=automation_id,
|
||||
refresh_first=refresh_first,
|
||||
)
|
||||
|
||||
if not playload_payloads:
|
||||
deps.state.set_pipeline_running(False)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
status='finished', progress=100,
|
||||
phase='No playlists to sync',
|
||||
log_line='No personalized playlists had tracks to sync',
|
||||
log_type='warning',
|
||||
)
|
||||
return {
|
||||
'status': 'completed',
|
||||
'_manages_own_progress': True,
|
||||
'playlists_synced': '0',
|
||||
'tracks_synced': '0',
|
||||
'duration_seconds': str(int(time.time() - pipeline_start)),
|
||||
}
|
||||
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=50,
|
||||
phase='Phase 1/2: Snapshot complete',
|
||||
log_line=f'Phase 1 done: {len(playload_payloads)} playlist(s) ready to sync',
|
||||
log_type='success',
|
||||
)
|
||||
|
||||
# ── PHASE 2: SYNC + WISHLIST (shared helper) ────────────────
|
||||
sync_summary = run_sync_and_wishlist(
|
||||
deps,
|
||||
automation_id,
|
||||
playload_payloads,
|
||||
sync_one_fn=lambda pl: _sync_personalized_playlist(deps, pl),
|
||||
sync_id_for_fn=lambda pl: pl['sync_id'],
|
||||
skip_wishlist=skip_wishlist,
|
||||
progress_start=51,
|
||||
progress_end=90,
|
||||
sync_phase_label='Phase 2/2: Syncing to server...',
|
||||
sync_phase_start_log='Phase 2: Sync',
|
||||
wishlist_phase_label='Phase 2/2: Processing wishlist...',
|
||||
wishlist_phase_start_log='Wishlist',
|
||||
)
|
||||
|
||||
# ── COMPLETE ────────────────────────────────────────────────
|
||||
duration = int(time.time() - pipeline_start)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
status='finished', progress=100,
|
||||
phase='Pipeline complete',
|
||||
log_line=f'Personalized pipeline finished in {duration // 60}m {duration % 60}s',
|
||||
log_type='success',
|
||||
)
|
||||
|
||||
deps.state.set_pipeline_running(False)
|
||||
return {
|
||||
'status': 'completed',
|
||||
'_manages_own_progress': True,
|
||||
'playlists_synced': str(len(playload_payloads)),
|
||||
'tracks_synced': str(sync_summary['synced']),
|
||||
'sync_skipped': str(sync_summary['skipped']),
|
||||
'wishlist_queued': str(sync_summary['wishlist_queued']),
|
||||
'duration_seconds': str(duration),
|
||||
}
|
||||
|
||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise into engine
|
||||
deps.state.set_pipeline_running(False)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
status='error', progress=100,
|
||||
phase='Pipeline error',
|
||||
log_line=f'Personalized pipeline failed: {e}',
|
||||
log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'error': str(e), '_manages_own_progress': True}
|
||||
|
||||
|
||||
def _build_payloads_for_kinds(
|
||||
deps: AutomationDeps,
|
||||
manager: Any,
|
||||
kinds_config: List[Dict[str, Any]],
|
||||
profile_id: int,
|
||||
*,
|
||||
automation_id: Optional[str],
|
||||
refresh_first: bool,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Resolve each requested kind+variant into a sync-payload dict.
|
||||
|
||||
Each payload has: ``{'name', 'kind', 'variant', 'tracks_json',
|
||||
'image_url', 'sync_id'}``. Playlists with no tracks (e.g. a
|
||||
seasonal mix that hasn't been populated yet) are omitted from
|
||||
the result so the sync loop doesn't waste time on empty pushes.
|
||||
"""
|
||||
payloads: List[Dict[str, Any]] = []
|
||||
for entry in kinds_config:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
kind = entry.get('kind')
|
||||
variant = entry.get('variant') or ''
|
||||
if not kind:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Refresh when ANY of:
|
||||
# - explicit user flag (cron use case: regenerate each run)
|
||||
# - snapshot marked stale by upstream data refresher
|
||||
# - playlist was never generated yet (auto-created by
|
||||
# ensure_playlist; track_count=0, last_generated_at=NULL).
|
||||
# Without this branch, a first-run pipeline reads the
|
||||
# empty snapshot and silently skips — user picks a kind,
|
||||
# hits run, gets "No tracks to sync" with no clue why.
|
||||
if refresh_first:
|
||||
record = manager.refresh_playlist(kind, variant, profile_id)
|
||||
else:
|
||||
existing = manager.ensure_playlist(kind, variant, profile_id)
|
||||
needs_first_gen = existing.last_generated_at is None
|
||||
if existing.is_stale or needs_first_gen:
|
||||
record = manager.refresh_playlist(kind, variant, profile_id)
|
||||
else:
|
||||
record = existing
|
||||
except Exception as exc: # noqa: BLE001 — log + continue with next kind
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Skipping {kind}{("/" + variant) if variant else ""}: {exc}',
|
||||
log_type='warning',
|
||||
)
|
||||
continue
|
||||
|
||||
tracks = manager.get_playlist_tracks(record.id)
|
||||
if not tracks:
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'No tracks in {record.name} — skipping sync',
|
||||
log_type='skip',
|
||||
)
|
||||
continue
|
||||
|
||||
tracks_json = [_track_to_sync_shape(t) for t in tracks]
|
||||
payloads.append({
|
||||
'name': record.name,
|
||||
'kind': record.kind,
|
||||
'variant': record.variant,
|
||||
'tracks_json': tracks_json,
|
||||
'image_url': '', # personalized playlists don't have a cover image yet
|
||||
'sync_id': f'{_SYNC_ID_PREFIX}_{record.kind}_{record.variant or "_"}',
|
||||
})
|
||||
return payloads
|
||||
|
||||
|
||||
def _track_to_sync_shape(track: Any) -> Dict[str, Any]:
|
||||
"""Convert a personalized.types.Track into the dict shape
|
||||
`_run_sync_task` expects. Mirrors what the mirrored pipeline
|
||||
builds from extra_data.matched_data, preserving enriched metadata
|
||||
from personalized snapshots when available."""
|
||||
primary_id = track.spotify_track_id or track.itunes_track_id or track.deezer_track_id or ''
|
||||
rich_data = _coerce_track_data_json(getattr(track, 'track_data_json', None))
|
||||
|
||||
if not rich_data:
|
||||
album = {'name': track.album_name or ''}
|
||||
cover_url = getattr(track, 'album_cover_url', None)
|
||||
if cover_url:
|
||||
album['images'] = [{'url': cover_url}]
|
||||
return {
|
||||
'name': track.track_name,
|
||||
'artists': [{'name': track.artist_name}],
|
||||
'album': album,
|
||||
'duration_ms': int(track.duration_ms or 0),
|
||||
'id': primary_id,
|
||||
}
|
||||
|
||||
payload = dict(rich_data)
|
||||
cover_url = (
|
||||
getattr(track, 'album_cover_url', None)
|
||||
or payload.get('album_cover_url')
|
||||
or payload.get('image_url')
|
||||
)
|
||||
payload['id'] = payload.get('id') or primary_id
|
||||
payload['name'] = payload.get('name') or track.track_name
|
||||
payload['artists'] = _normalize_artists(payload.get('artists'), track.artist_name)
|
||||
payload['album'] = _normalize_album(payload.get('album'), track, cover_url=cover_url)
|
||||
payload['duration_ms'] = int(payload.get('duration_ms') or track.duration_ms or 0)
|
||||
if 'popularity' not in payload and getattr(track, 'popularity', None) is not None:
|
||||
payload['popularity'] = int(track.popularity or 0)
|
||||
|
||||
if cover_url and not payload.get('image_url'):
|
||||
payload['image_url'] = cover_url
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def _coerce_track_data_json(value: Any) -> Dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str) and value.strip():
|
||||
try:
|
||||
loaded = json.loads(value)
|
||||
except (TypeError, ValueError):
|
||||
return {}
|
||||
return loaded if isinstance(loaded, dict) else {}
|
||||
return {}
|
||||
|
||||
|
||||
def _normalize_artists(artists: Any, fallback_artist: str) -> List[Dict[str, Any]]:
|
||||
if not artists:
|
||||
return [{'name': fallback_artist or 'Unknown Artist'}]
|
||||
if isinstance(artists, list):
|
||||
normalized = []
|
||||
for artist in artists:
|
||||
if isinstance(artist, dict):
|
||||
normalized.append(artist if artist.get('name') else {'name': str(artist)})
|
||||
elif isinstance(artist, str):
|
||||
normalized.append({'name': artist})
|
||||
else:
|
||||
normalized.append({'name': str(artist)})
|
||||
return normalized or [{'name': fallback_artist or 'Unknown Artist'}]
|
||||
if isinstance(artists, dict):
|
||||
return [artists if artists.get('name') else {'name': str(artists)}]
|
||||
return [{'name': str(artists)}]
|
||||
|
||||
|
||||
def _normalize_album(album: Any, track: Any, cover_url: Optional[str] = None) -> Dict[str, Any]:
|
||||
if isinstance(album, dict):
|
||||
normalized = dict(album)
|
||||
else:
|
||||
normalized = {'name': str(album) if album else (track.album_name or '')}
|
||||
|
||||
normalized['name'] = normalized.get('name') or track.album_name or ''
|
||||
images = normalized.get('images')
|
||||
if not isinstance(images, list):
|
||||
images = []
|
||||
if cover_url and not images:
|
||||
images = [{'url': cover_url}]
|
||||
if images:
|
||||
normalized['images'] = images
|
||||
return normalized
|
||||
|
||||
|
||||
def _sync_personalized_playlist(deps: AutomationDeps, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Launch a personalized playlist sync via _run_sync_task on a
|
||||
daemon thread + return immediately with status='started'.
|
||||
|
||||
Mirrors the mirrored ``auto_sync_playlist`` return contract so the
|
||||
shared helper can poll on ``sync_states[sync_id]`` and aggregate
|
||||
results identically."""
|
||||
sync_id = payload['sync_id']
|
||||
name = payload['name']
|
||||
tracks_json = payload['tracks_json']
|
||||
profile_id = deps.get_current_profile_id()
|
||||
|
||||
threading.Thread(
|
||||
target=deps.run_sync_task,
|
||||
args=(sync_id, name, tracks_json, None, profile_id, payload.get('image_url', '')),
|
||||
daemon=True,
|
||||
name=f'auto-personalized-{sync_id}',
|
||||
).start()
|
||||
return {
|
||||
'status': 'started',
|
||||
'playlist_name': name,
|
||||
'_manages_own_progress': True,
|
||||
}
|
||||
227
core/automation/handlers/playlist_pipeline.py
Normal file
227
core/automation/handlers/playlist_pipeline.py
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
"""Automation handler: ``playlist_pipeline`` action.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers`` (the
|
||||
``_auto_playlist_pipeline`` closure). Runs the full playlist
|
||||
lifecycle in a single trigger:
|
||||
|
||||
Phase 1: REFRESH -- pull fresh track lists from sources
|
||||
Phase 2: DISCOVER -- look up official Spotify/iTunes metadata
|
||||
Phase 3: SYNC -- push the result to the active media server
|
||||
Phase 4: WISHLIST -- queue any missing tracks for download
|
||||
|
||||
Each phase emits its own progress range so the trigger card shows
|
||||
useful per-phase percentages instead of "loading...". Phase 4 is
|
||||
optional via ``skip_wishlist`` config.
|
||||
|
||||
Composition: this handler invokes ``auto_refresh_mirrored`` and
|
||||
``auto_sync_playlist`` directly (passing ``_automation_id: None`` so
|
||||
the sub-handlers don't hijack pipeline progress) instead of going
|
||||
through the engine — keeps the four phases observable as one
|
||||
trigger from the user's perspective. Pipeline-level guard
|
||||
(``state.pipeline_running``) prevents overlapping runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
from core.automation.handlers._pipeline_shared import run_sync_and_wishlist
|
||||
from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored
|
||||
from core.automation.handlers.sync_playlist import auto_sync_playlist
|
||||
|
||||
|
||||
# Per-playlist sync poll cap inside Phase 3.
|
||||
# Discovery poll cap inside Phase 2.
|
||||
_DISCOVERY_TIMEOUT_SECONDS = 3600
|
||||
|
||||
|
||||
def auto_playlist_pipeline(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Run REFRESH → DISCOVER → SYNC → WISHLIST in sequence.
|
||||
|
||||
Sets / clears ``deps.state.pipeline_running`` around the whole
|
||||
run so the registration guard can short-circuit overlapping
|
||||
triggers.
|
||||
"""
|
||||
deps.state.set_pipeline_running(True)
|
||||
automation_id = config.get('_automation_id')
|
||||
pipeline_start = time.time()
|
||||
|
||||
try:
|
||||
db = deps.get_database()
|
||||
playlist_id = config.get('playlist_id')
|
||||
process_all = config.get('all', False)
|
||||
skip_wishlist = config.get('skip_wishlist', False)
|
||||
|
||||
# Resolve playlists.
|
||||
if process_all:
|
||||
playlists = db.get_mirrored_playlists()
|
||||
elif playlist_id:
|
||||
p = db.get_mirrored_playlist(int(playlist_id))
|
||||
playlists = [p] if p else []
|
||||
else:
|
||||
deps.state.set_pipeline_running(False)
|
||||
return {'status': 'error', 'error': 'No playlist specified'}
|
||||
|
||||
playlists = [pl for pl in playlists if pl.get('source', '') not in ('file', 'beatport')]
|
||||
if not playlists:
|
||||
deps.state.set_pipeline_running(False)
|
||||
return {'status': 'error', 'error': 'No refreshable playlists found'}
|
||||
|
||||
pl_names = ', '.join(p.get('name', '?') for p in playlists[:3])
|
||||
if len(playlists) > 3:
|
||||
pl_names += f' (+{len(playlists) - 3} more)'
|
||||
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=2,
|
||||
phase=f'Pipeline: {len(playlists)} playlist(s)',
|
||||
log_line=f'Starting pipeline for: {pl_names}',
|
||||
log_type='info',
|
||||
)
|
||||
|
||||
# ── PHASE 1: REFRESH ──────────────────────────────────────────
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=3,
|
||||
phase='Phase 1/4: Refreshing playlists...',
|
||||
log_line='Phase 1: Refresh',
|
||||
log_type='info',
|
||||
)
|
||||
|
||||
refresh_config = dict(config)
|
||||
refresh_config['_automation_id'] = None # Don't let sub-handler hijack pipeline progress.
|
||||
refresh_result = auto_refresh_mirrored(refresh_config, deps)
|
||||
refreshed = int(refresh_result.get('refreshed', 0))
|
||||
refresh_errors = int(refresh_result.get('errors', 0))
|
||||
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=25,
|
||||
phase='Phase 1/4: Refresh complete',
|
||||
log_line=f'Phase 1 done: {refreshed} refreshed, {refresh_errors} errors',
|
||||
log_type='success' if refresh_errors == 0 else 'warning',
|
||||
)
|
||||
|
||||
# ── PHASE 2: DISCOVER ─────────────────────────────────────────
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=26,
|
||||
phase='Phase 2/4: Discovering metadata...',
|
||||
log_line='Phase 2: Discover',
|
||||
log_type='info',
|
||||
)
|
||||
|
||||
# Reload playlists (refresh may have updated them).
|
||||
if process_all:
|
||||
disc_playlists = db.get_mirrored_playlists()
|
||||
else:
|
||||
disc_playlists = [db.get_mirrored_playlist(int(playlist_id))]
|
||||
disc_playlists = [p for p in disc_playlists if p]
|
||||
|
||||
# Run discovery in a thread and wait for it.
|
||||
disc_done = threading.Event()
|
||||
|
||||
def _disc_wrapper(pls):
|
||||
try:
|
||||
# The worker updates automation_progress internally,
|
||||
# but we pass None so it doesn't conflict with our
|
||||
# pipeline progress.
|
||||
deps.run_playlist_discovery_worker(pls, automation_id=None)
|
||||
except Exception as e:
|
||||
deps.logger.error(f"[Pipeline] Discovery error: {e}")
|
||||
finally:
|
||||
disc_done.set()
|
||||
|
||||
threading.Thread(
|
||||
target=_disc_wrapper, args=(disc_playlists,),
|
||||
daemon=True, name='pipeline-discover',
|
||||
).start()
|
||||
|
||||
# Poll for completion with progress updates.
|
||||
poll_start = time.time()
|
||||
while not disc_done.wait(timeout=3):
|
||||
elapsed = int(time.time() - poll_start)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=min(26 + elapsed // 4, 54),
|
||||
phase=f'Phase 2/4: Discovering... ({elapsed}s)',
|
||||
)
|
||||
if elapsed > _DISCOVERY_TIMEOUT_SECONDS:
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line='Discovery timed out after 1 hour',
|
||||
log_type='warning',
|
||||
)
|
||||
break
|
||||
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=55,
|
||||
phase='Phase 2/4: Discovery complete',
|
||||
log_line='Phase 2 done: discovery complete',
|
||||
log_type='success',
|
||||
)
|
||||
|
||||
# ── PHASE 3 + 4: SYNC + WISHLIST (delegated to shared helper) ──
|
||||
# Each mirrored playlist payload only needs `id` + `name` for
|
||||
# the helper; `auto_sync_playlist` reads the rest from the
|
||||
# mirrored DB by id.
|
||||
sync_summary = run_sync_and_wishlist(
|
||||
deps,
|
||||
automation_id,
|
||||
[pl for pl in playlists if pl.get('id')],
|
||||
sync_one_fn=lambda pl: auto_sync_playlist(
|
||||
{'playlist_id': str(pl['id']), '_automation_id': None},
|
||||
deps,
|
||||
),
|
||||
sync_id_for_fn=lambda pl: f"auto_mirror_{pl['id']}",
|
||||
skip_wishlist=skip_wishlist,
|
||||
progress_start=56,
|
||||
progress_end=85,
|
||||
sync_phase_label='Phase 3/4: Syncing to server...',
|
||||
sync_phase_start_log='Phase 3: Sync',
|
||||
wishlist_phase_label='Phase 4/4: Processing wishlist...',
|
||||
wishlist_phase_start_log='Phase 4: Wishlist',
|
||||
)
|
||||
total_synced = sync_summary['synced']
|
||||
total_skipped = sync_summary['skipped']
|
||||
sync_errors = sync_summary['errors']
|
||||
wishlist_queued = sync_summary['wishlist_queued']
|
||||
|
||||
# ── COMPLETE ──────────────────────────────────────────────────
|
||||
duration = int(time.time() - pipeline_start)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
status='finished',
|
||||
progress=100,
|
||||
phase='Pipeline complete',
|
||||
log_line=f'Pipeline finished in {duration // 60}m {duration % 60}s',
|
||||
log_type='success',
|
||||
)
|
||||
|
||||
deps.state.set_pipeline_running(False)
|
||||
return {
|
||||
'status': 'completed',
|
||||
'_manages_own_progress': True,
|
||||
'playlists_refreshed': str(refreshed),
|
||||
'tracks_discovered': 'completed',
|
||||
'tracks_synced': str(total_synced),
|
||||
'sync_skipped': str(total_skipped),
|
||||
'wishlist_queued': str(wishlist_queued),
|
||||
'duration_seconds': str(duration),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
deps.state.set_pipeline_running(False)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
status='error',
|
||||
progress=100,
|
||||
phase='Pipeline error',
|
||||
log_line=f'Pipeline failed: {e}',
|
||||
log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'error': str(e), '_manages_own_progress': True}
|
||||
27
core/automation/handlers/process_wishlist.py
Normal file
27
core/automation/handlers/process_wishlist.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""Automation handler: ``process_wishlist`` action.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers`` (the
|
||||
``_auto_process_wishlist`` closure). Wishlist processing is async —
|
||||
the helper submits a batch to an executor and returns immediately;
|
||||
per-track stats arrive later via batch-completion callbacks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
def auto_process_wishlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Kick off the wishlist processor for an automation trigger.
|
||||
|
||||
Returns immediately after submission; the wishlist worker emits
|
||||
per-batch progress via its own callbacks. We only report
|
||||
``status: completed`` to mark the trigger fired successfully.
|
||||
"""
|
||||
try:
|
||||
deps.process_wishlist_automatically(automation_id=config.get('_automation_id'))
|
||||
return {'status': 'completed'}
|
||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise into the engine
|
||||
return {'status': 'error', 'error': str(e)}
|
||||
89
core/automation/handlers/progress_callbacks.py
Normal file
89
core/automation/handlers/progress_callbacks.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""Progress + history callbacks the automation engine invokes around
|
||||
each handler run.
|
||||
|
||||
Lifted from the closures at the bottom of
|
||||
``web_server._register_automation_handlers``:
|
||||
- ``_progress_init`` → :func:`progress_init`
|
||||
- ``_progress_finish`` → :func:`progress_finish`
|
||||
- ``_record_automation_history`` → :func:`record_history`
|
||||
- ``_on_library_scan_completed`` → :func:`on_library_scan_completed`
|
||||
|
||||
The engine accepts four callables via
|
||||
``register_progress_callbacks(init, finish, update, history)``;
|
||||
``registration.register_all`` wires these here. The
|
||||
``library_scan_completed`` callback is registered separately on the
|
||||
``web_scan_manager`` (when one is available) -- see
|
||||
``register_library_scan_completed_emitter``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
def progress_init(aid: Any, name: str, action_type: str, deps: AutomationDeps) -> None:
|
||||
"""Initialize per-automation progress state when the engine starts
|
||||
a handler. Thin wrapper so the engine receives a closure that
|
||||
delegates into the live progress tracker."""
|
||||
deps.init_automation_progress(aid, name, action_type)
|
||||
|
||||
|
||||
def progress_finish(aid: Any, result: Dict[str, Any], deps: AutomationDeps) -> None:
|
||||
"""Emit the final progress update when a handler returns.
|
||||
|
||||
Skipped for handlers that manage their own progress lifecycle
|
||||
(they call ``update_progress(status='finished')`` themselves and
|
||||
set ``_manages_own_progress: True`` in the returned dict).
|
||||
Otherwise translates the handler's status into a finished/error
|
||||
progress emit with a status-appropriate phase + log line.
|
||||
"""
|
||||
if result.get('_manages_own_progress'):
|
||||
return
|
||||
result_status = result.get('status', '')
|
||||
status = 'error' if result_status == 'error' else 'finished'
|
||||
msg = result.get('error', result.get('reason', result_status or 'done'))
|
||||
deps.update_progress(
|
||||
aid,
|
||||
status=status,
|
||||
progress=100,
|
||||
phase='Error' if status == 'error' else 'Complete',
|
||||
log_line=msg,
|
||||
log_type='error' if status == 'error' else 'success',
|
||||
)
|
||||
|
||||
|
||||
def record_history(aid: Any, result: Dict[str, Any], deps: AutomationDeps) -> None:
|
||||
"""Capture progress state into run history before the engine's
|
||||
cleanup pass clears it. Thin wrapper so the engine sees a stable
|
||||
callable."""
|
||||
deps.record_progress_history(aid, result, deps.get_database())
|
||||
|
||||
|
||||
def on_library_scan_completed(deps: AutomationDeps) -> None:
|
||||
"""Emit the ``library_scan_completed`` automation event with the
|
||||
active media-server type. Replaces the hard-coded
|
||||
``scan_completion_callback → trigger_automatic_database_update``
|
||||
chain so any automation can listen for scan completion as a
|
||||
trigger."""
|
||||
if not deps.engine:
|
||||
return
|
||||
server_type = (
|
||||
getattr(deps.web_scan_manager, '_current_server_type', None)
|
||||
or 'unknown'
|
||||
)
|
||||
deps.engine.emit('library_scan_completed', {
|
||||
'server_type': server_type,
|
||||
})
|
||||
|
||||
|
||||
def register_library_scan_completed_emitter(deps: AutomationDeps) -> None:
|
||||
"""Wire :func:`on_library_scan_completed` to the
|
||||
``web_scan_manager``'s scan-completion callback list. No-op when
|
||||
no scan manager is configured (e.g. headless / test contexts)."""
|
||||
if not deps.web_scan_manager:
|
||||
return
|
||||
deps.web_scan_manager.add_scan_completion_callback(
|
||||
lambda: on_library_scan_completed(deps),
|
||||
)
|
||||
83
core/automation/handlers/quality_scanner.py
Normal file
83
core/automation/handlers/quality_scanner.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Automation handler: ``start_quality_scan`` action.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers`` (the
|
||||
``_auto_start_quality_scan`` closure). Submits the quality scanner
|
||||
to its executor with the configured scope (default: ``watchlist``)
|
||||
then polls the shared state dict.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
_TIMEOUT_SECONDS = 7200 # 2 hours
|
||||
_POLL_INTERVAL_SECONDS = 3
|
||||
_INITIAL_DELAY_SECONDS = 1
|
||||
|
||||
|
||||
def auto_start_quality_scan(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
automation_id = config.get('_automation_id')
|
||||
state = deps.get_quality_scanner_state()
|
||||
if state.get('status') == 'running':
|
||||
return {'status': 'skipped', 'reason': 'Quality scan already running'}
|
||||
|
||||
scope = config.get('scope', 'watchlist')
|
||||
# Pre-set status before submit so the polling loop doesn't see a
|
||||
# stale 'finished' from a previous run.
|
||||
with deps.quality_scanner_lock:
|
||||
state['status'] = 'running'
|
||||
deps.quality_scanner_executor.submit(deps.run_quality_scanner, scope, deps.get_current_profile_id())
|
||||
deps.update_progress(
|
||||
automation_id, log_line=f'Quality scan started (scope: {scope})', log_type='info',
|
||||
)
|
||||
|
||||
# Monitor progress (max 2 hours).
|
||||
time.sleep(_INITIAL_DELAY_SECONDS)
|
||||
poll_start = time.time()
|
||||
while time.time() - poll_start < _TIMEOUT_SECONDS:
|
||||
time.sleep(_POLL_INTERVAL_SECONDS)
|
||||
current_status = state.get('status', 'idle')
|
||||
if current_status not in ('running',):
|
||||
break
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
phase=state.get('phase', 'Scanning...'),
|
||||
progress=state.get('progress', 0),
|
||||
processed=state.get('processed', 0),
|
||||
total=state.get('total', 0),
|
||||
)
|
||||
else:
|
||||
deps.update_progress(
|
||||
automation_id, status='error',
|
||||
phase='Timed out', log_line='Quality scan timed out after 2 hours',
|
||||
log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'reason': 'Timed out', '_manages_own_progress': True}
|
||||
|
||||
final_status = state.get('status', 'idle')
|
||||
if final_status == 'error':
|
||||
err = state.get('error_message', 'Unknown error')
|
||||
deps.update_progress(
|
||||
automation_id, status='error', progress=100,
|
||||
phase='Error', log_line=err, log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'reason': err, '_manages_own_progress': True}
|
||||
|
||||
issues = state.get('low_quality', 0)
|
||||
deps.update_progress(
|
||||
automation_id, status='finished', progress=100,
|
||||
phase='Complete',
|
||||
log_line=f'Quality scan complete — {issues} issues found',
|
||||
log_type='success',
|
||||
)
|
||||
return {
|
||||
'status': 'completed', 'scope': scope, '_manages_own_progress': True,
|
||||
'tracks_scanned': state.get('processed', 0),
|
||||
'quality_met': state.get('quality_met', 0),
|
||||
'low_quality': issues,
|
||||
'matched': state.get('matched', 0),
|
||||
}
|
||||
308
core/automation/handlers/refresh_mirrored.py
Normal file
308
core/automation/handlers/refresh_mirrored.py
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
"""Automation handler: ``refresh_mirrored`` action.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers`` (the
|
||||
``_auto_refresh_mirrored`` closure). Re-pulls track lists from each
|
||||
mirrored playlist's source (Spotify / Tidal / Deezer / YouTube),
|
||||
updates the local mirror DB, and emits a ``playlist_changed``
|
||||
automation event when the track set actually shifts.
|
||||
|
||||
Source-specific branches (Spotify auth + public-embed fallback,
|
||||
``spotify_public`` URL→ID resolution, Deezer / Tidal / YouTube)
|
||||
remain identical to the pre-extraction closure — this is a
|
||||
mechanical lift, not a redesign.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Refresh mirrored playlist(s) from source.
|
||||
|
||||
Returns ``{'status': 'completed', 'refreshed': '<int>',
|
||||
'errors': '<int>'}`` on success (counts stringified to match the
|
||||
automation engine's stat-rendering convention).
|
||||
"""
|
||||
db = deps.get_database()
|
||||
playlist_id = config.get('playlist_id')
|
||||
refresh_all = config.get('all', False)
|
||||
auto_id = config.get('_automation_id')
|
||||
|
||||
if refresh_all:
|
||||
playlists = db.get_mirrored_playlists()
|
||||
elif playlist_id:
|
||||
p = db.get_mirrored_playlist(int(playlist_id))
|
||||
playlists = [p] if p else []
|
||||
else:
|
||||
return {'status': 'error', 'reason': 'No playlist specified'}
|
||||
|
||||
# Filter out sources that can't be refreshed (no external API).
|
||||
playlists = [pl for pl in playlists if pl.get('source', '') not in ('file', 'beatport')]
|
||||
|
||||
refreshed = 0
|
||||
errors = []
|
||||
for idx, pl in enumerate(playlists):
|
||||
try:
|
||||
source = pl.get('source', '')
|
||||
source_id = pl.get('source_playlist_id', '')
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
progress=(idx / max(1, len(playlists))) * 100,
|
||||
phase=f'Refreshing: "{pl.get("name", "")}"',
|
||||
current_item=pl.get('name', ''),
|
||||
)
|
||||
tracks = None
|
||||
|
||||
if source == 'spotify':
|
||||
# Try authenticated API first, fall back to public embed scraper.
|
||||
if deps.spotify_client and deps.spotify_client.is_spotify_authenticated():
|
||||
playlist_obj = deps.spotify_client.get_playlist_by_id(source_id)
|
||||
if playlist_obj and playlist_obj.tracks:
|
||||
tracks = []
|
||||
for t in playlist_obj.tracks:
|
||||
artist_name = t.artists[0] if t.artists else ''
|
||||
track_dict = {
|
||||
'track_name': t.name or '',
|
||||
'artist_name': str(artist_name),
|
||||
'album_name': t.album or '',
|
||||
'duration_ms': t.duration_ms or 0,
|
||||
'source_track_id': t.id or '',
|
||||
}
|
||||
# Spotify data IS official — auto-mark as discovered.
|
||||
if t.id:
|
||||
_album_obj = {'name': t.album or ''}
|
||||
if getattr(t, 'image_url', None):
|
||||
_album_obj['images'] = [{'url': t.image_url, 'height': 600, 'width': 600}]
|
||||
track_dict['extra_data'] = json.dumps({
|
||||
'discovered': True,
|
||||
'provider': 'spotify',
|
||||
'confidence': 1.0,
|
||||
'matched_data': {
|
||||
'id': t.id,
|
||||
'name': t.name or '',
|
||||
'artists': [{'name': str(a)} for a in (t.artists or [])],
|
||||
'album': _album_obj,
|
||||
'duration_ms': t.duration_ms or 0,
|
||||
'image_url': getattr(t, 'image_url', None),
|
||||
}
|
||||
})
|
||||
tracks.append(track_dict)
|
||||
|
||||
# Fallback: public embed scraper (no auth needed).
|
||||
if tracks is None:
|
||||
try:
|
||||
from core.spotify_public_scraper import scrape_spotify_embed
|
||||
embed_data = scrape_spotify_embed('playlist', source_id)
|
||||
if embed_data and not embed_data.get('error') and embed_data.get('tracks'):
|
||||
embed_album = embed_data.get('name', '') if embed_data.get('type') == 'album' else ''
|
||||
tracks = []
|
||||
for t in embed_data['tracks']:
|
||||
artist_names = [a['name'] for a in t.get('artists', [])]
|
||||
artist_name = artist_names[0] if artist_names else ''
|
||||
track_dict = {
|
||||
'track_name': t.get('name', ''),
|
||||
'artist_name': artist_name,
|
||||
'album_name': embed_album,
|
||||
'duration_ms': t.get('duration_ms', 0),
|
||||
'source_track_id': t.get('id', ''),
|
||||
}
|
||||
# Store Spotify track ID hint but don't mark discovered —
|
||||
# Discover step needs to run for proper album art.
|
||||
if t.get('id'):
|
||||
track_dict['extra_data'] = json.dumps({
|
||||
'discovered': False,
|
||||
'spotify_hint': {
|
||||
'id': t['id'],
|
||||
'name': t.get('name', ''),
|
||||
'artists': t.get('artists', []),
|
||||
}
|
||||
})
|
||||
tracks.append(track_dict)
|
||||
except Exception as e:
|
||||
deps.logger.warning(f"Spotify public scraper fallback failed for {source_id}: {e}")
|
||||
|
||||
elif source == 'spotify_public':
|
||||
# source_playlist_id is an MD5 hash; extract actual Spotify ID from stored description (URL).
|
||||
try:
|
||||
from core.spotify_public_scraper import parse_spotify_url, scrape_spotify_embed
|
||||
spotify_url = pl.get('description', '')
|
||||
parsed = parse_spotify_url(spotify_url) if spotify_url else None
|
||||
|
||||
# If Spotify is authenticated, use the full API (auto-discovers with album art).
|
||||
if (parsed and parsed.get('type') == 'playlist'
|
||||
and deps.spotify_client and deps.spotify_client.is_spotify_authenticated()):
|
||||
playlist_obj = deps.spotify_client.get_playlist_by_id(parsed['id'])
|
||||
if playlist_obj and playlist_obj.tracks:
|
||||
tracks = []
|
||||
for t in playlist_obj.tracks:
|
||||
artist_name = t.artists[0] if t.artists else ''
|
||||
track_dict = {
|
||||
'track_name': t.name or '',
|
||||
'artist_name': str(artist_name),
|
||||
'album_name': t.album or '',
|
||||
'duration_ms': t.duration_ms or 0,
|
||||
'source_track_id': t.id or '',
|
||||
}
|
||||
if t.id:
|
||||
_album_obj = {'name': t.album or ''}
|
||||
if getattr(t, 'image_url', None):
|
||||
_album_obj['images'] = [{'url': t.image_url, 'height': 600, 'width': 600}]
|
||||
track_dict['extra_data'] = json.dumps({
|
||||
'discovered': True,
|
||||
'provider': 'spotify',
|
||||
'confidence': 1.0,
|
||||
'matched_data': {
|
||||
'id': t.id,
|
||||
'name': t.name or '',
|
||||
'artists': [{'name': str(a)} for a in (t.artists or [])],
|
||||
'album': _album_obj,
|
||||
'duration_ms': t.duration_ms or 0,
|
||||
'image_url': getattr(t, 'image_url', None),
|
||||
}
|
||||
})
|
||||
tracks.append(track_dict)
|
||||
|
||||
# Fallback: public embed scraper (no auth or album-type URL).
|
||||
if tracks is None and parsed:
|
||||
embed_data = scrape_spotify_embed(parsed['type'], parsed['id'])
|
||||
if embed_data and not embed_data.get('error') and embed_data.get('tracks'):
|
||||
embed_album = embed_data.get('name', '') if embed_data.get('type') == 'album' else ''
|
||||
tracks = []
|
||||
for t in embed_data['tracks']:
|
||||
artist_names = [a['name'] for a in t.get('artists', [])]
|
||||
artist_name = artist_names[0] if artist_names else ''
|
||||
tracks.append({
|
||||
'track_name': t.get('name', ''),
|
||||
'artist_name': artist_name,
|
||||
'album_name': embed_album,
|
||||
'duration_ms': t.get('duration_ms', 0),
|
||||
'source_track_id': t.get('id', ''),
|
||||
})
|
||||
# No extra_data — let preservation code keep existing discovery data.
|
||||
except Exception as e:
|
||||
deps.logger.warning(f"Spotify public playlist refresh failed for {source_id}: {e}")
|
||||
|
||||
elif source == 'deezer':
|
||||
try:
|
||||
deezer = deps.get_deezer_client()
|
||||
playlist_data = deezer.get_playlist(source_id)
|
||||
if playlist_data and playlist_data.get('tracks'):
|
||||
tracks = []
|
||||
for t in playlist_data['tracks']:
|
||||
artist_name = t['artists'][0] if t.get('artists') else ''
|
||||
tracks.append({
|
||||
'track_name': t.get('name', ''),
|
||||
'artist_name': str(artist_name),
|
||||
'album_name': t.get('album', ''),
|
||||
'duration_ms': t.get('duration_ms', 0),
|
||||
'source_track_id': str(t.get('id', '')),
|
||||
})
|
||||
except Exception as e:
|
||||
deps.logger.warning(f"Deezer playlist refresh failed for {source_id}: {e}")
|
||||
|
||||
elif source == 'tidal':
|
||||
if not deps.tidal_client or not deps.tidal_client.is_authenticated():
|
||||
deps.logger.warning(f"Tidal not authenticated — skipping refresh for '{pl.get('name', '')}'")
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
log_line=f'Skipped "{pl.get("name", "")}" — Tidal not authenticated',
|
||||
log_type='skip',
|
||||
)
|
||||
continue
|
||||
full_playlist = deps.tidal_client.get_playlist(source_id)
|
||||
if full_playlist and full_playlist.tracks:
|
||||
tracks = []
|
||||
for t in full_playlist.tracks:
|
||||
artist_name = t.artists[0] if t.artists else ''
|
||||
tracks.append({
|
||||
'track_name': t.name or '',
|
||||
'artist_name': str(artist_name),
|
||||
'album_name': t.album or '',
|
||||
'duration_ms': t.duration_ms or 0,
|
||||
'source_track_id': t.id or '',
|
||||
})
|
||||
|
||||
elif source == 'youtube':
|
||||
# source_playlist_id is now a deterministic hash; use stored description (original URL) for refresh.
|
||||
yt_url = pl.get('description', '') or f"https://www.youtube.com/playlist?list={source_id}"
|
||||
playlist_data = deps.parse_youtube_playlist(yt_url)
|
||||
if playlist_data and playlist_data.get('tracks'):
|
||||
tracks = []
|
||||
for t in playlist_data['tracks']:
|
||||
artist_name = t['artists'][0] if t.get('artists') else ''
|
||||
tracks.append({
|
||||
'track_name': t.get('name', ''),
|
||||
'artist_name': str(artist_name),
|
||||
'album_name': '',
|
||||
'duration_ms': t.get('duration_ms', 0),
|
||||
'source_track_id': t.get('id', ''),
|
||||
})
|
||||
|
||||
if tracks is not None:
|
||||
# Compare old vs new track IDs to detect changes.
|
||||
old_tracks = db.get_mirrored_playlist_tracks(pl['id']) if pl.get('id') else []
|
||||
old_ids = {t.get('source_track_id') for t in old_tracks if t.get('source_track_id')}
|
||||
new_ids = {t.get('source_track_id') for t in tracks if t.get('source_track_id')}
|
||||
|
||||
# Preserve existing discovery extra_data for tracks that still exist.
|
||||
old_extra_map = db.get_mirrored_tracks_extra_data_map(pl['id']) if pl.get('id') else {}
|
||||
for t in tracks:
|
||||
sid = t.get('source_track_id', '')
|
||||
if sid and sid in old_extra_map and 'extra_data' not in t:
|
||||
t['extra_data'] = old_extra_map[sid]
|
||||
|
||||
db.mirror_playlist(
|
||||
source=source,
|
||||
source_playlist_id=source_id,
|
||||
name=pl['name'],
|
||||
tracks=tracks,
|
||||
profile_id=pl.get('profile_id', 1),
|
||||
owner=pl.get('owner'),
|
||||
image_url=pl.get('image_url'),
|
||||
)
|
||||
refreshed += 1
|
||||
|
||||
# Emit playlist_changed if tracks actually changed.
|
||||
if old_ids != new_ids:
|
||||
added_count = len(new_ids - old_ids)
|
||||
removed_count = len(old_ids - new_ids)
|
||||
deps.logger.info(
|
||||
f"[AUTOMATION] Playlist changed: '{pl.get('name', '')}' — "
|
||||
f"{added_count} added, {removed_count} removed (old={len(old_ids)}, new={len(new_ids)})"
|
||||
)
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
log_line=f'"{pl.get("name", "")}" — {added_count} added, {removed_count} removed',
|
||||
log_type='success',
|
||||
)
|
||||
try:
|
||||
if deps.engine:
|
||||
deps.engine.emit('playlist_changed', {
|
||||
'playlist_name': pl.get('name', ''),
|
||||
'playlist_id': str(pl.get('id', '')),
|
||||
'old_count': str(len(old_ids)),
|
||||
'new_count': str(len(new_ids)),
|
||||
'added': str(added_count),
|
||||
'removed': str(removed_count),
|
||||
})
|
||||
except Exception as e:
|
||||
deps.logger.debug("playlist_synced automation emit failed: %s", e)
|
||||
else:
|
||||
deps.logger.warning(f"[AUTOMATION] No changes: '{pl.get('name', '')}' (tracks={len(old_ids)})")
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
log_line=f'No changes: "{pl.get("name", "")}"',
|
||||
log_type='skip',
|
||||
)
|
||||
except Exception as e:
|
||||
errors.append(f"{pl.get('name', '?')}: {str(e)}")
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
log_line=f'Error: {pl.get("name", "?")} — {str(e)}',
|
||||
log_type='error',
|
||||
)
|
||||
return {'status': 'completed', 'refreshed': str(refreshed), 'errors': str(len(errors))}
|
||||
184
core/automation/handlers/registration.py
Normal file
184
core/automation/handlers/registration.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
"""One-stop registration of every extracted automation handler.
|
||||
|
||||
``web_server`` builds the deps once at startup and calls
|
||||
:func:`register_all` here. Each new handler module gets one line in
|
||||
this file when it lands.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
from core.automation.handlers.process_wishlist import auto_process_wishlist
|
||||
from core.automation.handlers.scan_watchlist import auto_scan_watchlist
|
||||
from core.automation.handlers.scan_library import auto_scan_library
|
||||
from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored
|
||||
from core.automation.handlers.sync_playlist import auto_sync_playlist
|
||||
from core.automation.handlers.discover_playlist import auto_discover_playlist
|
||||
from core.automation.handlers.playlist_pipeline import auto_playlist_pipeline
|
||||
from core.automation.handlers.personalized_pipeline import auto_personalized_pipeline
|
||||
from core.automation.handlers.database_update import (
|
||||
auto_start_database_update, auto_deep_scan_library,
|
||||
)
|
||||
from core.automation.handlers.duplicate_cleaner import auto_run_duplicate_cleaner
|
||||
from core.automation.handlers.quality_scanner import auto_start_quality_scan
|
||||
from core.automation.handlers.maintenance import (
|
||||
auto_clear_quarantine,
|
||||
auto_cleanup_wishlist,
|
||||
auto_update_discovery_pool,
|
||||
auto_backup_database,
|
||||
auto_refresh_beatport_cache,
|
||||
)
|
||||
from core.automation.handlers.download_cleanup import (
|
||||
auto_clean_search_history,
|
||||
auto_clean_completed_downloads,
|
||||
auto_full_cleanup,
|
||||
)
|
||||
from core.automation.handlers.run_script import auto_run_script
|
||||
from core.automation.handlers.search_and_download import auto_search_and_download
|
||||
from core.automation.handlers.progress_callbacks import (
|
||||
progress_init,
|
||||
progress_finish,
|
||||
record_history,
|
||||
register_library_scan_completed_emitter,
|
||||
)
|
||||
|
||||
|
||||
def register_all(deps: AutomationDeps) -> None:
|
||||
"""Wire every extracted handler to the engine.
|
||||
|
||||
Each ``register_action_handler`` call binds the action name (the
|
||||
string the trigger uses to look up its action) to a thin lambda
|
||||
that injects ``deps`` and forwards the engine-supplied config.
|
||||
Guards stay alongside their handler so duplicate-run prevention
|
||||
behaves identically to the pre-extraction code.
|
||||
"""
|
||||
engine = deps.engine
|
||||
|
||||
# Self-guards prevent duplicate runs of the SAME operation, but
|
||||
# different operations can run concurrently — wishlist downloads
|
||||
# use bandwidth, watchlist scans use API calls, library scans use
|
||||
# media-server CPU. Different resources, no contention.
|
||||
engine.register_action_handler(
|
||||
'process_wishlist',
|
||||
lambda config: auto_process_wishlist(config, deps),
|
||||
guard_fn=deps.is_wishlist_actually_processing,
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'scan_watchlist',
|
||||
lambda config: auto_scan_watchlist(config, deps),
|
||||
guard_fn=deps.is_watchlist_actually_scanning,
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'scan_library',
|
||||
lambda config: auto_scan_library(config, deps),
|
||||
deps.state.is_scan_library_active,
|
||||
)
|
||||
|
||||
# Playlist lifecycle handlers. The pipeline composes refresh +
|
||||
# sync + discover (it imports them directly), so all four ship
|
||||
# together. The pipeline guard prevents an in-flight pipeline
|
||||
# from being re-triggered mid-run.
|
||||
engine.register_action_handler(
|
||||
'refresh_mirrored',
|
||||
lambda config: auto_refresh_mirrored(config, deps),
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'sync_playlist',
|
||||
lambda config: auto_sync_playlist(config, deps),
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'discover_playlist',
|
||||
lambda config: auto_discover_playlist(config, deps),
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'playlist_pipeline',
|
||||
lambda config: auto_playlist_pipeline(config, deps),
|
||||
deps.state.is_pipeline_running,
|
||||
)
|
||||
# Personalized pipeline shares the pipeline_running flag with the
|
||||
# mirrored pipeline so the two can't overlap (single sync queue,
|
||||
# single wishlist worker).
|
||||
engine.register_action_handler(
|
||||
'personalized_pipeline',
|
||||
lambda config: auto_personalized_pipeline(config, deps),
|
||||
deps.state.is_pipeline_running,
|
||||
)
|
||||
|
||||
# Database update + deep scan share the db_update_state guard —
|
||||
# only one operation can mutate that state at a time.
|
||||
engine.register_action_handler(
|
||||
'start_database_update',
|
||||
lambda config: auto_start_database_update(config, deps),
|
||||
lambda: deps.get_db_update_state().get('status') == 'running',
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'deep_scan_library',
|
||||
lambda config: auto_deep_scan_library(config, deps),
|
||||
lambda: deps.get_db_update_state().get('status') == 'running',
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'run_duplicate_cleaner',
|
||||
lambda config: auto_run_duplicate_cleaner(config, deps),
|
||||
lambda: deps.get_duplicate_cleaner_state().get('status') == 'running',
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'clear_quarantine',
|
||||
lambda config: auto_clear_quarantine(config, deps),
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'cleanup_wishlist',
|
||||
lambda config: auto_cleanup_wishlist(config, deps),
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'update_discovery_pool',
|
||||
lambda config: auto_update_discovery_pool(config, deps),
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'start_quality_scan',
|
||||
lambda config: auto_start_quality_scan(config, deps),
|
||||
lambda: deps.get_quality_scanner_state().get('status') == 'running',
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'backup_database',
|
||||
lambda config: auto_backup_database(config, deps),
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'refresh_beatport_cache',
|
||||
lambda config: auto_refresh_beatport_cache(config, deps),
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'clean_search_history',
|
||||
lambda config: auto_clean_search_history(config, deps),
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'clean_completed_downloads',
|
||||
lambda config: auto_clean_completed_downloads(config, deps),
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'full_cleanup',
|
||||
lambda config: auto_full_cleanup(config, deps),
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'run_script',
|
||||
lambda config: auto_run_script(config, deps),
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'search_and_download',
|
||||
lambda config: auto_search_and_download(config, deps),
|
||||
)
|
||||
|
||||
# Progress + history callbacks: the engine invokes these around
|
||||
# each handler run. Lift the closures from
|
||||
# `web_server._register_automation_handlers` into thin lambdas
|
||||
# that delegate into the extracted top-level functions.
|
||||
engine.register_progress_callbacks(
|
||||
lambda aid, name, action_type: progress_init(aid, name, action_type, deps),
|
||||
lambda aid, result: progress_finish(aid, result, deps),
|
||||
deps.update_progress,
|
||||
lambda aid, result: record_history(aid, result, deps),
|
||||
)
|
||||
|
||||
# `library_scan_completed` event: when the media-server scan
|
||||
# manager finishes a scan, emit the event so any automation can
|
||||
# trigger off it. No-op when no scan manager is configured.
|
||||
register_library_scan_completed_emitter(deps)
|
||||
103
core/automation/handlers/run_script.py
Normal file
103
core/automation/handlers/run_script.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""Automation handler: ``run_script`` action.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers`` (the
|
||||
``_auto_run_script`` closure). Runs a user-provided shell or Python
|
||||
script from the configured scripts directory with bounded timeout +
|
||||
captured stdout/stderr. Path-traversal guard ensures users can't
|
||||
escape the scripts directory.
|
||||
|
||||
Environment variables exposed to the script:
|
||||
- ``SOULSYNC_EVENT``: triggering event type (when fired by an event)
|
||||
- ``SOULSYNC_AUTOMATION``: automation name
|
||||
- ``SOULSYNC_SCRIPTS_DIR``: absolute path to the scripts dir
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess as _sp
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
_MAX_TIMEOUT_SECONDS = 300 # Hard cap on user-supplied timeout config.
|
||||
|
||||
|
||||
def auto_run_script(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
script_name = config.get('script_name', '')
|
||||
timeout = min(int(config.get('timeout', 60)), _MAX_TIMEOUT_SECONDS)
|
||||
automation_id = config.get('_automation_id')
|
||||
|
||||
if not script_name:
|
||||
return {'status': 'error', 'error': 'No script selected'}
|
||||
|
||||
scripts_dir = deps.docker_resolve_path(deps.config_manager.get('scripts.path', './scripts'))
|
||||
if not scripts_dir or not os.path.isdir(scripts_dir):
|
||||
os.makedirs(scripts_dir, exist_ok=True)
|
||||
return {
|
||||
'status': 'error',
|
||||
'error': 'Scripts directory is empty. Add scripts to the scripts/ folder.',
|
||||
}
|
||||
|
||||
script_path = os.path.join(scripts_dir, script_name)
|
||||
script_path = os.path.realpath(script_path)
|
||||
|
||||
# Security: block path traversal — script must resolve under
|
||||
# the scripts dir, no symlinks/.. tricks allowed out.
|
||||
if not script_path.startswith(os.path.realpath(scripts_dir)):
|
||||
return {'status': 'error', 'error': 'Script path traversal blocked'}
|
||||
|
||||
if not os.path.isfile(script_path):
|
||||
return {'status': 'error', 'error': f'Script not found: {script_name}'}
|
||||
|
||||
deps.update_progress(automation_id, phase=f'Running {script_name}...', progress=10)
|
||||
|
||||
# Build environment with SoulSync context.
|
||||
env = os.environ.copy()
|
||||
event_data = config.get('_event_data') or {}
|
||||
env['SOULSYNC_EVENT'] = str(event_data.get('type', ''))
|
||||
env['SOULSYNC_AUTOMATION'] = config.get('_automation_name', '')
|
||||
env['SOULSYNC_SCRIPTS_DIR'] = scripts_dir
|
||||
|
||||
try:
|
||||
# Determine how to run the script.
|
||||
if script_path.endswith('.py'):
|
||||
cmd = ['python', script_path]
|
||||
elif script_path.endswith('.sh'):
|
||||
cmd = ['bash', script_path]
|
||||
else:
|
||||
cmd = [script_path]
|
||||
|
||||
result = _sp.run(
|
||||
cmd,
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
cwd=scripts_dir, env=env,
|
||||
)
|
||||
|
||||
deps.update_progress(automation_id, phase='Script completed', progress=100)
|
||||
|
||||
stdout = result.stdout[:2000] if result.stdout else ''
|
||||
stderr = result.stderr[:1000] if result.stderr else ''
|
||||
|
||||
if result.returncode == 0:
|
||||
deps.logger.info(f"Script '{script_name}' completed (exit 0)")
|
||||
else:
|
||||
deps.logger.warning(f"Script '{script_name}' exited with code {result.returncode}")
|
||||
|
||||
return {
|
||||
'status': 'completed' if result.returncode == 0 else 'error',
|
||||
'exit_code': str(result.returncode),
|
||||
'stdout': stdout,
|
||||
'stderr': stderr,
|
||||
'script': script_name,
|
||||
}
|
||||
except _sp.TimeoutExpired:
|
||||
deps.update_progress(automation_id, phase='Script timed out', progress=100)
|
||||
return {
|
||||
'status': 'error',
|
||||
'error': f'Script timed out after {timeout}s',
|
||||
'script': script_name,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise
|
||||
return {'status': 'error', 'error': str(e), 'script': script_name}
|
||||
158
core/automation/handlers/scan_library.py
Normal file
158
core/automation/handlers/scan_library.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
"""Automation handler: ``scan_library`` action.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers`` (the
|
||||
``_auto_scan_library`` closure). The handler triggers a media-server
|
||||
scan via ``web_scan_manager``, then polls the manager's status until
|
||||
the scan completes (or a 30-minute timeout fires). Progress phases
|
||||
are emitted via :func:`AutomationDeps.update_progress` so the
|
||||
trigger card stays current throughout the run.
|
||||
|
||||
The handler manages its own progress reporting (it sets
|
||||
``_manages_own_progress: True`` in the result) so the engine doesn't
|
||||
overwrite the live phase string with a generic 'completed' label.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
# Outer poll cap — covers extreme worst case (long Plex scans on
|
||||
# huge libraries). Past this point we surface a clear timeout error
|
||||
# so users notice rather than letting the trigger hang forever.
|
||||
_SCAN_TIMEOUT_SECONDS = 1800
|
||||
|
||||
# Per-phase poll intervals.
|
||||
_POLL_SCHEDULED_SECONDS = 2
|
||||
_POLL_SCANNING_SECONDS = 5
|
||||
_POLL_UNKNOWN_SECONDS = 2
|
||||
|
||||
# Progress percentage waypoints.
|
||||
_PROGRESS_SCHEDULED_MAX = 14
|
||||
_PROGRESS_SCAN_START = 15
|
||||
_PROGRESS_SCAN_MAX = 95
|
||||
|
||||
|
||||
def auto_scan_library(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Run a media-server library scan and stream progress to the
|
||||
trigger card.
|
||||
|
||||
Returns one of:
|
||||
- ``{'status': 'completed', '_manages_own_progress': True, ...}``
|
||||
- ``{'status': 'skipped', 'reason': 'Scan already being tracked'}``
|
||||
- ``{'status': 'error', 'reason': '...', '_manages_own_progress': True}``
|
||||
"""
|
||||
automation_id = config.get('_automation_id')
|
||||
|
||||
if not deps.web_scan_manager:
|
||||
return {'status': 'error', 'reason': 'Scan manager not available'}
|
||||
|
||||
# If another automation is already tracking the scan, just forward
|
||||
# the request — the original tracker keeps emitting progress.
|
||||
if deps.state.is_scan_library_active():
|
||||
deps.web_scan_manager.request_scan('Automation trigger (additional batch)')
|
||||
return {'status': 'skipped', 'reason': 'Scan already being tracked'}
|
||||
|
||||
deps.state.set_scan_library_id(automation_id)
|
||||
|
||||
try:
|
||||
result = deps.web_scan_manager.request_scan('Automation trigger')
|
||||
scan_status_val = result.get('status', 'unknown')
|
||||
|
||||
if scan_status_val == 'queued':
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line='Scan already in progress — waiting for completion',
|
||||
log_type='info',
|
||||
)
|
||||
else:
|
||||
delay = result.get('delay_seconds', 60)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Scan scheduled (debounce: {delay}s)',
|
||||
log_type='info',
|
||||
)
|
||||
|
||||
# Unified polling loop — handles debounce → scanning → idle.
|
||||
poll_start = time.time()
|
||||
scan_started = (scan_status_val == 'queued')
|
||||
while time.time() - poll_start < _SCAN_TIMEOUT_SECONDS:
|
||||
status = deps.web_scan_manager.get_scan_status()
|
||||
st = status.get('status')
|
||||
|
||||
if st == 'idle':
|
||||
break # Scan completed (or finished before we polled)
|
||||
|
||||
if st == 'scheduled':
|
||||
elapsed = int(time.time() - poll_start)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
phase=f'Waiting for scan to start... ({elapsed}s)',
|
||||
progress=min(int(elapsed / 60 * 10), _PROGRESS_SCHEDULED_MAX),
|
||||
)
|
||||
time.sleep(_POLL_SCHEDULED_SECONDS)
|
||||
continue
|
||||
|
||||
if st == 'scanning':
|
||||
if not scan_started:
|
||||
scan_started = True
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
progress=_PROGRESS_SCAN_START,
|
||||
log_line='Scan triggered on media server',
|
||||
log_type='success',
|
||||
)
|
||||
elapsed = status.get('elapsed_seconds', 0)
|
||||
max_time = status.get('max_time_seconds', 300)
|
||||
pct = min(_PROGRESS_SCAN_START + int(elapsed / max_time * 80), _PROGRESS_SCAN_MAX)
|
||||
mins, secs = divmod(elapsed, 60)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
phase=f'Library scan in progress... ({mins}m {secs}s)',
|
||||
progress=pct,
|
||||
)
|
||||
time.sleep(_POLL_SCANNING_SECONDS)
|
||||
continue
|
||||
|
||||
time.sleep(_POLL_UNKNOWN_SECONDS)
|
||||
else:
|
||||
# 30-min timeout reached
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
status='error',
|
||||
phase='Timed out',
|
||||
log_line='Library scan timed out after 30 minutes',
|
||||
log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'reason': 'Timed out', '_manages_own_progress': True}
|
||||
|
||||
elapsed = round(time.time() - poll_start, 1)
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
status='finished',
|
||||
progress=100,
|
||||
phase='Complete',
|
||||
log_line='Library scan completed',
|
||||
log_type='success',
|
||||
)
|
||||
return {
|
||||
'status': 'completed',
|
||||
'_manages_own_progress': True,
|
||||
'scan_duration_seconds': elapsed,
|
||||
}
|
||||
|
||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise into the engine
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
status='error',
|
||||
phase='Error',
|
||||
log_line=str(e),
|
||||
log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'error': str(e), '_manages_own_progress': True}
|
||||
|
||||
finally:
|
||||
deps.state.set_scan_library_id(None)
|
||||
46
core/automation/handlers/scan_watchlist.py
Normal file
46
core/automation/handlers/scan_watchlist.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"""Automation handler: ``scan_watchlist`` action.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers`` (the
|
||||
``_auto_scan_watchlist`` closure). The watchlist scanner returns
|
||||
summary stats for the trigger card only when a fresh scan actually
|
||||
ran — detected by snapshotting ``id(state_dict)`` before/after, since
|
||||
the live processor reassigns the dict on each new scan.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
def auto_scan_watchlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Run a watchlist scan when the automation triggers.
|
||||
|
||||
Pre-scan we capture ``id(watchlist_scan_state)`` so we can tell
|
||||
afterwards whether the worker ran (and reassigned the state dict)
|
||||
or short-circuited (kept the same dict). Only fresh scans report
|
||||
summary stats — repeat triggers without an intervening run return
|
||||
a bare ``completed``.
|
||||
"""
|
||||
try:
|
||||
pre_state = deps.get_watchlist_scan_state()
|
||||
pre_state_id = id(pre_state)
|
||||
deps.process_watchlist_scan_automatically(
|
||||
automation_id=config.get('_automation_id'),
|
||||
profile_id=config.get('_profile_id'),
|
||||
)
|
||||
post_state = deps.get_watchlist_scan_state()
|
||||
# Fresh scan = state dict was reassigned mid-run.
|
||||
if id(post_state) != pre_state_id:
|
||||
summary = post_state.get('summary', {}) if isinstance(post_state, dict) else {}
|
||||
return {
|
||||
'status': 'completed',
|
||||
'artists_scanned': summary.get('total_artists', 0),
|
||||
'successful_scans': summary.get('successful_scans', 0),
|
||||
'new_tracks_found': summary.get('new_tracks_found', 0),
|
||||
'tracks_added_to_wishlist': summary.get('tracks_added_to_wishlist', 0),
|
||||
}
|
||||
return {'status': 'completed'}
|
||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise into the engine
|
||||
return {'status': 'error', 'error': str(e)}
|
||||
57
core/automation/handlers/search_and_download.py
Normal file
57
core/automation/handlers/search_and_download.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"""Automation handler: ``search_and_download`` action.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers`` (the
|
||||
``_auto_search_and_download`` closure). Searches for a track by
|
||||
name/artist string and dispatches the best match through the
|
||||
download orchestrator. Query can come from the trigger config
|
||||
(direct value) or from event data (e.g. webhook payload).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
def auto_search_and_download(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
automation_id = config.get('_automation_id')
|
||||
query = config.get('query', '').strip()
|
||||
# Event-triggered: pull query from event data (e.g. webhook_received).
|
||||
if not query:
|
||||
event_data = config.get('_event_data', {})
|
||||
query = (event_data.get('query', '') or '').strip()
|
||||
if not query:
|
||||
if automation_id:
|
||||
deps.update_progress(
|
||||
automation_id, log_line='No search query provided', log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'error': 'No search query provided'}
|
||||
try:
|
||||
if automation_id:
|
||||
deps.update_progress(
|
||||
automation_id, phase='Searching',
|
||||
log_line=f'Searching: {query}', log_type='info',
|
||||
)
|
||||
result = deps.run_async(deps.download_orchestrator.search_and_download_best(query))
|
||||
if result:
|
||||
if automation_id:
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'Download started for: {query}',
|
||||
log_type='success',
|
||||
)
|
||||
return {'status': 'completed', 'query': query, 'download_id': result}
|
||||
if automation_id:
|
||||
deps.update_progress(
|
||||
automation_id,
|
||||
log_line=f'No match found for: {query}',
|
||||
log_type='warning',
|
||||
)
|
||||
return {'status': 'not_found', 'query': query, 'error': 'No match found'}
|
||||
except Exception as e: # noqa: BLE001 — automation handlers must never raise
|
||||
if automation_id:
|
||||
deps.update_progress(
|
||||
automation_id, log_line=f'Error: {e}', log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'query': query, 'error': str(e)}
|
||||
195
core/automation/handlers/sync_playlist.py
Normal file
195
core/automation/handlers/sync_playlist.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
"""Automation handler: ``sync_playlist`` action.
|
||||
|
||||
Lifted from ``web_server._register_automation_handlers`` (the
|
||||
``_auto_sync_playlist`` closure). Syncs a mirrored playlist to the
|
||||
configured media server, using discovered metadata when available
|
||||
and skipping undiscovered tracks. When triggered on a schedule with
|
||||
no track changes since the last sync, short-circuits with
|
||||
``status: skipped`` (saves Plex / Jellyfin / Navidrome from
|
||||
needless rewrites)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
|
||||
|
||||
def auto_sync_playlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
|
||||
"""Sync a mirrored playlist to the active media server.
|
||||
|
||||
Behavior:
|
||||
- Tracks with discovered metadata (extra_data.discovered + matched_data)
|
||||
are routed via the official metadata.
|
||||
- Tracks with a Spotify hint (real Spotify ID from the embed
|
||||
scraper) are included so they can still hit Soulseek + the
|
||||
wishlist.
|
||||
- Tracks with neither are counted as ``skipped_tracks``.
|
||||
- Empty result → ``status: skipped`` with the skipped count.
|
||||
- Same track set as last sync (matched_tracks unchanged) →
|
||||
``status: skipped`` (no-op).
|
||||
- Otherwise spawns a daemon thread running ``run_sync_task`` and
|
||||
returns ``status: started`` with ``_manages_own_progress: True``.
|
||||
"""
|
||||
auto_id = config.get('_automation_id')
|
||||
playlist_id = config.get('playlist_id')
|
||||
if not playlist_id:
|
||||
return {'status': 'error', 'reason': 'No playlist specified'}
|
||||
|
||||
db = deps.get_database()
|
||||
pl = db.get_mirrored_playlist(int(playlist_id))
|
||||
if not pl:
|
||||
return {'status': 'error', 'reason': 'Playlist not found'}
|
||||
|
||||
tracks = db.get_mirrored_playlist_tracks(int(playlist_id))
|
||||
if not tracks:
|
||||
return {'status': 'error', 'reason': 'No tracks in playlist'}
|
||||
|
||||
# Convert mirrored tracks to format expected by run_sync_task.
|
||||
# Use discovered metadata when available, fall back to Spotify
|
||||
# hint or raw playlist fields when not.
|
||||
tracks_json = []
|
||||
skipped_count = 0
|
||||
|
||||
for t in tracks:
|
||||
# Parse extra_data for discovery info.
|
||||
extra = {}
|
||||
if t.get('extra_data'):
|
||||
try:
|
||||
extra = json.loads(t['extra_data']) if isinstance(t['extra_data'], str) else t['extra_data']
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
if extra.get('discovered') and extra.get('matched_data'):
|
||||
# Use official discovered metadata.
|
||||
md = extra['matched_data']
|
||||
album_raw = md.get('album', '')
|
||||
album_obj = album_raw if isinstance(album_raw, dict) else {'name': album_raw or ''}
|
||||
_track_entry = {
|
||||
'name': md.get('name', ''),
|
||||
'artists': md.get('artists', [{'name': t.get('artist_name', '')}]),
|
||||
'album': album_obj,
|
||||
'duration_ms': md.get('duration_ms', 0),
|
||||
'id': md.get('id', ''),
|
||||
}
|
||||
if md.get('track_number'):
|
||||
_track_entry['track_number'] = md['track_number']
|
||||
if md.get('disc_number'):
|
||||
_track_entry['disc_number'] = md['disc_number']
|
||||
tracks_json.append(_track_entry)
|
||||
else:
|
||||
# NOT discovered — try to include using available metadata so
|
||||
# the track can still be searched on Soulseek and added to
|
||||
# wishlist. Without this, failed discovery blocks the entire
|
||||
# download pipeline.
|
||||
#
|
||||
# Priority: spotify_hint (has real Spotify ID from embed
|
||||
# scraper) > raw playlist fields (only if source_track_id
|
||||
# is valid).
|
||||
hint = extra.get('spotify_hint', {})
|
||||
# Build album object with cover art from the mirrored playlist track.
|
||||
track_image = (t.get('image_url') or '').strip()
|
||||
album_obj = {
|
||||
'name': (t.get('album_name') or '').strip(),
|
||||
'images': [{'url': track_image, 'height': 300, 'width': 300}] if track_image else [],
|
||||
}
|
||||
|
||||
if hint.get('id') and hint.get('name'):
|
||||
# spotify_hint has proper Spotify track ID + metadata from embed scraper.
|
||||
hint_artists = hint.get('artists', [])
|
||||
if hint_artists and isinstance(hint_artists[0], str):
|
||||
hint_artists = [{'name': a} for a in hint_artists]
|
||||
elif hint_artists and isinstance(hint_artists[0], dict):
|
||||
pass # Already in correct format
|
||||
else:
|
||||
hint_artists = [{'name': t.get('artist_name', '')}]
|
||||
tracks_json.append({
|
||||
'name': hint['name'],
|
||||
'artists': hint_artists,
|
||||
'album': album_obj,
|
||||
'duration_ms': t.get('duration_ms', 0),
|
||||
'id': hint['id'],
|
||||
})
|
||||
elif t.get('source_track_id') and (t.get('track_name') or '').strip():
|
||||
# Has a valid source ID and track name — usable for wishlist.
|
||||
tracks_json.append({
|
||||
'name': t['track_name'].strip(),
|
||||
'artists': [{'name': (t.get('artist_name') or '').strip() or 'Unknown Artist'}],
|
||||
'album': album_obj,
|
||||
'duration_ms': t.get('duration_ms', 0),
|
||||
'id': t['source_track_id'],
|
||||
})
|
||||
else:
|
||||
skipped_count += 1 # No usable ID or name — truly can't process.
|
||||
|
||||
if not tracks_json:
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
log_line=f'No discovered tracks — {skipped_count} need discovery first',
|
||||
log_type='skip',
|
||||
)
|
||||
return {
|
||||
'status': 'skipped',
|
||||
'reason': f'No discovered tracks to sync ({skipped_count} tracks need discovery first)',
|
||||
'skipped_tracks': str(skipped_count),
|
||||
}
|
||||
|
||||
# Preflight: hash the track list and compare against last sync.
|
||||
# Skip if the exact same set of tracks was already synced and
|
||||
# everything matched (no-op preserves Plex / Jellyfin / Navidrome
|
||||
# from needless rewrites).
|
||||
track_ids_str = ','.join(sorted(t.get('id', '') for t in tracks_json))
|
||||
tracks_hash = hashlib.md5(track_ids_str.encode()).hexdigest()
|
||||
|
||||
sync_id_key = f"auto_mirror_{playlist_id}"
|
||||
try:
|
||||
sync_statuses = deps.load_sync_status_file()
|
||||
last_status = sync_statuses.get(sync_id_key, {})
|
||||
last_hash = last_status.get('tracks_hash', '')
|
||||
last_matched = last_status.get('matched_tracks', -1)
|
||||
|
||||
if last_hash == tracks_hash and last_matched >= len(tracks_json):
|
||||
# Exact same tracks, all matched last time — nothing to do.
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
log_line=f'All {len(tracks_json)} tracks unchanged since last sync — skipping',
|
||||
log_type='skip',
|
||||
)
|
||||
return {
|
||||
'status': 'skipped',
|
||||
'reason': f'All {len(tracks_json)} tracks unchanged since last sync',
|
||||
}
|
||||
except Exception as e:
|
||||
deps.logger.debug("mirror sync last-status read: %s", e)
|
||||
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
progress=50,
|
||||
phase=f'Syncing "{pl["name"]}"',
|
||||
log_line=f'{len(tracks_json)} discovered, {skipped_count} skipped',
|
||||
log_type='info',
|
||||
)
|
||||
|
||||
sync_id = f"auto_mirror_{playlist_id}"
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
progress=90,
|
||||
log_line=f'Starting sync: {len(tracks_json)} tracks',
|
||||
log_type='success',
|
||||
)
|
||||
threading.Thread(
|
||||
target=deps.run_sync_task,
|
||||
args=(sync_id, pl['name'], tracks_json, auto_id, 1, pl.get('image_url', '')),
|
||||
daemon=True,
|
||||
name=f'auto-sync-{playlist_id}',
|
||||
).start()
|
||||
return {
|
||||
'status': 'started',
|
||||
'playlist_name': pl['name'],
|
||||
'discovered_tracks': str(len(tracks_json)),
|
||||
'skipped_tracks': str(skipped_count),
|
||||
'_manages_own_progress': True,
|
||||
}
|
||||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
299
core/discovery/qobuz.py
Normal 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')
|
||||
|
|
@ -397,9 +397,12 @@ def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, p
|
|||
}
|
||||
logger.info(f"Sync finished for {playlist_id} - state updated")
|
||||
|
||||
# Set playlist poster image if available (Plex, Jellyfin, Emby)
|
||||
# Set playlist poster image if available (Plex, Jellyfin, Emby).
|
||||
# Don't log the URL itself — it may carry an auth token (Plex
|
||||
# X-Plex-Token / Jellyfin X-Emby-Token / Subsonic auth) that we
|
||||
# don't want persisted to app.log.
|
||||
_synced = getattr(result, 'synced_tracks', 0)
|
||||
logger.info(f"[PLAYLIST IMAGE] image_url={playlist_image_url!r}, synced_tracks={_synced}")
|
||||
logger.info(f"[PLAYLIST IMAGE] has_image={bool(playlist_image_url)}, synced_tracks={_synced}")
|
||||
if playlist_image_url and _synced > 0:
|
||||
try:
|
||||
active_server = deps.config_manager.get_active_media_server()
|
||||
|
|
|
|||
|
|
@ -104,6 +104,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:
|
||||
|
|
|
|||
217
core/download_plugins/album_bundle.py
Normal file
217
core/download_plugins/album_bundle.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
666
core/download_plugins/torrent.py
Normal file
666
core/download_plugins/torrent.py
Normal 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,
|
||||
)
|
||||
|
|
@ -197,3 +197,4 @@ class DownloadStatus:
|
|||
speed: int
|
||||
time_remaining: Optional[int] = None
|
||||
file_path: Optional[str] = None
|
||||
audio_files: Optional[List[str]] = None
|
||||
|
|
|
|||
461
core/download_plugins/usenet.py
Normal file
461
core/download_plugins/usenet.py
Normal 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
|
||||
209
core/downloads/album_bundle_dispatch.py
Normal file
209
core/downloads/album_bundle_dispatch.py
Normal 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'
|
||||
|
|
@ -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))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
342
core/image_cache.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
405
core/imports/quarantine.py
Normal 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
522
core/imports/routes.py
Normal 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)
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
283
core/library/manual_library_match.py
Normal file
283
core/library/manual_library_match.py
Normal 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
|
||||
605
core/library/missing_track_import.py
Normal file
605
core/library/missing_track_import.py
Normal 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"
|
||||
327
core/library/reorganize_tag_source.py
Normal file
327
core/library/reorganize_tag_source.py
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
"""Build reorganize-planning metadata from a file's embedded tags
|
||||
instead of from a live metadata-source API call.
|
||||
|
||||
Issue #592 (tacobell444): when a library has been carefully enriched
|
||||
+ tagged, doing a fresh API lookup at reorganize time can introduce
|
||||
inconsistencies (provider naming drift, version-mismatches, missing
|
||||
album-level metadata for niche releases). The user's own embedded
|
||||
tags are usually the most stable source of truth for an enriched
|
||||
library — and using them costs zero API calls.
|
||||
|
||||
This module is the pure tag-to-context adapter. It turns the dict
|
||||
that ``core.library.file_tags.read_embedded_tags`` returns into the
|
||||
``api_album`` / ``api_track`` shapes that
|
||||
``library_reorganize._build_post_process_context`` already consumes.
|
||||
That keeps the downstream pipeline path-builder, post-process
|
||||
helpers, AcoustID, etc.) completely unchanged: tag-mode just produces
|
||||
the same input shape via a different upstream route.
|
||||
|
||||
Pure helpers — no IO inside the extractors so every shape is
|
||||
test-pinnable. The wrapper :func:`read_album_track_from_file` does
|
||||
the file IO via ``read_embedded_tags`` and then routes through the
|
||||
extractors.
|
||||
|
||||
Returns ``None`` (extractors) / ``(None, None, reason)`` (wrapper)
|
||||
when the embedded tags are missing fields essential for reorganize
|
||||
(track title, album name, or track artist). The plan layer surfaces
|
||||
that as an unmatched item with a clear reason — same UX as when the
|
||||
metadata-API call returns no candidate. No silent degradation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
# Tokens we accept as valid `releasetype` / `albumtype` values.
|
||||
# Mirrors the canonical set the rest of the metadata pipeline uses
|
||||
# (`core/metadata/album_tracks.py:_normalize_album_type`).
|
||||
_VALID_ALBUM_TYPES = frozenset({'album', 'single', 'ep', 'compilation'})
|
||||
|
||||
|
||||
# Match a 4-digit year anywhere in a date-like string ("2020",
|
||||
# "2020-01-15", "2020/01/15", "Jan 5, 2020", etc.).
|
||||
_YEAR_RE = re.compile(r'(\d{4})')
|
||||
|
||||
|
||||
# Separators we split a single artist field on to recover a list.
|
||||
# Mirrors the same separator set ``core/metadata/artist_resolution.py``
|
||||
# uses when normalizing soulseek matched-download artist strings.
|
||||
_ARTIST_SPLIT_RE = re.compile(
|
||||
r'\s*(?:,|;|/|&| feat\. | feat | ft\. | ft | featuring | x | with )\s*',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _stringify(value: Any) -> str:
|
||||
"""Coerce an embedded-tag value into a clean string."""
|
||||
if value is None:
|
||||
return ''
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _parse_int_first(value: Any) -> Optional[int]:
|
||||
"""Parse a track/disc number that may arrive as ``"5"``, ``"5/12"``,
|
||||
``5``, ``5.0`` or even ``"05"``. Returns the leading integer, or
|
||||
``None`` when no integer is recoverable.
|
||||
|
||||
Defensive against the trailing-``/N`` shape ID3 stores: ``TRCK =
|
||||
"5/12"`` means "track 5 of 12", and we want ``5``."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int,)):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
return int(value)
|
||||
s = _stringify(value)
|
||||
if not s:
|
||||
return None
|
||||
head = s.split('/', 1)[0].strip()
|
||||
try:
|
||||
return int(head)
|
||||
except (TypeError, ValueError):
|
||||
try:
|
||||
return int(float(head))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_int_total(value: Any) -> Optional[int]:
|
||||
"""Parse the trailing ``N`` of an ID3-style ``"5/12"`` value, or
|
||||
return the parsed value when it's a plain integer string."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
s = _stringify(value)
|
||||
if not s:
|
||||
return None
|
||||
if '/' in s:
|
||||
tail = s.split('/', 1)[1].strip()
|
||||
try:
|
||||
return int(tail)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
try:
|
||||
return int(s)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_year(value: Any) -> str:
|
||||
"""Extract a 4-digit year from a date-like field. Returns '' when
|
||||
no year is extractable. Reorganize templates only use the year
|
||||
portion of release dates, so we don't need to preserve the full
|
||||
date string."""
|
||||
s = _stringify(value)
|
||||
if not s:
|
||||
return ''
|
||||
m = _YEAR_RE.search(s)
|
||||
return m.group(1) if m else ''
|
||||
|
||||
|
||||
def _normalize_album_type(value: Any) -> str:
|
||||
"""Lowercase + validate the ``releasetype`` tag against the canonical
|
||||
token set. Returns '' for unknown values so the downstream path
|
||||
builder falls back to its default."""
|
||||
s = _stringify(value).lower()
|
||||
if s in _VALID_ALBUM_TYPES:
|
||||
return s
|
||||
return ''
|
||||
|
||||
|
||||
def _split_artists(value: Any) -> List[str]:
|
||||
"""Split an artist-string field into a list. Handles common
|
||||
separators (``,``, ``;``, ``/``, ``&``, ``feat``, ``ft``, ``x``,
|
||||
``with``). Strips whitespace, drops empties, dedupes (case-
|
||||
insensitive) while preserving order."""
|
||||
s = _stringify(value)
|
||||
if not s:
|
||||
return []
|
||||
parts = _ARTIST_SPLIT_RE.split(s)
|
||||
seen: set = set()
|
||||
out: List[str] = []
|
||||
for p in parts:
|
||||
cleaned = p.strip()
|
||||
if not cleaned:
|
||||
continue
|
||||
key = cleaned.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(cleaned)
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_track_artists(tags: Dict[str, Any]) -> List[str]:
|
||||
"""Resolve the per-track artist list from embedded tags. Prefers a
|
||||
multi-value ``artists`` tag (TXXX:Artists / Vorbis ``artists``)
|
||||
over splitting the single-string ``artist`` tag, which is exactly
|
||||
the precedence the post-download enrichment uses."""
|
||||
artists_value = tags.get('artists')
|
||||
if artists_value:
|
||||
# Multi-value tag readers may already have joined with ', '.
|
||||
# Re-split to recover the list.
|
||||
parts = _split_artists(artists_value)
|
||||
if parts:
|
||||
return parts
|
||||
return _split_artists(tags.get('artist') or '')
|
||||
|
||||
|
||||
def extract_track_meta_from_tags(tags: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Build an ``api_track``-shaped dict from embedded tags.
|
||||
|
||||
Returns ``None`` if essential fields are missing (title or
|
||||
artist). Caller surfaces that as an unmatched plan item.
|
||||
|
||||
Output shape matches what ``library_reorganize._build_post_process_context``
|
||||
consumes (``name`` / ``track_number`` / ``disc_number`` /
|
||||
``artists`` / ``duration_ms`` / ``id``)."""
|
||||
if not isinstance(tags, dict) or not tags:
|
||||
return None
|
||||
|
||||
title = _stringify(tags.get('title'))
|
||||
if not title:
|
||||
return None
|
||||
|
||||
artists = _resolve_track_artists(tags)
|
||||
if not artists:
|
||||
return None
|
||||
|
||||
track_number = _parse_int_first(tags.get('tracknumber')) or 1
|
||||
disc_number = _parse_int_first(tags.get('discnumber')) or 1
|
||||
|
||||
return {
|
||||
'name': title,
|
||||
'title': title, # belt-and-braces — both keys are read downstream
|
||||
'track_number': track_number,
|
||||
'disc_number': disc_number,
|
||||
'artists': [{'name': a} for a in artists],
|
||||
'duration_ms': 0, # not derivable from tags alone; set later from `duration`
|
||||
'id': '', # tag-mode has no source ID; reorganize doesn't need one
|
||||
'uri': '',
|
||||
}
|
||||
|
||||
|
||||
def extract_album_meta_from_tags(tags: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Build an ``api_album``-shaped dict from embedded tags.
|
||||
|
||||
Falls back to empty / zero values when fields are missing — the
|
||||
path builder accepts those and uses its own defaults. The album
|
||||
name is the only field we can't fall back on; if missing the
|
||||
caller should treat the track as unmatched (handled by
|
||||
:func:`read_album_track_from_file`)."""
|
||||
if not isinstance(tags, dict):
|
||||
tags = {}
|
||||
|
||||
album_name = _stringify(tags.get('album'))
|
||||
album_artist = _stringify(tags.get('albumartist') or tags.get('album_artist'))
|
||||
release_date = _normalize_year(tags.get('date') or tags.get('year') or tags.get('originaldate'))
|
||||
total_tracks = (
|
||||
_parse_int_total(tags.get('totaltracks'))
|
||||
or _parse_int_total(tags.get('tracktotal'))
|
||||
or _parse_int_total(tags.get('tracknumber')) # may be "5/12"
|
||||
or 0
|
||||
)
|
||||
album_type = _normalize_album_type(tags.get('releasetype'))
|
||||
|
||||
# `total_discs` only comes from explicit total signals: a
|
||||
# `totaldiscs` tag, or the trailing `/N` of an ID3-style
|
||||
# `discnumber = "1/2"`. A bare `discnumber = "1"` carries no total
|
||||
# and must NOT be treated as one (else single-disc albums would
|
||||
# claim total=1 and the path builder would still skip the
|
||||
# subfolder, but partial-album cases would underreport).
|
||||
total_discs = _parse_int_total(tags.get('totaldiscs')) or 0
|
||||
discnumber_raw = _stringify(tags.get('discnumber'))
|
||||
if '/' in discnumber_raw:
|
||||
explicit_total = _parse_int_total(discnumber_raw)
|
||||
if explicit_total:
|
||||
total_discs = max(total_discs, explicit_total)
|
||||
|
||||
return {
|
||||
'id': '',
|
||||
'album_id': '',
|
||||
'name': album_name,
|
||||
'title': album_name,
|
||||
'release_date': release_date,
|
||||
'total_tracks': total_tracks,
|
||||
'total_discs': total_discs,
|
||||
'image_url': '',
|
||||
'images': [],
|
||||
'album_artist': album_artist,
|
||||
'album_type': album_type,
|
||||
}
|
||||
|
||||
|
||||
def read_album_track_from_file(
|
||||
file_path: str,
|
||||
*,
|
||||
read_embedded_tags_fn=None,
|
||||
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]], Optional[str]]:
|
||||
"""Read embedded tags from ``file_path`` and produce
|
||||
``(album_meta, track_meta, error_reason)``.
|
||||
|
||||
Returns ``(None, None, reason)`` when the file can't be opened,
|
||||
has no recognisable tags, or is missing essential fields (title
|
||||
or artist). The reason string is human-readable and suitable for
|
||||
surfacing directly in the reorganize preview/error UI.
|
||||
|
||||
Args:
|
||||
file_path: Resolved on-disk path to the audio file.
|
||||
read_embedded_tags_fn: Optional override for the tag reader,
|
||||
used by tests to avoid real mutagen IO. Defaults to
|
||||
``core.library.file_tags.read_embedded_tags``."""
|
||||
if not file_path or not isinstance(file_path, str):
|
||||
return None, None, 'No file path on track row.'
|
||||
|
||||
if read_embedded_tags_fn is None:
|
||||
from core.library.file_tags import read_embedded_tags as _real_reader
|
||||
read_embedded_tags_fn = _real_reader
|
||||
|
||||
result = read_embedded_tags_fn(file_path)
|
||||
if not isinstance(result, dict) or not result.get('available'):
|
||||
reason = (result or {}).get('reason') if isinstance(result, dict) else None
|
||||
return None, None, reason or 'Could not read embedded tags from file.'
|
||||
|
||||
tags = result.get('tags') or {}
|
||||
track_meta = extract_track_meta_from_tags(tags)
|
||||
if track_meta is None:
|
||||
return None, None, 'Embedded tags missing required title or artist.'
|
||||
|
||||
album_meta = extract_album_meta_from_tags(tags)
|
||||
if not album_meta.get('name'):
|
||||
return None, None, 'Embedded tags missing album name.'
|
||||
|
||||
# Promote duration from the file-info block onto the track meta
|
||||
# so the path builder has a non-zero value if a downstream
|
||||
# consumer wants it.
|
||||
duration_seconds = result.get('duration') or 0
|
||||
try:
|
||||
track_meta['duration_ms'] = int(float(duration_seconds) * 1000)
|
||||
except (TypeError, ValueError):
|
||||
track_meta['duration_ms'] = 0
|
||||
|
||||
return album_meta, track_meta, None
|
||||
|
||||
|
||||
def normalize_resolved_path(file_path: Optional[str]) -> Optional[str]:
|
||||
"""Defensive wrapper: returns the input only when it points at a
|
||||
real file. Saves the caller from another ``os.path.exists`` check
|
||||
in already-noisy code paths."""
|
||||
if not file_path:
|
||||
return None
|
||||
try:
|
||||
if not os.path.exists(file_path):
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
return file_path
|
||||
|
||||
|
||||
__all__ = [
|
||||
'extract_track_meta_from_tags',
|
||||
'extract_album_meta_from_tags',
|
||||
'read_album_track_from_file',
|
||||
'normalize_resolved_path',
|
||||
]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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' "
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
143
core/matching/acoustid_candidates.py
Normal file
143
core/matching/acoustid_candidates.py
Normal 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
|
||||
195
core/matching/album_context_title.py
Normal file
195
core/matching/album_context_title.py
Normal 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
|
||||
115
core/matching/version_mismatch.py
Normal file
115
core/matching/version_mismatch.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Decide when an AcoustID version-annotation mismatch should still
|
||||
pass verification.
|
||||
|
||||
Issue #607 (AfonsoG6): live recordings were quarantining as
|
||||
"Version mismatch: expected '... (Live at Venue)' (live) but file is
|
||||
'Song' (original)" because MusicBrainz often stores the recording
|
||||
entity with a bare title — the venue / live annotation lives on the
|
||||
release entity, not the recording. The audio fingerprint correctly
|
||||
identifies the live recording (live audio has its own distinct
|
||||
fingerprint), but the title-text comparison flagged it as the wrong
|
||||
version.
|
||||
|
||||
Strict version mismatching stays for genuinely-different recordings
|
||||
(instrumental vs vocal, remix vs original, acoustic vs studio) —
|
||||
those have distinct fingerprints AND MB always annotates them in the
|
||||
recording title. We only loosen for the **live** direction
|
||||
specifically, since that's the asymmetry users actually hit:
|
||||
|
||||
- LIVE: MB often stores live recordings with bare titles. The
|
||||
fingerprint correctly identifies the live recording even when the
|
||||
recording's text title lacks a "(Live at ...)" annotation.
|
||||
- INSTRUMENTAL / REMIX / ACOUSTIC / DEMO / etc: MB always carries
|
||||
the version marker in the recording title. If AcoustID returns an
|
||||
instrumental for a vocal file query (or vice versa), it's a real
|
||||
wrong-recording match — the fingerprint matched the instrumental
|
||||
audio, which IS the wrong file.
|
||||
|
||||
Two-sided version mismatches stay strict — "live" vs "remix" really
|
||||
are different recordings even if MB titled them similarly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
_BARE_VERSION = 'original'
|
||||
|
||||
# Versions where a one-sided annotation difference is plausibly
|
||||
# explained by MB metadata gaps rather than a different recording.
|
||||
# Live recordings are the primary case; venue annotations live on the
|
||||
# release-track entity, not the recording entity, so the recording can
|
||||
# be bare-titled even though the audio is genuinely live.
|
||||
_LIVE_AWARE_VERSIONS = frozenset({'live'})
|
||||
|
||||
|
||||
def is_acceptable_version_mismatch(
|
||||
expected_version: str,
|
||||
matched_version: str,
|
||||
*,
|
||||
fingerprint_score: float,
|
||||
title_similarity: float,
|
||||
artist_similarity: float,
|
||||
score_threshold: float = 0.85,
|
||||
title_threshold: float = 0.70,
|
||||
artist_threshold: float = 0.60,
|
||||
) -> bool:
|
||||
"""Return True when an expected-vs-matched version annotation
|
||||
difference is likely a MusicBrainz metadata gap rather than a
|
||||
genuinely different recording.
|
||||
|
||||
Conditions (all must hold):
|
||||
|
||||
1. The mismatch is **one-sided AND involves a live-aware version**
|
||||
— exactly one side is ``'live'`` and the other is bare
|
||||
``'original'``. Other version markers (instrumental, remix,
|
||||
acoustic, etc) carry distinct fingerprints AND are always
|
||||
annotated in MB's recording title — we don't loosen for them.
|
||||
2. Fingerprint score ``>= score_threshold`` (default 0.85). The
|
||||
AcoustID fingerprint is high-confidence — we trust it.
|
||||
3. Bare title similarity ``>= title_threshold`` (default 0.70).
|
||||
After the version annotation is stripped, the underlying titles
|
||||
agree.
|
||||
4. Artist similarity ``>= artist_threshold`` (default 0.60). Same
|
||||
artist credit.
|
||||
|
||||
When ``expected_version == matched_version`` returns ``True``
|
||||
trivially — no mismatch to decide.
|
||||
|
||||
Examples that ACCEPT (return True):
|
||||
|
||||
- expected=``'live'``, matched=``'original'``, fp=0.95, title=0.95,
|
||||
artist=1.0 — typical live-recording MB-bare-title case (issue
|
||||
#607 example 2).
|
||||
- expected=``'original'``, matched=``'live'`` — same case in the
|
||||
other direction.
|
||||
|
||||
Examples that REJECT (return False):
|
||||
|
||||
- expected=``'instrumental'``, matched=``'original'`` — fingerprint
|
||||
matched the instrumental recording; if user asked for vocal, the
|
||||
file is genuinely wrong. Stays strict regardless of confidence.
|
||||
- expected=``'remix'``, matched=``'original'`` — same logic.
|
||||
- expected=``'live'``, matched=``'remix'`` — both versioned,
|
||||
both different. Real mismatch.
|
||||
- expected=``'live'``, matched=``'original'``, fp=0.50 — one-sided
|
||||
live but low confidence. Fall through to FAIL.
|
||||
- expected=``'live'``, matched=``'original'``, fp=0.95 but title
|
||||
sim 0.30 — bare titles don't agree. Different song.
|
||||
"""
|
||||
if expected_version == matched_version:
|
||||
return True
|
||||
|
||||
one_sided_live = (
|
||||
(expected_version in _LIVE_AWARE_VERSIONS and matched_version == _BARE_VERSION)
|
||||
or (expected_version == _BARE_VERSION and matched_version in _LIVE_AWARE_VERSIONS)
|
||||
)
|
||||
if not one_sided_live:
|
||||
return False
|
||||
|
||||
return (
|
||||
fingerprint_score >= score_threshold
|
||||
and title_similarity >= title_threshold
|
||||
and artist_similarity >= artist_threshold
|
||||
)
|
||||
|
||||
|
||||
__all__ = ['is_acceptable_version_mismatch']
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
74
core/metadata/artist_resolution.py
Normal file
74
core/metadata/artist_resolution.py
Normal 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 []
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
93
core/metadata/release_type.py
Normal file
93
core/metadata/release_type.py
Normal 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'
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
77
core/metadata/track_number_format.py
Normal file
77
core/metadata/track_number_format.py
Normal 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
|
||||
|
|
@ -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'])
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
24
core/personalized/__init__.py
Normal file
24
core/personalized/__init__.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""Standardized personalized-playlist subsystem.
|
||||
|
||||
Replaces the patchwork of `PersonalizedPlaylistsService` (computed-on-
|
||||
demand views, no persistence) + `discovery_curated_playlists` (ID-only
|
||||
storage) + `curated_seasonal_playlists` (full storage) with a single
|
||||
unified abstraction:
|
||||
|
||||
- ``manager.PersonalizedPlaylistManager`` — owns the storage layer +
|
||||
generator dispatch + refresh lifecycle.
|
||||
- ``specs.PlaylistKindSpec`` — one spec per playlist KIND
|
||||
(``hidden_gems``, ``time_machine``, ``seasonal_mix``, etc.) with
|
||||
generator function, default config, variant resolver, and display-
|
||||
name template.
|
||||
- ``types.Track`` / ``types.PlaylistConfig`` — shared dataclasses.
|
||||
|
||||
The legacy ``PersonalizedPlaylistsService`` keeps its existing
|
||||
generator implementations — they're called BY the manager rather than
|
||||
duplicated. This means:
|
||||
- The improved diversity logic / popularity thresholds / blacklist
|
||||
filtering all stays.
|
||||
- New behavior layered on top: persistence, refresh-on-demand,
|
||||
per-playlist user-tweakable config, staleness windows, listening-
|
||||
history cross-reference, seeded randomization.
|
||||
"""
|
||||
163
core/personalized/api.py
Normal file
163
core/personalized/api.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"""HTTP endpoint handlers for the personalized-playlists subsystem.
|
||||
|
||||
Wired into the Flask app from web_server.py. Each handler is a thin
|
||||
wrapper that:
|
||||
1. Pulls profile id + manager from request context.
|
||||
2. Calls one PersonalizedPlaylistManager method.
|
||||
3. Returns a JSON-serializable shape.
|
||||
|
||||
Live routes (registered against the main Flask app):
|
||||
- GET /api/personalized/playlists — list
|
||||
- GET /api/personalized/kinds — registry
|
||||
- GET /api/personalized/playlist/<kind> — singleton
|
||||
- GET /api/personalized/playlist/<kind>/<variant> — variant
|
||||
- POST /api/personalized/playlist/<kind>/refresh — singleton
|
||||
- POST /api/personalized/playlist/<kind>/<variant>/refresh — variant
|
||||
- PUT /api/personalized/playlist/<kind>/config — singleton
|
||||
- PUT /api/personalized/playlist/<kind>/<variant>/config — variant
|
||||
|
||||
The handlers themselves are pure functions returning Python dicts so
|
||||
they're testable without spinning up Flask. The wiring step in
|
||||
web_server.py wraps them in `jsonify` + URL routing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.personalized.manager import PersonalizedPlaylistManager
|
||||
from core.personalized.specs import PlaylistKindRegistry, get_registry
|
||||
from core.personalized.types import PlaylistRecord, Track
|
||||
|
||||
|
||||
def _record_to_dict(record: PlaylistRecord) -> Dict[str, Any]:
|
||||
return {
|
||||
'id': record.id,
|
||||
'profile_id': record.profile_id,
|
||||
'kind': record.kind,
|
||||
'variant': record.variant,
|
||||
'name': record.name,
|
||||
'config': record.config.to_json_dict(),
|
||||
'track_count': record.track_count,
|
||||
'last_generated_at': record.last_generated_at,
|
||||
'last_synced_at': record.last_synced_at,
|
||||
'last_generation_source': record.last_generation_source,
|
||||
'last_generation_error': record.last_generation_error,
|
||||
}
|
||||
|
||||
|
||||
def _track_to_dict(track: Track) -> Dict[str, Any]:
|
||||
return {
|
||||
'spotify_track_id': track.spotify_track_id,
|
||||
'itunes_track_id': track.itunes_track_id,
|
||||
'deezer_track_id': track.deezer_track_id,
|
||||
'track_name': track.track_name,
|
||||
'artist_name': track.artist_name,
|
||||
'album_name': track.album_name,
|
||||
'album_cover_url': track.album_cover_url,
|
||||
'duration_ms': track.duration_ms,
|
||||
'popularity': track.popularity,
|
||||
'track_data_json': track.track_data_json,
|
||||
'source': track.source,
|
||||
}
|
||||
|
||||
|
||||
def list_kinds(
|
||||
registry: Optional[PlaylistKindRegistry] = None,
|
||||
manager: Optional[PersonalizedPlaylistManager] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return every registered playlist kind with metadata.
|
||||
|
||||
UI uses this to render the "available playlists" picker. Each
|
||||
kind reports whether it requires a variant; when a manager is
|
||||
supplied AND the kind has a variant_resolver, the resolved
|
||||
variant list is also included so the UI can render variant
|
||||
checkboxes without a second round-trip per kind."""
|
||||
reg = registry or get_registry()
|
||||
out = []
|
||||
for spec in reg.all():
|
||||
entry = {
|
||||
'kind': spec.kind,
|
||||
'name_template': spec.name_template,
|
||||
'description': spec.description,
|
||||
'requires_variant': spec.requires_variant,
|
||||
'tags': list(spec.tags),
|
||||
'default_config': spec.default_config.to_json_dict(),
|
||||
'variants': [],
|
||||
}
|
||||
if manager is not None and spec.variant_resolver is not None:
|
||||
try:
|
||||
entry['variants'] = list(spec.variant_resolver(manager.deps) or [])
|
||||
except Exception:
|
||||
entry['variants'] = []
|
||||
out.append(entry)
|
||||
return {'success': True, 'kinds': out}
|
||||
|
||||
|
||||
def list_playlists(manager: PersonalizedPlaylistManager, profile_id: int) -> Dict[str, Any]:
|
||||
"""List every persisted playlist for a profile."""
|
||||
records = manager.list_playlists(profile_id)
|
||||
return {
|
||||
'success': True,
|
||||
'playlists': [_record_to_dict(r) for r in records],
|
||||
}
|
||||
|
||||
|
||||
def get_playlist_with_tracks(
|
||||
manager: PersonalizedPlaylistManager,
|
||||
kind: str,
|
||||
variant: str,
|
||||
profile_id: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Get the playlist row + its current track snapshot. Auto-creates
|
||||
the row from default config if it doesn't exist (so the UI's first-
|
||||
paint of an unseen kind works without a separate ensure call)."""
|
||||
record = manager.ensure_playlist(kind, variant, profile_id)
|
||||
tracks = manager.get_playlist_tracks(record.id)
|
||||
return {
|
||||
'success': True,
|
||||
'playlist': _record_to_dict(record),
|
||||
'tracks': [_track_to_dict(t) for t in tracks],
|
||||
}
|
||||
|
||||
|
||||
def refresh_playlist(
|
||||
manager: PersonalizedPlaylistManager,
|
||||
kind: str,
|
||||
variant: str,
|
||||
profile_id: int,
|
||||
config_overrides: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run the kind's generator and persist the snapshot. Returns the
|
||||
fresh row + tracks."""
|
||||
record = manager.refresh_playlist(kind, variant, profile_id, config_overrides=config_overrides)
|
||||
tracks = manager.get_playlist_tracks(record.id)
|
||||
return {
|
||||
'success': True,
|
||||
'playlist': _record_to_dict(record),
|
||||
'tracks': [_track_to_dict(t) for t in tracks],
|
||||
}
|
||||
|
||||
|
||||
def update_config(
|
||||
manager: PersonalizedPlaylistManager,
|
||||
kind: str,
|
||||
variant: str,
|
||||
profile_id: int,
|
||||
overrides: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Patch the playlist's config with the provided fields."""
|
||||
record = manager.update_config(kind, variant, profile_id, overrides)
|
||||
return {
|
||||
'success': True,
|
||||
'playlist': _record_to_dict(record),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
'list_kinds',
|
||||
'list_playlists',
|
||||
'get_playlist_with_tracks',
|
||||
'refresh_playlist',
|
||||
'update_config',
|
||||
]
|
||||
27
core/personalized/generators/__init__.py
Normal file
27
core/personalized/generators/__init__.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""Per-kind generators for the personalized-playlists subsystem.
|
||||
|
||||
Each module in this subpackage:
|
||||
1. Defines a generator function ``generate(deps, variant, config)``
|
||||
that returns a ``List[Track]``.
|
||||
2. Calls ``get_registry().register(spec)`` at import time so the
|
||||
manager auto-discovers it.
|
||||
|
||||
The legacy ``core.personalized_playlists.PersonalizedPlaylistsService``
|
||||
keeps its existing implementations — the wrappers in this package
|
||||
just adapt the call surface (`PlaylistConfig` → method kwargs) and
|
||||
coerce results into ``Track`` instances.
|
||||
|
||||
To register every generator, import this package — `from
|
||||
core.personalized import generators` — typically done once at
|
||||
application startup."""
|
||||
|
||||
# Importing each module triggers its registration side-effect.
|
||||
from core.personalized.generators import hidden_gems # noqa: F401
|
||||
from core.personalized.generators import discovery_shuffle # noqa: F401
|
||||
from core.personalized.generators import popular_picks # noqa: F401
|
||||
from core.personalized.generators import time_machine # noqa: F401
|
||||
from core.personalized.generators import genre_playlist # noqa: F401
|
||||
from core.personalized.generators import daily_mix # noqa: F401
|
||||
from core.personalized.generators import fresh_tape # noqa: F401
|
||||
from core.personalized.generators import archives # noqa: F401
|
||||
from core.personalized.generators import seasonal_mix # noqa: F401
|
||||
37
core/personalized/generators/_common.py
Normal file
37
core/personalized/generators/_common.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"""Shared helpers for personalized-playlist generators.
|
||||
|
||||
Each per-kind generator module is small + mechanical — it pulls the
|
||||
legacy ``PersonalizedPlaylistsService`` instance off the deps object
|
||||
and calls the matching method, then coerces results. This module
|
||||
holds the bits every generator reuses so we don't repeat them
|
||||
five times."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List
|
||||
|
||||
from core.personalized.types import Track
|
||||
|
||||
|
||||
def get_service(deps: Any):
|
||||
"""Pull the ``PersonalizedPlaylistsService`` instance from deps.
|
||||
|
||||
Generators access the service via ``deps.service``. Tests can
|
||||
pass a fake deps namespace with a ``service`` attribute that
|
||||
returns a stub. Raises a clear error if the dep isn't wired."""
|
||||
service = getattr(deps, 'service', None) or (deps.get('service') if isinstance(deps, dict) else None)
|
||||
if service is None:
|
||||
raise RuntimeError(
|
||||
"Personalized generator deps missing `service` "
|
||||
"(PersonalizedPlaylistsService instance). Wire it during "
|
||||
"PersonalizedPlaylistManager construction."
|
||||
)
|
||||
return service
|
||||
|
||||
|
||||
def coerce_tracks(rows: List[dict]) -> List[Track]:
|
||||
"""Convert legacy generator output (list of dicts) into Track
|
||||
instances. Tolerates None / non-list inputs by returning []."""
|
||||
if not rows:
|
||||
return []
|
||||
return [Track.from_dict(row) for row in rows if isinstance(row, dict)]
|
||||
35
core/personalized/generators/archives.py
Normal file
35
core/personalized/generators/archives.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""The Archives (Spotify Discover Weekly) generator.
|
||||
|
||||
Same shape as Fresh Tape — read curated track-id list from
|
||||
``discovery_curated_playlists`` under ``discovery_weekly_<source>``
|
||||
(fallback ``discovery_weekly``), hydrate via discovery pool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List
|
||||
|
||||
from core.personalized.generators.fresh_tape import _hydrate_curated
|
||||
from core.personalized.specs import PlaylistKindSpec, get_registry
|
||||
from core.personalized.types import PlaylistConfig, Track
|
||||
|
||||
|
||||
KIND = 'archives'
|
||||
|
||||
|
||||
def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]:
|
||||
return _hydrate_curated(deps, 'discovery_weekly', config)
|
||||
|
||||
|
||||
SPEC = PlaylistKindSpec(
|
||||
kind=KIND,
|
||||
name_template='The Archives',
|
||||
description='Your Spotify Discover Weekly — curated discovery picks.',
|
||||
default_config=PlaylistConfig(limit=50, max_per_album=5, max_per_artist=10),
|
||||
generator=generate,
|
||||
requires_variant=False,
|
||||
tags=['curated', 'spotify'],
|
||||
)
|
||||
|
||||
|
||||
if get_registry().get(KIND) is None:
|
||||
get_registry().register(SPEC)
|
||||
83
core/personalized/generators/daily_mix.py
Normal file
83
core/personalized/generators/daily_mix.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Daily Mix generator — top library genre → discovery picks.
|
||||
|
||||
Variant = rank position as a string ('1' / '2' / '3' / '4'). Each
|
||||
mix tracks the user's Nth top library genre and returns discovery
|
||||
picks within it. Top genres recompute at refresh time, so as the
|
||||
library evolves a mix's underlying genre can shift -- the playlist
|
||||
metadata records which genre was used at the most recent refresh
|
||||
so the UI can label the mix accurately.
|
||||
|
||||
Note: previously this kind ambitiously promised 50% library + 50%
|
||||
discovery. The library half was a stub (`tracks` table has no
|
||||
source IDs to sync), so the new generator is discovery-only.
|
||||
A future enhancement can backfill source IDs into library rows
|
||||
and re-add the hybrid behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List
|
||||
|
||||
from core.personalized.generators._common import coerce_tracks, get_service
|
||||
from core.personalized.specs import PlaylistKindSpec, get_registry
|
||||
from core.personalized.types import PlaylistConfig, Track
|
||||
|
||||
|
||||
KIND = 'daily_mix'
|
||||
|
||||
# Default rank set — UI surfaces 4 daily mixes by default.
|
||||
_DEFAULT_RANKS = ('1', '2', '3', '4')
|
||||
|
||||
# Number of top library genres to consider when ranking.
|
||||
_MAX_TOP_GENRES = 8
|
||||
|
||||
|
||||
def _resolve_genre_for_rank(service, rank: int) -> str:
|
||||
"""Look up the user's Nth-ranked top library genre. Returns the
|
||||
genre key or '' when no genre at that rank.
|
||||
|
||||
Calls ``service.get_top_genres_from_library(limit=...)`` and
|
||||
indexes the resulting (genre, count) tuples by 0-based rank.
|
||||
"""
|
||||
top = service.get_top_genres_from_library(limit=_MAX_TOP_GENRES) or []
|
||||
if rank < 1 or rank > len(top):
|
||||
return ''
|
||||
pair = top[rank - 1]
|
||||
if not pair:
|
||||
return ''
|
||||
# `top` is List[Tuple[str, int]] per service signature.
|
||||
return pair[0] if isinstance(pair, (tuple, list)) else str(pair)
|
||||
|
||||
|
||||
def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]:
|
||||
service = get_service(deps)
|
||||
try:
|
||||
rank = int(variant)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Daily Mix variant {variant!r} must be a rank int") from exc
|
||||
genre = _resolve_genre_for_rank(service, rank)
|
||||
if not genre:
|
||||
# User's library doesn't have enough genres for this rank.
|
||||
return []
|
||||
rows = service.get_genre_playlist(genre=genre, limit=config.limit)
|
||||
return coerce_tracks(rows)
|
||||
|
||||
|
||||
def variant_resolver(deps: Any) -> List[str]:
|
||||
"""Return the standard rank set."""
|
||||
return list(_DEFAULT_RANKS)
|
||||
|
||||
|
||||
SPEC = PlaylistKindSpec(
|
||||
kind=KIND,
|
||||
name_template='Daily Mix {variant}',
|
||||
description='Personalized mix based on your top library genres. One mix per top genre rank.',
|
||||
default_config=PlaylistConfig(limit=50, max_per_album=2, max_per_artist=3),
|
||||
generator=generate,
|
||||
variant_resolver=variant_resolver,
|
||||
requires_variant=True,
|
||||
tags=['discovery', 'personalized'],
|
||||
)
|
||||
|
||||
|
||||
if get_registry().get(KIND) is None:
|
||||
get_registry().register(SPEC)
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue