Merge pull request #869 from Nezreka/dev

Dev
This commit is contained in:
BoulderBadgeDad 2026-06-13 15:25:21 -07:00 committed by GitHub
commit 7e3738f7e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
110 changed files with 7767 additions and 1819 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

@ -0,0 +1 @@
"""Data export builders."""

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

@ -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',
]

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

View file

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

View file

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

View 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

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

View file

@ -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'"]

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View 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

View 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

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

View 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

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

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

View file

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

View 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

View 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() == []

View file

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

View 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

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

View 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

View file

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

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

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

View 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

View file

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

View file

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

View 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

View file

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

View 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

View file

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

View 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

View file

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

View 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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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