commit
7e3738f7e3
110 changed files with 7767 additions and 1819 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.7.1)'
|
||||
description: 'Version tag (e.g. 2.7.2)'
|
||||
required: true
|
||||
default: '2.7.1'
|
||||
default: '2.7.2'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
|
|
|
|||
|
|
@ -508,6 +508,22 @@ class ConfigManager:
|
|||
# its partial data, fail the download so the next source can
|
||||
# try) or "pause" (pause in the client, leave for the user).
|
||||
"torrent_stall_action": "abandon",
|
||||
# Where THIS container can read completed torrent/usenet
|
||||
# downloads (#857). The downloader (qBit/SAB) reports a save
|
||||
# path from inside ITS OWN container — often a category folder
|
||||
# like /data/downloads/music — which may be mounted at a
|
||||
# different point here. Set these to the in-container path(s)
|
||||
# where SoulSync sees those finished downloads; the resolver
|
||||
# then finds the release by name under them. Empty = fall back
|
||||
# to the soulseek download/transfer dirs (the shared-volume
|
||||
# default). See core.download_plugins.album_bundle.resolve_reported_save_path.
|
||||
"torrent_download_path": "",
|
||||
"usenet_download_path": "",
|
||||
# Explicit remote→local prefix mappings for non-shared / oddly
|
||||
# mounted layouts (Sonarr/Radarr "Remote Path Mapping" style):
|
||||
# a list of {"from": "<client path>", "to": "<soulsync path>"}.
|
||||
# Tried before the basename fallback above.
|
||||
"usenet_path_mappings": [],
|
||||
},
|
||||
"post_processing": {
|
||||
# When a download is quarantined (AcoustID mismatch, integrity /
|
||||
|
|
@ -696,6 +712,17 @@ class ConfigManager:
|
|||
"enabled": False,
|
||||
"entry_base_path": ""
|
||||
},
|
||||
"playlists": {
|
||||
# Where "Organize by playlist" materializes playlist folders.
|
||||
# MUST be a separate root from the music library so the media
|
||||
# server (and the maintenance jobs) never scan it — otherwise the
|
||||
# same track would show up twice. Mapped separately for Docker.
|
||||
"materialize_path": "./Playlists",
|
||||
# "symlink" (relative links, ~zero disk) or "copy" (real
|
||||
# duplicates for FAT/USB/DAPs that can't follow links). Symlink
|
||||
# auto-falls back to copy when the filesystem can't link.
|
||||
"materialize_mode": "symlink"
|
||||
},
|
||||
"youtube": {
|
||||
"cookies_browser": "", # "", "chrome", "firefox", "edge", "brave", "opera", "safari"
|
||||
"download_delay": 3, # seconds between sequential downloads
|
||||
|
|
|
|||
|
|
@ -171,12 +171,7 @@ ACTIONS: list[dict] = [
|
|||
{"type": "update_discovery_pool", "label": "Update Discovery", "icon": "compass",
|
||||
"description": "Refresh discovery pool with new tracks", "available": True},
|
||||
{"type": "start_quality_scan", "label": "Run Quality Scan", "icon": "bar-chart",
|
||||
"description": "Scan for low-quality audio files", "available": True,
|
||||
"config_fields": [
|
||||
{"key": "scope", "type": "select", "label": "Scope",
|
||||
"options": [{"value": "watchlist", "label": "Watchlist Artists"}, {"value": "library", "label": "Full Library"}],
|
||||
"default": "watchlist"}
|
||||
]},
|
||||
"description": "Run the Quality Upgrade Finder (scope is set in Library Maintenance)", "available": True},
|
||||
{"type": "backup_database", "label": "Backup Database", "icon": "save",
|
||||
"description": "Create timestamped database backup", "available": True},
|
||||
{"type": "refresh_beatport_cache", "label": "Refresh Beatport Cache", "icon": "music",
|
||||
|
|
|
|||
|
|
@ -123,10 +123,10 @@ class AutomationDeps:
|
|||
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]
|
||||
# Triggers a "Run Now" of a library-maintenance repair job by id (e.g.
|
||||
# 'quality_upgrade'). Returns truthy if the job was queued. Replaces the old
|
||||
# standalone quality-scanner executor/state (the scanner is now a repair job).
|
||||
run_repair_job_now: Callable[[str], Any]
|
||||
|
||||
# --- Download orchestrator + queue accessors ---
|
||||
download_orchestrator: Any
|
||||
|
|
|
|||
|
|
@ -1,83 +1,35 @@
|
|||
"""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.
|
||||
The quality scanner was redesigned from an auto-acting tool into the
|
||||
``quality_upgrade`` library-maintenance repair job (findings-based, reviewed
|
||||
before anything is wishlisted). This action now simply triggers a "Run Now" of
|
||||
that job; its progress and findings surface in Library Maintenance. The action
|
||||
name is kept so existing automation rules keep working.
|
||||
"""
|
||||
|
||||
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
|
||||
triggered = deps.run_repair_job_now('quality_upgrade')
|
||||
if not triggered:
|
||||
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',
|
||||
automation_id, status='error', phase='Unavailable',
|
||||
log_line='Quality Upgrade job could not be triggered (library worker unavailable)',
|
||||
log_type='error',
|
||||
)
|
||||
return {'status': 'error', 'reason': 'Timed out', '_manages_own_progress': True}
|
||||
return {'status': 'error', 'reason': 'library worker unavailable',
|
||||
'_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',
|
||||
automation_id, status='finished', progress=100, phase='Triggered',
|
||||
log_line='Quality Upgrade scan queued — findings appear in Library Maintenance',
|
||||
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),
|
||||
}
|
||||
return {'status': 'completed', 'triggered': True, '_manages_own_progress': True}
|
||||
|
|
|
|||
|
|
@ -292,6 +292,18 @@ def _commit_refresh(
|
|||
image_url=pl.get('image_url'),
|
||||
)
|
||||
|
||||
# Membership just changed — if this playlist is organize-by-playlist, rebuild
|
||||
# its folder (with prune) so a track that LEFT the playlist has its symlink
|
||||
# cleaned up now. Gated to organized playlists, non-fatal — never disturbs
|
||||
# the refresh. (Additions are handled by the post-download reconcile.)
|
||||
try:
|
||||
from core.playlists.materialize_service import rebuild_mirrored_playlist_if_organized
|
||||
rebuild_mirrored_playlist_if_organized(
|
||||
db, deps.config_manager, pl.get('id'), profile_id=pl.get('profile_id', 1)
|
||||
)
|
||||
except Exception as _mat_err:
|
||||
deps.logger.debug(f"[Playlist Folder] mirror-refresh cleanup skipped: {_mat_err}")
|
||||
|
||||
if old_ids != new_ids:
|
||||
added = len(new_ids - old_ids)
|
||||
removed = len(old_ids - new_ids)
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ def register_all(deps: AutomationDeps) -> None:
|
|||
engine.register_action_handler(
|
||||
'start_quality_scan',
|
||||
lambda config: auto_start_quality_scan(config, deps),
|
||||
lambda: deps.get_quality_scanner_state().get('status') == 'running',
|
||||
lambda: False, # repair worker dedupes Run-Now requests itself
|
||||
)
|
||||
engine.register_action_handler(
|
||||
'backup_database',
|
||||
|
|
|
|||
|
|
@ -203,10 +203,15 @@ def auto_sync_playlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str
|
|||
except Exception as e:
|
||||
deps.logger.debug("mirror sync last-status read: %s", e)
|
||||
|
||||
# Sync under the user's custom alias when set, else the upstream name (#865
|
||||
# follow-up). The server-side playlist is named with this.
|
||||
from core.playlists.naming import effective_mirrored_name
|
||||
sync_name = effective_mirrored_name(pl) or pl.get('name') or 'Playlist'
|
||||
|
||||
deps.update_progress(
|
||||
auto_id,
|
||||
progress=50,
|
||||
phase=f'Syncing "{pl["name"]}"',
|
||||
phase=f'Syncing "{sync_name}"',
|
||||
log_line=f'{len(tracks_json)} discovered, {skipped_count} skipped',
|
||||
log_type='info',
|
||||
)
|
||||
|
|
@ -221,14 +226,14 @@ def auto_sync_playlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str
|
|||
skip_wishlist_add = bool(pl.get('organize_by_playlist'))
|
||||
threading.Thread(
|
||||
target=deps.run_sync_task,
|
||||
args=(sync_id, pl['name'], tracks_json, auto_id, 1, pl.get('image_url', '')),
|
||||
args=(sync_id, sync_name, tracks_json, auto_id, 1, pl.get('image_url', '')),
|
||||
kwargs={'skip_wishlist_add': skip_wishlist_add},
|
||||
daemon=True,
|
||||
name=f'auto-sync-{playlist_id}',
|
||||
).start()
|
||||
return {
|
||||
'status': 'started',
|
||||
'playlist_name': pl['name'],
|
||||
'playlist_name': sync_name,
|
||||
'discovered_tracks': str(len(tracks_json)),
|
||||
'skipped_tracks': str(skipped_count),
|
||||
'_manages_own_progress': True,
|
||||
|
|
|
|||
|
|
@ -82,7 +82,16 @@ def run_service_test(service, test_config):
|
|||
if temp_client.is_spotify_authenticated():
|
||||
return True, "Spotify connection successful!"
|
||||
else:
|
||||
# Using fallback metadata source
|
||||
# Spotify-Free (no-auth) metadata path: officially unauthenticated,
|
||||
# but the no-creds source is selected and available. Report it as the
|
||||
# working source rather than the generic Deezer/Discogs/iTunes fallback.
|
||||
try:
|
||||
spotify_free_available = temp_client.is_spotify_metadata_available()
|
||||
except Exception:
|
||||
spotify_free_available = False
|
||||
if spotify_free_available:
|
||||
return True, "Spotify (no-auth) connection successful!"
|
||||
# Using a different fallback metadata source
|
||||
fb_src = _get_metadata_fallback_source()
|
||||
fallback_name = 'Deezer' if fb_src == 'deezer' else 'Discogs' if fb_src == 'discogs' else 'iTunes'
|
||||
if spotify_configured:
|
||||
|
|
|
|||
82
core/database_update_health.py
Normal file
82
core/database_update_health.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""Stall detection for the database-update job.
|
||||
|
||||
The DB updater keeps a single in-memory state dict whose ``status`` is set to
|
||||
``running`` at start and only flipped to ``finished``/``error`` by the worker's
|
||||
completion/error callbacks. If the worker thread hangs — e.g. a media-server API
|
||||
call with no timeout, a DB lock — those callbacks never fire, so ``status`` stays
|
||||
``running`` forever and the UI shows a frozen progress bar with no way to recover
|
||||
(GitHub #859).
|
||||
|
||||
This module is the single, *pure* decision for "is a running job stalled?". It
|
||||
takes the state dict plus the current wall-clock time and a timeout, and answers
|
||||
yes/no — no DB, no globals, no clock of its own. That keeps it unit-testable and
|
||||
lets the watchdog wiring in web_server.py stay a thin call. The job carries a
|
||||
``last_progress_at`` epoch timestamp that the start path and every progress/phase
|
||||
callback bump; staleness is simply "running, and that timestamp is older than the
|
||||
timeout".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
# 5 minutes with zero forward progress = presumed hung. A healthy scan ticks
|
||||
# progress (per-artist) far more often than this even for large libraries, so
|
||||
# the timeout won't false-positive a slow-but-working run.
|
||||
DEFAULT_STALL_TIMEOUT_SECONDS = 300
|
||||
|
||||
|
||||
def is_db_update_stalled(
|
||||
state: Mapping[str, Any],
|
||||
now: float,
|
||||
timeout_seconds: float = DEFAULT_STALL_TIMEOUT_SECONDS,
|
||||
) -> bool:
|
||||
"""Return True when the job is ``running`` but has made no progress within
|
||||
``timeout_seconds``.
|
||||
|
||||
Conservative by design — it only ever reports a stall it can prove:
|
||||
- Only a ``running`` job can stall (idle/finished/error never do).
|
||||
- With no usable ``last_progress_at`` timestamp we cannot judge, so we return
|
||||
False rather than risk killing a job we have no clock for.
|
||||
- A non-positive timeout is treated as "disabled" (never stalls).
|
||||
"""
|
||||
if not isinstance(state, Mapping):
|
||||
return False
|
||||
if state.get("status") != "running":
|
||||
return False
|
||||
if timeout_seconds is None or timeout_seconds <= 0:
|
||||
return False
|
||||
last = state.get("last_progress_at")
|
||||
if not last:
|
||||
return False
|
||||
try:
|
||||
elapsed = float(now) - float(last)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return elapsed >= float(timeout_seconds)
|
||||
|
||||
|
||||
def stalled_error_message(state: Mapping[str, Any], now: float) -> str:
|
||||
"""Build a clear, human-facing message for a stalled job, including how long
|
||||
it has been silent and the phase it died in."""
|
||||
last = state.get("last_progress_at") if isinstance(state, Mapping) else None
|
||||
phase = state.get("phase") if isinstance(state, Mapping) else None
|
||||
try:
|
||||
secs = int(float(now) - float(last)) if last else 0
|
||||
except (TypeError, ValueError):
|
||||
secs = 0
|
||||
msg = "Update appears stuck — no progress"
|
||||
if secs > 0:
|
||||
msg += f" for {secs}s"
|
||||
if phase:
|
||||
msg += f" (last phase: {phase})"
|
||||
msg += (". The worker may be hung on the media server. Start a new update "
|
||||
"to try again, or restart SoulSync if it keeps stalling.")
|
||||
return msg
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_STALL_TIMEOUT_SECONDS",
|
||||
"is_db_update_stalled",
|
||||
"stalled_error_message",
|
||||
]
|
||||
|
|
@ -8,7 +8,15 @@ from datetime import datetime, timedelta
|
|||
from utils.logging_config import get_logger
|
||||
from database.music_database import MusicDatabase
|
||||
from core.deezer_client import DeezerClient
|
||||
from core.worker_utils import accept_artist_match, interruptible_sleep, set_album_api_track_count
|
||||
from core.worker_utils import (
|
||||
accept_artist_match,
|
||||
artist_name_matches,
|
||||
interruptible_sleep,
|
||||
owned_album_titles,
|
||||
pick_artist_by_catalog,
|
||||
release_titles,
|
||||
set_album_api_track_count,
|
||||
)
|
||||
from core.enrichment.manual_match_honoring import honor_stored_match
|
||||
|
||||
logger = get_logger("deezer_worker")
|
||||
|
|
@ -401,7 +409,20 @@ class DeezerWorker:
|
|||
logger.debug(f"Preserving existing Deezer ID for artist '{artist_name}': {existing_id}")
|
||||
return
|
||||
|
||||
result = self.client.search_artist(artist_name)
|
||||
# Multi-candidate search (was single search_artist) so same-name artists
|
||||
# can be disambiguated: gate by name, then pick the one whose catalog
|
||||
# overlaps the albums this library owns.
|
||||
results = self.client.search_artists(artist_name, limit=5)
|
||||
gated = [a for a in (results or []) if artist_name_matches(artist_name, getattr(a, 'name', ''))]
|
||||
chosen, _overlap = pick_artist_by_catalog(
|
||||
gated,
|
||||
owned_album_titles(self.db, artist_id),
|
||||
lambda a: release_titles(self.client.get_artist_albums_list(a.id)),
|
||||
)
|
||||
|
||||
# search_artists returns lean Artist objects; fetch the full dict (same
|
||||
# shape the old search_artist returned) for storage.
|
||||
result = self.client.get_artist_info(chosen.id) if chosen else None
|
||||
if result:
|
||||
result_name = result.get('name', '')
|
||||
ok, reason = accept_artist_match(
|
||||
|
|
|
|||
|
|
@ -1,40 +1,23 @@
|
|||
"""Background worker for the library quality scanner.
|
||||
"""Shared metadata match + result-normalization helpers for quality matching.
|
||||
|
||||
`run_quality_scanner(scope, profile_id, deps)` is the function the
|
||||
quality-scanner endpoint kicks off in a thread to scan the library
|
||||
for low-quality tracks (below the user's configured quality profile)
|
||||
and add provider matches to the wishlist:
|
||||
These were the matching guts of the old auto-acting quality-scanner worker (now
|
||||
removed — quality scanning is the ``quality_upgrade`` library-maintenance repair
|
||||
job in ``core/repair_jobs/quality_upgrade.py``). They're kept here as a single
|
||||
source of truth and imported by that job:
|
||||
|
||||
1. Reset scanner state, load quality profile + minimum acceptable tier.
|
||||
2. Load tracks from DB based on scope:
|
||||
- 'watchlist' → tracks for watchlisted artists only.
|
||||
- other → all library tracks.
|
||||
3. For each track:
|
||||
- Stop-request gate (state['status'] != 'running').
|
||||
- Quality-tier check via _get_quality_tier_from_extension(file_path).
|
||||
- Skip tracks meeting standards (tier_num <= min_acceptable_tier).
|
||||
- For low-quality tracks: matching_engine search query gen, score
|
||||
candidates against the configured metadata source priority
|
||||
(artist + title similarity, album-type bonus), pick best match >=
|
||||
0.7 confidence.
|
||||
- On match: add normalized track data to wishlist via
|
||||
`wishlist_service.add_track_to_wishlist` with
|
||||
source_type='quality_scanner' and a source_context that captures
|
||||
original file_path, format tier, bitrate, and match confidence.
|
||||
4. After all tracks: status='finished', progress=100, activity feed
|
||||
entry, emit `quality_scan_completed` event for automation engine.
|
||||
5. On critical exception: status='error', error message captured.
|
||||
- ``_search_tracks_for_source`` — query one metadata source's ``search_tracks``.
|
||||
- ``_normalize_track_match`` / ``_normalize_track_album`` / ``_normalize_track_artists``
|
||||
— turn a provider track into the wishlist-ready dict (typed Album converters
|
||||
with legacy duck-typed fallback).
|
||||
- ``_track_name`` / ``_track_artist_names`` / ``_extract_lookup_value`` — accessors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from core.metadata.registry import get_client_for_source, get_primary_source, get_source_priority
|
||||
from core.metadata.registry import get_client_for_source
|
||||
from core.metadata.types import Album
|
||||
from core.wishlist.payloads import ensure_wishlist_track_format
|
||||
|
||||
|
|
@ -56,16 +39,6 @@ _TYPED_ALBUM_CONVERTERS: Dict[str, Callable[[Dict[str, Any]], Album]] = {
|
|||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class QualityScannerDeps:
|
||||
"""Bundle of cross-cutting deps the quality scanner needs."""
|
||||
quality_scanner_state: dict
|
||||
quality_scanner_lock: Any # threading.Lock
|
||||
QUALITY_TIERS: dict
|
||||
matching_engine: Any
|
||||
automation_engine: Any
|
||||
get_quality_tier_from_extension: Callable
|
||||
add_activity_item: Callable
|
||||
|
||||
|
||||
def _extract_lookup_value(value: Any, *names: str, default: Any = None) -> Any:
|
||||
|
|
@ -300,363 +273,3 @@ def _search_tracks_for_source(source: str, query: str, limit: int = 5, client: A
|
|||
except Exception as exc:
|
||||
logger.debug("Could not search %s for %s: %s", source, query, exc)
|
||||
return []
|
||||
|
||||
|
||||
def run_quality_scanner(scope='watchlist', profile_id=1, deps: QualityScannerDeps = None):
|
||||
"""Main quality scanner worker function"""
|
||||
from core.wishlist_service import get_wishlist_service
|
||||
from database.music_database import MusicDatabase
|
||||
|
||||
try:
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["status"] = "running"
|
||||
deps.quality_scanner_state["phase"] = "Initializing scan..."
|
||||
deps.quality_scanner_state["progress"] = 0
|
||||
deps.quality_scanner_state["processed"] = 0
|
||||
deps.quality_scanner_state["total"] = 0
|
||||
deps.quality_scanner_state["quality_met"] = 0
|
||||
deps.quality_scanner_state["low_quality"] = 0
|
||||
deps.quality_scanner_state["matched"] = 0
|
||||
deps.quality_scanner_state["results"] = []
|
||||
deps.quality_scanner_state["error_message"] = ""
|
||||
|
||||
logger.info(f"[Quality Scanner] Starting scan with scope: {scope}")
|
||||
|
||||
# Get database instance
|
||||
db = MusicDatabase()
|
||||
|
||||
# Get quality profile to determine preferred quality
|
||||
quality_profile = db.get_quality_profile()
|
||||
preferred_qualities = quality_profile.get('qualities', {})
|
||||
|
||||
# Determine minimum acceptable tier based on enabled qualities
|
||||
min_acceptable_tier = 999
|
||||
for quality_name, quality_config in preferred_qualities.items():
|
||||
if quality_config.get('enabled', False):
|
||||
# Map quality profile names to tier names
|
||||
tier_map = {
|
||||
'flac': 'lossless',
|
||||
'mp3_320': 'low_lossy',
|
||||
'mp3_256': 'low_lossy',
|
||||
'mp3_192': 'low_lossy'
|
||||
}
|
||||
tier_name = tier_map.get(quality_name)
|
||||
if tier_name:
|
||||
tier_num = deps.QUALITY_TIERS[tier_name]['tier']
|
||||
min_acceptable_tier = min(min_acceptable_tier, tier_num)
|
||||
|
||||
logger.info(f"[Quality Scanner] Minimum acceptable tier: {min_acceptable_tier}")
|
||||
|
||||
# Get tracks to scan based on scope
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["phase"] = "Loading tracks from database..."
|
||||
|
||||
if scope == 'watchlist':
|
||||
# Get watchlist artists
|
||||
watchlist_artists = db.get_watchlist_artists(profile_id=profile_id)
|
||||
if not watchlist_artists:
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["status"] = "finished"
|
||||
deps.quality_scanner_state["phase"] = "No watchlist artists found"
|
||||
deps.quality_scanner_state["error_message"] = "Please add artists to watchlist first"
|
||||
logger.warning("[Quality Scanner] No watchlist artists found")
|
||||
return
|
||||
|
||||
# Get artist names from watchlist
|
||||
artist_names = [artist.artist_name for artist in watchlist_artists]
|
||||
logger.info(f"[Quality Scanner] Scanning {len(artist_names)} watchlist artists")
|
||||
|
||||
# Get all tracks for these artists by name
|
||||
conn = db._get_connection()
|
||||
placeholders = ','.join(['?' for _ in artist_names])
|
||||
tracks_to_scan = conn.execute(
|
||||
f"SELECT t.id, t.title, t.artist_id, t.album_id, t.file_path, t.bitrate, a.name as artist_name, al.title as album_title "
|
||||
f"FROM tracks t "
|
||||
f"JOIN artists a ON t.artist_id = a.id "
|
||||
f"JOIN albums al ON t.album_id = al.id "
|
||||
f"WHERE a.name IN ({placeholders}) AND t.file_path IS NOT NULL",
|
||||
artist_names
|
||||
).fetchall()
|
||||
conn.close()
|
||||
else:
|
||||
# Scan all library tracks
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["phase"] = "Loading all library tracks..."
|
||||
|
||||
conn = db._get_connection()
|
||||
tracks_to_scan = conn.execute(
|
||||
"SELECT t.id, t.title, t.artist_id, t.album_id, t.file_path, t.bitrate, a.name as artist_name, al.title as album_title "
|
||||
"FROM tracks t "
|
||||
"JOIN artists a ON t.artist_id = a.id "
|
||||
"JOIN albums al ON t.album_id = al.id "
|
||||
"WHERE t.file_path IS NOT NULL"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
total_tracks = len(tracks_to_scan)
|
||||
logger.info(f"[Quality Scanner] Found {total_tracks} tracks to scan")
|
||||
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["total"] = total_tracks
|
||||
deps.quality_scanner_state["phase"] = f"Scanning {total_tracks} tracks..."
|
||||
|
||||
source_priority = get_source_priority(get_primary_source())
|
||||
if not source_priority:
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["status"] = "error"
|
||||
deps.quality_scanner_state["phase"] = "No metadata provider available"
|
||||
deps.quality_scanner_state["error_message"] = "No metadata provider is available for quality scanning"
|
||||
logger.info("[Quality Scanner] No metadata provider available")
|
||||
return
|
||||
|
||||
logger.info("[Quality Scanner] Using metadata source priority: %s", source_priority)
|
||||
|
||||
wishlist_service = get_wishlist_service()
|
||||
add_to_wishlist = getattr(wishlist_service, 'add_track_to_wishlist', None)
|
||||
if add_to_wishlist is None:
|
||||
add_to_wishlist = getattr(wishlist_service, 'add_spotify_track_to_wishlist', None)
|
||||
if add_to_wishlist is None:
|
||||
raise AttributeError("Wishlist service does not expose an add-to-wishlist method")
|
||||
|
||||
# Scan each track
|
||||
for idx, track_row in enumerate(tracks_to_scan, 1):
|
||||
# Check for stop request
|
||||
if deps.quality_scanner_state.get('status') != 'running':
|
||||
logger.info(f"[Quality Scanner] Stop requested, halting at track {idx}/{total_tracks}")
|
||||
break
|
||||
|
||||
try:
|
||||
track_id, title, artist_id, album_id, file_path, bitrate, artist_name, album_title = track_row
|
||||
|
||||
# Check quality tier
|
||||
tier_name, tier_num = deps.get_quality_tier_from_extension(file_path)
|
||||
|
||||
# Update progress
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["processed"] = idx
|
||||
deps.quality_scanner_state["progress"] = (idx / total_tracks) * 100
|
||||
deps.quality_scanner_state["phase"] = f"Scanning: {artist_name} - {title}"
|
||||
|
||||
# Check if meets quality standards
|
||||
if tier_num <= min_acceptable_tier:
|
||||
# Quality met
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["quality_met"] += 1
|
||||
continue
|
||||
|
||||
# Low quality track found
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["low_quality"] += 1
|
||||
|
||||
logger.info(f"[Quality Scanner] Low quality: {artist_name} - {title} ({tier_name}, {file_path})")
|
||||
|
||||
# Attempt to match using the active metadata provider
|
||||
matched = False
|
||||
matched_track_data = None
|
||||
best_source = None
|
||||
attempted_any_provider = False
|
||||
|
||||
try:
|
||||
# Generate search queries using matching engine
|
||||
temp_track = type('TempTrack', (), {
|
||||
'name': title,
|
||||
'artists': [artist_name],
|
||||
'album': album_title
|
||||
})()
|
||||
|
||||
search_queries = deps.matching_engine.generate_download_queries(temp_track)
|
||||
logger.info(f"[Quality Scanner] Generated {len(search_queries)} search queries for {artist_name} - {title}")
|
||||
|
||||
# Find best match using confidence scoring
|
||||
best_match = None
|
||||
best_confidence = 0.0
|
||||
min_confidence = 0.7 # Match existing standard
|
||||
|
||||
for _query_idx, search_query in enumerate(search_queries):
|
||||
try:
|
||||
for source in source_priority:
|
||||
client = get_client_for_source(source)
|
||||
if not client or not hasattr(client, 'search_tracks'):
|
||||
continue
|
||||
|
||||
attempted_any_provider = True
|
||||
provider_matches = _search_tracks_for_source(source, search_query, limit=5, client=client)
|
||||
time.sleep(0.5) # Rate limit metadata API calls
|
||||
|
||||
if not provider_matches:
|
||||
continue
|
||||
|
||||
# Score each result using matching engine
|
||||
for provider_track in provider_matches:
|
||||
try:
|
||||
# Calculate artist confidence
|
||||
artist_confidence = 0.0
|
||||
provider_artists = _track_artist_names(provider_track)
|
||||
if provider_artists:
|
||||
for result_artist in provider_artists:
|
||||
artist_sim = deps.matching_engine.similarity_score(
|
||||
deps.matching_engine.normalize_string(artist_name),
|
||||
deps.matching_engine.normalize_string(result_artist)
|
||||
)
|
||||
artist_confidence = max(artist_confidence, artist_sim)
|
||||
|
||||
# Calculate title confidence
|
||||
title_confidence = deps.matching_engine.similarity_score(
|
||||
deps.matching_engine.normalize_string(title),
|
||||
deps.matching_engine.normalize_string(_track_name(provider_track))
|
||||
)
|
||||
|
||||
# Combined confidence (50% artist + 50% title)
|
||||
combined_confidence = (artist_confidence * 0.5 + title_confidence * 0.5)
|
||||
|
||||
# Small bonus for album tracks over singles
|
||||
_at = _extract_lookup_value(provider_track, 'album_type', default='') or ''
|
||||
if _at == 'album':
|
||||
combined_confidence += 0.02
|
||||
elif _at == 'ep':
|
||||
combined_confidence += 0.01
|
||||
|
||||
candidate_artist = provider_artists[0] if provider_artists else 'Unknown Artist'
|
||||
candidate_name = _track_name(provider_track)
|
||||
logger.info(
|
||||
f"[Quality Scanner] Candidate ({source}): '{candidate_artist}' - "
|
||||
f"'{candidate_name}' (confidence: {combined_confidence:.3f})"
|
||||
)
|
||||
|
||||
# Update best match if this is better
|
||||
if combined_confidence > best_confidence and combined_confidence >= min_confidence:
|
||||
best_confidence = combined_confidence
|
||||
best_match = provider_track
|
||||
best_source = source
|
||||
logger.info(
|
||||
f"[Quality Scanner] New best match ({source}): {candidate_artist} - "
|
||||
f"{candidate_name} (confidence: {combined_confidence:.3f})"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Quality Scanner] Error scoring result: {e}")
|
||||
continue
|
||||
|
||||
# If we found a very high confidence match, stop searching this query
|
||||
if best_confidence >= 0.9:
|
||||
logger.info(f"[Quality Scanner] High confidence match found ({best_confidence:.3f}), stopping search")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"[Quality Scanner] Error searching with query '{search_query}': {e}")
|
||||
continue
|
||||
|
||||
if not attempted_any_provider:
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["status"] = "error"
|
||||
deps.quality_scanner_state["phase"] = "No metadata provider available"
|
||||
deps.quality_scanner_state["error_message"] = "No metadata provider is available for quality scanning"
|
||||
logger.info("[Quality Scanner] No metadata provider available")
|
||||
return
|
||||
|
||||
# Process best match
|
||||
if best_match:
|
||||
matched = True
|
||||
final_artist = _track_artist_names(best_match)[0] if _track_artist_names(best_match) else 'Unknown Artist'
|
||||
final_name = _track_name(best_match)
|
||||
final_source = best_source or 'metadata'
|
||||
logger.info(
|
||||
f"[Quality Scanner] Final match ({final_source}): {final_artist} - "
|
||||
f"{final_name} (confidence: {best_confidence:.3f})"
|
||||
)
|
||||
|
||||
# Build normalized track data for wishlist
|
||||
matched_track_data = _normalize_track_match(best_match, final_source)
|
||||
|
||||
# Add to wishlist
|
||||
source_context = {
|
||||
'quality_scanner': True,
|
||||
'original_file_path': file_path,
|
||||
'original_format': tier_name,
|
||||
'original_bitrate': bitrate,
|
||||
'match_confidence': best_confidence,
|
||||
'scan_date': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
success = add_to_wishlist(
|
||||
track_data=matched_track_data,
|
||||
failure_reason=f"Low quality - {tier_name.replace('_', ' ').title()} format",
|
||||
source_type='quality_scanner',
|
||||
source_context=source_context,
|
||||
profile_id=profile_id
|
||||
)
|
||||
|
||||
if success:
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["matched"] += 1
|
||||
logger.info(f"[Quality Scanner] Matched and added to wishlist: {artist_name} - {title}")
|
||||
else:
|
||||
logger.error(f"[Quality Scanner] Failed to add to wishlist: {artist_name} - {title}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"[Quality Scanner] No suitable metadata match found "
|
||||
f"(best confidence: {best_confidence:.3f}, required: {min_confidence:.3f})"
|
||||
)
|
||||
|
||||
except Exception as matching_error:
|
||||
logger.error(f"[Quality Scanner] Matching error for {artist_name} - {title}: {matching_error}")
|
||||
|
||||
# Store result
|
||||
result_entry = {
|
||||
'track_id': track_id,
|
||||
'title': title,
|
||||
'artist': artist_name,
|
||||
'album': album_title,
|
||||
'file_path': file_path,
|
||||
'current_format': tier_name,
|
||||
'bitrate': bitrate,
|
||||
'matched': matched,
|
||||
'match_id': matched_track_data['id'] if matched_track_data else None,
|
||||
'provider': best_source if matched else None,
|
||||
'spotify_id': matched_track_data['id'] if matched_track_data else None,
|
||||
}
|
||||
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["results"].append(result_entry)
|
||||
|
||||
if not matched:
|
||||
logger.warning(f"[Quality Scanner] No metadata match found for: {artist_name} - {title}")
|
||||
|
||||
except Exception as track_error:
|
||||
logger.error(f"[Quality Scanner] Error processing track: {track_error}")
|
||||
continue
|
||||
|
||||
# Scan complete (don't overwrite if already stopped by user)
|
||||
with deps.quality_scanner_lock:
|
||||
was_stopped = deps.quality_scanner_state["status"] != "running"
|
||||
deps.quality_scanner_state["status"] = "finished"
|
||||
deps.quality_scanner_state["progress"] = 100
|
||||
if not was_stopped:
|
||||
deps.quality_scanner_state["phase"] = "Scan complete"
|
||||
|
||||
logger.info(f"[Quality Scanner] Scan {'stopped' if was_stopped else 'complete'}: {deps.quality_scanner_state['processed']} processed, "
|
||||
f"{deps.quality_scanner_state['low_quality']} low quality, {deps.quality_scanner_state['matched']} matched to metadata providers")
|
||||
|
||||
# Add activity
|
||||
deps.add_activity_item("", "Quality Scan Complete",
|
||||
f"{deps.quality_scanner_state['matched']} tracks added to wishlist", "Now")
|
||||
|
||||
try:
|
||||
if deps.automation_engine:
|
||||
deps.automation_engine.emit('quality_scan_completed', {
|
||||
'quality_met': str(deps.quality_scanner_state.get('quality_met', 0)),
|
||||
'low_quality': str(deps.quality_scanner_state.get('low_quality', 0)),
|
||||
'total_scanned': str(deps.quality_scanner_state.get('processed', 0)),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug("emit quality_scan_completed failed: %s", e)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Quality Scanner] Critical error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
with deps.quality_scanner_lock:
|
||||
deps.quality_scanner_state["status"] = "error"
|
||||
deps.quality_scanner_state["error_message"] = str(e)
|
||||
deps.quality_scanner_state["phase"] = f"Error: {str(e)}"
|
||||
|
|
|
|||
|
|
@ -52,6 +52,10 @@ class YoutubeDiscoveryDeps:
|
|||
build_discovery_wing_it_stub: Callable
|
||||
get_database: Callable[[], Any]
|
||||
add_activity_item: Callable
|
||||
# Recover a YouTube track's artist from its own video page when flat playlist
|
||||
# extraction left it "Unknown Artist" (#863). Takes a video id, returns a raw
|
||||
# artist string or ''. Optional — discovery still works without it.
|
||||
recover_youtube_artist: Callable[[str], str] = None
|
||||
|
||||
|
||||
def run_youtube_discovery_worker(url_hash, deps: YoutubeDiscoveryDeps):
|
||||
|
|
@ -94,6 +98,30 @@ def run_youtube_discovery_worker(url_hash, deps: YoutubeDiscoveryDeps):
|
|||
cleaned_title = track['name']
|
||||
cleaned_artist = track['artists'][0] if track['artists'] else 'Unknown Artist'
|
||||
|
||||
# Recover the artist from the track's own video page if flat
|
||||
# playlist extraction left it Unknown (#863). Done here, in the
|
||||
# background worker, rather than in the parse request (which would
|
||||
# block for minutes on a big playlist). Per-track cost is hidden
|
||||
# behind the discovery progress bar; the recovered artist makes the
|
||||
# match below actually find the song.
|
||||
if cleaned_artist == 'Unknown Artist' and track.get('id'):
|
||||
if not deps.recover_youtube_artist:
|
||||
logger.warning("[YT Discovery] artist recovery unavailable (dep not wired) "
|
||||
"— '%s' stays Unknown", cleaned_title)
|
||||
else:
|
||||
try:
|
||||
_rec = deps.recover_youtube_artist(track['id'])
|
||||
except Exception as _rec_err:
|
||||
logger.warning(f"[YT Discovery] artist recovery raised for {track.get('id')}: {_rec_err}")
|
||||
_rec = ''
|
||||
if _rec and _rec != 'Unknown Artist':
|
||||
logger.info(f"[YT Discovery] recovered artist '{_rec}' for '{cleaned_title}' ({track['id']})")
|
||||
cleaned_artist = _rec
|
||||
track['artists'] = [_rec] # persist so retries/UI see it
|
||||
else:
|
||||
logger.info(f"[YT Discovery] artist recovery returned nothing for "
|
||||
f"'{cleaned_title}' ({track['id']}) — leaving Unknown")
|
||||
|
||||
logger.info(f"Searching {discovery_source} for: '{cleaned_artist}' - '{cleaned_title}'")
|
||||
|
||||
# Check discovery cache first
|
||||
|
|
|
|||
|
|
@ -339,7 +339,6 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
|
|||
)
|
||||
|
||||
logger.info(f"[Context] Set is_album_download: {is_album_context} (has clean data: {has_clean_spotify_data})")
|
||||
logger.debug(f"[Debug] Context creation - track_info: {track_info is not None}, playlist_folder_mode: {track_info.get('_playlist_folder_mode', False) if track_info else False}")
|
||||
|
||||
# Update task with successful download info
|
||||
with tasks_lock:
|
||||
|
|
|
|||
|
|
@ -549,6 +549,24 @@ def on_download_completed(batch_id: str, task_id: str, success: bool, deps: Life
|
|||
except Exception as m3u_err:
|
||||
logger.error(f"[M3U] Error regenerating M3U on batch complete: {m3u_err}")
|
||||
|
||||
# PLAYLIST MATERIALIZE: one path-independent reconcile — drop this
|
||||
# batch's newly-resolved tracks into the right Playlists/<name>/
|
||||
# folders. Covers an organize-by-playlist download AND a late
|
||||
# wishlist arrival (via each track's playlist provenance). Built
|
||||
# from the batch's own captured paths — non-fatal, derived view.
|
||||
try:
|
||||
from core.playlists.materialize_service import reconcile_batch_playlists
|
||||
from database.music_database import MusicDatabase
|
||||
for _pl_name, _mat in reconcile_batch_playlists(MusicDatabase(), batch, download_tasks, deps.config_manager):
|
||||
logger.info(
|
||||
f"[Playlist Folder] Rebuilt '{_mat.playlist_dir}': "
|
||||
f"{_mat.linked} linked, {_mat.copied} copied, "
|
||||
f"{_mat.unchanged} unchanged, {_mat.removed_stale} stale removed"
|
||||
+ (" (symlinks unsupported here → copied)" if _mat.fellback else "")
|
||||
)
|
||||
except Exception as _mat_err:
|
||||
logger.error(f"[Playlist Folder] Materialize failed (non-fatal): {_mat_err}")
|
||||
|
||||
# REPAIR: Scan all album folders from this batch for track number issues
|
||||
if deps.repair_worker:
|
||||
deps.repair_worker.process_batch(batch_id)
|
||||
|
|
@ -735,6 +753,23 @@ def check_batch_completion_v2(batch_id: str, deps: LifecycleDeps) -> Optional[bo
|
|||
deps.download_monitor.stop_monitoring(batch_id)
|
||||
_cleanup_private_album_bundle_staging(batch_id, batch)
|
||||
|
||||
# PLAYLIST MATERIALIZE: same reconcile as the primary completion path
|
||||
# (on_download_completed). Monitor-detected downloads complete via THIS
|
||||
# V2 path, so the reconcile must run here too or playlist folders never
|
||||
# get built for them. Path-independent, non-fatal, derived view.
|
||||
try:
|
||||
from core.playlists.materialize_service import reconcile_batch_playlists
|
||||
from database.music_database import MusicDatabase
|
||||
for _pl_name, _mat in reconcile_batch_playlists(MusicDatabase(), batch, download_tasks, deps.config_manager):
|
||||
logger.info(
|
||||
f"[Playlist Folder] Rebuilt '{_mat.playlist_dir}': "
|
||||
f"{_mat.linked} linked, {_mat.copied} copied, "
|
||||
f"{_mat.unchanged} unchanged, {_mat.removed_stale} stale removed"
|
||||
+ (" (symlinks unsupported here → copied)" if _mat.fellback else "")
|
||||
)
|
||||
except Exception as _mat_err:
|
||||
logger.error(f"[Playlist Folder] Materialize failed (non-fatal): {_mat_err}")
|
||||
|
||||
# REPAIR: Scan all album folders from this batch for track number issues
|
||||
if deps.repair_worker:
|
||||
deps.repair_worker.process_batch(batch_id)
|
||||
|
|
|
|||
|
|
@ -505,13 +505,19 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
track_name = track_data.get('name', '')
|
||||
artists = track_data.get('artists', [])
|
||||
found, confidence = False, 0.0
|
||||
# Additive payload: the owned library track (DatabaseTrack) when this
|
||||
# item is found in the library, so downstream (playlist materialization)
|
||||
# knows WHERE the real file is without re-matching. None when not owned.
|
||||
matched_track = None
|
||||
|
||||
# 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
|
||||
):
|
||||
_manual_match = (
|
||||
_mlm.get_match_for_track(db, batch_profile_id, track_data, default_source=batch_source)
|
||||
if (not ignore_manual_matches and _stid) else None
|
||||
)
|
||||
if _manual_match:
|
||||
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)
|
||||
|
|
@ -523,6 +529,8 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
'found': True,
|
||||
'confidence': 1.0,
|
||||
'match_reason': 'manual_library_match',
|
||||
'matched_file_path': _manual_match.get('library_file_path'),
|
||||
'matched_track_id': _manual_match.get('library_track_id'),
|
||||
})
|
||||
continue
|
||||
|
||||
|
|
@ -568,8 +576,10 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
# Direct title match (try both raw and normalized)
|
||||
if track_name_lower in album_tracks_map:
|
||||
found, confidence = True, 1.0
|
||||
matched_track = album_tracks_map[track_name_lower]
|
||||
elif _normalized_source_title and _normalized_source_title in album_tracks_map:
|
||||
found, confidence = True, 1.0
|
||||
matched_track = album_tracks_map[_normalized_source_title]
|
||||
else:
|
||||
# Fuzzy match against album tracks using string similarity.
|
||||
# Compare BOTH the raw and normalized source titles —
|
||||
|
|
@ -577,14 +587,17 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
# matching when the album doesn't imply version
|
||||
# context (helper returns the input unchanged).
|
||||
best_sim = 0.0
|
||||
best_track = None
|
||||
for db_title_lower, _db_track in album_tracks_map.items():
|
||||
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
|
||||
best_track = _db_track
|
||||
if best_sim >= 0.7:
|
||||
found, confidence = True, best_sim
|
||||
matched_track = best_track
|
||||
else:
|
||||
# Fall back to global per-track search for this track
|
||||
# When allow_duplicates is on for album downloads, skip global
|
||||
|
|
@ -605,6 +618,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
)
|
||||
if db_track and track_confidence >= 0.7:
|
||||
found, confidence = True, track_confidence
|
||||
matched_track = db_track
|
||||
break
|
||||
elif allow_duplicates and batch_is_album:
|
||||
# Allow duplicates + album download + album not in DB yet → treat all as missing
|
||||
|
|
@ -624,10 +638,15 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
)
|
||||
if db_track and track_confidence >= 0.7:
|
||||
found, confidence = True, track_confidence
|
||||
matched_track = db_track
|
||||
break
|
||||
|
||||
analysis_results.append({
|
||||
'track_index': track_index, 'track': track_data, 'found': found, 'confidence': confidence
|
||||
'track_index': track_index, 'track': track_data, 'found': found, 'confidence': confidence,
|
||||
# Additive: real on-disk location of the owned track (None when not
|
||||
# owned), so playlist materialization links the right file.
|
||||
'matched_file_path': getattr(matched_track, 'file_path', None),
|
||||
'matched_track_id': getattr(matched_track, 'id', None),
|
||||
})
|
||||
|
||||
# WISHLIST REMOVAL: If track is found in database, check if it should be removed from wishlist
|
||||
|
|
@ -766,6 +785,41 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
logger.warning("[Auto-Wishlist] No missing tracks found - calling auto-completion handler to toggle cycle and reschedule")
|
||||
deps.missing_download_executor.submit(deps.process_failed_tracks_to_wishlist_exact_with_auto_completion, batch_id)
|
||||
|
||||
# Organize-by-playlist with NOTHING to download (every track already
|
||||
# owned): the batch never enters the download/lifecycle path, so build
|
||||
# the playlist folder here from the owned files the analysis matched.
|
||||
# Gated + non-fatal; runs once after analysis, not in the per-track loop.
|
||||
if effective_playlist_folder_mode:
|
||||
try:
|
||||
from core.playlists.materialize_service import reconcile_batch_playlists
|
||||
from database.music_database import MusicDatabase as _MDB
|
||||
_batch = download_batches.get(batch_id)
|
||||
if _batch is not None:
|
||||
# We KNOW the intent is organize-by-playlist here (the gate
|
||||
# above). The line-431 sync only writes the dict field when
|
||||
# effective and NOT batch_playlist_folder_mode, so when the
|
||||
# toggle itself drove it the dict field can still be falsy —
|
||||
# which makes reconcile build no batch ref. Make the dict
|
||||
# authoritative so reconcile sees the batch's own playlist.
|
||||
_batch['playlist_folder_mode'] = True
|
||||
if effective_playlist_name:
|
||||
_batch['playlist_name'] = effective_playlist_name
|
||||
_results = reconcile_batch_playlists(_MDB(), _batch, download_tasks, deps.config_manager)
|
||||
if not _results:
|
||||
logger.info(
|
||||
f"[Playlist Folder] All-owned: nothing rebuilt for "
|
||||
f"ref={_batch.get('source_playlist_ref') or _batch.get('playlist_id')} "
|
||||
f"source={_batch.get('batch_source')}"
|
||||
)
|
||||
for _pl_name, _mat in _results:
|
||||
logger.info(
|
||||
f"[Playlist Folder] Rebuilt '{_mat.playlist_dir}' (all owned): "
|
||||
f"{_mat.linked} linked, {_mat.copied} copied, {_mat.removed_stale} stale removed"
|
||||
+ (" (symlinks unsupported here → copied)" if _mat.fellback else "")
|
||||
)
|
||||
except Exception as _mat_err:
|
||||
logger.error(f"[Playlist Folder] All-owned materialize failed (non-fatal): {_mat_err}")
|
||||
|
||||
return
|
||||
|
||||
logger.warning(f" transitioning batch {batch_id} to download phase with {len(missing_tracks)} tracks.")
|
||||
|
|
@ -1144,7 +1198,13 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
task_pl_folder_mode = True
|
||||
task_pl_name = wl_pl_name or wl_mirrored.get('name') or batch_playlist_name
|
||||
if task_pl_folder_mode:
|
||||
track_info['_playlist_folder_mode'] = True
|
||||
# Organize-by-playlist now imports each track NORMALLY into the
|
||||
# Artist/Album library (i.e. exactly what a normal download does)
|
||||
# — the playlist folder is built as links/copies AFTER the batch
|
||||
# from the real library files. So we deliberately DON'T set
|
||||
# `_playlist_folder_mode` (which routed the real file into a flat
|
||||
# Music/<playlist>/ dump). We keep `_playlist_name` + source_info
|
||||
# — they're download provenance (core/downloads/origin.py).
|
||||
track_info['_playlist_name'] = task_pl_name
|
||||
if batch_source_playlist_ref:
|
||||
track_info['source_info'] = {
|
||||
|
|
@ -1153,8 +1213,8 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
'source': batch_source,
|
||||
}
|
||||
logger.info(
|
||||
f"[Task Creation] Added playlist folder mode for: "
|
||||
f"{track_info.get('name')} → {task_pl_name}"
|
||||
f"[Task Creation] Organize-by-playlist (normal import + "
|
||||
f"materialize after batch): {track_info.get('name')} → {task_pl_name}"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
|
|
|
|||
|
|
@ -216,7 +216,22 @@ def build_reset_query(
|
|||
table = meta['table']
|
||||
ms = match_status_column(service)
|
||||
la = last_attempted_column(service)
|
||||
set_clause = f"SET {ms} = NULL, {la} = NULL"
|
||||
set_parts = [f"{ms} = NULL", f"{la} = NULL"]
|
||||
|
||||
# Also forget the stored source ID so re-matching actually RE-RESOLVES the
|
||||
# entity. Without this, the worker hits its existing-id short-circuit, sees
|
||||
# the old (possibly WRONG) id and just re-confirms it — which is why "click
|
||||
# to rematch" never fixed a mis-matched same-name artist (#868). Tracks keep
|
||||
# their ids in file tags rather than a column, so only artist/album clear one.
|
||||
if entity_type in ('artist', 'album'):
|
||||
try:
|
||||
from core.source_ids import id_column
|
||||
id_col = id_column(service, entity_type)
|
||||
except Exception:
|
||||
id_col = None
|
||||
if id_col:
|
||||
set_parts.append(f"{id_col} = NULL")
|
||||
set_clause = "SET " + ", ".join(set_parts)
|
||||
|
||||
if scope == 'item':
|
||||
if not entity_id:
|
||||
|
|
|
|||
1
core/exports/__init__.py
Normal file
1
core/exports/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Data export builders."""
|
||||
109
core/exports/artist_export.py
Normal file
109
core/exports/artist_export.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""Export an artist roster — watchlist OR library — to JSON / CSV / plain text
|
||||
(corruption's request).
|
||||
|
||||
Pure shaping + formatting so it's the single source of truth and unit-testable —
|
||||
web_server fetches the artists (normalizing each source's fields onto the canonical
|
||||
``*_artist_id`` keys below) and hands them here; the UI just picks options and
|
||||
downloads. Always exports the name + whatever source IDs each artist has;
|
||||
``include_links`` adds external discography URLs; ``extra_fields`` passes through
|
||||
source-specific extras (e.g. library album/track counts) in a stable order.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# Canonical id field → external URL builder.
|
||||
_LINKS = {
|
||||
'spotify_artist_id': lambda i: f'https://open.spotify.com/artist/{i}',
|
||||
'musicbrainz_artist_id': lambda i: f'https://musicbrainz.org/artist/{i}',
|
||||
'deezer_artist_id': lambda i: f'https://www.deezer.com/artist/{i}',
|
||||
'discogs_artist_id': lambda i: f'https://www.discogs.com/artist/{i}',
|
||||
'itunes_artist_id': lambda i: f'https://music.apple.com/artist/{i}',
|
||||
'tidal_artist_id': lambda i: f'https://tidal.com/artist/{i}',
|
||||
'qobuz_artist_id': lambda i: f'https://www.qobuz.com/artist/{i}',
|
||||
}
|
||||
# Stable order so CSV columns + JSON keys are deterministic. amazon carries an id
|
||||
# but no clean public URL.
|
||||
_ID_FIELDS = ['spotify_artist_id', 'musicbrainz_artist_id', 'deezer_artist_id',
|
||||
'discogs_artist_id', 'itunes_artist_id', 'tidal_artist_id',
|
||||
'qobuz_artist_id', 'amazon_artist_id']
|
||||
|
||||
VALID_FORMATS = ('json', 'csv', 'txt')
|
||||
|
||||
|
||||
def _name(a: Dict[str, Any]) -> str:
|
||||
return str(a.get('artist_name') or a.get('name') or '').strip()
|
||||
|
||||
|
||||
def _short(field: str) -> str:
|
||||
return field.replace('_artist_id', '')
|
||||
|
||||
|
||||
def _row(a: Dict[str, Any], include_links: bool, extra_fields: List[str]) -> Dict[str, Any]:
|
||||
row: Dict[str, Any] = {'name': _name(a)}
|
||||
for f in _ID_FIELDS:
|
||||
if a.get(f):
|
||||
row[f] = str(a[f])
|
||||
for f in extra_fields:
|
||||
if a.get(f) not in (None, ''):
|
||||
row[f] = a[f]
|
||||
if include_links:
|
||||
links = {_short(f): b(a[f]) for f, b in _LINKS.items() if a.get(f)}
|
||||
if links:
|
||||
row['links'] = links
|
||||
return row
|
||||
|
||||
|
||||
def build_artist_export(artists: Optional[List[Dict[str, Any]]],
|
||||
fmt: str = 'json', include_links: bool = False,
|
||||
extra_fields: Optional[List[str]] = None) -> str:
|
||||
"""Return the roster serialized in ``fmt`` (json | csv | txt).
|
||||
|
||||
- ``txt`` → one artist name per line.
|
||||
- ``csv`` → name + each source-id column + ``extra_fields`` columns (+ a
|
||||
*_url column per service when ``include_links``).
|
||||
- ``json`` → a list of objects: name, present source ids, present extras, and
|
||||
a ``links`` map when ``include_links``.
|
||||
"""
|
||||
artists = artists or []
|
||||
extra_fields = list(extra_fields or [])
|
||||
fmt = (fmt or 'json').lower()
|
||||
if fmt not in VALID_FORMATS:
|
||||
fmt = 'json'
|
||||
|
||||
if fmt == 'txt':
|
||||
return '\n'.join(n for n in (_name(a) for a in artists) if n)
|
||||
|
||||
if fmt == 'csv':
|
||||
cols = ['name'] + _ID_FIELDS + extra_fields
|
||||
if include_links:
|
||||
cols += [f'{_short(f)}_url' for f in _LINKS]
|
||||
out = io.StringIO()
|
||||
w = csv.writer(out)
|
||||
w.writerow(cols)
|
||||
for a in artists:
|
||||
line = [_name(a)] + [str(a.get(f) or '') for f in _ID_FIELDS]
|
||||
line += [str(a.get(f) if a.get(f) is not None else '') for f in extra_fields]
|
||||
if include_links:
|
||||
line += [_LINKS[f](a[f]) if a.get(f) else '' for f in _LINKS]
|
||||
w.writerow(line)
|
||||
return out.getvalue()
|
||||
|
||||
return json.dumps([_row(a, include_links, extra_fields) for a in artists],
|
||||
indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def export_mime_and_ext(fmt: str):
|
||||
"""(content-type, file extension) for a format."""
|
||||
return {
|
||||
'json': ('application/json', 'json'),
|
||||
'csv': ('text/csv', 'csv'),
|
||||
'txt': ('text/plain', 'txt'),
|
||||
}.get((fmt or 'json').lower(), ('application/json', 'json'))
|
||||
|
||||
|
||||
__all__ = ['build_artist_export', 'export_mime_and_ext', 'VALID_FORMATS']
|
||||
|
|
@ -92,8 +92,57 @@ DEFAULT_INSTANCES = [
|
|||
'https://hund.qqdl.site',
|
||||
'https://katze.qqdl.site',
|
||||
'https://arran.monochrome.tf',
|
||||
'https://us-west.monochrome.tf', # community-confirmed working (Sokhi)
|
||||
]
|
||||
|
||||
# The default instances as they shipped BEFORE the auto-push mechanism below.
|
||||
# Used as the one-time baseline for the "already offered" set so existing
|
||||
# installs don't get pre-existing defaults they'd deliberately removed
|
||||
# resurrected — only genuinely NEW defaults are pushed.
|
||||
LEGACY_DEFAULTS = [
|
||||
'https://triton.squid.wtf',
|
||||
'https://hifi-one.spotisaver.net',
|
||||
'https://hifi-two.spotisaver.net',
|
||||
'https://hund.qqdl.site',
|
||||
'https://katze.qqdl.site',
|
||||
'https://arran.monochrome.tf',
|
||||
]
|
||||
|
||||
|
||||
def compute_new_default_pushes(all_defaults, offered, legacy_baseline, existing):
|
||||
"""Decide which default instances to auto-add to an EXISTING install.
|
||||
|
||||
A new working instance added to ``DEFAULT_INSTANCES`` should reach everyone,
|
||||
not just fresh installs / people who click "Restore Defaults" — but we must
|
||||
NOT re-add defaults a user deliberately removed.
|
||||
|
||||
The ``offered`` set records every default ever presented to this install.
|
||||
First run (``offered is None``) baselines to ``legacy_baseline`` (the defaults
|
||||
that shipped before tracking), so those are treated as already-offered. Any
|
||||
default NOT in the offered set is genuinely new → added once (unless already
|
||||
present) and recorded.
|
||||
|
||||
Pure: returns ``(urls_to_add, new_offered_list)``. The caller does the I/O.
|
||||
"""
|
||||
def _n(u):
|
||||
return (u or '').rstrip('/')
|
||||
base = list(legacy_baseline) if offered is None else list(offered)
|
||||
offered_set = {_n(u) for u in base}
|
||||
existing_set = {_n(u) for u in (existing or [])}
|
||||
to_add, new_offered = [], list(base)
|
||||
for u in all_defaults:
|
||||
if _n(u) in offered_set:
|
||||
continue
|
||||
offered_set.add(_n(u))
|
||||
new_offered.append(u)
|
||||
if _n(u) not in existing_set:
|
||||
to_add.append(u)
|
||||
return to_add, new_offered
|
||||
|
||||
|
||||
# Run the new-default push at most once per process.
|
||||
_pushed_new_defaults = False
|
||||
|
||||
|
||||
from core.download_plugins.base import DownloadSourcePlugin
|
||||
|
||||
|
|
@ -138,11 +187,41 @@ class HiFiClient(DownloadSourcePlugin):
|
|||
def set_engine(self, engine):
|
||||
self._engine = engine
|
||||
|
||||
def _push_new_default_instances(self, db):
|
||||
"""One-time-per-process: auto-add any genuinely-new default instances to an
|
||||
existing config so a newly-added working instance reaches everyone, not
|
||||
just fresh installs / Restore-Defaults clickers. Never resurrects defaults
|
||||
a user removed (tracked via the persisted 'offered' set)."""
|
||||
global _pushed_new_defaults
|
||||
if _pushed_new_defaults:
|
||||
return
|
||||
try:
|
||||
from config.settings import config_manager
|
||||
offered = config_manager.get('hifi.offered_defaults', None)
|
||||
existing = db.get_all_hifi_instances()
|
||||
to_add, new_offered = compute_new_default_pushes(
|
||||
DEFAULT_INSTANCES, offered, LEGACY_DEFAULTS,
|
||||
[i.get('url') for i in existing],
|
||||
)
|
||||
if to_add:
|
||||
priority = len(existing)
|
||||
for url in to_add:
|
||||
if db.add_hifi_instance(url.rstrip('/'), priority):
|
||||
priority += 1
|
||||
logger.info(f"[HiFi] Auto-added {len(to_add)} new default instance(s) "
|
||||
f"to existing config: {to_add}")
|
||||
if offered is None or to_add:
|
||||
config_manager.set('hifi.offered_defaults', new_offered)
|
||||
_pushed_new_defaults = True
|
||||
except Exception as e:
|
||||
logger.warning(f"[HiFi] new-default auto-push skipped: {e}")
|
||||
|
||||
def _load_instances_from_db(self):
|
||||
try:
|
||||
from database.music_database import get_database
|
||||
db = get_database()
|
||||
db.seed_hifi_instances(DEFAULT_INSTANCES)
|
||||
self._push_new_default_instances(db)
|
||||
rows = db.get_hifi_instances()
|
||||
urls = [r['url'] for r in rows if r['enabled']]
|
||||
if urls:
|
||||
|
|
|
|||
|
|
@ -438,7 +438,6 @@ def build_final_path_for_track(context, artist_context, album_info, file_ext, cr
|
|||
original_search = get_import_original_search(context)
|
||||
album_context = get_import_context_album(context)
|
||||
source = get_import_source(context)
|
||||
playlist_folder_mode = track_info.get("_playlist_folder_mode", False)
|
||||
artist_name = extract_artist_name(artist_context)
|
||||
|
||||
source_info = track_info.get("source_info") or {}
|
||||
|
|
@ -466,40 +465,6 @@ def build_final_path_for_track(context, artist_context, album_info, file_ext, cr
|
|||
total_tracks = (album_context.get("total_tracks", 0) or 0) if album_context else 0
|
||||
album_type_display = get_album_type_display(raw_album_type, total_tracks)
|
||||
|
||||
if playlist_folder_mode:
|
||||
playlist_name = track_info.get("_playlist_name", "Unknown Playlist")
|
||||
track_name = get_import_clean_title(context, default=original_search.get("title", "Unknown Track"))
|
||||
_artists = original_search.get("artists") or track_info.get("artists") or []
|
||||
|
||||
template_context = {
|
||||
"artist": artist_name,
|
||||
"albumartist": artist_name,
|
||||
"album": track_name,
|
||||
"title": track_name,
|
||||
"playlist_name": playlist_name,
|
||||
"track_number": 1,
|
||||
"disc_number": 1,
|
||||
"year": year,
|
||||
"quality": context.get("_audio_quality", ""),
|
||||
"albumtype": album_type_display,
|
||||
"_artists_list": _artists,
|
||||
"_itunes_artist_id": str(artist_context.get("id", "")) if isinstance(artist_context, dict) and str(artist_context.get("id", "")).isdigit() and source == "itunes" else None,
|
||||
}
|
||||
|
||||
folder_path, filename_base = get_file_path_from_template(template_context, "playlist_path")
|
||||
if folder_path and filename_base:
|
||||
final_path = os.path.join(transfer_dir, folder_path, filename_base + file_ext)
|
||||
_ensure_dir(os.path.join(transfer_dir, folder_path), exist_ok=True)
|
||||
return final_path, True
|
||||
|
||||
playlist_name_sanitized = sanitize_filename(playlist_name)
|
||||
playlist_dir = os.path.join(transfer_dir, playlist_name_sanitized)
|
||||
_ensure_dir(playlist_dir, exist_ok=True)
|
||||
artist_name_sanitized = sanitize_filename(template_context["artist"])
|
||||
track_name_sanitized = sanitize_filename(track_name)
|
||||
new_filename = f"{artist_name_sanitized} - {track_name_sanitized}{file_ext}"
|
||||
return os.path.join(playlist_dir, new_filename), True
|
||||
|
||||
if album_info and album_info.get("is_album"):
|
||||
clean_track_name = get_import_clean_title(context, album_info=album_info, default=original_search.get("title", "Unknown Track"))
|
||||
track_number = _coerce_int(album_info.get("track_number", 1), 1)
|
||||
|
|
|
|||
|
|
@ -543,162 +543,10 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
|
|||
|
||||
context['artist'] = artist_context
|
||||
|
||||
playlist_folder_mode = track_info.get("_playlist_folder_mode", False)
|
||||
logger.debug(f"[Debug] Post-processing - track_info type: {type(track_info)}, is None: {track_info is None}, is empty: {not track_info}")
|
||||
logger.debug(f"[Debug] Post-processing - playlist_folder_mode: {playlist_folder_mode}")
|
||||
if track_info:
|
||||
logger.debug(f"[Debug] Post-processing - track_info keys: {list(track_info.keys())}")
|
||||
|
||||
if playlist_folder_mode:
|
||||
playlist_name = track_info.get("_playlist_name", "Unknown Playlist")
|
||||
logger.info(f"[Playlist Folder Mode] Organizing in playlist folder: {playlist_name}")
|
||||
|
||||
file_ext = os.path.splitext(file_path)[1]
|
||||
final_path, _ = build_final_path_for_track(context, artist_context, None, file_ext)
|
||||
logger.info(f"Playlist mode final path: '{final_path}'")
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
if os.path.exists(final_path):
|
||||
logger.info(
|
||||
f"[Playlist Folder Mode] Source gone but destination exists — already processed by another thread: "
|
||||
f"{os.path.basename(final_path)}"
|
||||
)
|
||||
context['_final_processed_path'] = final_path
|
||||
return
|
||||
pp_logger.info(f"[inner] EXCEPTION in post-processing for {context_key}: Source file not found and destination does not exist: {file_path}")
|
||||
raise FileNotFoundError(f"Source file not found and destination does not exist: {file_path}")
|
||||
|
||||
context['_audio_quality'] = get_audio_quality_string(file_path)
|
||||
if context['_audio_quality']:
|
||||
logger.info(f"Audio quality detected: {context['_audio_quality']}")
|
||||
|
||||
_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(
|
||||
file_path,
|
||||
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}")
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except Exception as e:
|
||||
logger.debug("delete quarantine fallback: %s", e)
|
||||
|
||||
context['_bitdepth_rejected'] = True
|
||||
with matched_context_lock:
|
||||
if context_key in matched_downloads_context:
|
||||
del matched_downloads_context[context_key]
|
||||
|
||||
task_id = context.get('task_id')
|
||||
batch_id = context.get('batch_id')
|
||||
if task_id:
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
download_tasks[task_id]['error_message'] = f"Bit depth filter: {rejection_reason}"
|
||||
if task_id and batch_id:
|
||||
_notify_download_completed(batch_id, task_id, success=False)
|
||||
return
|
||||
|
||||
try:
|
||||
logger.warning(
|
||||
f"[Metadata Input] Playlist mode - artist: '{artist_context.get('name', 'MISSING')}' "
|
||||
f"(id: {artist_context.get('id', 'MISSING')})"
|
||||
)
|
||||
enhance_file_metadata(file_path, context, artist_context, None, runtime=metadata_runtime)
|
||||
except Exception as meta_err:
|
||||
import traceback
|
||||
pp_logger.info(f"[inner] Metadata enhancement FAILED for {context_key}: {meta_err}\n{traceback.format_exc()}")
|
||||
if should_wipe_tags_on_enhancement_failure(has_clean_metadata):
|
||||
wipe_source_tags(file_path)
|
||||
else:
|
||||
logger.warning(
|
||||
"[Metadata] Enhancement failed but import has clean/matched metadata — "
|
||||
"preserving the file's existing tags (not wiping): %s",
|
||||
os.path.basename(file_path))
|
||||
|
||||
logger.info(f"Moving '{os.path.basename(file_path)}' to '{final_path}'")
|
||||
safe_move_file(file_path, final_path)
|
||||
context['_final_processed_path'] = final_path
|
||||
cleanup_slskd_dedup_siblings(file_path)
|
||||
|
||||
if config_manager.get('post_processing.replaygain_enabled', False):
|
||||
try:
|
||||
from core.replaygain import analyze_track as _rg_analyze, write_replaygain_tags as _rg_write, is_ffmpeg_available as _rg_ffmpeg_ok, RG_REFERENCE_LUFS as _RG_REF
|
||||
if _rg_ffmpeg_ok():
|
||||
lufs, peak_dbfs = _rg_analyze(final_path)
|
||||
gain_db = _RG_REF - lufs
|
||||
_rg_write(final_path, gain_db, peak_dbfs)
|
||||
pp_logger.info(f"ReplayGain: {gain_db:+.2f} dB — {os.path.basename(final_path)}")
|
||||
except Exception as rg_err:
|
||||
pp_logger.debug(f"ReplayGain analysis skipped: {rg_err}")
|
||||
|
||||
downsampled_path = downsample_hires_flac(final_path, context)
|
||||
if downsampled_path:
|
||||
final_path = downsampled_path
|
||||
context['_final_processed_path'] = final_path
|
||||
|
||||
_persist_verification_status(context, final_path)
|
||||
|
||||
blasphemy_path = create_lossy_copy(final_path)
|
||||
if blasphemy_path:
|
||||
context['_final_processed_path'] = blasphemy_path
|
||||
|
||||
downloads_path = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads'))
|
||||
cleanup_empty_directories(downloads_path, file_path)
|
||||
|
||||
logger.info(f"[Playlist Folder Mode] Post-processing complete: {final_path}")
|
||||
|
||||
try:
|
||||
check_and_remove_from_wishlist(context)
|
||||
except Exception as wishlist_error:
|
||||
logger.error(f"[Playlist Folder] Error checking wishlist removal: {wishlist_error}")
|
||||
|
||||
emit_track_downloaded(context, automation_engine)
|
||||
record_library_history_download(context)
|
||||
record_download_provenance(context)
|
||||
|
||||
try:
|
||||
pf_album_info = build_import_album_info(context, force_album=False)
|
||||
if not pf_album_info or not pf_album_info.get("album_name"):
|
||||
pf_album_info = {
|
||||
"is_album": True,
|
||||
"album_name": playlist_name,
|
||||
"track_number": track_info.get("track_number", 1) or 1,
|
||||
"disc_number": track_info.get("disc_number", 1) or 1,
|
||||
"clean_track_name": get_import_clean_title(
|
||||
context,
|
||||
default=get_import_original_search(context).get("title", "Unknown"),
|
||||
),
|
||||
"source": get_import_source(context) or "spotify",
|
||||
}
|
||||
elif not pf_album_info.get("is_album"):
|
||||
pf_album_info["is_album"] = True
|
||||
record_soulsync_library_entry(context, artist_context, pf_album_info)
|
||||
except Exception as lib_err:
|
||||
logger.error(f"[Playlist Folder] SoulSync library registration failed: {lib_err}")
|
||||
|
||||
task_id = context.get('task_id')
|
||||
batch_id = context.get('batch_id')
|
||||
if task_id and batch_id:
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['stream_processed'] = True
|
||||
download_tasks[task_id]['status'] = 'completed'
|
||||
logger.info(f"[Playlist Folder Mode] Marked task {task_id} as completed")
|
||||
_notify_download_completed(batch_id, task_id, success=True)
|
||||
return
|
||||
|
||||
is_album_download = bool(context.get("is_album_download", False))
|
||||
album_info = build_import_album_info(context, force_album=is_album_download)
|
||||
|
||||
|
|
@ -1026,6 +874,10 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
|
|||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['stream_processed'] = True
|
||||
download_tasks[task_id]['status'] = 'completed'
|
||||
# Additive: record where the imported file landed so downstream
|
||||
# (playlist materialization) knows the real path of a freshly
|
||||
# downloaded track without re-resolving it.
|
||||
download_tasks[task_id]['final_file_path'] = context.get('_final_processed_path')
|
||||
logger.info(f"[Post-Process] Marked task {task_id} as completed")
|
||||
_notify_download_completed(batch_id, task_id, success=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,15 @@ from datetime import datetime, timedelta
|
|||
from utils.logging_config import get_logger
|
||||
from database.music_database import MusicDatabase
|
||||
from core.itunes_client import iTunesClient
|
||||
from core.worker_utils import accept_artist_match, interruptible_sleep, set_album_api_track_count
|
||||
from core.worker_utils import (
|
||||
accept_artist_match,
|
||||
artist_name_matches,
|
||||
interruptible_sleep,
|
||||
owned_album_titles,
|
||||
pick_artist_by_catalog,
|
||||
release_titles,
|
||||
set_album_api_track_count,
|
||||
)
|
||||
from core.enrichment.manual_match_honoring import honor_stored_match
|
||||
|
||||
logger = get_logger("itunes_worker")
|
||||
|
|
@ -392,20 +400,30 @@ class iTunesWorker:
|
|||
logger.debug(f"No iTunes results for artist '{artist_name}'")
|
||||
return
|
||||
|
||||
for artist_obj in results:
|
||||
# Candidates clearing the name gate (results are source-ranked, so [0] is
|
||||
# the legacy "first passing" pick), then disambiguate same-name artists by
|
||||
# which one's catalog overlaps the albums this library owns.
|
||||
gated = [a for a in results if artist_name_matches(artist_name, a.name)]
|
||||
chosen, _overlap = pick_artist_by_catalog(
|
||||
gated,
|
||||
owned_album_titles(self.db, artist_id),
|
||||
lambda a: release_titles(self.client.get_artist_albums(a.id)),
|
||||
)
|
||||
|
||||
if chosen:
|
||||
ok, reason = accept_artist_match(
|
||||
self.db, 'itunes_artist_id', artist_obj.id, artist_id,
|
||||
artist_name, artist_obj.name,
|
||||
self.db, 'itunes_artist_id', chosen.id, artist_id,
|
||||
artist_name, chosen.name,
|
||||
)
|
||||
if ok:
|
||||
if not self._is_itunes_id(artist_obj.id):
|
||||
logger.warning(f"Rejecting non-iTunes ID '{artist_obj.id}' for artist '{artist_name}'")
|
||||
if not self._is_itunes_id(chosen.id):
|
||||
logger.warning(f"Rejecting non-iTunes ID '{chosen.id}' for artist '{artist_name}'")
|
||||
self._mark_status('artist', artist_id, 'error')
|
||||
self.stats['errors'] += 1
|
||||
return
|
||||
self._update_artist(artist_id, artist_obj)
|
||||
self._update_artist(artist_id, chosen)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched artist '{artist_name}' -> iTunes ID: {artist_obj.id}")
|
||||
logger.info(f"Matched artist '{artist_name}' -> iTunes ID: {chosen.id}")
|
||||
return
|
||||
|
||||
self._mark_status('artist', artist_id, 'not_found')
|
||||
|
|
|
|||
|
|
@ -42,4 +42,43 @@ def is_implausible_stale_removal(
|
|||
return missing_count > total_count * max_fraction
|
||||
|
||||
|
||||
__all__ = ["is_implausible_stale_removal", "DEFAULT_MIN_TOTAL", "DEFAULT_MAX_MISSING_FRACTION"]
|
||||
# The orphan detector walks the transfer folder and flags any audio file whose
|
||||
# path/title doesn't resolve to a DB track. If the DB's stored paths share a base
|
||||
# prefix the local filesystem no longer has (remount, Docker volume change, WSL
|
||||
# hiccup), EVERY file misses and the whole library looks "orphaned" — and a user
|
||||
# batch-applying "move to staging" on those findings would relocate their entire
|
||||
# library. Same failure mode as stale-removal, so we skip the whole result when
|
||||
# the orphan share is implausibly large. Needs an absolute floor too: 3/4 orphans
|
||||
# in a tiny folder is normal, 4000/5000 is a path mismatch.
|
||||
DEFAULT_MIN_ORPHANS = 20
|
||||
DEFAULT_MAX_ORPHAN_FRACTION = 0.5
|
||||
|
||||
|
||||
def is_implausible_orphan_flood(
|
||||
orphan_count: int,
|
||||
total_count: int,
|
||||
*,
|
||||
min_orphans: int = DEFAULT_MIN_ORPHANS,
|
||||
max_fraction: float = DEFAULT_MAX_ORPHAN_FRACTION,
|
||||
) -> bool:
|
||||
"""True when so many files look orphaned that the DB↔filesystem path mapping is
|
||||
almost certainly broken (not real orphans) and the scan should create NO
|
||||
findings — otherwise a batch "move to staging" / "delete" could wipe the
|
||||
library. Below ``min_orphans`` (absolute) it always returns False so small,
|
||||
genuine orphan sets still surface.
|
||||
"""
|
||||
if total_count <= 0 or orphan_count <= 0:
|
||||
return False
|
||||
if orphan_count <= min_orphans:
|
||||
return False
|
||||
return orphan_count > total_count * max_fraction
|
||||
|
||||
|
||||
__all__ = [
|
||||
"is_implausible_stale_removal",
|
||||
"is_implausible_orphan_flood",
|
||||
"DEFAULT_MIN_TOTAL",
|
||||
"DEFAULT_MAX_MISSING_FRACTION",
|
||||
"DEFAULT_MIN_ORPHANS",
|
||||
"DEFAULT_MAX_ORPHAN_FRACTION",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -115,7 +115,8 @@ class MusicMatchingEngine:
|
|||
# Replace common separators with spaces to preserve word boundaries.
|
||||
# Include hyphen in separator replacement for artist names like "AC/DC" vs "AC-DC"
|
||||
# Include '&' so "Pig&Dan" becomes "Pig Dan" (matches "Pig & Dan" on Soulseek)
|
||||
text = re.sub(r'[._/&-]', ' ', text)
|
||||
# Include ':' so "T:T" becomes "T T" (matches "T_T" stored with underscores on Soulseek)
|
||||
text = re.sub(r'[._/&:\-]', ' ', text)
|
||||
|
||||
# Keep alphanumeric characters, spaces, AND the '$' sign.
|
||||
# When CJK was detected upstream, also preserve CJK Unified
|
||||
|
|
|
|||
|
|
@ -442,10 +442,18 @@ def get_primary_source_status(
|
|||
connected = bool(client and client.is_spotify_authenticated())
|
||||
# No-auth composite (fallback_source='spotify' + metadata.spotify_free):
|
||||
# works without authentication, so treat the free path's availability
|
||||
# as "connected" too.
|
||||
# as "connected" too. get_client_for_source() returns None when not
|
||||
# officially authed, so fetch the client directly to probe the free
|
||||
# path — otherwise this check can never fire for a no-auth user.
|
||||
if not connected and _get_config_value("metadata.spotify_free", False):
|
||||
free_client = client
|
||||
if free_client is None:
|
||||
try:
|
||||
free_client = get_spotify_client(client_factory=spotify_client_factory)
|
||||
except Exception:
|
||||
free_client = None
|
||||
try:
|
||||
connected = bool(client and client.is_spotify_metadata_available())
|
||||
connected = bool(free_client and free_client.is_spotify_metadata_available())
|
||||
except Exception:
|
||||
connected = False
|
||||
elif source == "hydrabase":
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from datetime import datetime, timedelta
|
|||
from difflib import SequenceMatcher
|
||||
from utils.logging_config import get_logger
|
||||
from core.musicbrainz_client import MusicBrainzClient
|
||||
from core.worker_utils import catalog_overlap_score, pick_artist_by_catalog
|
||||
from database.music_database import MusicDatabase
|
||||
|
||||
logger = get_logger("musicbrainz_service")
|
||||
|
|
@ -117,23 +118,51 @@ class MusicBrainzService:
|
|||
if conn:
|
||||
conn.close()
|
||||
|
||||
def match_artist(self, artist_name: str) -> Optional[Dict[str, Any]]:
|
||||
def _candidate_release_titles(self, mbid: str) -> list:
|
||||
"""Release-group titles for a candidate MBID — the catalog side of
|
||||
same-name artist disambiguation."""
|
||||
if not mbid:
|
||||
return []
|
||||
try:
|
||||
data = self.mb_client.get_artist(mbid, includes=['release-groups'])
|
||||
except Exception:
|
||||
return []
|
||||
groups = (data or {}).get('release-groups') or []
|
||||
return [g.get('title') for g in groups if isinstance(g, dict) and g.get('title')]
|
||||
|
||||
def match_artist(self, artist_name: str, owned_titles: Optional[list] = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Match an artist by name to MusicBrainz
|
||||
|
||||
Match an artist by name to MusicBrainz.
|
||||
|
||||
``owned_titles`` — the library artist's owned album titles. When given and
|
||||
more than one strong same-name candidate exists, the one whose release
|
||||
groups overlap those owned titles is chosen (disambiguates the ~5 "Rone"s);
|
||||
omitted → falls back to the highest-confidence candidate as before.
|
||||
|
||||
Returns:
|
||||
Dict with 'mbid', 'name', 'confidence' or None if no good match
|
||||
"""
|
||||
# Check cache first
|
||||
cached = self._check_cache('artist', artist_name)
|
||||
if cached:
|
||||
logger.debug(f"Cache hit for artist '{artist_name}'")
|
||||
return {
|
||||
'mbid': cached['musicbrainz_id'],
|
||||
'name': artist_name,
|
||||
'confidence': cached['confidence'],
|
||||
'cached': True
|
||||
}
|
||||
cached_mbid = cached.get('musicbrainz_id')
|
||||
# Don't trust a cached mbid whose catalog has ZERO overlap with the
|
||||
# albums this library owns — that's the wrong same-name artist (and a
|
||||
# re-match would otherwise be blocked for up to the 90-day cache TTL,
|
||||
# #868). Fall through to a fresh, disambiguated resolve in that case.
|
||||
stale_wrong_match = bool(
|
||||
cached_mbid and owned_titles
|
||||
and catalog_overlap_score(owned_titles, self._candidate_release_titles(cached_mbid)) == 0
|
||||
)
|
||||
if not stale_wrong_match:
|
||||
logger.debug(f"Cache hit for artist '{artist_name}'")
|
||||
return {
|
||||
'mbid': cached_mbid,
|
||||
'name': artist_name,
|
||||
'confidence': cached['confidence'],
|
||||
'cached': True
|
||||
}
|
||||
logger.debug(f"Cached MB match for '{artist_name}' has no owned-catalog overlap — re-resolving")
|
||||
|
||||
# Search MusicBrainz
|
||||
try:
|
||||
|
|
@ -144,25 +173,30 @@ class MusicBrainzService:
|
|||
self._save_to_cache('artist', artist_name, None, None, None, 0)
|
||||
return None
|
||||
|
||||
# Find best match
|
||||
best_match = None
|
||||
best_confidence = 0
|
||||
|
||||
# Score every candidate (name similarity 60% + MB's own relevance 40%).
|
||||
scored = []
|
||||
for result in results:
|
||||
mb_name = result.get('name', '')
|
||||
mb_score = result.get('score', 0) # MusicBrainz search score
|
||||
|
||||
# Calculate our own similarity
|
||||
similarity = self._calculate_similarity(artist_name, mb_name)
|
||||
|
||||
# Combine MusicBrainz score with our similarity (weighted)
|
||||
# Cap at 100 to prevent edge cases where MB score > 100
|
||||
confidence = min(100, int((similarity * 60) + (mb_score / 100 * 40)))
|
||||
|
||||
if confidence > best_confidence:
|
||||
best_confidence = confidence
|
||||
best_match = result
|
||||
|
||||
scored.append((confidence, result))
|
||||
scored.sort(key=lambda s: s[0], reverse=True)
|
||||
|
||||
# Among the strong (>=70) candidates, disambiguate same-name artists by
|
||||
# which one's release groups overlap the albums this library owns.
|
||||
gated = [r for conf, r in scored if conf >= 70]
|
||||
best_match = None
|
||||
best_confidence = scored[0][0] if scored else 0
|
||||
if gated:
|
||||
chosen, _overlap = pick_artist_by_catalog(
|
||||
gated, owned_titles or [],
|
||||
lambda r: self._candidate_release_titles(r.get('id')),
|
||||
)
|
||||
best_match = chosen
|
||||
best_confidence = next(conf for conf, r in scored if r is chosen)
|
||||
|
||||
# Only return matches with confidence >= 70%
|
||||
if best_match and best_confidence >= 70:
|
||||
mbid = best_match.get('id')
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from datetime import datetime, timedelta
|
|||
from utils.logging_config import get_logger
|
||||
from database.music_database import MusicDatabase
|
||||
from core.musicbrainz_service import MusicBrainzService
|
||||
from core.worker_utils import interruptible_sleep, source_id_conflict
|
||||
from core.worker_utils import interruptible_sleep, owned_album_titles, source_id_conflict
|
||||
|
||||
logger = get_logger("musicbrainz_worker")
|
||||
|
||||
|
|
@ -366,7 +366,8 @@ class MusicBrainzWorker:
|
|||
return
|
||||
|
||||
if item_type == 'artist':
|
||||
result = self.mb_service.match_artist(item_name)
|
||||
result = self.mb_service.match_artist(
|
||||
item_name, owned_titles=owned_album_titles(self.db, item_id))
|
||||
mbid = result.get('mbid') if result else None
|
||||
# MB's combined score can match a weak name ("Grant" -> "Amy
|
||||
# Grant") when its own relevance rank is high. Guard against
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import requests
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlencode
|
||||
|
|
@ -161,6 +162,7 @@ class NavidromeClient(MediaServerClient):
|
|||
self.music_folder_id: Optional[str] = None
|
||||
self._connection_attempted = False
|
||||
self._is_connecting = False
|
||||
self._last_connect_attempt = 0.0 # monotonic time of the last connect try
|
||||
|
||||
# Cache for performance
|
||||
self._artist_cache = {}
|
||||
|
|
@ -245,15 +247,32 @@ class NavidromeClient(MediaServerClient):
|
|||
logger.error(f"Error setting music folder: {e}")
|
||||
return False
|
||||
|
||||
# A failed connect used to latch the client "disconnected" until the user hit
|
||||
# the manual Test button (a transient ping failure nukes the creds in
|
||||
# _setup_client). Re-attempt at most this often so it self-heals on its own.
|
||||
_RECONNECT_THROTTLE_S = 20.0
|
||||
|
||||
def ensure_connection(self) -> bool:
|
||||
"""Ensure connection to Navidrome server with lazy initialization."""
|
||||
if self._connection_attempted:
|
||||
return self.base_url is not None and self.username is not None
|
||||
"""Ensure connection to Navidrome with lazy init + self-healing retry.
|
||||
|
||||
Already connected → return True. A prior FAILED attempt no longer latches
|
||||
forever: once _RECONNECT_THROTTLE_S has elapsed it re-attempts, so a
|
||||
transient ping failure (network blip, Navidrome busy mid-scan) recovers by
|
||||
itself instead of needing the manual "Test" reconnect."""
|
||||
if self.base_url is not None and self.username is not None:
|
||||
return True
|
||||
|
||||
if self._is_connecting:
|
||||
return False
|
||||
|
||||
# Disconnected but attempted recently → don't hammer; let it heal on the
|
||||
# next check past the throttle window.
|
||||
if self._connection_attempted and \
|
||||
(time.monotonic() - self._last_connect_attempt) < self._RECONNECT_THROTTLE_S:
|
||||
return False
|
||||
|
||||
self._is_connecting = True
|
||||
self._last_connect_attempt = time.monotonic()
|
||||
try:
|
||||
self._setup_client()
|
||||
return self.base_url is not None and self.username is not None
|
||||
|
|
@ -462,10 +481,11 @@ class NavidromeClient(MediaServerClient):
|
|||
return None
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if connected to Navidrome server"""
|
||||
if not self._connection_attempted:
|
||||
if not self._is_connecting:
|
||||
self.ensure_connection()
|
||||
"""Connected = configured + last connect OK. When NOT connected, trigger a
|
||||
(throttled) reconnect attempt so the status self-heals instead of staying
|
||||
latched disconnected until a manual Test."""
|
||||
if not (self.base_url and self.username and self.password) and not self._is_connecting:
|
||||
self.ensure_connection()
|
||||
return (self.base_url is not None and
|
||||
self.username is not None and
|
||||
self.password is not None)
|
||||
|
|
|
|||
214
core/playlists/materialize.py
Normal file
214
core/playlists/materialize.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"""Materialize a playlist as a folder of links into the real music library.
|
||||
|
||||
A playlist folder is a **view**, not storage. Every entry points at the one real
|
||||
file that already lives in the library (``Artist/Album/track.ext``); a track is
|
||||
never stored twice no matter how many playlists it's in. Two modes:
|
||||
|
||||
- ``symlink`` (default): a *relative* symlink to the real file — ~zero disk, and
|
||||
relative so the tree stays valid if the parent folder is moved.
|
||||
- ``copy``: a real duplicate of the file — for filesystems/players that can't
|
||||
follow symlinks (FAT USB sticks, some DAPs) or when a self-contained,
|
||||
portable folder is wanted.
|
||||
|
||||
Symlinks silently fail or are unsupported on a lot of real setups (Windows
|
||||
without the privilege, SMB/CIFS shares, FAT/exFAT). So when a symlink can't be
|
||||
created we **fall back to a copy automatically** — the folder is always fully
|
||||
populated, never left with dangling links.
|
||||
|
||||
This module is pure filesystem mechanics: no DB, no app state. Given a list of
|
||||
real file paths, a playlists root, a playlist name and a mode, it (re)builds the
|
||||
folder to match. That makes it the single unit-tested source of truth for "how a
|
||||
playlist folder looks on disk", and means the folder is a *derived view* that can
|
||||
be rebuilt from scratch at any time. Filesystem ops are injectable so the
|
||||
behaviour — including the symlink→copy fallback — is testable without depending
|
||||
on the host filesystem's symlink support.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, List, Optional, Sequence
|
||||
|
||||
from core.imports.paths import sanitize_filename
|
||||
|
||||
MATERIALIZE_MODES = ("symlink", "copy")
|
||||
DEFAULT_MODE = "symlink"
|
||||
|
||||
|
||||
def normalize_mode(mode: Optional[str]) -> str:
|
||||
"""Coerce a config value to a valid mode (``symlink`` default)."""
|
||||
m = (mode or "").strip().lower()
|
||||
return m if m in MATERIALIZE_MODES else DEFAULT_MODE
|
||||
|
||||
|
||||
@dataclass
|
||||
class RebuildSummary:
|
||||
"""Outcome of rebuilding one playlist folder. ``copied`` may be non-zero even
|
||||
in symlink mode when the fallback kicked in (flagged by ``fellback``)."""
|
||||
playlist_dir: str = ""
|
||||
linked: int = 0
|
||||
copied: int = 0
|
||||
unchanged: int = 0
|
||||
removed_stale: int = 0
|
||||
missing_source: int = 0
|
||||
failed: int = 0
|
||||
fellback: bool = False
|
||||
mode_requested: str = DEFAULT_MODE
|
||||
errors: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def playlist_dir_for(playlists_root: str, playlist_name: str) -> str:
|
||||
"""Absolute path of one playlist's folder, sanitized and guaranteed to stay
|
||||
directly under ``playlists_root`` (defends against ``..`` / separators in a
|
||||
playlist name)."""
|
||||
root = os.path.abspath(playlists_root)
|
||||
safe_name = sanitize_filename(playlist_name or "").strip() or "Unnamed Playlist"
|
||||
candidate = os.path.abspath(os.path.join(root, safe_name))
|
||||
if os.path.dirname(candidate) != root:
|
||||
safe_name = sanitize_filename(os.path.basename(candidate)) or "Unnamed Playlist"
|
||||
candidate = os.path.join(root, safe_name)
|
||||
return candidate
|
||||
|
||||
|
||||
def _desired_entries(playlist_dir: str, real_paths: Sequence[str]) -> "list[tuple[str, str]]":
|
||||
"""Map each real file to a flat destination inside ``playlist_dir``, preserving
|
||||
the source filename. On a basename collision between two *different* sources,
|
||||
disambiguate with a numeric suffix rather than silently overwriting."""
|
||||
entries: list[tuple[str, str]] = []
|
||||
used: dict[str, str] = {} # dest basename -> source real path
|
||||
for real in real_paths:
|
||||
if not real:
|
||||
continue
|
||||
base = os.path.basename(real)
|
||||
name = base
|
||||
stem, ext = os.path.splitext(base)
|
||||
counter = 1
|
||||
while name in used and used[name] != os.path.abspath(real):
|
||||
counter += 1
|
||||
name = f"{stem} ({counter}){ext}"
|
||||
used[name] = os.path.abspath(real)
|
||||
entries.append((os.path.abspath(real), os.path.join(playlist_dir, name)))
|
||||
return entries
|
||||
|
||||
|
||||
def _symlink_is_current(dest: str, rel_target: str) -> bool:
|
||||
try:
|
||||
return os.path.islink(dest) and os.readlink(dest) == rel_target
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _remove_entry(path: str) -> None:
|
||||
"""Remove an existing file/symlink at ``path`` (incl. a broken symlink)."""
|
||||
if os.path.islink(path) or os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def materialize_one(
|
||||
real_path: str,
|
||||
dest_path: str,
|
||||
mode: str = DEFAULT_MODE,
|
||||
*,
|
||||
symlink_fn: Callable[[str, str], None] = os.symlink,
|
||||
copy_fn: Callable[[str, str], object] = shutil.copy2,
|
||||
) -> str:
|
||||
"""Create one playlist entry at ``dest_path`` pointing at ``real_path``.
|
||||
|
||||
Idempotent: a correct existing entry is left alone. In ``symlink`` mode a
|
||||
relative link is used; if it can't be created (unsupported FS, no privilege)
|
||||
it falls back to a copy so the entry is never left broken. Returns one of:
|
||||
``'linked'``, ``'copied'``, ``'unchanged'``, ``'fellback'`` (symlink
|
||||
requested but copied), ``'missing'`` (source gone)."""
|
||||
if not real_path or not os.path.exists(real_path):
|
||||
return "missing"
|
||||
|
||||
dest_dir = os.path.dirname(dest_path)
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
rel_target = os.path.relpath(os.path.abspath(real_path), start=dest_dir)
|
||||
|
||||
if mode == "copy":
|
||||
if os.path.isfile(dest_path) and not os.path.islink(dest_path):
|
||||
return "unchanged"
|
||||
_remove_entry(dest_path)
|
||||
copy_fn(real_path, dest_path)
|
||||
return "copied"
|
||||
|
||||
# symlink mode
|
||||
if _symlink_is_current(dest_path, rel_target):
|
||||
return "unchanged"
|
||||
_remove_entry(dest_path)
|
||||
try:
|
||||
symlink_fn(rel_target, dest_path)
|
||||
return "linked"
|
||||
except (OSError, NotImplementedError):
|
||||
copy_fn(real_path, dest_path)
|
||||
return "fellback"
|
||||
|
||||
|
||||
def rebuild_playlist_folder(
|
||||
playlists_root: str,
|
||||
playlist_name: str,
|
||||
real_paths: Sequence[str],
|
||||
mode: str = DEFAULT_MODE,
|
||||
*,
|
||||
prune_stale: bool = True,
|
||||
symlink_fn: Callable[[str, str], None] = os.symlink,
|
||||
copy_fn: Callable[[str, str], object] = shutil.copy2,
|
||||
) -> RebuildSummary:
|
||||
"""(Re)build ``playlists_root/<playlist_name>/`` so it contains exactly one
|
||||
entry per real file in ``real_paths`` — adding missing entries, leaving correct
|
||||
ones untouched, and (when ``prune_stale``) removing entries no longer present.
|
||||
Idempotent and safe to re-run any time. Filesystem ops are injectable."""
|
||||
mode = normalize_mode(mode)
|
||||
pdir = playlist_dir_for(playlists_root, playlist_name)
|
||||
summary = RebuildSummary(playlist_dir=pdir, mode_requested=mode)
|
||||
os.makedirs(pdir, exist_ok=True)
|
||||
|
||||
entries = _desired_entries(pdir, real_paths)
|
||||
keep = {dest for _real, dest in entries}
|
||||
|
||||
for real, dest in entries:
|
||||
try:
|
||||
outcome = materialize_one(real, dest, mode, symlink_fn=symlink_fn, copy_fn=copy_fn)
|
||||
except OSError as e:
|
||||
summary.failed += 1
|
||||
summary.errors.append(f"{os.path.basename(dest)}: {e}")
|
||||
continue
|
||||
if outcome == "linked":
|
||||
summary.linked += 1
|
||||
elif outcome == "copied":
|
||||
summary.copied += 1
|
||||
elif outcome == "fellback":
|
||||
summary.copied += 1
|
||||
summary.fellback = True
|
||||
elif outcome == "unchanged":
|
||||
summary.unchanged += 1
|
||||
elif outcome == "missing":
|
||||
summary.missing_source += 1
|
||||
|
||||
if prune_stale and os.path.isdir(pdir):
|
||||
for name in os.listdir(pdir):
|
||||
full = os.path.join(pdir, name)
|
||||
if full in keep:
|
||||
continue
|
||||
if os.path.islink(full) or os.path.isfile(full):
|
||||
_remove_entry(full)
|
||||
summary.removed_stale += 1
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MATERIALIZE_MODES",
|
||||
"DEFAULT_MODE",
|
||||
"normalize_mode",
|
||||
"RebuildSummary",
|
||||
"playlist_dir_for",
|
||||
"materialize_one",
|
||||
"rebuild_playlist_folder",
|
||||
]
|
||||
215
core/playlists/materialize_service.py
Normal file
215
core/playlists/materialize_service.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
"""Build a playlist's materialized folder from a FINISHED organize-by-playlist
|
||||
download batch.
|
||||
|
||||
The batch already carries the real on-disk location of every resolved track:
|
||||
|
||||
- **owned** tracks → ``analysis_results[*]['matched_file_path']`` (the library
|
||||
matcher's result, captured during analysis), and
|
||||
- **downloaded** tracks → ``download_tasks[tid]['final_file_path']`` (where the
|
||||
import landed).
|
||||
|
||||
So this is pure stitching + filesystem work: **no re-matching, no source-ID
|
||||
lookup, no mirrored-playlist resolution**. It works for any organize-by-playlist
|
||||
download, mirrored or not, and for the all-owned case (where nothing downloads).
|
||||
|
||||
Each stored path is run through ``resolve_library_file_path`` — the same resolver
|
||||
playback uses — so a DB path that's host-formatted (Docker) maps to the real file
|
||||
the container can see; a freshly-downloaded path already exists and passes
|
||||
through unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from core.imports.paths import docker_resolve_path
|
||||
from core.playlists.materialize import (
|
||||
RebuildSummary,
|
||||
normalize_mode,
|
||||
rebuild_playlist_folder,
|
||||
)
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("playlists.materialize")
|
||||
|
||||
|
||||
def collect_batch_real_paths(batch: dict, download_tasks: dict, *, config_manager) -> List[str]:
|
||||
"""Real on-disk paths of every track in a finished batch that resolved —
|
||||
owned (from analysis) + downloaded (from completed tasks) — resolved to this
|
||||
process's filesystem and de-duplicated, in playlist order then download order."""
|
||||
from core.library.path_resolver import resolve_library_file_path
|
||||
|
||||
out: List[str] = []
|
||||
seen = set()
|
||||
|
||||
def _add(stored_path: Any) -> None:
|
||||
if not stored_path:
|
||||
return
|
||||
real = resolve_library_file_path(str(stored_path), config_manager=config_manager)
|
||||
if real and real not in seen:
|
||||
seen.add(real)
|
||||
out.append(real)
|
||||
|
||||
for res in (batch.get("analysis_results") or []):
|
||||
if res.get("found"):
|
||||
_add(res.get("matched_file_path"))
|
||||
|
||||
for tid in (batch.get("queue") or []):
|
||||
task = (download_tasks or {}).get(tid) or {}
|
||||
if task.get("status") == "completed":
|
||||
_add(task.get("final_file_path"))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def materialize_playlist_from_batch(batch: dict, download_tasks: dict, config_manager) -> Optional[RebuildSummary]:
|
||||
"""(Re)build the playlist folder for a finished organize-by-playlist batch.
|
||||
|
||||
Returns the :class:`RebuildSummary`, or ``None`` when the batch isn't an
|
||||
organize-by-playlist batch. Reads ``playlists.materialize_path`` /
|
||||
``playlists.materialize_mode`` from config."""
|
||||
if not batch or not batch.get("playlist_folder_mode"):
|
||||
return None
|
||||
name = batch.get("playlist_name") or "Unknown Playlist"
|
||||
real_paths = collect_batch_real_paths(batch, download_tasks, config_manager=config_manager)
|
||||
root = docker_resolve_path(config_manager.get("playlists.materialize_path", "./Playlists"))
|
||||
mode = normalize_mode(config_manager.get("playlists.materialize_mode", "symlink"))
|
||||
return rebuild_playlist_folder(root, name, real_paths, mode)
|
||||
|
||||
|
||||
def reconcile_batch_playlists(db, batch: dict, download_tasks: dict, config_manager, *, profile_id: int = 1):
|
||||
"""One post-batch step: rebuild every organize-by-playlist playlist this batch
|
||||
TOUCHED, from CURRENT library ownership.
|
||||
|
||||
Touched playlists = the batch's own organize playlist + any playlist a completed
|
||||
track belongs to (via the per-track ``source_info`` provenance — covers a
|
||||
wishlist batch fulfilling a track that belongs to an organize playlist). Each is
|
||||
rebuilt via ``_rebuild_one_from_db`` (``check_track_exists`` over its membership),
|
||||
so it's robust to HOW a track imported — modal worker, slskd monitor, or the
|
||||
verification worker, which don't all set the same task fields. It simply asks the
|
||||
library what's owned, and prunes tracks that have left the playlist.
|
||||
|
||||
Returns ``[(playlist_name, RebuildSummary)]``. Callers wrap non-fatally."""
|
||||
import json as _json
|
||||
|
||||
# Collect the (ref, source) of every organize playlist this batch touched.
|
||||
# (ref, source, force). force=True for the batch's OWN playlist: the per-download
|
||||
# "organize by playlist" toggle (batch playlist_folder_mode) is the user's
|
||||
# intent, so it rebuilds regardless of the saved row preference. Provenance
|
||||
# playlists (force=False) require the saved organize_by_playlist flag.
|
||||
wanted, seen_ref = [], set()
|
||||
|
||||
def _want(ref, source, force):
|
||||
ref = str(ref or "").strip()
|
||||
if not ref:
|
||||
return
|
||||
key = (ref, source or "spotify")
|
||||
if key not in seen_ref:
|
||||
seen_ref.add(key)
|
||||
wanted.append((ref, source or "spotify", force))
|
||||
|
||||
if batch.get("playlist_folder_mode"):
|
||||
_want(batch.get("source_playlist_ref") or batch.get("playlist_id"),
|
||||
batch.get("batch_source") or "spotify", True)
|
||||
|
||||
for tid in (batch.get("queue") or []):
|
||||
task = (download_tasks or {}).get(tid) or {}
|
||||
if task.get("status") != "completed":
|
||||
continue
|
||||
si = (task.get("track_info") or {}).get("source_info") or {}
|
||||
if isinstance(si, str):
|
||||
try:
|
||||
si = _json.loads(si)
|
||||
except Exception:
|
||||
si = {}
|
||||
if isinstance(si, dict) and si.get("playlist_id"):
|
||||
_want(si["playlist_id"], si.get("source") or "spotify", False)
|
||||
|
||||
results, seen_id = [], set()
|
||||
for ref, source, force in wanted:
|
||||
try:
|
||||
pl = db.resolve_mirrored_playlist(ref, profile_id, default_source=source)
|
||||
except Exception as e:
|
||||
logger.debug("[Playlist Folder] resolve failed for ref=%s source=%s: %s", ref, source, e)
|
||||
pl = None
|
||||
if not pl:
|
||||
logger.info("[Playlist Folder] no mirrored playlist resolved for ref=%s source=%s "
|
||||
"(force=%s) — folder not built", ref, source, force)
|
||||
continue
|
||||
if pl.get("id") in seen_id:
|
||||
continue
|
||||
if not force and not pl.get("organize_by_playlist"):
|
||||
continue
|
||||
seen_id.add(pl.get("id"))
|
||||
try:
|
||||
results.append(_rebuild_one_from_db(db, config_manager, pl))
|
||||
except Exception as e:
|
||||
logger.error("[Playlist Folder] rebuild failed for '%s': %s", pl.get("name"), e)
|
||||
continue
|
||||
return results
|
||||
|
||||
|
||||
def _rebuild_one_from_db(db, config_manager, playlist: dict):
|
||||
"""Rebuild ONE playlist's folder from its CURRENT membership × ownership —
|
||||
re-matching each member via the app's own ``check_track_exists`` (by name, not
|
||||
source IDs), resolving to disk, and rebuilding WITH prune. Because it's driven
|
||||
by current membership, a track that has LEFT the playlist drops out of the set
|
||||
and its symlink is pruned. Returns ``(playlist_name, RebuildSummary)``."""
|
||||
from core.library.path_resolver import resolve_library_file_path
|
||||
|
||||
root = docker_resolve_path(config_manager.get("playlists.materialize_path", "./Playlists"))
|
||||
mode = normalize_mode(config_manager.get("playlists.materialize_mode", "symlink"))
|
||||
real_paths: List[str] = []
|
||||
seen = set()
|
||||
for t in (db.get_mirrored_playlist_tracks(playlist["id"]) or []):
|
||||
title = (t.get("track_name") or "").strip()
|
||||
artist = (t.get("artist_name") or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
try:
|
||||
db_track, conf = db.check_track_exists(title, artist, confidence_threshold=0.7)
|
||||
except Exception:
|
||||
continue
|
||||
if db_track is None or conf < 0.7:
|
||||
continue
|
||||
real = resolve_library_file_path(getattr(db_track, "file_path", None), config_manager=config_manager)
|
||||
if real and real not in seen:
|
||||
seen.add(real)
|
||||
real_paths.append(real)
|
||||
name = playlist.get("name") or "Unnamed Playlist"
|
||||
return name, rebuild_playlist_folder(root, name, real_paths, mode) # prune_stale=True
|
||||
|
||||
|
||||
def rebuild_organized_playlists_from_db(db, config_manager, *, profile_id: int = 1):
|
||||
"""Rebuild EVERY "organize by playlist" folder from current library ownership —
|
||||
for the manual "Rebuild" button. Self-heals after a library reorganize moves
|
||||
files or membership changes. Returns a list of ``(playlist_name, summary)``."""
|
||||
return [
|
||||
_rebuild_one_from_db(db, config_manager, pl)
|
||||
for pl in (db.get_mirrored_playlists(profile_id) or [])
|
||||
if pl.get("organize_by_playlist")
|
||||
]
|
||||
|
||||
|
||||
def rebuild_mirrored_playlist_if_organized(db, config_manager, playlist_id, *, profile_id: int = 1):
|
||||
"""Mirror-update hook: after a playlist's membership is re-synced, rebuild its
|
||||
folder (with prune) IF it's organize-by-playlist — so a track that just LEFT
|
||||
the playlist has its symlink cleaned up the instant membership changes (the
|
||||
mirror image of the post-download reconcile that handles additions). Returns
|
||||
the summary, or ``None`` when the playlist isn't organized / can't be found."""
|
||||
if playlist_id is None:
|
||||
return None
|
||||
pl = db.get_mirrored_playlist(playlist_id)
|
||||
if not pl or not pl.get("organize_by_playlist"):
|
||||
return None
|
||||
_name, summary = _rebuild_one_from_db(db, config_manager, pl)
|
||||
return summary
|
||||
|
||||
|
||||
__all__ = [
|
||||
"collect_batch_real_paths",
|
||||
"materialize_playlist_from_batch",
|
||||
"reconcile_batch_playlists",
|
||||
"rebuild_organized_playlists_from_db",
|
||||
"rebuild_mirrored_playlist_if_organized",
|
||||
]
|
||||
30
core/playlists/naming.py
Normal file
30
core/playlists/naming.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""Effective name for a mirrored playlist.
|
||||
|
||||
A mirrored playlist tracks an upstream playlist, so its ``name`` column is
|
||||
rewritten from upstream on every refresh (see ``mirror_playlist``). Users can set
|
||||
a ``custom_name`` alias that overrides what's shown in the UI and what's used when
|
||||
syncing the playlist to the media server — while staying tied to the original
|
||||
(the upstream ``name`` keeps tracking; the alias just overrides the visible/synced
|
||||
label, and lives in its own column so refresh never clobbers it).
|
||||
|
||||
This module is the single, pure source of truth for "which name wins", so the API
|
||||
payload, the card, and the sync path all agree.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
def effective_mirrored_name(playlist: Mapping[str, Any]) -> str:
|
||||
"""Return the name to DISPLAY + SYNC: the user's ``custom_name`` alias when
|
||||
set (non-blank), otherwise the upstream ``name``. Always returns a string."""
|
||||
if not isinstance(playlist, Mapping):
|
||||
return ''
|
||||
custom = str(playlist.get('custom_name') or '').strip()
|
||||
if custom:
|
||||
return custom
|
||||
return str(playlist.get('name') or '').strip()
|
||||
|
||||
|
||||
__all__ = ['effective_mirrored_name']
|
||||
|
|
@ -19,6 +19,31 @@ from urllib.parse import parse_qs, urlparse
|
|||
|
||||
_SPOTIFY_ID_RE = re.compile(r"^[A-Za-z0-9]{16,32}$")
|
||||
|
||||
# Synthetic batch playlist_id prefixes that wrap a mirrored_playlists PK.
|
||||
# Download/discovery flows build a batch playlist_id as f"{prefix}{pk}" — e.g.
|
||||
# auto_mirror_<pk> (core/automation/handlers/sync_playlist.py), youtube_mirrored_<pk>
|
||||
# (YouTube discovery), and mirrored_<pk> (web_server url hashes). The trailing
|
||||
# digits are the mirrored_playlists primary key, NOT an upstream source id, so a
|
||||
# (source, source_playlist_id) lookup will never match them.
|
||||
_MIRRORED_PK_PREFIXES = ("youtube_mirrored_", "auto_mirror_", "mirrored_")
|
||||
|
||||
|
||||
def extract_mirrored_pk(playlist_ref: object) -> Optional[int]:
|
||||
"""Return the mirrored_playlists PK from a synthetic batch ref, else None.
|
||||
|
||||
Handles the synthetic forms above plus a bare numeric ref. Anything else
|
||||
(a real upstream source id) returns None so the caller falls back to a
|
||||
(source, source_playlist_id) lookup.
|
||||
"""
|
||||
ref = str(playlist_ref or "").strip()
|
||||
if not ref:
|
||||
return None
|
||||
for prefix in _MIRRORED_PK_PREFIXES:
|
||||
if ref.startswith(prefix):
|
||||
tail = ref[len(prefix):]
|
||||
return int(tail) if tail.isdigit() else None
|
||||
return int(ref) if ref.isdigit() else None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MirroredSourceRef:
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ _JOB_MODULES = [
|
|||
'core.repair_jobs.acoustid_scanner',
|
||||
'core.repair_jobs.missing_cover_art',
|
||||
'core.repair_jobs.missing_lyrics',
|
||||
'core.repair_jobs.replaygain_filler',
|
||||
'core.repair_jobs.empty_folder_cleaner',
|
||||
'core.repair_jobs.expired_download_cleaner',
|
||||
'core.repair_jobs.metadata_gap_filler',
|
||||
'core.repair_jobs.album_completeness',
|
||||
|
|
@ -48,6 +50,7 @@ _JOB_MODULES = [
|
|||
'core.repair_jobs.discography_backfill',
|
||||
'core.repair_jobs.canonical_version_resolve',
|
||||
'core.repair_jobs.library_retag',
|
||||
'core.repair_jobs.quality_upgrade',
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
195
core/repair_jobs/empty_folder_cleaner.py
Normal file
195
core/repair_jobs/empty_folder_cleaner.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
"""Empty Folder Cleaner maintenance job (corruption's request).
|
||||
|
||||
After imports, relocations, and deletions, the music library accumulates empty
|
||||
artist/album folders (and folders left holding only OS junk like .DS_Store). This
|
||||
scans the library root and flags folders that are safe to remove, so the library
|
||||
stays tidy.
|
||||
|
||||
Safety is the whole point — deleting directories is destructive:
|
||||
- only TRULY empty folders (no real files) are ever flagged; a folder with a
|
||||
cover.jpg or any audio is never touched,
|
||||
- optionally folders holding *only* OS-junk files (.DS_Store, Thumbs.db, …),
|
||||
- the library root itself is never removed, nor symlinked directories,
|
||||
- it walks bottom-up so a parent left empty by its (removable) children cascades,
|
||||
- and the apply handler RE-CHECKS emptiness at delete time, so anything that
|
||||
gained a file between scan and apply is left alone.
|
||||
|
||||
``dir_is_removable`` is the pure decision seam — unit-tested independent of the FS.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Iterable, List
|
||||
|
||||
from core.repair_jobs import register_job
|
||||
from core.repair_jobs.base import JobContext, JobResult, RepairJob
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("repair_jobs.empty_folder_cleaner")
|
||||
|
||||
# Files that don't count as real content — safe to delete along with the folder.
|
||||
JUNK_FILES = {'.ds_store', 'thumbs.db', 'desktop.ini', '.directory', 'album.nfo~'}
|
||||
|
||||
|
||||
def is_junk(name: str) -> bool:
|
||||
return (name or '').lower() in JUNK_FILES
|
||||
|
||||
|
||||
def dir_is_removable(files: Iterable[str], surviving_subdirs: Iterable[str],
|
||||
*, ignore_junk: bool = True) -> bool:
|
||||
"""Pure: is a directory safe to remove?
|
||||
|
||||
Removable iff it has **no surviving subdirectories** and **no real files** —
|
||||
where "no real files" means literally empty, or (when ``ignore_junk``) only
|
||||
OS-junk files. ``surviving_subdirs`` is the list of child dirs that are NOT
|
||||
themselves being removed (i.e. still hold content).
|
||||
"""
|
||||
if list(surviving_subdirs):
|
||||
return False
|
||||
files = list(files)
|
||||
if not files:
|
||||
return True
|
||||
if not ignore_junk:
|
||||
return False
|
||||
return all(is_junk(f) for f in files)
|
||||
|
||||
|
||||
@register_job
|
||||
class EmptyFolderCleanerJob(RepairJob):
|
||||
job_id = 'empty_folder_cleaner'
|
||||
display_name = 'Empty Folder Cleaner'
|
||||
description = 'Finds empty (or junk-only) folders in the library and removes them'
|
||||
help_text = (
|
||||
'Scans your music library for empty folders left behind after imports, '
|
||||
'relocations, and deletions — empty artist/album folders, or folders that '
|
||||
'hold only OS junk like .DS_Store / Thumbs.db.\n\n'
|
||||
'A finding is created for each. Applying one deletes the folder (after '
|
||||
're-checking it is still empty). Folders that contain any real file — a '
|
||||
'cover image, an audio track, anything — are never touched, the library '
|
||||
'root is never removed, and it cascades: a folder left empty once its '
|
||||
'empty children are removed is cleaned too.'
|
||||
)
|
||||
icon = 'repair-icon-folder'
|
||||
default_enabled = False
|
||||
default_interval_hours = 168 # weekly — empties accrue slowly
|
||||
default_settings = {'remove_junk_files': True}
|
||||
auto_fix = False
|
||||
|
||||
def scan(self, context: JobContext) -> JobResult:
|
||||
result = JobResult()
|
||||
|
||||
root = context.transfer_folder
|
||||
if not root or not os.path.isdir(root):
|
||||
logger.info("[Empty Folder Cleaner] library root not available — skipping")
|
||||
return result
|
||||
root = os.path.realpath(root)
|
||||
|
||||
ignore_junk = True
|
||||
try:
|
||||
if context.config_manager:
|
||||
ignore_junk = bool(context.config_manager.get(
|
||||
'repair.jobs.empty_folder_cleaner.remove_junk_files', True))
|
||||
except Exception: # noqa: S110 — setting read is best-effort; defaults to True
|
||||
pass
|
||||
|
||||
flagged = set() # dir paths we'd remove → a parent sees them as "gone"
|
||||
|
||||
# topdown=False ⇒ deepest first, so children are decided before parents.
|
||||
for dirpath, dirnames, filenames in os.walk(root, topdown=False):
|
||||
if context.check_stop():
|
||||
return result
|
||||
real = os.path.realpath(dirpath)
|
||||
if real == root:
|
||||
continue # never the library root itself
|
||||
if os.path.islink(dirpath):
|
||||
continue # don't delete symlinked dirs
|
||||
result.scanned += 1
|
||||
|
||||
surviving = [d for d in dirnames
|
||||
if os.path.join(dirpath, d) not in flagged]
|
||||
if not dir_is_removable(filenames, surviving, ignore_junk=ignore_junk):
|
||||
result.skipped += 1
|
||||
continue
|
||||
|
||||
flagged.add(dirpath)
|
||||
junk = [f for f in filenames if is_junk(f)]
|
||||
rel = os.path.relpath(dirpath, root)
|
||||
if context.report_progress:
|
||||
context.report_progress(log_line=f'Empty folder: {rel}', log_type='info')
|
||||
if context.create_finding:
|
||||
try:
|
||||
inserted = context.create_finding(
|
||||
job_id=self.job_id,
|
||||
finding_type='empty_folder',
|
||||
severity='info',
|
||||
entity_type='folder',
|
||||
entity_id=dirpath,
|
||||
file_path=dirpath,
|
||||
title=f'Empty folder: {os.path.basename(dirpath) or rel}',
|
||||
description=(f'"{rel}" holds no music'
|
||||
+ (f' (only {len(junk)} junk file(s))' if junk else '')
|
||||
+ ' — safe to remove.'),
|
||||
details={
|
||||
'folder_path': dirpath,
|
||||
'junk_files': junk,
|
||||
'remove_junk': ignore_junk,
|
||||
})
|
||||
if inserted:
|
||||
result.findings_created += 1
|
||||
else:
|
||||
result.findings_skipped_dedup += 1
|
||||
except Exception as e:
|
||||
logger.debug("[Empty Folder Cleaner] create finding failed for %s: %s", dirpath, e)
|
||||
result.errors += 1
|
||||
|
||||
logger.info("[Empty Folder Cleaner] %d folders scanned, %d empty flagged",
|
||||
result.scanned, result.findings_created)
|
||||
return result
|
||||
|
||||
def estimate_scope(self, context: JobContext) -> int:
|
||||
root = context.transfer_folder
|
||||
if not root or not os.path.isdir(root):
|
||||
return 0
|
||||
total = 0
|
||||
for _dp, dirnames, _f in os.walk(root):
|
||||
total += len(dirnames)
|
||||
return total
|
||||
|
||||
|
||||
def remove_empty_folder(folder_path: str, *, junk_files: List[str], remove_junk: bool,
|
||||
root: str, listdir, isdir, islink, remove_file, rmdir) -> dict:
|
||||
"""Pure-ish orchestration for the apply handler — RE-CHECKS the folder is still
|
||||
removable, then deletes any junk + the folder. Effects injected for testing.
|
||||
|
||||
Returns ``{'removed': bool, 'error': str|None}``. Refuses to touch the root, a
|
||||
symlink, a non-dir, or a folder that gained real content since the scan.
|
||||
"""
|
||||
if not folder_path or not isdir(folder_path):
|
||||
return {'removed': False, 'error': 'Folder no longer exists'}
|
||||
if islink(folder_path):
|
||||
return {'removed': False, 'error': 'Refusing to remove a symlinked folder'}
|
||||
if root and os.path.realpath(folder_path) == os.path.realpath(root):
|
||||
return {'removed': False, 'error': 'Refusing to remove the library root'}
|
||||
|
||||
# Re-check at apply time: only junk/empty now? (Anything else = leave it.)
|
||||
entries = list(listdir(folder_path))
|
||||
real_entries = [e for e in entries if not (remove_junk and is_junk(e))]
|
||||
if real_entries:
|
||||
return {'removed': False, 'error': 'Folder is no longer empty — left untouched'}
|
||||
|
||||
if remove_junk:
|
||||
for j in entries:
|
||||
if is_junk(j):
|
||||
try:
|
||||
remove_file(os.path.join(folder_path, j))
|
||||
except Exception: # noqa: S110 — junk best-effort; rmdir below fails loudly if blocked
|
||||
pass
|
||||
try:
|
||||
rmdir(folder_path)
|
||||
except Exception as e:
|
||||
return {'removed': False, 'error': f'Could not remove folder: {e}'}
|
||||
return {'removed': True, 'error': None}
|
||||
|
||||
|
||||
__all__ = ['dir_is_removable', 'is_junk', 'remove_empty_folder', 'EmptyFolderCleanerJob']
|
||||
|
|
@ -161,6 +161,16 @@ class LibraryReorganizeJob(RepairJob):
|
|||
album_id = album_row['id']
|
||||
album_title = album_row['title'] or 'Unknown Album'
|
||||
|
||||
# Default to the API planner (authoritative metadata via source IDs).
|
||||
# But media-server libraries usually have NO source IDs, so that path
|
||||
# dead-ends at 'no_source_id' and the job only ever reports "needs
|
||||
# enrichment" without moving anything (#862). Reorganizing to match
|
||||
# the template only needs the metadata already on the files — so when
|
||||
# the API planner can't resolve a source, fall back to TAG mode, which
|
||||
# reads each file's embedded title/artist/album/year (the year is what
|
||||
# the user's "($year) $album" template needs). Only genuinely tag-poor
|
||||
# albums then fall through to a finding.
|
||||
reorg_metadata_source = 'api'
|
||||
try:
|
||||
preview = preview_album_reorganize(
|
||||
album_id=str(album_id),
|
||||
|
|
@ -169,6 +179,18 @@ class LibraryReorganizeJob(RepairJob):
|
|||
resolve_file_path_fn=_resolve,
|
||||
build_final_path_fn=build_final_path_for_track,
|
||||
)
|
||||
if preview.get('status') == 'no_source_id':
|
||||
tags_preview = preview_album_reorganize(
|
||||
album_id=str(album_id),
|
||||
db=context.db,
|
||||
transfer_dir=transfer_dir,
|
||||
resolve_file_path_fn=_resolve,
|
||||
build_final_path_fn=build_final_path_for_track,
|
||||
metadata_source='tags',
|
||||
)
|
||||
if tags_preview.get('status') == 'planned':
|
||||
preview = tags_preview
|
||||
reorg_metadata_source = 'tags'
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Reorganize preview failed for album %s ('%s'): %s",
|
||||
|
|
@ -189,9 +211,10 @@ class LibraryReorganizeJob(RepairJob):
|
|||
continue
|
||||
|
||||
if status == 'no_source_id':
|
||||
# Can't compute destinations without a metadata source —
|
||||
# skip cleanly with a single finding rather than 12 per-track
|
||||
# "no source" findings that would clutter the UI.
|
||||
# Reached only when BOTH the API planner (no source ID) AND the
|
||||
# tag-mode fallback (files missing essential title/artist/album
|
||||
# tags, or not on disk) failed — so no destination can be computed.
|
||||
# One album-level finding rather than N per-track ones (UI clutter).
|
||||
result.skipped += len(tracks) or 1
|
||||
if dry_run and context.create_finding and tracks:
|
||||
inserted = context.create_finding(
|
||||
|
|
@ -201,12 +224,13 @@ class LibraryReorganizeJob(RepairJob):
|
|||
entity_type='album',
|
||||
entity_id=str(album_id),
|
||||
file_path=None,
|
||||
title=f'Needs enrichment: {album_title}',
|
||||
title=f'Cannot place: {album_title}',
|
||||
description=(
|
||||
f"Album '{album_title}' by {preview.get('artist', '?')} "
|
||||
"has no metadata source ID — run enrichment first to "
|
||||
"populate at least one of spotify_album_id / "
|
||||
"itunes_album_id / deezer_id / discogs_id / soul_id."
|
||||
"couldn't be reorganized: it has no metadata source ID "
|
||||
"AND its files are missing essential tags (title / artist "
|
||||
"/ album) or aren't on disk. Re-tag the files or run "
|
||||
"'Fix Unknown Artists', then run this job again."
|
||||
),
|
||||
details={'album_id': str(album_id), 'reason': 'no_source_id'},
|
||||
)
|
||||
|
|
@ -280,6 +304,10 @@ class LibraryReorganizeJob(RepairJob):
|
|||
'artist_id': str(album_row.get('artist_id') or ''),
|
||||
'artist_name': preview.get('artist') or album_row.get('artist_name') or 'Unknown Artist',
|
||||
'source': preview.get('source'),
|
||||
# Carry the mode the preview actually used so the live move
|
||||
# matches it — otherwise the queue runner defaults to 'api'
|
||||
# and a tag-mode-only album would fail at apply time (#862).
|
||||
'metadata_source': reorg_metadata_source,
|
||||
})
|
||||
|
||||
if context.update_progress and (i + 1) % 25 == 0:
|
||||
|
|
|
|||
|
|
@ -218,17 +218,33 @@ class OrphanFileDetectorJob(RepairJob):
|
|||
if context.update_progress and (i + 1) % 50 == 0:
|
||||
context.update_progress(i + 1, total)
|
||||
|
||||
# Safety check: if most files look like orphans, it's probably a path
|
||||
# mismatch between the DB and filesystem — not actual orphans.
|
||||
orphan_ratio = len(orphan_files) / total if total else 0
|
||||
mass_orphan = orphan_ratio > 0.5 and len(orphan_files) > 20
|
||||
|
||||
if mass_orphan:
|
||||
# Safety: if most files look like orphans, it's almost certainly a path
|
||||
# mismatch between the DB and filesystem (remount / Docker volume change),
|
||||
# NOT real orphans. Creating findings anyway is dangerous — a user batch-
|
||||
# applying "move to staging" / "delete" on them would relocate or wipe the
|
||||
# whole library. So we create NO findings here, the same hard skip the
|
||||
# stale-removal paths use. Fix the path mismatch and real orphans surface.
|
||||
from core.library.stale_guard import is_implausible_orphan_flood
|
||||
if is_implausible_orphan_flood(len(orphan_files), total):
|
||||
pct = (len(orphan_files) / total * 100) if total else 0
|
||||
logger.warning(
|
||||
"Mass orphan warning: %d of %d files (%.0f%%) flagged as orphans — "
|
||||
"this likely indicates a DB path mismatch, not actual orphans",
|
||||
len(orphan_files), total, orphan_ratio * 100
|
||||
"Mass orphan guard: %d of %d files (%.0f%%) flagged as orphans — "
|
||||
"almost certainly a DB↔filesystem path mismatch, not real orphans. "
|
||||
"Creating no findings so a batch move/delete can't wipe the library.",
|
||||
len(orphan_files), total, pct,
|
||||
)
|
||||
if context.report_progress:
|
||||
context.report_progress(
|
||||
log_line=(f'Skipped: {len(orphan_files)} of {total} files look '
|
||||
'orphaned — likely a DB path mismatch, not real orphans. '
|
||||
'No findings created.'),
|
||||
log_type='skip',
|
||||
)
|
||||
if context.update_progress:
|
||||
context.update_progress(total, total)
|
||||
logger.info("Orphan file scan: %d files scanned, mass-orphan guard tripped "
|
||||
"(0 findings)", result.scanned)
|
||||
return result
|
||||
|
||||
for fpath in orphan_files:
|
||||
if context.report_progress:
|
||||
|
|
@ -243,16 +259,12 @@ class OrphanFileDetectorJob(RepairJob):
|
|||
inserted = context.create_finding(
|
||||
job_id=self.job_id,
|
||||
finding_type='orphan_file',
|
||||
severity='warning' if mass_orphan else 'info',
|
||||
severity='info',
|
||||
entity_type='file',
|
||||
entity_id=None,
|
||||
file_path=fpath,
|
||||
title=f'Orphan file: {os.path.basename(fpath)}',
|
||||
description=(
|
||||
'Audio file in transfer folder is not tracked in the database. '
|
||||
'WARNING: Mass orphan detection triggered — this may be a path '
|
||||
'mismatch, not actual orphans. Verify before deleting!'
|
||||
) if mass_orphan else (
|
||||
'Audio file in transfer folder is not tracked in the database'
|
||||
),
|
||||
details={
|
||||
|
|
@ -261,7 +273,6 @@ class OrphanFileDetectorJob(RepairJob):
|
|||
'modified': time.strftime('%Y-%m-%d %H:%M:%S',
|
||||
time.localtime(stat.st_mtime)),
|
||||
'folder': os.path.dirname(fpath),
|
||||
'mass_orphan': mass_orphan,
|
||||
}
|
||||
)
|
||||
if inserted:
|
||||
|
|
|
|||
720
core/repair_jobs/quality_upgrade.py
Normal file
720
core/repair_jobs/quality_upgrade.py
Normal file
|
|
@ -0,0 +1,720 @@
|
|||
"""Quality Upgrade Finder maintenance job.
|
||||
|
||||
Replaces the old auto-acting "Quality Scanner" tool. That tool decided quality
|
||||
purely by file EXTENSION (so a 128 kbps MP3 and a 320 kbps MP3 looked identical),
|
||||
ignored the bitrate-based quality profile, and silently dumped every match
|
||||
straight into the wishlist with no review — which, on the default profile, meant
|
||||
flagging an entire non-lossless library at once.
|
||||
|
||||
This job does it the way the rest of the app works: it SCANS (watchlist artists
|
||||
or the whole library), judges each track against the user's quality profile using
|
||||
BOTH format and bitrate, and for anything below the preferred quality it searches
|
||||
the configured metadata source for a better version and emits a FINDING. Nothing
|
||||
is queued until you review and Apply the finding — at which point the matched
|
||||
track (carrying its album context) is added to the wishlist, exactly like every
|
||||
other acquisition path.
|
||||
|
||||
The quality decision (``meets_preferred_quality``) is a pure function so it can be
|
||||
unit-tested without a database or network. Transcode/"fake lossless" detection is
|
||||
intentionally NOT done here — that's the separate Fake Lossless Detector job.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from core.metadata.registry import get_client_for_source, get_primary_source, get_source_priority
|
||||
from core.repair_jobs import register_job
|
||||
from core.repair_jobs.base import JobContext, JobResult, RepairJob
|
||||
# Reuse the (tested) provider search + result-normalization helpers from the old
|
||||
# scanner module so matching stays a single source of truth.
|
||||
from core.discovery.quality_scanner import (
|
||||
_extract_lookup_value,
|
||||
_normalize_track_match,
|
||||
_search_tracks_for_source,
|
||||
_track_artist_names,
|
||||
_track_name,
|
||||
)
|
||||
from core.library.file_tags import read_embedded_tags
|
||||
from core.library.path_resolver import resolve_library_file_path
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("repair_jobs.quality_upgrade")
|
||||
|
||||
|
||||
# Quality ranks — higher is better. Lossless tops everything; lossy tiers fall out
|
||||
# of bitrate. 0 means "below the lowest tracked tier / unknown".
|
||||
RANK_LOSSLESS = 4
|
||||
RANK_320 = 3
|
||||
RANK_256 = 2
|
||||
RANK_192 = 1
|
||||
RANK_BELOW = 0
|
||||
|
||||
LOSSLESS_EXTENSIONS = {'.flac', '.alac', '.ape', '.wav', '.aiff', '.aif', '.dsf', '.dff', '.m4a'}
|
||||
# NB: .m4a is ambiguous (ALAC vs AAC); we treat the *format* as lossy-capable and
|
||||
# rely on bitrate below — a true ALAC .m4a reports a lossless-scale bitrate.
|
||||
|
||||
# Quality-profile bucket key -> rank.
|
||||
_PROFILE_KEY_RANK = {
|
||||
'flac': RANK_LOSSLESS,
|
||||
'mp3_320': RANK_320,
|
||||
'mp3_256': RANK_256,
|
||||
'mp3_192': RANK_192,
|
||||
}
|
||||
|
||||
# Per-source file-tag key holding that source's own track ID (written by enrichment).
|
||||
_SOURCE_TRACK_ID_TAG = {
|
||||
'spotify': 'spotify_track_id',
|
||||
'deezer': 'deezer_track_id',
|
||||
'itunes': 'itunes_track_id',
|
||||
'audiodb': 'audiodb_track_id',
|
||||
'musicbrainz': 'musicbrainz_releasetrackid',
|
||||
'tidal': 'tidal_track_id',
|
||||
}
|
||||
|
||||
# Reject a fuzzy candidate whose length differs from ours by more than this (ms) —
|
||||
# catches wrong versions (live/edit/remix) that share a title. Exact tiers skip it.
|
||||
_DURATION_TOLERANCE_MS = 5000
|
||||
|
||||
|
||||
def _normalize_kbps(bitrate: Optional[int]) -> Optional[int]:
|
||||
"""Library bitrate may be stored in bps (e.g. 320000) or kbps (320).
|
||||
Normalize to kbps. Returns None when unknown/zero."""
|
||||
if not bitrate:
|
||||
return None
|
||||
try:
|
||||
b = int(bitrate)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if b <= 0:
|
||||
return None
|
||||
return b // 1000 if b > 4000 else b
|
||||
|
||||
|
||||
def classify_track_quality(file_path: str, bitrate: Optional[int]) -> Optional[int]:
|
||||
"""Rank a file by format + bitrate. Returns a RANK_* value, or None when it
|
||||
can't be judged (a lossy file with no known bitrate)."""
|
||||
ext = os.path.splitext(file_path or '')[1].lower()
|
||||
kbps = _normalize_kbps(bitrate)
|
||||
|
||||
# Lossless containers: a real lossless file has a high bitrate; a low one is a
|
||||
# lossy stream in a lossless container — but flagging that is the Fake Lossless
|
||||
# Detector's job, so here we treat the lossless *format* as top rank.
|
||||
if ext in {'.flac', '.alac', '.ape', '.wav', '.aiff', '.aif', '.dsf', '.dff'}:
|
||||
return RANK_LOSSLESS
|
||||
# .m4a / lossy: judge purely by bitrate. A lossless-scale bitrate (ALAC in m4a,
|
||||
# or a mislabeled lossless) ranks as lossless.
|
||||
if kbps is None:
|
||||
return None
|
||||
if kbps >= 800:
|
||||
return RANK_LOSSLESS
|
||||
if kbps >= 280:
|
||||
return RANK_320
|
||||
if kbps >= 200:
|
||||
return RANK_256
|
||||
if kbps >= 150:
|
||||
return RANK_192
|
||||
return RANK_BELOW
|
||||
|
||||
|
||||
def preferred_quality_floor(quality_profile: Dict[str, Any]) -> Optional[int]:
|
||||
"""The lowest acceptable quality rank from the profile's ENABLED buckets — the
|
||||
floor a track must meet. Returns None when nothing is enabled (caller should
|
||||
then flag nothing, rather than flagging everything)."""
|
||||
qualities = (quality_profile or {}).get('qualities', {}) or {}
|
||||
enabled_ranks = [
|
||||
_PROFILE_KEY_RANK[key]
|
||||
for key, cfg in qualities.items()
|
||||
if isinstance(cfg, dict) and cfg.get('enabled') and key in _PROFILE_KEY_RANK
|
||||
]
|
||||
if not enabled_ranks:
|
||||
return None
|
||||
return min(enabled_ranks)
|
||||
|
||||
|
||||
def meets_preferred_quality(file_path: str, bitrate: Optional[int],
|
||||
quality_profile: Dict[str, Any]) -> bool:
|
||||
"""Pure decision: does this track already meet the user's preferred quality?
|
||||
|
||||
A track meets quality when its format+bitrate rank is at least the profile's
|
||||
floor (the worst quality the user still accepts). This honors a profile that
|
||||
enables, say, FLAC *and* MP3-320: a 320 kbps MP3 passes, a 128 kbps MP3 does
|
||||
not. With nothing enabled, everything passes (we never flag the whole library
|
||||
on an empty profile)."""
|
||||
floor = preferred_quality_floor(quality_profile)
|
||||
if floor is None:
|
||||
return True
|
||||
|
||||
file_rank = classify_track_quality(file_path, bitrate)
|
||||
if file_rank is None:
|
||||
# Lossy file with unknown bitrate: only judgeable when the floor is
|
||||
# lossless (then any lossy file is below it). Otherwise don't flag.
|
||||
ext = os.path.splitext(file_path or '')[1].lower()
|
||||
if floor == RANK_LOSSLESS and ext not in LOSSLESS_EXTENSIONS:
|
||||
return False
|
||||
return True
|
||||
|
||||
return file_rank >= floor
|
||||
|
||||
|
||||
def _rank_label(rank: Optional[int]) -> str:
|
||||
return {
|
||||
RANK_LOSSLESS: 'Lossless', RANK_320: 'MP3 320', RANK_256: 'MP3 256',
|
||||
RANK_192: 'MP3 192', RANK_BELOW: 'low bitrate',
|
||||
}.get(rank, 'unknown')
|
||||
|
||||
|
||||
def _norm_isrc(value: Any) -> str:
|
||||
"""Canonicalize an ISRC for comparison: uppercase, strip dashes/spaces."""
|
||||
if not value:
|
||||
return ''
|
||||
return str(value).upper().replace('-', '').replace(' ', '').strip()
|
||||
|
||||
|
||||
def _read_file_ids(file_path: str) -> Dict[str, str]:
|
||||
"""Read the identifiers enrichment embedded in the file's tags.
|
||||
|
||||
Enrichment matches every track to the metadata sources and writes the IDs
|
||||
(ISRC + per-source track IDs) into the file — so an already-enriched track
|
||||
carries its exact identity. Returns a dict with a normalized ``isrc`` plus any
|
||||
``<source>_track_id`` tags present; empty dict when unreadable / not enriched."""
|
||||
resolved = resolve_library_file_path(file_path) if file_path else None
|
||||
if not resolved and file_path and os.path.isfile(file_path):
|
||||
resolved = file_path
|
||||
if not resolved:
|
||||
return {}
|
||||
try:
|
||||
info = read_embedded_tags(resolved)
|
||||
except Exception:
|
||||
return {}
|
||||
if not info or not info.get('available'):
|
||||
return {}
|
||||
tags = info.get('tags') or {}
|
||||
out: Dict[str, str] = {}
|
||||
isrc = _norm_isrc(tags.get('isrc'))
|
||||
if isrc:
|
||||
out['isrc'] = isrc
|
||||
for tag_key in set(_SOURCE_TRACK_ID_TAG.values()):
|
||||
val = tags.get(tag_key)
|
||||
if val:
|
||||
out[tag_key] = str(val)
|
||||
return out
|
||||
|
||||
|
||||
def _duration_ok(want_ms: Any, got_ms: Any, tolerance_ms: int = _DURATION_TOLERANCE_MS) -> bool:
|
||||
"""Wrong-version guard: True when the candidate's length is within tolerance of
|
||||
ours — or when either length is unknown (never reject on missing data)."""
|
||||
try:
|
||||
w, g = int(want_ms or 0), int(got_ms or 0)
|
||||
except (TypeError, ValueError):
|
||||
return True
|
||||
if w <= 0 or g <= 0:
|
||||
return True
|
||||
return abs(w - g) <= tolerance_ms
|
||||
|
||||
|
||||
def _match_via_track_id(file_ids: Dict[str, str],
|
||||
source_priority: List[str]) -> Tuple[Optional[Any], Optional[str]]:
|
||||
"""Most-direct path: enrichment already wrote this track's per-source IDs into
|
||||
the file. If we have the active source's own track ID, fetch that exact track by
|
||||
ID — no search at all. Returns (track, source) or (None, None)."""
|
||||
for source in source_priority:
|
||||
tag_key = _SOURCE_TRACK_ID_TAG.get(source)
|
||||
track_id = file_ids.get(tag_key) if tag_key else None
|
||||
if not track_id:
|
||||
continue
|
||||
client = get_client_for_source(source)
|
||||
if not client or not hasattr(client, 'get_track_details'):
|
||||
continue
|
||||
try:
|
||||
track = client.get_track_details(str(track_id))
|
||||
except Exception:
|
||||
track = None
|
||||
if track:
|
||||
return track, source
|
||||
return None, None
|
||||
|
||||
|
||||
def _candidate_isrc(cand: Any) -> str:
|
||||
"""Pull an ISRC off a provider search result (Track / dict), checking the
|
||||
common shapes: a flat ``isrc`` or a nested ``external_ids.isrc``."""
|
||||
direct = _extract_lookup_value(cand, 'isrc')
|
||||
if direct:
|
||||
return _norm_isrc(direct)
|
||||
ext = _extract_lookup_value(cand, 'external_ids')
|
||||
if isinstance(ext, dict):
|
||||
return _norm_isrc(ext.get('isrc'))
|
||||
return ''
|
||||
|
||||
|
||||
def _match_via_isrc(isrc: str, source_priority: List[str]) -> Tuple[Optional[Any], Optional[str]]:
|
||||
"""Exact-match a track by its ISRC via each source's ``isrc:`` search.
|
||||
|
||||
ISRC is the universal cross-source recording key, so this resolves the EXACT
|
||||
track (with its real album) instead of fuzzy-matching by name. Guarded: only
|
||||
a candidate whose own ISRC equals ours is accepted, so a source that ignores
|
||||
the ``isrc:`` syntax and returns unrelated hits can't produce a false match.
|
||||
Returns (track, source) or (None, None)."""
|
||||
if not isrc:
|
||||
return None, None
|
||||
for source in source_priority:
|
||||
client = get_client_for_source(source)
|
||||
if not client or not hasattr(client, 'search_tracks'):
|
||||
continue
|
||||
try:
|
||||
results = _search_tracks_for_source(source, f'isrc:{isrc}', limit=5, client=client)
|
||||
except Exception:
|
||||
results = []
|
||||
for cand in results or []:
|
||||
if _candidate_isrc(cand) == isrc:
|
||||
return cand, source
|
||||
return None, None
|
||||
|
||||
|
||||
# Column order for the _load_tracks SELECT — rows come back as dicts keyed by these.
|
||||
_TRACK_COLS = (
|
||||
'id', 'title', 'file_path', 'bitrate', 'duration', 'artist_name', 'album_title',
|
||||
'album_id', 'track_number', 'spotify_album_id', 'itunes_album_id', 'deezer_id',
|
||||
'musicbrainz_release_id', 'audiodb_id',
|
||||
)
|
||||
|
||||
# Human-readable note per match tier (search uses a confidence % instead).
|
||||
_MATCH_NOTE = {
|
||||
'track_id': 'exact track ID', 'isrc': 'exact ISRC match',
|
||||
'album': 'matched within album',
|
||||
}
|
||||
|
||||
# Per-source column holding that source's album ID on the albums table.
|
||||
_SOURCE_ALBUM_ID_COL = {
|
||||
'spotify': 'spotify_album_id',
|
||||
'itunes': 'itunes_album_id',
|
||||
'deezer': 'deezer_id',
|
||||
'musicbrainz': 'musicbrainz_release_id',
|
||||
'audiodb': 'audiodb_id',
|
||||
}
|
||||
|
||||
|
||||
def _norm_title(value: Any) -> str:
|
||||
"""Collapse a title to alphanumerics for tolerant comparison."""
|
||||
return ''.join(ch for ch in str(value or '').lower() if ch.isalnum())
|
||||
|
||||
|
||||
def _find_track_in_album(items: Any, title: str, track_number: Any, engine: Any,
|
||||
want_duration_ms: Any = None) -> Optional[Any]:
|
||||
"""Pick the track in an album's tracklist that matches ours — exact normalized
|
||||
title first (track_number then duration break ties), then a high-similarity
|
||||
fuzzy fallback that respects the duration guard."""
|
||||
want = _norm_title(title)
|
||||
exact = []
|
||||
best, best_score = None, 0.0
|
||||
for it in items or []:
|
||||
it_name = _extract_lookup_value(it, 'name', 'title', default='')
|
||||
if want and _norm_title(it_name) == want:
|
||||
exact.append(it)
|
||||
continue
|
||||
if engine and it_name:
|
||||
if not _duration_ok(want_duration_ms, _extract_lookup_value(it, 'duration_ms', 'duration')):
|
||||
continue
|
||||
score = engine.similarity_score(
|
||||
engine.normalize_string(title), engine.normalize_string(it_name))
|
||||
if score > best_score and score >= 0.85:
|
||||
best, best_score = it, score
|
||||
if exact:
|
||||
if track_number:
|
||||
for it in exact:
|
||||
if _extract_lookup_value(it, 'track_number') == track_number:
|
||||
return it
|
||||
# Multiple same-title cuts (e.g. album + live): prefer the closest length.
|
||||
if want_duration_ms and len(exact) > 1:
|
||||
exact.sort(key=lambda it: abs(int(want_duration_ms) - int(
|
||||
_extract_lookup_value(it, 'duration_ms', 'duration', default=0) or 0)))
|
||||
return exact[0]
|
||||
return best
|
||||
|
||||
|
||||
def _match_via_album(engine: Any, source_priority: List[str], artist: str, album_title: str,
|
||||
title: str, track_number: Any, stored_album_ids: Dict[str, str],
|
||||
want_duration_ms: Any = None) -> Tuple[Optional[Any], Optional[str]]:
|
||||
"""Structured artist → album → track match. For each source: use the album's
|
||||
stored source ID if we already have it (enriched album), else find the album
|
||||
by searching ``artist album``; then pull that album's tracklist and locate our
|
||||
track in it. This pins the right album (exact context) without needing the
|
||||
track itself to be enriched. Returns (track, source) or (None, None)."""
|
||||
if not album_title:
|
||||
return None, None
|
||||
for source in source_priority:
|
||||
client = get_client_for_source(source)
|
||||
if not client or not hasattr(client, 'get_album_tracks'):
|
||||
continue
|
||||
|
||||
album_id = stored_album_ids.get(source)
|
||||
album_name = album_title
|
||||
if not album_id and hasattr(client, 'search_albums'):
|
||||
try:
|
||||
albums = client.search_albums(f'{artist} {album_title}'.strip(), limit=5)
|
||||
except Exception:
|
||||
albums = []
|
||||
best_alb, best_s = None, 0.0
|
||||
for alb in albums or []:
|
||||
aname = _extract_lookup_value(alb, 'name', 'title', default='')
|
||||
s = engine.similarity_score(
|
||||
engine.normalize_string(album_title), engine.normalize_string(aname))
|
||||
if s > best_s and s >= 0.80:
|
||||
best_alb, best_s = alb, s
|
||||
if best_alb is not None:
|
||||
album_id = _extract_lookup_value(best_alb, 'id')
|
||||
album_name = _extract_lookup_value(best_alb, 'name', 'title', default=album_title)
|
||||
if not album_id:
|
||||
continue
|
||||
|
||||
try:
|
||||
resp = client.get_album_tracks(str(album_id))
|
||||
except Exception:
|
||||
resp = None
|
||||
items = resp.get('items') if isinstance(resp, dict) else None
|
||||
match = _find_track_in_album(items, title, track_number, engine, want_duration_ms)
|
||||
if match is None:
|
||||
continue
|
||||
# The album tracklist's tracks usually omit the album object — attach it so
|
||||
# the wishlist add carries the correct album context.
|
||||
if isinstance(match, dict):
|
||||
alb = match.get('album')
|
||||
if not isinstance(alb, dict) or not alb.get('name'):
|
||||
match['album'] = {'name': album_name, 'images': []}
|
||||
return match, source
|
||||
return None, None
|
||||
|
||||
|
||||
def _find_best_match(engine: Any, source_priority: List[str], title: str, artist: str,
|
||||
album: str, min_confidence: float,
|
||||
want_duration_ms: Any = None) -> Tuple[Optional[Any], float, Optional[str], bool]:
|
||||
"""Search the configured metadata sources for the best replacement match.
|
||||
Returns (best_track, confidence, source, attempted_any_provider)."""
|
||||
temp_track = type('TempTrack', (), {'name': title, 'artists': [artist], 'album': album})()
|
||||
queries = engine.generate_download_queries(temp_track)
|
||||
|
||||
best, best_conf, best_src = None, 0.0, None
|
||||
attempted = False
|
||||
for query in queries:
|
||||
for source in source_priority:
|
||||
client = get_client_for_source(source)
|
||||
if not client or not hasattr(client, 'search_tracks'):
|
||||
continue
|
||||
attempted = True
|
||||
matches = _search_tracks_for_source(source, query, limit=5, client=client)
|
||||
time.sleep(0.5) # be gentle on metadata APIs
|
||||
for cand in matches or []:
|
||||
# Wrong-version guard: a candidate whose length is way off is a
|
||||
# different cut (live/edit/remix) — reject before it can win.
|
||||
if not _duration_ok(want_duration_ms, _extract_lookup_value(cand, 'duration_ms', 'duration')):
|
||||
continue
|
||||
cand_artists = _track_artist_names(cand)
|
||||
artist_conf = max(
|
||||
(engine.similarity_score(engine.normalize_string(artist),
|
||||
engine.normalize_string(n)) for n in cand_artists),
|
||||
default=0.0,
|
||||
)
|
||||
title_conf = engine.similarity_score(
|
||||
engine.normalize_string(title), engine.normalize_string(_track_name(cand)))
|
||||
conf = artist_conf * 0.5 + title_conf * 0.5
|
||||
album_type = _extract_lookup_value(cand, 'album_type', default='') or ''
|
||||
if album_type == 'album':
|
||||
conf += 0.02
|
||||
elif album_type == 'ep':
|
||||
conf += 0.01
|
||||
if conf > best_conf and conf >= min_confidence:
|
||||
best, best_conf, best_src = cand, conf, source
|
||||
if best_conf >= 0.9:
|
||||
break
|
||||
if best_conf >= 0.9:
|
||||
break
|
||||
return best, best_conf, best_src, attempted
|
||||
|
||||
|
||||
@register_job
|
||||
class QualityUpgradeJob(RepairJob):
|
||||
job_id = 'quality_upgrade'
|
||||
display_name = 'Quality Upgrade Finder'
|
||||
description = 'Finds library tracks below your preferred quality and proposes a better version'
|
||||
help_text = (
|
||||
'Scans your library (or just your watchlist artists) and compares each '
|
||||
"track against your Quality Profile using BOTH the file format and its "
|
||||
'bitrate — so a 128 kbps MP3 is no longer treated the same as a 320 kbps '
|
||||
'one, and enabling MP3-320/256 in your profile actually counts.\n\n'
|
||||
'For every track below your preferred quality it resolves the exact better '
|
||||
'version using the most precise identity available, in order: the source '
|
||||
"track ID enrichment wrote into the file → the file's ISRC → the album's "
|
||||
'tracklist (by stored album ID or album search) → a name/artist search. The '
|
||||
'fuzzy steps also reject candidates whose length is off (wrong live/edit cut). '
|
||||
'It skips tracks it already proposed, so re-runs are cheap. Nothing is queued '
|
||||
'automatically: applying a finding adds that matched track — with its album '
|
||||
'context — to the wishlist, the same as any other download.\n\n'
|
||||
'Settings:\n'
|
||||
'- Scope: "watchlist" (watchlisted artists only) or "all" (whole library)\n'
|
||||
'- Min confidence: minimum match confidence (0-1) to surface a finding\n\n'
|
||||
'Note: detecting fake/transcoded lossless files is handled by the separate '
|
||||
'Fake Lossless Detector job.'
|
||||
)
|
||||
icon = 'repair-icon-lossy'
|
||||
default_enabled = False
|
||||
default_interval_hours = 168
|
||||
default_settings = {'scope': 'watchlist', 'min_confidence': 0.7}
|
||||
setting_options = {'scope': ['watchlist', 'all']}
|
||||
auto_fix = False
|
||||
|
||||
def _get_settings(self, context: JobContext) -> Dict[str, Any]:
|
||||
cfg = context.config_manager
|
||||
scope = 'watchlist'
|
||||
min_conf = 0.7
|
||||
if cfg:
|
||||
scope = cfg.get(self.get_config_key('settings.scope'), 'watchlist') or 'watchlist'
|
||||
try:
|
||||
min_conf = float(cfg.get(self.get_config_key('settings.min_confidence'), 0.7))
|
||||
except (TypeError, ValueError):
|
||||
min_conf = 0.7
|
||||
return {'scope': scope, 'min_confidence': min_conf}
|
||||
|
||||
def _load_tracks(self, db: Any, scope: str) -> List[dict]:
|
||||
conn = db._get_connection()
|
||||
try:
|
||||
base = (
|
||||
"SELECT t.id, t.title, t.file_path, t.bitrate, t.duration, "
|
||||
"a.name AS artist_name, al.title AS album_title, t.album_id, t.track_number, "
|
||||
"al.spotify_album_id, al.itunes_album_id, al.deezer_id, "
|
||||
"al.musicbrainz_release_id, al.audiodb_id "
|
||||
"FROM tracks t "
|
||||
"JOIN artists a ON t.artist_id = a.id "
|
||||
"JOIN albums al ON t.album_id = al.id "
|
||||
"WHERE t.file_path IS NOT NULL AND t.file_path != ''"
|
||||
)
|
||||
if scope == 'watchlist':
|
||||
artists = db.get_watchlist_artists(profile_id=1)
|
||||
names = [getattr(ar, 'artist_name', None) for ar in artists]
|
||||
names = [n for n in names if n]
|
||||
if not names:
|
||||
return []
|
||||
placeholders = ','.join('?' for _ in names)
|
||||
rows = conn.execute(
|
||||
base + f" AND a.name IN ({placeholders})", names).fetchall()
|
||||
else:
|
||||
rows = conn.execute(base).fetchall()
|
||||
return [dict(zip(_TRACK_COLS, r, strict=False)) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _load_existing_finding_ids(self, db: Any) -> set:
|
||||
"""Track IDs that already have a finding for this job (any status). Lets a
|
||||
re-run skip tracks we've already proposed/dismissed without re-hitting the
|
||||
metadata API — pending stays deduped, and a dismissed track stays dismissed."""
|
||||
conn = db._get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT entity_id FROM repair_findings WHERE job_id = ? AND entity_type = 'track'",
|
||||
(self.job_id,)).fetchall()
|
||||
return {str(r[0]) for r in rows if r and r[0] is not None}
|
||||
except Exception:
|
||||
return set()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def estimate_scope(self, context: JobContext) -> int:
|
||||
try:
|
||||
return len(self._load_tracks(context.db, self._get_settings(context)['scope']))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def scan(self, context: JobContext) -> JobResult:
|
||||
result = JobResult()
|
||||
settings = self._get_settings(context)
|
||||
scope = settings['scope']
|
||||
min_conf = settings['min_confidence']
|
||||
|
||||
db = context.db
|
||||
quality_profile = db.get_quality_profile()
|
||||
if preferred_quality_floor(quality_profile) is None:
|
||||
logger.info("[Quality Upgrade] No quality buckets enabled in profile — nothing to flag")
|
||||
return result
|
||||
|
||||
try:
|
||||
tracks = self._load_tracks(db, scope)
|
||||
except Exception as e:
|
||||
logger.error("[Quality Upgrade] Error loading tracks: %s", e, exc_info=True)
|
||||
result.errors += 1
|
||||
return result
|
||||
|
||||
total = len(tracks)
|
||||
if context.update_progress:
|
||||
context.update_progress(0, total)
|
||||
if context.report_progress:
|
||||
context.report_progress(phase=f'Checking quality on {total} tracks...', total=total)
|
||||
|
||||
# Tracks we've already proposed/dismissed — skip them so a re-run doesn't
|
||||
# re-resolve the same tracks against the metadata API.
|
||||
already_found = self._load_existing_finding_ids(db)
|
||||
|
||||
# Metadata source for matching — resolved lazily so we only fail if we
|
||||
# actually find a low-quality track that needs a match.
|
||||
engine = None
|
||||
source_priority: List[str] = []
|
||||
|
||||
for i, row in enumerate(tracks):
|
||||
if context.check_stop():
|
||||
return result
|
||||
if i % 10 == 0 and context.wait_if_paused():
|
||||
return result
|
||||
|
||||
track_id = row['id']
|
||||
title = row['title']
|
||||
file_path = row['file_path']
|
||||
bitrate = row['bitrate']
|
||||
duration_ms = row.get('duration')
|
||||
artist_name = row['artist_name']
|
||||
album_title = row['album_title']
|
||||
album_id = row['album_id']
|
||||
track_number = row.get('track_number')
|
||||
stored_album_ids = {
|
||||
src: row[col] for src, col in _SOURCE_ALBUM_ID_COL.items() if row.get(col)
|
||||
}
|
||||
result.scanned += 1
|
||||
|
||||
if str(track_id) in already_found:
|
||||
result.findings_skipped_dedup += 1
|
||||
continue
|
||||
|
||||
if meets_preferred_quality(file_path, bitrate, quality_profile):
|
||||
result.skipped += 1
|
||||
if context.update_progress and (i + 1) % 25 == 0:
|
||||
context.update_progress(i + 1, total)
|
||||
continue
|
||||
|
||||
# Below preferred quality — find a better version to propose.
|
||||
if engine is None:
|
||||
from core.matching_engine import MusicMatchingEngine
|
||||
engine = MusicMatchingEngine()
|
||||
source_priority = get_source_priority(get_primary_source()) or []
|
||||
if not source_priority:
|
||||
logger.warning("[Quality Upgrade] No metadata provider available — cannot propose upgrades")
|
||||
return result
|
||||
|
||||
if context.is_spotify_rate_limited():
|
||||
logger.info("[Quality Upgrade] Spotify rate-limited — stopping scan early")
|
||||
return result
|
||||
|
||||
current_rank = classify_track_quality(file_path, bitrate)
|
||||
current_label = _rank_label(current_rank)
|
||||
if context.report_progress:
|
||||
context.report_progress(
|
||||
scanned=i + 1, total=total,
|
||||
log_line=f'Low quality ({current_label}): {artist_name} - {title}',
|
||||
log_type='info')
|
||||
|
||||
# Read the identifiers enrichment embedded in the file once (ISRC +
|
||||
# per-source track IDs), used by the two most-exact tiers below.
|
||||
file_ids = _read_file_ids(file_path)
|
||||
|
||||
# Tiered match, best identity first, loosest last:
|
||||
# 0. The active source's OWN track ID, embedded in the file by
|
||||
# enrichment → fetch that exact track by ID. No search at all.
|
||||
# 1. ISRC (also in the tags) → exact track on any source.
|
||||
# 2. Album → track: stored album source ID if we have it (enriched
|
||||
# album), else find the album by search, then locate our track in
|
||||
# its tracklist. Pins the right album even when the track itself
|
||||
# isn't enriched. (artist → album → track)
|
||||
# 3. Plain artist+title search with similarity scoring. (artist → track)
|
||||
# The fuzzy tiers (2-3) also apply a duration guard to reject wrong cuts.
|
||||
best, source, conf, attempted = None, None, 0.0, False
|
||||
|
||||
matched_via = 'track_id'
|
||||
best, source = _match_via_track_id(file_ids, source_priority)
|
||||
if best:
|
||||
conf, attempted = 1.0, True
|
||||
|
||||
if not best:
|
||||
matched_via = 'isrc'
|
||||
best, source = _match_via_isrc(file_ids.get('isrc', ''), source_priority)
|
||||
if best:
|
||||
conf, attempted = 1.0, True
|
||||
|
||||
if not best:
|
||||
matched_via = 'album'
|
||||
try:
|
||||
best, source = _match_via_album(
|
||||
engine, source_priority, artist_name or '', album_title or '',
|
||||
title, track_number, stored_album_ids, duration_ms)
|
||||
except Exception as e:
|
||||
logger.debug("[Quality Upgrade] Album match error for %s - %s: %s", artist_name, title, e)
|
||||
best = None
|
||||
if best:
|
||||
conf, attempted = 1.0, True
|
||||
|
||||
if not best:
|
||||
matched_via = 'search'
|
||||
try:
|
||||
best, conf, source, attempted = _find_best_match(
|
||||
engine, source_priority, title, artist_name or '', album_title or '',
|
||||
min_conf, duration_ms)
|
||||
except Exception as e:
|
||||
logger.debug("[Quality Upgrade] Match error for %s - %s: %s", artist_name, title, e)
|
||||
result.errors += 1
|
||||
continue
|
||||
|
||||
if not best:
|
||||
if matched_via == 'search' and not attempted:
|
||||
logger.warning("[Quality Upgrade] No metadata provider responded — stopping")
|
||||
return result
|
||||
result.skipped += 1
|
||||
continue
|
||||
|
||||
matched = _normalize_track_match(best, source or 'metadata')
|
||||
# Carry album context: prefer the matched album, fall back to the
|
||||
# library album the low-quality track came from.
|
||||
alb = matched.get('album')
|
||||
if (not isinstance(alb, dict) or not alb.get('name')) and album_title:
|
||||
matched['album'] = {'name': album_title, 'images': (alb or {}).get('images', []) if isinstance(alb, dict) else []}
|
||||
|
||||
if context.create_finding:
|
||||
try:
|
||||
inserted = context.create_finding(
|
||||
job_id=self.job_id,
|
||||
finding_type='quality_upgrade',
|
||||
severity='info',
|
||||
entity_type='track',
|
||||
entity_id=str(track_id),
|
||||
file_path=file_path,
|
||||
title=f'Upgrade: {artist_name} - {title} ({current_label})',
|
||||
description=(
|
||||
f'"{title}" by {artist_name} is {current_label}, below your preferred '
|
||||
f'quality. Best match: "{_track_name(best)}" via {source} '
|
||||
f'({_MATCH_NOTE.get(matched_via, "matched") if matched_via != "search" else f"confidence {conf:.0%}"}). '
|
||||
'Apply to add it to the wishlist.'),
|
||||
details={
|
||||
'track_id': track_id,
|
||||
'track_title': title,
|
||||
'artist': artist_name,
|
||||
'album_id': album_id,
|
||||
'album_title': album_title,
|
||||
'current_format': current_label,
|
||||
'current_bitrate': bitrate,
|
||||
'match_confidence': conf,
|
||||
'matched_via': matched_via,
|
||||
'provider': source,
|
||||
'matched_track_data': matched,
|
||||
})
|
||||
if inserted:
|
||||
result.findings_created += 1
|
||||
else:
|
||||
result.findings_skipped_dedup += 1
|
||||
except Exception as e:
|
||||
logger.debug("[Quality Upgrade] create finding failed for track %s: %s", track_id, e)
|
||||
result.errors += 1
|
||||
|
||||
if context.update_progress and (i + 1) % 10 == 0:
|
||||
context.update_progress(i + 1, total)
|
||||
|
||||
if context.update_progress:
|
||||
context.update_progress(total, total)
|
||||
logger.info("[Quality Upgrade] %d scanned, %d upgrades found, %d met/skip",
|
||||
result.scanned, result.findings_created, result.skipped)
|
||||
return result
|
||||
197
core/repair_jobs/replaygain_filler.py
Normal file
197
core/repair_jobs/replaygain_filler.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"""ReplayGain Filler maintenance job (#437) — the loudness sibling of the Lyrics
|
||||
and Cover Art fillers.
|
||||
|
||||
Post-processing applies ReplayGain to slskd/WebUI downloads, but content that
|
||||
enters the library another way — Lidarr, the REST API, manual adds — never gets
|
||||
it, and there was no way to (re)apply RG to existing tracks or fix the ones where
|
||||
analysis failed (a recurring ask on #437).
|
||||
|
||||
This scans the library for tracks with no ReplayGain track-gain tag and creates a
|
||||
finding for each. Applying a finding runs the same ffmpeg ebur128 analysis the
|
||||
import pipeline uses and writes the RG tags in place — no moves, no re-matching.
|
||||
|
||||
Scan only READS tags (cheap); the expensive ffmpeg analysis happens on apply.
|
||||
Requires ffmpeg (RG analysis can't run without it), so the scan no-ops when ffmpeg
|
||||
isn't on PATH rather than surfacing findings that could never be applied.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from core.library.path_resolver import resolve_library_file_path
|
||||
from core.repair_jobs import register_job
|
||||
from core.repair_jobs.base import JobContext, JobResult, RepairJob
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("repair_jobs.replaygain_filler")
|
||||
|
||||
|
||||
def needs_replaygain(rg_tags: Optional[Dict[str, Any]]) -> bool:
|
||||
"""Pure decision: does this track need ReplayGain written?
|
||||
|
||||
True when the track-gain tag is absent or blank. ``rg_tags`` is the dict from
|
||||
``core.replaygain.read_replaygain_tags`` (keys: track_gain, track_peak, …).
|
||||
A present track_gain — even "+0.00 dB" — counts as already-tagged.
|
||||
"""
|
||||
if not rg_tags:
|
||||
return True
|
||||
val = rg_tags.get('track_gain')
|
||||
return val is None or str(val).strip() == ''
|
||||
|
||||
|
||||
def _resolve(file_path: str) -> Optional[str]:
|
||||
"""Resolve a stored library path to one this process can read (Docker/host
|
||||
prefix mapping), falling back to the raw path if it's already a real file."""
|
||||
resolved = resolve_library_file_path(file_path) if file_path else None
|
||||
if not resolved and file_path and os.path.isfile(file_path):
|
||||
resolved = file_path
|
||||
return resolved
|
||||
|
||||
|
||||
@register_job
|
||||
class ReplayGainFillerJob(RepairJob):
|
||||
job_id = 'replaygain_filler'
|
||||
display_name = 'ReplayGain Filler'
|
||||
description = 'Finds tracks with no ReplayGain tag and analyzes + writes loudness tags'
|
||||
help_text = (
|
||||
'Scans your library for tracks that have no ReplayGain track-gain tag — '
|
||||
'common for albums added by Lidarr, the REST API, or by hand, which skip '
|
||||
"the download post-processing where ReplayGain normally runs.\n\n"
|
||||
'A finding is created for each. Applying one runs the same ffmpeg loudness '
|
||||
'analysis (EBU R128) the import pipeline uses and writes the ReplayGain '
|
||||
'tags in place — no files are moved or renamed. This also lets you re-fill '
|
||||
'tracks where the original analysis failed.\n\n'
|
||||
'Requires ffmpeg to be installed (the analysis cannot run without it).'
|
||||
)
|
||||
icon = 'repair-icon-replaygain'
|
||||
default_enabled = False
|
||||
default_interval_hours = 48
|
||||
default_settings = {}
|
||||
auto_fix = False
|
||||
|
||||
def scan(self, context: JobContext) -> JobResult:
|
||||
result = JobResult()
|
||||
|
||||
try:
|
||||
from core.replaygain import is_ffmpeg_available, read_replaygain_tags
|
||||
except Exception as e:
|
||||
logger.warning("[ReplayGain Filler] replaygain module unavailable: %s", e)
|
||||
return result
|
||||
if not is_ffmpeg_available():
|
||||
logger.info("[ReplayGain Filler] ffmpeg not available — skipping scan "
|
||||
"(analysis cannot run without it)")
|
||||
return result
|
||||
|
||||
rows = []
|
||||
conn = None
|
||||
try:
|
||||
conn = context.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT t.id, t.title, ar.name, t.file_path
|
||||
FROM tracks t
|
||||
LEFT JOIN artists ar ON ar.id = t.artist_id
|
||||
WHERE t.file_path IS NOT NULL AND t.file_path != ''
|
||||
""")
|
||||
rows = cursor.fetchall()
|
||||
except Exception as e:
|
||||
logger.error("[ReplayGain Filler] Error reading tracks: %s", e, exc_info=True)
|
||||
result.errors += 1
|
||||
return result
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
total = len(rows)
|
||||
if context.update_progress:
|
||||
context.update_progress(0, total)
|
||||
if context.report_progress:
|
||||
context.report_progress(phase=f'Checking ReplayGain on {total} tracks...', total=total)
|
||||
|
||||
for i, row in enumerate(rows):
|
||||
if context.check_stop():
|
||||
return result
|
||||
if i % 10 == 0 and context.wait_if_paused():
|
||||
return result
|
||||
|
||||
track_id, title, artist_name, file_path = row[:4]
|
||||
result.scanned += 1
|
||||
|
||||
resolved = _resolve(file_path)
|
||||
if not resolved:
|
||||
# Can't read the file from here → can't analyze it on apply either.
|
||||
result.skipped += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
rg = read_replaygain_tags(resolved)
|
||||
except Exception as e:
|
||||
logger.debug("[ReplayGain Filler] tag read failed for '%s': %s", title, e)
|
||||
result.skipped += 1
|
||||
continue
|
||||
|
||||
if not needs_replaygain(rg):
|
||||
result.skipped += 1
|
||||
if context.update_progress and (i + 1) % 25 == 0:
|
||||
context.update_progress(i + 1, total)
|
||||
continue
|
||||
|
||||
if context.report_progress:
|
||||
context.report_progress(
|
||||
scanned=i + 1, total=total,
|
||||
log_line=f'No ReplayGain: {title} — {artist_name or "Unknown"}',
|
||||
log_type='info')
|
||||
|
||||
if context.create_finding:
|
||||
try:
|
||||
inserted = context.create_finding(
|
||||
job_id=self.job_id,
|
||||
finding_type='missing_replaygain',
|
||||
severity='info',
|
||||
entity_type='track',
|
||||
entity_id=str(track_id),
|
||||
file_path=file_path,
|
||||
title=f'No ReplayGain: {title or "Unknown"}',
|
||||
description=(f'"{title}" by {artist_name or "Unknown"} has no '
|
||||
'ReplayGain tag — loudness can be analyzed + written.'),
|
||||
details={
|
||||
'track_id': track_id,
|
||||
'track_title': title,
|
||||
'artist': artist_name,
|
||||
'file_path': file_path,
|
||||
})
|
||||
if inserted:
|
||||
result.findings_created += 1
|
||||
else:
|
||||
result.findings_skipped_dedup += 1
|
||||
except Exception as e:
|
||||
logger.debug("[ReplayGain Filler] create finding failed for track %s: %s", track_id, e)
|
||||
result.errors += 1
|
||||
|
||||
if context.update_progress and (i + 1) % 10 == 0:
|
||||
context.update_progress(i + 1, total)
|
||||
|
||||
if context.update_progress:
|
||||
context.update_progress(total, total)
|
||||
logger.info("[ReplayGain Filler] %d tracks checked, %d missing ReplayGain, %d skipped",
|
||||
result.scanned, result.findings_created, result.skipped)
|
||||
return result
|
||||
|
||||
def estimate_scope(self, context: JobContext) -> int:
|
||||
conn = None
|
||||
try:
|
||||
conn = context.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM tracks
|
||||
WHERE file_path IS NOT NULL AND file_path != ''
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
return row[0] if row else 0
|
||||
except Exception:
|
||||
return 0
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
|
@ -964,6 +964,8 @@ class RepairWorker:
|
|||
'track_number_mismatch': self._fix_track_number,
|
||||
'missing_cover_art': self._fix_missing_cover_art,
|
||||
'missing_lyrics': self._fix_missing_lyrics,
|
||||
'missing_replaygain': self._fix_missing_replaygain,
|
||||
'empty_folder': self._fix_empty_folder,
|
||||
'expired_download': self._fix_expired_download,
|
||||
'metadata_gap': self._fix_metadata_gap,
|
||||
'duplicate_tracks': self._fix_duplicates,
|
||||
|
|
@ -979,6 +981,7 @@ class RepairWorker:
|
|||
'acoustid_mismatch': self._fix_acoustid_mismatch,
|
||||
'missing_discography_track': self._fix_discography_backfill,
|
||||
'library_retag': self._fix_library_retag,
|
||||
'quality_upgrade': self._fix_quality_upgrade,
|
||||
}
|
||||
handler = handlers.get(finding_type)
|
||||
if not handler:
|
||||
|
|
@ -1005,6 +1008,36 @@ class RepairWorker:
|
|||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def _fix_quality_upgrade(self, entity_type, entity_id, file_path, details):
|
||||
"""Add the matched higher-quality version to the wishlist (with album
|
||||
context). Applying a Quality Upgrade finding is the user-approved step
|
||||
that the old auto-acting Quality Scanner did without review."""
|
||||
track_data = details.get('matched_track_data')
|
||||
if not track_data:
|
||||
return {'success': False, 'error': 'No matched track in finding'}
|
||||
try:
|
||||
success = self.db.add_to_wishlist(
|
||||
spotify_track_data=track_data,
|
||||
failure_reason=f"Quality upgrade — current file is {details.get('current_format', 'low quality')}",
|
||||
source_type='repair',
|
||||
source_info={
|
||||
'job': 'quality_upgrade',
|
||||
'original_file_path': file_path,
|
||||
'original_format': details.get('current_format'),
|
||||
'original_bitrate': details.get('current_bitrate'),
|
||||
'album_title': details.get('album_title'),
|
||||
'match_confidence': details.get('match_confidence'),
|
||||
'provider': details.get('provider'),
|
||||
},
|
||||
)
|
||||
track_name = track_data.get('name', '?')
|
||||
if success:
|
||||
return {'success': True, 'action': 'added_to_wishlist',
|
||||
'message': f"Added '{track_name}' to wishlist for re-download"}
|
||||
return {'success': False, 'error': f"Could not add '{track_name}' to wishlist (may already exist or be blocklisted)"}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def _fix_dead_file(self, entity_type, entity_id, file_path, details):
|
||||
"""Fix a dead file reference. Action depends on details['_fix_action']:
|
||||
'redownload' (default) — add to wishlist + remove DB entry
|
||||
|
|
@ -1479,6 +1512,56 @@ class RepairWorker:
|
|||
return {'success': False, 'error': 'Could not fetch lyrics (no longer available?)'}
|
||||
return {'success': True, 'action': 'applied_lyrics', 'message': 'Wrote lyrics (.lrc) + embedded'}
|
||||
|
||||
def _fix_missing_replaygain(self, entity_type, entity_id, file_path, details):
|
||||
"""Apply a missing-ReplayGain finding: run the same ffmpeg ebur128 loudness
|
||||
analysis the import pipeline uses and write the RG tags in place (#437)."""
|
||||
raw_path = details.get('file_path') or file_path
|
||||
if not raw_path:
|
||||
return {'success': False, 'error': 'No file path in finding'}
|
||||
download_folder = self._config_manager.get('soulseek.download_path', '') if self._config_manager else None
|
||||
resolved = _resolve_file_path(raw_path, self.transfer_folder, download_folder,
|
||||
config_manager=self._config_manager) or raw_path
|
||||
if not os.path.isfile(resolved):
|
||||
return {'success': False, 'error': f'File not found on disk: {os.path.basename(raw_path)}'}
|
||||
try:
|
||||
from core.replaygain import (analyze_track, write_replaygain_tags,
|
||||
is_ffmpeg_available, RG_REFERENCE_LUFS)
|
||||
if not is_ffmpeg_available():
|
||||
return {'success': False, 'error': 'ffmpeg not available — cannot analyze ReplayGain'}
|
||||
lufs, peak_dbfs = analyze_track(resolved)
|
||||
gain_db = RG_REFERENCE_LUFS - lufs # same formula as the import pipeline
|
||||
ok = write_replaygain_tags(resolved, gain_db, peak_dbfs)
|
||||
except Exception as e:
|
||||
logger.error("ReplayGain fix failed for %s: %s", os.path.basename(raw_path), e)
|
||||
return {'success': False, 'error': str(e)}
|
||||
if not ok:
|
||||
return {'success': False, 'error': 'Could not write ReplayGain tags'}
|
||||
return {'success': True, 'action': 'applied_replaygain',
|
||||
'message': f'Wrote ReplayGain ({gain_db:+.2f} dB)'}
|
||||
|
||||
def _fix_empty_folder(self, entity_type, entity_id, file_path, details):
|
||||
"""Apply an empty-folder finding: re-check the folder is still empty/junk-
|
||||
only (anything that gained a real file since the scan is left alone), then
|
||||
remove it. The library root + symlinked dirs are refused."""
|
||||
from core.repair_jobs.empty_folder_cleaner import remove_empty_folder
|
||||
raw = details.get('folder_path') or file_path
|
||||
if not raw:
|
||||
return {'success': False, 'error': 'No folder path in finding'}
|
||||
resolved = self._resolve_path(raw) if hasattr(self, '_resolve_path') else raw
|
||||
res = remove_empty_folder(
|
||||
resolved,
|
||||
junk_files=details.get('junk_files') or [],
|
||||
remove_junk=bool(details.get('remove_junk', True)),
|
||||
root=self.transfer_folder,
|
||||
listdir=os.listdir, isdir=os.path.isdir, islink=os.path.islink,
|
||||
remove_file=os.remove, rmdir=os.rmdir,
|
||||
)
|
||||
if not res.get('removed'):
|
||||
return {'success': False, 'error': res.get('error') or 'Could not remove folder'}
|
||||
_name = os.path.basename(resolved.rstrip('/\\')) or resolved
|
||||
return {'success': True, 'action': 'removed_empty_folder',
|
||||
'message': f'Removed empty folder: {_name}'}
|
||||
|
||||
def _fix_expired_download(self, entity_type, entity_id, file_path, details):
|
||||
"""Apply an expired-download finding: delete the file + library row +
|
||||
history entry, via the same helper the cleaner's auto mode uses."""
|
||||
|
|
@ -3284,7 +3367,7 @@ class RepairWorker:
|
|||
'album_mbid_mismatch',
|
||||
'album_tag_inconsistency',
|
||||
'incomplete_album', 'path_mismatch',
|
||||
'missing_lossy_copy',
|
||||
'missing_lossy_copy', 'missing_replaygain', 'empty_folder',
|
||||
'missing_discography_track', 'acoustid_mismatch')
|
||||
placeholders = ','.join(['?'] * len(fixable_types))
|
||||
where_parts = [f"finding_type IN ({placeholders})", "status = 'pending'"]
|
||||
|
|
|
|||
45
core/security/login_provisioning.py
Normal file
45
core/security/login_provisioning.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""Login-mode password provisioning policy.
|
||||
|
||||
Invariant: while ``security.require_login`` is on, every profile must have a login
|
||||
password — otherwise it's fail-closed locked out (usable only after the admin
|
||||
provisions one). That's not a security hole (no-password = can't get in), but it's
|
||||
a usability gap, and the point here is to make it impossible to OPEN one from any
|
||||
write-point: creating a profile, clearing a password, or flipping login mode on.
|
||||
|
||||
These are pure decisions so they're the single source of truth + unit-testable;
|
||||
web_server wires them into the create / set-password / enable-login endpoints, and
|
||||
the UI mirrors them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
def members_without_password(profiles: Optional[List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
|
||||
"""Non-admin profiles with no login password — they can't sign in once login
|
||||
mode is on. The admin is covered separately by its own anti-lockout, so it's
|
||||
excluded here. Returns ``[{'id', 'name'}, …]`` (empty = no gap)."""
|
||||
out: List[Dict[str, Any]] = []
|
||||
for p in (profiles or []):
|
||||
if not p.get('is_admin') and not p.get('has_password'):
|
||||
out.append({'id': p.get('id'), 'name': p.get('name')})
|
||||
return out
|
||||
|
||||
|
||||
def create_needs_password(require_login: bool, is_admin: bool = False) -> bool:
|
||||
"""A non-admin profile created while login mode is on must carry a password,
|
||||
or it's born unable to sign in."""
|
||||
return bool(require_login) and not is_admin
|
||||
|
||||
|
||||
def removing_password_strands(require_login: bool) -> bool:
|
||||
"""Clearing a profile's password while login mode is on would lock it out."""
|
||||
return bool(require_login)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"members_without_password",
|
||||
"create_needs_password",
|
||||
"removing_password_strands",
|
||||
]
|
||||
|
|
@ -29,6 +29,7 @@ import uuid
|
|||
import time
|
||||
from typing import List, Optional, Dict, Any, Tuple, Callable
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
import yt_dlp
|
||||
|
|
@ -45,6 +46,33 @@ from core.download_plugins.types import TrackResult, AlbumResult, DownloadStatus
|
|||
logger = get_logger("soundcloud_client")
|
||||
|
||||
|
||||
def is_soundcloud_url(value: Any) -> bool:
|
||||
"""True when ``value`` is a SoundCloud URL (track, set/playlist, or a
|
||||
public-but-unlisted ``/s-<token>`` share link), incl. ``on.soundcloud.com``
|
||||
short links and scheme-less ``soundcloud.com/...`` forms.
|
||||
|
||||
Pure + network-free. Used so a pasted SoundCloud link is RESOLVED directly
|
||||
(it carries its own access token) instead of being run through scsearch,
|
||||
which can't find unlisted/private tracks (#865). A normal text query — even
|
||||
one mentioning "soundcloud" — is not a URL and returns False."""
|
||||
if not value or not isinstance(value, str):
|
||||
return False
|
||||
raw = value.strip()
|
||||
if not raw:
|
||||
return False
|
||||
candidate = raw if '://' in raw else f'https://{raw}'
|
||||
try:
|
||||
host = (urlparse(candidate).netloc or '').lower()
|
||||
except Exception:
|
||||
return False
|
||||
host = host.split('@')[-1].split(':')[0] # strip any userinfo / port
|
||||
return (
|
||||
host == 'soundcloud.com'
|
||||
or host.endswith('.soundcloud.com')
|
||||
or host.endswith('soundcloud.app.goo.gl')
|
||||
)
|
||||
|
||||
|
||||
# Quality tiers — SoundCloud anonymous access only really delivers one
|
||||
# quality, but we keep the structure consistent with other clients so
|
||||
# UI/settings can reference a familiar shape later.
|
||||
|
|
@ -173,6 +201,12 @@ class SoundcloudClient(DownloadSourcePlugin):
|
|||
logger.warning(f"Invalid SoundCloud search query: {query!r}")
|
||||
return ([], [])
|
||||
|
||||
# A pasted SoundCloud link resolves directly — scsearch can't find an
|
||||
# unlisted/private track, but its share URL carries an access token that
|
||||
# yt-dlp can resolve (#865).
|
||||
if is_soundcloud_url(query):
|
||||
return await self.resolve_url(query, timeout=timeout, progress_callback=progress_callback)
|
||||
|
||||
# SoundCloud or a transient yt-dlp parse can fail; the caller still
|
||||
# gets an empty list, never a raised exception.
|
||||
limit = min(MAX_SEARCH_LIMIT, max(1, DEFAULT_SEARCH_LIMIT))
|
||||
|
|
@ -225,6 +259,70 @@ class SoundcloudClient(DownloadSourcePlugin):
|
|||
entries = info.get('entries') or []
|
||||
return [e for e in entries if isinstance(e, dict)]
|
||||
|
||||
async def resolve_url(
|
||||
self,
|
||||
url: str,
|
||||
timeout: Optional[int] = None,
|
||||
progress_callback: Optional[Callable] = None,
|
||||
) -> Tuple[List[TrackResult], List[AlbumResult]]:
|
||||
"""Resolve a direct SoundCloud track/set URL into downloadable
|
||||
TrackResult(s) — including public-but-unlisted/private share links that
|
||||
scsearch can't find, because the URL itself carries the access token
|
||||
(#865). A set/playlist link resolves to all its tracks. Returns
|
||||
``(tracks, [])`` and never raises (empty on any failure)."""
|
||||
if not self.is_available():
|
||||
logger.warning("SoundCloud not available for URL resolve (yt-dlp missing)")
|
||||
return ([], [])
|
||||
url = (url or '').strip()
|
||||
if not url:
|
||||
return ([], [])
|
||||
|
||||
logger.info(f"Resolving SoundCloud URL: {url}")
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
info = await loop.run_in_executor(None, self._extract_url_info, url)
|
||||
except Exception as exc:
|
||||
logger.error(f"SoundCloud URL resolve failed: {exc}")
|
||||
return ([], [])
|
||||
if not isinstance(info, dict):
|
||||
return ([], [])
|
||||
|
||||
# A set/playlist link yields `entries`; a single track is the dict itself.
|
||||
entries = info.get('entries')
|
||||
raw_entries = [e for e in entries if isinstance(e, dict)] if entries else [info]
|
||||
|
||||
track_results: List[TrackResult] = []
|
||||
for entry in raw_entries:
|
||||
# Full (non-flat) extraction puts a transient media-stream URL in
|
||||
# `url`; the stable permalink lives in `webpage_url`. Pin `url` to the
|
||||
# permalink so the download encoding matches what search() produces
|
||||
# (the worker re-resolves the permalink at download time).
|
||||
permalink = entry.get('webpage_url') or entry.get('url')
|
||||
if permalink:
|
||||
entry = {**entry, 'url': permalink}
|
||||
try:
|
||||
converted = self._sc_to_track_result(entry)
|
||||
if converted is not None:
|
||||
track_results.append(converted)
|
||||
except Exception as exc:
|
||||
logger.debug(f"Skipping SoundCloud URL entry conversion error: {exc}")
|
||||
|
||||
logger.info(f"Resolved {len(track_results)} SoundCloud track(s) from URL")
|
||||
return (track_results, [])
|
||||
|
||||
def _extract_url_info(self, url: str) -> Optional[Dict[str, Any]]:
|
||||
"""Full (non-flat) yt-dlp extraction of a direct SoundCloud URL — needed
|
||||
to get the real id/permalink/title for a single track or set. Anonymous;
|
||||
the share token (if any) is already in the URL. Injectable for tests."""
|
||||
opts = {
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
'skip_download': True,
|
||||
'noplaylist': False,
|
||||
}
|
||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||
return ydl.extract_info(url, download=False)
|
||||
|
||||
def _sc_to_track_result(self, entry: Dict[str, Any]) -> Optional[TrackResult]:
|
||||
"""Convert a yt-dlp SoundCloud entry into the standard TrackResult.
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ from core.spotify_client import SpotifyClient, SpotifyRateLimitError
|
|||
from core.worker_utils import (
|
||||
ARTIST_NAME_MATCH_THRESHOLD,
|
||||
interruptible_sleep,
|
||||
owned_album_titles,
|
||||
pick_artist_by_catalog,
|
||||
release_titles,
|
||||
set_album_api_track_count,
|
||||
source_id_conflict,
|
||||
)
|
||||
|
|
@ -563,16 +566,23 @@ class SpotifyWorker:
|
|||
logger.debug(f"No Spotify results for artist '{artist_name}'")
|
||||
return
|
||||
|
||||
# Find best fuzzy match — score all candidates, pick highest above the
|
||||
# (stricter, artist-specific) threshold so short-name false positives
|
||||
# like "ODESZA"/"odessa" don't slip through.
|
||||
best_obj = None
|
||||
best_score = 0
|
||||
for artist_obj in results:
|
||||
score = self._name_similarity(artist_name, artist_obj.name)
|
||||
if score >= ARTIST_NAME_MATCH_THRESHOLD and score > best_score:
|
||||
best_obj = artist_obj
|
||||
best_score = score
|
||||
# Candidates clearing the (stricter, artist-specific) name gate, best
|
||||
# name-score first so [0] is the legacy "first/highest" pick.
|
||||
scored = [
|
||||
(self._name_similarity(artist_name, a.name), a)
|
||||
for a in results
|
||||
]
|
||||
gated = [a for score, a in sorted(scored, key=lambda s: s[0], reverse=True)
|
||||
if score >= ARTIST_NAME_MATCH_THRESHOLD]
|
||||
|
||||
# Same-name disambiguation: when more than one "Rone" clears the gate,
|
||||
# pick the one whose catalog overlaps the albums this library owns.
|
||||
best_obj, _overlap = pick_artist_by_catalog(
|
||||
gated,
|
||||
owned_album_titles(self.db, artist_id),
|
||||
lambda a: release_titles(self.client.get_artist_albums(a.id)),
|
||||
)
|
||||
best_score = self._name_similarity(artist_name, best_obj.name) if best_obj else 0
|
||||
|
||||
if best_obj:
|
||||
if not self._is_spotify_id(best_obj.id):
|
||||
|
|
|
|||
|
|
@ -1324,13 +1324,20 @@ class TidalClient:
|
|||
track_ids.append(item.get("id"))
|
||||
|
||||
if track_ids:
|
||||
# Batch fetch full track details with artists and albums
|
||||
try:
|
||||
batch_tracks = self._get_tracks_batch(track_ids)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching track details for page {page_num}: {e}")
|
||||
# Continue pagination — we lose this batch but can still get remaining
|
||||
batch_tracks = []
|
||||
# Batch fetch full track details with artists and albums.
|
||||
# Chunk to the filter[id] page cap — Tidal returns at most
|
||||
# _COLLECTION_BATCH_SIZE tracks per request, so a relationships
|
||||
# page larger than the cap would be silently truncated if sent
|
||||
# in one shot. Mirrors get_album_tracks. (#867)
|
||||
batch_tracks = []
|
||||
for j in range(0, len(track_ids), self._COLLECTION_BATCH_SIZE):
|
||||
chunk_ids = track_ids[j:j + self._COLLECTION_BATCH_SIZE]
|
||||
try:
|
||||
batch_tracks.extend(self._get_tracks_batch(chunk_ids))
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching track details for page {page_num}: {e}")
|
||||
# Lose this chunk but keep going — remaining chunks/pages can still load
|
||||
continue
|
||||
|
||||
if len(batch_tracks) < len(track_ids):
|
||||
logger.warning(f"Page {page_num}: requested {len(track_ids)} tracks but only {len(batch_tracks)} returned (some may be unavailable in your region)")
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from typing import Optional
|
|||
|
||||
from config.settings import config_manager
|
||||
|
||||
from core.torrent_clients.aria2 import Aria2Adapter
|
||||
from core.torrent_clients.base import TorrentClientAdapter, TorrentStatus
|
||||
from core.torrent_clients.deluge import DelugeAdapter
|
||||
from core.torrent_clients.qbittorrent import QBittorrentAdapter
|
||||
|
|
@ -26,6 +27,7 @@ __all__ = [
|
|||
"QBittorrentAdapter",
|
||||
"TransmissionAdapter",
|
||||
"DelugeAdapter",
|
||||
"Aria2Adapter",
|
||||
"get_active_adapter",
|
||||
"adapter_for_type",
|
||||
]
|
||||
|
|
@ -43,6 +45,8 @@ def adapter_for_type(client_type: str) -> Optional[TorrentClientAdapter]:
|
|||
return TransmissionAdapter()
|
||||
if client_type == "deluge":
|
||||
return DelugeAdapter()
|
||||
if client_type == "aria2":
|
||||
return Aria2Adapter()
|
||||
return None
|
||||
|
||||
|
||||
|
|
|
|||
218
core/torrent_clients/aria2.py
Normal file
218
core/torrent_clients/aria2.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
"""Aria2 JSON-RPC adapter (Shdjfgatdif's request).
|
||||
|
||||
Auth model: aria2 uses a single RPC ``--rpc-secret`` token (no username). It is
|
||||
passed as the FIRST positional param of every call as the string
|
||||
``token:<secret>``. The RPC endpoint is ``<host>:6800/jsonrpc``.
|
||||
|
||||
A few aria2 quirks the adapter smooths over:
|
||||
- the secret maps onto SoulSync's ``password`` field (aria2 has no username),
|
||||
- ``addUri`` returns a GID — that's our torrent id,
|
||||
- aria2 does NOT delete files on remove; for ``delete_files`` we read the file
|
||||
paths first and unlink them ourselves,
|
||||
- a finished/errored download is removed via ``removeDownloadResult`` (force/Remove
|
||||
only work on active/waiting/paused), so the adapter picks the right one.
|
||||
|
||||
Reference: https://aria2.github.io/manual/en/html/aria2c.html#rpc-interface
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
from typing import List, Optional
|
||||
|
||||
import requests as http_requests
|
||||
|
||||
from config.settings import config_manager
|
||||
from core.torrent_clients.base import TorrentStatus, normalize_client_url
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("torrent.aria2")
|
||||
|
||||
|
||||
# aria2 download status → adapter-uniform state. 'active' is resolved separately
|
||||
# (it covers both downloading and post-complete seeding for BT).
|
||||
_ARIA2_STATE = {
|
||||
'waiting': 'queued',
|
||||
'paused': 'paused',
|
||||
'error': 'error',
|
||||
'removed': 'error',
|
||||
'complete': 'completed',
|
||||
}
|
||||
|
||||
|
||||
def _map_state(status: str, completed: int, total: int) -> str:
|
||||
if status == 'active':
|
||||
# A BT download that finished its payload keeps reporting 'active' while
|
||||
# it seeds — differentiate by progress.
|
||||
if total and completed >= total:
|
||||
return 'seeding'
|
||||
return 'downloading'
|
||||
return _ARIA2_STATE.get(status, 'error')
|
||||
|
||||
|
||||
class Aria2Adapter:
|
||||
"""Aria2 JSON-RPC adapter."""
|
||||
|
||||
DEFAULT_TIMEOUT = 15
|
||||
_STATUS_KEYS = ['gid', 'status', 'totalLength', 'completedLength', 'downloadSpeed',
|
||||
'uploadSpeed', 'connections', 'numSeeders', 'dir', 'files',
|
||||
'errorMessage', 'bittorrent']
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._load_config()
|
||||
|
||||
def _load_config(self) -> None:
|
||||
url = normalize_client_url(config_manager.get('torrent_client.url', ''))
|
||||
# aria2's RPC endpoint is /jsonrpc — append if the user pasted a bare host.
|
||||
if url and not url.rstrip('/').endswith('/jsonrpc'):
|
||||
url = url.rstrip('/') + '/jsonrpc'
|
||||
self._url = url
|
||||
# aria2 has no username; the RPC secret maps onto the password field.
|
||||
self._secret = config_manager.get('torrent_client.password', '') or ''
|
||||
self._category = config_manager.get('torrent_client.category', 'soulsync') or 'soulsync'
|
||||
self._save_path = config_manager.get('torrent_client.save_path', '') or ''
|
||||
|
||||
def reload_settings(self) -> None:
|
||||
self._load_config()
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self._url)
|
||||
|
||||
def _params(self, *params) -> list:
|
||||
"""token:<secret> must lead every call when a secret is configured."""
|
||||
return ([f"token:{self._secret}"] if self._secret else []) + list(params)
|
||||
|
||||
def _rpc(self, method: str, *params):
|
||||
if not self._url:
|
||||
return None
|
||||
payload = {'jsonrpc': '2.0', 'id': 'soulsync', 'method': method,
|
||||
'params': self._params(*params)}
|
||||
try:
|
||||
resp = http_requests.post(self._url, json=payload, timeout=self.DEFAULT_TIMEOUT)
|
||||
if not resp.ok:
|
||||
logger.warning("aria2 RPC %s returned HTTP %s", method, resp.status_code)
|
||||
return None
|
||||
data = resp.json()
|
||||
if isinstance(data, dict) and data.get('error'):
|
||||
logger.warning("aria2 RPC %s error: %s", method, data.get('error'))
|
||||
return None
|
||||
return data.get('result') if isinstance(data, dict) else None
|
||||
except Exception as e:
|
||||
logger.error("aria2 RPC %s failed: %s", method, e)
|
||||
return None
|
||||
|
||||
async def check_connection(self) -> bool:
|
||||
if not self.is_configured():
|
||||
return False
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, lambda: self._rpc('aria2.getVersion') is not None)
|
||||
|
||||
# ── add ──
|
||||
async def add_torrent(self, url_or_magnet: str, category: str = "soulsync",
|
||||
save_path: Optional[str] = None) -> Optional[str]:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self._add_uri_sync, url_or_magnet, save_path)
|
||||
|
||||
def _opts(self, save_path: Optional[str]) -> dict:
|
||||
opts: dict = {}
|
||||
if save_path or self._save_path:
|
||||
opts['dir'] = save_path or self._save_path
|
||||
return opts
|
||||
|
||||
def _add_uri_sync(self, uri: str, save_path: Optional[str]) -> Optional[str]:
|
||||
result = self._rpc('aria2.addUri', [uri], self._opts(save_path))
|
||||
return str(result) if result else None # GID
|
||||
|
||||
async def add_torrent_file(self, file_bytes: bytes, category: str = "soulsync",
|
||||
save_path: Optional[str] = None) -> Optional[str]:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self._add_file_sync, file_bytes, save_path)
|
||||
|
||||
def _add_file_sync(self, file_bytes: bytes, save_path: Optional[str]) -> Optional[str]:
|
||||
b64 = base64.b64encode(file_bytes).decode('ascii')
|
||||
result = self._rpc('aria2.addTorrent', b64, [], self._opts(save_path))
|
||||
return str(result) if result else None
|
||||
|
||||
# ── status ──
|
||||
async def get_status(self, torrent_id: str) -> Optional[TorrentStatus]:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self._get_status_sync, torrent_id)
|
||||
|
||||
def _get_status_sync(self, gid: str) -> Optional[TorrentStatus]:
|
||||
result = self._rpc('aria2.tellStatus', gid, self._STATUS_KEYS)
|
||||
return self._parse_status(result) if result else None
|
||||
|
||||
async def get_all(self) -> List[TorrentStatus]:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self._get_all_sync)
|
||||
|
||||
def _get_all_sync(self) -> List[TorrentStatus]:
|
||||
active = self._rpc('aria2.tellActive', self._STATUS_KEYS) or []
|
||||
waiting = self._rpc('aria2.tellWaiting', 0, 1000, self._STATUS_KEYS) or []
|
||||
stopped = self._rpc('aria2.tellStopped', 0, 1000, self._STATUS_KEYS) or []
|
||||
return [self._parse_status(i) for i in (list(active) + list(waiting) + list(stopped))]
|
||||
|
||||
def _parse_status(self, item: dict) -> TorrentStatus:
|
||||
total = int(item.get('totalLength') or 0)
|
||||
completed = int(item.get('completedLength') or 0)
|
||||
status = item.get('status') or 'error'
|
||||
progress = (completed / total) if total > 0 else 0.0
|
||||
# Name: BT info name, else the first file's basename.
|
||||
name = ''
|
||||
bt = item.get('bittorrent')
|
||||
if isinstance(bt, dict):
|
||||
name = (bt.get('info') or {}).get('name') or ''
|
||||
files = item.get('files') or []
|
||||
if not name and files:
|
||||
name = os.path.basename((files[0] or {}).get('path') or '')
|
||||
file_paths = [f.get('path') for f in files if f.get('path')] or None
|
||||
return TorrentStatus(
|
||||
id=str(item.get('gid') or ''),
|
||||
name=name,
|
||||
state=_map_state(status, completed, total),
|
||||
progress=progress,
|
||||
size=total,
|
||||
downloaded=completed,
|
||||
download_speed=int(item.get('downloadSpeed') or 0),
|
||||
upload_speed=int(item.get('uploadSpeed') or 0),
|
||||
seeders=int(item.get('numSeeders') or 0),
|
||||
peers=int(item.get('connections') or 0),
|
||||
save_path=item.get('dir'),
|
||||
files=file_paths,
|
||||
error=item.get('errorMessage') or None,
|
||||
)
|
||||
|
||||
# ── remove / pause / resume ──
|
||||
async def remove(self, torrent_id: str, delete_files: bool = False) -> bool:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self._remove_sync, torrent_id, delete_files)
|
||||
|
||||
def _remove_sync(self, gid: str, delete_files: bool) -> bool:
|
||||
st = self._rpc('aria2.tellStatus', gid, ['status', 'files']) or {}
|
||||
status = st.get('status')
|
||||
paths = ([f.get('path') for f in (st.get('files') or []) if f.get('path')]
|
||||
if delete_files else [])
|
||||
if status in ('active', 'waiting', 'paused'):
|
||||
ok = self._rpc('aria2.forceRemove', gid) is not None
|
||||
self._rpc('aria2.removeDownloadResult', gid) # clear the result row
|
||||
else:
|
||||
# complete / error / removed → force/Remove would error; clear the row.
|
||||
ok = self._rpc('aria2.removeDownloadResult', gid) is not None
|
||||
if delete_files:
|
||||
for p in paths:
|
||||
try:
|
||||
if p and os.path.isfile(p):
|
||||
os.remove(p)
|
||||
except Exception: # noqa: S110 — partial data delete is best-effort
|
||||
pass
|
||||
return ok
|
||||
|
||||
async def pause(self, torrent_id: str) -> bool:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, lambda: self._rpc('aria2.pause', torrent_id) is not None)
|
||||
|
||||
async def resume(self, torrent_id: str) -> bool:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, lambda: self._rpc('aria2.unpause', torrent_id) is not None)
|
||||
|
|
@ -1376,10 +1376,17 @@ class WatchlistScanner:
|
|||
if scan_state is not None:
|
||||
scan_state['tracks_found_this_scan'] += 1
|
||||
|
||||
added = self.add_track_to_wishlist(
|
||||
track, album_data, artist,
|
||||
scan_run_id=(scan_state or {}).get('scan_run_id', ''),
|
||||
)
|
||||
# "Follow only" artists (auto_download off): discover and
|
||||
# surface the release exactly like normal, but skip the one
|
||||
# call that adds it to the wishlist — so nothing auto-downloads
|
||||
# and the user can pick what to grab (corruption's request).
|
||||
if getattr(artist, 'auto_download', True):
|
||||
added = self.add_track_to_wishlist(
|
||||
track, album_data, artist,
|
||||
scan_run_id=(scan_state or {}).get('scan_run_id', ''),
|
||||
)
|
||||
else:
|
||||
added = False
|
||||
|
||||
track_artists = track.get('artists', [])
|
||||
track_artist_name = track_artists[0].get('name', 'Unknown Artist') if track_artists else 'Unknown Artist'
|
||||
|
|
|
|||
|
|
@ -101,6 +101,108 @@ def accept_artist_match(database, id_column: str, source_id, artist_id,
|
|||
return True, ""
|
||||
|
||||
|
||||
# --- Same-name artist disambiguation by owned-catalog overlap -------------
|
||||
# The name gate (above) can't separate two artists who share a name ("Rone" has
|
||||
# ~5). The decisive signal is the library itself: the user owns albums by the
|
||||
# RIGHT one. So when several candidates clear the name gate, fetch each one's
|
||||
# catalog and pick the one whose releases overlap the albums actually owned.
|
||||
|
||||
def normalize_release_title(title: str) -> str:
|
||||
"""Collapse an album/release title for tolerant comparison — drop edition
|
||||
suffixes ('(Deluxe)', ' - Remastered'), punctuation, and case."""
|
||||
t = (title or '').lower().strip()
|
||||
t = re.sub(r'\s*\(.*?\)\s*', ' ', t)
|
||||
t = re.sub(r'\s*\[.*?\]\s*', ' ', t)
|
||||
t = re.sub(r'\s+[-–—]\s+.*$', '', t)
|
||||
t = re.sub(r'[^\w\s]', '', t)
|
||||
t = re.sub(r'\s+', ' ', t).strip()
|
||||
return t
|
||||
|
||||
|
||||
def catalog_overlap_score(owned_titles, candidate_titles, threshold: float = 0.85) -> int:
|
||||
"""How many OWNED album titles appear in the candidate's catalog (fuzzy,
|
||||
edition-insensitive). The disambiguation signal — higher = better match."""
|
||||
owned = {normalize_release_title(t) for t in (owned_titles or []) if t}
|
||||
owned.discard('')
|
||||
cand = {normalize_release_title(t) for t in (candidate_titles or []) if t}
|
||||
cand.discard('')
|
||||
if not owned or not cand:
|
||||
return 0
|
||||
score = 0
|
||||
for o in owned:
|
||||
if o in cand or any(SequenceMatcher(None, o, c).ratio() >= threshold for c in cand):
|
||||
score += 1
|
||||
return score
|
||||
|
||||
|
||||
def pick_artist_by_catalog(candidates, owned_titles, fetch_titles) -> tuple:
|
||||
"""Choose, among same-name candidates, the one whose catalog best overlaps the
|
||||
OWNED albums. Returns ``(chosen, overlap_score)``.
|
||||
|
||||
``candidates`` — artist objects already past the name gate (order = the
|
||||
worker's existing best-by-name order; candidates[0] is the
|
||||
current behavior's pick).
|
||||
``owned_titles`` — the library artist's owned album titles.
|
||||
``fetch_titles(candidate) -> list[str]`` — that candidate's album titles;
|
||||
called ONLY when disambiguation is actually needed (2+
|
||||
candidates and we have owned albums), so the common
|
||||
single-candidate path costs no extra API calls.
|
||||
|
||||
Falls back to candidates[0] (unchanged behavior) when there's nothing to
|
||||
disambiguate or no candidate overlaps the owned catalog.
|
||||
"""
|
||||
candidates = list(candidates or [])
|
||||
if not candidates:
|
||||
return None, 0
|
||||
if len(candidates) == 1:
|
||||
return candidates[0], 0
|
||||
owned = [t for t in (owned_titles or []) if t]
|
||||
if not owned:
|
||||
return candidates[0], 0
|
||||
|
||||
best, best_score = None, 0
|
||||
for cand in candidates:
|
||||
try:
|
||||
titles = fetch_titles(cand) or []
|
||||
except Exception as exc:
|
||||
logger.debug("catalog disambiguation: fetch_titles failed: %s", exc)
|
||||
titles = []
|
||||
score = catalog_overlap_score(owned, titles)
|
||||
if score > best_score:
|
||||
best, best_score = cand, score
|
||||
if best is not None and best_score > 0:
|
||||
return best, best_score
|
||||
return candidates[0], 0 # no overlap signal → keep the best-by-name pick
|
||||
|
||||
|
||||
def owned_album_titles(database, artist_id) -> list:
|
||||
"""The album titles the library actually has for this artist — the ground
|
||||
truth used to disambiguate same-name source artists."""
|
||||
try:
|
||||
with database._get_connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT title FROM albums WHERE artist_id = ?", (artist_id,)
|
||||
).fetchall()
|
||||
return [r[0] for r in rows if r and r[0]]
|
||||
except Exception as exc:
|
||||
logger.debug("owned_album_titles(%s) failed: %s", artist_id, exc)
|
||||
return []
|
||||
|
||||
|
||||
def release_titles(albums) -> list:
|
||||
"""Extract titles from a list of album objects/dicts (handles ``.title``/
|
||||
``.name`` / dict keys) — the candidate side of catalog disambiguation."""
|
||||
out = []
|
||||
for al in albums or []:
|
||||
if isinstance(al, dict):
|
||||
t = al.get('title') or al.get('name')
|
||||
else:
|
||||
t = getattr(al, 'title', None) or getattr(al, 'name', None)
|
||||
if t:
|
||||
out.append(t)
|
||||
return out
|
||||
|
||||
|
||||
def interruptible_sleep(stop_event: threading.Event, seconds: float, step: float = 0.5) -> bool:
|
||||
"""Sleep in chunks so shutdown can interrupt long waits."""
|
||||
if seconds <= 0:
|
||||
|
|
|
|||
91
core/youtube_track_meta.py
Normal file
91
core/youtube_track_meta.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"""Derive a track's artist + title from a yt-dlp playlist entry.
|
||||
|
||||
Flat playlist extraction (used to dodge YouTube rate limits) gives sparse
|
||||
per-entry data: often just ``title``, ``id``, ``duration``, and an
|
||||
``uploader``/``channel`` that — for a playlist like "Likes" — is the PLAYLIST
|
||||
OWNER, not the track artist. GitHub #863: every track came out as the owner
|
||||
("Wing It"), or "Unknown Artist" when ``uploader`` was absent, because the
|
||||
parser used ``entry['uploader']`` as the artist.
|
||||
|
||||
The artist is usually recoverable from one of, in priority order:
|
||||
|
||||
1. yt-dlp music-metadata fields (``artists`` / ``artist`` / ``creator``),
|
||||
populated for YouTube Music tracks.
|
||||
2. An auto-generated ``"<Artist> - Topic"`` channel name.
|
||||
3. The classic ``"<Artist> - <Title>"`` form embedded in the video title.
|
||||
|
||||
This module is the single, pure place that decides which signal wins, so the
|
||||
precedence is unit-testable instead of buried in the web_server endpoint. It
|
||||
deliberately does NOT fall back to the channel/uploader as the artist — on a
|
||||
playlist that's the owner, and mislabelling every track is worse than an honest
|
||||
"Unknown Artist" (which downstream MusicBrainz discovery can still try to fix).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Mapping, Tuple
|
||||
|
||||
# Trailing "- Topic" on an auto-generated YouTube Music channel.
|
||||
_TOPIC_RE = re.compile(r'\s*-\s*topic\s*$', re.IGNORECASE)
|
||||
|
||||
# "Artist - Title": a hyphen/en-dash/em-dash flanked by spaces, both sides
|
||||
# non-empty. Splits on the FIRST such separator so "A - B (C Remix)" → ("A",
|
||||
# "B (C Remix)"). Spaces around the dash are required so hyphenated names like
|
||||
# "Jean-Michel Jarre" aren't split.
|
||||
_TITLE_SPLIT_RE = re.compile(r'^\s*(?P<artist>.+?)\s+[-–—]\s+(?P<title>.+?)\s*$')
|
||||
|
||||
|
||||
def _first_music_field(entry: Mapping[str, Any]) -> str:
|
||||
"""First non-empty value from yt-dlp's music-metadata fields."""
|
||||
artists = entry.get('artists')
|
||||
if isinstance(artists, (list, tuple)):
|
||||
for a in artists:
|
||||
s = str(a or '').strip()
|
||||
if s:
|
||||
return s
|
||||
for key in ('artist', 'creator'):
|
||||
v = entry.get(key)
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
return ''
|
||||
|
||||
|
||||
def derive_artist_and_title(entry: Mapping[str, Any]) -> Tuple[str, str]:
|
||||
"""Return ``(artist, title)`` from a yt-dlp (flat) playlist entry.
|
||||
|
||||
``artist`` is ``''`` when no reliable signal exists — the caller defaults
|
||||
that to "Unknown Artist" rather than using the playlist owner's channel
|
||||
(#863). ``title`` is the raw video title, except when an "Artist - Title"
|
||||
split provided the artist, in which case it's the right-hand side.
|
||||
"""
|
||||
if not isinstance(entry, Mapping):
|
||||
return '', 'Unknown Track'
|
||||
|
||||
title = str(entry.get('title') or '').strip() or 'Unknown Track'
|
||||
|
||||
# 1. Music-metadata fields (YouTube Music).
|
||||
field_artist = _first_music_field(entry)
|
||||
if field_artist:
|
||||
return field_artist, title
|
||||
|
||||
# 2. "<Artist> - Topic" auto-channel — the channel name IS the artist.
|
||||
channel = str(entry.get('uploader') or entry.get('channel') or '').strip()
|
||||
if _TOPIC_RE.search(channel):
|
||||
stripped = _TOPIC_RE.sub('', channel).strip()
|
||||
if stripped:
|
||||
return stripped, title
|
||||
|
||||
# 3. "<Artist> - <Title>" embedded in the title.
|
||||
m = _TITLE_SPLIT_RE.match(title)
|
||||
if m:
|
||||
artist = m.group('artist').strip()
|
||||
rest = m.group('title').strip()
|
||||
if artist and rest:
|
||||
return artist, rest
|
||||
|
||||
# 4. No reliable artist signal — caller defaults to "Unknown Artist".
|
||||
return '', title
|
||||
|
||||
|
||||
__all__ = ['derive_artist_and_title']
|
||||
|
|
@ -102,6 +102,10 @@ class WatchlistArtist:
|
|||
include_instrumentals: bool = False
|
||||
lookback_days: Optional[int] = None # Per-artist override; None = use global setting
|
||||
preferred_metadata_source: Optional[str] = None # Per-artist override; None = use global setting
|
||||
# When False ("follow only"), the watchlist scan still discovers + surfaces new
|
||||
# releases for this artist but does NOT auto-add them to the wishlist (so they
|
||||
# don't auto-download). Default True = current behaviour.
|
||||
auto_download: bool = True
|
||||
profile_id: int = 1
|
||||
|
||||
@dataclass
|
||||
|
|
@ -467,6 +471,12 @@ class MusicDatabase:
|
|||
self._add_soul_id_columns(cursor)
|
||||
self._add_listening_history_table(cursor)
|
||||
|
||||
# Per-artist auto_download ("follow only") column. MUST run after the
|
||||
# profile-support migrations above — those recreate watchlist_artists
|
||||
# from an explicit column list, so any column added before them gets
|
||||
# dropped. Adding it here (after the last recreate) makes it stick.
|
||||
self._add_watchlist_auto_download_column(cursor)
|
||||
|
||||
# Spotify library cache
|
||||
self._add_spotify_library_cache_table(cursor)
|
||||
|
||||
|
|
@ -579,6 +589,7 @@ class MusicDatabase:
|
|||
# Add explored_at to mirrored_playlists (migration)
|
||||
self._add_mirrored_playlist_explored_column(cursor)
|
||||
self._add_mirrored_playlist_organize_column(cursor)
|
||||
self._add_mirrored_playlist_custom_name_column(cursor)
|
||||
|
||||
# Add notification columns to automations (migration)
|
||||
self._add_automation_notify_columns(cursor)
|
||||
|
|
@ -1251,6 +1262,23 @@ class MusicDatabase:
|
|||
except Exception as e:
|
||||
logger.error(f"Error adding organize_by_playlist column to mirrored_playlists: {e}")
|
||||
|
||||
def _add_mirrored_playlist_custom_name_column(self, cursor):
|
||||
"""Add custom_name (a user alias) for a mirrored playlist.
|
||||
|
||||
Overrides the upstream ``name`` for both UI display and sync-to-server,
|
||||
while staying tied to the original — the upstream ``name`` keeps tracking
|
||||
on refresh, ``custom_name`` just overrides what's shown/synced. Stored in
|
||||
its OWN column (not ``name``) precisely so ``mirror_playlist`` — which
|
||||
rewrites ``name`` from upstream on every refresh — never clobbers it."""
|
||||
try:
|
||||
cursor.execute("PRAGMA table_info(mirrored_playlists)")
|
||||
cols = [c[1] for c in cursor.fetchall()]
|
||||
if 'custom_name' not in cols:
|
||||
cursor.execute("ALTER TABLE mirrored_playlists ADD COLUMN custom_name TEXT DEFAULT NULL")
|
||||
logger.info("Added custom_name column to mirrored_playlists table")
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding custom_name column to mirrored_playlists: {e}")
|
||||
|
||||
def _add_automation_notify_columns(self, cursor):
|
||||
"""Add notification and result columns to automations table."""
|
||||
try:
|
||||
|
|
@ -2140,6 +2168,21 @@ class MusicDatabase:
|
|||
logger.error(f"Error adding content type filter columns to watchlist_artists: {e}")
|
||||
# Don't raise - this is a migration, database can still function
|
||||
|
||||
def _add_watchlist_auto_download_column(self, cursor):
|
||||
"""Add per-artist auto_download column ("follow only" toggle).
|
||||
|
||||
Default 1 (auto-download) = existing behaviour. When 0, the watchlist scan
|
||||
still finds + surfaces new releases for the artist but skips adding them to
|
||||
the wishlist."""
|
||||
try:
|
||||
cursor.execute("PRAGMA table_info(watchlist_artists)")
|
||||
columns = [column[1] for column in cursor.fetchall()]
|
||||
if 'auto_download' not in columns:
|
||||
cursor.execute("ALTER TABLE watchlist_artists ADD COLUMN auto_download INTEGER NOT NULL DEFAULT 1")
|
||||
logger.info("Added auto_download column to watchlist_artists table")
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding auto_download column to watchlist_artists: {e}")
|
||||
|
||||
def _add_watchlist_lookback_days_column(self, cursor):
|
||||
"""Add per-artist lookback_days column to watchlist_artists table"""
|
||||
try:
|
||||
|
|
@ -6883,6 +6926,31 @@ class MusicDatabase:
|
|||
logger.error(f"API: Error searching tracks with title='{title}', artist='{artist}': {e}")
|
||||
return []
|
||||
|
||||
def get_tracks_for_m3u_resolution(self, server_source: Optional[str] = None) -> List[Dict[str, str]]:
|
||||
"""Bulk-load (artist, title, file_path) for in-memory M3U path resolution.
|
||||
|
||||
ONE indexed read instead of a per-artist search loop. SQLite WAL allows it
|
||||
to run concurrently with the enrichment/scan writers, so M3U export no
|
||||
longer blocks behind them (the 'Export M3U hangs forever' report). Only
|
||||
rows that actually have a file_path are returned (the rest can't go in an
|
||||
M3U anyway)."""
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
sql = ("SELECT tracks.title AS title, artists.name AS artist_name, tracks.file_path AS file_path "
|
||||
"FROM tracks JOIN artists ON tracks.artist_id = artists.id "
|
||||
"WHERE tracks.file_path IS NOT NULL AND tracks.file_path != ''")
|
||||
params: list = []
|
||||
if server_source:
|
||||
sql += " AND tracks.server_source = ?"
|
||||
params.append(server_source)
|
||||
cursor.execute(sql, params)
|
||||
return [{'title': r['title'] or '', 'artist': r['artist_name'] or '', 'file_path': r['file_path']}
|
||||
for r in cursor.fetchall()]
|
||||
except Exception as e:
|
||||
logger.error(f"Error bulk-loading tracks for M3U resolution: {e}")
|
||||
return []
|
||||
|
||||
def _search_tracks_basic(self, cursor, title: str, artist: str, limit: int, server_source: str = None,
|
||||
rank_artist: str = None) -> List[DatabaseTrack]:
|
||||
"""Basic SQL LIKE search - fastest method"""
|
||||
|
|
@ -9275,7 +9343,8 @@ class MusicDatabase:
|
|||
'last_scan_timestamp', 'created_at', 'updated_at']
|
||||
optional_columns = ['image_url', 'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id', 'musicbrainz_artist_id', 'include_albums', 'include_eps', 'include_singles',
|
||||
'include_live', 'include_remixes', 'include_acoustic', 'include_compilations',
|
||||
'include_instrumentals', 'lookback_days', 'preferred_metadata_source']
|
||||
'include_instrumentals', 'lookback_days', 'preferred_metadata_source',
|
||||
'auto_download']
|
||||
|
||||
columns_to_select = base_columns + [col for col in optional_columns if col in existing_columns]
|
||||
|
||||
|
|
@ -9313,6 +9382,7 @@ class MusicDatabase:
|
|||
include_instrumentals = bool(row['include_instrumentals']) if 'include_instrumentals' in existing_columns else False
|
||||
lookback_days = row['lookback_days'] if 'lookback_days' in existing_columns else None
|
||||
preferred_metadata_source = row['preferred_metadata_source'] if 'preferred_metadata_source' in existing_columns else None
|
||||
auto_download = bool(row['auto_download']) if 'auto_download' in existing_columns else True
|
||||
|
||||
watchlist_artists.append(WatchlistArtist(
|
||||
id=row['id'],
|
||||
|
|
@ -9337,6 +9407,7 @@ class MusicDatabase:
|
|||
include_instrumentals=include_instrumentals,
|
||||
lookback_days=lookback_days,
|
||||
preferred_metadata_source=preferred_metadata_source,
|
||||
auto_download=auto_download,
|
||||
profile_id=profile_id
|
||||
))
|
||||
|
||||
|
|
@ -13704,8 +13775,12 @@ class MusicDatabase:
|
|||
row = self.get_mirrored_playlist_by_source(default_source, ref, profile_id)
|
||||
if row:
|
||||
return row
|
||||
if ref.isdigit():
|
||||
return self.get_mirrored_playlist(int(ref))
|
||||
# Fallback: bare numeric ref or a synthetic batch id (auto_mirror_<pk>,
|
||||
# youtube_mirrored_<pk>, mirrored_<pk>) whose trailing digits are the PK.
|
||||
from core.playlists.source_refs import extract_mirrored_pk
|
||||
pk = extract_mirrored_pk(ref)
|
||||
if pk is not None:
|
||||
return self.get_mirrored_playlist(pk)
|
||||
return None
|
||||
|
||||
def set_mirrored_playlist_organize_by_playlist(
|
||||
|
|
@ -13731,6 +13806,32 @@ class MusicDatabase:
|
|||
logger.error(f"Error updating organize_by_playlist for playlist {playlist_id}: {e}")
|
||||
return False
|
||||
|
||||
def set_mirrored_playlist_custom_name(self, playlist_id: int, custom_name) -> bool:
|
||||
"""Set or clear a user alias for a mirrored playlist.
|
||||
|
||||
A blank/None value CLEARS the alias (display + sync fall back to the
|
||||
upstream name). Touches only ``custom_name`` + ``updated_at``, leaving the
|
||||
upstream ``name`` and the tracks untouched — so the alias survives upstream
|
||||
refresh and never disturbs anything else (mirrors the source-ref/organize
|
||||
update pattern)."""
|
||||
value = (str(custom_name).strip() or None) if custom_name is not None else None
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE mirrored_playlists
|
||||
SET custom_name = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(value, playlist_id),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating custom_name for playlist {playlist_id}: {e}")
|
||||
return False
|
||||
|
||||
def get_mirrored_playlist_tracks(self, playlist_id: int) -> List[Dict]:
|
||||
"""Return all tracks for a mirrored playlist ordered by position."""
|
||||
try:
|
||||
|
|
|
|||
8
dev.py
8
dev.py
|
|
@ -316,6 +316,14 @@ def watch_and_run_backend() -> None:
|
|||
|
||||
|
||||
def main() -> int:
|
||||
# `--lan` exposes the dev server on the network (binds 0.0.0.0) so you can
|
||||
# reach it from another device at http://<this-pc-ip>:8008. Default stays
|
||||
# localhost-only. Set before build_backend_env() so both backend modes see it.
|
||||
if '--lan' in sys.argv:
|
||||
os.environ['SOULSYNC_WEB_BIND_HOST'] = '0.0.0.0'
|
||||
print('LAN mode ON — dev server reachable from other devices on your network '
|
||||
'(http://<this-pc-ip>:8008). Allow port 8008 through the firewall if needed.')
|
||||
|
||||
if not (ROOT_DIR / 'webui' / 'node_modules').is_dir():
|
||||
print('webui/node_modules is missing.')
|
||||
print('Run: cd webui && npm ci')
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@
|
|||
from pathlib import Path
|
||||
import os
|
||||
|
||||
bind = "127.0.0.1:8008"
|
||||
# Localhost-only by default (a dev server shouldn't expose itself to the LAN
|
||||
# without asking). To reach it from another device on your network, opt in with
|
||||
# SOULSYNC_WEB_BIND_HOST=0.0.0.0 python dev.py
|
||||
# then browse to http://<this-pc-lan-ip>:8008 (and allow port 8008 in the firewall).
|
||||
bind = f"{os.environ.get('SOULSYNC_WEB_BIND_HOST', '127.0.0.1')}:{os.environ.get('SOULSYNC_WEB_BIND_PORT', '8008')}"
|
||||
worker_class = "gthread"
|
||||
workers = 1
|
||||
threads = 4
|
||||
|
|
|
|||
|
|
@ -142,10 +142,7 @@ def _build_deps(engine, scan_mgr=None) -> AutomationDeps:
|
|||
duplicate_cleaner_lock=threading.Lock(),
|
||||
duplicate_cleaner_executor=None,
|
||||
run_duplicate_cleaner=lambda: None,
|
||||
get_quality_scanner_state=lambda: {},
|
||||
quality_scanner_lock=threading.Lock(),
|
||||
quality_scanner_executor=None,
|
||||
run_quality_scanner=lambda *a, **k: None,
|
||||
run_repair_job_now=lambda *a, **k: True,
|
||||
download_orchestrator=None,
|
||||
run_async=lambda coro: None,
|
||||
tasks_lock=threading.Lock(),
|
||||
|
|
@ -360,7 +357,6 @@ class TestHandlerInvocation:
|
|||
**{f.name: getattr(deps, f.name) for f in deps.__dataclass_fields__.values()},
|
||||
'get_db_update_state': lambda: running_state,
|
||||
'get_duplicate_cleaner_state': lambda: running_state,
|
||||
'get_quality_scanner_state': lambda: running_state,
|
||||
'get_download_batches': lambda: active_batches, # forces clean_completed_downloads to skip
|
||||
'get_database': lambda: _StubDB(),
|
||||
'get_app': lambda: _StubApp(),
|
||||
|
|
|
|||
|
|
@ -89,10 +89,7 @@ def _build_deps(**overrides) -> AutomationDeps:
|
|||
duplicate_cleaner_lock=threading.Lock(),
|
||||
duplicate_cleaner_executor=None,
|
||||
run_duplicate_cleaner=lambda: None,
|
||||
get_quality_scanner_state=lambda: {},
|
||||
quality_scanner_lock=threading.Lock(),
|
||||
quality_scanner_executor=None,
|
||||
run_quality_scanner=lambda *a, **k: None,
|
||||
run_repair_job_now=lambda *a, **k: True,
|
||||
download_orchestrator=None,
|
||||
run_async=lambda coro: None,
|
||||
tasks_lock=threading.Lock(),
|
||||
|
|
@ -185,11 +182,18 @@ class TestDuplicateCleaner:
|
|||
|
||||
|
||||
class TestQualityScanner:
|
||||
def test_already_running_returns_skipped(self):
|
||||
state = {'status': 'running'}
|
||||
deps = _build_deps(get_quality_scanner_state=lambda: state)
|
||||
def test_triggers_quality_upgrade_repair_job(self):
|
||||
triggered = []
|
||||
deps = _build_deps(run_repair_job_now=lambda job_id: triggered.append(job_id) or True)
|
||||
result = auto_start_quality_scan({}, deps)
|
||||
assert result == {'status': 'skipped', 'reason': 'Quality scan already running'}
|
||||
assert triggered == ['quality_upgrade']
|
||||
assert result['status'] == 'completed'
|
||||
assert result['triggered'] is True
|
||||
|
||||
def test_error_when_worker_unavailable(self):
|
||||
deps = _build_deps(run_repair_job_now=lambda job_id: None)
|
||||
result = auto_start_quality_scan({}, deps)
|
||||
assert result['status'] == 'error'
|
||||
|
||||
|
||||
# ─── clear_quarantine ────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -63,10 +63,7 @@ def _build_deps(**overrides) -> AutomationDeps:
|
|||
duplicate_cleaner_lock=threading.Lock(),
|
||||
duplicate_cleaner_executor=None,
|
||||
run_duplicate_cleaner=lambda: None,
|
||||
get_quality_scanner_state=lambda: {},
|
||||
quality_scanner_lock=threading.Lock(),
|
||||
quality_scanner_executor=None,
|
||||
run_quality_scanner=lambda *a, **k: None,
|
||||
run_repair_job_now=lambda *a, **k: True,
|
||||
download_orchestrator=None,
|
||||
run_async=lambda coro: None,
|
||||
tasks_lock=threading.Lock(),
|
||||
|
|
|
|||
|
|
@ -125,10 +125,7 @@ def _build_deps(**overrides) -> AutomationDeps:
|
|||
duplicate_cleaner_lock=threading.Lock(),
|
||||
duplicate_cleaner_executor=None,
|
||||
run_duplicate_cleaner=lambda: None,
|
||||
get_quality_scanner_state=lambda: {},
|
||||
quality_scanner_lock=threading.Lock(),
|
||||
quality_scanner_executor=None,
|
||||
run_quality_scanner=lambda *a, **k: None,
|
||||
run_repair_job_now=lambda *a, **k: True,
|
||||
download_orchestrator=None,
|
||||
run_async=lambda coro: None,
|
||||
tasks_lock=threading.Lock(),
|
||||
|
|
|
|||
|
|
@ -76,10 +76,7 @@ def _build_deps(**overrides: Any) -> AutomationDeps:
|
|||
duplicate_cleaner_lock=threading.Lock(),
|
||||
duplicate_cleaner_executor=None,
|
||||
run_duplicate_cleaner=lambda: None,
|
||||
get_quality_scanner_state=lambda: {},
|
||||
quality_scanner_lock=threading.Lock(),
|
||||
quality_scanner_executor=None,
|
||||
run_quality_scanner=lambda *a, **k: None,
|
||||
run_repair_job_now=lambda *a, **k: True,
|
||||
download_orchestrator=None,
|
||||
run_async=lambda coro: None,
|
||||
tasks_lock=threading.Lock(),
|
||||
|
|
|
|||
|
|
@ -41,10 +41,7 @@ def _minimal_deps(**overrides):
|
|||
duplicate_cleaner_lock=threading.Lock(),
|
||||
duplicate_cleaner_executor=None,
|
||||
run_duplicate_cleaner=lambda: None,
|
||||
get_quality_scanner_state=lambda: {},
|
||||
quality_scanner_lock=threading.Lock(),
|
||||
quality_scanner_executor=None,
|
||||
run_quality_scanner=lambda *a, **k: None,
|
||||
run_repair_job_now=lambda *a, **k: True,
|
||||
download_orchestrator=None,
|
||||
run_async=lambda coro: None,
|
||||
tasks_lock=threading.Lock(),
|
||||
|
|
|
|||
|
|
@ -62,10 +62,7 @@ def _build_deps(**overrides) -> AutomationDeps:
|
|||
duplicate_cleaner_lock=threading.Lock(),
|
||||
duplicate_cleaner_executor=None,
|
||||
run_duplicate_cleaner=lambda: None,
|
||||
get_quality_scanner_state=lambda: {},
|
||||
quality_scanner_lock=threading.Lock(),
|
||||
quality_scanner_executor=None,
|
||||
run_quality_scanner=lambda *a, **k: None,
|
||||
run_repair_job_now=lambda *a, **k: True,
|
||||
download_orchestrator=None,
|
||||
run_async=lambda coro: None,
|
||||
tasks_lock=threading.Lock(),
|
||||
|
|
|
|||
|
|
@ -113,12 +113,6 @@ _DEFAULT_STREAM_STATE = {
|
|||
"error_message": None,
|
||||
}
|
||||
|
||||
_DEFAULT_QUALITY_SCANNER_STATE = {
|
||||
"status": "running", "phase": "Scanning...", "progress": 35,
|
||||
"processed": 35, "total": 100, "quality_met": 30,
|
||||
"low_quality": 5, "matched": 2, "error_message": "", "results": [],
|
||||
}
|
||||
|
||||
_DEFAULT_DUPLICATE_CLEANER_STATE = {
|
||||
"status": "running", "phase": "Scanning...", "progress": 50,
|
||||
"files_scanned": 500, "total_files": 1000, "duplicates_found": 10,
|
||||
|
|
@ -257,7 +251,6 @@ enrichment_status = copy.deepcopy(_DEFAULT_ENRICHMENT_STATUS)
|
|||
|
||||
# Phase 4: Tool progress state
|
||||
stream_state = copy.deepcopy(_DEFAULT_STREAM_STATE)
|
||||
quality_scanner_state = copy.deepcopy(_DEFAULT_QUALITY_SCANNER_STATE)
|
||||
duplicate_cleaner_state = copy.deepcopy(_DEFAULT_DUPLICATE_CLEANER_STATE)
|
||||
retag_state = copy.deepcopy(_DEFAULT_RETAG_STATE)
|
||||
db_update_state = copy.deepcopy(_DEFAULT_DB_UPDATE_STATE)
|
||||
|
|
@ -350,13 +343,12 @@ ENRICHMENT_ENDPOINTS = {
|
|||
# Phase 4 helpers
|
||||
|
||||
TOOL_NAMES = [
|
||||
'stream', 'quality-scanner', 'duplicate-cleaner',
|
||||
'stream', 'duplicate-cleaner',
|
||||
'retag', 'db-update', 'metadata', 'logs',
|
||||
]
|
||||
|
||||
TOOL_ENDPOINTS = {
|
||||
'stream': '/api/stream/status',
|
||||
'quality-scanner': '/api/quality-scanner/status',
|
||||
'duplicate-cleaner': '/api/duplicate-cleaner/status',
|
||||
'retag': '/api/retag/status',
|
||||
'db-update': '/api/database/update/status',
|
||||
|
|
@ -374,10 +366,6 @@ def _build_stream_status():
|
|||
}
|
||||
|
||||
|
||||
def _build_quality_scanner_status():
|
||||
return dict(quality_scanner_state)
|
||||
|
||||
|
||||
def _build_duplicate_cleaner_status():
|
||||
state_copy = duplicate_cleaner_state.copy()
|
||||
state_copy["space_freed_mb"] = duplicate_cleaner_state["space_freed"] / (1024 * 1024)
|
||||
|
|
@ -419,7 +407,6 @@ def _build_tool_status(tool_name):
|
|||
"""Dispatcher that returns the correct status payload for any tool."""
|
||||
builders = {
|
||||
'stream': _build_stream_status,
|
||||
'quality-scanner': _build_quality_scanner_status,
|
||||
'duplicate-cleaner': _build_duplicate_cleaner_status,
|
||||
'retag': _build_retag_status,
|
||||
'db-update': _build_db_update_status,
|
||||
|
|
@ -635,10 +622,6 @@ def test_app():
|
|||
def stream_status_endpoint():
|
||||
return jsonify(_build_stream_status())
|
||||
|
||||
@app.route('/api/quality-scanner/status')
|
||||
def quality_scanner_status_endpoint():
|
||||
return jsonify(_build_quality_scanner_status())
|
||||
|
||||
@app.route('/api/duplicate-cleaner/status')
|
||||
def duplicate_cleaner_status_endpoint():
|
||||
return jsonify(_build_duplicate_cleaner_status())
|
||||
|
|
@ -961,7 +944,6 @@ def shared_state():
|
|||
'enrichment_endpoints': ENRICHMENT_ENDPOINTS,
|
||||
# Phase 4 state
|
||||
'stream_state': stream_state,
|
||||
'quality_scanner_state': quality_scanner_state,
|
||||
'duplicate_cleaner_state': duplicate_cleaner_state,
|
||||
'retag_state': retag_state,
|
||||
'db_update_state': db_update_state,
|
||||
|
|
@ -969,7 +951,6 @@ def shared_state():
|
|||
'logs_activities': logs_activities,
|
||||
'build_tool_status': _build_tool_status,
|
||||
'build_stream_status': _build_stream_status,
|
||||
'build_quality_scanner_status': _build_quality_scanner_status,
|
||||
'build_duplicate_cleaner_status': _build_duplicate_cleaner_status,
|
||||
'build_retag_status': _build_retag_status,
|
||||
'build_db_update_status': _build_db_update_status,
|
||||
|
|
@ -1019,8 +1000,6 @@ def reset_state():
|
|||
# Phase 4 resets
|
||||
stream_state.clear()
|
||||
stream_state.update(copy.deepcopy(_DEFAULT_STREAM_STATE))
|
||||
quality_scanner_state.clear()
|
||||
quality_scanner_state.update(copy.deepcopy(_DEFAULT_QUALITY_SCANNER_STATE))
|
||||
duplicate_cleaner_state.clear()
|
||||
duplicate_cleaner_state.update(copy.deepcopy(_DEFAULT_DUPLICATE_CLEANER_STATE))
|
||||
retag_state.clear()
|
||||
|
|
@ -1061,8 +1040,6 @@ def reset_state():
|
|||
enrichment_status.update(copy.deepcopy(_DEFAULT_ENRICHMENT_STATUS))
|
||||
stream_state.clear()
|
||||
stream_state.update(copy.deepcopy(_DEFAULT_STREAM_STATE))
|
||||
quality_scanner_state.clear()
|
||||
quality_scanner_state.update(copy.deepcopy(_DEFAULT_QUALITY_SCANNER_STATE))
|
||||
duplicate_cleaner_state.clear()
|
||||
duplicate_cleaner_state.update(copy.deepcopy(_DEFAULT_DUPLICATE_CLEANER_STATE))
|
||||
retag_state.clear()
|
||||
|
|
|
|||
|
|
@ -1,492 +0,0 @@
|
|||
"""Tests for core/discovery/quality_scanner.py — library quality scanner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
|
||||
from core.discovery import quality_scanner as qs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fakes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class _FakeSpotifyTrack:
|
||||
id: str = 'spt-1'
|
||||
name: str = 'Found'
|
||||
artists: list = None
|
||||
album: str = 'Found Album'
|
||||
duration_ms: int = 200000
|
||||
popularity: int = 50
|
||||
preview_url: str = ''
|
||||
external_urls: dict = None
|
||||
album_type: str = 'album'
|
||||
release_date: str = '2024-01-01'
|
||||
|
||||
def __post_init__(self):
|
||||
if self.artists is None:
|
||||
self.artists = ['Found Artist']
|
||||
if self.external_urls is None:
|
||||
self.external_urls = {}
|
||||
|
||||
|
||||
class _FakeMetadataClient:
|
||||
def __init__(self, results=None):
|
||||
self._results = results if results is not None else []
|
||||
self.search_calls = []
|
||||
|
||||
def search_tracks(self, query, limit=5, allow_fallback=True):
|
||||
self.search_calls.append((query, limit, allow_fallback))
|
||||
return self._results
|
||||
|
||||
|
||||
_TEST_PRIMARY_SOURCE = 'spotify'
|
||||
_TEST_SOURCE_CLIENTS = {}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_source_resolution(monkeypatch):
|
||||
monkeypatch.setattr(qs, 'get_primary_source', lambda: _TEST_PRIMARY_SOURCE)
|
||||
monkeypatch.setattr(qs, 'get_client_for_source', lambda source, **_kwargs: _TEST_SOURCE_CLIENTS.get(source))
|
||||
monkeypatch.setattr(qs.time, 'sleep', lambda *_args, **_kwargs: None)
|
||||
yield
|
||||
_TEST_SOURCE_CLIENTS.clear()
|
||||
globals()['_TEST_PRIMARY_SOURCE'] = 'spotify'
|
||||
|
||||
|
||||
class _FakeMatchingEngine:
|
||||
def generate_download_queries(self, t):
|
||||
return [f"{t.artists[0]} {t.name}"]
|
||||
|
||||
def normalize_string(self, s):
|
||||
return (s or '').lower().strip()
|
||||
|
||||
def similarity_score(self, a, b):
|
||||
if a == b:
|
||||
return 1.0
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
return 0.95 if a in b or b in a else 0.0
|
||||
|
||||
|
||||
class _MultiQueryMatchingEngine(_FakeMatchingEngine):
|
||||
def generate_download_queries(self, t):
|
||||
return [
|
||||
f"{t.artists[0]} {t.name} first",
|
||||
f"{t.artists[0]} {t.name} second",
|
||||
]
|
||||
|
||||
|
||||
class _FakeAutomationEngine:
|
||||
def __init__(self):
|
||||
self.events = []
|
||||
|
||||
def emit(self, event_type, data):
|
||||
self.events.append((event_type, data))
|
||||
|
||||
|
||||
class _FakeWishlistService:
|
||||
def __init__(self):
|
||||
self.added = []
|
||||
|
||||
def add_spotify_track_to_wishlist(self, **kwargs):
|
||||
self.added.append(kwargs)
|
||||
return True
|
||||
|
||||
|
||||
class _FakeMusicDB:
|
||||
def __init__(self, watchlist_artists=None, tracks=None, profile=None):
|
||||
self._watchlist_artists = watchlist_artists if watchlist_artists is not None else []
|
||||
self._tracks = tracks if tracks is not None else []
|
||||
self._profile = profile or {'qualities': {'flac': {'enabled': True}}}
|
||||
|
||||
def get_quality_profile(self):
|
||||
return self._profile
|
||||
|
||||
def get_watchlist_artists(self, profile_id=1):
|
||||
return self._watchlist_artists
|
||||
|
||||
def _get_connection(self):
|
||||
rows = self._tracks
|
||||
return _FakeConn(rows)
|
||||
|
||||
|
||||
class _FakeConn:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def execute(self, query, params=None):
|
||||
return _FakeCursor(self._rows)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _FakeCursor:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def fetchall(self):
|
||||
return self._rows
|
||||
|
||||
|
||||
@dataclass
|
||||
class _WatchlistArtist:
|
||||
artist_name: str
|
||||
|
||||
|
||||
def _build_deps(
|
||||
*,
|
||||
state=None,
|
||||
source_clients=None,
|
||||
primary_source='spotify',
|
||||
quality_tier_result=('lossless', 1),
|
||||
automation=None,
|
||||
):
|
||||
globals()['_TEST_PRIMARY_SOURCE'] = primary_source
|
||||
_TEST_SOURCE_CLIENTS.clear()
|
||||
if source_clients is not None:
|
||||
_TEST_SOURCE_CLIENTS.update(source_clients)
|
||||
elif primary_source:
|
||||
_TEST_SOURCE_CLIENTS[primary_source] = _FakeMetadataClient(results=[])
|
||||
|
||||
deps = qs.QualityScannerDeps(
|
||||
quality_scanner_state=state if state is not None else {},
|
||||
quality_scanner_lock=threading.Lock(),
|
||||
QUALITY_TIERS={'lossless': {'tier': 1}, 'low_lossy': {'tier': 4}, 'lossy': {'tier': 3}},
|
||||
matching_engine=_FakeMatchingEngine(),
|
||||
automation_engine=automation or _FakeAutomationEngine(),
|
||||
get_quality_tier_from_extension=lambda fp: quality_tier_result,
|
||||
add_activity_item=lambda *a, **kw: None,
|
||||
)
|
||||
return deps
|
||||
|
||||
|
||||
def _track_row(track_id=1, title='Track', artist_id=1, album_id=1,
|
||||
file_path='/x.mp3', bitrate=128, artist_name='Artist',
|
||||
album_title='Album'):
|
||||
return (track_id, title, artist_id, album_id, file_path, bitrate, artist_name, album_title)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db_and_wishlist(monkeypatch):
|
||||
"""Patches MusicDatabase and get_wishlist_service used inside the worker."""
|
||||
db = _FakeMusicDB()
|
||||
ws = _FakeWishlistService()
|
||||
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
||||
monkeypatch.setattr('core.wishlist_service.get_wishlist_service', lambda: ws)
|
||||
return db, ws
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State init + DB load
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_state_initialized_on_run(mock_db_and_wishlist):
|
||||
"""Scanner resets state to running with cleared counters."""
|
||||
db, _ = mock_db_and_wishlist
|
||||
db._watchlist_artists = [] # no artists → exits early but after init
|
||||
state = {}
|
||||
deps = _build_deps(state=state)
|
||||
|
||||
qs.run_quality_scanner('watchlist', 1, deps)
|
||||
|
||||
assert state['status'] == 'finished' # exited early since no artists
|
||||
assert state['error_message'] == 'Please add artists to watchlist first'
|
||||
|
||||
|
||||
def test_no_watchlist_artists_short_circuit(mock_db_and_wishlist):
|
||||
"""Scope=watchlist with no artists → status=finished, error message."""
|
||||
db, _ = mock_db_and_wishlist
|
||||
db._watchlist_artists = []
|
||||
state = {}
|
||||
deps = _build_deps(state=state)
|
||||
|
||||
qs.run_quality_scanner('watchlist', 1, deps)
|
||||
|
||||
assert state['status'] == 'finished'
|
||||
assert 'add artists' in state['error_message']
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider availability gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_no_available_provider_marks_error(mock_db_and_wishlist):
|
||||
"""No available metadata providers → state['status']='error'."""
|
||||
db, _ = mock_db_and_wishlist
|
||||
db._watchlist_artists = [_WatchlistArtist('A')]
|
||||
db._tracks = [_track_row()]
|
||||
state = {}
|
||||
deps = _build_deps(state=state, source_clients={}, quality_tier_result=('low_lossy', 4))
|
||||
|
||||
qs.run_quality_scanner('watchlist', 1, deps)
|
||||
|
||||
assert state['status'] == 'error'
|
||||
assert 'metadata provider' in state['error_message'].lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Quality tier check + skip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_high_quality_tracks_skipped(mock_db_and_wishlist):
|
||||
"""Tracks meeting quality (tier_num <= min_acceptable) → quality_met += 1."""
|
||||
db, _ = mock_db_and_wishlist
|
||||
db._watchlist_artists = [_WatchlistArtist('A')]
|
||||
db._tracks = [_track_row(file_path='/x.flac')]
|
||||
state = {}
|
||||
# Default min_acceptable is from {flac: enabled} → tier 1 (lossless)
|
||||
# quality_tier_result=('lossless', 1) → 1 <= 1 → skip
|
||||
deps = _build_deps(state=state, quality_tier_result=('lossless', 1))
|
||||
|
||||
qs.run_quality_scanner('watchlist', 1, deps)
|
||||
|
||||
assert state['quality_met'] == 1
|
||||
assert state['low_quality'] == 0
|
||||
|
||||
|
||||
def test_low_quality_tracks_attempted(mock_db_and_wishlist):
|
||||
"""Low-quality tracks (tier_num > min) trigger a metadata search."""
|
||||
db, _ = mock_db_and_wishlist
|
||||
db._watchlist_artists = [_WatchlistArtist('Artist')]
|
||||
db._tracks = [_track_row(file_path='/x.mp3', artist_name='Artist', title='Track')]
|
||||
state = {}
|
||||
match = _FakeSpotifyTrack(name='Track', artists=['Artist'])
|
||||
spotify_client = _FakeMetadataClient(results=[match])
|
||||
deps = _build_deps(
|
||||
state=state,
|
||||
quality_tier_result=('low_lossy', 4),
|
||||
source_clients={'spotify': spotify_client},
|
||||
primary_source='spotify',
|
||||
)
|
||||
|
||||
qs.run_quality_scanner('watchlist', 1, deps)
|
||||
|
||||
assert state['low_quality'] == 1
|
||||
assert spotify_client.search_calls
|
||||
assert spotify_client.search_calls[0][2] is False
|
||||
|
||||
|
||||
def test_client_lookup_happens_per_query(mock_db_and_wishlist, monkeypatch):
|
||||
"""Each generated query re-resolves the source client."""
|
||||
db, _ = mock_db_and_wishlist
|
||||
db._watchlist_artists = [_WatchlistArtist('Artist')]
|
||||
db._tracks = [_track_row(file_path='/x.mp3', artist_name='Artist', title='Track')]
|
||||
state = {}
|
||||
spotify_client = _FakeMetadataClient(results=[])
|
||||
lookups = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
qs,
|
||||
'get_client_for_source',
|
||||
lambda source, **_kwargs: (lookups.append(source), _TEST_SOURCE_CLIENTS.get(source))[1],
|
||||
)
|
||||
|
||||
deps = _build_deps(
|
||||
state=state,
|
||||
quality_tier_result=('low_lossy', 4),
|
||||
source_clients={'spotify': spotify_client},
|
||||
primary_source='spotify',
|
||||
)
|
||||
deps.matching_engine = _MultiQueryMatchingEngine()
|
||||
|
||||
qs.run_quality_scanner('watchlist', 1, deps)
|
||||
|
||||
assert len(lookups) % 2 == 0
|
||||
midpoint = len(lookups) // 2
|
||||
assert lookups[:midpoint] == lookups[midpoint:]
|
||||
assert len(spotify_client.search_calls) == 2
|
||||
|
||||
|
||||
def test_low_quality_tracks_follow_source_priority(mock_db_and_wishlist):
|
||||
"""Primary source is searched before Spotify."""
|
||||
db, ws = mock_db_and_wishlist
|
||||
db._watchlist_artists = [_WatchlistArtist('Artist')]
|
||||
db._tracks = [_track_row(file_path='/x.mp3', artist_name='Artist', title='Track')]
|
||||
state = {}
|
||||
match = _FakeSpotifyTrack(name='Track', artists=['Artist'])
|
||||
deezer_client = _FakeMetadataClient(results=[match])
|
||||
spotify_client = _FakeMetadataClient(results=[])
|
||||
deps = _build_deps(
|
||||
state=state,
|
||||
source_clients={'deezer': deezer_client, 'spotify': spotify_client},
|
||||
primary_source='deezer',
|
||||
quality_tier_result=('low_lossy', 4),
|
||||
)
|
||||
|
||||
qs.run_quality_scanner('watchlist', 1, deps)
|
||||
|
||||
assert state['matched'] == 1
|
||||
assert deezer_client.search_calls
|
||||
assert spotify_client.search_calls == []
|
||||
assert len(ws.added) == 1
|
||||
add_args = ws.added[0]
|
||||
assert add_args['track_data']['provider'] == 'deezer'
|
||||
assert add_args['track_data']['source'] == 'deezer'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Match → wishlist add
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_match_adds_to_wishlist(mock_db_and_wishlist):
|
||||
"""High-confidence match → wishlist_service.add_spotify_track_to_wishlist called."""
|
||||
db, ws = mock_db_and_wishlist
|
||||
db._watchlist_artists = [_WatchlistArtist('Artist')]
|
||||
db._tracks = [_track_row(artist_name='Artist', title='Track', file_path='/x.mp3', bitrate=128)]
|
||||
state = {}
|
||||
match = _FakeSpotifyTrack(name='Track', artists=['Artist'])
|
||||
deps = _build_deps(
|
||||
state=state,
|
||||
quality_tier_result=('low_lossy', 4),
|
||||
source_clients={'spotify': _FakeMetadataClient(results=[match])},
|
||||
primary_source='spotify',
|
||||
)
|
||||
|
||||
qs.run_quality_scanner('watchlist', 1, deps)
|
||||
|
||||
assert state['matched'] == 1
|
||||
assert len(ws.added) == 1
|
||||
add_args = ws.added[0]
|
||||
assert add_args['source_type'] == 'quality_scanner'
|
||||
assert add_args['source_context']['original_file_path'] == '/x.mp3'
|
||||
|
||||
|
||||
def test_match_preserves_album_and_artist_images(mock_db_and_wishlist):
|
||||
"""Image metadata from the provider payload should survive the wishlist handoff."""
|
||||
db, ws = mock_db_and_wishlist
|
||||
db._watchlist_artists = [_WatchlistArtist('Artist')]
|
||||
db._tracks = [_track_row(artist_name='Artist', title='Track', file_path='/x.mp3', bitrate=128)]
|
||||
state = {}
|
||||
match = {
|
||||
'id': 'sp-1',
|
||||
'name': 'Track',
|
||||
'artists': [{'name': 'Artist', 'image_url': 'https://example.test/artist.jpg'}],
|
||||
'album': 'Album',
|
||||
'image_url': 'https://example.test/cover.jpg',
|
||||
'duration_ms': 200000,
|
||||
'popularity': 50,
|
||||
'external_urls': {},
|
||||
'album_type': 'album',
|
||||
'release_date': '2024-01-01',
|
||||
}
|
||||
deps = _build_deps(
|
||||
state=state,
|
||||
quality_tier_result=('low_lossy', 4),
|
||||
source_clients={'spotify': _FakeMetadataClient(results=[match])},
|
||||
primary_source='spotify',
|
||||
)
|
||||
|
||||
qs.run_quality_scanner('watchlist', 1, deps)
|
||||
|
||||
assert state['matched'] == 1
|
||||
assert len(ws.added) == 1
|
||||
add_args = ws.added[0]
|
||||
assert add_args['track_data']['image_url'] == 'https://example.test/cover.jpg'
|
||||
assert add_args['track_data']['album']['image_url'] == 'https://example.test/cover.jpg'
|
||||
assert add_args['track_data']['album']['images'] == [{'url': 'https://example.test/cover.jpg'}]
|
||||
assert add_args['track_data']['artists'][0]['image_url'] == 'https://example.test/artist.jpg'
|
||||
|
||||
|
||||
def test_no_match_no_wishlist_add(mock_db_and_wishlist):
|
||||
"""No match found → no wishlist add, matched stays 0."""
|
||||
db, ws = mock_db_and_wishlist
|
||||
db._watchlist_artists = [_WatchlistArtist('A')]
|
||||
db._tracks = [_track_row(artist_name='A', title='Z', file_path='/x.mp3')]
|
||||
state = {}
|
||||
# No spotify results → no match
|
||||
deps = _build_deps(
|
||||
state=state,
|
||||
quality_tier_result=('low_lossy', 4),
|
||||
source_clients={'spotify': _FakeMetadataClient(results=[])},
|
||||
primary_source='spotify',
|
||||
)
|
||||
|
||||
qs.run_quality_scanner('watchlist', 1, deps)
|
||||
|
||||
assert state['matched'] == 0
|
||||
assert ws.added == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stop request gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_stop_request_halts_loop(mock_db_and_wishlist):
|
||||
"""Setting state['status'] != 'running' mid-loop halts processing."""
|
||||
db, _ = mock_db_and_wishlist
|
||||
db._watchlist_artists = [_WatchlistArtist('A')]
|
||||
db._tracks = [_track_row(track_id=1), _track_row(track_id=2)]
|
||||
state = {}
|
||||
deps = _build_deps(state=state, quality_tier_result=('lossless', 1))
|
||||
|
||||
# Override get_quality_tier_from_extension to set stop after first track
|
||||
call_count = [0]
|
||||
|
||||
def stop_after_first(fp):
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
# Set status to non-running BEFORE second track iter checks
|
||||
with deps.quality_scanner_lock:
|
||||
state['status'] = 'stopping'
|
||||
return ('lossless', 1)
|
||||
|
||||
deps.get_quality_tier_from_extension = stop_after_first
|
||||
|
||||
qs.run_quality_scanner('watchlist', 1, deps)
|
||||
|
||||
# Only first track processed
|
||||
assert state['quality_met'] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Completion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_completion_marks_finished(mock_db_and_wishlist):
|
||||
"""All tracks processed → status='finished', progress=100."""
|
||||
db, _ = mock_db_and_wishlist
|
||||
db._watchlist_artists = [_WatchlistArtist('A')]
|
||||
db._tracks = [_track_row()]
|
||||
state = {}
|
||||
deps = _build_deps(state=state)
|
||||
|
||||
qs.run_quality_scanner('watchlist', 1, deps)
|
||||
|
||||
assert state['status'] == 'finished'
|
||||
assert state['progress'] == 100
|
||||
|
||||
|
||||
def test_automation_event_emitted(mock_db_and_wishlist):
|
||||
"""Successful completion emits 'quality_scan_completed' on automation engine."""
|
||||
db, _ = mock_db_and_wishlist
|
||||
db._watchlist_artists = [_WatchlistArtist('A')]
|
||||
db._tracks = [_track_row()]
|
||||
automation = _FakeAutomationEngine()
|
||||
state = {}
|
||||
deps = _build_deps(state=state, automation=automation)
|
||||
|
||||
qs.run_quality_scanner('watchlist', 1, deps)
|
||||
|
||||
assert any(name == 'quality_scan_completed' for name, _ in automation.events)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# All-library scope
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_scope_all_loads_all_tracks(mock_db_and_wishlist):
|
||||
"""scope != 'watchlist' loads all tracks (no watchlist filter)."""
|
||||
db, _ = mock_db_and_wishlist
|
||||
db._tracks = [_track_row(track_id=1), _track_row(track_id=2)]
|
||||
state = {}
|
||||
deps = _build_deps(state=state)
|
||||
|
||||
qs.run_quality_scanner('all', 1, deps)
|
||||
|
||||
assert state['total'] == 2
|
||||
|
|
@ -1048,8 +1048,12 @@ def test_wishlist_album_grouping_uses_shared_rich_album_context(monkeypatch):
|
|||
assert images == {'http://img'}
|
||||
|
||||
|
||||
def test_playlist_folder_mode_propagates(monkeypatch):
|
||||
"""Playlist folder mode flag carried through to track_info."""
|
||||
def test_organize_by_playlist_keeps_provenance_not_routing(monkeypatch):
|
||||
"""Organize-by-playlist no longer routes the file per-track. Each track imports
|
||||
NORMALLY into Artist/Album (what a normal download does); the playlist folder
|
||||
is built as links AFTER the batch. So the old routing flag
|
||||
`_playlist_folder_mode` is NOT set, but `_playlist_name` provenance IS kept
|
||||
(core/downloads/origin.py)."""
|
||||
db = _FakeDB()
|
||||
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
||||
|
||||
|
|
@ -1062,8 +1066,8 @@ def test_playlist_folder_mode_propagates(monkeypatch):
|
|||
|
||||
task_id = download_batches['B15']['queue'][0]
|
||||
info = download_tasks[task_id]['track_info']
|
||||
assert info['_playlist_folder_mode'] is True
|
||||
assert info['_playlist_name'] == 'My Mix'
|
||||
assert '_playlist_folder_mode' not in info # routing retired → normal import
|
||||
assert info['_playlist_name'] == 'My Mix' # provenance preserved
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -187,6 +187,24 @@ def test_reset_builder_nulls_status_not_just_attempted():
|
|||
assert "WHERE spotify_match_status = 'not_found'" in sql
|
||||
|
||||
|
||||
def test_reset_builder_also_clears_artist_source_id():
|
||||
# #868: a re-match must forget the stored id so the worker actually
|
||||
# re-resolves (otherwise its existing-id short-circuit re-confirms the wrong
|
||||
# same-name artist).
|
||||
for service, col in [('spotify', 'spotify_artist_id'), ('itunes', 'itunes_artist_id'),
|
||||
('deezer', 'deezer_id'), ('musicbrainz', 'musicbrainz_id')]:
|
||||
sql, _ = build_reset_query(service, 'artist', 'item', entity_id=5)
|
||||
assert f'{col} = NULL' in sql, f'{service}: expected {col} cleared'
|
||||
assert f'{service}_match_status = NULL' in sql
|
||||
|
||||
|
||||
def test_reset_builder_does_not_clear_track_id():
|
||||
# Tracks have no source-id column (ids live in tags) — must not emit one.
|
||||
sql, _ = build_reset_query('spotify', 'track', 'item', entity_id=5)
|
||||
assert 'spotify_match_status = NULL' in sql
|
||||
assert 'spotify_track_id = NULL' not in sql
|
||||
|
||||
|
||||
def test_reset_item_requeues_to_pending(db):
|
||||
n = db.reset_enrichment('spotify', 'artist', 'item', entity_id='a2') # was not_found
|
||||
assert n == 1
|
||||
|
|
|
|||
391
tests/repair_jobs/test_quality_upgrade.py
Normal file
391
tests/repair_jobs/test_quality_upgrade.py
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
"""Quality Upgrade Finder job — the findings-based replacement for the old
|
||||
auto-acting Quality Scanner.
|
||||
|
||||
The old tool judged quality by file EXTENSION only and used min() of the enabled
|
||||
tiers, so with the default profile (FLAC + MP3-320 + MP3-256 enabled) it flagged
|
||||
EVERY non-lossless file — a 320 kbps MP3 included — and dumped them all into the
|
||||
wishlist with no review. These tests pin the corrected behavior: bitrate-aware,
|
||||
honors every enabled bucket, and only proposes (findings) rather than auto-acting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
|
||||
import core.repair_jobs.quality_upgrade as qu
|
||||
from core.repair_jobs.base import JobContext, JobResult
|
||||
|
||||
|
||||
# Profiles ------------------------------------------------------------------
|
||||
|
||||
BALANCED = { # default: FLAC + MP3-320 + MP3-256 enabled, MP3-192 off
|
||||
'qualities': {
|
||||
'flac': {'enabled': True, 'min_kbps': 500},
|
||||
'mp3_320': {'enabled': True, 'min_kbps': 280},
|
||||
'mp3_256': {'enabled': True, 'min_kbps': 200},
|
||||
'mp3_192': {'enabled': False, 'min_kbps': 150},
|
||||
}
|
||||
}
|
||||
LOSSLESS_ONLY = {
|
||||
'qualities': {
|
||||
'flac': {'enabled': True, 'min_kbps': 500},
|
||||
'mp3_320': {'enabled': False, 'min_kbps': 280},
|
||||
'mp3_256': {'enabled': False, 'min_kbps': 200},
|
||||
'mp3_192': {'enabled': False, 'min_kbps': 150},
|
||||
}
|
||||
}
|
||||
NOTHING_ENABLED = {'qualities': {'flac': {'enabled': False}, 'mp3_320': {'enabled': False}}}
|
||||
|
||||
|
||||
# --- pure quality decision -------------------------------------------------
|
||||
|
||||
def test_balanced_profile_accepts_320_mp3_REGRESSION():
|
||||
"""The headline bug: with FLAC+320+256 enabled, a 320 kbps MP3 is acceptable.
|
||||
The old min()-tier logic flagged it (and every other MP3) for re-download."""
|
||||
assert meets('song.mp3', 320, BALANCED) is True
|
||||
|
||||
|
||||
def test_balanced_profile_accepts_256_mp3():
|
||||
assert meets('song.mp3', 256, BALANCED) is True
|
||||
|
||||
|
||||
def test_balanced_profile_flags_low_bitrate_mp3():
|
||||
assert meets('song.mp3', 128, BALANCED) is False
|
||||
assert meets('song.mp3', 192, BALANCED) is False # below the 256 floor
|
||||
|
||||
|
||||
def test_flac_always_meets_when_flac_enabled():
|
||||
assert meets('song.flac', 900, BALANCED) is True
|
||||
assert meets('song.flac', 900, LOSSLESS_ONLY) is True
|
||||
|
||||
|
||||
def test_lossless_only_flags_every_lossy_regardless_of_bitrate():
|
||||
assert meets('song.mp3', 320, LOSSLESS_ONLY) is False
|
||||
assert meets('song.m4a', 256, LOSSLESS_ONLY) is False
|
||||
|
||||
|
||||
def test_nothing_enabled_flags_nothing():
|
||||
"""Empty/disabled profile must NOT flag the whole library."""
|
||||
assert meets('song.mp3', 64, NOTHING_ENABLED) is True
|
||||
|
||||
|
||||
def test_bitrate_in_bps_is_normalized():
|
||||
"""Library bitrate stored as bps (320000) classifies the same as 320 kbps."""
|
||||
assert qu.classify_track_quality('song.mp3', 320000) == qu.RANK_320
|
||||
assert meets('song.mp3', 320000, BALANCED) is True
|
||||
|
||||
|
||||
def test_unknown_lossy_bitrate_not_flagged_under_lossy_floor():
|
||||
"""A lossy file with no bitrate can't be judged against a lossy floor → don't
|
||||
flag (avoid false positives); but under a lossless floor it's clearly below."""
|
||||
assert meets('song.mp3', None, BALANCED) is True
|
||||
assert meets('song.mp3', None, LOSSLESS_ONLY) is False
|
||||
|
||||
|
||||
def test_floor_is_worst_enabled_not_best():
|
||||
# FLAC+320+256 enabled → floor is MP3-256 (rank 2), not FLAC.
|
||||
assert qu.preferred_quality_floor(BALANCED) == qu.RANK_256
|
||||
assert qu.preferred_quality_floor(LOSSLESS_ONLY) == qu.RANK_LOSSLESS
|
||||
assert qu.preferred_quality_floor(NOTHING_ENABLED) is None
|
||||
|
||||
|
||||
def meets(path, bitrate, profile):
|
||||
return qu.meets_preferred_quality(path, bitrate, profile)
|
||||
|
||||
|
||||
# --- scan produces a finding (seam) ----------------------------------------
|
||||
|
||||
class _FakeConn:
|
||||
def __init__(self, rows, finding_ids=()):
|
||||
self._rows = rows
|
||||
self._finding_ids = list(finding_ids)
|
||||
self._sql = ''
|
||||
|
||||
def execute(self, sql='', *a, **k):
|
||||
self._sql = sql or ''
|
||||
return self
|
||||
|
||||
def fetchall(self):
|
||||
# The existing-findings query reads repair_findings; everything else is the
|
||||
# track load.
|
||||
if 'repair_findings' in self._sql:
|
||||
return [(fid,) for fid in self._finding_ids]
|
||||
return self._rows
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
def __init__(self, rows, profile, finding_ids=()):
|
||||
self._rows = rows
|
||||
self._profile = profile
|
||||
self._finding_ids = finding_ids
|
||||
|
||||
def get_quality_profile(self):
|
||||
return self._profile
|
||||
|
||||
def _get_connection(self):
|
||||
return _FakeConn(self._rows, self._finding_ids)
|
||||
|
||||
def get_watchlist_artists(self, profile_id=1):
|
||||
return [types.SimpleNamespace(artist_name='Artist A')]
|
||||
|
||||
|
||||
def _ctx(db, findings):
|
||||
return JobContext(
|
||||
db=db,
|
||||
transfer_folder='/tmp',
|
||||
config_manager=None,
|
||||
create_finding=lambda **kw: findings.append(kw) or True,
|
||||
should_stop=lambda: False,
|
||||
is_paused=lambda: False,
|
||||
)
|
||||
|
||||
|
||||
def _row(track_id=1, title='Song One', path='/music/a.mp3', bitrate=128, duration=180000,
|
||||
artist='Artist A', album='Album X', album_id=10, track_number=6):
|
||||
"""A track row in _TRACK_COLS order (album source-id columns default to None)."""
|
||||
return (track_id, title, path, bitrate, duration, artist, album, album_id, track_number)
|
||||
|
||||
|
||||
def _stub_engine(monkeypatch):
|
||||
monkeypatch.setattr(qu, 'get_primary_source', lambda: 'spotify')
|
||||
monkeypatch.setattr(qu, 'get_source_priority', lambda src: ['spotify'])
|
||||
monkeypatch.setattr(
|
||||
'core.matching_engine.MusicMatchingEngine',
|
||||
lambda: types.SimpleNamespace(
|
||||
generate_download_queries=lambda t: ['q'],
|
||||
similarity_score=lambda a, b: 1.0,
|
||||
normalize_string=lambda s: s,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_scan_creates_finding_for_low_quality_track(monkeypatch):
|
||||
db = _FakeDB([_row(bitrate=128)], BALANCED)
|
||||
_stub_engine(monkeypatch)
|
||||
fake_match = {'id': 'sp1', 'name': 'Song One', 'artists': ['Artist A'],
|
||||
'album': {'name': 'Album X', 'images': []}}
|
||||
# No track-id / ISRC / album hit → exercise the search tier.
|
||||
monkeypatch.setattr(qu, '_read_file_ids', lambda fp: {})
|
||||
monkeypatch.setattr(qu, '_match_via_track_id', lambda *a, **k: (None, None))
|
||||
monkeypatch.setattr(qu, '_match_via_album', lambda *a, **k: (None, None))
|
||||
monkeypatch.setattr(qu, '_find_best_match',
|
||||
lambda *a, **k: (fake_match, 0.95, 'spotify', True))
|
||||
monkeypatch.setattr(qu, '_normalize_track_match', lambda track, src: dict(fake_match))
|
||||
monkeypatch.setattr(qu, '_track_name', lambda t: 'Song One')
|
||||
|
||||
findings = []
|
||||
result = qu.QualityUpgradeJob().scan(_ctx(db, findings))
|
||||
|
||||
assert result.findings_created == 1
|
||||
assert len(findings) == 1
|
||||
f = findings[0]
|
||||
assert f['finding_type'] == 'quality_upgrade'
|
||||
assert f['entity_id'] == '1'
|
||||
# Album context + matched track carried for the apply step.
|
||||
assert f['details']['matched_track_data']['id'] == 'sp1'
|
||||
assert f['details']['album_title'] == 'Album X'
|
||||
assert f['details']['provider'] == 'spotify'
|
||||
|
||||
|
||||
def test_match_via_track_id_fetches_exact_by_id(monkeypatch):
|
||||
"""Most-direct tier: a per-source track ID in the tags → get_track_details by ID."""
|
||||
track = {'id': 'sp9', 'name': 'Song One', 'album': {'name': 'Album X'}}
|
||||
client = types.SimpleNamespace(get_track_details=lambda tid: track if tid == 'sp9' else None)
|
||||
monkeypatch.setattr(qu, 'get_client_for_source', lambda src: client)
|
||||
best, source = qu._match_via_track_id({'spotify_track_id': 'sp9'}, ['spotify'])
|
||||
assert best['id'] == 'sp9'
|
||||
assert source == 'spotify'
|
||||
assert qu._match_via_track_id({}, ['spotify']) == (None, None) # no ID → nothing
|
||||
|
||||
|
||||
def test_duration_ok_guard():
|
||||
assert qu._duration_ok(180000, 181000) is True # within 5s
|
||||
assert qu._duration_ok(180000, 200000) is False # 20s off — wrong cut
|
||||
assert qu._duration_ok(None, 200000) is True # unknown → lenient
|
||||
assert qu._duration_ok(180000, 0) is True # unknown → lenient
|
||||
|
||||
|
||||
def test_scan_prefers_track_id_tier(monkeypatch):
|
||||
"""The source's own track ID (from file tags) wins over every other tier."""
|
||||
db = _FakeDB([_row()], BALANCED)
|
||||
_stub_engine(monkeypatch)
|
||||
monkeypatch.setattr(qu, '_read_file_ids', lambda fp: {'spotify_track_id': 'sp9', 'isrc': 'X'})
|
||||
fake = {'id': 'sp9', 'name': 'Song One', 'album': {'name': 'Album X'}}
|
||||
monkeypatch.setattr(qu, '_match_via_track_id', lambda ids, sp: (fake, 'spotify'))
|
||||
monkeypatch.setattr(qu, '_normalize_track_match', lambda t, s: dict(fake))
|
||||
monkeypatch.setattr(qu, '_track_name', lambda t: 'Song One')
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise AssertionError("no lower tier should run when the track-ID tier matches")
|
||||
monkeypatch.setattr(qu, '_match_via_isrc', _boom)
|
||||
monkeypatch.setattr(qu, '_match_via_album', _boom)
|
||||
monkeypatch.setattr(qu, '_find_best_match', _boom)
|
||||
|
||||
findings = []
|
||||
result = qu.QualityUpgradeJob().scan(_ctx(db, findings))
|
||||
assert result.findings_created == 1
|
||||
assert findings[0]['details']['matched_via'] == 'track_id'
|
||||
|
||||
|
||||
def test_scan_skips_already_proposed_tracks(monkeypatch):
|
||||
"""A re-run must not re-resolve a track that already has a finding."""
|
||||
db = _FakeDB([_row(track_id=1)], BALANCED, finding_ids=['1'])
|
||||
monkeypatch.setattr(qu, 'get_primary_source', lambda: 'spotify')
|
||||
monkeypatch.setattr(qu, 'get_source_priority', lambda src: ['spotify'])
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise AssertionError("no matching for an already-proposed track")
|
||||
monkeypatch.setattr(qu, '_match_via_track_id', _boom)
|
||||
monkeypatch.setattr(qu, '_find_best_match', _boom)
|
||||
|
||||
findings = []
|
||||
result = qu.QualityUpgradeJob().scan(_ctx(db, findings))
|
||||
assert findings == []
|
||||
assert result.findings_skipped_dedup == 1
|
||||
|
||||
|
||||
def test_match_via_isrc_accepts_exact_match(monkeypatch):
|
||||
"""The guard accepts only a candidate whose own ISRC equals ours (dash/case
|
||||
insensitive), so it survives a source returning unrelated hits first."""
|
||||
monkeypatch.setattr(qu, 'get_client_for_source',
|
||||
lambda src: types.SimpleNamespace(search_tracks=lambda *a, **k: []))
|
||||
monkeypatch.setattr(qu, '_search_tracks_for_source', lambda *a, **k: [
|
||||
{'id': 'x', 'name': 'Wrong', 'isrc': 'ZZISRC000000'},
|
||||
{'id': 'sp1', 'name': 'Right', 'isrc': 'US-RC1-76-07839'}, # dashed form
|
||||
])
|
||||
best, source = qu._match_via_isrc('USRC17607839', ['spotify'])
|
||||
assert best['id'] == 'sp1'
|
||||
assert source == 'spotify'
|
||||
|
||||
|
||||
def test_match_via_isrc_rejects_all_mismatches(monkeypatch):
|
||||
monkeypatch.setattr(qu, 'get_client_for_source',
|
||||
lambda src: types.SimpleNamespace(search_tracks=lambda *a, **k: []))
|
||||
monkeypatch.setattr(qu, '_search_tracks_for_source', lambda *a, **k: [
|
||||
{'id': 'x', 'name': 'Wrong', 'external_ids': {'isrc': 'ZZISRC000000'}},
|
||||
])
|
||||
assert qu._match_via_isrc('USRC17607839', ['spotify']) == (None, None)
|
||||
|
||||
|
||||
def test_scan_prefers_isrc_exact_match_over_fuzzy(monkeypatch):
|
||||
"""No track-ID, but the file carries an ISRC that resolves → use the exact match
|
||||
and do NOT run the album/search tiers."""
|
||||
db = _FakeDB([_row()], BALANCED)
|
||||
_stub_engine(monkeypatch)
|
||||
monkeypatch.setattr(qu, '_read_file_ids', lambda fp: {'isrc': 'USRC17607839'})
|
||||
monkeypatch.setattr(qu, '_match_via_track_id', lambda *a, **k: (None, None))
|
||||
fake = {'id': 'sp1', 'name': 'Song One', 'artists': ['Artist A'], 'album': {'name': 'Album X'}}
|
||||
monkeypatch.setattr(qu, '_match_via_isrc', lambda isrc, sp: (fake, 'spotify'))
|
||||
monkeypatch.setattr(qu, '_normalize_track_match', lambda t, s: dict(fake))
|
||||
monkeypatch.setattr(qu, '_track_name', lambda t: 'Song One')
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise AssertionError("fuzzy search must not run when an ISRC match exists")
|
||||
monkeypatch.setattr(qu, '_find_best_match', _boom)
|
||||
|
||||
findings = []
|
||||
result = qu.QualityUpgradeJob().scan(_ctx(db, findings))
|
||||
assert result.findings_created == 1
|
||||
assert findings[0]['details']['matched_via'] == 'isrc'
|
||||
assert findings[0]['details']['match_confidence'] == 1.0
|
||||
|
||||
|
||||
def test_scan_falls_back_to_search_without_ids(monkeypatch):
|
||||
"""No track-ID / ISRC / album hit → fall back to fuzzy search."""
|
||||
db = _FakeDB([_row()], BALANCED)
|
||||
_stub_engine(monkeypatch)
|
||||
monkeypatch.setattr(qu, '_read_file_ids', lambda fp: {}) # un-enriched
|
||||
monkeypatch.setattr(qu, '_match_via_track_id', lambda *a, **k: (None, None))
|
||||
monkeypatch.setattr(qu, '_match_via_album', lambda *a, **k: (None, None))
|
||||
fake = {'id': 'sp1', 'name': 'Song One', 'artists': ['Artist A'], 'album': {'name': 'Album X'}}
|
||||
monkeypatch.setattr(qu, '_find_best_match', lambda *a, **k: (fake, 0.88, 'spotify', True))
|
||||
monkeypatch.setattr(qu, '_normalize_track_match', lambda t, s: dict(fake))
|
||||
monkeypatch.setattr(qu, '_track_name', lambda t: 'Song One')
|
||||
|
||||
findings = []
|
||||
result = qu.QualityUpgradeJob().scan(_ctx(db, findings))
|
||||
assert result.findings_created == 1
|
||||
assert findings[0]['details']['matched_via'] == 'search'
|
||||
|
||||
|
||||
def test_scan_uses_album_tier_when_no_ids(monkeypatch):
|
||||
"""No track-ID / ISRC, but the album→track lookup resolves it → matched_via
|
||||
'album', and the fuzzy search is never reached."""
|
||||
db = _FakeDB([_row()], BALANCED)
|
||||
_stub_engine(monkeypatch)
|
||||
monkeypatch.setattr(qu, '_read_file_ids', lambda fp: {})
|
||||
monkeypatch.setattr(qu, '_match_via_track_id', lambda *a, **k: (None, None))
|
||||
fake = {'id': 'sp1', 'name': 'Song One', 'artists': ['Artist A'], 'album': {'name': 'Album X'}}
|
||||
monkeypatch.setattr(qu, '_match_via_album', lambda *a, **k: (fake, 'spotify'))
|
||||
monkeypatch.setattr(qu, '_normalize_track_match', lambda t, s: dict(fake))
|
||||
monkeypatch.setattr(qu, '_track_name', lambda t: 'Song One')
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise AssertionError("fuzzy search must not run when the album tier matches")
|
||||
monkeypatch.setattr(qu, '_find_best_match', _boom)
|
||||
|
||||
findings = []
|
||||
result = qu.QualityUpgradeJob().scan(_ctx(db, findings))
|
||||
assert result.findings_created == 1
|
||||
assert findings[0]['details']['matched_via'] == 'album'
|
||||
assert findings[0]['details']['match_confidence'] == 1.0
|
||||
|
||||
|
||||
def test_find_track_in_album_exact_title_with_track_number(monkeypatch):
|
||||
items = [
|
||||
{'id': 'a', 'name': 'Intro', 'track_number': 1},
|
||||
{'id': 'b', 'name': 'Karma Police', 'track_number': 6},
|
||||
{'id': 'c', 'name': 'Karma Police (Live)', 'track_number': 12},
|
||||
]
|
||||
eng = types.SimpleNamespace(similarity_score=lambda a, b: 0.0, normalize_string=lambda s: s)
|
||||
got = qu._find_track_in_album(items, 'Karma Police', 6, eng)
|
||||
assert got['id'] == 'b'
|
||||
|
||||
|
||||
def test_scan_skips_tracks_meeting_quality(monkeypatch):
|
||||
# A 320 kbps MP3 meets the balanced profile → no finding, no metadata calls.
|
||||
db = _FakeDB([_row(track_id=2, title='Good Song', bitrate=320)], BALANCED)
|
||||
|
||||
def _boom(*a, **k): # must never be called for an acceptable track
|
||||
raise AssertionError("matching should not run for an acceptable track")
|
||||
|
||||
monkeypatch.setattr(qu, '_find_best_match', _boom)
|
||||
|
||||
findings = []
|
||||
result = qu.QualityUpgradeJob().scan(_ctx(db, findings))
|
||||
assert result.findings_created == 0
|
||||
assert result.skipped == 1
|
||||
assert findings == []
|
||||
|
||||
|
||||
# --- fix handler adds to wishlist ------------------------------------------
|
||||
|
||||
def test_fix_handler_adds_matched_track_to_wishlist():
|
||||
from core.repair_worker import RepairWorker
|
||||
|
||||
captured = {}
|
||||
|
||||
class _DB:
|
||||
def add_to_wishlist(self, **kw):
|
||||
captured.update(kw)
|
||||
return True
|
||||
|
||||
worker = object.__new__(RepairWorker)
|
||||
worker.db = _DB()
|
||||
|
||||
details = {
|
||||
'matched_track_data': {'id': 'sp1', 'name': 'Song One',
|
||||
'album': {'name': 'Album X'}},
|
||||
'current_format': 'MP3 192', 'current_bitrate': 192,
|
||||
'album_title': 'Album X', 'provider': 'spotify', 'match_confidence': 0.9,
|
||||
}
|
||||
res = worker._fix_quality_upgrade('track', '1', '/music/a.mp3', details)
|
||||
|
||||
assert res['success'] is True
|
||||
assert captured['spotify_track_data']['id'] == 'sp1'
|
||||
assert captured['source_type'] == 'repair'
|
||||
assert captured['source_info']['job'] == 'quality_upgrade'
|
||||
assert captured['source_info']['album_title'] == 'Album X'
|
||||
|
|
@ -551,6 +551,26 @@ def test_resolve_skips_mapping_when_target_missing_then_tries_basename(tmp_path:
|
|||
assert resolved == str(tmp_path / "MyAlbum")
|
||||
|
||||
|
||||
def test_resolve_uses_custom_torrent_download_path(tmp_path: Path) -> None:
|
||||
"""#857: the user's torrent client saves to a category folder (e.g. a
|
||||
'Music' category) mounted here at a custom in-container path. Setting
|
||||
download_source.torrent_download_path lets SoulSync find the release there."""
|
||||
music_mount = tmp_path / "downloads" / "music"
|
||||
(music_mount / "MyAlbum").mkdir(parents=True)
|
||||
cfg = _cfg({'download_source.torrent_download_path': str(music_mount)})
|
||||
resolved = resolve_reported_save_path('/data/Downloads/Music/MyAlbum', config_get=cfg)
|
||||
assert resolved == str(music_mount / "MyAlbum")
|
||||
|
||||
|
||||
def test_resolve_uses_custom_usenet_download_path(tmp_path: Path) -> None:
|
||||
"""#857: same for the usenet source's custom completed-downloads path."""
|
||||
nzb_mount = tmp_path / "nzb" / "music"
|
||||
(nzb_mount / "MyAlbum").mkdir(parents=True)
|
||||
cfg = _cfg({'download_source.usenet_download_path': str(nzb_mount)})
|
||||
resolved = resolve_reported_save_path('/config/Downloads/complete/MyAlbum', config_get=cfg)
|
||||
assert resolved == str(nzb_mount / "MyAlbum")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# poll_album_download — lifted poll loop for both torrent + usenet plugins.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
90
tests/test_aria2_adapter.py
Normal file
90
tests/test_aria2_adapter.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Aria2 JSON-RPC adapter (Shdjfgatdif's request) — state mapping, token-prefixed
|
||||
params, the /jsonrpc URL fixup, status parsing, and registry wiring. No network."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.torrent_clients import adapter_for_type
|
||||
from core.torrent_clients.aria2 import Aria2Adapter, _map_state
|
||||
|
||||
|
||||
def _adapter(url='http://nas:6800', secret='sekret', save_path=''):
|
||||
cfg = {'torrent_client.url': url, 'torrent_client.password': secret,
|
||||
'torrent_client.category': 'soulsync', 'torrent_client.save_path': save_path}
|
||||
with patch('core.torrent_clients.aria2.config_manager') as cm:
|
||||
cm.get.side_effect = lambda k, d=None: cfg.get(k, d)
|
||||
return Aria2Adapter()
|
||||
|
||||
|
||||
# ── registry ──
|
||||
def test_registered_in_factory():
|
||||
a = adapter_for_type('aria2')
|
||||
assert isinstance(a, Aria2Adapter)
|
||||
|
||||
|
||||
# ── state mapping (aria2 native → adapter-uniform) ──
|
||||
def test_state_map():
|
||||
assert _map_state('waiting', 0, 100) == 'queued'
|
||||
assert _map_state('paused', 0, 100) == 'paused'
|
||||
assert _map_state('error', 0, 100) == 'error'
|
||||
assert _map_state('complete', 100, 100) == 'completed'
|
||||
assert _map_state('active', 40, 100) == 'downloading'
|
||||
assert _map_state('active', 100, 100) == 'seeding' # finished payload, still seeding
|
||||
assert _map_state('removed', 0, 100) == 'error'
|
||||
|
||||
|
||||
# ── URL fixup + token-prefixed params ──
|
||||
def test_jsonrpc_appended_to_bare_host():
|
||||
a = _adapter(url='http://nas:6800')
|
||||
assert a._url == 'http://nas:6800/jsonrpc'
|
||||
|
||||
|
||||
def test_jsonrpc_not_double_appended():
|
||||
a = _adapter(url='http://nas:6800/jsonrpc')
|
||||
assert a._url == 'http://nas:6800/jsonrpc'
|
||||
|
||||
|
||||
def test_secret_leads_params_as_token():
|
||||
a = _adapter(secret='sekret')
|
||||
assert a._params('gid123', ['status']) == ['token:sekret', 'gid123', ['status']]
|
||||
|
||||
|
||||
def test_no_token_when_no_secret():
|
||||
a = _adapter(secret='')
|
||||
assert a._params('gid123') == ['gid123']
|
||||
|
||||
|
||||
def test_is_configured():
|
||||
assert _adapter(url='http://nas:6800').is_configured() is True
|
||||
assert _adapter(url='').is_configured() is False
|
||||
|
||||
|
||||
# ── status parse ──
|
||||
def test_parse_status_torrent():
|
||||
a = _adapter()
|
||||
item = {
|
||||
'gid': 'abc123', 'status': 'active',
|
||||
'totalLength': '1000', 'completedLength': '250',
|
||||
'downloadSpeed': '500', 'uploadSpeed': '10',
|
||||
'connections': '7', 'numSeeders': '3', 'dir': '/downloads',
|
||||
'files': [{'path': '/downloads/Album/01.flac'}],
|
||||
'bittorrent': {'info': {'name': 'Some Album'}},
|
||||
}
|
||||
s = a._parse_status(item)
|
||||
assert s.id == 'abc123' and s.name == 'Some Album'
|
||||
assert s.state == 'downloading'
|
||||
assert s.size == 1000 and s.downloaded == 250
|
||||
assert abs(s.progress - 0.25) < 1e-9
|
||||
assert s.download_speed == 500 and s.peers == 7 and s.seeders == 3
|
||||
assert s.save_path == '/downloads' and s.files == ['/downloads/Album/01.flac']
|
||||
|
||||
|
||||
def test_parse_status_name_falls_back_to_file_basename():
|
||||
a = _adapter()
|
||||
s = a._parse_status({'gid': 'g', 'status': 'active', 'totalLength': '0',
|
||||
'completedLength': '0', 'files': [{'path': '/d/song.mp3'}]})
|
||||
assert s.name == 'song.mp3'
|
||||
assert s.progress == 0.0 # no division by zero when totalLength is 0
|
||||
98
tests/test_artist_catalog_disambiguation.py
Normal file
98
tests/test_artist_catalog_disambiguation.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"""Same-name artist disambiguation by owned-catalog overlap (#868).
|
||||
|
||||
Enrichment matched artists by NAME ONLY, so for a common name ("Rone" has ~5
|
||||
artists) it grabbed whichever the source ranked first — often the wrong one,
|
||||
which then drove a wrong/sparse library discography. The fix: when several
|
||||
candidates clear the name gate, pick the one whose catalog overlaps the albums
|
||||
the user actually OWNS. These pin the source-agnostic selector.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.worker_utils import (
|
||||
catalog_overlap_score,
|
||||
normalize_release_title,
|
||||
pick_artist_by_catalog,
|
||||
)
|
||||
|
||||
|
||||
# --- normalization ---------------------------------------------------------
|
||||
|
||||
def test_normalize_strips_editions_and_punctuation():
|
||||
assert normalize_release_title('Tohu Bohu (Deluxe Edition)') == 'tohu bohu'
|
||||
assert normalize_release_title('Mirapolis - Remastered') == 'mirapolis'
|
||||
assert normalize_release_title('Room with a View [2020]') == 'room with a view'
|
||||
assert normalize_release_title('') == ''
|
||||
|
||||
|
||||
# --- overlap scoring -------------------------------------------------------
|
||||
|
||||
def test_overlap_counts_matching_owned_titles():
|
||||
owned = ['Tohu Bohu', 'Creatures', 'Mirapolis']
|
||||
cand = ['Tohu Bohu (Deluxe)', 'Creatures', 'Spanish Breakfast', 'Motion']
|
||||
assert catalog_overlap_score(owned, cand) == 2 # Tohu Bohu + Creatures
|
||||
|
||||
|
||||
def test_overlap_zero_for_a_different_artists_catalog():
|
||||
owned = ['Tohu Bohu', 'Creatures', 'Mirapolis']
|
||||
cand = ['Some Other Record', 'Unrelated Album']
|
||||
assert catalog_overlap_score(owned, cand) == 0
|
||||
|
||||
|
||||
def test_overlap_zero_when_either_side_empty():
|
||||
assert catalog_overlap_score([], ['A']) == 0
|
||||
assert catalog_overlap_score(['A'], []) == 0
|
||||
|
||||
|
||||
# --- the selector ----------------------------------------------------------
|
||||
|
||||
def _cand(cid, titles):
|
||||
return {'id': cid, '_titles': titles}
|
||||
|
||||
|
||||
def _fetch(cand):
|
||||
return cand['_titles']
|
||||
|
||||
|
||||
def test_single_candidate_returns_without_fetching():
|
||||
calls = []
|
||||
chosen, score = pick_artist_by_catalog(
|
||||
[_cand('only', ['X'])], ['Tohu Bohu'],
|
||||
lambda c: calls.append(c) or c['_titles'])
|
||||
assert chosen['id'] == 'only'
|
||||
assert calls == [] # never fetched — nothing to disambiguate
|
||||
|
||||
|
||||
def test_no_owned_albums_keeps_name_order():
|
||||
calls = []
|
||||
chosen, score = pick_artist_by_catalog(
|
||||
[_cand('first', ['A']), _cand('second', ['B'])], [],
|
||||
lambda c: calls.append(c) or c['_titles'])
|
||||
assert chosen['id'] == 'first' # candidates[0] — current behavior
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_picks_the_candidate_overlapping_owned_catalog():
|
||||
# The WRONG Rone is ranked first; the right one overlaps the owned albums.
|
||||
wrong = _cand('wrong', ['Rap Mixtape Vol 1', 'Some Single'])
|
||||
right = _cand('right', ['Tohu Bohu', 'Creatures', 'Mirapolis'])
|
||||
chosen, score = pick_artist_by_catalog(
|
||||
[wrong, right], ['Tohu Bohu', 'Creatures', 'Spanish Breakfast'], _fetch)
|
||||
assert chosen['id'] == 'right'
|
||||
assert score == 2
|
||||
|
||||
|
||||
def test_no_overlap_anywhere_falls_back_to_first():
|
||||
a = _cand('a', ['Nope']); b = _cand('b', ['Also Nope'])
|
||||
chosen, score = pick_artist_by_catalog([a, b], ['Tohu Bohu'], _fetch)
|
||||
assert chosen['id'] == 'a'
|
||||
assert score == 0
|
||||
|
||||
|
||||
def test_fetch_failure_is_tolerated():
|
||||
def _boom(_c):
|
||||
raise RuntimeError('api down')
|
||||
chosen, score = pick_artist_by_catalog(
|
||||
[_cand('a', []), _cand('b', [])], ['Tohu Bohu'], _boom)
|
||||
assert chosen['id'] == 'a' # both fail → fall back to first
|
||||
assert score == 0
|
||||
59
tests/test_artist_db_record_endpoint.py
Normal file
59
tests/test_artist_db_record_endpoint.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""GET /api/artist/<id>/record — the artist-detail "DB Record" inspector source.
|
||||
Returns the full artists row (JSON text columns decoded) + owned counts, 404 if
|
||||
the artist isn't in the library."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_TMP = tempfile.mkdtemp(prefix='soulsync-testdb-arec-')
|
||||
os.environ['DATABASE_PATH'] = os.path.join(_TMP, 'a.db')
|
||||
os.environ['SOULSYNC_TEST_DB_READY'] = '1'
|
||||
|
||||
web_server = pytest.importorskip('web_server')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return web_server.app.test_client()
|
||||
|
||||
|
||||
def _insert_artist():
|
||||
db = web_server.get_database()
|
||||
conn = db._get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO artists (id, name, genres, musicbrainz_id, "
|
||||
"musicbrainz_match_status, lastfm_listeners) VALUES (?,?,?,?,?,?)",
|
||||
('99001', 'Test Artist', '["rock", "metal"]',
|
||||
'mbid-123', 'matched', 4242),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_record_returns_full_row_with_decoded_json(client):
|
||||
_insert_artist()
|
||||
r = client.get('/api/artist/99001/record')
|
||||
assert r.status_code == 200
|
||||
body = r.get_json()
|
||||
assert body['success'] is True
|
||||
rec = body['record']
|
||||
assert rec['name'] == 'Test Artist'
|
||||
assert rec['genres'] == ['rock', 'metal'] # JSON text decoded to a list
|
||||
assert rec['musicbrainz_id'] == 'mbid-123'
|
||||
assert rec['musicbrainz_match_status'] == 'matched'
|
||||
assert rec['lastfm_listeners'] == 4242
|
||||
assert 'counts' in body and 'albums' in body['counts'] and 'tracks' in body['counts']
|
||||
assert body['artist_id'] == '99001'
|
||||
|
||||
|
||||
def test_missing_artist_is_404(client):
|
||||
r = client.get('/api/artist/does-not-exist-77777/record')
|
||||
assert r.status_code == 404
|
||||
assert r.get_json()['success'] is False
|
||||
126
tests/test_artist_export.py
Normal file
126
tests/test_artist_export.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""Watchlist roster export builder (corruption's request) — JSON / CSV / txt,
|
||||
optional external links, deterministic columns."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
|
||||
from core.exports.artist_export import build_artist_export, export_mime_and_ext
|
||||
|
||||
|
||||
_ARTISTS = [
|
||||
{'artist_name': 'Rob Zombie', 'spotify_artist_id': 'sp1',
|
||||
'musicbrainz_artist_id': 'mb1', 'deezer_artist_id': 'dz1'},
|
||||
{'artist_name': 'Nobody IDs', 'spotify_artist_id': None},
|
||||
]
|
||||
|
||||
|
||||
def test_txt_is_names_one_per_line():
|
||||
out = build_artist_export(_ARTISTS, fmt='txt')
|
||||
assert out == 'Rob Zombie\nNobody IDs'
|
||||
|
||||
|
||||
def test_json_includes_present_ids_only():
|
||||
out = json.loads(build_artist_export(_ARTISTS, fmt='json'))
|
||||
assert out[0]['name'] == 'Rob Zombie'
|
||||
assert out[0]['spotify_artist_id'] == 'sp1' and out[0]['musicbrainz_artist_id'] == 'mb1'
|
||||
assert 'deezer_artist_id' in out[0]
|
||||
assert out[1] == {'name': 'Nobody IDs'} # null id dropped, no links key
|
||||
|
||||
|
||||
def test_json_links_when_requested():
|
||||
out = json.loads(build_artist_export(_ARTISTS, fmt='json', include_links=True))
|
||||
assert out[0]['links']['spotify'] == 'https://open.spotify.com/artist/sp1'
|
||||
assert out[0]['links']['musicbrainz'] == 'https://musicbrainz.org/artist/mb1'
|
||||
assert 'links' not in out[1] # no ids → no links
|
||||
|
||||
|
||||
def test_csv_header_and_rows():
|
||||
out = build_artist_export(_ARTISTS, fmt='csv')
|
||||
rows = list(csv.reader(io.StringIO(out)))
|
||||
assert rows[0][0] == 'name' and 'spotify_artist_id' in rows[0]
|
||||
assert rows[1][0] == 'Rob Zombie'
|
||||
assert rows[2][0] == 'Nobody IDs'
|
||||
|
||||
|
||||
def test_csv_adds_url_columns_with_links():
|
||||
out = build_artist_export(_ARTISTS, fmt='csv', include_links=True)
|
||||
header = next(csv.reader(io.StringIO(out)))
|
||||
assert 'spotify_url' in header and 'discogs_url' in header
|
||||
|
||||
|
||||
def test_empty_and_bad_format():
|
||||
assert build_artist_export([], fmt='txt') == ''
|
||||
assert build_artist_export(None, fmt='json') == '[]'
|
||||
assert build_artist_export(_ARTISTS, fmt='nonsense').startswith('[') # falls back to json
|
||||
|
||||
|
||||
def test_mime_and_ext():
|
||||
assert export_mime_and_ext('csv') == ('text/csv', 'csv')
|
||||
assert export_mime_and_ext('txt') == ('text/plain', 'txt')
|
||||
assert export_mime_and_ext('weird') == ('application/json', 'json')
|
||||
|
||||
|
||||
# ── endpoint wiring (empty watchlist → valid shapes + headers) ──────────────
|
||||
import os, tempfile # noqa: E402
|
||||
os.environ['DATABASE_PATH'] = os.path.join(tempfile.mkdtemp(prefix='soulsync-testdb-wlexp-'), 'w.db')
|
||||
os.environ['SOULSYNC_TEST_DB_READY'] = '1'
|
||||
import pytest # noqa: E402
|
||||
web_server = pytest.importorskip('web_server')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return web_server.app.test_client()
|
||||
|
||||
|
||||
def test_export_endpoint_wiring(client):
|
||||
# Don't assume an empty DB (a shared test run may have rows) — just verify the
|
||||
# endpoint returns a valid JSON array + the right headers/columns.
|
||||
r = client.get('/api/watchlist/export?format=json')
|
||||
assert r.status_code == 200
|
||||
assert isinstance(json.loads(r.data.decode()), list)
|
||||
assert r.headers.get('X-Export-Ext') == 'json'
|
||||
|
||||
r2 = client.get('/api/watchlist/export?format=csv&links=1')
|
||||
assert r2.status_code == 200 and r2.headers.get('X-Export-Ext') == 'csv'
|
||||
assert 'spotify_url' in r2.data.decode().splitlines()[0] # header row with links
|
||||
|
||||
|
||||
# ── library-side: extra services + extra_fields passthrough ─────────────────
|
||||
_LIB = [{
|
||||
'name': 'Rob Zombie', 'spotify_artist_id': 'sp1', 'tidal_artist_id': 'td1',
|
||||
'qobuz_artist_id': 'qz1', 'lastfm_url': 'https://last.fm/x', 'soul_id': 'soul_abc',
|
||||
'album_count': 10, 'track_count': 159,
|
||||
}]
|
||||
|
||||
|
||||
def test_tidal_qobuz_links_and_extra_fields_json():
|
||||
out = json.loads(build_artist_export(_LIB, fmt='json', include_links=True,
|
||||
extra_fields=['lastfm_url', 'soul_id', 'album_count', 'track_count']))
|
||||
a = out[0]
|
||||
assert a['tidal_artist_id'] == 'td1' and a['qobuz_artist_id'] == 'qz1'
|
||||
assert a['links']['tidal'] == 'https://tidal.com/artist/td1'
|
||||
assert a['links']['qobuz'] == 'https://www.qobuz.com/artist/qz1'
|
||||
assert a['lastfm_url'] == 'https://last.fm/x' and a['soul_id'] == 'soul_abc'
|
||||
assert a['album_count'] == 10 and a['track_count'] == 159
|
||||
|
||||
|
||||
def test_extra_fields_become_csv_columns():
|
||||
out = build_artist_export(_LIB, fmt='csv', extra_fields=['album_count', 'track_count'])
|
||||
header = next(csv.reader(io.StringIO(out)))
|
||||
assert 'album_count' in header and 'track_count' in header
|
||||
assert 'tidal_artist_id' in header # new service column present
|
||||
|
||||
|
||||
def test_library_export_endpoint_wiring(client):
|
||||
# Robust to a shared DB that may already hold artist rows.
|
||||
r = client.get('/api/library/artists/export?format=json&contents=1&links=1')
|
||||
assert r.status_code == 200
|
||||
assert isinstance(json.loads(r.data.decode()), list)
|
||||
assert r.headers.get('X-Export-Ext') == 'json'
|
||||
r2 = client.get('/api/library/artists/export?format=csv&contents=1')
|
||||
header = r2.data.decode().splitlines()[0]
|
||||
assert 'album_count' in header and 'track_count' in header
|
||||
92
tests/test_database_update_health.py
Normal file
92
tests/test_database_update_health.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""Seam tests for the database-update stall watchdog (GitHub #859).
|
||||
|
||||
A DB-update job can hang (media-server call with no timeout, DB lock) and sit at
|
||||
status='running' forever because the worker's finished/error callbacks never
|
||||
fire. `is_db_update_stalled` is the pure decision that lets the watchdog flip
|
||||
such a job to 'error' so the UI recovers. These tests pin that decision —
|
||||
including the conservative cases where it must NOT false-positive.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.database_update_health import (
|
||||
DEFAULT_STALL_TIMEOUT_SECONDS,
|
||||
is_db_update_stalled,
|
||||
stalled_error_message,
|
||||
)
|
||||
|
||||
|
||||
def _state(**over):
|
||||
base = {"status": "running", "phase": "Incremental: scanning",
|
||||
"processed": 2, "total": 3, "progress": 66.7, "last_progress_at": 1000.0}
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
|
||||
def test_running_and_heartbeat_stale_is_stalled():
|
||||
# last tick at t=1000, now=1000+timeout → exactly at the boundary counts.
|
||||
now = 1000.0 + DEFAULT_STALL_TIMEOUT_SECONDS
|
||||
assert is_db_update_stalled(_state(), now) is True
|
||||
|
||||
|
||||
def test_running_and_heartbeat_fresh_is_not_stalled():
|
||||
now = 1000.0 + 5 # ticked 5s ago, well within timeout
|
||||
assert is_db_update_stalled(_state(), now) is False
|
||||
|
||||
|
||||
def test_just_under_timeout_is_not_stalled():
|
||||
now = 1000.0 + DEFAULT_STALL_TIMEOUT_SECONDS - 0.001
|
||||
assert is_db_update_stalled(_state(), now) is False
|
||||
|
||||
|
||||
def test_non_running_statuses_never_stall():
|
||||
now = 1000.0 + 10_000 # very stale heartbeat
|
||||
for status in ("idle", "finished", "error"):
|
||||
assert is_db_update_stalled(_state(status=status), now) is False, status
|
||||
|
||||
|
||||
def test_missing_heartbeat_cannot_judge():
|
||||
# No usable timestamp → we refuse to kill a job we have no clock for.
|
||||
now = 1_000_000.0
|
||||
assert is_db_update_stalled(_state(last_progress_at=0), now) is False
|
||||
assert is_db_update_stalled(_state(last_progress_at=None), now) is False
|
||||
s = _state()
|
||||
del s["last_progress_at"]
|
||||
assert is_db_update_stalled(s, now) is False
|
||||
|
||||
|
||||
def test_non_positive_timeout_disables_watchdog():
|
||||
now = 1000.0 + 10_000
|
||||
assert is_db_update_stalled(_state(), now, timeout_seconds=0) is False
|
||||
assert is_db_update_stalled(_state(), now, timeout_seconds=-1) is False
|
||||
|
||||
|
||||
def test_bad_inputs_are_safe():
|
||||
assert is_db_update_stalled(None, 123.0) is False
|
||||
assert is_db_update_stalled("not a dict", 123.0) is False
|
||||
assert is_db_update_stalled(_state(last_progress_at="oops"), 1_000_000.0) is False
|
||||
|
||||
|
||||
def test_custom_timeout_respected():
|
||||
now = 1000.0 + 120
|
||||
assert is_db_update_stalled(_state(), now, timeout_seconds=60) is True
|
||||
assert is_db_update_stalled(_state(), now, timeout_seconds=180) is False
|
||||
|
||||
|
||||
def test_stalled_message_is_informative():
|
||||
now = 1000.0 + 360
|
||||
msg = stalled_error_message(_state(phase="Incremental: scanning"), now)
|
||||
assert "stuck" in msg.lower()
|
||||
assert "360s" in msg
|
||||
assert "Incremental: scanning" in msg
|
||||
|
||||
|
||||
def test_issue_859_frozen_running_job_is_caught():
|
||||
"""Regression: the reported state — running, frozen at 2/3 (66.7%), heartbeat
|
||||
long stale — is detected as stalled so the card can self-heal."""
|
||||
state = _state(status="running", processed=2, total=3, progress=66.7,
|
||||
last_progress_at=1000.0)
|
||||
now = 1000.0 + DEFAULT_STALL_TIMEOUT_SECONDS + 30
|
||||
assert is_db_update_stalled(state, now) is True
|
||||
# And a healthy job that's actively ticking is left alone.
|
||||
assert is_db_update_stalled(_state(last_progress_at=now - 2), now) is False
|
||||
74
tests/test_empty_folder_cleaner.py
Normal file
74
tests/test_empty_folder_cleaner.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""Empty Folder Cleaner (corruption's request) — pure removable decision + the
|
||||
apply handler's re-check safety (never deletes a folder that gained content, the
|
||||
library root, or a symlink)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from core.repair_jobs.empty_folder_cleaner import dir_is_removable, remove_empty_folder, is_junk
|
||||
|
||||
|
||||
# ── pure decision ───────────────────────────────────────────────────────────
|
||||
def test_empty_dir_is_removable():
|
||||
assert dir_is_removable([], []) is True
|
||||
|
||||
|
||||
def test_dir_with_real_file_is_not_removable():
|
||||
assert dir_is_removable(['cover.jpg'], []) is False
|
||||
assert dir_is_removable(['song.flac'], []) is False
|
||||
|
||||
|
||||
def test_dir_with_surviving_subdir_is_not_removable():
|
||||
assert dir_is_removable([], ['Album']) is False
|
||||
|
||||
|
||||
def test_junk_only_dir_removable_when_ignore_junk():
|
||||
assert dir_is_removable(['.DS_Store', 'Thumbs.db'], []) is True
|
||||
assert dir_is_removable(['.DS_Store'], [], ignore_junk=False) is False # strict mode keeps it
|
||||
|
||||
|
||||
def test_junk_plus_real_file_not_removable():
|
||||
assert dir_is_removable(['.DS_Store', 'cover.jpg'], []) is False
|
||||
|
||||
|
||||
def test_is_junk():
|
||||
assert is_junk('.DS_Store') and is_junk('thumbs.db') and not is_junk('cover.jpg')
|
||||
|
||||
|
||||
# ── apply re-check (real FS) ────────────────────────────────────────────────
|
||||
def _fx():
|
||||
return dict(listdir=os.listdir, isdir=os.path.isdir, islink=os.path.islink,
|
||||
remove_file=os.remove, rmdir=os.rmdir)
|
||||
|
||||
|
||||
def test_apply_removes_truly_empty_folder(tmp_path):
|
||||
root = tmp_path / 'lib'; root.mkdir()
|
||||
empty = root / 'Artist' / 'Album'; empty.mkdir(parents=True)
|
||||
res = remove_empty_folder(str(empty), junk_files=[], remove_junk=True, root=str(root), **_fx())
|
||||
assert res['removed'] is True
|
||||
assert not empty.exists()
|
||||
|
||||
|
||||
def test_apply_deletes_junk_then_folder(tmp_path):
|
||||
root = tmp_path / 'lib'; root.mkdir()
|
||||
d = root / 'Empty'; d.mkdir()
|
||||
(d / '.DS_Store').write_text('x')
|
||||
res = remove_empty_folder(str(d), junk_files=['.DS_Store'], remove_junk=True, root=str(root), **_fx())
|
||||
assert res['removed'] is True and not d.exists()
|
||||
|
||||
|
||||
def test_apply_refuses_folder_that_gained_a_file(tmp_path):
|
||||
root = tmp_path / 'lib'; root.mkdir()
|
||||
d = root / 'NowFull'; d.mkdir()
|
||||
(d / 'new.flac').write_text('audio') # appeared between scan and apply
|
||||
res = remove_empty_folder(str(d), junk_files=[], remove_junk=True, root=str(root), **_fx())
|
||||
assert res['removed'] is False and 'no longer empty' in res['error'].lower()
|
||||
assert d.exists() # left untouched
|
||||
|
||||
|
||||
def test_apply_refuses_library_root(tmp_path):
|
||||
root = tmp_path / 'lib'; root.mkdir()
|
||||
res = remove_empty_folder(str(root), junk_files=[], remove_junk=True, root=str(root), **_fx())
|
||||
assert res['removed'] is False and 'root' in res['error'].lower()
|
||||
assert root.exists()
|
||||
53
tests/test_hifi_default_push.py
Normal file
53
tests/test_hifi_default_push.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""One-time auto-push of NEW default HiFi instances to existing installs.
|
||||
|
||||
A working instance added to DEFAULT_INSTANCES should reach everyone — but a
|
||||
default a user deliberately removed must NOT come back. compute_new_default_pushes
|
||||
is the pure decision; these pin both guarantees.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.hifi_client import compute_new_default_pushes
|
||||
|
||||
LEGACY = ['https://a.tf', 'https://b.tf'] # shipped before tracking
|
||||
DEFAULTS = ['https://a.tf', 'https://b.tf', 'https://new.tf'] # 'new.tf' added later
|
||||
|
||||
|
||||
def test_first_run_pushes_only_the_new_default():
|
||||
"""offered=None → baseline to legacy, so existing user gets ONLY new.tf,
|
||||
not a re-seed of a.tf/b.tf (which they may have curated)."""
|
||||
existing = ['https://a.tf'] # user removed b.tf at some point
|
||||
to_add, new_offered = compute_new_default_pushes(DEFAULTS, None, LEGACY, existing)
|
||||
assert to_add == ['https://new.tf'] # only the genuinely-new one
|
||||
assert 'https://b.tf' not in to_add # removed default NOT resurrected
|
||||
assert set(new_offered) == {'https://a.tf', 'https://b.tf', 'https://new.tf'}
|
||||
|
||||
|
||||
def test_already_offered_new_default_not_re_added():
|
||||
"""Once new.tf has been offered, removing it must stick (no re-add)."""
|
||||
offered = ['https://a.tf', 'https://b.tf', 'https://new.tf']
|
||||
existing = ['https://a.tf', 'https://b.tf'] # user removed new.tf
|
||||
to_add, new_offered = compute_new_default_pushes(DEFAULTS, offered, LEGACY, existing)
|
||||
assert to_add == []
|
||||
assert set(new_offered) == set(offered)
|
||||
|
||||
|
||||
def test_present_new_default_recorded_not_duplicated():
|
||||
"""If the new default is already present (fresh install / user added it),
|
||||
record it as offered but don't add a duplicate."""
|
||||
existing = ['https://a.tf', 'https://b.tf', 'https://new.tf']
|
||||
to_add, new_offered = compute_new_default_pushes(DEFAULTS, None, LEGACY, existing)
|
||||
assert to_add == []
|
||||
assert 'https://new.tf' in new_offered
|
||||
|
||||
|
||||
def test_trailing_slash_insensitive():
|
||||
existing = ['https://a.tf/', 'https://new.tf'] # already has new.tf (slash variant)
|
||||
to_add, _ = compute_new_default_pushes(DEFAULTS, None, LEGACY, existing)
|
||||
assert to_add == [] # new.tf seen as present
|
||||
|
||||
|
||||
def test_no_new_defaults_is_noop():
|
||||
to_add, new_offered = compute_new_default_pushes(LEGACY, None, LEGACY, ['https://a.tf'])
|
||||
assert to_add == []
|
||||
assert set(new_offered) == set(LEGACY)
|
||||
|
|
@ -463,6 +463,77 @@ def test_scan_apply_mode_enqueues_albums_via_reorganize_queue(make_context, monk
|
|||
assert result.findings_created == 0
|
||||
|
||||
|
||||
def _stub_preview_by_mode(monkeypatch, api_resp, tags_resp):
|
||||
"""Patch preview to return different responses for api vs tag mode, so the
|
||||
#862 api→tags fallback can be exercised."""
|
||||
from core import library_reorganize as core_lr
|
||||
|
||||
def _fake_preview(*, album_id, metadata_source='api', **kwargs):
|
||||
return tags_resp if metadata_source == 'tags' else api_resp
|
||||
monkeypatch.setattr(core_lr, 'preview_album_reorganize', _fake_preview)
|
||||
|
||||
|
||||
def test_scan_falls_back_to_tag_mode_when_api_has_no_source_id(make_context, monkeypatch):
|
||||
"""#862: media-server albums have no source ID, so the API planner returns
|
||||
no_source_id. The job must fall back to TAG mode and, when that plans, emit
|
||||
real path_mismatch findings — NOT a dead-end 'needs enrichment' finding."""
|
||||
db = _FakeDB([_make_album_row(id_='A1', title='Tagged Album')])
|
||||
_stub_preview_by_mode(
|
||||
monkeypatch,
|
||||
api_resp={'success': False, 'status': 'no_source_id', 'source': None,
|
||||
'album': 'Tagged Album', 'artist': 'A',
|
||||
'tracks': [{'track_id': 't1', 'title': 'X', 'matched': False}]},
|
||||
tags_resp={'success': True, 'status': 'planned', 'source': 'tags',
|
||||
'album': 'Tagged Album', 'artist': 'A',
|
||||
'tracks': [{'track_id': 't1', 'title': 'X',
|
||||
'current_path': 'old/X.flac', 'new_path': 'A/(2008) Tagged Album/01 - X.flac',
|
||||
'matched': True, 'unchanged': False, 'file_exists': True}]},
|
||||
)
|
||||
ctx = make_context(db=db, dry_run=True)
|
||||
result = LibraryReorganizeJob().scan(ctx)
|
||||
|
||||
findings = ctx._captured_findings # type: ignore[attr-defined]
|
||||
assert result.findings_created == 1
|
||||
assert findings[0]['finding_type'] == 'path_mismatch'
|
||||
# Crucially NOT the enrichment dead-end the user reported.
|
||||
assert all(f['finding_type'] != 'album_needs_enrichment' for f in findings)
|
||||
|
||||
|
||||
def test_apply_mode_enqueues_tag_metadata_source_on_fallback(make_context, monkeypatch):
|
||||
"""#862: when the album reorganizes via the tag-mode fallback, the enqueued
|
||||
item must carry metadata_source='tags' so the live move uses tags too (the
|
||||
queue runner otherwise defaults to 'api' and would fail again)."""
|
||||
db = _FakeDB([_make_album_row(id_='A1', title='Tagged Album', artist_id=10, artist_name='A')])
|
||||
_stub_preview_by_mode(
|
||||
monkeypatch,
|
||||
api_resp={'success': False, 'status': 'no_source_id', 'source': None,
|
||||
'album': 'Tagged Album', 'artist': 'A',
|
||||
'tracks': [{'track_id': 't1', 'title': 'X', 'matched': False}]},
|
||||
tags_resp={'success': True, 'status': 'planned', 'source': 'tags',
|
||||
'album': 'Tagged Album', 'artist': 'A',
|
||||
'tracks': [{'track_id': 't1', 'title': 'X',
|
||||
'current_path': 'old/X.flac', 'new_path': 'A/(2008) Tagged Album/01 - X.flac',
|
||||
'matched': True, 'unchanged': False, 'file_exists': True}]},
|
||||
)
|
||||
|
||||
enqueue_calls = []
|
||||
|
||||
class _StubQueue:
|
||||
def enqueue_many(self, items):
|
||||
enqueue_calls.append(items)
|
||||
return {'enqueued': len(items), 'already_queued': 0, 'total': len(items)}
|
||||
|
||||
import core.reorganize_queue as queue_mod
|
||||
monkeypatch.setattr(queue_mod, 'get_queue', lambda: _StubQueue())
|
||||
|
||||
ctx = make_context(db=db, dry_run=False)
|
||||
LibraryReorganizeJob().scan(ctx)
|
||||
|
||||
assert len(enqueue_calls) == 1
|
||||
assert enqueue_calls[0][0]['metadata_source'] == 'tags'
|
||||
assert enqueue_calls[0][0]['source'] == 'tags'
|
||||
|
||||
|
||||
def test_scan_only_iterates_albums_for_active_server(make_context, monkeypatch):
|
||||
"""Pin: multi-server users (Plex + Jellyfin etc) — the job only
|
||||
iterates albums on the ACTIVE server. Inactive server's rows are
|
||||
|
|
|
|||
97
tests/test_login_provisioning.py
Normal file
97
tests/test_login_provisioning.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""No-gaps invariant: while login mode is on, every profile must have a login
|
||||
password. Pure policy seam + endpoint enforcement at every write-point (create,
|
||||
clear, enable-login)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from core.security.login_provisioning import (
|
||||
members_without_password, create_needs_password, removing_password_strands)
|
||||
|
||||
|
||||
# ── pure policy ─────────────────────────────────────────────────────────────
|
||||
def test_members_without_password_flags_only_passwordless_nonadmins():
|
||||
profiles = [
|
||||
{'id': 1, 'name': 'Admin', 'is_admin': True, 'has_password': False}, # admin: own anti-lockout
|
||||
{'id': 2, 'name': 'HasPw', 'is_admin': False, 'has_password': True}, # fine
|
||||
{'id': 3, 'name': 'NoPw', 'is_admin': False, 'has_password': False}, # stranded
|
||||
]
|
||||
out = members_without_password(profiles)
|
||||
assert out == [{'id': 3, 'name': 'NoPw'}]
|
||||
|
||||
|
||||
def test_members_without_password_empty_when_all_set():
|
||||
assert members_without_password([{'id': 2, 'is_admin': False, 'has_password': True}]) == []
|
||||
assert members_without_password(None) == []
|
||||
|
||||
|
||||
def test_create_needs_password_only_when_login_on_and_nonadmin():
|
||||
assert create_needs_password(True) is True
|
||||
assert create_needs_password(False) is False
|
||||
assert create_needs_password(True, is_admin=True) is False
|
||||
|
||||
|
||||
def test_removing_password_strands_only_when_login_on():
|
||||
assert removing_password_strands(True) is True
|
||||
assert removing_password_strands(False) is False
|
||||
|
||||
|
||||
# ── endpoint enforcement ────────────────────────────────────────────────────
|
||||
_TMP = tempfile.mkdtemp(prefix='soulsync-testdb-prov-')
|
||||
os.environ['DATABASE_PATH'] = os.path.join(_TMP, 'p.db')
|
||||
os.environ['SOULSYNC_TEST_DB_READY'] = '1'
|
||||
web_server = pytest.importorskip('web_server')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return web_server.app.test_client()
|
||||
|
||||
|
||||
def _login_on(monkeypatch, on=True):
|
||||
real = web_server.config_manager.get
|
||||
monkeypatch.setattr(web_server.config_manager, 'get',
|
||||
lambda k, d=None: on if k == 'security.require_login' else real(k, d))
|
||||
|
||||
|
||||
def _auth(c):
|
||||
# Turning login mode on activates the HTTP gate — authenticate the session as
|
||||
# admin so the request reaches the endpoint (we're testing the endpoint logic).
|
||||
with c.session_transaction() as sess:
|
||||
sess['login_authenticated'] = True
|
||||
sess['profile_id'] = 1
|
||||
|
||||
|
||||
def test_create_without_password_blocked_when_login_on(monkeypatch, client):
|
||||
_login_on(monkeypatch, True); _auth(client)
|
||||
r = client.post('/api/profiles', json={'name': 'NoPwMember'})
|
||||
assert r.status_code == 400
|
||||
assert 'login' in r.get_json()['error'].lower()
|
||||
|
||||
|
||||
def test_create_with_password_succeeds_when_login_on(monkeypatch, client):
|
||||
_login_on(monkeypatch, True); _auth(client)
|
||||
r = client.post('/api/profiles', json={'name': 'PwMember', 'password': 'secret9'})
|
||||
assert r.status_code == 200 and r.get_json()['success'] is True
|
||||
pid = r.get_json()['profile_id']
|
||||
assert web_server.get_database().verify_profile_password(pid, 'secret9') is True
|
||||
|
||||
|
||||
def test_create_without_password_fine_when_login_off(monkeypatch, client):
|
||||
_login_on(monkeypatch, False)
|
||||
r = client.post('/api/profiles', json={'name': 'PinOnlyMember'})
|
||||
assert r.status_code == 200 and r.get_json()['success'] is True # no friction when off
|
||||
|
||||
|
||||
def test_clear_password_blocked_when_login_on(monkeypatch, client):
|
||||
db = web_server.get_database()
|
||||
r = client.post('/api/profiles', json={'name': 'Clearable', 'password': 'x12345'})
|
||||
pid = r.get_json()['profile_id']
|
||||
_login_on(monkeypatch, True); _auth(client)
|
||||
r2 = client.post(f'/api/profiles/{pid}/set-password', json={'password': ''})
|
||||
assert r2.status_code == 400 and 'login mode' in r2.get_json()['error'].lower()
|
||||
assert db.verify_profile_password(pid, 'x12345') is True # still set
|
||||
60
tests/test_m3u_track_resolution.py
Normal file
60
tests/test_m3u_track_resolution.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""Bulk track-load used by M3U export path resolution.
|
||||
|
||||
M3U export used to resolve each track with a per-artist search_tracks() loop,
|
||||
which could block for a long time behind the enrichment/scan writers (the
|
||||
"Export M3U hangs forever" report). It now bulk-loads (artist, title, file_path)
|
||||
in one WAL-concurrent read; this pins that method's contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from database.music_database import MusicDatabase
|
||||
|
||||
|
||||
def _db_with_track(tmp_path, *, title, artist, file_path, server='jellyfin'):
|
||||
db = MusicDatabase(str(tmp_path / 'm.db'))
|
||||
with db._get_connection() as c:
|
||||
c.execute("INSERT INTO artists (id, name) VALUES (1, ?)", (artist,))
|
||||
c.execute("INSERT INTO albums (id, title, artist_id) VALUES (1, 'Album', 1)")
|
||||
c.execute(
|
||||
"INSERT INTO tracks (id, title, artist_id, album_id, file_path, server_source) "
|
||||
"VALUES (1, ?, 1, 1, ?, ?)",
|
||||
(title, file_path, server),
|
||||
)
|
||||
c.commit()
|
||||
return db
|
||||
|
||||
|
||||
def test_returns_artist_title_path(tmp_path):
|
||||
db = _db_with_track(tmp_path, title='How You Remind Me', artist='Nickelback',
|
||||
file_path='/music/nb/how.flac')
|
||||
rows = db.get_tracks_for_m3u_resolution(server_source='jellyfin')
|
||||
assert rows == [{'title': 'How You Remind Me', 'artist': 'Nickelback',
|
||||
'file_path': '/music/nb/how.flac'}]
|
||||
|
||||
|
||||
def test_filters_by_server_source(tmp_path):
|
||||
db = _db_with_track(tmp_path, title='X', artist='Y', file_path='/m/x.flac', server='jellyfin')
|
||||
assert db.get_tracks_for_m3u_resolution(server_source='jellyfin') # match
|
||||
assert db.get_tracks_for_m3u_resolution(server_source='plex') == [] # other server
|
||||
|
||||
|
||||
def test_excludes_rows_without_file_path(tmp_path):
|
||||
db = MusicDatabase(str(tmp_path / 'm.db'))
|
||||
with db._get_connection() as c:
|
||||
c.execute("INSERT INTO artists (id, name) VALUES (1, 'A')")
|
||||
c.execute("INSERT INTO albums (id, title, artist_id) VALUES (1, 'Al', 1)")
|
||||
# one with a path, one without — only the first should come back.
|
||||
c.execute("INSERT INTO tracks (id, title, artist_id, album_id, file_path, server_source) "
|
||||
"VALUES (1, 'Has Path', 1, 1, '/m/a.flac', 'jellyfin')")
|
||||
c.execute("INSERT INTO tracks (id, title, artist_id, album_id, file_path, server_source) "
|
||||
"VALUES (2, 'No Path', 1, 1, NULL, 'jellyfin')")
|
||||
c.commit()
|
||||
rows = db.get_tracks_for_m3u_resolution()
|
||||
titles = {r['title'] for r in rows}
|
||||
assert titles == {'Has Path'}
|
||||
|
||||
|
||||
def test_empty_db_safe(tmp_path):
|
||||
db = MusicDatabase(str(tmp_path / 'm.db'))
|
||||
assert db.get_tracks_for_m3u_resolution() == []
|
||||
|
|
@ -130,6 +130,10 @@ def manual_search_client(monkeypatch):
|
|||
'qobuz': _make_plugin(),
|
||||
'hifi': _make_plugin(),
|
||||
'deezer': _make_plugin(),
|
||||
# Present in the registry but deliberately NOT in the default
|
||||
# hybrid_order, so the #865 SoundCloud-link test can select it
|
||||
# without changing the 'all' fan-out tests.
|
||||
'soundcloud': _make_plugin(),
|
||||
}
|
||||
|
||||
class _FakeSpec:
|
||||
|
|
@ -217,6 +221,46 @@ def manual_search_client(monkeypatch):
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_manual_search_soundcloud_link_forces_soundcloud_source(manual_search_client):
|
||||
"""#865: pasting a SoundCloud URL forces the SoundCloud source and passes the
|
||||
URL straight through (so its search resolves the link, incl. unlisted/private)
|
||||
— it must NOT be turned into a text query or fan out to other sources."""
|
||||
client, ctx = manual_search_client
|
||||
# Hybrid order = soundcloud only, so SoundCloud is the lone available source.
|
||||
from config.settings import config_manager
|
||||
original = config_manager.get
|
||||
|
||||
def _cfg(key, default=None):
|
||||
if key == 'download_source.mode':
|
||||
return 'hybrid'
|
||||
if key == 'download_source.hybrid_order':
|
||||
return ['soundcloud']
|
||||
return original(key, default)
|
||||
ctx['config_get_setter'](_cfg)
|
||||
|
||||
url = 'https://soundcloud.com/artist/secret-track/s-AbC123'
|
||||
resp = client.post('/api/downloads/task/task-abc/manual-search',
|
||||
json={'query': url, 'source': 'all'})
|
||||
assert resp.status_code == 200
|
||||
msgs = _consume_ndjson(resp)
|
||||
header = next(m for m in msgs if m.get('type') == 'header')
|
||||
# The link forced the SoundCloud source, with the raw URL kept as the query.
|
||||
assert header['sources_queried'] == ['soundcloud']
|
||||
assert header['query'] == url
|
||||
|
||||
|
||||
def test_manual_search_soundcloud_link_errors_when_not_connected(manual_search_client):
|
||||
"""A SoundCloud link with SoundCloud not configured → clear 400, not a
|
||||
useless text search of the raw URL."""
|
||||
client, ctx = manual_search_client
|
||||
# Default hybrid_order has no soundcloud → it's not an available source.
|
||||
resp = client.post('/api/downloads/task/task-abc/manual-search',
|
||||
json={'query': 'https://soundcloud.com/artist/track', 'source': 'all'})
|
||||
assert resp.status_code == 400
|
||||
assert 'soundcloud' in resp.get_data(as_text=True).lower()
|
||||
ctx['plugins']['soundcloud'].search.assert_not_called()
|
||||
|
||||
|
||||
def test_manual_search_validates_query_length(manual_search_client):
|
||||
"""Empty / 1-char query returns 400 — frontend hint says ≥2 chars."""
|
||||
client, _ctx = manual_search_client
|
||||
|
|
|
|||
54
tests/test_member_login_password.py
Normal file
54
tests/test_member_login_password.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""Admin sets a member's LOGIN password (the gap behind 'non-admins can't log in
|
||||
when Require Login is on'). The endpoint already allowed admin→anyone; this locks
|
||||
that the round-trip actually lets the member authenticate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_TMP = tempfile.mkdtemp(prefix='soulsync-testdb-memberpw-')
|
||||
os.environ['DATABASE_PATH'] = os.path.join(_TMP, 'm.db')
|
||||
os.environ['SOULSYNC_TEST_DB_READY'] = '1'
|
||||
|
||||
web_server = pytest.importorskip('web_server')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return web_server.app.test_client()
|
||||
|
||||
|
||||
def _make_member(db, pid=77):
|
||||
conn = db._get_connection()
|
||||
try:
|
||||
conn.execute("INSERT OR REPLACE INTO profiles (id, name, is_admin) VALUES (?,?,0)", (pid, 'Member'))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_admin_sets_member_password_then_member_can_authenticate(client):
|
||||
db = web_server.get_database()
|
||||
_make_member(db)
|
||||
assert db.verify_profile_password(77, 'secret123') is False # no password → can't log in
|
||||
|
||||
r = client.post('/api/profiles/77/set-password', json={'password': 'secret123'})
|
||||
assert r.status_code == 200
|
||||
body = r.get_json()
|
||||
assert body['success'] is True and body['has_password'] is True
|
||||
|
||||
assert db.verify_profile_password(77, 'secret123') is True # member can now authenticate
|
||||
assert db.verify_profile_password(77, 'wrong') is False
|
||||
|
||||
|
||||
def test_admin_can_clear_member_password(client):
|
||||
db = web_server.get_database()
|
||||
_make_member(db, pid=78)
|
||||
client.post('/api/profiles/78/set-password', json={'password': 'pw12345'})
|
||||
assert db.verify_profile_password(78, 'pw12345') is True
|
||||
r = client.post('/api/profiles/78/set-password', json={'password': ''})
|
||||
assert r.status_code == 200
|
||||
assert db.verify_profile_password(78, 'pw12345') is False # cleared → no login again
|
||||
86
tests/test_mirrored_custom_name.py
Normal file
86
tests/test_mirrored_custom_name.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""Mirrored-playlist custom name (alias) — seam + DB regression tests.
|
||||
|
||||
Users can rename a mirrored playlist; the alias overrides the name shown in the
|
||||
UI and used when syncing, while the playlist stays tied to its upstream source.
|
||||
The non-negotiable guarantee: the alias must SURVIVE an upstream refresh (which
|
||||
rewrites the upstream `name`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.playlists.naming import effective_mirrored_name
|
||||
from database.music_database import MusicDatabase
|
||||
|
||||
|
||||
# ── pure seam ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_custom_name_wins_when_set():
|
||||
assert effective_mirrored_name({'name': 'Discover Weekly', 'custom_name': 'My Jams'}) == 'My Jams'
|
||||
|
||||
|
||||
def test_falls_back_to_upstream_name_when_no_alias():
|
||||
assert effective_mirrored_name({'name': 'Discover Weekly', 'custom_name': None}) == 'Discover Weekly'
|
||||
assert effective_mirrored_name({'name': 'Discover Weekly', 'custom_name': ''}) == 'Discover Weekly'
|
||||
assert effective_mirrored_name({'name': 'Discover Weekly', 'custom_name': ' '}) == 'Discover Weekly'
|
||||
assert effective_mirrored_name({'name': 'Discover Weekly'}) == 'Discover Weekly'
|
||||
|
||||
|
||||
def test_safe_on_bad_input():
|
||||
assert effective_mirrored_name(None) == ''
|
||||
assert effective_mirrored_name('nope') == ''
|
||||
assert effective_mirrored_name({}) == ''
|
||||
|
||||
|
||||
# ── DB behaviour ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _mk(tmp_path):
|
||||
db = MusicDatabase(str(tmp_path / 'm.db'))
|
||||
pk = db.mirror_playlist(source='spotify', source_playlist_id='PL1',
|
||||
name='Original Name', tracks=[], profile_id=1)
|
||||
assert pk
|
||||
return db, pk
|
||||
|
||||
|
||||
def test_set_and_clear_custom_name(tmp_path):
|
||||
db, pk = _mk(tmp_path)
|
||||
assert db.get_mirrored_playlist(pk).get('custom_name') in (None, '')
|
||||
|
||||
assert db.set_mirrored_playlist_custom_name(pk, 'My Alias') is True
|
||||
assert db.get_mirrored_playlist(pk)['custom_name'] == 'My Alias'
|
||||
|
||||
# Blank clears it back to upstream.
|
||||
assert db.set_mirrored_playlist_custom_name(pk, ' ') is True
|
||||
assert db.get_mirrored_playlist(pk).get('custom_name') is None
|
||||
|
||||
# None clears too.
|
||||
db.set_mirrored_playlist_custom_name(pk, 'Again')
|
||||
assert db.set_mirrored_playlist_custom_name(pk, None) is True
|
||||
assert db.get_mirrored_playlist(pk).get('custom_name') is None
|
||||
|
||||
|
||||
def test_alias_survives_upstream_refresh(tmp_path):
|
||||
"""THE regression: re-mirroring (refresh) rewrites the upstream `name` but
|
||||
must NOT touch the custom alias."""
|
||||
db, pk = _mk(tmp_path)
|
||||
db.set_mirrored_playlist_custom_name(pk, 'My Alias')
|
||||
|
||||
# Upstream renamed the playlist + added tracks → refresh.
|
||||
db.mirror_playlist(source='spotify', source_playlist_id='PL1',
|
||||
name='Upstream Renamed', tracks=[
|
||||
{'track_name': 'A', 'artist_name': 'X'},
|
||||
], profile_id=1)
|
||||
|
||||
row = db.get_mirrored_playlist(pk)
|
||||
assert row['name'] == 'Upstream Renamed' # upstream name keeps tracking
|
||||
assert row['custom_name'] == 'My Alias' # alias preserved
|
||||
assert effective_mirrored_name(row) == 'My Alias'
|
||||
|
||||
|
||||
def test_set_custom_name_does_not_touch_other_fields(tmp_path):
|
||||
db, pk = _mk(tmp_path)
|
||||
before = db.get_mirrored_playlist(pk)
|
||||
db.set_mirrored_playlist_custom_name(pk, 'Alias')
|
||||
after = db.get_mirrored_playlist(pk)
|
||||
assert after['name'] == before['name']
|
||||
assert after['source'] == before['source']
|
||||
assert after['source_playlist_id'] == before['source_playlist_id']
|
||||
54
tests/test_navidrome_self_heal.py
Normal file
54
tests/test_navidrome_self_heal.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""Navidrome connection self-heals after a transient ping failure (jimmydotcom).
|
||||
|
||||
A failed ping nukes the creds in _setup_client; previously _connection_attempted
|
||||
latched the client 'disconnected' until a manual Test. Now is_connected re-attempts
|
||||
(throttled) so a blip recovers on its own."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.navidrome_client import NavidromeClient
|
||||
|
||||
|
||||
def test_self_heals_after_transient_failure(monkeypatch):
|
||||
c = NavidromeClient()
|
||||
calls = {'n': 0}
|
||||
|
||||
def fake_setup():
|
||||
calls['n'] += 1
|
||||
if calls['n'] == 1: # transient failure nukes creds
|
||||
c.base_url = c.username = c.password = None
|
||||
else: # blip passed → connects
|
||||
c.base_url, c.username, c.password = 'http://nd:4533', 'u', 'p'
|
||||
monkeypatch.setattr(c, '_setup_client', fake_setup)
|
||||
|
||||
assert c.is_connected() is False # first attempt fails
|
||||
assert calls['n'] == 1
|
||||
|
||||
assert c.is_connected() is False # immediate recheck: throttled
|
||||
assert calls['n'] == 1 # did NOT re-ping (no storm)
|
||||
|
||||
c._last_connect_attempt -= (c._RECONNECT_THROTTLE_S + 1) # throttle window elapses
|
||||
assert c.is_connected() is True # re-attempts → recovers itself
|
||||
assert calls['n'] == 2 # no manual reconnect was needed
|
||||
|
||||
|
||||
def test_connected_client_does_not_reattempt(monkeypatch):
|
||||
c = NavidromeClient()
|
||||
c.base_url, c.username, c.password = 'http://nd', 'u', 'p'
|
||||
c._connection_attempted = True
|
||||
calls = {'n': 0}
|
||||
monkeypatch.setattr(c, '_setup_client', lambda: calls.__setitem__('n', calls['n'] + 1))
|
||||
assert c.is_connected() is True
|
||||
assert calls['n'] == 0 # already connected → never re-pings
|
||||
|
||||
|
||||
def test_first_connect_attempts_once(monkeypatch):
|
||||
c = NavidromeClient()
|
||||
calls = {'n': 0}
|
||||
|
||||
def fake_setup():
|
||||
calls['n'] += 1
|
||||
c.base_url, c.username, c.password = 'http://nd', 'u', 'p'
|
||||
monkeypatch.setattr(c, '_setup_client', fake_setup)
|
||||
assert c.is_connected() is True
|
||||
assert calls['n'] == 1
|
||||
|
|
@ -52,6 +52,64 @@ def _seed_library(db_path: Path) -> None:
|
|||
conn.close()
|
||||
|
||||
|
||||
def test_mass_orphan_path_mismatch_creates_no_findings(tmp_path: Path) -> None:
|
||||
"""The "transferred to staging" footgun: when the DB's stored paths no longer
|
||||
match the filesystem (remount / Docker volume change) EVERY file looks
|
||||
orphaned. The detector must create NO findings then — otherwise a user
|
||||
batch-applying "move to staging" relocates their whole library. Mirrors the
|
||||
hard skip the stale-removal paths use.
|
||||
"""
|
||||
db_path = tmp_path / "library.sqlite"
|
||||
_seed_library(db_path) # DB tracks live under /old/prefix/... — nothing on disk matches
|
||||
|
||||
# Drop 30 untracked files (> the 20 absolute floor, and 100% > 50%).
|
||||
music = tmp_path / "Some Artist" / "Some Album"
|
||||
music.mkdir(parents=True)
|
||||
for i in range(30):
|
||||
(music / f"{i:02d} - Track {i}.mp3").write_bytes(b"unreadable tags; no DB match")
|
||||
|
||||
findings = []
|
||||
context = JobContext(
|
||||
db=_DB(db_path),
|
||||
transfer_folder=str(tmp_path),
|
||||
config_manager=None,
|
||||
create_finding=lambda **kwargs: findings.append(kwargs) or True,
|
||||
)
|
||||
|
||||
result = OrphanFileDetectorJob().scan(context)
|
||||
|
||||
assert result.scanned == 30
|
||||
assert result.findings_created == 0
|
||||
assert findings == [] # hard skip — not even flagged as warnings
|
||||
|
||||
|
||||
def test_small_orphan_set_still_surfaces(tmp_path: Path) -> None:
|
||||
"""Below the absolute floor, genuine orphans must still be reported — the
|
||||
guard only suppresses an implausibly large flood, not normal stray files.
|
||||
"""
|
||||
db_path = tmp_path / "library.sqlite"
|
||||
_seed_library(db_path)
|
||||
|
||||
music = tmp_path / "Stray" / "Files"
|
||||
music.mkdir(parents=True)
|
||||
for i in range(3): # 3 orphans — under the 20-file floor
|
||||
(music / f"{i:02d} - Stray {i}.mp3").write_bytes(b"no DB match")
|
||||
|
||||
findings = []
|
||||
context = JobContext(
|
||||
db=_DB(db_path),
|
||||
transfer_folder=str(tmp_path),
|
||||
config_manager=None,
|
||||
create_finding=lambda **kwargs: findings.append(kwargs) or True,
|
||||
)
|
||||
|
||||
result = OrphanFileDetectorJob().scan(context)
|
||||
|
||||
assert result.scanned == 3
|
||||
assert result.findings_created == 3
|
||||
assert all(f['finding_type'] == 'orphan_file' for f in findings)
|
||||
|
||||
|
||||
def test_orphan_detector_accepts_picard_albumartist_folder_match(tmp_path: Path) -> None:
|
||||
"""Picard paths use albumartist/album (year)/track - title.
|
||||
|
||||
|
|
|
|||
143
tests/test_playlist_materialize.py
Normal file
143
tests/test_playlist_materialize.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"""Playlist materialization seam — a playlist folder is a derived view of links
|
||||
into the real library. Locks down: symlink vs copy, the auto-fallback when
|
||||
symlinks aren't supported, idempotency, stale-link pruning, collision handling,
|
||||
and that nothing is ever written outside the playlists root."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from core.playlists.materialize import (
|
||||
DEFAULT_MODE,
|
||||
materialize_one,
|
||||
normalize_mode,
|
||||
playlist_dir_for,
|
||||
rebuild_playlist_folder,
|
||||
)
|
||||
|
||||
|
||||
def _library(tmp_path: Path) -> list[str]:
|
||||
a = tmp_path / "Music" / "Daft Punk" / "Discovery" / "One More Time.mp3"
|
||||
b = tmp_path / "Music" / "Queen" / "A Night at the Opera" / "Bohemian Rhapsody.mp3"
|
||||
for f in (a, b):
|
||||
f.parent.mkdir(parents=True, exist_ok=True)
|
||||
f.write_bytes(b"audio")
|
||||
return [str(a), str(b)]
|
||||
|
||||
|
||||
def test_normalize_mode():
|
||||
assert normalize_mode("symlink") == "symlink"
|
||||
assert normalize_mode("COPY") == "copy"
|
||||
assert normalize_mode("") == DEFAULT_MODE
|
||||
assert normalize_mode(None) == DEFAULT_MODE
|
||||
assert normalize_mode("nonsense") == DEFAULT_MODE
|
||||
|
||||
|
||||
def test_symlink_mode_creates_relative_links(tmp_path: Path):
|
||||
real = _library(tmp_path)
|
||||
s = rebuild_playlist_folder(str(tmp_path / "Playlists"), "Road Trip", real, mode="symlink")
|
||||
assert s.linked == 2 and s.copied == 0 and not s.fellback
|
||||
link = Path(s.playlist_dir) / "One More Time.mp3"
|
||||
assert link.is_symlink()
|
||||
assert not os.path.isabs(os.readlink(link)) # relative for portability
|
||||
assert link.resolve() == Path(real[0]).resolve()
|
||||
assert link.read_bytes() == b"audio"
|
||||
|
||||
|
||||
def test_symlink_mode_idempotent(tmp_path: Path):
|
||||
real = _library(tmp_path)
|
||||
root = str(tmp_path / "Playlists")
|
||||
rebuild_playlist_folder(root, "Mix", real, mode="symlink")
|
||||
s2 = rebuild_playlist_folder(root, "Mix", real, mode="symlink")
|
||||
assert s2.unchanged == 2 and s2.linked == 0 and s2.removed_stale == 0
|
||||
|
||||
|
||||
def test_copy_mode_duplicates_real_files(tmp_path: Path):
|
||||
real = _library(tmp_path)
|
||||
s = rebuild_playlist_folder(str(tmp_path / "Playlists"), "USB", real, mode="copy")
|
||||
assert s.copied == 2 and s.linked == 0
|
||||
f = Path(s.playlist_dir) / "Bohemian Rhapsody.mp3"
|
||||
assert f.is_file() and not f.is_symlink() and f.read_bytes() == b"audio"
|
||||
|
||||
|
||||
def test_copy_mode_idempotent(tmp_path: Path):
|
||||
real = _library(tmp_path)
|
||||
root = str(tmp_path / "Playlists")
|
||||
rebuild_playlist_folder(root, "Mix", real, mode="copy")
|
||||
s2 = rebuild_playlist_folder(root, "Mix", real, mode="copy")
|
||||
assert s2.unchanged == 2 and s2.copied == 0
|
||||
|
||||
|
||||
def test_falls_back_to_copy_when_symlinks_unsupported(tmp_path: Path):
|
||||
real = _library(tmp_path)
|
||||
|
||||
def _no_symlinks(target, link):
|
||||
raise OSError("symlinks not supported here")
|
||||
|
||||
s = rebuild_playlist_folder(str(tmp_path / "Playlists"), "Car", real,
|
||||
mode="symlink", symlink_fn=_no_symlinks)
|
||||
assert s.fellback is True and s.copied == 2 and s.linked == 0
|
||||
f = Path(s.playlist_dir) / "One More Time.mp3"
|
||||
assert f.is_file() and not f.is_symlink() and f.read_bytes() == b"audio"
|
||||
|
||||
|
||||
def test_rebuild_prunes_entries_no_longer_in_playlist(tmp_path: Path):
|
||||
real = _library(tmp_path)
|
||||
root = str(tmp_path / "Playlists")
|
||||
rebuild_playlist_folder(root, "Mix", real, mode="symlink") # 2 entries
|
||||
s = rebuild_playlist_folder(root, "Mix", real[:1], mode="symlink") # drop one
|
||||
assert s.removed_stale == 1
|
||||
pdir = Path(s.playlist_dir)
|
||||
assert (pdir / "One More Time.mp3").exists()
|
||||
assert not (pdir / "Bohemian Rhapsody.mp3").exists()
|
||||
|
||||
|
||||
def test_prune_stale_can_be_disabled(tmp_path: Path):
|
||||
real = _library(tmp_path)
|
||||
root = str(tmp_path / "Playlists")
|
||||
rebuild_playlist_folder(root, "Mix", real, mode="symlink")
|
||||
s = rebuild_playlist_folder(root, "Mix", real[:1], mode="symlink", prune_stale=False)
|
||||
assert s.removed_stale == 0
|
||||
assert (Path(s.playlist_dir) / "Bohemian Rhapsody.mp3").exists()
|
||||
|
||||
|
||||
def test_switching_mode_replaces_links_with_copies(tmp_path: Path):
|
||||
real = _library(tmp_path)
|
||||
root = str(tmp_path / "Playlists")
|
||||
rebuild_playlist_folder(root, "Mix", real, mode="symlink")
|
||||
s = rebuild_playlist_folder(root, "Mix", real, mode="copy")
|
||||
f = Path(s.playlist_dir) / "One More Time.mp3"
|
||||
assert f.is_file() and not f.is_symlink() and s.copied == 2
|
||||
|
||||
|
||||
def test_basename_collision_is_disambiguated_not_overwritten(tmp_path: Path):
|
||||
p1 = tmp_path / "Music" / "A" / "AlbumA" / "01 - Intro.mp3"
|
||||
p2 = tmp_path / "Music" / "B" / "AlbumB" / "01 - Intro.mp3"
|
||||
for f, data in ((p1, b"one"), (p2, b"two")):
|
||||
f.parent.mkdir(parents=True, exist_ok=True)
|
||||
f.write_bytes(data)
|
||||
s = rebuild_playlist_folder(str(tmp_path / "Playlists"), "Dup", [str(p1), str(p2)], mode="copy")
|
||||
pdir = Path(s.playlist_dir)
|
||||
assert (pdir / "01 - Intro.mp3").read_bytes() == b"one"
|
||||
assert (pdir / "01 - Intro (2).mp3").read_bytes() == b"two" # second kept, not lost
|
||||
assert s.copied == 2
|
||||
|
||||
|
||||
def test_missing_source_is_counted_not_fatal(tmp_path: Path):
|
||||
real = _library(tmp_path)
|
||||
s = rebuild_playlist_folder(str(tmp_path / "Playlists"), "Mix",
|
||||
real + [str(tmp_path / "gone.mp3")], mode="symlink")
|
||||
assert s.linked == 2 and s.missing_source == 1
|
||||
|
||||
|
||||
def test_playlist_name_cannot_escape_root(tmp_path: Path):
|
||||
root = tmp_path / "Playlists"
|
||||
nasty = playlist_dir_for(str(root), "../../etc/evil")
|
||||
assert os.path.abspath(nasty).startswith(os.path.abspath(str(root)) + os.sep)
|
||||
|
||||
|
||||
def test_materialize_one_missing_source(tmp_path: Path):
|
||||
dest = tmp_path / "Playlists" / "X" / "x.mp3"
|
||||
assert materialize_one(str(tmp_path / "nope.mp3"), str(dest), "symlink") == "missing"
|
||||
assert not dest.exists()
|
||||
268
tests/test_playlist_materialize_service.py
Normal file
268
tests/test_playlist_materialize_service.py
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
"""Playlist materialize SERVICE — builds the folder from a finished batch's own
|
||||
payload (owned matched_file_path + downloaded final_file_path). Locks down: it
|
||||
stitches owned + downloaded, ignores not-found/not-completed, de-dupes, gates on
|
||||
the organize flag, and never depends on source IDs or a mirrored playlist."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from core.playlists.materialize_service import (
|
||||
collect_batch_real_paths,
|
||||
materialize_playlist_from_batch,
|
||||
rebuild_mirrored_playlist_if_organized,
|
||||
rebuild_organized_playlists_from_db,
|
||||
reconcile_batch_playlists,
|
||||
)
|
||||
|
||||
|
||||
class _Track:
|
||||
"""Mimics database.DatabaseTrack — what check_track_exists returns."""
|
||||
def __init__(self, file_path):
|
||||
self.file_path = file_path
|
||||
|
||||
|
||||
class _RebuildDB:
|
||||
"""One organized playlist (Mix) + one not (Off); check_track_exists matches
|
||||
by NAME via `owned` (track_name -> file_path), so no source IDs involved."""
|
||||
def __init__(self, owned):
|
||||
self.owned = owned
|
||||
|
||||
def get_mirrored_playlists(self, profile_id=1):
|
||||
return [
|
||||
{"id": 1, "name": "Mix", "source_playlist_id": "PL1", "organize_by_playlist": True},
|
||||
{"id": 2, "name": "Off", "source_playlist_id": "PL2", "organize_by_playlist": False},
|
||||
]
|
||||
|
||||
def get_mirrored_playlist_tracks(self, pid):
|
||||
if pid == 1:
|
||||
return [{"track_name": "A", "artist_name": "x"},
|
||||
{"track_name": "Gone", "artist_name": "y"}] # not owned
|
||||
return [{"track_name": "B", "artist_name": "z"}]
|
||||
|
||||
def get_mirrored_playlist(self, playlist_id):
|
||||
for pl in self.get_mirrored_playlists():
|
||||
if pl["id"] == playlist_id:
|
||||
return pl
|
||||
return None
|
||||
|
||||
def resolve_mirrored_playlist(self, ref, profile_id=1, *, default_source="spotify"):
|
||||
for pl in self.get_mirrored_playlists():
|
||||
if str(ref) in (pl["source_playlist_id"], str(pl["id"]), pl["name"]):
|
||||
return pl
|
||||
return None
|
||||
|
||||
def check_track_exists(self, title, artist, confidence_threshold=0.8,
|
||||
server_source=None, album=None, candidate_tracks=None):
|
||||
fp = self.owned.get(title)
|
||||
return (_Track(fp), 1.0) if fp else (None, 0.0)
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, root, mode="symlink"):
|
||||
self._d = {"playlists.materialize_path": root, "playlists.materialize_mode": mode}
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._d.get(key, default)
|
||||
|
||||
|
||||
def _mk(tmp_path: Path, *names) -> list[str]:
|
||||
paths = []
|
||||
for n in names:
|
||||
f = tmp_path / "Music" / n
|
||||
f.parent.mkdir(parents=True, exist_ok=True)
|
||||
f.write_bytes(b"audio")
|
||||
paths.append(str(f))
|
||||
return paths
|
||||
|
||||
|
||||
def test_collect_stitches_owned_and_downloaded(tmp_path: Path):
|
||||
owned, downloaded = _mk(tmp_path, "Owned.mp3"), _mk(tmp_path, "Fresh.mp3")
|
||||
batch = {
|
||||
"analysis_results": [
|
||||
{"found": True, "matched_file_path": owned[0]},
|
||||
{"found": False, "matched_file_path": None}, # not owned → skip
|
||||
],
|
||||
"queue": ["t1", "t2"],
|
||||
}
|
||||
tasks = {
|
||||
"t1": {"status": "completed", "final_file_path": downloaded[0]},
|
||||
"t2": {"status": "failed", "final_file_path": None}, # not completed → skip
|
||||
}
|
||||
cfg = _Cfg(str(tmp_path / "Playlists"))
|
||||
paths = collect_batch_real_paths(batch, tasks, config_manager=cfg)
|
||||
assert paths == [owned[0], downloaded[0]] # owned first, then downloaded
|
||||
|
||||
|
||||
def test_collect_dedupes(tmp_path: Path):
|
||||
owned = _mk(tmp_path, "Same.mp3")
|
||||
batch = {
|
||||
"analysis_results": [{"found": True, "matched_file_path": owned[0]}],
|
||||
"queue": ["t1"],
|
||||
}
|
||||
tasks = {"t1": {"status": "completed", "final_file_path": owned[0]}} # same file
|
||||
cfg = _Cfg(str(tmp_path / "Playlists"))
|
||||
assert collect_batch_real_paths(batch, tasks, config_manager=cfg) == [owned[0]]
|
||||
|
||||
|
||||
def test_materialize_from_batch_all_owned(tmp_path: Path):
|
||||
"""The all-owned case (no downloads) — folder built entirely from analysis."""
|
||||
owned = _mk(tmp_path, "A.mp3", "B.mp3")
|
||||
batch = {
|
||||
"playlist_folder_mode": True,
|
||||
"playlist_name": "Smack That",
|
||||
"analysis_results": [
|
||||
{"found": True, "matched_file_path": owned[0]},
|
||||
{"found": True, "matched_file_path": owned[1]},
|
||||
],
|
||||
"queue": [],
|
||||
}
|
||||
cfg = _Cfg(str(tmp_path / "Playlists"))
|
||||
summary = materialize_playlist_from_batch(batch, {}, cfg)
|
||||
assert summary is not None and summary.linked == 2
|
||||
pdir = Path(summary.playlist_dir)
|
||||
assert pdir == tmp_path / "Playlists" / "Smack That"
|
||||
assert (pdir / "A.mp3").resolve() == Path(owned[0]).resolve()
|
||||
assert (pdir / "B.mp3").resolve() == Path(owned[1]).resolve()
|
||||
|
||||
|
||||
def test_materialize_from_batch_owned_plus_downloaded(tmp_path: Path):
|
||||
owned, downloaded = _mk(tmp_path, "Owned.mp3"), _mk(tmp_path, "Fresh.mp3")
|
||||
batch = {
|
||||
"playlist_folder_mode": True,
|
||||
"playlist_name": "Mix",
|
||||
"analysis_results": [{"found": True, "matched_file_path": owned[0]}],
|
||||
"queue": ["t1"],
|
||||
}
|
||||
tasks = {"t1": {"status": "completed", "final_file_path": downloaded[0]}}
|
||||
cfg = _Cfg(str(tmp_path / "Playlists"), mode="copy")
|
||||
summary = materialize_playlist_from_batch(batch, tasks, cfg)
|
||||
assert summary.copied == 2
|
||||
pdir = Path(summary.playlist_dir)
|
||||
assert (pdir / "Owned.mp3").is_file() and (pdir / "Fresh.mp3").is_file()
|
||||
|
||||
|
||||
def test_materialize_skipped_when_not_organize(tmp_path: Path):
|
||||
batch = {"playlist_folder_mode": False, "playlist_name": "X", "analysis_results": [], "queue": []}
|
||||
assert materialize_playlist_from_batch(batch, {}, _Cfg(str(tmp_path / "Playlists"))) is None
|
||||
assert not (tmp_path / "Playlists").exists()
|
||||
|
||||
|
||||
def test_reconcile_organize_batch_rebuilds_from_library(tmp_path: Path):
|
||||
"""An organize batch → its playlist is rebuilt from the LIBRARY (membership ×
|
||||
check_track_exists), not from fragile per-task fields. Owned members link,
|
||||
non-owned drop out."""
|
||||
a = _mk(tmp_path, "A.mp3")[0] # Mix membership = A, Gone; only A owned
|
||||
db = _RebuildDB({"A": a})
|
||||
batch = {"playlist_folder_mode": True, "playlist_name": "Mix",
|
||||
"source_playlist_ref": "PL1", "batch_source": "spotify", "queue": []}
|
||||
cfg = _Cfg(str(tmp_path / "Playlists"))
|
||||
results = reconcile_batch_playlists(db, batch, {}, cfg)
|
||||
assert len(results) == 1
|
||||
name, s = results[0]
|
||||
assert name == "Mix" and s.linked == 1 # A owned; Gone not in library → skipped
|
||||
assert (tmp_path / "Playlists" / "Mix" / "A.mp3").exists()
|
||||
|
||||
|
||||
def test_reconcile_batch_playlist_rebuilds_even_if_row_flag_off(tmp_path: Path):
|
||||
"""The batch's OWN playlist rebuilds because the per-download toggle (batch
|
||||
playlist_folder_mode) is the intent — even when the saved organize_by_playlist
|
||||
row flag is off (the common case: user flips the download-modal toggle, never
|
||||
the saved preference). 'Off' (PL2) has organize_by_playlist=False on the row."""
|
||||
b = _mk(tmp_path, "B.mp3")[0] # Off's membership = B
|
||||
db = _RebuildDB({"B": b})
|
||||
batch = {"playlist_folder_mode": True, "playlist_name": "Off",
|
||||
"source_playlist_ref": "PL2", "batch_source": "spotify", "queue": []}
|
||||
cfg = _Cfg(str(tmp_path / "Playlists"))
|
||||
results = reconcile_batch_playlists(db, batch, {}, cfg)
|
||||
assert len(results) == 1 and results[0][0] == "Off"
|
||||
assert (tmp_path / "Playlists" / "Off" / "B.mp3").exists()
|
||||
|
||||
|
||||
def test_reconcile_wishlist_track_rebuilds_its_playlist(tmp_path: Path):
|
||||
"""The wishlist gap: a wishlist batch (not organize) completes a track whose
|
||||
provenance points to an organize playlist → that playlist gets rebuilt from the
|
||||
library, regardless of which import path set which task field."""
|
||||
a = _mk(tmp_path, "A.mp3")[0]
|
||||
db = _RebuildDB({"A": a})
|
||||
batch = {"playlist_folder_mode": False, "playlist_name": "wishlist", "queue": ["w1"]}
|
||||
tasks = {"w1": {"status": "completed",
|
||||
"track_info": {"source_info": {"playlist_id": "PL1", "source": "spotify"}}}}
|
||||
cfg = _Cfg(str(tmp_path / "Playlists"))
|
||||
results = reconcile_batch_playlists(db, batch, tasks, cfg)
|
||||
assert len(results) == 1 and results[0][0] == "Mix"
|
||||
assert (tmp_path / "Playlists" / "Mix" / "A.mp3").exists() # Mix rebuilt from the library
|
||||
|
||||
|
||||
def test_reconcile_skips_non_organized_provenance(tmp_path: Path):
|
||||
"""A completed track pointing to a NON-organize playlist (Off / PL2) is ignored."""
|
||||
db = _RebuildDB({"B": _mk(tmp_path, "B.mp3")[0]})
|
||||
batch = {"playlist_folder_mode": False, "playlist_name": "wishlist", "queue": ["w1"]}
|
||||
tasks = {"w1": {"status": "completed",
|
||||
"track_info": {"source_info": {"playlist_id": "PL2", "source": "spotify"}}}}
|
||||
assert reconcile_batch_playlists(db, batch, tasks, _Cfg(str(tmp_path / "Playlists"))) == []
|
||||
assert not (tmp_path / "Playlists" / "Off").exists()
|
||||
|
||||
|
||||
def test_reconcile_noop_for_plain_batch(tmp_path: Path):
|
||||
"""A normal (non-organize, no provenance) batch → nothing happens."""
|
||||
db = _RebuildDB({})
|
||||
batch = {"playlist_folder_mode": False, "playlist_name": "album", "queue": ["a1"]}
|
||||
tasks = {"a1": {"status": "completed", "track_info": {}}} # no source_info
|
||||
assert reconcile_batch_playlists(db, batch, tasks, _Cfg(str(tmp_path / "Playlists"))) == []
|
||||
assert not (tmp_path / "Playlists").exists()
|
||||
|
||||
|
||||
def test_mirror_cleanup_prunes_removed_track(tmp_path: Path):
|
||||
"""Mirror-update hook: after a track LEAVES the playlist, its symlink is pruned."""
|
||||
a = tmp_path / "Music" / "A.mp3"
|
||||
gone = tmp_path / "Music" / "Gone.mp3"
|
||||
for f in (a, gone):
|
||||
f.parent.mkdir(parents=True, exist_ok=True)
|
||||
f.write_bytes(b"audio")
|
||||
|
||||
class _DB:
|
||||
def get_mirrored_playlist(self, pid):
|
||||
return {"id": 1, "name": "Mix", "organize_by_playlist": True} if pid == 1 else None
|
||||
|
||||
def get_mirrored_playlist_tracks(self, pid):
|
||||
return [{"track_name": "A", "artist_name": "x"}] # 'Gone' was removed upstream
|
||||
|
||||
def check_track_exists(self, title, artist, confidence_threshold=0.8,
|
||||
server_source=None, album=None, candidate_tracks=None):
|
||||
fp = {"A": str(a), "Gone": str(gone)}.get(title)
|
||||
return (_Track(fp), 1.0) if fp else (None, 0.0)
|
||||
|
||||
from core.playlists.materialize import rebuild_playlist_folder
|
||||
rebuild_playlist_folder(str(tmp_path / "Playlists"), "Mix", [str(a), str(gone)], "symlink")
|
||||
assert (tmp_path / "Playlists" / "Mix" / "Gone.mp3").exists() # both present before
|
||||
|
||||
summary = rebuild_mirrored_playlist_if_organized(_DB(), _Cfg(str(tmp_path / "Playlists")), 1, profile_id=1)
|
||||
assert summary is not None
|
||||
pdir = tmp_path / "Playlists" / "Mix"
|
||||
assert (pdir / "A.mp3").exists()
|
||||
assert not (pdir / "Gone.mp3").exists() # pruned on mirror update
|
||||
|
||||
|
||||
def test_mirror_cleanup_skips_non_organized(tmp_path: Path):
|
||||
db = _RebuildDB({})
|
||||
cfg = _Cfg(str(tmp_path / "Playlists"))
|
||||
assert rebuild_mirrored_playlist_if_organized(db, cfg, 2, profile_id=1) is None # Off (organize=0)
|
||||
assert rebuild_mirrored_playlist_if_organized(db, cfg, 999, profile_id=1) is None # unknown
|
||||
assert rebuild_mirrored_playlist_if_organized(db, cfg, None, profile_id=1) is None
|
||||
|
||||
|
||||
def test_rebuild_from_db_only_organized_and_owned(tmp_path: Path):
|
||||
"""The manual button: rebuild every organized playlist by re-matching with
|
||||
check_track_exists (name), linking only owned tracks."""
|
||||
f = tmp_path / "Music" / "A.mp3"
|
||||
f.parent.mkdir(parents=True, exist_ok=True)
|
||||
f.write_bytes(b"audio")
|
||||
db = _RebuildDB({"A": str(f)}) # 'Gone' + the 'Off' playlist's 'B' not owned
|
||||
cfg = _Cfg(str(tmp_path / "Playlists"))
|
||||
results = rebuild_organized_playlists_from_db(db, cfg, profile_id=1)
|
||||
assert len(results) == 1 # only Mix (organize on)
|
||||
name, s = results[0]
|
||||
assert name == "Mix" and s.linked == 1 # only A owned; Gone skipped
|
||||
assert (tmp_path / "Playlists" / "Mix" / "A.mp3").exists()
|
||||
assert not (tmp_path / "Playlists" / "Off").exists()
|
||||
82
tests/test_replaygain_filler_job.py
Normal file
82
tests/test_replaygain_filler_job.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""ReplayGain Filler job (#437) — fills ReplayGain on library content that skipped
|
||||
download post-processing (Lidarr / REST API / manual adds). Pure flag decision +
|
||||
the apply handler's analyze→compute→write seam (ffmpeg mocked)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from core.repair_jobs.replaygain_filler import needs_replaygain
|
||||
from core.repair_worker import RepairWorker
|
||||
|
||||
|
||||
# ── pure decision: does a track need ReplayGain? ────────────────────────────
|
||||
def test_needs_rg_when_no_tags():
|
||||
assert needs_replaygain(None) is True
|
||||
|
||||
|
||||
def test_needs_rg_when_track_gain_missing():
|
||||
assert needs_replaygain({'track_gain': None, 'track_peak': None}) is True
|
||||
|
||||
|
||||
def test_needs_rg_when_track_gain_blank():
|
||||
assert needs_replaygain({'track_gain': ' '}) is True
|
||||
|
||||
|
||||
def test_no_rg_needed_when_gain_present():
|
||||
assert needs_replaygain({'track_gain': '-6.50 dB'}) is False
|
||||
|
||||
|
||||
def test_zero_gain_counts_as_tagged():
|
||||
# A legitimate "+0.00 dB" is already analyzed — must NOT be re-flagged forever.
|
||||
assert needs_replaygain({'track_gain': '+0.00 dB'}) is False
|
||||
|
||||
|
||||
# ── apply handler: analyze → compute gain → write (ffmpeg mocked) ────────────
|
||||
def _worker():
|
||||
w = RepairWorker(database=SimpleNamespace())
|
||||
w._config_manager = None
|
||||
return w
|
||||
|
||||
|
||||
def test_apply_writes_rg_with_pipeline_gain_formula(tmp_path):
|
||||
f = tmp_path / 'song.flac'
|
||||
f.write_bytes(b'\x00' * 64)
|
||||
written = {}
|
||||
|
||||
def fake_write(path, gain, peak, *a, **k):
|
||||
written.update(path=path, gain=gain, peak=peak)
|
||||
return True
|
||||
|
||||
with patch('core.replaygain.is_ffmpeg_available', return_value=True), \
|
||||
patch('core.replaygain.analyze_track', return_value=(-12.0, -1.5)), \
|
||||
patch('core.replaygain.write_replaygain_tags', side_effect=fake_write), \
|
||||
patch('core.replaygain.RG_REFERENCE_LUFS', -18.0):
|
||||
res = _worker()._fix_missing_replaygain('track', '1', str(f), {'file_path': str(f)})
|
||||
|
||||
assert res['success'] is True and res['action'] == 'applied_replaygain'
|
||||
# gain = reference - lufs = -18.0 - (-12.0) = -6.0 (same as the import pipeline)
|
||||
assert written['gain'] == -6.0
|
||||
assert written['peak'] == -1.5
|
||||
assert written['path'] == str(f)
|
||||
|
||||
|
||||
def test_apply_errors_without_ffmpeg(tmp_path):
|
||||
f = tmp_path / 's.flac'
|
||||
f.write_bytes(b'\x00' * 64)
|
||||
with patch('core.replaygain.is_ffmpeg_available', return_value=False):
|
||||
res = _worker()._fix_missing_replaygain('track', '1', str(f), {'file_path': str(f)})
|
||||
assert res['success'] is False and 'ffmpeg' in res['error'].lower()
|
||||
|
||||
|
||||
def test_apply_errors_when_file_missing():
|
||||
res = _worker()._fix_missing_replaygain(
|
||||
'track', '1', '/no/such/file.flac', {'file_path': '/no/such/file.flac'})
|
||||
assert res['success'] is False
|
||||
|
||||
|
||||
def test_job_is_registered_and_opt_in():
|
||||
from core.repair_jobs import get_all_jobs
|
||||
j = get_all_jobs().get('replaygain_filler')
|
||||
assert j is not None and j.default_enabled is False
|
||||
|
|
@ -55,3 +55,30 @@ def test_empty_refs_return_none(tmp_path):
|
|||
assert db.resolve_mirrored_playlist(None) is None
|
||||
assert db.resolve_mirrored_playlist('') is None
|
||||
assert db.resolve_mirrored_playlist(' ') is None
|
||||
|
||||
|
||||
def test_synthetic_mirrored_batch_ref_resolves_by_pk(tmp_path):
|
||||
"""A discovery/mirror batch carries a synthetic playlist_id like
|
||||
youtube_mirrored_<pk> with batch source 'mirrored'. (source, source_playlist_id)
|
||||
can't match it — it must resolve via the embedded PK. Regression for the
|
||||
organize-by-playlist 'all found but no folder built' report."""
|
||||
db = MusicDatabase(str(tmp_path / "m.db"))
|
||||
pk = db.mirror_playlist(source='youtube', source_playlist_id='abc123XYZ',
|
||||
name='My Mirror', tracks=[], profile_id=1)
|
||||
assert pk
|
||||
for ref in (f'youtube_mirrored_{pk}', f'auto_mirror_{pk}', f'mirrored_{pk}'):
|
||||
row = db.resolve_mirrored_playlist(ref, profile_id=1, default_source='mirrored')
|
||||
assert row is not None and row['id'] == pk, ref
|
||||
|
||||
|
||||
def test_extract_mirrored_pk_pure():
|
||||
from core.playlists.source_refs import extract_mirrored_pk
|
||||
assert extract_mirrored_pk('youtube_mirrored_63') == 63
|
||||
assert extract_mirrored_pk('auto_mirror_7') == 7
|
||||
assert extract_mirrored_pk('mirrored_12') == 12
|
||||
assert extract_mirrored_pk('42') == 42 # bare PK
|
||||
assert extract_mirrored_pk('908622995') == 908622995 # numeric upstream id (PK fallback only)
|
||||
assert extract_mirrored_pk('37i9dQZF1DXcBWIGoYBM5M') is None # real spotify id
|
||||
assert extract_mirrored_pk('youtube_mirrored_') is None # no digits
|
||||
assert extract_mirrored_pk('') is None
|
||||
assert extract_mirrored_pk(None) is None
|
||||
|
|
|
|||
|
|
@ -248,6 +248,99 @@ def test_search_handles_empty_entries(tmp_dl: Path) -> None:
|
|||
assert tracks == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# URL resolution (#865): paste a SoundCloud link, incl. unlisted/private
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_is_soundcloud_url_accepts_real_links() -> None:
|
||||
ok = [
|
||||
'https://soundcloud.com/artist/track-name',
|
||||
'http://soundcloud.com/artist/sets/my-set',
|
||||
'https://soundcloud.com/artist/track/s-AbC123', # private share token
|
||||
'soundcloud.com/artist/track-name', # scheme-less
|
||||
'https://m.soundcloud.com/artist/track',
|
||||
'https://on.soundcloud.com/aBcDe', # short link
|
||||
]
|
||||
for u in ok:
|
||||
assert soundcloud_client.is_soundcloud_url(u) is True, u
|
||||
|
||||
|
||||
def test_is_soundcloud_url_rejects_non_links() -> None:
|
||||
no = [
|
||||
'daft punk around the world',
|
||||
'best soundcloud tracks 2024', # mentions soundcloud, not a URL
|
||||
'https://youtube.com/watch?v=abc',
|
||||
'https://open.spotify.com/track/xyz',
|
||||
'', None, 42,
|
||||
]
|
||||
for u in no:
|
||||
assert soundcloud_client.is_soundcloud_url(u) is False, repr(u)
|
||||
|
||||
|
||||
def test_search_routes_a_url_to_resolve(tmp_dl: Path) -> None:
|
||||
"""A pasted SoundCloud URL must go through resolve_url, NOT scsearch."""
|
||||
client = SoundcloudClient(download_path=str(tmp_dl))
|
||||
sentinel = ([TrackResult(username='soundcloud', filename='1||u||n', size=0,
|
||||
bitrate=128, duration=None, quality='mp3',
|
||||
free_upload_slots=999, upload_speed=1, queue_length=0)], [])
|
||||
# resolve_url is async → patch.object gives an AsyncMock, so return_value is
|
||||
# what `await` yields.
|
||||
with patch.object(client, 'resolve_url', return_value=sentinel) as rv, \
|
||||
patch.object(client, '_extract_search_entries') as scsearch:
|
||||
tracks, albums = _run(client.search('https://soundcloud.com/x/private/s-tok'))
|
||||
rv.assert_called_once()
|
||||
scsearch.assert_not_called()
|
||||
assert len(tracks) == 1
|
||||
|
||||
|
||||
def test_resolve_url_single_track_uses_permalink(tmp_dl: Path) -> None:
|
||||
"""Full extraction puts a transient stream URL in `url`; the stable permalink
|
||||
is `webpage_url`. The filename must encode the permalink."""
|
||||
info = {
|
||||
'id': '999',
|
||||
'title': 'Artist Name - Secret Song',
|
||||
'uploader': 'artistname',
|
||||
'url': 'https://cf-media.sndcdn.com/transient-stream.mp3', # expires
|
||||
'webpage_url': 'https://soundcloud.com/artistname/secret-song/s-Tok3n',
|
||||
'duration': 200.0,
|
||||
}
|
||||
client = SoundcloudClient(download_path=str(tmp_dl))
|
||||
with patch.object(client, '_extract_url_info', return_value=info):
|
||||
tracks, albums = _run(client.resolve_url('https://soundcloud.com/artistname/secret-song/s-Tok3n'))
|
||||
assert albums == []
|
||||
assert len(tracks) == 1
|
||||
parts = tracks[0].filename.split('||')
|
||||
assert parts[0] == '999'
|
||||
assert parts[1] == 'https://soundcloud.com/artistname/secret-song/s-Tok3n' # permalink, not stream
|
||||
assert tracks[0]._source_metadata['permalink_url'] == 'https://soundcloud.com/artistname/secret-song/s-Tok3n'
|
||||
assert tracks[0].artist == 'Artist Name'
|
||||
assert tracks[0].duration == 200000
|
||||
|
||||
|
||||
def test_resolve_url_set_yields_all_tracks(tmp_dl: Path) -> None:
|
||||
info = {
|
||||
'entries': [
|
||||
{'id': '1', 'title': 'A - One', 'webpage_url': 'https://soundcloud.com/a/one', 'duration': 100},
|
||||
{'id': '2', 'title': 'A - Two', 'webpage_url': 'https://soundcloud.com/a/two', 'duration': 120},
|
||||
],
|
||||
}
|
||||
client = SoundcloudClient(download_path=str(tmp_dl))
|
||||
with patch.object(client, '_extract_url_info', return_value=info):
|
||||
tracks, _ = _run(client.resolve_url('https://soundcloud.com/a/sets/my-set'))
|
||||
assert {t._source_metadata['track_id'] for t in tracks} == {'1', '2'}
|
||||
assert all('soundcloud.com' in t.filename.split('||')[1] for t in tracks)
|
||||
|
||||
|
||||
def test_resolve_url_empty_on_failure(tmp_dl: Path) -> None:
|
||||
client = SoundcloudClient(download_path=str(tmp_dl))
|
||||
with patch.object(client, '_extract_url_info', side_effect=RuntimeError('blocked')):
|
||||
assert _run(client.resolve_url('https://soundcloud.com/x/y')) == ([], [])
|
||||
with patch.object(client, '_extract_url_info', return_value=None):
|
||||
assert _run(client.resolve_url('https://soundcloud.com/x/y')) == ([], [])
|
||||
assert _run(client.resolve_url('')) == ([], [])
|
||||
|
||||
|
||||
def test_search_handles_malformed_entries_individually(tmp_dl: Path) -> None:
|
||||
"""One bad entry shouldn't poison the entire result set."""
|
||||
fake_entries = [
|
||||
|
|
|
|||
126
tests/test_spotify_free_status.py
Normal file
126
tests/test_spotify_free_status.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""Spotify-Free (no-auth) must read as a WORKING primary metadata source.
|
||||
|
||||
A user who picks 'Spotify Free' (fallback_source='spotify' + metadata.spotify_free)
|
||||
is officially unauthenticated, so is_spotify_authenticated() is False. The sidebar/
|
||||
dashboard status dot keys on get_primary_source_status()['connected'], and the
|
||||
dashboard test button on run_service_test('spotify', ...). Both used to report
|
||||
disconnected / "Deezer connection successful! (Spotify configured but not
|
||||
authenticated)" even though Spotify metadata was actually flowing.
|
||||
|
||||
Root cause (pinned by test_*_unauthed_free_seen_via_direct_client): get_client_for_source
|
||||
('spotify') returns None unless officially authed, so the free-availability check in
|
||||
get_primary_source_status could never fire — the client it probed was always None.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import core.metadata.registry as registry
|
||||
import core.connection_test as connection_test
|
||||
|
||||
|
||||
class _FreeClient:
|
||||
"""No-auth Spotify: not officially authed, but free metadata IS available."""
|
||||
|
||||
def is_spotify_authenticated(self):
|
||||
return False
|
||||
|
||||
def is_spotify_metadata_available(self):
|
||||
return True
|
||||
|
||||
|
||||
class _NoMetaClient:
|
||||
def is_spotify_authenticated(self):
|
||||
return False
|
||||
|
||||
def is_spotify_metadata_available(self):
|
||||
return False
|
||||
|
||||
|
||||
def _patch_registry(monkeypatch, *, free_selected, client):
|
||||
cfg = {
|
||||
"metadata.fallback_source": "spotify",
|
||||
"metadata.spotify_free": free_selected,
|
||||
}
|
||||
monkeypatch.setattr(registry, "_get_config_value", lambda k, d=None: cfg.get(k, d))
|
||||
# get_client_for_source('spotify') returns None when unauthed; the direct fetch
|
||||
# is what the fix relies on, so route both through the fake.
|
||||
monkeypatch.setattr(registry, "get_spotify_client", lambda client_factory=None: client)
|
||||
|
||||
|
||||
def test_unauthed_free_seen_via_direct_client(monkeypatch):
|
||||
"""REGRESSION: free selected + available but not officially authed → connected.
|
||||
Before the fix this was False because the probed client was None."""
|
||||
_patch_registry(monkeypatch, free_selected=True, client=_FreeClient())
|
||||
status = registry.get_primary_source_status()
|
||||
assert status["connected"] is True
|
||||
assert status["source"] == "spotify_free"
|
||||
|
||||
|
||||
def test_free_not_selected_unauthed_is_disconnected(monkeypatch):
|
||||
"""Free NOT chosen → an unauthenticated Spotify primary is genuinely down."""
|
||||
_patch_registry(monkeypatch, free_selected=False, client=_FreeClient())
|
||||
status = registry.get_primary_source_status()
|
||||
assert status["connected"] is False
|
||||
assert status["source"] == "spotify"
|
||||
|
||||
|
||||
def test_free_selected_but_unavailable_is_disconnected(monkeypatch):
|
||||
"""Free chosen but the package/path can't serve → not connected (no false green)."""
|
||||
_patch_registry(monkeypatch, free_selected=True, client=_NoMetaClient())
|
||||
status = registry.get_primary_source_status()
|
||||
assert status["connected"] is False
|
||||
|
||||
|
||||
# --- dashboard test button (run_service_test) ---------------------------------
|
||||
|
||||
|
||||
class _FakeConfigManager:
|
||||
def __init__(self, store):
|
||||
self._store = store
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._store.get(key, default)
|
||||
|
||||
def set(self, key, value):
|
||||
self._store[key] = value
|
||||
|
||||
|
||||
def _run_spotify_test(monkeypatch, *, metadata_available, fallback="deezer"):
|
||||
fake_client = _FreeClient() if metadata_available else _NoMetaClient()
|
||||
|
||||
class _Client:
|
||||
def __init__(self):
|
||||
self._d = fake_client
|
||||
|
||||
def is_authenticated(self):
|
||||
return True # free user passes the top-level auth gate
|
||||
|
||||
def is_spotify_authenticated(self):
|
||||
return self._d.is_spotify_authenticated()
|
||||
|
||||
def is_spotify_metadata_available(self):
|
||||
return self._d.is_spotify_metadata_available()
|
||||
|
||||
monkeypatch.setattr(connection_test, "SpotifyClient", _Client)
|
||||
monkeypatch.setattr(
|
||||
connection_test,
|
||||
"config_manager",
|
||||
_FakeConfigManager({"spotify": {"client_id": "x", "client_secret": "y"}}),
|
||||
)
|
||||
monkeypatch.setattr(connection_test, "_get_metadata_fallback_source", lambda: fallback)
|
||||
monkeypatch.setattr(connection_test, "docker_resolve_url", lambda v: v, raising=False)
|
||||
return connection_test.run_service_test("spotify", {})
|
||||
|
||||
|
||||
def test_test_button_reports_spotify_free(monkeypatch):
|
||||
ok, msg = _run_spotify_test(monkeypatch, metadata_available=True)
|
||||
assert ok is True
|
||||
assert "Spotify (no-auth)" in msg
|
||||
|
||||
|
||||
def test_test_button_falls_back_when_free_unavailable(monkeypatch):
|
||||
"""No free path → keep the honest Deezer-fallback message."""
|
||||
ok, msg = _run_spotify_test(monkeypatch, metadata_available=False, fallback="deezer")
|
||||
assert ok is True
|
||||
assert "Deezer connection successful" in msg
|
||||
assert "Spotify (no-auth)" not in msg
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.library.stale_guard import is_implausible_orphan_flood as flood
|
||||
from core.library.stale_guard import is_implausible_stale_removal as g
|
||||
|
||||
|
||||
|
|
@ -26,3 +27,29 @@ def test_edge_inputs():
|
|||
assert g(0, 0) is False
|
||||
assert g(0, 100) is False # nothing missing
|
||||
assert g(5, 5) is True # min_total met, all missing
|
||||
|
||||
|
||||
# ── orphan-flood guard: same shape, protects the "move to staging" path ──────
|
||||
|
||||
def test_whole_library_flagged_orphan_is_blocked():
|
||||
# 4000/5000 files "orphaned" → a path mismatch, not real orphans.
|
||||
assert flood(4000, 5000) is True
|
||||
assert flood(21, 40) is True # just over both floors (>20 and >50%)
|
||||
|
||||
|
||||
def test_a_handful_of_real_orphans_still_surface():
|
||||
assert flood(3, 4000) is False # a few stray files — report them
|
||||
assert flood(20, 30) is False # at the absolute floor (not > 20)
|
||||
assert flood(2000, 4000) is False # exactly 50% is NOT over the threshold
|
||||
|
||||
|
||||
def test_orphan_flood_small_folders_never_blocked():
|
||||
# A 5-file folder that's all orphans is plausible (manual drop) — don't hide it.
|
||||
assert flood(5, 5) is False
|
||||
assert flood(20, 20) is False # below the absolute orphan floor
|
||||
|
||||
|
||||
def test_orphan_flood_edge_inputs():
|
||||
assert flood(0, 0) is False
|
||||
assert flood(0, 5000) is False # nothing orphaned
|
||||
assert flood(5000, 0) is False # nonsense totals don't trip it
|
||||
|
|
|
|||
107
tests/test_tidal_playlist_batch_chunking.py
Normal file
107
tests/test_tidal_playlist_batch_chunking.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"""#867 regression: Tidal playlist track hydration must chunk to the API cap.
|
||||
|
||||
Tidal's ``/tracks?filter[id]=...`` endpoint returns at most ``_COLLECTION_BATCH_SIZE``
|
||||
(20) tracks per request. ``get_playlist`` fetches a page of track-ID links and then
|
||||
hydrates them via ``_get_tracks_batch``. If it sends a relationships page with more
|
||||
than 20 IDs in a single hydration call, the surplus is silently dropped — a 59-track
|
||||
playlist would surface as ~20. ``get_album_tracks`` already chunks; this pins the same
|
||||
behavior for ``get_playlist`` so discovery sees every track.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.tidal_client import TidalClient, Track
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
status_code = 200
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {
|
||||
"data": {
|
||||
"id": "PL1",
|
||||
"attributes": {"name": "My Playlist", "accessType": "PRIVATE"},
|
||||
"relationships": {},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def get(self, *args, **kwargs):
|
||||
return _FakeResp()
|
||||
|
||||
|
||||
def _make_client():
|
||||
"""Build a TidalClient without running its network/config __init__."""
|
||||
client = object.__new__(TidalClient)
|
||||
client.base_url = "https://api.tidal.test"
|
||||
client.session = _FakeSession()
|
||||
return client
|
||||
|
||||
|
||||
def test_get_playlist_chunks_oversized_relationships_page(monkeypatch):
|
||||
client = _make_client()
|
||||
cap = TidalClient._COLLECTION_BATCH_SIZE # 20
|
||||
n = 59
|
||||
ids = [str(i) for i in range(n)]
|
||||
|
||||
monkeypatch.setattr(client, "_ensure_valid_token", lambda: True)
|
||||
# One relationships page returns ALL 59 ID links at once, then no cursor.
|
||||
monkeypatch.setattr(
|
||||
client,
|
||||
"_get_playlist_tracks_page",
|
||||
lambda playlist_id, cursor=None: {
|
||||
"data": [{"type": "tracks", "id": i} for i in ids],
|
||||
"links": {"meta": {}}, # no nextCursor -> single page
|
||||
},
|
||||
)
|
||||
|
||||
seen_chunk_sizes = []
|
||||
|
||||
def fake_batch(chunk_ids):
|
||||
# Simulate the real filter[id] cap: never return more than the cap, so a
|
||||
# single oversized call would lose the surplus (the bug being guarded).
|
||||
seen_chunk_sizes.append(len(chunk_ids))
|
||||
capped = chunk_ids[:cap]
|
||||
return [Track(id=i, name=f"t{i}", artists=["a"]) for i in capped]
|
||||
|
||||
monkeypatch.setattr(client, "_get_tracks_batch", fake_batch)
|
||||
|
||||
playlist = client.get_playlist("PL1")
|
||||
|
||||
assert playlist is not None
|
||||
# The whole point: all 59 hydrate, not just the first 20.
|
||||
assert len(playlist.tracks) == n
|
||||
# Every hydration call stayed within the API cap (so none truncated).
|
||||
assert seen_chunk_sizes and max(seen_chunk_sizes) <= cap
|
||||
|
||||
|
||||
def test_get_playlist_small_page_single_call(monkeypatch):
|
||||
"""A page at/under the cap still hydrates in one call (no behavior change)."""
|
||||
client = _make_client()
|
||||
ids = [str(i) for i in range(5)]
|
||||
|
||||
monkeypatch.setattr(client, "_ensure_valid_token", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
client,
|
||||
"_get_playlist_tracks_page",
|
||||
lambda playlist_id, cursor=None: {
|
||||
"data": [{"type": "tracks", "id": i} for i in ids],
|
||||
"links": {"meta": {}},
|
||||
},
|
||||
)
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_batch(chunk_ids):
|
||||
calls.append(list(chunk_ids))
|
||||
return [Track(id=i, name=f"t{i}", artists=["a"]) for i in chunk_ids]
|
||||
|
||||
monkeypatch.setattr(client, "_get_tracks_batch", fake_batch)
|
||||
|
||||
playlist = client.get_playlist("PL1")
|
||||
assert len(playlist.tracks) == 5
|
||||
assert len(calls) == 1 # one chunk, one hydration call
|
||||
|
|
@ -13,16 +13,15 @@ conftest is a different module instance. Use the ``shared_state`` fixture instea
|
|||
import pytest
|
||||
|
||||
|
||||
# All 7 tool progress pollers
|
||||
# Tool progress pollers
|
||||
TOOLS = [
|
||||
'stream', 'quality-scanner', 'duplicate-cleaner',
|
||||
'stream', 'duplicate-cleaner',
|
||||
'retag', 'db-update', 'metadata', 'logs',
|
||||
]
|
||||
|
||||
# Endpoint URLs keyed by tool name
|
||||
ENDPOINTS = {
|
||||
'stream': '/api/stream/status',
|
||||
'quality-scanner': '/api/quality-scanner/status',
|
||||
'duplicate-cleaner': '/api/duplicate-cleaner/status',
|
||||
'retag': '/api/retag/status',
|
||||
'db-update': '/api/database/update/status',
|
||||
|
|
@ -63,27 +62,6 @@ class TestToolDataShape:
|
|||
assert 'error_message' in data
|
||||
assert isinstance(data['progress'], (int, float))
|
||||
|
||||
def test_quality_scanner_shape(self, test_app, shared_state):
|
||||
"""Quality scanner has status, phase, progress, processed, total, quality_met."""
|
||||
app, socketio = test_app
|
||||
client = socketio.test_client(app)
|
||||
build = shared_state['build_quality_scanner_status']
|
||||
|
||||
socketio.emit('tool:quality-scanner', build())
|
||||
received = client.get_received()
|
||||
events = [e for e in received if e['name'] == 'tool:quality-scanner']
|
||||
assert len(events) >= 1
|
||||
data = events[0]['args'][0]
|
||||
|
||||
assert 'status' in data
|
||||
assert 'phase' in data
|
||||
assert 'progress' in data
|
||||
assert 'processed' in data
|
||||
assert 'total' in data
|
||||
assert 'quality_met' in data
|
||||
assert 'low_quality' in data
|
||||
assert 'matched' in data
|
||||
|
||||
def test_duplicate_cleaner_shape(self, test_app, shared_state):
|
||||
"""Duplicate cleaner has status, phase, progress, space_freed_mb."""
|
||||
app, socketio = test_app
|
||||
|
|
@ -255,11 +233,11 @@ class TestToolBackwardCompat:
|
|||
client2 = socketio.test_client(app)
|
||||
build = shared_state['build_tool_status']
|
||||
|
||||
socketio.emit('tool:quality-scanner', build('quality-scanner'))
|
||||
socketio.emit('tool:duplicate-cleaner', build('duplicate-cleaner'))
|
||||
|
||||
for client in [client1, client2]:
|
||||
received = client.get_received()
|
||||
events = [e for e in received if e['name'] == 'tool:quality-scanner']
|
||||
events = [e for e in received if e['name'] == 'tool:duplicate-cleaner']
|
||||
assert len(events) >= 1
|
||||
|
||||
client1.disconnect()
|
||||
|
|
@ -270,17 +248,15 @@ class TestToolBackwardCompat:
|
|||
app, socketio = test_app
|
||||
client = socketio.test_client(app)
|
||||
build = shared_state['build_tool_status']
|
||||
qs = shared_state['quality_scanner_state']
|
||||
dc = shared_state['duplicate_cleaner_state']
|
||||
|
||||
# Mutate state
|
||||
qs['status'] = 'finished'
|
||||
qs['progress'] = 100
|
||||
qs['processed'] = 100
|
||||
dc['status'] = 'finished'
|
||||
dc['progress'] = 100
|
||||
|
||||
socketio.emit('tool:quality-scanner', build('quality-scanner'))
|
||||
socketio.emit('tool:duplicate-cleaner', build('duplicate-cleaner'))
|
||||
received = client.get_received()
|
||||
events = [e for e in received if e['name'] == 'tool:quality-scanner']
|
||||
events = [e for e in received if e['name'] == 'tool:duplicate-cleaner']
|
||||
data = events[-1]['args'][0]
|
||||
assert data['status'] == 'finished'
|
||||
assert data['progress'] == 100
|
||||
assert data['processed'] == 100
|
||||
|
|
|
|||
42
tests/test_watchlist_auto_download.py
Normal file
42
tests/test_watchlist_auto_download.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""DB-level coverage for the per-artist watchlist auto_download ("follow only")
|
||||
toggle — the column migrates in, defaults to auto-download, and round-trips
|
||||
through get_watchlist_artists onto the WatchlistArtist dataclass."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from database.music_database import MusicDatabase
|
||||
|
||||
|
||||
def _get(db, profile_id=1):
|
||||
return {a.spotify_artist_id: a for a in db.get_watchlist_artists(profile_id=profile_id)}
|
||||
|
||||
|
||||
def test_auto_download_defaults_true(tmp_path):
|
||||
db = MusicDatabase(str(tmp_path / 'm.db'))
|
||||
assert db.add_artist_to_watchlist('sp1', 'Artist One', profile_id=1)
|
||||
artist = _get(db)['sp1']
|
||||
# New watchlist artists keep the existing behaviour: auto-download on.
|
||||
assert artist.auto_download is True
|
||||
|
||||
|
||||
def test_auto_download_persists_when_disabled(tmp_path):
|
||||
db = MusicDatabase(str(tmp_path / 'm.db'))
|
||||
db.add_artist_to_watchlist('sp1', 'Artist One', profile_id=1)
|
||||
|
||||
# Flip it off the same way the config endpoint's UPDATE does.
|
||||
with db._get_connection() as conn:
|
||||
conn.execute(
|
||||
"UPDATE watchlist_artists SET auto_download = 0 WHERE spotify_artist_id = ?",
|
||||
('sp1',),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
artist = _get(db)['sp1']
|
||||
assert artist.auto_download is False
|
||||
|
||||
|
||||
def test_auto_download_column_exists_after_migration(tmp_path):
|
||||
db = MusicDatabase(str(tmp_path / 'm.db'))
|
||||
with db._get_connection() as conn:
|
||||
cols = [c[1] for c in conn.execute("PRAGMA table_info(watchlist_artists)").fetchall()]
|
||||
assert 'auto_download' in cols
|
||||
|
|
@ -446,6 +446,52 @@ def test_scan_watchlist_artists_scans_tracks_and_updates_state(monkeypatch):
|
|||
assert scan_state["recent_wishlist_additions"][0]["track_name"] == "Track One"
|
||||
|
||||
|
||||
def test_follow_only_artist_discovers_but_skips_wishlist_add(monkeypatch):
|
||||
"""auto_download=False ("follow only"): the scan still finds the new track
|
||||
(counts toward new_tracks_found + the scan summary) but must NOT call
|
||||
add_track_to_wishlist — so nothing auto-downloads (corruption's request)."""
|
||||
monkeypatch.setattr(watchlist_scanner_module, "DELAY_BETWEEN_ARTISTS", 0)
|
||||
monkeypatch.setattr(watchlist_scanner_module, "DELAY_BETWEEN_ALBUMS", 0)
|
||||
|
||||
artist = _build_artist()
|
||||
artist.auto_download = False # follow only
|
||||
album = types.SimpleNamespace(id="album-1", name="Album One")
|
||||
album_data = {
|
||||
"name": "Album One",
|
||||
"images": [{"url": "https://example.com/album.jpg"}],
|
||||
"tracks": {"items": [{
|
||||
"id": "track-1", "name": "Track One", "track_number": 1,
|
||||
"disc_number": 1, "artists": [{"name": "Artist One"}],
|
||||
}]},
|
||||
}
|
||||
scanner = _build_scanner(album_data, [artist])
|
||||
scanner._database.has_fresh_similar_artists = lambda *a, **k: False
|
||||
|
||||
add_calls = []
|
||||
monkeypatch.setattr(scanner, "_backfill_missing_ids", lambda *a, **k: None)
|
||||
monkeypatch.setattr(scanner, "get_artist_image_url", lambda *a, **k: "https://example.com/artist.jpg")
|
||||
monkeypatch.setattr(scanner, "get_artist_discography_for_watchlist", lambda *a, **k: [album])
|
||||
monkeypatch.setattr(scanner, "_get_lookback_period_setting", lambda: "30")
|
||||
monkeypatch.setattr(scanner, "_get_rescan_cutoff", lambda: None)
|
||||
monkeypatch.setattr(scanner, "_should_include_release", lambda *a, **k: True)
|
||||
monkeypatch.setattr(scanner, "_should_include_track", lambda *a, **k: True)
|
||||
monkeypatch.setattr(scanner, "is_track_missing_from_library", lambda *a, **k: True)
|
||||
monkeypatch.setattr(scanner, "add_track_to_wishlist", lambda *a, **k: add_calls.append(a) or True)
|
||||
monkeypatch.setattr(scanner, "update_artist_scan_timestamp", lambda *a, **k: True)
|
||||
monkeypatch.setattr(scanner, "update_similar_artists", lambda *a, **k: True)
|
||||
monkeypatch.setattr(scanner, "_backfill_similar_artists_fallback_ids", lambda *a, **k: 0)
|
||||
|
||||
scan_state = {}
|
||||
results = scanner.scan_watchlist_artists([artist], scan_state=scan_state)
|
||||
|
||||
assert results[0].success is True
|
||||
assert results[0].new_tracks_found == 1 # still discovered/surfaced
|
||||
assert results[0].tracks_added_to_wishlist == 0 # but not auto-added
|
||||
assert add_calls == [] # the one gated call never fired
|
||||
assert scan_state["summary"]["new_tracks_found"] == 1
|
||||
assert scan_state["summary"]["tracks_added_to_wishlist"] == 0
|
||||
|
||||
|
||||
def test_scan_watchlist_artists_skips_placeholder_tracklists(monkeypatch):
|
||||
monkeypatch.setattr(watchlist_scanner_module, "DELAY_BETWEEN_ARTISTS", 0)
|
||||
monkeypatch.setattr(watchlist_scanner_module, "DELAY_BETWEEN_ALBUMS", 0)
|
||||
|
|
|
|||
186
tests/test_worker_artist_disambiguation.py
Normal file
186
tests/test_worker_artist_disambiguation.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
"""Per-worker same-name artist disambiguation (#868).
|
||||
|
||||
Each enrichment worker, when several same-name candidates clear the name gate,
|
||||
must pick the one whose catalog overlaps the albums the library actually owns —
|
||||
not whichever the source ranked first. Covers Spotify (also the Spotify-Free
|
||||
path, same client surface), iTunes, Deezer, and MusicBrainz.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
|
||||
|
||||
# A query-aware fake DB: owned-albums query → owned titles; the source_id_conflict
|
||||
# query (SELECT name FROM artists ...) → no conflict.
|
||||
class _Cur:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def fetchall(self):
|
||||
return self._rows
|
||||
|
||||
|
||||
class _Conn:
|
||||
def __init__(self, owned):
|
||||
self._owned = owned
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def execute(self, sql, params=()):
|
||||
if 'FROM albums' in sql:
|
||||
return _Cur([(t,) for t in self._owned])
|
||||
return _Cur([]) # conflict check → none
|
||||
|
||||
def cursor(self):
|
||||
return self
|
||||
|
||||
def commit(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _DB:
|
||||
def __init__(self, owned):
|
||||
self._owned = owned
|
||||
|
||||
def _get_connection(self):
|
||||
return _Conn(self._owned)
|
||||
|
||||
|
||||
# The owned library: the RIGHT "Rone" made these.
|
||||
OWNED = ['Tohu Bohu', 'Creatures', 'Mirapolis']
|
||||
WRONG = types.SimpleNamespace(id='wrong_rone', name='Rone')
|
||||
RIGHT = types.SimpleNamespace(id='right_rone', name='Rone')
|
||||
ALBUMS = {
|
||||
'wrong_rone': [{'title': 'Mixtape Vol 1'}, {'title': 'Random Single'}],
|
||||
'right_rone': [{'title': 'Tohu Bohu (Deluxe)'}, {'title': 'Creatures'}],
|
||||
}
|
||||
|
||||
|
||||
def test_spotify_picks_artist_overlapping_owned_catalog():
|
||||
from core.spotify_worker import SpotifyWorker
|
||||
w = object.__new__(SpotifyWorker)
|
||||
w.db = _DB(OWNED)
|
||||
w.stats = {'matched': 0, 'not_found': 0, 'errors': 0}
|
||||
w.client = types.SimpleNamespace(
|
||||
search_artists=lambda name, limit=5: [WRONG, RIGHT], # wrong ranked first
|
||||
get_artist_albums=lambda aid: ALBUMS.get(aid, []),
|
||||
)
|
||||
captured = {}
|
||||
w._get_existing_id = lambda *a: None
|
||||
w._mark_status = lambda *a: None
|
||||
w._name_similarity = lambda a, b: 1.0 # both "Rone" clear the gate
|
||||
w._is_spotify_id = lambda i: True
|
||||
w._update_artist = lambda artist_id, obj: captured.update(id=obj.id)
|
||||
|
||||
w._process_artist({'id': 5, 'name': 'Rone'})
|
||||
assert captured.get('id') == 'right_rone'
|
||||
assert w.stats['matched'] == 1
|
||||
|
||||
|
||||
def test_itunes_picks_artist_overlapping_owned_catalog():
|
||||
from core.itunes_worker import iTunesWorker
|
||||
w = object.__new__(iTunesWorker)
|
||||
w.db = _DB(OWNED)
|
||||
w.stats = {'matched': 0, 'not_found': 0, 'errors': 0}
|
||||
w.client = types.SimpleNamespace(
|
||||
search_artists=lambda name, limit=5: [WRONG, RIGHT],
|
||||
get_artist_albums=lambda aid: ALBUMS.get(aid, []),
|
||||
)
|
||||
captured = {}
|
||||
w._get_existing_id = lambda *a: None
|
||||
w._mark_status = lambda *a: None
|
||||
w._is_itunes_id = lambda i: True
|
||||
w._update_artist = lambda artist_id, obj: captured.update(id=obj.id)
|
||||
|
||||
w._process_artist({'id': 5, 'name': 'Rone'})
|
||||
assert captured.get('id') == 'right_rone'
|
||||
assert w.stats['matched'] == 1
|
||||
|
||||
|
||||
def test_deezer_picks_artist_overlapping_owned_catalog():
|
||||
from core.deezer_worker import DeezerWorker
|
||||
w = object.__new__(DeezerWorker)
|
||||
w.db = _DB(OWNED)
|
||||
w.stats = {'matched': 0, 'not_found': 0, 'errors': 0}
|
||||
w.client = types.SimpleNamespace(
|
||||
search_artists=lambda name, limit=5: [WRONG, RIGHT],
|
||||
get_artist_albums_list=lambda aid: ALBUMS.get(aid, []),
|
||||
get_artist_info=lambda aid: {'id': aid, 'name': 'Rone'},
|
||||
)
|
||||
captured = {}
|
||||
w._get_existing_id = lambda *a: None
|
||||
w._mark_status = lambda *a: None
|
||||
w._update_artist = lambda artist_id, data: captured.update(id=data.get('id'))
|
||||
|
||||
w._process_artist(5, 'Rone')
|
||||
assert captured.get('id') == 'right_rone'
|
||||
assert w.stats['matched'] == 1
|
||||
|
||||
|
||||
def _mb_service(owned_release_groups):
|
||||
from core.musicbrainz_service import MusicBrainzService
|
||||
s = object.__new__(MusicBrainzService)
|
||||
s._check_cache = lambda *a, **k: None
|
||||
s._save_to_cache = lambda *a, **k: None
|
||||
s._calculate_similarity = lambda a, b: 1.0
|
||||
s.mb_client = types.SimpleNamespace(
|
||||
search_artist=lambda name, limit=5: [
|
||||
{'id': 'wrong_mbid', 'name': 'Rone', 'score': 100}, # ranked first
|
||||
{'id': 'right_mbid', 'name': 'Rone', 'score': 90},
|
||||
],
|
||||
get_artist=lambda mbid, includes=None: owned_release_groups.get(mbid),
|
||||
)
|
||||
return s
|
||||
|
||||
|
||||
def test_musicbrainz_picks_artist_overlapping_owned_catalog():
|
||||
rg = {
|
||||
'wrong_mbid': {'release-groups': [{'title': 'Other Stuff'}]},
|
||||
'right_mbid': {'release-groups': [{'title': 'Tohu Bohu'}, {'title': 'Creatures'}]},
|
||||
}
|
||||
result = _mb_service(rg).match_artist('Rone', owned_titles=OWNED)
|
||||
assert result is not None
|
||||
assert result['mbid'] == 'right_mbid'
|
||||
|
||||
|
||||
def test_musicbrainz_without_owned_titles_keeps_legacy_behavior():
|
||||
"""No owned titles → highest-confidence (name-order first) candidate, unchanged."""
|
||||
rg = {'wrong_mbid': {'release-groups': []}, 'right_mbid': {'release-groups': []}}
|
||||
result = _mb_service(rg).match_artist('Rone') # no owned_titles
|
||||
assert result['mbid'] == 'wrong_mbid' # the top-confidence pick
|
||||
|
||||
|
||||
def test_musicbrainz_cache_hit_used_when_catalog_overlaps():
|
||||
"""A cached mbid whose catalog overlaps the owned albums is trusted (no re-resolve)."""
|
||||
rg = {'cached_mbid': {'release-groups': [{'title': 'Tohu Bohu'}, {'title': 'Creatures'}]}}
|
||||
s = _mb_service(rg)
|
||||
s._check_cache = lambda *a, **k: {'musicbrainz_id': 'cached_mbid', 'confidence': 95}
|
||||
s.mb_client.search_artist = lambda *a, **k: (_ for _ in ()).throw(
|
||||
AssertionError("must not re-search when the cached match fits the library"))
|
||||
result = s.match_artist('Rone', owned_titles=OWNED)
|
||||
assert result['mbid'] == 'cached_mbid'
|
||||
assert result['cached'] is True
|
||||
|
||||
|
||||
def test_musicbrainz_stale_cache_is_bypassed_and_re_resolved():
|
||||
"""A cached mbid with ZERO owned-catalog overlap (wrong same-name artist) is
|
||||
bypassed → fresh disambiguated resolve. This is what makes a re-match work for
|
||||
MB despite the 90-day cache TTL (#868)."""
|
||||
rg = {
|
||||
'cached_wrong': {'release-groups': [{'title': 'Unrelated'}]}, # the stale cache
|
||||
'wrong_mbid': {'release-groups': [{'title': 'Other Stuff'}]},
|
||||
'right_mbid': {'release-groups': [{'title': 'Tohu Bohu'}, {'title': 'Creatures'}]},
|
||||
}
|
||||
s = _mb_service(rg)
|
||||
s._check_cache = lambda *a, **k: {'musicbrainz_id': 'cached_wrong', 'confidence': 95}
|
||||
result = s.match_artist('Rone', owned_titles=OWNED)
|
||||
assert result['mbid'] == 'right_mbid' # re-resolved + disambiguated
|
||||
assert result.get('cached') is not True
|
||||
|
|
@ -82,3 +82,26 @@ def test_socket_allowed_when_pin_verified(monkeypatch):
|
|||
s['launch_pin_verified'] = True
|
||||
sio = web_server.socketio.test_client(web_server.app, flask_test_client=flask_client)
|
||||
assert sio.is_connected() is True
|
||||
|
||||
|
||||
# ── login mode (the "Sign in to SoulSync" path — #852 report was this one) ──
|
||||
def _login_on(monkeypatch):
|
||||
real = web_server.config_manager.get
|
||||
monkeypatch.setattr(web_server.config_manager, 'get',
|
||||
lambda k, d=None: True if k == 'security.require_login' else real(k, d))
|
||||
|
||||
|
||||
def test_socket_rejected_when_login_required_and_unauthenticated(monkeypatch):
|
||||
_login_on(monkeypatch)
|
||||
flask_client = web_server.app.test_client() # no login_authenticated
|
||||
sio = web_server.socketio.test_client(web_server.app, flask_test_client=flask_client)
|
||||
assert sio.is_connected() is False # login overlay can't be bypassed via WS
|
||||
|
||||
|
||||
def test_socket_allowed_when_login_authenticated(monkeypatch):
|
||||
_login_on(monkeypatch)
|
||||
flask_client = web_server.app.test_client()
|
||||
with flask_client.session_transaction() as s:
|
||||
s['login_authenticated'] = True
|
||||
sio = web_server.socketio.test_client(web_server.app, flask_test_client=flask_client)
|
||||
assert sio.is_connected() is True
|
||||
|
|
|
|||
94
tests/test_youtube_track_meta.py
Normal file
94
tests/test_youtube_track_meta.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""Seam tests for YouTube playlist artist/title derivation (GitHub #863).
|
||||
|
||||
Flat playlist extraction gives sparse entries; the parser used to take the
|
||||
artist straight from `uploader`, which on a playlist is the OWNER — so every
|
||||
track came out as "Wing It" / "Unknown Artist". `derive_artist_and_title` picks
|
||||
the best available signal instead. These pin the precedence + the
|
||||
"never use the playlist owner" guarantee.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.youtube_track_meta import derive_artist_and_title
|
||||
|
||||
|
||||
def test_music_artists_field_wins():
|
||||
artist, title = derive_artist_and_title(
|
||||
{'title': 'Forgiven', 'artists': ['Within Temptation'], 'uploader': 'Wing It'})
|
||||
assert artist == 'Within Temptation'
|
||||
assert title == 'Forgiven'
|
||||
|
||||
|
||||
def test_artist_field_used_when_no_artists_list():
|
||||
artist, title = derive_artist_and_title(
|
||||
{'title': 'Alive', 'artist': 'Empire of the Sun', 'uploader': 'Wing It'})
|
||||
assert artist == 'Empire of the Sun'
|
||||
assert title == 'Alive'
|
||||
|
||||
|
||||
def test_topic_channel_is_the_artist():
|
||||
artist, title = derive_artist_and_title(
|
||||
{'title': 'Revolte', 'uploader': 'Paul Kalkbrenner - Topic'})
|
||||
assert artist == 'Paul Kalkbrenner'
|
||||
assert title == 'Revolte'
|
||||
|
||||
|
||||
def test_artist_title_split_from_title():
|
||||
# The exact #863 log case — title carries "Artist - Track", uploader is the
|
||||
# playlist owner.
|
||||
artist, title = derive_artist_and_title(
|
||||
{'title': 'Paul Kalkbrenner - Revolte (Original Mix) [Bpitch]', 'uploader': 'Wing It'})
|
||||
assert artist == 'Paul Kalkbrenner'
|
||||
# Splits on the FIRST separator; the remainder keeps the qualifiers for the
|
||||
# title cleaner to strip downstream.
|
||||
assert title == 'Revolte (Original Mix) [Bpitch]'
|
||||
|
||||
|
||||
def test_no_signal_returns_empty_artist_not_playlist_owner():
|
||||
# The unrecoverable case: plain title, uploader is the owner. Must NOT label
|
||||
# the track with the owner's channel (#863).
|
||||
artist, title = derive_artist_and_title(
|
||||
{'title': 'Forgiven', 'uploader': 'Wing It'})
|
||||
assert artist == ''
|
||||
assert title == 'Forgiven'
|
||||
|
||||
|
||||
def test_hyphenated_name_without_spaces_not_split():
|
||||
# "Jean-Michel Jarre" has no spaced dash → not an Artist-Title split.
|
||||
artist, title = derive_artist_and_title({'title': 'Jean-Michel Jarre', 'uploader': 'Wing It'})
|
||||
assert artist == ''
|
||||
assert title == 'Jean-Michel Jarre'
|
||||
|
||||
|
||||
def test_en_dash_separator_splits():
|
||||
artist, title = derive_artist_and_title({'title': 'Koven – Worlds Apart'}) # en dash
|
||||
assert artist == 'Koven'
|
||||
assert title == 'Worlds Apart'
|
||||
|
||||
|
||||
def test_topic_beats_title_split_but_cleaner_handles_prefix():
|
||||
# Topic channel present AND title repeats "Artist - Title": topic wins for the
|
||||
# artist; the full title is returned for the downstream cleaner to de-prefix.
|
||||
artist, title = derive_artist_and_title(
|
||||
{'title': 'Paul Kalkbrenner - Revolte', 'uploader': 'Paul Kalkbrenner - Topic'})
|
||||
assert artist == 'Paul Kalkbrenner'
|
||||
assert title == 'Paul Kalkbrenner - Revolte'
|
||||
|
||||
|
||||
def test_missing_title_is_safe():
|
||||
artist, title = derive_artist_and_title({'uploader': 'Wing It'})
|
||||
assert artist == ''
|
||||
assert title == 'Unknown Track'
|
||||
|
||||
|
||||
def test_bad_input_is_safe():
|
||||
assert derive_artist_and_title(None) == ('', 'Unknown Track')
|
||||
assert derive_artist_and_title("not a dict") == ('', 'Unknown Track')
|
||||
|
||||
|
||||
def test_empty_artists_list_falls_through():
|
||||
# An empty/blank artists list must not win — fall through to the title split.
|
||||
artist, title = derive_artist_and_title(
|
||||
{'title': 'Koven - Worlds Apart', 'artists': ['', None]})
|
||||
assert artist == 'Koven'
|
||||
assert title == 'Worlds Apart'
|
||||
619
web_server.py
619
web_server.py
|
|
@ -40,7 +40,7 @@ logger = setup_logging(_log_level, _log_path)
|
|||
|
||||
# App version — single source of truth for backup metadata, system-info, update check, etc.
|
||||
# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release.
|
||||
_SOULSYNC_BASE_VERSION = "2.7.1"
|
||||
_SOULSYNC_BASE_VERSION = "2.7.2"
|
||||
|
||||
def _build_version_string():
|
||||
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
|
||||
|
|
@ -973,6 +973,10 @@ db_update_state = {
|
|||
"removed_artists": 0,
|
||||
"removed_albums": 0,
|
||||
"removed_tracks": 0,
|
||||
# Heartbeat epoch (seconds): bumped at start and on every progress/phase
|
||||
# callback. The stall watchdog (core.database_update_health) flips a job to
|
||||
# 'error' when this goes stale while status is still 'running' (#859).
|
||||
"last_progress_at": 0,
|
||||
}
|
||||
_db_update_automation_id = None # Set when automation triggers DB update, used by callbacks
|
||||
db_update_lock = threading.Lock()
|
||||
|
|
@ -986,21 +990,8 @@ def _set_db_update_automation_id(value):
|
|||
global _db_update_automation_id
|
||||
_db_update_automation_id = value
|
||||
|
||||
# Quality Scanner state
|
||||
quality_scanner_state = {
|
||||
"status": "idle", # idle, running, finished, error
|
||||
"phase": "Ready to scan",
|
||||
"progress": 0,
|
||||
"processed": 0,
|
||||
"total": 0,
|
||||
"quality_met": 0,
|
||||
"low_quality": 0,
|
||||
"matched": 0,
|
||||
"error_message": "",
|
||||
"results": [], # List of low quality tracks with match status
|
||||
}
|
||||
quality_scanner_lock = threading.Lock()
|
||||
quality_scanner_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="QualityScanner")
|
||||
# Quality scanning is now the 'quality_upgrade' library-maintenance repair job
|
||||
# (core/repair_jobs/quality_upgrade.py) — no standalone state/executor here.
|
||||
|
||||
# Duplicate Cleaner state
|
||||
duplicate_cleaner_state = {
|
||||
|
|
@ -1249,10 +1240,7 @@ def _register_automation_handlers():
|
|||
duplicate_cleaner_lock=duplicate_cleaner_lock,
|
||||
duplicate_cleaner_executor=duplicate_cleaner_executor,
|
||||
run_duplicate_cleaner=_run_duplicate_cleaner,
|
||||
get_quality_scanner_state=lambda: quality_scanner_state,
|
||||
quality_scanner_lock=quality_scanner_lock,
|
||||
quality_scanner_executor=quality_scanner_executor,
|
||||
run_quality_scanner=_run_quality_scanner,
|
||||
run_repair_job_now=lambda job_id: repair_worker.run_job_now(job_id) if repair_worker else None,
|
||||
download_orchestrator=download_orchestrator,
|
||||
run_async=run_async,
|
||||
tasks_lock=tasks_lock,
|
||||
|
|
@ -1855,7 +1843,6 @@ def _shutdown_runtime_components():
|
|||
for executor, name in [
|
||||
(stream_executor, "stream executor"),
|
||||
(db_update_executor, "db update executor"),
|
||||
(quality_scanner_executor, "quality scanner executor"),
|
||||
(duplicate_cleaner_executor, "duplicate cleaner executor"),
|
||||
(sync_executor, "sync executor"),
|
||||
(missing_download_executor, "missing download executor"),
|
||||
|
|
@ -2695,6 +2682,15 @@ def generate_playlist_m3u():
|
|||
save_to_disk = data.get('save_to_disk', False)
|
||||
force = data.get('force', False)
|
||||
|
||||
# The download modal auto-saves an M3U (save_to_disk, no force) on every
|
||||
# render. When M3U export is disabled it would do nothing anyway — but only
|
||||
# AFTER ~30s of per-track DB search + fuzzy matching below, which it then
|
||||
# throws away (and which, fired repeatedly, jams the analysis). Bail out
|
||||
# immediately for that case. The manual "Export as M3U" sends force=True and
|
||||
# is unaffected; a content-only request (save_to_disk False) also proceeds.
|
||||
if save_to_disk and not force and not config_manager.get('m3u_export.enabled', False):
|
||||
return jsonify({"success": True, "skipped": True, "reason": "m3u_export disabled"})
|
||||
|
||||
raw_base = config_manager.get('m3u_export.entry_base_path', '') or ''
|
||||
entry_base_path = raw_base.rstrip('/\\')
|
||||
|
||||
|
|
@ -2723,43 +2719,41 @@ def generate_playlist_m3u():
|
|||
s = _re.sub(r'\s*remaster(ed)?.*', '', s)
|
||||
return _re.sub(r'\s+', ' ', s).strip()
|
||||
|
||||
# Group tracks by primary artist to minimise DB queries
|
||||
# Resolve each track's library file path. We bulk-load the library ONCE and
|
||||
# match in memory, keyed by cleaned artist, instead of issuing a
|
||||
# search_tracks() query per distinct artist. The per-artist loop could
|
||||
# block for a long time behind the enrichment/scan writers (SQLite lock
|
||||
# contention) — which is exactly why "Export M3U" hung with nothing in the
|
||||
# logs. One WAL-concurrent read can't be starved that way.
|
||||
from collections import defaultdict
|
||||
artist_groups = defaultdict(list)
|
||||
for idx, t in enumerate(tracks):
|
||||
artist_groups[t.get('artist', '') or ''].append((idx, t))
|
||||
lib_by_artist = defaultdict(list)
|
||||
for row in db.get_tracks_for_m3u_resolution(server_source=active_server):
|
||||
lib_by_artist[_clean(row['artist'])].append(
|
||||
(_norm(row['title']), _clean(row['title']), row['file_path'])
|
||||
)
|
||||
|
||||
file_path_map = {}
|
||||
for artist, group in artist_groups.items():
|
||||
if not artist:
|
||||
for idx, _ in group:
|
||||
file_path_map[idx] = None
|
||||
for idx, track in enumerate(tracks):
|
||||
name = track.get('name', '') or ''
|
||||
artist = track.get('artist', '') or ''
|
||||
if not name or not artist:
|
||||
file_path_map[idx] = None
|
||||
continue
|
||||
|
||||
db_tracks = db.search_tracks(artist=artist, limit=500, server_source=active_server)
|
||||
if not db_tracks:
|
||||
for idx, _ in group:
|
||||
file_path_map[idx] = None
|
||||
candidates = lib_by_artist.get(_clean(artist))
|
||||
if not candidates:
|
||||
file_path_map[idx] = None
|
||||
continue
|
||||
|
||||
db_entries = [(_norm(t.title), _clean(t.title), t) for t in db_tracks]
|
||||
|
||||
for idx, track in group:
|
||||
name = track.get('name', '')
|
||||
if not name:
|
||||
file_path_map[idx] = None
|
||||
continue
|
||||
s_norm, s_clean = _norm(name), _clean(name)
|
||||
matched = None
|
||||
for db_n, db_c, db_t in db_entries:
|
||||
if s_norm == db_n or s_clean == db_c:
|
||||
matched = db_t
|
||||
break
|
||||
if max(SequenceMatcher(None, s_norm, db_n).ratio(),
|
||||
SequenceMatcher(None, s_clean, db_c).ratio()) >= 0.7:
|
||||
matched = db_t
|
||||
break
|
||||
file_path_map[idx] = matched.file_path if matched else None
|
||||
s_norm, s_clean = _norm(name), _clean(name)
|
||||
matched_path = None
|
||||
for db_n, db_c, fp in candidates:
|
||||
if s_norm == db_n or s_clean == db_c:
|
||||
matched_path = fp
|
||||
break
|
||||
if max(SequenceMatcher(None, s_norm, db_n).ratio(),
|
||||
SequenceMatcher(None, s_clean, db_c).ratio()) >= 0.7:
|
||||
matched_path = fp
|
||||
break
|
||||
file_path_map[idx] = matched_path
|
||||
|
||||
# --- build M3U content ---
|
||||
import datetime as _dt
|
||||
|
|
@ -3120,11 +3114,22 @@ def handle_settings():
|
|||
if not get_database().profile_has_password(1):
|
||||
return jsonify({"success": False,
|
||||
"error": "Set an admin password before enabling login mode."}), 400
|
||||
# No-gaps: every member must have a password too, or they'd be
|
||||
# locked out the moment login turns on.
|
||||
from core.security.login_provisioning import members_without_password
|
||||
_stranded = members_without_password(get_database().get_all_profiles())
|
||||
if _stranded:
|
||||
_names = ', '.join(str(m.get('name') or '?') for m in _stranded)
|
||||
return jsonify({"success": False,
|
||||
"error": f"These members have no login password and "
|
||||
f"couldn't sign in: {_names}. Set their passwords "
|
||||
f"in Manage Profiles first.",
|
||||
"members_without_password": _stranded}), 400
|
||||
|
||||
if 'active_media_server' in new_settings:
|
||||
config_manager.set_active_media_server(new_settings['active_media_server'])
|
||||
|
||||
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'deezer_download', 'amazon_download', 'lidarr_download', 'prowlarr', 'torrent_client', 'usenet_client', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase', 'security', 'discogs', 'library', 'discover', 'wishlist', 'genre_whitelist', 'post_processing']:
|
||||
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'deezer_download', 'amazon_download', 'lidarr_download', 'prowlarr', 'torrent_client', 'usenet_client', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase', 'security', 'discogs', 'library', 'discover', 'wishlist', 'genre_whitelist', 'post_processing', 'playlists']:
|
||||
if service in new_settings:
|
||||
for key, value in new_settings[service].items():
|
||||
config_manager.set(f'{service}.{key}', value)
|
||||
|
|
@ -3807,6 +3812,30 @@ def get_mirrored_playlists_list():
|
|||
except Exception as e:
|
||||
return jsonify({"playlists": [], "spotify_authenticated": False}), 200
|
||||
|
||||
@app.route('/api/playlists/materialize/rebuild', methods=['POST'])
|
||||
def rebuild_playlist_materialization_endpoint():
|
||||
"""(Re)build every "organize by playlist" folder from current library ownership
|
||||
(the manual Settings button). Safe to call any time — it's a derived view and
|
||||
only links tracks the user actually owns."""
|
||||
try:
|
||||
from core.playlists.materialize_service import rebuild_organized_playlists_from_db
|
||||
database = get_database()
|
||||
profile_id = get_current_profile_id()
|
||||
results = rebuild_organized_playlists_from_db(database, config_manager, profile_id=profile_id)
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'count': len(results),
|
||||
'results': [{
|
||||
'playlist': name, 'folder': s.playlist_dir,
|
||||
'linked': s.linked, 'copied': s.copied, 'unchanged': s.unchanged,
|
||||
'removed_stale': s.removed_stale, 'missing_source': s.missing_source,
|
||||
'failed': s.failed, 'fellback': s.fellback,
|
||||
} for name, s in results],
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"[Playlist Materialize] Rebuild endpoint failed: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/setup/status', methods=['GET'])
|
||||
def setup_status_endpoint():
|
||||
"""Check if first-run setup has been completed."""
|
||||
|
|
@ -7319,10 +7348,22 @@ def manual_search_for_task(task_id):
|
|||
# source isn't connected or the link can't be resolved — so the user is
|
||||
# never worse off than typing the query themselves.
|
||||
from core.downloads.track_link import parse_download_track_link
|
||||
from core.soundcloud_client import is_soundcloud_url
|
||||
link = parse_download_track_link(query)
|
||||
link_source = None
|
||||
link_track_id = None
|
||||
if link:
|
||||
# A pasted SoundCloud link can't be turned into an "artist title" query
|
||||
# and searched — unlisted/private tracks aren't searchable. Instead force
|
||||
# the SoundCloud source and keep the URL as the query; the SoundCloud
|
||||
# client resolves the link directly (#865).
|
||||
if is_soundcloud_url(query):
|
||||
if 'soundcloud' not in valid_source_ids:
|
||||
return jsonify({
|
||||
"error": "SoundCloud isn't connected — enable it in Settings to "
|
||||
"resolve a SoundCloud link."
|
||||
}), 400
|
||||
source = 'soundcloud'
|
||||
elif link:
|
||||
_src, _tid = link
|
||||
# A parsed link is unambiguously a Tidal/Qobuz track URL, never a
|
||||
# name a user would type — so if we can't use it, say why clearly
|
||||
|
|
@ -8055,7 +8096,8 @@ def request_incremental_database_update():
|
|||
with db_update_lock:
|
||||
db_update_state.update({
|
||||
"status": "running", "phase": "Initializing...",
|
||||
"progress": 0, "current_item": "", "processed": 0, "total": 0, "error_message": ""
|
||||
"progress": 0, "current_item": "", "processed": 0, "total": 0, "error_message": "",
|
||||
"last_progress_at": time.time(), # seed heartbeat for the stall watchdog
|
||||
})
|
||||
db_update_executor.submit(_run_db_update_task, False, active_server)
|
||||
|
||||
|
|
@ -9316,6 +9358,59 @@ def get_album_tracks(album_id):
|
|||
logger.exception("Error fetching album tracks for album %s", album_id)
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/artist/<artist_id>/record', methods=['GET'])
|
||||
def get_artist_db_record(artist_id):
|
||||
"""Return the COMPLETE database record for a library artist — every column of
|
||||
the ``artists`` row (all source IDs + match statuses, cached bios / tags /
|
||||
similar / urls, timestamps, soul_id, etc.) plus owned album/track counts.
|
||||
|
||||
Powers the artist-detail "DB Record" inspector. JSON-encoded text columns
|
||||
(genres, aliases, lastfm_tags/similar, discogs_urls, …) are decoded into real
|
||||
arrays/objects so the dump is clean rather than escaped strings.
|
||||
"""
|
||||
try:
|
||||
database = get_database()
|
||||
conn = database._get_connection()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT * FROM artists WHERE id = ?", (str(artist_id),))
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return jsonify({"success": False, "error": "Artist not found in library"}), 404
|
||||
|
||||
record = {}
|
||||
for key in row.keys():
|
||||
val = row[key]
|
||||
if isinstance(val, str):
|
||||
s = val.strip()
|
||||
if s and s[0] in '[{':
|
||||
try:
|
||||
val = json.loads(s)
|
||||
except Exception: # noqa: S110 — leave non-JSON text as-is
|
||||
pass
|
||||
record[key] = val
|
||||
|
||||
counts = {}
|
||||
for label, table in (('albums', 'albums'), ('tracks', 'tracks')):
|
||||
try:
|
||||
cur.execute(f"SELECT COUNT(*) FROM {table} WHERE artist_id = ?", (str(artist_id),))
|
||||
counts[label] = cur.fetchone()[0]
|
||||
except Exception:
|
||||
counts[label] = None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"artist_id": str(artist_id),
|
||||
"counts": counts,
|
||||
"record": record,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Artist DB record fetch failed for {artist_id}: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/artist/<artist_id>/download-discography', methods=['POST'])
|
||||
def download_discography(artist_id):
|
||||
"""Add selected albums from an artist's discography to the wishlist.
|
||||
|
|
@ -14634,12 +14729,63 @@ def clean_youtube_artist(artist_string):
|
|||
|
||||
return artist_string
|
||||
|
||||
def _youtube_cookie_opts():
|
||||
"""yt-dlp cookie options matching the rest of the app (Settings → YouTube).
|
||||
Per-video extraction needs these to get past YouTube's bot checks."""
|
||||
opts = {}
|
||||
try:
|
||||
cb = config_manager.get('youtube.cookies_browser', '')
|
||||
if cb:
|
||||
opts['cookiesfrombrowser'] = (cb,)
|
||||
except Exception: # noqa: S110 - cookie config is best-effort; resolve still works without it
|
||||
pass
|
||||
return opts
|
||||
|
||||
|
||||
def _fetch_youtube_video_artist(video_id, cookie_opts):
|
||||
"""Recover a track's artist from its OWN video page (uploader/channel), which
|
||||
flat playlist extraction no longer returns on current yt-dlp (#863).
|
||||
|
||||
Returns a raw artist string or ''. Per-VIDEO uploader/channel is the track's
|
||||
channel (the artist / "<Artist> - Topic"), NOT the playlist owner — so unlike
|
||||
a flat playlist entry it's safe to trust here. ``ignoreerrors`` keeps an
|
||||
age-gated / unavailable video from raising."""
|
||||
from core.youtube_track_meta import derive_artist_and_title
|
||||
opts = {'quiet': True, 'no_warnings': True, 'skip_download': True, 'ignoreerrors': True}
|
||||
opts.update(cookie_opts or {})
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||
info = ydl.extract_info(f"https://www.youtube.com/watch?v={video_id}", download=False)
|
||||
except Exception as e:
|
||||
logger.debug(f"[YT Parse] per-video meta fetch failed for {video_id}: {e}")
|
||||
return ''
|
||||
if not info:
|
||||
return ''
|
||||
# Reuse the precedence (music fields / Topic / title split); then fall back to
|
||||
# the per-video uploader/channel, which here IS the artist.
|
||||
artist, _title = derive_artist_and_title(info)
|
||||
if artist:
|
||||
return artist
|
||||
return (info.get('uploader') or info.get('channel') or '').strip()
|
||||
|
||||
|
||||
def _recover_youtube_artist_cleaned(video_id):
|
||||
"""Fetch + clean a YouTube track's artist from its video page (#863).
|
||||
Used by the async discovery worker for tracks flat extraction left Unknown."""
|
||||
raw = _fetch_youtube_video_artist(video_id, _youtube_cookie_opts())
|
||||
if not raw:
|
||||
return ''
|
||||
cleaned = clean_youtube_artist(raw)
|
||||
return cleaned if cleaned and cleaned != 'Unknown Artist' else ''
|
||||
|
||||
|
||||
def parse_youtube_playlist(url):
|
||||
"""
|
||||
Parse a YouTube Music playlist URL and extract track information using yt-dlp
|
||||
Uses flat playlist extraction to avoid rate limits and get all tracks
|
||||
Returns a list of track dictionaries compatible with our Track structure
|
||||
"""
|
||||
from core.youtube_track_meta import derive_artist_and_title
|
||||
try:
|
||||
# Configure yt-dlp options for flat playlist extraction (avoids rate limits)
|
||||
ydl_opts = {
|
||||
|
|
@ -14649,6 +14795,7 @@ def parse_youtube_playlist(url):
|
|||
'skip_download': True, # Don't download, just extract IDs and basic info
|
||||
'lazy_playlist': False, # Force full playlist resolution (prevents ~100 entry cap)
|
||||
}
|
||||
ydl_opts.update(_youtube_cookie_opts())
|
||||
|
||||
tracks = []
|
||||
|
||||
|
|
@ -14672,14 +14819,25 @@ def parse_youtube_playlist(url):
|
|||
|
||||
# Extract basic information from flat extraction
|
||||
raw_title = entry.get('title', 'Unknown Track')
|
||||
raw_uploader = entry.get('uploader', 'Unknown Artist')
|
||||
raw_uploader = entry.get('uploader') or entry.get('channel') or ''
|
||||
duration = entry.get('duration', 0)
|
||||
video_id = entry.get('id', '')
|
||||
|
||||
# Clean the track title and artist using our cleaning functions
|
||||
cleaned_artist = clean_youtube_artist(raw_uploader)
|
||||
cleaned_title = clean_youtube_track_title(raw_title, cleaned_artist)
|
||||
|
||||
|
||||
# Derive the artist from the best available signal — music
|
||||
# fields, a "- Topic" channel, or an "Artist - Title" split —
|
||||
# instead of blindly using `uploader`, which on a playlist is the
|
||||
# OWNER, not the track artist (#863: every track became "Wing It"
|
||||
# / "Unknown Artist"). Returns ('' , title) when nothing reliable.
|
||||
derived_artist, derived_title = derive_artist_and_title(entry)
|
||||
|
||||
# Clean the track title and artist using our cleaning functions.
|
||||
if derived_artist:
|
||||
cleaned_artist = clean_youtube_artist(derived_artist)
|
||||
cleaned_title = clean_youtube_track_title(derived_title, cleaned_artist)
|
||||
else:
|
||||
cleaned_artist = 'Unknown Artist'
|
||||
cleaned_title = clean_youtube_track_title(derived_title, None)
|
||||
|
||||
# Create track object matching GUI structure
|
||||
track_data = {
|
||||
'id': video_id,
|
||||
|
|
@ -14687,12 +14845,20 @@ def parse_youtube_playlist(url):
|
|||
'artists': [cleaned_artist],
|
||||
'duration_ms': duration * 1000 if duration else 0,
|
||||
'raw_title': raw_title, # Keep original for reference
|
||||
'raw_artist': raw_uploader, # Keep original for reference
|
||||
'raw_artist': derived_artist or raw_uploader, # Keep original for reference
|
||||
'url': f"https://www.youtube.com/watch?v={video_id}"
|
||||
}
|
||||
|
||||
tracks.append(track_data)
|
||||
|
||||
|
||||
# NOTE: current yt-dlp flat extraction returns ONLY the title per entry
|
||||
# (no uploader/artist), so tracks whose title isn't "Artist - Title"
|
||||
# land here as "Unknown Artist". The per-video artist recovery does NOT
|
||||
# run here — it would block this request for minutes on a big playlist
|
||||
# and risk the 120s worker timeout. It runs in the async discovery
|
||||
# worker instead (which already iterates every track with a progress
|
||||
# bar). See run_youtube_discovery_worker / recover_youtube_artist (#863).
|
||||
|
||||
# Create playlist object matching GUI structure
|
||||
playlist_data = {
|
||||
'id': playlist_id,
|
||||
|
|
@ -14703,7 +14869,7 @@ def parse_youtube_playlist(url):
|
|||
'source': 'youtube',
|
||||
'image_url': playlist_info.get('thumbnail', '') or '',
|
||||
}
|
||||
|
||||
|
||||
logger.info(f"Successfully parsed YouTube playlist: {len(tracks)} tracks extracted")
|
||||
return playlist_data
|
||||
|
||||
|
|
@ -15626,7 +15792,8 @@ def _db_update_progress_callback(current_item, processed, total, percentage):
|
|||
"current_item": current_item,
|
||||
"processed": processed,
|
||||
"total": total,
|
||||
"progress": percentage
|
||||
"progress": percentage,
|
||||
"last_progress_at": time.time(), # heartbeat for the stall watchdog
|
||||
})
|
||||
_update_automation_progress(_db_update_automation_id,
|
||||
progress=percentage, processed=processed, total=total,
|
||||
|
|
@ -15636,6 +15803,7 @@ def _db_update_phase_callback(phase):
|
|||
logger.info(f"[DB Phase] {phase}")
|
||||
with db_update_lock:
|
||||
db_update_state["phase"] = phase
|
||||
db_update_state["last_progress_at"] = time.time() # heartbeat for the stall watchdog
|
||||
_update_automation_progress(_db_update_automation_id, phase=phase)
|
||||
|
||||
def _db_update_artist_callback(artist_name, success, details, album_count, track_count):
|
||||
|
|
@ -15756,6 +15924,44 @@ def _db_update_error_callback(error_message):
|
|||
# Add activity for database update error
|
||||
add_activity_item("", "Database Update Failed", error_message, "Now")
|
||||
|
||||
|
||||
def _check_db_update_stall():
|
||||
"""Watchdog: flip a hung 'running' DB-update job to 'error' so the UI can
|
||||
recover (#859). A worker that blocks indefinitely (media-server call with no
|
||||
timeout, DB lock) never fires its finished/error callback, so the job would
|
||||
otherwise sit at 'running' forever with a frozen progress bar.
|
||||
|
||||
Idempotent — only acts on the running→stalled transition (after the flip,
|
||||
status != 'running' so the pure check returns False and we don't re-fire).
|
||||
Safe to call from the status endpoint and the 1s broadcast loop. Returns True
|
||||
only on the transition."""
|
||||
from core.database_update_health import (
|
||||
DEFAULT_STALL_TIMEOUT_SECONDS,
|
||||
is_db_update_stalled,
|
||||
stalled_error_message,
|
||||
)
|
||||
try:
|
||||
timeout = config_manager.get('database.update_stall_timeout_seconds',
|
||||
DEFAULT_STALL_TIMEOUT_SECONDS)
|
||||
except Exception:
|
||||
timeout = DEFAULT_STALL_TIMEOUT_SECONDS
|
||||
now = time.time()
|
||||
with db_update_lock:
|
||||
if not is_db_update_stalled(db_update_state, now, timeout):
|
||||
return False
|
||||
msg = stalled_error_message(db_update_state, now)
|
||||
db_update_state["status"] = "error"
|
||||
db_update_state["error_message"] = msg
|
||||
db_update_state["phase"] = "Stalled"
|
||||
logger.error(f"[DB Update Watchdog] {msg}")
|
||||
# The hung worker paused enrichment/maintenance workers and won't resume them
|
||||
# itself — resume here so a stall doesn't leave them parked indefinitely.
|
||||
try:
|
||||
_resume_workers_after_scan()
|
||||
except Exception as e:
|
||||
logger.debug(f"[DB Update Watchdog] resume workers failed: {e}")
|
||||
return True
|
||||
|
||||
_workers_paused_by_scan = set() # Track which workers WE paused (don't resume manually-paused ones)
|
||||
|
||||
def _pause_workers_for_scan():
|
||||
|
|
@ -16622,7 +16828,10 @@ def start_database_update():
|
|||
db_update_state.update({
|
||||
"status": "running",
|
||||
"phase": f"{scan_type}: Initializing...",
|
||||
"progress": 0, "current_item": "", "processed": 0, "total": 0, "error_message": ""
|
||||
"progress": 0, "current_item": "", "processed": 0, "total": 0, "error_message": "",
|
||||
# Seed the heartbeat now so a worker that hangs during init (before the
|
||||
# first progress/phase callback) is still caught by the stall watchdog.
|
||||
"last_progress_at": time.time(),
|
||||
})
|
||||
|
||||
# Add activity for database update start
|
||||
|
|
@ -16640,6 +16849,7 @@ def start_database_update():
|
|||
@app.route('/api/database/update/status', methods=['GET'])
|
||||
def get_database_update_status():
|
||||
"""Endpoint to poll for the current update status."""
|
||||
_check_db_update_stall() # self-heal a hung job before reporting (#859)
|
||||
with db_update_lock:
|
||||
# Debug: Log current state occasionally
|
||||
if db_update_state["status"] == "running":
|
||||
|
|
@ -17412,28 +17622,7 @@ def _get_quality_tier_from_extension(file_path):
|
|||
|
||||
return ('unknown', 999)
|
||||
|
||||
# Quality scanner worker logic lives in core/discovery/quality_scanner.py.
|
||||
from core.discovery import quality_scanner as _discovery_quality_scanner
|
||||
|
||||
|
||||
def _build_quality_scanner_deps():
|
||||
"""Build the QualityScannerDeps bundle from web_server.py globals on each call."""
|
||||
return _discovery_quality_scanner.QualityScannerDeps(
|
||||
quality_scanner_state=quality_scanner_state,
|
||||
quality_scanner_lock=quality_scanner_lock,
|
||||
QUALITY_TIERS=QUALITY_TIERS,
|
||||
matching_engine=matching_engine,
|
||||
automation_engine=automation_engine,
|
||||
get_quality_tier_from_extension=_get_quality_tier_from_extension,
|
||||
add_activity_item=add_activity_item,
|
||||
)
|
||||
|
||||
|
||||
def _run_quality_scanner(scope='watchlist', profile_id=1):
|
||||
return _discovery_quality_scanner.run_quality_scanner(
|
||||
scope, profile_id, _build_quality_scanner_deps()
|
||||
)
|
||||
|
||||
# (Quality scanning moved to the 'quality_upgrade' library-maintenance repair job.)
|
||||
|
||||
from core.library.duplicate_cleaner import (
|
||||
_run_duplicate_cleaner,
|
||||
|
|
@ -17446,55 +17635,6 @@ _init_duplicate_cleaner(
|
|||
engine=automation_engine,
|
||||
)
|
||||
|
||||
@app.route('/api/quality-scanner/start', methods=['POST'])
|
||||
def start_quality_scan():
|
||||
"""Start the quality scanner"""
|
||||
with quality_scanner_lock:
|
||||
if quality_scanner_state["status"] == "running":
|
||||
return jsonify({"success": False, "error": "A scan is already in progress"}), 409
|
||||
|
||||
data = request.get_json() or {}
|
||||
scope = data.get('scope', 'watchlist') # 'watchlist' or 'all'
|
||||
|
||||
logger.info(f"[Quality Scanner API] Starting scan with scope: {scope}")
|
||||
|
||||
# Reset state
|
||||
quality_scanner_state["status"] = "running"
|
||||
quality_scanner_state["phase"] = "Initializing..."
|
||||
quality_scanner_state["progress"] = 0
|
||||
quality_scanner_state["processed"] = 0
|
||||
quality_scanner_state["total"] = 0
|
||||
quality_scanner_state["quality_met"] = 0
|
||||
quality_scanner_state["low_quality"] = 0
|
||||
quality_scanner_state["matched"] = 0
|
||||
quality_scanner_state["results"] = []
|
||||
quality_scanner_state["error_message"] = ""
|
||||
|
||||
# Submit worker (capture profile_id before thread)
|
||||
scan_profile_id = get_current_profile_id()
|
||||
quality_scanner_executor.submit(_run_quality_scanner, scope, scan_profile_id)
|
||||
|
||||
add_activity_item("", "Quality Scan Started", f"Scanning {scope} tracks", "Now")
|
||||
|
||||
return jsonify({"success": True, "message": "Quality scan started"})
|
||||
|
||||
@app.route('/api/quality-scanner/status', methods=['GET'])
|
||||
def get_quality_scanner_status():
|
||||
"""Get current quality scanner status"""
|
||||
with quality_scanner_lock:
|
||||
return jsonify(quality_scanner_state)
|
||||
|
||||
@app.route('/api/quality-scanner/stop', methods=['POST'])
|
||||
def stop_quality_scan():
|
||||
"""Stop the quality scanner (sets a stop flag)"""
|
||||
with quality_scanner_lock:
|
||||
if quality_scanner_state["status"] == "running":
|
||||
quality_scanner_state["status"] = "finished"
|
||||
quality_scanner_state["phase"] = "Scan stopped by user"
|
||||
return jsonify({"success": True, "message": "Stop request sent"})
|
||||
else:
|
||||
return jsonify({"success": False, "error": "No scan is currently running"}), 404
|
||||
|
||||
@app.route('/api/duplicate-cleaner/start', methods=['POST'])
|
||||
def start_duplicate_cleaner():
|
||||
"""Start the duplicate cleaner"""
|
||||
|
|
@ -21384,6 +21524,35 @@ def hifi_reorder_instances():
|
|||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/hifi/instances/reset', methods=['POST'])
|
||||
@admin_only
|
||||
def hifi_reset_instances():
|
||||
"""Restore any built-in default HiFi instances that were removed.
|
||||
|
||||
Non-destructive (#Sokhi): keeps user-added instances and the existing
|
||||
order/enabled state, and only re-adds the provided defaults that are
|
||||
currently missing — so you can recover ones you removed by accident without
|
||||
wiping the working instance you just found."""
|
||||
try:
|
||||
from database.music_database import get_database
|
||||
from core.hifi_client import DEFAULT_INSTANCES
|
||||
db = get_database()
|
||||
existing = {(i.get('url') or '').rstrip('/') for i in db.get_all_hifi_instances()}
|
||||
priority = len(existing)
|
||||
restored = []
|
||||
for url in DEFAULT_INSTANCES:
|
||||
u = url.rstrip('/')
|
||||
if u not in existing and db.add_hifi_instance(u, priority):
|
||||
restored.append(u)
|
||||
priority += 1
|
||||
if download_orchestrator:
|
||||
download_orchestrator.reload_instances('hifi')
|
||||
return jsonify({'success': True, 'restored': len(restored), 'urls': restored})
|
||||
except Exception as e:
|
||||
logger.error(f"Error resetting HiFi instances: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/hifi/instances/list', methods=['GET'])
|
||||
@admin_only
|
||||
def hifi_list_instances():
|
||||
|
|
@ -24499,6 +24668,7 @@ def _build_youtube_discovery_deps():
|
|||
build_discovery_wing_it_stub=_build_discovery_wing_it_stub,
|
||||
get_database=get_database,
|
||||
add_activity_item=add_activity_item,
|
||||
recover_youtube_artist=_recover_youtube_artist_cleaned,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -25509,6 +25679,15 @@ def create_profile():
|
|||
from werkzeug.security import generate_password_hash
|
||||
pin_hash = generate_password_hash(pin, method='pbkdf2:sha256')
|
||||
|
||||
# No-gaps: while login mode is on, a new member must be born with a login
|
||||
# password or they could never sign in.
|
||||
password = (data.get('password') or '').strip()
|
||||
from core.security.login_provisioning import create_needs_password
|
||||
if create_needs_password(_require_login_enabled()) and not password:
|
||||
return jsonify({'success': False,
|
||||
'error': 'Login mode is on — give this profile a login '
|
||||
'password so they can sign in.'}), 400
|
||||
|
||||
# Profile settings: home_page, allowed_pages, can_download
|
||||
home_page = data.get('home_page') or None
|
||||
allowed_pages = data.get('allowed_pages') # list or None
|
||||
|
|
@ -25533,6 +25712,9 @@ def create_profile():
|
|||
if profile_id is None:
|
||||
return jsonify({'success': False, 'error': 'Profile name already exists'}), 409
|
||||
|
||||
if password:
|
||||
database.set_profile_password(profile_id, password)
|
||||
|
||||
return jsonify({'success': True, 'profile_id': profile_id})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
|
@ -25947,6 +26129,13 @@ def set_profile_password_endpoint(profile_id):
|
|||
return jsonify({'success': False, 'error': 'Unauthorized'}), 403
|
||||
data = request.json or {}
|
||||
password = data.get('password', '')
|
||||
# No-gaps: clearing a password while login mode is on would lock that
|
||||
# profile out — refuse it (delete the profile instead if that's intended).
|
||||
from core.security.login_provisioning import removing_password_strands
|
||||
if not (password or '').strip() and removing_password_strands(_require_login_enabled()):
|
||||
return jsonify({'success': False,
|
||||
'error': "Can't remove this password while login mode is on — "
|
||||
"that profile couldn't sign in."}), 400
|
||||
ok = database.set_profile_password(profile_id, password)
|
||||
return jsonify({'success': bool(ok), 'has_password': database.profile_has_password(profile_id)})
|
||||
except Exception as e:
|
||||
|
|
@ -26639,6 +26828,101 @@ def get_watchlist_artists():
|
|||
logger.error(f"Error getting watchlist artists: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/watchlist/export', methods=['GET'])
|
||||
def export_watchlist():
|
||||
"""Export the watchlist roster (name + source IDs, optionally external links)
|
||||
as json / csv / txt. Returns the content for the export modal to preview +
|
||||
download (X-Export-Count / X-Export-Ext headers carry the metadata)."""
|
||||
try:
|
||||
from core.exports.artist_export import build_artist_export, export_mime_and_ext
|
||||
fmt = (request.args.get('format', 'json') or 'json').lower()
|
||||
include_links = request.args.get('links', '') in ('1', 'true', 'yes')
|
||||
database = get_database()
|
||||
artists = [{
|
||||
'artist_name': a.artist_name,
|
||||
'spotify_artist_id': a.spotify_artist_id,
|
||||
'itunes_artist_id': a.itunes_artist_id,
|
||||
'deezer_artist_id': getattr(a, 'deezer_artist_id', None),
|
||||
'discogs_artist_id': getattr(a, 'discogs_artist_id', None),
|
||||
'musicbrainz_artist_id': getattr(a, 'musicbrainz_artist_id', None),
|
||||
'amazon_artist_id': getattr(a, 'amazon_artist_id', None),
|
||||
} for a in database.get_watchlist_artists(profile_id=get_current_profile_id())]
|
||||
content = build_artist_export(artists, fmt=fmt, include_links=include_links)
|
||||
mime, ext = export_mime_and_ext(fmt)
|
||||
return Response(content, mimetype=mime,
|
||||
headers={'X-Export-Count': str(len(artists)), 'X-Export-Ext': ext})
|
||||
except Exception as e:
|
||||
logger.error(f"Watchlist export failed: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/library/artists/export', methods=['GET'])
|
||||
def export_library_artists():
|
||||
"""Export the WHOLE library artist roster (name + every source id/url we have,
|
||||
optional external links, optional owned album/track counts) as json/csv/txt."""
|
||||
try:
|
||||
from core.exports.artist_export import build_artist_export, export_mime_and_ext
|
||||
fmt = (request.args.get('format', 'json') or 'json').lower()
|
||||
include_links = request.args.get('links', '') in ('1', 'true', 'yes')
|
||||
include_contents = request.args.get('contents', '') in ('1', 'true', 'yes')
|
||||
database = get_database()
|
||||
conn = database._get_connection()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, name, spotify_artist_id, musicbrainz_id, deezer_id,
|
||||
discogs_id, itunes_artist_id, tidal_id, qobuz_id, amazon_id,
|
||||
lastfm_url, genius_url, soul_id
|
||||
FROM artists ORDER BY name COLLATE NOCASE
|
||||
""")
|
||||
cols = [d[0] for d in cur.description]
|
||||
rows = [dict(zip(cols, r, strict=False)) for r in cur.fetchall()]
|
||||
|
||||
counts = {}
|
||||
if include_contents:
|
||||
for table, key in (('albums', 'album_count'), ('tracks', 'track_count')):
|
||||
try:
|
||||
for aid, n in cur.execute(
|
||||
f"SELECT artist_id, COUNT(*) FROM {table} GROUP BY artist_id"):
|
||||
counts.setdefault(str(aid), {})[key] = n
|
||||
except Exception: # noqa: S110 — counts are best-effort
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# Normalize onto the canonical *_artist_id keys the exporter expects.
|
||||
artists = []
|
||||
for r in rows:
|
||||
c = counts.get(str(r['id']), {})
|
||||
artists.append({
|
||||
'name': r['name'],
|
||||
'spotify_artist_id': r['spotify_artist_id'],
|
||||
'musicbrainz_artist_id': r['musicbrainz_id'],
|
||||
'deezer_artist_id': r['deezer_id'],
|
||||
'discogs_artist_id': r['discogs_id'],
|
||||
'itunes_artist_id': r['itunes_artist_id'],
|
||||
'tidal_artist_id': r['tidal_id'],
|
||||
'qobuz_artist_id': r['qobuz_id'],
|
||||
'amazon_artist_id': r['amazon_id'],
|
||||
'lastfm_url': r['lastfm_url'],
|
||||
'genius_url': r['genius_url'],
|
||||
'soul_id': r['soul_id'],
|
||||
'album_count': c.get('album_count'),
|
||||
'track_count': c.get('track_count'),
|
||||
})
|
||||
extra = ['lastfm_url', 'genius_url', 'soul_id']
|
||||
if include_contents:
|
||||
extra += ['album_count', 'track_count']
|
||||
content = build_artist_export(artists, fmt=fmt, include_links=include_links, extra_fields=extra)
|
||||
mime, ext = export_mime_and_ext(fmt)
|
||||
return Response(content, mimetype=mime,
|
||||
headers={'X-Export-Count': str(len(artists)), 'X-Export-Ext': ext})
|
||||
except Exception as e:
|
||||
logger.error(f"Library artist export failed: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/watchlist/add', methods=['POST'])
|
||||
def add_to_watchlist():
|
||||
"""Add an artist to the watchlist"""
|
||||
|
|
@ -27485,7 +27769,7 @@ def watchlist_artist_config(artist_id):
|
|||
artist_name, image_url, spotify_artist_id, itunes_artist_id,
|
||||
last_scan_timestamp, date_added, include_instrumentals, deezer_artist_id,
|
||||
lookback_days, discogs_artist_id, preferred_metadata_source,
|
||||
amazon_artist_id, musicbrainz_artist_id
|
||||
amazon_artist_id, musicbrainz_artist_id, auto_download
|
||||
FROM watchlist_artists
|
||||
WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?
|
||||
OR discogs_artist_id = ? OR amazon_artist_id = ? OR musicbrainz_artist_id = ?
|
||||
|
|
@ -27614,6 +27898,8 @@ def watchlist_artist_config(artist_id):
|
|||
'date_added': result[12],
|
||||
'lookback_days': result[15] if len(result) > 15 else None,
|
||||
'preferred_metadata_source': result[17] if len(result) > 17 else None,
|
||||
# follow-only toggle (default True/auto-download when column absent)
|
||||
'auto_download': bool(result[20]) if len(result) > 20 and result[20] is not None else True,
|
||||
}
|
||||
|
||||
from core.metadata.registry import get_primary_source
|
||||
|
|
@ -27653,6 +27939,9 @@ def watchlist_artist_config(artist_id):
|
|||
# Validate — only accept known sources, empty string means clear override
|
||||
if preferred_metadata_source == '' or preferred_metadata_source not in ('spotify', 'deezer', 'itunes', 'discogs', 'musicbrainz'):
|
||||
preferred_metadata_source = None
|
||||
# Follow-only toggle: default True so an older client that omits the
|
||||
# field keeps auto-downloading.
|
||||
auto_download = bool(data.get('auto_download', True))
|
||||
|
||||
# Validate at least one release type is selected
|
||||
if not (include_albums or include_eps or include_singles):
|
||||
|
|
@ -27677,13 +27966,14 @@ def watchlist_artist_config(artist_id):
|
|||
SET include_albums = ?, include_eps = ?, include_singles = ?,
|
||||
include_live = ?, include_remixes = ?, include_acoustic = ?, include_compilations = ?,
|
||||
include_instrumentals = ?, lookback_days = ?, preferred_metadata_source = ?,
|
||||
auto_download = ?,
|
||||
last_scan_timestamp = CASE WHEN ? THEN NULL ELSE last_scan_timestamp END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ?
|
||||
OR discogs_artist_id = ? OR musicbrainz_artist_id = ?
|
||||
""", (int(include_albums), int(include_eps), int(include_singles),
|
||||
int(include_live), int(include_remixes), int(include_acoustic), int(include_compilations),
|
||||
int(include_instrumentals), lookback_days, preferred_metadata_source, lookback_changed,
|
||||
int(include_instrumentals), lookback_days, preferred_metadata_source, int(auto_download), lookback_changed,
|
||||
artist_id, artist_id, artist_id, artist_id, artist_id))
|
||||
conn.commit()
|
||||
|
||||
|
|
@ -27707,6 +27997,7 @@ def watchlist_artist_config(artist_id):
|
|||
'include_acoustic': include_acoustic,
|
||||
'include_compilations': include_compilations,
|
||||
'include_instrumentals': include_instrumentals,
|
||||
'auto_download': auto_download,
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -33480,6 +33771,15 @@ def mirror_playlist_endpoint():
|
|||
if playlist_id is None:
|
||||
return jsonify({"error": "Failed to mirror playlist"}), 500
|
||||
|
||||
# Membership just changed — if organize-by-playlist, rebuild its folder
|
||||
# (with prune) so a removed track's symlink is cleaned up now. Gated +
|
||||
# non-fatal, so it can never break the mirror response.
|
||||
try:
|
||||
from core.playlists.materialize_service import rebuild_mirrored_playlist_if_organized
|
||||
rebuild_mirrored_playlist_if_organized(database, config_manager, playlist_id, profile_id=profile_id)
|
||||
except Exception as _mat_err:
|
||||
logger.debug("[Playlist Folder] mirror-time cleanup skipped: %s", _mat_err)
|
||||
|
||||
try:
|
||||
if automation_engine:
|
||||
automation_engine.emit('mirrored_playlist_created', {
|
||||
|
|
@ -33500,6 +33800,7 @@ def get_mirrored_playlists_endpoint():
|
|||
"""List all mirrored playlists for the active profile."""
|
||||
try:
|
||||
from core.playlists.source_refs import describe_mirrored_source_ref
|
||||
from core.playlists.naming import effective_mirrored_name
|
||||
database = get_database()
|
||||
profile_id = get_current_profile_id()
|
||||
playlists = database.get_mirrored_playlists(profile_id=profile_id)
|
||||
|
|
@ -33518,6 +33819,9 @@ def get_mirrored_playlists_endpoint():
|
|||
pl['source_ref_kind'] = source_ref.source_ref_kind
|
||||
pl['source_ref_status'] = source_ref.source_ref_status
|
||||
pl['source_ref_error'] = source_ref.source_ref_error
|
||||
# The name the UI should show / sync uses: custom alias if set, else
|
||||
# the upstream name. Single source of truth so card + sync agree.
|
||||
pl['display_name'] = effective_mirrored_name(pl)
|
||||
pl['pipeline_state'] = _snapshot_playlist_pipeline_state(pl['id'])
|
||||
return jsonify(playlists)
|
||||
except Exception as e:
|
||||
|
|
@ -33529,6 +33833,7 @@ def get_mirrored_playlist_endpoint(playlist_id):
|
|||
"""Get a mirrored playlist with its tracks."""
|
||||
try:
|
||||
from core.playlists.source_refs import describe_mirrored_source_ref
|
||||
from core.playlists.naming import effective_mirrored_name
|
||||
database = get_database()
|
||||
playlist = database.get_mirrored_playlist(playlist_id)
|
||||
if not playlist:
|
||||
|
|
@ -33538,6 +33843,7 @@ def get_mirrored_playlist_endpoint(playlist_id):
|
|||
playlist['source_ref_kind'] = source_ref.source_ref_kind
|
||||
playlist['source_ref_status'] = source_ref.source_ref_status
|
||||
playlist['source_ref_error'] = source_ref.source_ref_error
|
||||
playlist['display_name'] = effective_mirrored_name(playlist)
|
||||
playlist['pipeline_state'] = _snapshot_playlist_pipeline_state(playlist_id)
|
||||
playlist['tracks'] = database.get_mirrored_playlist_tracks(playlist_id)
|
||||
return jsonify(playlist)
|
||||
|
|
@ -33595,6 +33901,32 @@ def update_mirrored_playlist_source_ref_endpoint(playlist_id):
|
|||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/mirrored-playlists/<int:playlist_id>/custom-name', methods=['PATCH'])
|
||||
def update_mirrored_playlist_custom_name_endpoint(playlist_id):
|
||||
"""Set or clear a user alias (custom display + sync name) for a mirrored
|
||||
playlist. A blank/missing custom_name CLEARS the alias (falls back to the
|
||||
upstream name). The upstream name keeps tracking on refresh either way."""
|
||||
try:
|
||||
from core.playlists.naming import effective_mirrored_name
|
||||
data = request.get_json() or {}
|
||||
database = get_database()
|
||||
playlist = database.get_mirrored_playlist(playlist_id)
|
||||
if not playlist:
|
||||
return jsonify({"error": "Playlist not found"}), 404
|
||||
|
||||
# `custom_name` may be '' / null to CLEAR the alias.
|
||||
ok = database.set_mirrored_playlist_custom_name(playlist_id, data.get('custom_name'))
|
||||
if not ok:
|
||||
return jsonify({"error": "Failed to update name"}), 500
|
||||
|
||||
updated = database.get_mirrored_playlist(playlist_id) or {}
|
||||
updated['display_name'] = effective_mirrored_name(updated)
|
||||
return jsonify({"success": True, "playlist": updated})
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating mirrored playlist custom name: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/mirrored-playlists/<int:playlist_id>/preferences', methods=['PATCH'])
|
||||
def update_mirrored_playlist_preferences_endpoint(playlist_id):
|
||||
"""Update per-playlist download preferences (e.g. organize by playlist folder)."""
|
||||
|
|
@ -36922,12 +37254,6 @@ def _emit_tool_progress_loop():
|
|||
# (which skipped HTTP polling while the socket was up) never learned
|
||||
# its stream was ready. Each client polls /api/stream/status instead,
|
||||
# which resolves its own session from the cookie.
|
||||
# Quality Scanner
|
||||
try:
|
||||
with quality_scanner_lock:
|
||||
socketio.emit('tool:quality-scanner', dict(quality_scanner_state))
|
||||
except Exception as e:
|
||||
logger.debug(f"Error emitting quality scanner status: {e}")
|
||||
# Duplicate Cleaner (add computed space_freed_mb)
|
||||
try:
|
||||
with duplicate_cleaner_lock:
|
||||
|
|
@ -36938,6 +37264,7 @@ def _emit_tool_progress_loop():
|
|||
logger.debug(f"Error emitting duplicate cleaner status: {e}")
|
||||
# DB Update
|
||||
try:
|
||||
_check_db_update_stall() # self-heal a hung job, then broadcast (#859)
|
||||
with db_update_lock:
|
||||
socketio.emit('tool:db-update', dict(db_update_state))
|
||||
except Exception as e:
|
||||
|
|
|
|||
122
webui/index.html
122
webui/index.html
|
|
@ -131,6 +131,7 @@
|
|||
<span class="profile-color-swatch" data-color="#14b8a6" style="background:#14b8a6" title="Teal"></span>
|
||||
</div>
|
||||
<input type="password" id="new-profile-pin" class="profile-input" placeholder="PIN (optional)" maxlength="6">
|
||||
<input type="password" id="new-profile-password" class="profile-input" placeholder="Login password (required when login mode is on)" autocomplete="new-password">
|
||||
<div class="profile-settings-section">
|
||||
<label class="profile-settings-label">Home Page</label>
|
||||
<select id="new-profile-home-page" class="profile-input">
|
||||
|
|
@ -2088,6 +2089,7 @@
|
|||
<button class="discog-filter" data-filter="matched" onclick="_serverEditorFilter(this, 'matched')">Matched</button>
|
||||
<button class="discog-filter" data-filter="missing" onclick="_serverEditorFilter(this, 'missing')">Missing</button>
|
||||
<button class="discog-filter" data-filter="extra" onclick="_serverEditorFilter(this, 'extra')">Extra</button>
|
||||
<button class="discog-filter server-editor-export" id="server-editor-export-btn" style="margin-left:auto" onclick="exportServerPlaylistM3U()" title="Export this server playlist as an M3U file (for Music Assistant etc.)">📋 Export M3U</button>
|
||||
</div>
|
||||
<div class="server-compare-columns">
|
||||
<div class="server-compare-col source" id="server-col-source">
|
||||
|
|
@ -2487,6 +2489,10 @@
|
|||
<span class="stat-label">Artists</span>
|
||||
</span>
|
||||
</div>
|
||||
<button class="library-watchlist-all-btn library-export-btn" id="library-export-btn" onclick="openArtistExportModal()" title="Export artists — pick watchlist or whole library, as JSON / CSV / text">
|
||||
<span class="watchlist-all-icon">⬇</span>
|
||||
<span class="watchlist-all-text">Export</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
|
|
@ -4544,6 +4550,32 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Playlists Folder:</label>
|
||||
<div class="path-input-group">
|
||||
<input type="text" id="playlists-materialize-path" placeholder="./Playlists" readonly>
|
||||
<button class="browse-button locked" onclick="togglePathLock('playlists-materialize', this)">Unlock</button>
|
||||
</div>
|
||||
<div class="setting-help-text" style="margin-top: 6px;">
|
||||
Where <strong>Organize by playlist</strong> builds playlist folders — as shortcuts into your library, so songs are never stored twice. Keep this <strong>outside</strong> your music library folder so your media server doesn't scan the same tracks twice.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Playlist Folder Style:</label>
|
||||
<select id="playlists-materialize-mode">
|
||||
<option value="symlink">Symlinks — no extra disk, point to your library</option>
|
||||
<option value="copy">Copies — real duplicates (USB sticks / players that can't follow links)</option>
|
||||
</select>
|
||||
<div class="setting-help-text" style="margin-top: 6px;">
|
||||
Symlinks use almost no space. Copies are self-contained. Symlinks fall back to copies automatically where the filesystem can't link (Windows without privileges, some network shares, FAT/exFAT drives).
|
||||
</div>
|
||||
<button class="test-button" id="playlists-rebuild-btn" onclick="rebuildPlaylistFolders()" style="margin-top: 10px;">
|
||||
Rebuild playlist folders now
|
||||
</button>
|
||||
<div class="setting-help-text" id="playlists-rebuild-status" style="margin-top: 6px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>M3U Entry Base Path:</label>
|
||||
<div class="path-input-group">
|
||||
|
|
@ -4844,7 +4876,10 @@
|
|||
<input type="url" id="hifi-new-instance" class="form-input" placeholder="https://example.com" style="flex:1;">
|
||||
<button class="test-button" onclick="addHiFiInstance()">Add</button>
|
||||
</div>
|
||||
<button class="test-button" onclick="checkHiFiInstances()" id="hifi-instances-check-btn" style="margin-bottom: 8px;">Check All Instances</button>
|
||||
<div style="display:flex;gap:8px;margin-bottom:8px;flex-wrap:wrap;">
|
||||
<button class="test-button" onclick="checkHiFiInstances()" id="hifi-instances-check-btn">Check All Instances</button>
|
||||
<button class="test-button" onclick="restoreDefaultHiFiInstances()" id="hifi-instances-restore-btn" title="Re-add any of the built-in default instances you've removed (keeps the ones you added)">Restore Defaults</button>
|
||||
</div>
|
||||
<div id="hifi-instances-status-panel" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -5365,9 +5400,10 @@
|
|||
<option value="qbittorrent">qBittorrent</option>
|
||||
<option value="transmission">Transmission</option>
|
||||
<option value="deluge">Deluge 2.x</option>
|
||||
<option value="aria2">Aria2 (RPC)</option>
|
||||
</select>
|
||||
<div class="setting-help-text" id="torrent-client-help">
|
||||
qBittorrent: WebUI port (default 8080). Transmission: RPC port (default 9091). Deluge: WebUI port (default 8112).
|
||||
qBittorrent: WebUI port (default 8080). Transmission: RPC port (default 9091). Deluge: WebUI port (default 8112). Aria2: RPC port (default 6800) — leave Username blank and put your <code>--rpc-secret</code> in the Password field.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
|
@ -5399,6 +5435,13 @@
|
|||
Override where the torrent client writes downloads. This path is on the <strong>torrent client's</strong> machine, not SoulSync's.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Completed Downloads Path — in SoulSync (optional):</label>
|
||||
<input type="text" id="torrent-download-path" placeholder="leave blank for the shared-volume default">
|
||||
<div class="setting-help-text">
|
||||
Where <strong>SoulSync</strong> can read finished torrents — its own in-container path. Set this when your client saves to a category folder (e.g. a "Music" category at <code>/data/downloads/music</code>) that's mounted here at a different path. SoulSync finds the release by name under this folder. Blank = use the Soulseek download/transfer folders.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Stalled torrent timeout (minutes):</label>
|
||||
<input type="number" id="torrent-stall-timeout" min="0" step="1" placeholder="10">
|
||||
|
|
@ -5479,6 +5522,13 @@
|
|||
SoulSync tags every NZB with this category so it ends up in a predictable post-processing folder.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Completed Downloads Path — in SoulSync (optional):</label>
|
||||
<input type="text" id="usenet-download-path" placeholder="leave blank for the shared-volume default">
|
||||
<div class="setting-help-text">
|
||||
Where <strong>SoulSync</strong> can read finished NZB downloads — its own in-container path. Set this when SABnzbd/NZBGet writes that category to a folder (e.g. <code>/data/downloads/music</code>) mounted here at a different path. SoulSync finds the release by name under this folder. Blank = use the Soulseek download/transfer folders.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Status:</label>
|
||||
<div class="form-actions" style="margin-top: 4px;">
|
||||
|
|
@ -5526,7 +5576,12 @@
|
|||
<small class="settings-hint">Variables: $albumartist, $artist, $artistletter, $title, $track (01), $album, $albumtype, $year, $quality (filename only). Use ${var} to append text: ${albumtype}s → Singles</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<!-- Playlist Path Template: hidden from the UI. Playlists now import
|
||||
normally into Artist/Album and the Playlists/ folder is a symlink
|
||||
view that mirrors the real library filename, so this template no
|
||||
longer drives playlist-file naming. Kept in the DOM (display:none)
|
||||
so settings load/save still round-trips the stored value. -->
|
||||
<div class="form-group" style="display: none;">
|
||||
<label>Playlist Path Template:</label>
|
||||
<input type="text" id="template-playlist-path"
|
||||
placeholder="$playlist/$artist - $title">
|
||||
|
|
@ -6776,49 +6831,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card" id="quality-scanner-card">
|
||||
<div class="tool-card-header">
|
||||
<h4 class="tool-card-title">Quality Scanner</h4>
|
||||
<button class="tool-help-button" data-tool="quality-scanner"
|
||||
title="Learn more about this tool">?</button>
|
||||
</div>
|
||||
<p class="tool-card-info">Scan library for tracks below quality preferences</p>
|
||||
<div class="tool-card-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-item-label">Processed:</span>
|
||||
<span class="stat-item-value" id="quality-stat-processed">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-item-label">Quality Met:</span>
|
||||
<span class="stat-item-value" id="quality-stat-met">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-item-label">Low Quality:</span>
|
||||
<span class="stat-item-value" id="quality-stat-low">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-item-label">Matched:</span>
|
||||
<span class="stat-item-value" id="quality-stat-matched">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-card-controls">
|
||||
<select id="quality-scan-scope">
|
||||
<option value="watchlist">Watchlist Artists Only</option>
|
||||
<option value="all">All Library Tracks</option>
|
||||
</select>
|
||||
<button id="quality-scan-button">Scan Library</button>
|
||||
</div>
|
||||
<div class="tool-card-progress-section">
|
||||
<p class="progress-phase-label" id="quality-phase-label">Ready to scan</p>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar-fill" id="quality-progress-bar" style="width: 0%;">
|
||||
</div>
|
||||
</div>
|
||||
<p class="progress-details-label" id="quality-progress-label">0 / 0 tracks scanned
|
||||
(0.0%)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card" id="reconcile-ids-card">
|
||||
<div class="tool-card-header">
|
||||
<h4 class="tool-card-title">Import IDs from File Tags</h4>
|
||||
|
|
@ -7705,6 +7717,24 @@
|
|||
|
||||
<div class="watchlist-artist-config-content">
|
||||
<div class="watchlist-artist-config-body">
|
||||
<div class="config-section">
|
||||
<h3 class="config-section-title">Auto-Download</h3>
|
||||
<p class="config-section-subtitle">When on, new releases are added to the wishlist and downloaded automatically. Turn off to <strong>follow only</strong> — still see new releases in scans, but pick what to download yourself.</p>
|
||||
|
||||
<div class="config-options">
|
||||
<label class="config-option">
|
||||
<input type="checkbox" id="config-auto-download" checked>
|
||||
<div class="config-option-content">
|
||||
<div class="config-option-icon">⬇️</div>
|
||||
<div class="config-option-text">
|
||||
<span class="config-option-title">Auto-download new releases</span>
|
||||
<span class="config-option-description">Off = follow only (discover but don't auto-add to wishlist)</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<h3 class="config-section-title">Download Preferences</h3>
|
||||
<p class="config-section-subtitle">Select which types of releases to monitor for this artist</p>
|
||||
|
|
|
|||
|
|
@ -2498,6 +2498,9 @@ async function openWatchlistArtistConfigModal(artistId, artistName) {
|
|||
}
|
||||
|
||||
// Set checkbox states
|
||||
// Default to true (auto-download) when absent (older configs).
|
||||
const _autoDlToggle = document.getElementById('config-auto-download');
|
||||
if (_autoDlToggle) _autoDlToggle.checked = config.auto_download !== false;
|
||||
document.getElementById('config-include-albums').checked = config.include_albums;
|
||||
document.getElementById('config-include-eps').checked = config.include_eps;
|
||||
document.getElementById('config-include-singles').checked = config.include_singles;
|
||||
|
|
@ -2985,6 +2988,8 @@ async function saveWatchlistArtistConfig(artistId) {
|
|||
const includeAcoustic = document.getElementById('config-include-acoustic').checked;
|
||||
const includeCompilations = document.getElementById('config-include-compilations').checked;
|
||||
const includeInstrumentals = document.getElementById('config-include-instrumentals').checked;
|
||||
const autoDownloadEl = document.getElementById('config-auto-download');
|
||||
const autoDownload = autoDownloadEl ? autoDownloadEl.checked : true;
|
||||
const lookbackDaysVal = document.getElementById('config-lookback-days').value;
|
||||
const lookbackDays = lookbackDaysVal !== '' ? parseInt(lookbackDaysVal) : null;
|
||||
const activeSourceBtn = document.querySelector('#config-metadata-source-selector .config-msrc-btn.active');
|
||||
|
|
@ -3016,6 +3021,7 @@ async function saveWatchlistArtistConfig(artistId) {
|
|||
include_acoustic: includeAcoustic,
|
||||
include_compilations: includeCompilations,
|
||||
include_instrumentals: includeInstrumentals,
|
||||
auto_download: autoDownload,
|
||||
lookback_days: lookbackDays,
|
||||
preferred_metadata_source: preferredMetadataSource,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2014,6 +2014,39 @@ async function parseMirroredPipelineResponse(res, fallbackMessage) {
|
|||
return data;
|
||||
}
|
||||
|
||||
async function editMirroredCustomName(playlistId, originalName, currentCustom) {
|
||||
// Custom alias: changes the name shown in SoulSync AND used when syncing to
|
||||
// the media server, while staying tied to the original upstream playlist.
|
||||
// Blank clears the alias (falls back to the original name).
|
||||
const nextName = window.prompt(
|
||||
`Rename "${originalName}"\n\nThis name is shown here and used when syncing. ` +
|
||||
`Leave blank to use the original name.`,
|
||||
currentCustom || ''
|
||||
);
|
||||
if (nextName === null) return; // cancelled
|
||||
const trimmed = nextName.trim();
|
||||
try {
|
||||
const res = await fetch(`/api/mirrored-playlists/${playlistId}/custom-name`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ custom_name: trimmed })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) {
|
||||
throw new Error(data.error || 'Failed to update name');
|
||||
}
|
||||
showToast(trimmed ? `Renamed to "${trimmed}"` : `Reverted to "${originalName}"`, 'success');
|
||||
loadMirroredPlaylists();
|
||||
const openModal = document.getElementById('mirrored-track-modal');
|
||||
if (openModal) {
|
||||
closeMirroredModal();
|
||||
openMirroredPlaylistModal(playlistId);
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`Error: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function editMirroredSourceRef(playlistId, name, source, currentRef) {
|
||||
const label = (source === 'spotify_public' || source === 'youtube')
|
||||
? 'original playlist URL'
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ let isSortReversed = false;
|
|||
let searchAbortController = null;
|
||||
let dbStatsInterval = null;
|
||||
let dbUpdateStatusInterval = null;
|
||||
let qualityScannerStatusInterval = null;
|
||||
let duplicateCleanerStatusInterval = null;
|
||||
let wishlistCountInterval = null;
|
||||
let wishlistCountdownInterval = null; // Countdown timer for wishlist overview modal
|
||||
|
|
@ -487,7 +486,6 @@ function initializeWebSocket() {
|
|||
// 'tool:stream' is intentionally NOT wired: stream state is per-listener
|
||||
// (session cookie), so the global broadcast could only carry the DEFAULT
|
||||
// session's eternal "stopped" — the player polls /api/stream/status instead.
|
||||
socket.on('tool:quality-scanner', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateQualityScanProgressFromData(data); });
|
||||
socket.on('tool:duplicate-cleaner', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateDuplicateCleanProgressFromData(data); });
|
||||
socket.on('tool:db-update', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateDbProgressFromData(data); });
|
||||
socket.on('tool:metadata', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateMetadataStatusFromData(data); });
|
||||
|
|
|
|||
|
|
@ -2735,6 +2735,7 @@ async function loadRepairFindings() {
|
|||
path_mismatch: 'Path Mismatch', metadata_gap: 'Missing Metadata',
|
||||
missing_cover_art: 'Missing Art', track_number_mismatch: 'Track Number',
|
||||
missing_lyrics: 'Missing Lyrics', expired_download: 'Expired',
|
||||
missing_replaygain: 'No ReplayGain', empty_folder: 'Empty Folder',
|
||||
missing_lossy_copy: 'No Lossy Copy', library_retag: 'Re-tag'
|
||||
};
|
||||
|
||||
|
|
@ -2745,6 +2746,8 @@ async function loadRepairFindings() {
|
|||
track_number_mismatch: 'Fix',
|
||||
missing_cover_art: 'Apply Art',
|
||||
missing_lyrics: 'Apply Lyrics',
|
||||
missing_replaygain: 'Apply RG',
|
||||
empty_folder: 'Delete Folder',
|
||||
expired_download: 'Delete',
|
||||
metadata_gap: 'Apply',
|
||||
duplicate_tracks: 'Keep Best',
|
||||
|
|
@ -3138,6 +3141,16 @@ function _renderFindingDetail(f) {
|
|||
if (d.album_title) rows.push(['Album', d.album_title]);
|
||||
return _gridRows(rows);
|
||||
|
||||
case 'missing_replaygain':
|
||||
if (d.track_title) rows.push(['Track', d.track_title]);
|
||||
if (d.artist) rows.push(['Artist', d.artist]);
|
||||
return _gridRows(rows);
|
||||
|
||||
case 'empty_folder':
|
||||
if (d.folder_path) rows.push(['Folder', d.folder_path, 'path']);
|
||||
if (d.junk_files && d.junk_files.length) rows.push(['Junk files', d.junk_files.join(', ')]);
|
||||
return _gridRows(rows);
|
||||
|
||||
case 'expired_download':
|
||||
if (d.title) rows.push(['Track', d.title]);
|
||||
if (d.artist) rows.push(['Artist', d.artist]);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue