Merge pull request #749 from Nezreka/dev

Dev
This commit is contained in:
BoulderBadgeDad 2026-05-30 00:40:07 -07:00 committed by GitHub
commit eea0f5ead0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 7515 additions and 3249 deletions

View file

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

View file

@ -79,8 +79,15 @@ COPY --chown=soulsync:soulsync --from=webui-builder /app/webui/static/dist /app/
# fails silently on rootless Docker where the soulsync UID can't write
# to /app — playback then errors out with no obvious cause. Pre-baking
# at build time (when the layer is owned by root) avoids that path.
RUN mkdir -p /app/config /app/data /app/logs /app/downloads /app/Transfer /app/Staging /app/Stream /app/MusicVideos /app/scripts && \
chown soulsync:soulsync /app/config /app/data /app/logs /app/downloads /app/Transfer /app/Staging /app/Stream /app/MusicVideos /app/scripts
# NOTE: /app/storage is the PRIVATE album-bundle staging area for the
# torrent / usenet whole-release flow (download_source.album_bundle_staging_path
# defaults to 'storage/album_bundle_staging'). Like /app/Stream it's created
# lazily at runtime via mkdir(parents=True); without pre-baking it owned by
# soulsync, the album-bundle copy fails with "[Errno 13] Permission denied:
# 'storage'" because /app itself is root-owned and the soulsync UID can't
# create a top-level dir there.
RUN mkdir -p /app/config /app/data /app/logs /app/downloads /app/Transfer /app/Staging /app/Stream /app/storage /app/MusicVideos /app/scripts && \
chown soulsync:soulsync /app/config /app/data /app/logs /app/downloads /app/Transfer /app/Staging /app/Stream /app/storage /app/MusicVideos /app/scripts
# Create defaults directory and copy template files
# These will be used by entrypoint.sh to initialize empty volumes

View file

@ -21,6 +21,8 @@ from __future__ import annotations
import logging
from typing import Optional
from core.source_ids import id_column as _artist_id_column
logger = logging.getLogger("artist_source_lookup")
@ -29,14 +31,14 @@ SOURCE_ONLY_ARTIST_SOURCES = frozenset({
})
# The per-source column on the ``artists`` table, derived from the canonical
# source-ID registry (the single source of truth). Values are unchanged from the
# previous hardcoded map — this just stops duplicating that knowledge here.
SOURCE_ID_FIELD = {
"spotify": "spotify_artist_id",
"itunes": "itunes_artist_id",
"deezer": "deezer_id",
"discogs": "discogs_id",
"hydrabase": "soul_id",
"musicbrainz": "musicbrainz_id",
"amazon": "amazon_id",
source: _artist_id_column(source, "artist")
for source in (
"spotify", "itunes", "deezer", "discogs", "hydrabase", "musicbrainz", "amazon",
)
}

738
core/discovery/endpoints.py Normal file
View file

@ -0,0 +1,738 @@
"""Generic, source-agnostic helpers for the playlist-discovery route layer.
The discovery/sync endpoints in ``web_server.py`` were copy-pasted once per
source (Tidal, Deezer, Qobuz, Spotify-public, iTunes-link, YouTube,
ListenBrainz, Beatport). The per-source copies differ only by a source label
string and which ``<source>_discovery_states`` global they read. This module
lifts the source-agnostic pieces into importable, unit-testable helpers so the
route functions become thin wrappers exactly preserving behavior (1:1).
Each helper is lifted verbatim from its web_server.py counterpart; any
per-source quirk that genuinely differs (e.g. Beatport's distinct result
shape) is intentionally NOT routed through here and stays in its own function.
"""
from __future__ import annotations
import time
from typing import Any, Dict, List, Tuple
from utils.logging_config import get_logger
logger = get_logger("discovery.endpoints")
def convert_results_to_spotify_tracks(
discovery_results: List[Dict[str, Any]],
source_label: str,
) -> List[Dict[str, Any]]:
"""Convert a source's discovery results into the Spotify-track dicts the
sync pipeline expects.
Lifted verbatim from the per-source ``convert_<source>_results_to_spotify_tracks``
functions (and the already-generic ``_convert_link_results_to_spotify_tracks``),
which were byte-identical apart from the ``source_label`` used in the log
line. Two input shapes are supported, matching the originals exactly:
- ``spotify_data`` (manual-fix shape): copied through, preserving optional
``track_number`` / ``disc_number``.
- ``spotify_track`` + ``status_class == 'found'`` (auto-discovery shape):
rebuilt from the flat ``spotify_*`` fields.
Any result matching neither shape is skipped, identical to the originals.
NOTE: Beatport deliberately does NOT use this its converter coerces
artist objects to strings and emits a different track shape (``source``
field, album dict), so it keeps its own implementation.
"""
spotify_tracks: List[Dict[str, Any]] = []
for result in discovery_results:
# Support both data formats: spotify_data (manual fixes) and individual
# fields (automatic discovery).
if result.get('spotify_data'):
spotify_data = result['spotify_data']
track = {
'id': spotify_data['id'],
'name': spotify_data['name'],
'artists': spotify_data['artists'],
'album': spotify_data['album'],
'duration_ms': spotify_data.get('duration_ms', 0),
}
if spotify_data.get('track_number'):
track['track_number'] = spotify_data['track_number']
if spotify_data.get('disc_number'):
track['disc_number'] = spotify_data['disc_number']
spotify_tracks.append(track)
elif result.get('spotify_track') and result.get('status_class') == 'found':
spotify_tracks.append({
'id': result.get('spotify_id', 'unknown'),
'name': result.get('spotify_track', 'Unknown Track'),
'artists': [result.get('spotify_artist', 'Unknown Artist')] if result.get('spotify_artist') else ['Unknown Artist'],
'album': result.get('spotify_album', 'Unknown Album'),
'duration_ms': 0,
})
logger.info(f"Converted {len(spotify_tracks)} {source_label} matches to Spotify tracks for sync")
return spotify_tracks
def cancel_sync(
states: Dict[str, Any],
key: str,
*,
label: str,
not_found_message: str,
sync_lock: Any,
sync_states: Dict[str, Any],
active_sync_workers: Dict[str, Any],
) -> Tuple[Dict[str, Any], int]:
"""Cancel an in-progress sync for one discovery playlist.
1:1 lift of the byte-identical ``cancel_<source>_sync`` bodies (Tidal,
Deezer, Qobuz, Spotify-Public, iTunes-Link, YouTube, ListenBrainz). The
caller passes the already-resolved state key (ListenBrainz transforms it
via ``_lb_state_key`` first), the source ``label``, the exact 404 message
(iTunes-Link uses "iTunes Link not found", not "... playlist not found"),
and the shared sync infrastructure (so this stays free of web_server
globals / Flask).
Returns ``(payload_dict, status_code)``; the caller wraps in ``jsonify``.
Beatport is NOT routed here it cancels a stored ``sync_future`` and
returns a different payload.
"""
try:
if key not in states:
return {"error": not_found_message}, 404
state = states[key]
state['last_accessed'] = time.time()
sync_playlist_id = state.get('sync_playlist_id')
if sync_playlist_id:
with sync_lock:
sync_states[sync_playlist_id] = {"status": "cancelled"}
if sync_playlist_id in active_sync_workers:
del active_sync_workers[sync_playlist_id]
state['phase'] = 'discovered'
state['sync_playlist_id'] = None
state['sync_progress'] = {}
return {"success": True, "message": f"{label} sync cancelled"}, 200
except Exception as e:
logger.error(f"Error cancelling {label} sync: {e}")
return {"error": str(e)}, 500
def delete_playlist_state(
states: Dict[str, Any],
key: str,
*,
label: str,
not_found_message: str,
) -> Tuple[Dict[str, Any], int]:
"""Delete a discovery playlist's state entry, cancelling any active
discovery first.
1:1 lift of the byte-identical ``delete_<source>_playlist`` bodies
(Tidal, Deezer, Qobuz, Spotify-Public). Returns ``(payload, status_code)``.
The iTunes-Link / YouTube / ListenBrainz / Beatport deletes intentionally
keep their own bodies they differ in success message, info-log wording,
name extraction, and/or key transform.
"""
try:
if key not in states:
return {"error": not_found_message}, 404
state = states[key]
if 'discovery_future' in state and state['discovery_future']:
state['discovery_future'].cancel()
del states[key]
logger.info(f"Deleted {label} playlist state: {key}")
return {"success": True, "message": "Playlist deleted"}, 200
except Exception as e:
logger.error(f"Error deleting {label} playlist: {e}")
return {"error": str(e)}, 500
# --- playlist-name accessors -------------------------------------------------
# The per-source sync-status handlers read the display name three different
# ways. Each is reproduced verbatim so the 1:1 behavior (including which ones
# raise vs. fall back to 'Unknown Playlist') is preserved.
def playlist_name_attr_or_unknown(state: Dict[str, Any]) -> str:
"""Tidal: playlist is an object — use ``.name`` or 'Unknown Playlist'."""
pl = state.get('playlist')
return pl.name if pl and hasattr(pl, 'name') else 'Unknown Playlist'
def playlist_name_strict(state: Dict[str, Any]) -> str:
"""Deezer / Qobuz / Spotify-Public / iTunes-Link: strict dict access —
raises ( 500) if 'playlist' is missing, exactly like the originals."""
return state['playlist']['name']
def playlist_name_safe(state: Dict[str, Any]) -> str:
"""YouTube / ListenBrainz: safe dict access, defaulting to 'Unknown
Playlist'."""
return state.get('playlist', {}).get('name', 'Unknown Playlist')
def playlist_name_obj(state: Dict[str, Any]) -> str:
"""Tidal start-sync: playlist is an object — strict ``.name`` (raises if
absent, exactly like the original)."""
return state['playlist'].name
def playlist_image_obj(state: Dict[str, Any]) -> str:
"""Tidal: ``getattr(playlist, 'image_url', '')`` (object attribute)."""
return getattr(state['playlist'], 'image_url', '')
def playlist_image_dict(state: Dict[str, Any]) -> str:
"""Deezer/Qobuz/Spotify-Public/YouTube: ``playlist.get('image_url', '')``
(dict access)."""
return state['playlist'].get('image_url', '')
def get_sync_status(
states: Dict[str, Any],
key: str,
*,
not_found_message: str,
error_label: str,
activity_subject: str,
playlist_name_getter,
sync_lock: Any,
sync_states: Dict[str, Any],
add_activity_item,
) -> Tuple[Dict[str, Any], int]:
"""Report sync status for one discovery playlist, posting an activity-feed
item when the sync finishes or errors.
1:1 lift of the ``get_<source>_sync_status`` bodies (Tidal, Deezer, Qobuz,
Spotify-Public, iTunes-Link, YouTube, ListenBrainz). Per-source variation
is captured by the parameters:
- ``not_found_message`` the 404 string (iTunes-Link drops "playlist").
- ``error_label`` used in the except log ("Error getting <X> sync status").
- ``activity_subject`` the activity-feed prefix; note Spotify-Public uses
"Spotify Link playlist" while its error_label is "Spotify Public".
- ``playlist_name_getter`` one of the accessors above (attr/strict/safe);
the strict one can raise, matching the originals ( 500). The state's
phase/sync_progress are mutated BEFORE the name is read, so a raising
getter leaves the same partial mutation the original did.
Beatport is NOT routed here it returns a different payload (``status``
not ``sync_status``, includes ``sync_id``, no lock, ``chart`` key).
"""
try:
if key not in states:
return {"error": not_found_message}, 404
state = states[key]
state['last_accessed'] = time.time()
sync_playlist_id = state.get('sync_playlist_id')
if not sync_playlist_id:
return {"error": "No sync in progress"}, 404
with sync_lock:
sync_state = sync_states.get(sync_playlist_id, {})
response = {
'phase': state['phase'],
'sync_status': sync_state.get('status', 'unknown'),
'progress': sync_state.get('progress', {}),
'complete': sync_state.get('status') == 'finished',
'error': sync_state.get('error'),
}
if sync_state.get('status') == 'finished':
state['phase'] = 'sync_complete'
state['sync_progress'] = sync_state.get('progress', {})
playlist_name = playlist_name_getter(state)
add_activity_item("", "Sync Complete", f"{activity_subject} '{playlist_name}' synced successfully", "Now")
elif sync_state.get('status') == 'error':
state['phase'] = 'discovered'
playlist_name = playlist_name_getter(state)
add_activity_item("", "Sync Failed", f"{activity_subject} '{playlist_name}' sync failed", "Now")
return response, 200
except Exception as e:
logger.error(f"Error getting {error_label} sync status: {e}")
return {"error": str(e)}, 500
def get_discovery_status(
states: Dict[str, Any],
key: str,
*,
not_found_message: str,
error_label: str,
) -> Tuple[Dict[str, Any], int]:
"""Report real-time discovery progress/results for one playlist.
1:1 lift of the byte-identical ``get_<source>_discovery_status`` bodies.
Unlike sync-status, this shape is identical for ALL eight sources
Beatport included so it folds in too. Only the 404 message
(".../discovery not found" vs ".../playlist not found" vs "Beatport chart
not found") and the except-log label vary, both passed in. The caller
resolves the key (ListenBrainz via ``_lb_state_key``).
Returns ``(payload, status_code)``.
"""
try:
if key not in states:
return {"error": not_found_message}, 404
state = states[key]
state['last_accessed'] = time.time()
return {
'phase': state['phase'],
'status': state['status'],
'progress': state['discovery_progress'],
'spotify_matches': state['spotify_matches'],
'spotify_total': state['spotify_total'],
'results': state['discovery_results'],
'complete': state['phase'] == 'discovered',
}, 200
except Exception as e:
logger.error(f"Error getting {error_label} discovery status: {e}")
return {"error": str(e)}, 500
def reset_playlist(
states: Dict[str, Any],
key: str,
*,
label: str,
not_found_message: str,
) -> Tuple[Dict[str, Any], int]:
"""Reset a discovery playlist back to the 'fresh' phase, clearing all
discovery/sync data while preserving the original playlist payload.
1:1 lift of the byte-identical ``reset_<source>_playlist`` bodies
(Tidal, Deezer, Qobuz, Spotify-Public). Returns ``(payload, status_code)``.
NOT folded in (genuinely divergent): YouTube (status -> 'parsed', no
download_process_id, logs the playlist name, "reset to fresh state"),
ListenBrainz (status -> 'cached', logs playlist title, returns
{"phase": "fresh"}), iTunes-Link (uses state.update, no info log, distinct
message). Those keep their own bodies.
"""
try:
if key not in states:
return {"error": not_found_message}, 404
state = states[key]
if 'discovery_future' in state and state['discovery_future']:
state['discovery_future'].cancel()
state['phase'] = 'fresh'
state['status'] = 'fresh'
state['discovery_results'] = []
state['discovery_progress'] = 0
state['spotify_matches'] = 0
state['sync_playlist_id'] = None
state['converted_spotify_playlist_id'] = None
state['download_process_id'] = None
state['sync_progress'] = {}
state['discovery_future'] = None
state['last_accessed'] = time.time()
logger.info(f"Reset {label} playlist to fresh: {key}")
return {"success": True, "message": "Playlist reset to fresh phase"}, 200
except Exception as e:
logger.error(f"Error resetting {label} playlist: {e}")
return {"error": str(e)}, 500
def get_playlist_states(
states: Dict[str, Any],
*,
error_label: str,
info_log_label: str = None,
) -> Tuple[Dict[str, Any], int]:
"""Return all stored discovery states for a source as a list for frontend
card hydration (``{"states": [...]}``).
1:1 lift of the ``get_<source>_playlist_states`` bodies (Tidal, Deezer,
Qobuz, Spotify-Public, iTunes-Link), which build the same per-entry dict.
iTunes-Link is the only one without the "Returning N ..." info log, so
``info_log_label`` is optional (pass None to suppress it, as iTunes did).
NOT folded in: the YouTube/ListenBrainz ``get_all_*_playlists`` endpoints
they return ``{"playlists": [...]}`` (different key + fields: url/created_at,
no discovery_results) and filter mirrored/profile-scoped entries.
"""
try:
result = []
current_time = time.time()
for key, state in states.items():
state['last_accessed'] = current_time
result.append({
'playlist_id': key,
'phase': state['phase'],
'status': state['status'],
'discovery_progress': state['discovery_progress'],
'spotify_matches': state['spotify_matches'],
'spotify_total': state['spotify_total'],
'discovery_results': state['discovery_results'],
'converted_spotify_playlist_id': state.get('converted_spotify_playlist_id'),
'download_process_id': state.get('download_process_id'),
'last_accessed': state['last_accessed'],
})
if info_log_label:
logger.info(f"Returning {len(result)} stored {info_log_label} playlist states for hydration")
return {"states": result}, 200
except Exception as e:
logger.error(f"Error getting {error_label} playlist states: {e}")
return {"error": str(e)}, 500
def save_bubble_snapshot(
get_json,
*,
payload_key: str,
no_data_error: str,
snapshot_kind: str,
success_noun: str,
log_subject: str,
log_noun: str,
get_database,
get_current_profile_id,
) -> Tuple[Dict[str, Any], int]:
"""Persist a bubble/download snapshot for cross-refresh hydration.
1:1 lift of the four structurally-identical snapshot endpoints
(discover_downloads, artist_bubbles, search_bubbles, beatport_bubbles),
which differ only by:
- ``payload_key`` ('downloads' for discover, 'bubbles' for the rest) and
its ``no_data_error`` message.
- ``snapshot_kind`` the db.save_bubble_snapshot category.
- ``success_noun`` fills "Snapshot saved with N <noun>".
- ``log_subject`` / ``log_noun`` the info ("Saved <subject>: N <noun>")
and except ("Error saving <subject>") log lines.
Returns ``(payload, status_code)``. ``get_json`` is invoked inside the try
like the original ``request.json``.
"""
try:
from datetime import datetime
data = get_json()
if not data or payload_key not in data:
return {'success': False, 'error': no_data_error}, 400
items = data[payload_key]
db = get_database()
db.save_bubble_snapshot(snapshot_kind, items, profile_id=get_current_profile_id())
count = len(items)
logger.info(f"Saved {log_subject}: {count} {log_noun}")
return {
'success': True,
'message': f'Snapshot saved with {count} {success_noun}',
'timestamp': datetime.now().isoformat(),
}, 200
except Exception as e:
logger.error(f"Error saving {log_subject}: {e}")
import traceback
traceback.print_exc()
return {'success': False, 'error': str(e)}, 500
def update_playlist_phase(
states: Dict[str, Any],
key: str,
get_json,
*,
not_found_message: str,
error_label: str,
valid_phases: List[str],
apply_extra_fields: bool,
) -> Tuple[Dict[str, Any], int]:
"""Update a discovery playlist's phase (used when the modal closes, e.g. to
reset download_complete -> discovered).
1:1 lift of the ``update_<source>_playlist_phase`` bodies for the five
sources with the identical validation + full-message response (Tidal,
Deezer, Qobuz, Spotify-Public, YouTube). Per-source params:
- ``valid_phases`` YouTube's list additionally includes 'parsed'.
- ``apply_extra_fields`` Deezer/Qobuz/Spotify-Public also persist
download_process_id / converted_spotify_playlist_id from the body;
Tidal/YouTube do NOT (so pass False to keep them 1:1).
- ``not_found_message`` / ``error_label``; ``get_json`` invoked inside the
try like the original ``request.get_json()``.
Returns ``(payload, status_code)``.
NOT folded in: iTunes-Link it uses ``data.get('phase')`` (no separate
"Phase not provided" 400) and returns a no-message payload.
"""
try:
if key not in states:
return {"error": not_found_message}, 404
data = get_json()
if not data or 'phase' not in data:
return {"error": "Phase not provided"}, 400
new_phase = data['phase']
if new_phase not in valid_phases:
return {"error": f"Invalid phase. Must be one of: {', '.join(valid_phases)}"}, 400
state = states[key]
old_phase = state.get('phase', 'unknown')
state['phase'] = new_phase
state['last_accessed'] = time.time()
if apply_extra_fields:
if 'download_process_id' in data:
state['download_process_id'] = data['download_process_id']
if 'converted_spotify_playlist_id' in data:
state['converted_spotify_playlist_id'] = data['converted_spotify_playlist_id']
logger.info(f"Updated {error_label} playlist {key} phase: {old_phase}{new_phase}")
return {"success": True, "message": f"Phase updated to {new_phase}", "old_phase": old_phase, "new_phase": new_phase}, 200
except Exception as e:
logger.error(f"Error updating {error_label} playlist phase: {e}")
return {"error": str(e)}, 500
def first_artist_str_or_obj(original_track: Dict[str, Any]) -> str:
"""Tidal: first artist from an artists list that may hold strings OR
objects ({'name': ...}); '' when empty."""
artists = original_track.get('artists', [])
if artists:
return artists[0] if isinstance(artists[0], str) else artists[0].get('name', '')
return ''
def first_artist_plain(original_track: Dict[str, Any]) -> str:
"""Deezer/Qobuz/Spotify-Public: first artist assuming a list of strings;
'' when empty."""
artists = original_track.get('artists', [])
return artists[0] if artists else ''
def update_discovery_match(
states: Dict[str, Any],
get_json,
*,
source_log_label: str,
error_label: str,
original_track_key: str,
original_artist_getter,
join_artist_names,
extract_artist_name,
build_fix_modal_spotify_data,
get_discovery_cache_key,
get_database,
get_active_discovery_source,
) -> Tuple[Dict[str, Any], int]:
"""Apply a manually-selected Spotify track to a discovery result (the
fix-modal flow) and persist it to the discovery cache.
1:1 lift of the ``update_<source>_discovery_match`` bodies for the four
sources with the identical structure (Tidal, Deezer, Qobuz, Spotify-Public).
Per-source pieces are params:
- ``source_log_label`` (lowercase, e.g. "tidal") for the "Manual match
updated: ..." line; ``error_label`` for the except log.
- ``original_track_key`` the raw-source track key on the result
('tidal_track', 'deezer_track', ...).
- ``original_artist_getter`` Tidal handles string-or-object artists
(``first_artist_str_or_obj``); the rest assume strings
(``first_artist_plain``).
- the web_server helpers (join/extract artist, build_fix_modal_spotify_data,
cache-key, get_database, active-discovery-source) are injected so this
stays free of those globals.
- ``get_json`` is called INSIDE the try (like the original's
``request.get_json()``) so a malformed body yields the same 500.
Returns ``(payload, status_code)``.
NOT folded in: iTunes-Link (saves spotify_data directly via a different
cache signature), YouTube (multi-key original_track fallback), ListenBrainz
(entirely different unmatch-capable structure, no cache write), Beatport.
"""
try:
data = get_json()
identifier = data.get('identifier')
track_index = data.get('track_index')
spotify_track = data.get('spotify_track')
if not identifier or track_index is None or not spotify_track:
return {'error': 'Missing required fields'}, 400
state = states.get(identifier)
if not state:
return {'error': 'Discovery state not found'}, 404
if track_index >= len(state['discovery_results']):
return {'error': 'Invalid track index'}, 400
result = state['discovery_results'][track_index]
old_status = result.get('status')
result['status'] = 'Found'
result['status_class'] = 'found'
result['spotify_track'] = spotify_track['name']
result['spotify_artist'] = join_artist_names(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else extract_artist_name(spotify_track['artists'])
result['spotify_album'] = spotify_track['album']
result['spotify_id'] = spotify_track['id']
duration_ms = spotify_track.get('duration_ms', 0)
if duration_ms:
minutes = duration_ms // 60000
seconds = (duration_ms % 60000) // 1000
result['duration'] = f"{minutes}:{seconds:02d}"
else:
result['duration'] = '0:00'
result['spotify_data'] = build_fix_modal_spotify_data(spotify_track)
result['wing_it_fallback'] = False
result['manual_match'] = True
if old_status != 'found' and old_status != 'Found':
state['spotify_matches'] = state.get('spotify_matches', 0) + 1
logger.info(f"Manual match updated: {source_log_label} - {identifier} - track {track_index}")
logger.info(f"{result['spotify_artist']} - {result['spotify_track']}")
try:
original_track = result.get(original_track_key, {})
original_name = original_track.get('name', spotify_track['name'])
original_artist = original_artist_getter(original_track)
cache_key = get_discovery_cache_key(original_name, original_artist)
artists_list = spotify_track['artists']
if isinstance(artists_list, list):
artists_list = [a if isinstance(a, str) else a.get('name', '') for a in artists_list]
image_url = spotify_track.get('image_url') or ''
album_raw = spotify_track.get('album', '')
if isinstance(album_raw, dict):
album_obj = dict(album_raw)
if image_url and not album_obj.get('image_url'):
album_obj['image_url'] = image_url
if image_url and not album_obj.get('images'):
album_obj['images'] = [{'url': image_url}]
else:
album_obj = {'name': album_raw or ''}
if image_url:
album_obj['image_url'] = image_url
album_obj['images'] = [{'url': image_url}]
matched_data = {
'id': spotify_track['id'],
'name': spotify_track['name'],
'artists': artists_list,
'album': album_obj,
'duration_ms': spotify_track.get('duration_ms', 0),
'image_url': image_url,
'source': 'spotify',
}
cache_db = get_database()
cache_db.save_discovery_cache_match(
cache_key[0], cache_key[1], get_active_discovery_source(), 1.0, matched_data,
original_name, original_artist
)
logger.info(f"Manual fix saved to discovery cache: {original_name} by {original_artist}")
except Exception as cache_err:
logger.error(f"Error saving manual fix to discovery cache: {cache_err}")
return {'success': True, 'result': result}, 200
except Exception as e:
logger.error(f"Error updating {error_label} discovery match: {e}")
return {'error': str(e)}, 500
def start_sync(
states: Dict[str, Any],
key: str,
*,
sync_id_prefix: str,
not_found_message: str,
not_ready_message: str,
convert_fn,
playlist_name_getter,
playlist_image_getter,
activity_label: str,
error_label: str,
sync_lock: Any,
sync_states: Dict[str, Any],
active_sync_workers: Dict[str, Any],
submit_sync_task,
add_activity_item,
) -> Tuple[Dict[str, Any], int]:
"""Kick off a playlist sync from a source's discovered Spotify matches.
1:1 lift of the ``start_<source>_sync`` bodies for the five sources with
the identical flow (Tidal, Deezer, Qobuz, Spotify-Public, YouTube). The
per-source pieces are parameters:
- ``sync_id_prefix`` the ``f"{prefix}_{key}"`` sync id.
- ``convert_fn`` the source's discovery->spotify-tracks converter.
- ``playlist_name_getter`` / ``playlist_image_getter`` Tidal reads an
object (``.name`` / ``getattr``), the rest read a dict; lifted as the
``playlist_name_obj``/``playlist_image_obj`` vs ``playlist_name_strict``/
``playlist_image_dict`` accessors.
- ``activity_label`` vs ``error_label`` these DIFFER for Spotify-Public:
activity says "Spotify Link Sync Started" while logs say "Spotify Public".
- ``submit_sync_task(sync_playlist_id, playlist_name, spotify_tracks,
playlist_image_url) -> Future`` wraps sync_executor/_run_sync_task/
get_current_profile_id so this stays free of those globals.
Returns ``(payload, status_code)``.
NOT folded in: iTunes-Link (no final info log), ListenBrainz (submits the
task without an image arg), Beatport (extra debug logging, 'chart' key).
"""
try:
if key not in states:
return {"error": not_found_message}, 404
state = states[key]
state['last_accessed'] = time.time()
if state['phase'] not in ['discovered', 'sync_complete', 'download_complete']:
return {"error": not_ready_message}, 400
spotify_tracks = convert_fn(state['discovery_results'])
if not spotify_tracks:
return {"error": "No Spotify matches found for sync"}, 400
sync_playlist_id = f"{sync_id_prefix}_{key}"
playlist_name = playlist_name_getter(state)
add_activity_item("", f"{activity_label} Sync Started", f"'{playlist_name}' - {len(spotify_tracks)} tracks", "Now")
state['phase'] = 'syncing'
state['sync_playlist_id'] = sync_playlist_id
state['sync_progress'] = {}
with sync_lock:
sync_states[sync_playlist_id] = {"status": "starting", "progress": {}}
playlist_image_url = playlist_image_getter(state)
future = submit_sync_task(sync_playlist_id, playlist_name, spotify_tracks, playlist_image_url)
active_sync_workers[sync_playlist_id] = future
logger.info(f"Started {error_label} sync for: {playlist_name} ({len(spotify_tracks)} tracks)")
return {"success": True, "sync_playlist_id": sync_playlist_id}, 200
except Exception as e:
logger.error(f"Error starting {error_label} sync: {e}")
return {"error": str(e)}, 500

View file

@ -202,6 +202,34 @@ def get_transient_miss_threshold() -> int:
return DEFAULT_TRANSIENT_MISS_THRESHOLD
# How long to keep polling after the client reports terminal success
# but hasn't yet exposed a final save_path. Distinct from the
# transient-miss threshold because the two model different things:
# a transient miss is "the job vanished — fail fast (~10s) so a deleted
# job doesn't hang"; a completed-no-path read is "the download SUCCEEDED
# and the files are on disk — SAB just hasn't finished writing the
# ``storage`` field." The #706 fix reused the 5-poll (~10s) miss window
# here, but #721's own report shows SAB can take 2+ minutes (or, on some
# versions, never expose ``storage`` at all) — so a 10s window false-fails
# a download that actually completed. Expressed in SECONDS (converted to
# a poll count against the live interval) so it's interval-independent.
# Override via ``download_source.album_bundle_completed_no_path_seconds``.
DEFAULT_COMPLETED_NO_PATH_WINDOW_SECONDS = 120.0
def get_completed_no_path_window_seconds() -> float:
"""Return the completed-but-no-save_path tolerance window (seconds)."""
raw = config_manager.get('download_source.album_bundle_completed_no_path_seconds',
DEFAULT_COMPLETED_NO_PATH_WINDOW_SECONDS)
try:
value = float(raw)
if value > 0:
return value
except (TypeError, ValueError):
pass
return DEFAULT_COMPLETED_NO_PATH_WINDOW_SECONDS
class TransientMissCounter:
"""Bounded retry counter for adapter status reads.
@ -237,6 +265,7 @@ def poll_album_download(
failed_states: frozenset = frozenset(['failed']),
is_shutdown: Optional[Callable[[], bool]] = None,
transient_miss_threshold: int = DEFAULT_TRANSIENT_MISS_THRESHOLD,
completed_no_path_threshold: Optional[int] = None,
poll_interval: Optional[float] = None,
timeout: Optional[float] = None,
sleep: Callable[[float], None] = time.sleep,
@ -270,17 +299,51 @@ def poll_album_download(
'error' poll infinite-looped until the 6-hour timeout.
- ``transient_miss_threshold`` is the number of consecutive None /
'error' reads tolerated before declaring the job gone. Sized for
the SAB queuehistory gap window.
the SAB queuehistory gap window (~10s) a vanished job should
fail fast.
- ``completed_no_path_threshold`` is a SEPARATE, longer window for
the "client says complete but no save_path yet" case. The download
already succeeded, so this defaults to ~120s (configurable via
``download_source.album_bundle_completed_no_path_seconds``) instead
of reusing the 10s miss window #721 showed SAB can take 2+ minutes
to write ``storage``. When the window is exhausted the loop falls
back to the adapter's ``incomplete_path`` (the on-disk in-progress
dir) if present, and only emits terminal ``failed`` when there's no
path of any kind to scan.
Returns the adapter's reported save_path on terminal success, or
``None`` on any failure (timeout / disappeared / explicit failed
/ shutdown). On every failure path emits ``'failed'`` once with an
``error`` field describing why.
Returns the adapter's reported save_path (or, as a last resort, its
``incomplete_path``) on terminal success, or ``None`` on any failure
(timeout / disappeared / explicit failed / shutdown). On every
failure path emits ``'failed'`` once with an ``error`` field
describing why.
"""
interval = poll_interval if poll_interval is not None else get_poll_interval()
deadline = monotonic() + (timeout if timeout is not None else get_poll_timeout())
last_save_path: Optional[str] = None
last_incomplete_path: Optional[str] = None
misses = TransientMissCounter(transient_miss_threshold)
# Separate counter for "client reports terminal-success state but no
# save_path field has landed yet." SAB History flips ``status`` to
# 'Completed' a few seconds before its post-processing pipeline
# writes the final ``storage`` field — see issue #721 (Forty Licks
# stuck at 61%): SAB shows Completed in the UI, but
# ``_parse_history_slot`` returns ``save_path=None`` for those few
# seconds because ``storage`` isn't populated yet. Pre-fix the
# poll returned ``None`` on the first such read, the bundle
# plugin marked the batch failed, but the UI still displayed the
# last ``downloading`` progress emit.
#
# This window is intentionally LONGER than the transient-miss window:
# the download already SUCCEEDED, so being patient here is cheap and
# correct, whereas the original 5-poll (~10s) reuse false-failed real
# completions (#721 reported SAB taking 2+ minutes). Default ~120s,
# converted from seconds to a poll count against the live interval.
if completed_no_path_threshold is None:
completed_no_path_threshold = max(
transient_miss_threshold,
int(get_completed_no_path_window_seconds() / max(interval, 0.001)) or 1,
)
completed_no_path_misses = TransientMissCounter(completed_no_path_threshold)
def _fail(reason: str) -> None:
try:
@ -288,6 +351,16 @@ def poll_album_download(
except Exception as cb_exc:
logger.debug("%s terminal emit failed: %s", log_prefix, cb_exc)
# Heartbeat so the otherwise-silent download loop is diagnosable.
# The loop emits progress to the UI on every poll but logs nothing
# during normal operation — which made the #721 "stuck at N%" reports
# impossible to triage from logs alone (we couldn't tell if the poll
# was alive, what state SAB returned, or whether it had wedged). Log
# the raw adapter read at most once per heartbeat interval.
HEARTBEAT_SECONDS = 30.0
last_heartbeat = monotonic()
poll_count = 0
while monotonic() < deadline:
if is_shutdown and is_shutdown():
# Shutdown is a clean exit — don't paint failure on the UI;
@ -300,6 +373,21 @@ def poll_album_download(
logger.warning("%s Poll error: %s", log_prefix, e)
status = None
poll_count += 1
now = monotonic()
if now - last_heartbeat >= HEARTBEAT_SECONDS:
last_heartbeat = now
if status is None:
logger.info("%s '%s' poll #%d: client returned no status (miss %d/%d)",
log_prefix, title, poll_count, misses.misses, misses.threshold)
else:
logger.info(
"%s '%s' poll #%d: state=%r progress=%.2f save_path=%r",
log_prefix, title, poll_count,
getattr(status, 'state', None), getattr(status, 'progress', 0.0) or 0.0,
getattr(status, 'save_path', None),
)
if status is None:
if misses.record_miss():
logger.error(
@ -322,9 +410,58 @@ def poll_album_download(
speed=status.download_speed)
if status.save_path:
last_save_path = status.save_path
# Remember the in-progress dir too — never used on a normal
# completion, only as the last-resort fallback below when the
# final save_path provably never lands.
incomplete_path = getattr(status, 'incomplete_path', None)
if incomplete_path:
last_incomplete_path = incomplete_path
if status.state in complete_states:
return last_save_path
if last_save_path:
completed_no_path_misses.reset()
return last_save_path
# Terminal-success state but no save_path landed yet.
# SAB History flips ``Completed`` a few seconds before
# ``storage`` is populated — give the adapter a generous
# window before declaring this a hard failure. Without this
# tolerance, every TAR / unrar-bearing usenet release
# would race the path-write window and randomly fail.
if completed_no_path_misses.record_miss():
# Last resort before failing: SAB finished and the files
# are physically on disk (#721), but the final ``storage``
# field never landed. Fall back to the in-progress dir so
# the bundle can still scan + stage the audio, rather than
# leaving the user stuck with a completed-in-SAB download
# that SoulSync never imports.
if last_incomplete_path:
logger.warning(
"%s '%s' completed on the client but never exposed a final "
"save_path after %d polls — falling back to the in-progress "
"path %r as a last resort. If staging fails, the SAB job "
"likely needs its post-process move to finish first.",
log_prefix, title, completed_no_path_misses.misses,
last_incomplete_path,
)
return last_incomplete_path
logger.error(
"%s '%s' reported terminal success but no save_path landed "
"after %d consecutive polls — bundle cannot stage. Adapter "
"may need new history-slot fallback fields (storage / path "
"/ download_path / dirname). Last status: state=%r progress=%r",
log_prefix, title, completed_no_path_misses.misses,
status.state, status.progress,
)
_fail('Client reported success but never provided a save_path')
return None
logger.info(
"%s '%s' is %s on the client but save_path not yet set — "
"retrying (poll %d/%d)",
log_prefix, title, status.state,
completed_no_path_misses.misses, completed_no_path_misses.threshold,
)
sleep(interval)
continue
if status.state in failed_states:
error = getattr(status, 'error', None) or 'Client reported failure'
logger.error("%s '%s' failed: %s", log_prefix, title, error)
@ -350,6 +487,111 @@ def poll_album_download(
return None
def _candidate_download_roots(config_get: Callable[..., Any]) -> list:
"""Directories where THIS process can read finished downloads — used by
``resolve_reported_save_path`` for the basename fallback.
Order matters: most-specific usenet/torrent roots first, then the
general Soulseek download / transfer dirs, which in the standard
shared-volume arr setup are bind-mounted to the very directory the
usenet client writes its completed downloads into. Relative values
(e.g. ``./downloads``) resolve against the process CWD the
container's ``/app`` — which is exactly where those mounts live.
"""
roots: list = []
for key in (
'download_source.usenet_download_path',
'usenet_client.completed_path',
'usenet_client.download_path',
'download_source.torrent_download_path',
'soulseek.download_path',
'soulseek.transfer_path',
):
value = config_get(key, None)
if value:
roots.append(str(value))
seen: set = set()
out: list = []
for root in roots:
if root not in seen:
seen.add(root)
out.append(root)
return out
def resolve_reported_save_path(
reported_path: Optional[str],
config_get: Optional[Callable[..., Any]] = None,
) -> Optional[str]:
"""Translate a downloader-reported save_path into one THIS process can read.
Usenet / torrent clients report paths from inside THEIR OWN container
(e.g. SAB hands back ``/data/downloads/music/<album>``); SoulSync often
mounts the very same files at a different point (``/app/downloads/<album>``).
Feeding the client's path straight to the audio walker then yields
"No audio files found" even though the files are physically present
the classic arr-stack remote-path mismatch.
Resolution order:
1. The reported path verbatim, if it's a readable directory here
(deployments that mirror the client's mount paths).
2. Explicit prefix mappings from ``download_source.usenet_path_mappings``
a list of ``{"from": "...", "to": "..."}`` (Sonarr/Radarr-style
remote path mapping) for non-shared / oddly-mounted layouts.
3. Basename fallback: a same-named folder under a known SoulSync
download root. Zero-config for the standard shared-volume setup
the album folder shows up under SoulSync's own ``./downloads``
mount with the same name the client reported.
Returns the best resolved path, or ``reported_path`` unchanged when
nothing better is found (so the caller's existing "no audio" error still
surfaces, with both paths logged).
"""
if not reported_path:
return reported_path
if config_get is None:
config_get = config_manager.get
def _is_dir(candidate) -> bool:
try:
return Path(candidate).is_dir()
except OSError:
return False
# 1. Reported path is directly readable — mounts already line up.
if _is_dir(reported_path):
return reported_path
normalized = str(reported_path).replace('\\', '/')
# 2. Explicit prefix mappings (remote-path-mapping escape hatch).
mappings = config_get('download_source.usenet_path_mappings', None) or []
if isinstance(mappings, (list, tuple)):
for mapping in mappings:
if not isinstance(mapping, dict):
continue
frm = str(mapping.get('from') or '').replace('\\', '/').rstrip('/')
to = str(mapping.get('to') or '')
if not frm or not to:
continue
if normalized == frm or normalized.startswith(frm + '/'):
rest = normalized[len(frm):].lstrip('/')
candidate = str(Path(to) / rest) if rest else to
if _is_dir(candidate):
return candidate
# 3. Basename fallback under known download roots — covers the standard
# shared-volume layout with zero configuration.
basename = Path(normalized).name
if basename:
for root in _candidate_download_roots(config_get):
candidate = Path(root) / basename
if _is_dir(candidate):
return str(candidate)
return reported_path
def copy_audio_files_atomically(
sources: Iterable[Path], staging_dir: Path,
) -> list:
@ -380,12 +622,15 @@ __all__ = [
"DEFAULT_POLL_INTERVAL_SECONDS",
"DEFAULT_POLL_TIMEOUT_SECONDS",
"DEFAULT_TRANSIENT_MISS_THRESHOLD",
"DEFAULT_COMPLETED_NO_PATH_WINDOW_SECONDS",
"TransientMissCounter",
"atomic_copy_to_staging",
"copy_audio_files_atomically",
"get_completed_no_path_window_seconds",
"get_poll_interval",
"get_poll_timeout",
"get_transient_miss_threshold",
"resolve_reported_save_path",
"pick_best_album_release",
"poll_album_download",
"quality_score",

View file

@ -65,6 +65,7 @@ from core.download_plugins.album_bundle import (
get_poll_timeout,
pick_best_album_release,
poll_album_download,
resolve_reported_save_path,
)
from core.download_plugins.base import DownloadSourcePlugin
from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult
@ -354,13 +355,21 @@ class TorrentDownloadPlugin(DownloadSourcePlugin):
if not save_path:
self._mark_error(download_id, "Torrent completed but no save_path reported")
return
# Resolve the client-reported path to one this process can read
# (the client may report its own container's mount). See
# ``resolve_reported_save_path``.
local_path = resolve_reported_save_path(save_path)
if local_path != save_path:
logger.info("Torrent %s: resolved client path %r -> %r",
download_id[:8], save_path, local_path)
try:
audio_files = collect_audio_after_extraction(Path(save_path))
audio_files = collect_audio_after_extraction(Path(local_path))
except Exception as e:
self._mark_error(download_id, f"Post-extract walk failed: {e}")
return
if not audio_files:
self._mark_error(download_id, f"No audio files found in {save_path}")
suffix = f" (resolved: {local_path})" if local_path != save_path else ""
self._mark_error(download_id, f"No audio files found in {save_path}{suffix}")
return
primary = audio_files[0]
with self._lock:
@ -539,13 +548,18 @@ class TorrentDownloadPlugin(DownloadSourcePlugin):
# Phase 4: extract + walk + copy to staging.
_emit('staging', release=picked.title)
# Resolve the client-reported path to one this process can read.
local_path = resolve_reported_save_path(save_path)
if local_path != save_path:
logger.info("[Torrent album] Resolved client path %r -> %r", save_path, local_path)
try:
audio_files = collect_audio_after_extraction(Path(save_path))
audio_files = collect_audio_after_extraction(Path(local_path))
except Exception as e:
result['error'] = f'Failed to walk audio files: {e}'
return result
if not audio_files:
result['error'] = f'No audio files found in {save_path}'
suffix = f' (resolved: {local_path})' if local_path != save_path else ''
result['error'] = f'No audio files found in {save_path}{suffix}'
return result
copied = copy_audio_files_atomically(audio_files, Path(staging_dir))

View file

@ -24,8 +24,10 @@ from core.archive_pipeline import collect_audio_after_extraction
from core.download_plugins.album_bundle import (
TransientMissCounter,
copy_audio_files_atomically,
get_completed_no_path_window_seconds,
pick_best_album_release,
poll_album_download,
resolve_reported_save_path,
)
from core.download_plugins.base import DownloadSourcePlugin
from core.download_plugins.torrent import (
@ -229,11 +231,22 @@ class UsenetDownloadPlugin(DownloadSourcePlugin):
deadline = time.monotonic() + _POLL_TIMEOUT_SECONDS
last_save_path: Optional[str] = None
last_incomplete_path: Optional[str] = None
# Tolerate transient None / unmapped 'error' reads — SAB
# removes a job from the queue before adding it to history,
# and on busy servers that gap spans several polls. See
# ``album_bundle.TransientMissCounter`` for the shared rule.
misses = TransientMissCounter()
# Separate, LONGER window for "SAB says completed but hasn't
# written the final save_path yet" — the per-track sibling of the
# bundle fix (#721). Without this the thread called
# ``_finalize_download(None)`` on the first Completed-no-path read
# and errored a download that actually succeeded in SAB. Default
# ~120s, converted to a poll count against the live interval.
completed_no_path_misses = TransientMissCounter(
max(misses.threshold,
int(get_completed_no_path_window_seconds() / max(_POLL_INTERVAL_SECONDS, 0.001)) or 1)
)
while time.monotonic() < deadline:
if self.shutdown_check and self.shutdown_check():
return
@ -267,10 +280,41 @@ class UsenetDownloadPlugin(DownloadSourcePlugin):
row['error'] = status.error
if status.save_path:
last_save_path = status.save_path
incomplete_path = getattr(status, 'incomplete_path', None)
if incomplete_path:
last_incomplete_path = incomplete_path
if status.state in _COMPLETE_STATES:
self._finalize_download(download_id, last_save_path)
return
if last_save_path:
self._finalize_download(download_id, last_save_path)
return
# Completed but no final save_path yet — SAB flips
# History to 'Completed' before writing ``storage``.
# Wait out the (longer) completed-no-path window rather
# than erroring a download that actually succeeded.
if completed_no_path_misses.record_miss():
if last_incomplete_path:
logger.warning(
"Usenet %s: '%s' completed but no final save_path after "
"%d polls — falling back to in-progress path %r",
download_id[:8], job_id, completed_no_path_misses.misses,
last_incomplete_path,
)
self._finalize_download(download_id, last_incomplete_path)
return
self._mark_error(
download_id,
"Usenet job completed but client never reported a save_path",
)
return
logger.info(
"Usenet %s: '%s' completed on client but save_path not yet set — "
"retrying (poll %d/%d)",
download_id[:8], job_id,
completed_no_path_misses.misses, completed_no_path_misses.threshold,
)
time.sleep(_POLL_INTERVAL_SECONDS)
continue
if status.state == 'failed':
self._mark_error(download_id, status.error or "Usenet client reported failure")
return
@ -294,13 +338,21 @@ class UsenetDownloadPlugin(DownloadSourcePlugin):
if not save_path:
self._mark_error(download_id, "Usenet job completed but no save_path reported")
return
# Translate the client-reported path to one THIS process can read
# (SAB reports its own container path; SoulSync may see the same
# files at a different mount). See ``resolve_reported_save_path``.
local_path = resolve_reported_save_path(save_path)
if local_path != save_path:
logger.info("Usenet %s: resolved client path %r -> %r",
download_id[:8], save_path, local_path)
try:
audio_files = collect_audio_after_extraction(Path(save_path))
audio_files = collect_audio_after_extraction(Path(local_path))
except Exception as e:
self._mark_error(download_id, f"Post-extract walk failed: {e}")
return
if not audio_files:
self._mark_error(download_id, f"No audio files found in {save_path}")
suffix = f" (resolved: {local_path})" if local_path != save_path else ""
self._mark_error(download_id, f"No audio files found in {save_path}{suffix}")
return
primary = audio_files[0]
with self._lock:
@ -454,13 +506,19 @@ class UsenetDownloadPlugin(DownloadSourcePlugin):
return result
_emit('staging', release=picked.title)
# SAB reports its own container path; SoulSync may mount the same
# files elsewhere. Resolve to a locally-readable path before walking.
local_path = resolve_reported_save_path(save_path)
if local_path != save_path:
logger.info("[Usenet album] Resolved client path %r -> %r", save_path, local_path)
try:
audio_files = collect_audio_after_extraction(Path(save_path))
audio_files = collect_audio_after_extraction(Path(local_path))
except Exception as e:
result['error'] = f'Failed to walk audio files: {e}'
return result
if not audio_files:
result['error'] = f'No audio files found in {save_path}'
suffix = f' (resolved: {local_path})' if local_path != save_path else ''
result['error'] = f'No audio files found in {save_path}{suffix}'
return result
copied = copy_audio_files_atomically(audio_files, Path(staging_dir))

View file

@ -31,11 +31,18 @@ testable without touching live runtime state.
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, Callable, Optional, Protocol
logger = logging.getLogger(__name__)
from utils.logging_config import get_logger
# Use the project logger factory so these lines land in app.log under the
# ``soulsync.*`` namespace the file handler captures. Plain
# ``logging.getLogger(__name__)`` logs to the console only (the file
# handler is attached to the ``soulsync`` logger), which is why
# ``[Album Bundle] flow failed`` showed up in the terminal but never in
# app.log during the #721 triage.
logger = get_logger("downloads.album_bundle_dispatch")
class BatchStateAccess(Protocol):

View file

@ -5,7 +5,6 @@ The class body is byte-identical to the original. Module-level globals
helpers and orchestrator handles. ``IS_SHUTTING_DOWN`` is a module-level
flag mirrored from web_server's own flag in ``_shutdown_runtime_components``.
"""
import logging
import threading
import time
@ -18,8 +17,10 @@ from core.runtime_state import (
tasks_lock,
)
from utils.async_helpers import run_async
from utils.logging_config import get_logger
logger = logging.getLogger(__name__)
# Project logger factory so these lines reach app.log (soulsync.* namespace).
logger = get_logger("downloads.monitor")
# Mirrored from web_server.IS_SHUTTING_DOWN via _shutdown_runtime_components.
IS_SHUTTING_DOWN = False

View file

@ -20,7 +20,6 @@ are passed via `StatusDeps` so the module is web_server-import-free.
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass
@ -32,8 +31,10 @@ from core.runtime_state import (
download_tasks,
tasks_lock,
)
from utils.logging_config import get_logger
logger = logging.getLogger(__name__)
# Project logger factory so these lines reach app.log (soulsync.* namespace).
logger = get_logger("downloads.status")
def _schedule_completion_callback(deps, batch_id: str, task_id: str, success: bool) -> None:

View file

@ -38,6 +38,26 @@ def _get_config_manager():
return _FallbackConfig()
def _extract_year_from_release_date(release_date) -> str:
"""Return a validated 4-digit year from a release_date, or '' .
The ``$year`` template variable used to be a blind ``release_date[:4]``
slice. When something upstream poisons ``release_date`` with a non-date
value (e.g. the album NAME #745, where "Mantras (Deluxe)" produced a
"(Mant)" folder), that slice happily emitted garbage. Validate that the
leading 4 chars are a plausible year, matching the guard the rest of the
codebase already uses (see ``soulid_worker._extract_year``). Anything
that isn't a real year resolves to '' — the template's bracket-cleanup
then drops the empty ``()`` instead of writing rubbish into the path.
"""
if not release_date:
return ""
candidate = str(release_date)[:4]
if candidate.isdigit() and 1900 < int(candidate) <= 2100:
return candidate
return ""
def _get_itunes_client():
try:
from core.metadata_service import get_itunes_client
@ -426,9 +446,7 @@ def build_final_path_for_track(context, artist_context, album_info, file_ext):
year = ""
if album_context and album_context.get("release_date"):
release_date = album_context["release_date"]
if release_date and len(release_date) >= 4:
year = release_date[:4]
year = _extract_year_from_release_date(album_context["release_date"])
raw_album_type = ""
if album_context:

View file

@ -956,6 +956,17 @@ def preview_album_reorganize(
'disc_number': None,
}
# #746: never reorganize files sitting in the duplicate-cleaner
# quarantine (<transfer>/deleted). Surface as a non-matched skip so
# the preview shows WHY and apply leaves them put. Checked before the
# matched branch so a quarantined track that happens to match the API
# tracklist is still skipped.
if _is_in_deleted_quarantine(resolved, transfer_dir):
item['matched'] = False
item['reason'] = 'In deleted/quarantine folder — skipped'
preview_tracks.append(item)
continue
if not plan_item['matched']:
preview_tracks.append(item)
continue
@ -1011,6 +1022,42 @@ def preview_album_reorganize(
}
def _is_in_deleted_quarantine(resolved_path, transfer_dir) -> bool:
"""True when ``resolved_path`` lives inside the duplicate-cleaner
quarantine folder (``<transfer_dir>/deleted/...``).
The Duplicate Cleaner (``core/library/duplicate_cleaner.py``) moves
de-duplicated files into ``<transfer_dir>/deleted/``. If the user's
media server scans the transfer folder (e.g. a ``/music`` root that
contains both the library and the transfer dir), those quarantined
files get real rows in SoulSync's DB — and Reorganize, being purely
DB-driven, would otherwise dutifully move them back OUT of /deleted
to the template location. This guard makes Reorganize skip them so
the quarantine stays quarantined (#746).
Anchored to ``<transfer_dir>/deleted`` specifically so a legitimately
named artist/album like "Deleted" elsewhere in the library is NOT
skipped. When ``transfer_dir`` is unavailable we fall back to an exact
``deleted`` path-SEGMENT match (mirrors the cleaner's own
``if 'deleted' in dirs`` skip) never a substring, so "Undeleted"
or "deleted scenes" stay safe.
"""
if not resolved_path:
return False
def _norm(p):
# normpath collapses redundant separators / '..'; normcase applies
# the platform's case rule (lowercases on Windows, no-op on posix);
# fold to '/' so the segment/prefix checks are separator-agnostic.
return os.path.normcase(os.path.normpath(p)).replace('\\', '/')
norm = _norm(resolved_path)
if transfer_dir:
quarantine = _norm(os.path.join(transfer_dir, 'deleted'))
return norm == quarantine or norm.startswith(quarantine + '/')
return 'deleted' in norm.split('/')
def _trim_to_transfer(db_path, resolved, transfer_dir):
"""Compose the user-facing 'current path' string — relative to the
transfer dir if the file lives there, else the raw DB value."""
@ -1094,6 +1141,7 @@ class _RunContext:
update_track_path_fn: Optional[Callable[[Any, str], None]] = None
on_progress: Optional[Callable[[dict], None]] = None
stop_check: Optional[Callable[[], bool]] = None
transfer_dir: Optional[str] = None # anchors the #746 /deleted-quarantine skip
def emit(self, **updates) -> None:
"""Fire the progress callback. Caller is responsible for
@ -1271,6 +1319,15 @@ def _process_one_track(ctx: _RunContext, plan_item: dict) -> None:
f"File not found on disk — DB path: {db_path or '(empty)'}")
return
# #746: leave duplicate-cleaner quarantine files (<transfer>/deleted)
# where they are. Matches the preview's skip so apply never yanks a file
# back out of /deleted. (Mirrors the preview guard in
# preview_album_reorganize.)
if _is_in_deleted_quarantine(resolved_src, ctx.transfer_dir):
ctx.record_error(track_id, title,
'In deleted/quarantine folder — skipped')
return
staging_file = _stage_track(ctx, track_id, title, resolved_src)
if staging_file is None:
return
@ -1470,6 +1527,7 @@ def reorganize_album(
update_track_path_fn=update_track_path_fn,
on_progress=on_progress,
stop_check=stop_check,
transfer_dir=transfer_dir,
)
try:

View file

@ -74,7 +74,22 @@ class MusicMatchingEngine:
# Skip unidecode for CJK text — it converts Japanese kanji to Chinese pinyin,
# producing gibberish like "tvanimedei" for "命の灯火". Preserve original characters
# so Soulseek searches use the real title. Only apply unidecode to non-CJK text.
if any('\u2e80' <= c <= '\u9fff' or '\u3040' <= c <= '\u30ff' or '\uff00' <= c <= '\uffef' or '\uac00' <= c <= '\ud7af' for c in text):
# Issue #722 — flag CJK presence here so the alphanumeric strip
# below preserves CJK ranges instead of nuking them. Pre-fix the
# strip pattern ``[^a-z0-9\s$]`` deleted every CJK character,
# which left every Japanese title normalised to ``''``. Two empty
# strings produce 0.0 title similarity, the matcher fell back to
# duration+artist alone, and multiple iTunes tracks mapped to the
# same Tidal candidate, so the user got duplicate downloads under
# different track positions.
has_cjk = any(
'\u2e80' <= c <= '\u9fff' # CJK Unified Ideographs + radicals
or '\u3040' <= c <= '\u30ff' # Hiragana + Katakana
or '\uff00' <= c <= '\uffef' # Halfwidth / Fullwidth forms
or '\uac00' <= c <= '\ud7af' # Hangul syllables
for c in text
)
if has_cjk:
# CJK detected — just lowercase, don't transliterate
text = text.lower()
else:
@ -103,7 +118,17 @@ class MusicMatchingEngine:
text = re.sub(r'[._/&-]', ' ', text)
# Keep alphanumeric characters, spaces, AND the '$' sign.
text = re.sub(r'[^a-z0-9\s$]', '', text)
# When CJK was detected upstream, also preserve CJK Unified
# Ideographs / Hiragana / Katakana / Hangul / Halfwidth-Fullwidth
# ranges so Japanese / Chinese / Korean titles produce a
# comparable normalised form instead of an empty string.
if has_cjk:
text = re.sub(
r'[^a-z0-9\s$\u2e80-\u9fff\u3040-\u30ff\uff00-\uffef\uac00-\ud7af]',
'', text,
)
else:
text = re.sub(r'[^a-z0-9\s$]', '', text)
# Consolidate multiple spaces into one
text = re.sub(r'\s+', ' ', text).strip()

View file

@ -256,6 +256,80 @@ def _browser_safe_image_url(url: str) -> str:
return url
def _upgrade_art_url(art_url: str) -> str:
"""Rewrite a source CDN art URL to the highest resolution that source
serves, so embedded tag art is as sharp as the cover.jpg in the folder.
- Spotify (i.scdn.co): request the original uploaded master (~2000px+).
- iTunes (mzstatic.com): bump the size segment to 3000x3000.
- Deezer (dzcdn): rewrite to 1900x1900 (CDN serves larger than the API's
1000px cover_xl).
Unrecognized URLs are returned unchanged. Both the embed and cover.jpg
paths call this so the two never diverge in quality again.
"""
if not art_url:
return art_url
if "i.scdn.co" in art_url:
try:
from core.spotify_client import _upgrade_spotify_image_url
return _upgrade_spotify_image_url(art_url)
except Exception as e:
logger.debug("upgrade spotify image url failed: %s", e)
elif "mzstatic.com" in art_url:
return re.sub(r"\d+x\d+bb", "3000x3000bb", art_url)
elif "dzcdn" in art_url:
try:
from core.deezer_client import _upgrade_deezer_cover_url
return _upgrade_deezer_cover_url(art_url)
except Exception as e:
logger.debug("upgrade deezer image url failed: %s", e)
elif "coverartarchive.org" in art_url:
# MusicBrainz art arrives as Cover Art Archive thumbnails
# (/front-250 — see musicbrainz_search._cover_art_url). Upgrade to the
# 1200px thumbnail: a huge jump from 240p yet still served by CAA's own
# CDN. Deliberately NOT the bare /front original — that redirects to
# archive.org, which is flaky (intermittent 500s/timeouts) and can be
# multi-MB, nasty to embed in every track. 1200 is the sweet spot of
# quality + reliability. `_fetch_art_bytes` falls back to the original
# sized URL if /front-1200 is ever refused.
return re.sub(r"/front-\d+", "/front-1200", art_url)
return art_url
def _fetch_art_bytes(art_url: str):
"""Fetch artwork bytes at the highest resolution the source serves.
Upgrades the URL via `_upgrade_art_url`, then fetches. If the upgraded
(larger) size is refused by the CDN, retry once with the original URL so
we never regress vs. the un-upgraded behavior. Empirically the upgrade
works for every album tested; the fallback just defends the edge case.
Returns `(image_data, mime_type)` or `(None, None)` on failure.
"""
if not art_url:
return None, None
upgraded = _upgrade_art_url(art_url)
try:
with urllib.request.urlopen(upgraded, timeout=10) as response:
return response.read(), (response.info().get_content_type() or "image/jpeg")
except Exception as fetch_err:
if upgraded != art_url:
logger.info(
"Upgraded art URL refused (%s); retrying original size", fetch_err
)
try:
with urllib.request.urlopen(art_url, timeout=10) as response:
return response.read(), (response.info().get_content_type() or "image/jpeg")
except Exception as retry_err:
logger.error("Art fetch failed after fallback: %s", retry_err)
return None, None
logger.error("Art fetch failed: %s", fetch_err)
return None, None
def embed_album_art_metadata(audio_file, metadata: dict):
cfg = get_config_manager()
symbols = get_mutagen_symbols()
@ -269,7 +343,8 @@ def embed_album_art_metadata(audio_file, metadata: dict):
release_mbid = metadata.get("musicbrainz_release_id")
if release_mbid and cfg.get("metadata_enhancement.prefer_caa_art", False):
try:
caa_url = f"https://coverartarchive.org/release/{release_mbid}/front"
# 1200px CDN thumbnail, not the flaky bare /front original.
caa_url = f"https://coverartarchive.org/release/{release_mbid}/front-1200"
req = urllib.request.Request(caa_url, headers={"Accept": "image/*"})
with urllib.request.urlopen(req, timeout=10) as response:
image_data = response.read()
@ -284,9 +359,7 @@ def embed_album_art_metadata(audio_file, metadata: dict):
if not art_url:
logger.warning("No album art URL available for embedding.")
return
with urllib.request.urlopen(art_url, timeout=10) as response:
image_data = response.read()
mime_type = response.info().get_content_type() or "image/jpeg"
image_data, mime_type = _fetch_art_bytes(art_url)
if not image_data:
logger.error("Failed to download album art data.")
@ -341,7 +414,8 @@ def download_cover_art(album_info: dict, target_dir: str, context: dict = None):
image_data = None
if release_mbid and prefer_caa:
try:
caa_url = f"https://coverartarchive.org/release/{release_mbid}/front"
# 1200px CDN thumbnail, not the flaky bare /front original.
caa_url = f"https://coverartarchive.org/release/{release_mbid}/front-1200"
req = urllib.request.Request(caa_url, headers={"Accept": "image/*"})
with urllib.request.urlopen(req, timeout=10) as response:
image_data = response.read()
@ -365,59 +439,13 @@ def download_cover_art(album_info: dict, target_dir: str, context: dict = None):
art_url = images[0].get("url", "")
if art_url:
logger.info("Using cover art URL from album context")
if art_url and "i.scdn.co" in art_url:
try:
from core.spotify_client import _upgrade_spotify_image_url
art_url = _upgrade_spotify_image_url(art_url)
except Exception as e:
logger.debug("upgrade spotify image url failed: %s", e)
elif art_url and "mzstatic.com" in art_url:
art_url = re.sub(r"\d+x\d+bb", "3000x3000bb", art_url)
elif art_url and "dzcdn" in art_url:
# Deezer's API returns cover_xl URLs at 1000×1000 but
# the underlying CDN serves up to 1900×1900 by rewriting
# the size segment in the URL path. Without this upgrade
# users embedding cover art via Deezer get visibly
# blurry covers in their library / phone player (Discord
# report from Tim, 2026-05). Same shape as the iTunes
# mzstatic upgrade above + Spotify scdn upgrade.
try:
from core.deezer_client import _upgrade_deezer_cover_url
art_url = _upgrade_deezer_cover_url(art_url)
except Exception as e:
logger.debug("upgrade deezer image url failed: %s", e)
if not art_url:
logger.warning("No cover art URL available for download.")
return
# Fetch with one fallback level: if we upgraded a Deezer
# URL above and the CDN happens to refuse the larger size
# for this specific album, retry with the original URL so
# we never regress vs. pre-upgrade behavior. Empirically
# 1900 works for every album tested but defending against
# the edge case keeps the fix strictly non-regressive.
original_url = album_info.get("album_image_url")
if context and not original_url:
album_ctx = get_import_context_album(context)
original_url = album_ctx.get("image_url") or original_url
try:
with urllib.request.urlopen(art_url, timeout=10) as response:
image_data = response.read()
except Exception as fetch_err:
if (
"dzcdn" in art_url
and original_url
and original_url != art_url
):
logger.info(
"Deezer CDN refused upgraded cover URL (%s); "
"retrying with original size", fetch_err,
)
with urllib.request.urlopen(original_url, timeout=10) as response:
image_data = response.read()
else:
raise
# Upgrade to the source's highest resolution (Spotify master /
# iTunes 3000 / Deezer 1900) with a one-level fallback — shared
# with the tag-embed path so cover.jpg and embedded art match.
image_data, _ = _fetch_art_bytes(art_url)
if not image_data:
return

View file

@ -1032,11 +1032,19 @@ def extract_source_metadata(context: dict, artist: dict, album_info: dict) -> di
album_artists = album_ctx.get("artists", [])
if album_artists:
first_album_artist = album_artists[0]
if isinstance(first_album_artist, dict) and first_album_artist.get("name"):
raw_album_artist = first_album_artist["name"]
elif isinstance(first_album_artist, str) and first_album_artist:
raw_album_artist = first_album_artist
album_artists_for_collab = album_artists
if isinstance(first_album_artist, dict):
candidate = first_album_artist.get("name", "")
elif isinstance(first_album_artist, str):
candidate = first_album_artist
else:
candidate = ""
# An unresolved "Unknown Artist" placeholder from the album context
# must NOT clobber the real track artist already in raw_album_artist
# (bug #735: album-artist tag overwritten to "Unknown Artist" on
# import). Only override when the album context names a real artist.
if candidate and candidate != "Unknown Artist":
raw_album_artist = candidate
album_artists_for_collab = album_artists
collab_mode = cfg.get("file_organization.collab_artist_mode", "first")
if collab_mode == "first" and raw_album_artist:

View file

@ -1131,30 +1131,59 @@ class MusicBrainzSearchClient:
}
def get_artist_albums(self, artist_mbid: str, album_type: str = 'album,single', limit: int = 200) -> List:
"""Get artist's releases for discography view."""
"""Get an artist's full discography for the artist-detail view.
Walks the paginated browse endpoint (`/release-group?artist=<mbid>`)
instead of the artist lookup's embedded release-groups. The lookup
(`/artist/<mbid>?inc=release-groups`) is hard-capped at 25 release-
groups by MusicBrainz and ignores the `limit` param entirely so
prolific artists had ~85% of their catalogue silently dropped. That
was the bug Sokhi reported: "a lot of albums are missing vs what's
showing on the site" (e.g. Kendrick Lamar — 167 release-groups on
MB, only the first 25 ever reached SoulSync).
Unlike the search path there is NO `type` filter and NO studio-only
filter here: the artist-detail page wants the *whole* catalogue
albums, EPs, singles, compilations, soundtracks, live so its tabs
mirror musicbrainz.org. `_release_group_to_album` maps each release-
group's primary/secondary types into the right bucket.
"""
try:
artist = self._client.get_artist(artist_mbid, includes=['release-groups'])
if not artist or 'release-groups' not in artist:
return []
# Lightweight lookup purely for the canonical artist name — the
# browse endpoint doesn't carry it. Non-fatal: on failure each
# Album falls back to 'Unknown Artist' via _release_group_to_album.
artist = self._client.get_artist(artist_mbid)
artist_name = (artist or {}).get('name', '') or ''
albums = []
for rg in artist.get('release-groups', []):
primary_type = rg.get('primary-type', '') or ''
rg_type = _map_release_type(primary_type, rg.get('secondary-types', []))
rg_mbid = rg.get('id', '')
image_url = self._cached_art(rg_mbid, rg_mbid)
albums.append(Album(
id=rg_mbid,
name=rg.get('title', ''),
artists=[artist.get('name', 'Unknown Artist')],
release_date=rg.get('first-release-date', '') or '',
total_tracks=0,
album_type=rg_type,
image_url=image_url,
external_urls={'musicbrainz': f'https://musicbrainz.org/release-group/{rg_mbid}'},
))
page_size = 100 # MusicBrainz browse hard cap per page
albums: List[Album] = []
seen: set = set()
offset = 0
while len(albums) < limit:
page = self._client.browse_artist_release_groups(
artist_mbid,
# No type filter — fetch every primary type. Secondary
# types (Compilation/Soundtrack/Live) ride along on their
# Album/Single/EP parent and are bucketed downstream.
# (Filtering by type=compilation is intentionally avoided:
# it's a secondary type and silently breaks MB's filter —
# see browse_artist_release_groups docs.)
release_types=None,
limit=page_size,
offset=offset,
)
if not page:
break
for rg in page:
rg_mbid = rg.get('id', '')
if rg_mbid and rg_mbid in seen:
continue
if rg_mbid:
seen.add(rg_mbid)
albums.append(self._release_group_to_album(rg, artist_name))
if len(page) < page_size:
break # last page reached
offset += page_size
return albums[:limit]
except Exception as e:
logger.warning(f"MusicBrainz artist albums failed: {e}")

View file

@ -1,28 +1,59 @@
"""Basic Soulseek file search — flat list of file results sorted by quality.
"""Basic download-source file search — flat list of file results sorted by quality.
Used by the Soulseek source icon in the unified search UI and by direct
/api/search calls. Synchronous wrapper around the async soulseek client.
Used by the basic search UI on the Search page and by ``/api/search``.
``run_basic_search`` replaced ``run_basic_soulseek_search`` so the caller
can target any active download source (not just slskd). The old name is
kept as a thin alias for backwards compat with any callers outside this
module.
"""
from __future__ import annotations
import logging
from typing import Callable
from typing import Callable, Optional
logger = logging.getLogger(__name__)
def run_basic_soulseek_search(
def run_basic_search(
query: str,
download_orchestrator,
run_async: Callable,
*,
source: Optional[str] = None,
) -> list[dict]:
"""Search Soulseek for `query`, normalize albums + tracks to one sorted list.
"""Search ``source`` (or the active/first hybrid source) for ``query``.
Returns dicts with `result_type` set to "album" or "track" and sorted by
`quality_score` descending. Empty list on any failure (caller logs).
Returns dicts with ``result_type`` set to ``"album"`` or ``"track"``
and sorted by ``quality_score`` descending. Empty list on any failure.
Parameters
----------
source:
Optional source name to override the orchestrator's default selection.
Must be a canonical name from ``DownloadPluginRegistry`` (e.g.
``"soulseek"``, ``"tidal"``, ``"qobuz"``). When ``None``, behaviour
is unchanged from before: orchestrator.search() picks the active
source (single mode) or the first in chain (hybrid).
"""
tracks, albums = run_async(download_orchestrator.search(query))
if source and download_orchestrator:
# Target a specific source: resolve the client and call search()
# directly instead of going through the orchestrator chain.
try:
client = download_orchestrator.client(source)
except Exception as exc:
logger.warning("basic search: could not resolve client for %r: %s", source, exc)
client = None
if client is None:
logger.warning("basic search: no client for source %r — falling back to orchestrator", source)
tracks, albums = run_async(download_orchestrator.search(query))
else:
logger.info("basic search: targeting %r for %r", source, query)
tracks, albums = run_async(client.search(query))
else:
tracks, albums = run_async(download_orchestrator.search(query))
processed_albums = []
for album in albums:
@ -42,3 +73,7 @@ def run_basic_soulseek_search(
key=lambda x: x.get('quality_score', 0),
reverse=True,
)
# Backwards-compat alias for any callers that haven't been updated yet.
run_basic_soulseek_search = run_basic_search

View file

@ -1601,7 +1601,15 @@ class SoulseekClient(DownloadSourcePlugin):
result['fallback'] = False
completed = self._poll_album_bundle_downloads(transfer_keys, _emit)
if not completed:
result['error'] = 'Soulseek album download failed or timed out'
# The selected folder yielded ZERO usable files — every transfer
# failed / aborted / stalled (a dead or unwilling peer). Don't hard-
# fail the batch: fall back to the per-track flow, which searches ALL
# sources per track and can pull each from a live peer. We reuse that
# proven multi-source robustness instead of looping candidate folders
# here. (Per-track only fires for a genuinely-missing album anyway.)
result['error'] = ('Soulseek album folder produced no usable files '
'(peer failed/aborted/stalled) — falling back to per-track')
result['fallback'] = True
return result
_emit('staging', release=getattr(picked, 'album_title', folder_path) if picked else folder_path)
@ -1700,6 +1708,22 @@ class SoulseekClient(DownloadSourcePlugin):
# Seconds an "slskd Completed but locally unresolved" key
# has to stay stuck before we give up on it.
_unresolved_grace = 45.0
# Bundle-level stall guard. The #715 grace above only covers
# "slskd says Completed but the file isn't on disk yet". It does NOT
# cover a transfer the peer stalls on — stuck InProgress / Queued, or
# dropped by slskd entirely — which is never failed, never completed,
# and never marked unresolved, so it blocks BOTH the all-terminal
# finish check AND the grace exit, and the poll spun to the full
# ``get_poll_timeout()`` deadline (the Slipknot hang). If NOTHING
# progresses — no transfer completes/fails and no pending transfer's
# byte count moves — for this long, the folder has stalled: mark the
# stuck transfers failed so the bundle resolves with whatever
# completed (the per-track matcher then handles the missing tracks).
# Conservative on purpose: only trips when EVERYTHING is frozen, so a
# slow-but-progressing or still-queued-then-starting transfer is safe.
_stall_grace = 180.0
_last_progress_marker = None
_last_progress_at = time.monotonic()
while time.monotonic() < deadline:
try:
downloads = run_async(self.get_all_downloads())
@ -1724,7 +1748,14 @@ class SoulseekClient(DownloadSourcePlugin):
os.path.basename((key[1] or '').replace('\\', '/')),
))
state = (getattr(dl, 'state', '') or '') if dl else ''
if any(token in state for token in ('Errored', 'Failed', 'Rejected', 'TimedOut')):
# NOTE: check failure tokens BEFORE the 'Completed' branch — slskd
# reports terminal failures as "Completed, <reason>" (e.g.
# "Completed, Aborted" / "Completed, Cancelled" when a peer accepts
# then drops every transfer at 0 bytes). Those contain "Completed",
# so without catching the failure reason first they'd be misread as
# "completed but file missing" (the #715 download_path path).
if any(token in state for token in
('Errored', 'Failed', 'Rejected', 'TimedOut', 'Aborted', 'Cancelled')):
failed_states[key] = state or 'Failed'
logger.warning(
"[Soulseek album] Transfer failed from selected folder: %s (%s)",
@ -1755,6 +1786,40 @@ class SoulseekClient(DownloadSourcePlugin):
count=len(completed_paths),
failed=len(failed_states),
)
# Bundle-level stall detection (see ``_stall_grace`` above). Advance
# the progress marker on ANY forward motion — a transfer reaching a
# terminal state, or a still-pending transfer downloading more bytes.
# If the marker is frozen for ``_stall_grace``, the peer has stalled;
# mark the stuck transfers failed so the finish/all-failed checks
# below resolve the bundle instead of spinning to the deadline.
now = time.monotonic()
stall_pending = [
k for k in transfer_keys
if k not in completed_paths and k not in failed_states
]
pending_bytes = 0
for k in stall_pending:
dl = by_key.get(k) or by_key.get((
k[0], os.path.basename((k[1] or '').replace('\\', '/')),
))
pending_bytes += (getattr(dl, 'transferred', 0) or 0) if dl else 0
marker = (len(completed_paths) + len(failed_states), pending_bytes)
if marker != _last_progress_marker:
_last_progress_marker = marker
_last_progress_at = now
elif stall_pending and (now - _last_progress_at) >= _stall_grace:
logger.warning(
"[Soulseek album] No progress for %.0fs — peer stalled on %d "
"transfer(s) (stuck / queued / dropped). Marking them failed and "
"resolving with what completed; missing tracks fall back to "
"per-track. Stalled: %s",
_stall_grace, len(stall_pending),
[transfer_keys[k].filename for k in stall_pending[:5]],
)
for k in stall_pending:
failed_states[k] = 'Stalled'
if completed_paths and len(completed_paths) + len(failed_states) == len(transfer_keys):
logger.warning(
"[Soulseek album] Selected folder finished with %d completed and %d failed transfer(s)",

144
core/source_ids.py Normal file
View file

@ -0,0 +1,144 @@
"""Canonical registry of external/source ID column names.
SoulSync stores each metadata provider's ID for an artist/album/track under a
column whose NAME is inconsistent across tables e.g. Deezer's artist id is
``deezer_id`` on the ``artists`` table but ``deezer_artist_id`` on
``watchlist_artists`` and ``album_deezer_id`` / ``similar_artist_deezer_id`` on
the discovery tables. Spotify/iTunes keep an entity qualifier on the core tables
while Deezer/Amazon/Tidal/... don't, and MusicBrainz uses three different nouns.
The result is code that checks 25 property-name variants everywhere.
This module is the single source of truth for "(provider, entity) → column".
It does NOT rename any database column these ARE the real names today; the
registry just centralizes the knowledge and offers accessors that read an ID
from a dict / sqlite3.Row robustly (canonical column first, then known aliases),
so callers stop hand-rolling variant checks.
"""
from __future__ import annotations
from typing import Any, Dict, Iterable, Optional
# Entity types this registry knows about.
ENTITIES = ("artist", "album", "track")
# Canonical column name on the CORE table (artists / albums / tracks) for each
# (entity, provider). This is the name to prefer when reading/writing.
_CORE_ID_COLUMNS: Dict[str, Dict[str, str]] = {
"artist": {
"spotify": "spotify_artist_id",
"itunes": "itunes_artist_id",
"deezer": "deezer_id",
"musicbrainz": "musicbrainz_id",
"discogs": "discogs_id",
"amazon": "amazon_id",
"tidal": "tidal_id",
"qobuz": "qobuz_id",
"audiodb": "audiodb_id",
"genius": "genius_id",
"hydrabase": "soul_id",
},
"album": {
"spotify": "spotify_album_id",
"itunes": "itunes_album_id",
"deezer": "deezer_id",
"musicbrainz": "musicbrainz_release_id",
"discogs": "discogs_id",
"amazon": "amazon_id",
"tidal": "tidal_id",
"qobuz": "qobuz_id",
"audiodb": "audiodb_id",
"hydrabase": "soul_id",
},
"track": {
"spotify": "spotify_track_id",
"itunes": "itunes_track_id",
"deezer": "deezer_id",
"musicbrainz": "musicbrainz_recording_id",
"amazon": "amazon_id",
"tidal": "tidal_id",
"qobuz": "qobuz_id",
"audiodb": "audiodb_id",
"genius": "genius_id",
"hydrabase": "soul_id",
},
}
# Other column / dict-key names the SAME (entity, provider) ID appears under
# elsewhere (satellite tables, API payloads). Accessors check the canonical
# column first, then these, so a read works regardless of where the row/dict
# came from. Keyed by (entity, provider).
_ALIASES: Dict[tuple, tuple] = {
("artist", "spotify"): ("similar_artist_spotify_id",),
("artist", "itunes"): ("artist_itunes_id", "similar_artist_itunes_id"),
("artist", "deezer"): ("deezer_artist_id", "artist_deezer_id", "similar_artist_deezer_id"),
("artist", "musicbrainz"): ("musicbrainz_artist_id", "similar_artist_musicbrainz_id"),
("artist", "discogs"): ("discogs_artist_id",),
("artist", "amazon"): ("amazon_artist_id",),
("album", "spotify"): ("album_spotify_id",),
("album", "itunes"): ("album_itunes_id",),
("album", "deezer"): ("deezer_album_id", "album_deezer_id"),
("album", "discogs"): ("discogs_release_id",),
("track", "deezer"): ("deezer_track_id",),
}
def id_column(provider: str, entity: str = "artist") -> Optional[str]:
"""Canonical core-table column for this provider + entity, or None if the
provider isn't tracked for that entity."""
return _CORE_ID_COLUMNS.get(entity, {}).get(provider)
def id_keys(provider: str, entity: str = "artist") -> tuple:
"""All known key names (canonical first, then aliases) the ID may live
under. Useful for code that needs the full variant list explicitly."""
keys = []
canon = id_column(provider, entity)
if canon:
keys.append(canon)
for alias in _ALIASES.get((entity, provider), ()): # preserve order, no dups
if alias not in keys:
keys.append(alias)
return tuple(keys)
def _read(data: Any, key: str) -> Any:
"""Read ``key`` from a dict or sqlite3.Row, returning None if absent."""
try:
keys = data.keys() # dict and sqlite3.Row both support .keys()
except AttributeError:
return None
if key in keys:
try:
return data[key]
except (KeyError, IndexError):
return None
return None
def get_id(data: Any, provider: str, entity: str = "artist") -> Optional[str]:
"""Read this provider's ID for ``entity`` from a dict / sqlite3.Row.
Tries the canonical column first, then every known alias, and returns the
first non-empty value (or None). Replaces hand-rolled
``row.get('deezer_id') or row.get('deezer_artist_id')`` chains.
"""
for key in id_keys(provider, entity):
value = _read(data, key)
if value:
return value
return None
def source_id_map(
data: Any,
entity: str = "artist",
providers: Optional[Iterable[str]] = None,
) -> Dict[str, Optional[str]]:
"""Build a ``{provider: id}`` dict for ``entity`` from a row/dict — the
common "artist_source_ids" pattern. Defaults to every provider known for the
entity; pass ``providers`` to restrict/order the result.
"""
if providers is None:
providers = list(_CORE_ID_COLUMNS.get(entity, {}).keys())
return {p: get_id(data, p, entity) for p in providers}

View file

@ -6,7 +6,6 @@ Reuses the same Mutagen patterns as _enhance_file_metadata in web_server.py.
import os
import logging
import urllib.request
from typing import Dict, Any, Optional, List, Tuple
from mutagen import File as MutagenFile
@ -205,48 +204,18 @@ def download_cover_art(cover_url: str) -> Optional[Tuple[bytes, str]]:
Download cover art once. Returns (image_data, mime_type) or None on failure.
Call this once per album, then pass the result to write_tags_to_file for each track.
For Deezer CDN URLs, upgrades the size segment to 1900×1900 (CDN
max). Mirrors the same upgrade in
``core.metadata.artwork.download_cover_art`` so the
enhanced-library-view "Write Tags to File" feature embeds the same
high-resolution cover the auto post-process flow does. Falls back
to the original URL if the CDN refuses the upgraded size for a
specific album keeps the fix strictly non-regressive vs. the
pre-upgrade behaviour.
Delegates to ``core.metadata.artwork._fetch_art_bytes`` so the enhanced-
library-view "Write Tags to File" feature embeds the same highest-
resolution cover the auto post-process flow does Spotify master
(~2000px), iTunes 3000×3000, and Deezer 1900×1900 with the same
one-level fallback to the original size if the CDN refuses the upgrade.
"""
if not cover_url:
return None
original_url = cover_url
if 'dzcdn' in cover_url:
try:
from core.deezer_client import _upgrade_deezer_cover_url
cover_url = _upgrade_deezer_cover_url(cover_url)
except Exception as e:
logger.debug("upgrade deezer image url failed: %s", e)
try:
with urllib.request.urlopen(cover_url, timeout=15) as response:
image_data = response.read()
mime_type = response.info().get_content_type() or 'image/jpeg'
if image_data:
return (image_data, mime_type)
except Exception as e:
# Deezer CDN refused upgraded size for this album — retry with
# original URL so we never get less than pre-upgrade behaviour.
if 'dzcdn' in cover_url and cover_url != original_url:
logger.info(
"Deezer CDN refused upgraded cover URL (%s); retrying with original size", e,
)
try:
with urllib.request.urlopen(original_url, timeout=15) as response:
image_data = response.read()
mime_type = response.info().get_content_type() or 'image/jpeg'
if image_data:
return (image_data, mime_type)
except Exception as fallback_err:
logger.error(f"Error downloading cover art (fallback): {fallback_err}")
return None
logger.error(f"Error downloading cover art from {cover_url}: {e}")
return None
from core.metadata.artwork import _fetch_art_bytes
image_data, mime_type = _fetch_art_bytes(cover_url)
return (image_data, mime_type) if image_data else None
def write_tags_to_file(file_path: str, db_data: Dict[str, Any],

View file

@ -39,6 +39,15 @@ class UsenetStatus:
download_speed: int # bytes/sec
eta: Optional[int] = None # seconds, None if unknown
save_path: Optional[str] = None
# In-progress / pre-move directory (SAB ``incomplete_path``). Kept
# SEPARATE from ``save_path`` on purpose: it points at the staging
# dir SAB uses BEFORE its post-process move, so it must never be
# treated as the final path on a normal completion. The poll loops
# only fall back to it as a LAST RESORT — after waiting the full
# completed-but-no-save_path window — to recover the (#721) case
# where SAB finished, the files are physically on disk, but the
# final ``storage`` field never lands. See ``poll_album_download``.
incomplete_path: Optional[str] = None
category: Optional[str] = None
files: Optional[List[str]] = None
error: Optional[str] = None

View file

@ -232,22 +232,106 @@ class SABnzbdAdapter:
)
def _parse_history_slot(self, slot: dict) -> UsenetStatus:
# History entries are post-download — progress is 1.0 unless failed.
sab_state = (slot.get('status') or '').lower()
is_failed = sab_state == 'failed'
# History entries cover BOTH finished jobs AND jobs that are still
# POST-PROCESSING. SAB removes a job from the queue the moment the
# download finishes and runs par2 verify / repair / unpack / move
# while the job sits in History — exposing the live stage in the
# ``status`` field ('Verifying' / 'Repairing' / 'Extracting' /
# 'Moving' / 'Running' / ...). Only 'Completed' means truly done;
# 'Failed' / 'Deleted' mean failure.
#
# The old logic mapped EVERY non-'failed' status to 'completed'.
# That made the poll treat a still-extracting 1.7 GB FLAC album
# (status 'Extracting', ``storage`` not written yet) as "completed
# but no save_path" and burn the completed-no-path window mid-PP —
# exactly the #721 stuck-at-99% signature in production where the
# path IS shared. Route the status through the same queue-state map
# so PP stages stay NON-terminal: the poll keeps waiting (as
# 'downloading') for as long as post-processing takes, and only a
# real 'Completed' flips it to terminal success.
mapped = _map_state(slot.get('status') or '')
is_failed = mapped == 'failed'
is_completed = mapped == 'completed'
# Only trust the final path on a TRUE completion. Mid-PP the path
# fields may be empty or still point at the incomplete dir; the
# completed-no-path retry handles the brief gap between the
# 'Completed' flip and ``storage`` landing in the same response.
save_path = self._extract_history_save_path(slot) if is_completed else None
# ``incomplete_path`` is surfaced separately (NOT as save_path) so
# the poll loops can fall back to it only as a last resort once
# the final ``storage`` field has provably failed to land — see
# ``UsenetStatus.incomplete_path`` and the #721 fallback in
# ``poll_album_download``. Whitespace-only values are treated as
# absent, same as the save_path chain.
incomplete_path = slot.get('incomplete_path')
if not (isinstance(incomplete_path, str) and incomplete_path.strip()):
incomplete_path = None
bytes_total = int(slot.get('bytes') or 0)
return UsenetStatus(
id=str(slot.get('nzo_id') or ''),
name=slot.get('name') or '',
state='failed' if is_failed else 'completed',
state=mapped,
# Download itself is finished for ANY History slot (in-PP or
# done), so report full download progress — don't snap the UI
# back to 0% while SAB verifies/unpacks. Failed = 0.
progress=0.0 if is_failed else 1.0,
size=int(slot.get('bytes') or 0),
downloaded=int(slot.get('bytes') or 0) if not is_failed else 0,
size=bytes_total,
downloaded=0 if is_failed else bytes_total,
download_speed=0,
save_path=slot.get('storage') or slot.get('path'),
save_path=save_path,
incomplete_path=incomplete_path,
category=slot.get('category'),
error=slot.get('fail_message') if is_failed else None,
)
# SAB version differences: ``storage`` is the documented final-path
# field on recent versions, but older builds populated ``path``
# instead, and some forks use ``download_path`` or ``dirname``.
# ``storage`` is also empty for the first few seconds after a job
# flips to History — SAB writes the path AFTER its post-processing
# move completes. Issue #721 (Forty Licks stuck at 61%): the bundle
# poll returned save_path=None on the first ``Completed`` read, the
# plugin marked the batch failed, and the UI never unstuck. The
# ``poll_album_download`` retry loop now tolerates a short window
# of completed-but-no-path; this helper widens the field net so
# we pick the path up whenever ANY of the known SAB keys carries it.
_HISTORY_SAVE_PATH_KEYS = (
'storage',
'path',
'download_path',
'dirname',
)
# ``incomplete_path`` is intentionally NOT in this list. SAB
# populates it BEFORE the post-process move, so it would always
# resolve on the first ``Completed`` read — bypassing
# ``poll_album_download``'s new retry window, and pointing the
# bundle plugin at the in-progress staging dir instead of the
# final destination. The poll's retry loop is the safer place to
# handle the SAB History→storage write gap.
def _extract_history_save_path(self, slot: dict) -> Optional[str]:
for key in self._HISTORY_SAVE_PATH_KEYS:
value = slot.get(key)
if value and isinstance(value, str) and value.strip():
return value
# Loud diagnostic when the bundle poll is about to wait/fail on
# this: users on SAB versions / forks with novel field names need
# to see this in the logs so we can grow ``_HISTORY_SAVE_PATH_KEYS``.
# INFO (not DEBUG) on purpose — a completed History slot with no
# resolvable path is the #721 stuck-bundle signature, and dumping
# the actual slot keys here is what lets us add the missing field
# without a debug-logging round-trip. It only fires for completed
# slots that lack every known path field, so it self-limits the
# moment ``storage`` lands.
logger.info(
"[SAB] History slot for nzo_id=%s has no save_path in any "
"of the known fields %r — slot keys: %r",
slot.get('nzo_id'),
self._HISTORY_SAVE_PATH_KEYS,
sorted(slot.keys()),
)
return None
@staticmethod
def _safe_float(value) -> Optional[float]:
if value is None or value == '':

View file

@ -7,7 +7,7 @@ from dataclasses import dataclass
from datetime import datetime
from contextlib import AbstractContextManager
from types import SimpleNamespace
from typing import Any, Callable, Dict
from typing import Any, Callable, Dict, Optional
from core.wishlist.payloads import build_failed_track_wishlist_context
from core.wishlist.selection import filter_wishlist_tracks_by_category, sanitize_and_dedupe_wishlist_tracks
@ -67,6 +67,14 @@ class WishlistAutoProcessingRuntime:
current_time_fn: Callable[[], float]
profile_id: int = 1
logger: Any = module_logger
# Dedicated pool for the inline-blocking per-album bundle downloads.
# Album-bundle batches block their worker thread for the whole search +
# download; running them on the shared ``missing_download_executor`` lets a
# burst of album batches (e.g. a big Album-Completeness "Fix all" → wishlist)
# starve the per-track flow AND the user's manual "Download Wishlist" (#740).
# Routing them here keeps the shared pool free. Falls back to the shared
# executor when unset (older callers / tests) — see the submit site below.
album_bundle_executor: Any = None
def remove_completed_tracks_from_wishlist(
@ -92,6 +100,202 @@ def remove_completed_tracks_from_wishlist(
return removed_count
def make_wishlist_batch_row(
*,
playlist_id: str,
playlist_name: str,
track_count: int,
max_concurrent: int,
profile_id: int,
phase: str,
run_id: str | None = None,
is_album: bool = False,
album_context: Optional[Dict[str, Any]] = None,
artist_context: Optional[Dict[str, Any]] = None,
extra_fields: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Single source of truth for a wishlist ``download_batches`` row.
The auto and manual wishlist flows used to build this ~20-field dict in four
separate places, which let their batch shapes silently drift apart. They now
all go through here so every wishlist batch has an IDENTICAL field shape; the
genuinely per-flow differences (initial ``phase``, the auto-only
``auto_initiated`` / ``current_cycle`` fields, album vs residual contexts) are
explicit arguments / ``extra_fields``.
NOTE: this builds the row only it does NOT decide grouping, batch-id
allocation, or dispatch (parallel-submit vs serial), which legitimately
differ between the flows and stay in their callers.
"""
row: Dict[str, Any] = {
'phase': phase,
'playlist_id': playlist_id,
'playlist_name': playlist_name,
'queue': [],
'active_count': 0,
'max_concurrent': max_concurrent,
'queue_index': 0,
'analysis_total': track_count,
'analysis_processed': 0,
'analysis_results': [],
'permanently_failed_tracks': [],
'cancelled_tracks': set(),
'force_download_all': True,
'profile_id': profile_id,
'is_album_download': is_album,
'album_context': album_context,
'artist_context': artist_context,
'wishlist_run_id': run_id,
}
if extra_fields:
row.update(extra_fields)
return row
def _run_wishlist_cycle(
runtime,
*,
playlist_id: str,
cycle: str,
tracks: list,
run_id: str,
auto_initiated: bool,
first_batch_id: Optional[str] = None,
) -> Dict[str, Any]:
"""THE single wishlist orchestration engine — both the auto timer and the
manual trigger call this, so a manual scan runs the exact same code path as
an auto scan (group per-album + residual batches register dispatch).
Per-flow differences are arguments, not separate code:
* ``auto_initiated`` stamps the auto-only fields (auto_initiated /
auto_processing_timestamp / current_cycle, which also drives the
once-per-run cycle toggle on completion) and selects the auto vs manual
display-name + log style.
* ``first_batch_id`` lets the manual flow reuse its synchronously-created
placeholder batch so the modal's existing poll target stays valid.
Album batches block their worker for the whole search+download, so they run
on the dedicated album pool; the residual per-track batch runs on the shared
pool. Returns a summary dict (submitted ids + album / residual counts).
"""
logger = runtime.logger
from core.wishlist.album_grouping import group_wishlist_tracks_by_album
# Albums cycle splits into per-album bundles; singles keep the single
# per-track batch shape (Spotify already classifies them away from albums).
grouping = (
group_wishlist_tracks_by_album(
tracks, min_tracks_per_album=_resolve_album_bundle_threshold(),
)
if cycle == 'albums' else None
)
extra_fields = None
if auto_initiated:
extra_fields = {
'auto_initiated': True,
'auto_processing_timestamp': runtime.current_time_fn(),
'current_cycle': cycle,
}
# Reuse the caller-provided placeholder id for the FIRST batch created; every
# other batch gets a fresh uuid.
_reuse_id = first_batch_id
def _alloc_id() -> str:
nonlocal _reuse_id
if _reuse_id is not None:
bid, _reuse_id = _reuse_id, None
return bid
return str(uuid.uuid4())
album_executor = runtime.album_bundle_executor or runtime.missing_download_executor
submitted: list = []
album_groups = grouping.album_groups if grouping else []
for album_idx, group in enumerate(album_groups):
album_batch_id = _alloc_id()
album_name = group.album_context.get('name', 'Unknown')
batch_name = (
f"Wishlist (Auto - Album: {album_name})" if auto_initiated
else f"Wishlist (Album: {album_name})"
)
with runtime.tasks_lock:
runtime.download_batches[album_batch_id] = make_wishlist_batch_row(
playlist_id=playlist_id,
playlist_name=batch_name,
track_count=len(group.tracks),
max_concurrent=runtime.get_batch_max_concurrent(),
profile_id=runtime.profile_id,
phase='queued',
run_id=run_id,
is_album=True,
album_context=group.album_context,
artist_context=group.artist_context,
extra_fields=extra_fields,
)
if auto_initiated:
logger.info(
f"[Auto-Wishlist] Album sub-batch {album_idx + 1}/{len(album_groups)}: "
f"'{album_name}' by '{group.artist_context.get('name')}' "
f"({len(group.tracks)} tracks) → {album_batch_id} [run {run_id[:8]}]"
)
else:
logger.info(
f"[Manual-Wishlist] Album sub-batch {album_idx + 1}/{len(album_groups)}: "
f"'{album_name}' ({len(group.tracks)} tracks) → {album_batch_id}"
)
submitted.append(album_batch_id)
# Album bundles block their worker for the whole search+download → dedicated
# pool (falls back to the shared pool when unset). See #740.
album_executor.submit(
runtime.run_full_missing_tracks_process,
album_batch_id, playlist_id, group.tracks,
)
residual_tracks = grouping.residual_tracks if grouping is not None else tracks
residual_count = len(residual_tracks) if residual_tracks else 0
if residual_tracks:
residual_batch_id = _alloc_id()
residual_name = (
f"Wishlist (Auto - {cycle.capitalize()})" if auto_initiated
else "Wishlist (Residual)"
)
with runtime.tasks_lock:
runtime.download_batches[residual_batch_id] = make_wishlist_batch_row(
playlist_id=playlist_id,
playlist_name=residual_name,
track_count=residual_count,
max_concurrent=runtime.get_batch_max_concurrent(),
profile_id=runtime.profile_id,
phase='queued',
run_id=run_id,
extra_fields=extra_fields,
)
submitted.append(residual_batch_id)
runtime.missing_download_executor.submit(
runtime.run_full_missing_tracks_process,
residual_batch_id, playlist_id, residual_tracks,
)
if auto_initiated:
logger.info(
f"Starting wishlist residual batch {residual_batch_id} with {residual_count} tracks "
f"({'singles' if cycle == 'singles' else 'unbucketed albums'}) "
f"[run {run_id[:8]}]"
)
else:
logger.info(
f"[Manual-Wishlist] Residual per-track batch {residual_batch_id} "
f"with {residual_count} tracks"
)
return {
'submitted': submitted,
'album_batches': len(album_groups),
'residual_count': residual_count,
}
def add_cancelled_tracks_to_failed_tracks(
batch: Dict[str, Any],
download_tasks: Dict[str, Dict[str, Any]],
@ -423,6 +627,9 @@ class WishlistManualDownloadRuntime:
active_server: str
profile_id: int
logger: Any = module_logger
# Dedicated album-bundle pool, shared with the auto flow via
# _run_wishlist_cycle. Falls back to missing_download_executor when unset.
album_bundle_executor: Any = None
def start_manual_wishlist_download_batch(
@ -447,24 +654,16 @@ def start_manual_wishlist_download_batch(
playlist_name = "Wishlist"
with runtime.tasks_lock:
runtime.download_batches[batch_id] = {
'phase': 'analysis',
'playlist_id': playlist_id,
'playlist_name': playlist_name,
'queue': [],
'active_count': 0,
'max_concurrent': runtime.get_batch_max_concurrent(),
'queue_index': 0,
# analysis_total starts at 0; the bg job updates it after cleanup
# finishes and the real track count is known.
'analysis_total': 0,
'analysis_processed': 0,
'analysis_results': [],
'permanently_failed_tracks': [],
'cancelled_tracks': set(),
'force_download_all': True,
'profile_id': runtime.profile_id,
}
# analysis_total starts at 0; the bg job updates it after cleanup
# finishes and the real track count is known.
runtime.download_batches[batch_id] = make_wishlist_batch_row(
playlist_id=playlist_id,
playlist_name=playlist_name,
track_count=0,
max_concurrent=runtime.get_batch_max_concurrent(),
profile_id=runtime.profile_id,
phase='analysis',
)
runtime.missing_download_executor.submit(
_prepare_and_run_manual_wishlist_batch,
@ -552,118 +751,34 @@ def _prepare_and_run_manual_wishlist_batch(
runtime.add_activity_item("", "Wishlist Download Started", f"{len(wishlist_tracks)} tracks", "Now")
# Try to split into per-album sub-batches so each album fires
# ONE slskd / torrent / usenet album-bundle search (gates on
# ``is_album_download`` + populated album/artist context).
# When a single category was requested (or no category filter)
# we apply the same grouping the auto-wishlist path uses.
# Tracks the grouper can't bucket fall through to a residual
# batch with the classic per-track flow.
from core.wishlist.album_grouping import group_wishlist_tracks_by_album
grouping = group_wishlist_tracks_by_album(
wishlist_tracks,
min_tracks_per_album=_resolve_album_bundle_threshold(),
)
# Build the final payload list (batch_id, tracks, album_context,
# artist_context, is_album). The first payload re-uses the
# caller-allocated ``batch_id`` so the frontend's existing poll
# against it keeps working. Subsequent payloads get fresh ids.
payloads = []
for group in grouping.album_groups:
payloads.append({
'tracks': group.tracks,
'is_album': True,
'album_context': group.album_context,
'artist_context': group.artist_context,
'display_name': f"Wishlist (Album: {group.album_context.get('name', 'Unknown')})",
})
if grouping.residual_tracks:
payloads.append({
'tracks': grouping.residual_tracks,
'is_album': False,
'album_context': None,
'artist_context': None,
'display_name': "Wishlist (Residual)",
})
if not payloads:
# Nothing to download — clear out the original batch.
if not wishlist_tracks:
# Nothing to download — clear out the placeholder batch.
with runtime.tasks_lock:
if batch_id in runtime.download_batches:
runtime.download_batches[batch_id]['analysis_total'] = 0
runtime.download_batches[batch_id]['phase'] = 'complete'
return
# Attach the original batch_id to the first payload; allocate
# fresh batch_ids for the rest.
payloads[0]['batch_id'] = batch_id
for payload in payloads[1:]:
payload['batch_id'] = str(uuid.uuid4())
# Reify "wishlist run" — one shared id stamped on every sub-
# batch this manual invocation produces. Mirrors the auto
# path. Note manual wishlist completion currently doesn't
# toggle the cycle (only auto does), but the id is set anyway
# so future code + UI grouping have a consistent hook.
wishlist_run_id = str(uuid.uuid4())
# Materialize each sub-batch's row state up-front so the
# frontend's polling can see them all under the original
# batch's flow.
with runtime.tasks_lock:
if batch_id in runtime.download_batches:
# Re-purpose the existing row for the first payload.
first = payloads[0]
runtime.download_batches[batch_id]['analysis_total'] = len(first['tracks'])
runtime.download_batches[batch_id]['wishlist_run_id'] = wishlist_run_id
if first['is_album']:
runtime.download_batches[batch_id]['is_album_download'] = True
runtime.download_batches[batch_id]['album_context'] = first['album_context']
runtime.download_batches[batch_id]['artist_context'] = first['artist_context']
runtime.download_batches[batch_id]['playlist_name'] = first['display_name']
for payload in payloads[1:]:
runtime.download_batches[payload['batch_id']] = {
'phase': 'analysis',
'playlist_id': 'wishlist',
'playlist_name': payload['display_name'],
'queue': [],
'active_count': 0,
'max_concurrent': runtime.get_batch_max_concurrent(),
'queue_index': 0,
'analysis_total': len(payload['tracks']),
'analysis_processed': 0,
'analysis_results': [],
'permanently_failed_tracks': [],
'cancelled_tracks': set(),
'force_download_all': True,
'profile_id': runtime.profile_id,
'is_album_download': bool(payload['is_album']),
'album_context': payload['album_context'],
'artist_context': payload['artist_context'],
'wishlist_run_id': wishlist_run_id,
}
logger.info(
f"[Manual-Wishlist] Split into {len(payloads)} sub-batch(es) "
f"({sum(1 for p in payloads if p['is_album'])} album + "
f"{sum(1 for p in payloads if not p['is_album'])} residual)"
# Run the selection through the SHARED engine — the exact code path the
# auto timer uses (group → album bundles + per-track residual → parallel
# dispatch on the album / shared pools). cycle='albums' bundles whatever
# forms an album and drops the rest (singles / ungroupable) into the
# per-track residual, so this single call covers the whole selection.
# The placeholder batch_id is reused as the first sub-batch so the
# modal's existing poll target stays valid.
result = _run_wishlist_cycle(
runtime,
playlist_id='wishlist',
cycle='albums',
tracks=wishlist_tracks,
run_id=str(uuid.uuid4()),
auto_initiated=False,
first_batch_id=batch_id,
)
logger.info(
f"[Manual-Wishlist] Dispatched {result['album_batches']} album batch(es) + "
f"{result['residual_count']} residual track(s) via the shared engine"
)
# Serial dispatch — each album-bundle search happens one at a
# time so the slskd / Prowlarr pipeline doesn't fan out across
# multiple parallel release searches.
for payload in payloads:
label = (
f"album '{payload['album_context'].get('name')}'"
if payload['is_album'] else 'residual per-track'
)
logger.info(
f"[Manual-Wishlist] Running sub-batch {payload['batch_id']} "
f"({label}, {len(payload['tracks'])} tracks)"
)
runtime.run_full_missing_tracks_process(
payload['batch_id'], "wishlist", payload['tracks'],
)
except Exception as exc:
logger.error(f"Error preparing manual wishlist batch {batch_id}: {exc}")
@ -825,134 +940,24 @@ def process_wishlist_automatically(runtime: WishlistAutoProcessingRuntime, autom
for i, track in enumerate(wishlist_tracks):
track['_original_index'] = i
# When the cycle is 'albums', try to split the wishlist
# into per-album sub-batches so each album fires ONE
# album-bundle search (slskd / torrent / usenet) instead
# of N per-track searches. Residual tracks (no resolvable
# album metadata) fall through to a normal per-track
# batch. Singles cycle keeps its original single-batch
# shape — Spotify already classifies them away from
# albums.
_submitted_batches: list[str] = []
if current_cycle == 'albums':
from core.wishlist.album_grouping import group_wishlist_tracks_by_album
grouping = group_wishlist_tracks_by_album(
wishlist_tracks,
min_tracks_per_album=_resolve_album_bundle_threshold(),
)
else:
grouping = None
# Reify "wishlist run" — one shared id stamped on every
# sub-batch this invocation produces. The completion
# handler uses it to gate the once-per-run cycle toggle
# (so it doesn't fire N times for N sub-batches).
# Reify one "wishlist run" id (the completion handler gates the
# once-per-run cycle toggle on it) and hand off to the SHARED
# wishlist engine — the same code path the manual trigger uses.
wishlist_run_id = str(uuid.uuid4())
if grouping and grouping.album_groups:
for album_idx, group in enumerate(grouping.album_groups):
album_batch_id = str(uuid.uuid4())
album_batch_name = (
f"Wishlist (Auto - Album: {group.album_context.get('name', 'Unknown')})"
)
with runtime.tasks_lock:
runtime.download_batches[album_batch_id] = {
# ``queued`` until the master worker
# picks the batch up from the
# ``missing_download_executor`` pool
# (max_workers=3 by default). The worker
# flips phase to ``analysis`` as its
# first action — see
# ``core/downloads/master.py:328``.
# Pre-fix the row was created with
# ``analysis`` directly, so a wishlist
# run with N > 3 sub-batches looked like
# all N were working when really only
# 3 were running.
'phase': 'queued',
'playlist_id': playlist_id,
'playlist_name': album_batch_name,
'queue': [],
'active_count': 0,
'max_concurrent': runtime.get_batch_max_concurrent(),
'queue_index': 0,
'analysis_total': len(group.tracks),
'analysis_processed': 0,
'analysis_results': [],
'permanently_failed_tracks': [],
'cancelled_tracks': set(),
'force_download_all': True,
'auto_initiated': True,
'auto_processing_timestamp': runtime.current_time_fn(),
'current_cycle': current_cycle,
'profile_id': runtime.profile_id,
# Album-bundle dispatch gate reads these
# three. With them set, the master worker
# routes through slskd / torrent / usenet
# album-bundle search instead of per-track.
'is_album_download': True,
'album_context': group.album_context,
'artist_context': group.artist_context,
'wishlist_run_id': wishlist_run_id,
}
logger.info(
f"[Auto-Wishlist] Album sub-batch {album_idx + 1}/{len(grouping.album_groups)}: "
f"'{group.album_context.get('name')}' by '{group.artist_context.get('name')}' "
f"({len(group.tracks)} tracks) → {album_batch_id} [run {wishlist_run_id[:8]}]"
)
_submitted_batches.append(album_batch_id)
runtime.missing_download_executor.submit(
runtime.run_full_missing_tracks_process,
album_batch_id, playlist_id, group.tracks,
)
# Residual tracks (no album group could be formed, OR
# singles cycle): one classic per-track batch as before.
residual_tracks = (
grouping.residual_tracks if grouping is not None else wishlist_tracks
_cycle_result = _run_wishlist_cycle(
runtime,
playlist_id=playlist_id,
cycle=current_cycle,
tracks=wishlist_tracks,
run_id=wishlist_run_id,
auto_initiated=True,
)
if residual_tracks:
batch_id = str(uuid.uuid4())
playlist_name = f"Wishlist (Auto - {current_cycle.capitalize()})"
with runtime.tasks_lock:
runtime.download_batches[batch_id] = {
# See album sub-batch above — ``queued``
# until the master worker picks it up.
'phase': 'queued',
'playlist_id': playlist_id,
'playlist_name': playlist_name,
'queue': [],
'active_count': 0,
'max_concurrent': runtime.get_batch_max_concurrent(),
'queue_index': 0,
'analysis_total': len(residual_tracks),
'analysis_processed': 0,
'analysis_results': [],
'permanently_failed_tracks': [],
'cancelled_tracks': set(),
'force_download_all': True,
'auto_initiated': True,
'auto_processing_timestamp': runtime.current_time_fn(),
'current_cycle': current_cycle,
'profile_id': runtime.profile_id,
'wishlist_run_id': wishlist_run_id,
}
_submitted_batches.append(batch_id)
runtime.missing_download_executor.submit(
runtime.run_full_missing_tracks_process,
batch_id, playlist_id, residual_tracks,
)
logger.info(
f"Starting wishlist residual batch {batch_id} with {len(residual_tracks)} tracks "
f"({'singles' if current_cycle == 'singles' else 'unbucketed albums'}) "
f"[run {wishlist_run_id[:8]}]"
)
_summary_parts: list[str] = []
if grouping and grouping.album_groups:
_summary_parts.append(f"{len(grouping.album_groups)} album batch(es)")
if residual_tracks:
_summary_parts.append(f"{len(residual_tracks)} per-track")
if _cycle_result['album_batches']:
_summary_parts.append(f"{_cycle_result['album_batches']} album batch(es)")
if _cycle_result['residual_count']:
_summary_parts.append(f"{_cycle_result['residual_count']} per-track")
_summary_text = ', '.join(_summary_parts) or 'no batches'
runtime.update_automation_progress(
automation_id, progress=50,

View file

@ -312,6 +312,19 @@ class MusicDatabase:
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Migration ledger — a single, readable record of which one-time
# migrations have run. ADDITIVE backstop only: existing migrations
# keep their own idempotency gates (PRAGMA checks, marker tables,
# metadata flags); this table just unifies that scattered state so a
# half-migrated DB is detectable. Nothing GATES on it. Paired with
# PRAGMA user_version (set at the end of init).
cursor.execute("""
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Wishlist table for storing failed download tracks for retry
cursor.execute("""
@ -337,6 +350,7 @@ class MusicDatabase:
deezer_artist_id TEXT,
discogs_artist_id TEXT,
musicbrainz_artist_id TEXT,
amazon_artist_id TEXT,
artist_name TEXT NOT NULL,
date_added TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_scan_timestamp TIMESTAMP,
@ -792,6 +806,10 @@ class MusicDatabase:
logger.error(f"Personalized-playlist schema init failed: {ps_err}")
self._ensure_core_media_schema_columns(cursor)
self._normalize_genres_to_json(cursor)
# Unify scattered migration state into the ledger + stamp the schema
# version. Additive backstop — runs last, gates nothing.
self._sync_migration_ledger(cursor)
conn.commit()
logger.info("Database initialized successfully")
@ -802,6 +820,122 @@ class MusicDatabase:
self._init_manual_library_match_table()
# Bump when the schema's generation meaningfully changes. Stamped into
# PRAGMA user_version as a backstop indicator; nothing GATES on it yet.
SCHEMA_VERSION = 1
# Maps a ledger name to the EXISTING idempotency signal that proves a
# one-time migration ran: ('table', <marker table>) or ('flag', <metadata
# key>). Used to back-fill the ledger for DBs created before it existed.
# The ledger is a non-gating backstop, so this can grow lazily — a missing
# entry just means that migration isn't surfaced in the ledger (harmless).
_KNOWN_MIGRATION_SIGNALS = {
'id_columns_to_text': ('flag', 'id_columns_migrated'),
'genres_json': ('flag', 'genres_json_normalized'),
'metadata_cache_v1': ('flag', 'metadata_cache_v1'),
'repair_worker_v2': ('flag', 'repair_worker_v2'),
'spotify_library_cache_v1': ('flag', 'spotify_library_cache_v1'),
'profiles_v1': ('flag', 'profiles_migration_v1'),
'profiles_v2': ('flag', 'profiles_migration_v2'),
'profiles_v3': ('flag', 'profiles_migration_v3'),
'profiles_v4': ('flag', 'profiles_migration_v4'),
'discovery_cache_v2': ('table', '_discovery_cache_v2_migrated'),
'deezer_cache_v2': ('table', '_deezer_cache_v2_migrated'),
'cache_junk_artist_purged': ('table', '_cache_junk_artist_purged'),
'genius_search_fix': ('table', '_genius_search_fix_applied'),
}
def _record_migration(self, cursor, name):
"""Record a one-time migration in the schema_migrations ledger.
Idempotent (INSERT OR IGNORE). New one-time migrations should call this
when they complete; the ledger is a non-gating backstop, so a failure to
record never affects correctness.
"""
try:
cursor.execute(
"INSERT OR IGNORE INTO schema_migrations (name, applied_at) "
"VALUES (?, CURRENT_TIMESTAMP)", (name,)
)
except Exception as e:
logger.debug("Could not record migration %s in ledger: %s", name, e)
def _sync_migration_ledger(self, cursor):
"""Back-fill the ledger from existing idempotency signals and stamp
PRAGMA user_version.
ADDITIVE + non-gating: this only RECORDS state that already exists (which
marker tables / metadata flags are present); it never decides whether a
migration runs. Safe to run on every startup.
"""
try:
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = {r[0] for r in cursor.fetchall()}
for ledger_name, (kind, signal) in self._KNOWN_MIGRATION_SIGNALS.items():
if kind == 'table':
present = signal in tables
else: # 'flag' — a metadata row
cursor.execute("SELECT 1 FROM metadata WHERE key = ? LIMIT 1", (signal,))
present = cursor.fetchone() is not None
if present:
self._record_migration(cursor, ledger_name)
# Backstop version stamp; nothing gates on it.
cursor.execute(f"PRAGMA user_version = {int(self.SCHEMA_VERSION)}")
except Exception as e:
logger.error(f"Error syncing migration ledger: {e}")
def _normalize_genres_to_json(self, cursor):
"""One-time: rewrite legacy comma-separated genres to canonical JSON arrays.
``artists.genres`` / ``albums.genres`` historically stored EITHER a JSON
array (new writes) OR a comma-separated string (old writes), so every
reader has to try-JSON-then-split. This normalizes existing rows to JSON
in place. It mirrors the readers' exact parse (JSON list, else
comma-split/strip/drop-empties), so the genre VALUES are unchanged only
the storage format. Marker-gated to run once, and per-row diffed so a
re-run (or a row already in JSON form) is a no-op. Non-fatal on error,
like the other migrations.
"""
import json
try:
cursor.execute("SELECT value FROM metadata WHERE key = 'genres_json_normalized' LIMIT 1")
if cursor.fetchone():
return
def _to_list(raw):
# Identical semantics to the genres readers elsewhere in this file.
try:
parsed = json.loads(raw)
return parsed if isinstance(parsed, list) else [str(parsed)]
except (json.JSONDecodeError, ValueError, TypeError):
return [g.strip() for g in raw.split(',') if g.strip()]
total = 0
for table in ('artists', 'albums'):
cursor.execute(
f"SELECT id, genres FROM {table} "
f"WHERE genres IS NOT NULL AND TRIM(genres) != ''"
)
pending = []
for row in cursor.fetchall():
rid, raw = row[0], row[1]
canonical = json.dumps(_to_list(raw))
if canonical != raw: # leave already-canonical rows untouched
pending.append((canonical, rid))
for canonical, rid in pending:
cursor.execute(f"UPDATE {table} SET genres = ? WHERE id = ?", (canonical, rid))
total += 1
cursor.execute(
"INSERT OR REPLACE INTO metadata (key, value, updated_at) "
"VALUES ('genres_json_normalized', 'true', CURRENT_TIMESTAMP)"
)
self._record_migration(cursor, 'genres_json')
if total:
logger.info("Normalized %d legacy genres value(s) to JSON", total)
except Exception as e:
logger.error(f"Error normalizing genres to JSON: {e}")
def _ensure_core_media_schema_columns(self, cursor):
"""Repair required media-library columns that older migrations may miss.
@ -1826,6 +1960,7 @@ class MusicDatabase:
deezer_artist_id TEXT,
discogs_artist_id TEXT,
musicbrainz_artist_id TEXT,
amazon_artist_id TEXT,
profile_id INTEGER DEFAULT 1,
UNIQUE(profile_id, spotify_artist_id),
UNIQUE(profile_id, itunes_artist_id)
@ -1854,7 +1989,8 @@ class MusicDatabase:
itunes_artist_id TEXT,
deezer_artist_id TEXT,
discogs_artist_id TEXT,
musicbrainz_artist_id TEXT
musicbrainz_artist_id TEXT,
amazon_artist_id TEXT
)
""")
@ -1867,7 +2003,7 @@ class MusicDatabase:
'include_remixes', 'include_acoustic', 'include_compilations',
'include_instrumentals', 'lookback_days',
'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id',
'musicbrainz_artist_id', 'profile_id']
'musicbrainz_artist_id', 'amazon_artist_id', 'profile_id']
shared_cols = [c for c in new_cols if c in old_cols]
cols_str = ', '.join(shared_cols)
cursor.execute(f"INSERT INTO watchlist_artists_new ({cols_str}) SELECT {cols_str} FROM watchlist_artists")
@ -2711,6 +2847,7 @@ class MusicDatabase:
deezer_artist_id TEXT,
discogs_artist_id TEXT,
musicbrainz_artist_id TEXT,
amazon_artist_id TEXT,
profile_id INTEGER DEFAULT 1,
UNIQUE(profile_id, spotify_artist_id),
UNIQUE(profile_id, itunes_artist_id)
@ -2724,7 +2861,7 @@ class MusicDatabase:
'include_remixes', 'include_acoustic', 'include_compilations',
'include_instrumentals', 'lookback_days',
'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id',
'musicbrainz_artist_id', 'profile_id']
'musicbrainz_artist_id', 'amazon_artist_id', 'profile_id']
shared_cols = [c for c in new_cols if c in col_names]
cols_str = ', '.join(shared_cols)
@ -9983,6 +10120,24 @@ class MusicDatabase:
# Get artist with all columns
cursor.execute("SELECT * FROM artists WHERE id = ?", (artist_id,))
artist_row = cursor.fetchone()
if not artist_row:
# `artist_id` may be a *source* ID (e.g. a MusicBrainz MBID
# from a search result) rather than the integer library PK.
# The /api/artist-detail route resolves this upstream via
# find_library_artist_for_source, but the enhanced-view and
# quality-analysis endpoints call this method directly with
# whatever ID the page holds — for a library artist opened
# from a non-library search result that's the source ID, so
# the page 404'd. Resolve by matching any per-service ID
# column (single source of truth: SOURCE_ID_FIELD).
from core.artist_source_lookup import SOURCE_ID_FIELD
id_columns = list(dict.fromkeys(SOURCE_ID_FIELD.values()))
where = ' OR '.join(f"{col} = ?" for col in id_columns)
cursor.execute(
f"SELECT * FROM artists WHERE {where} LIMIT 1",
tuple(str(artist_id) for _ in id_columns),
)
artist_row = cursor.fetchone()
if not artist_row:
return {'success': False, 'error': f'Artist with ID {artist_id} not found'}

View file

@ -40,7 +40,7 @@ if [ "$CURRENT_UID" != "$PUID" ] || [ "$CURRENT_GID" != "$PGID" ]; then
DATA_OWNER=$(stat -c '%u:%g' /app/data 2>/dev/null || echo "unknown")
if [ "$DATA_OWNER" != "$PUID:$PGID" ]; then
echo "🔒 Fixing permissions on app directories..."
chown -R soulsync:soulsync /app/config /app/data /app/logs /app/downloads /app/Transfer /app/Staging /app/Stream 2>/dev/null || true
chown -R soulsync:soulsync /app/config /app/data /app/logs /app/downloads /app/Transfer /app/Staging /app/Stream /app/storage 2>/dev/null || true
else
echo "✅ App directory permissions already correct"
fi

View file

@ -1,76 +1,116 @@
## Summary
# SoulSync 2.6.4 — Merge `dev``main`
Adds torrent and usenet as release-oriented download sources backed by Prowlarr and the configured downloader clients. These sources can download full releases, stage the resulting audio files, and let SoulSync match/import the requested tracks through the existing post-processing pipeline.
Patch release on top of 2.6.3. Headline:
This PR also improves the torrent/usenet user experience with clearer release-download progress, source-aware service labels, library history provenance, and safer staged-release matching for album files that include featured-artist or bonus-track filename noise.
- **#721 — Usenet album bundles stuck on "downloading release" when SAB History flips before storage lands.** Reported by @IamGroot60 against 2.6.3, validated on the `fix/usenet-bundle-save-path-handoff` branch, merged via PR #723.
## Scope
Everything from 2.6.3 also rolls in unchanged (was bumped on dev but never tagged / published to main / docker, so this is the first time these changes reach users).
- Adds torrent and usenet plugin support for release downloads.
- Adds album-bundle staging for single-source torrent/usenet album downloads.
- Keeps hybrid album downloads on per-track-capable sources by excluding torrent/usenet from hybrid album per-track searches.
- Allows torrent/usenet in hybrid for non-album track downloads, such as playlist singles and wishlist tracks.
- Selects the requested audio file from completed release folders instead of importing the first audio file blindly.
- Reports live album-bundle progress to the Downloads page and download modal.
- Records torrent/usenet provenance in library history.
- Adds UI polish for release-first download states.
---
## Behavior Gates
## 2.6.4 — the patch
- Users who do not select torrent or usenet as a download source should not hit the new download paths.
- Single-source `torrent` / `usenet` album downloads use the release staging flow.
- Hybrid album downloads skip torrent/usenet and continue through the existing per-track source chain.
- Hybrid non-album downloads may use torrent/usenet when they are included in the hybrid order.
### #721 — Usenet album bundle stuck on "downloading release" when SAB History flips before `storage` lands
Follow-up to the 2.6.3 queue→history handoff fix (#706). 2.6.3 covered the gap where SAB removes a job from the queue before adding it to history. **2.6.4** covers a second-stage gap: SAB flips `status` to `Completed` in History a few seconds **before** its post-processing writes the final `storage` field.
## Notable Implementation Details
Pre-fix: `poll_album_download` saw the first `Completed` read with `save_path=None` and bailed. The bundle plugin marked the batch failed, but the UI froze on the last `downloading progress=0.61` emit because the terminal `failed` emit never registered (renderer holds the last-known progress).
- Torrent/usenet release downloads use private per-batch staging folders under the configured album-bundle staging root.
- Post-processing receives the real source label (`torrent` / `usenet`) so library history no longer falls back to `Soulseek`.
- Staging matching strips only conservative noise like `(feat. Artist)` and `(Bonus Track)` while preserving meaningful version text like `remix`, `extended`, `live`, and `acoustic`.
- When a staged release does not contain a requested track, the task is marked not found instead of repeatedly searching/downloading the same release.
- Completed release downloads expose all discovered audio files so post-processing can choose the best matching track.
- **`poll_album_download`**: separate transient counter for "completed but no save_path." Tolerates up to `transient_miss_threshold` (default 5) consecutive reads in that state — gives SAB ~10s to land the path. When it arrives, return normally. When it doesn't, fail loudly with an explicit error pointing at the missing field.
- **Sticky save_path**: earlier `downloading` reads with a non-empty `save_path` (qBit / Transmission set this from the start) remain cached. So torrent flows aren't affected by the retry path.
- **SAB adapter (`_parse_history_slot`)**: widened the save_path fallback chain — `storage``path``download_path``dirname`. Covers SAB version differences (older builds populated `path`) and forks that expose `download_path` or `dirname`. Whitespace-only values skipped. `incomplete_path` intentionally NOT in the chain — it'd bypass the retry window and point at the in-progress staging dir.
- **Diagnostic**: loud debug log when none of the known fields land, dumping the slot keys so we can grow `_HISTORY_SAVE_PATH_KEYS` if a fork ships a novel field name.
## Testing
**9 new tests**:
- `test_album_bundle.py` (3): late-save_path arrival recovers; threshold-exhausted fails cleanly; sticky save_path keeps torrent flows working.
- `test_usenet_client_adapters.py` (6): each fallback field tier, whitespace-only skip, all-empty returns None, `incomplete_path` ignored.
Manually tested:
132 album-bundle + usenet tests pass. Strictly additive — zero impact on users whose SAB returns `storage` on the first Completed read.
- Torrent-only album download for `good kid, m.A.A.d city (Deluxe)`.
- Torrent playlist/single-track flow.
- Album-bundle progress in the download modal and Downloads page.
- Torrent source labeling in library history for new imports.
- Staging match behavior for featured-artist filenames and bonus-track labels.
- Quarantine behavior for wrong-version matches.
---
Automated tests run during development:
## Everything else from 2.6.3 (carried forward)
```bash
.venv/bin/python -m pytest \
tests/downloads/test_downloads_status.py \
tests/test_album_bundle_dispatch.py \
tests/downloads/test_downloads_staging.py \
tests/test_torrent_usenet_plugins.py
```
### Fixes
```bash
.venv/bin/python -m pytest \
tests/downloads/test_downloads_validation.py \
tests/test_manual_pick_no_auto_retry.py \
tests/downloads/test_downloads_post_processing.py \
tests/downloads/test_downloads_task_worker.py \
tests/imports/test_import_side_effects.py
```
**#715 — Soulseek album downloads stuck on "failed" after slskd finished the release.**
`core/soulseek_client._resolve_downloaded_album_file` probed 3 hard-coded candidate paths. On the common slskd config `directories.downloads.username = true`, files land at `<download_dir>/<username>/<filename>` — none of the 3 candidates carried a username segment, so every file looked locally missing and the bundle poll silently spun for ~30 minutes before marking the batch failed.
Focused checks also passed for:
- Lifted the per-track flow's recursive walk-by-basename helper into `core/downloads/file_finder.py` (`find_completed_audio_file`). Bundle resolver now delegates to it. Default-slskd users see zero behavior change (3-candidate fast path preserved).
- Bundle poll detects "slskd reports Completed but local file can't be resolved past a 45s grace window" → exits early with explicit log line pointing at the likely `soulseek.download_path` mismatch.
- Misleading `"(0 tracks, quality=)"` log on the preflight-reuse path fixed.
- **17 new tests** pin every slskd layout (flat, username-prefixed, full-tree-preserved, deep nested, dedup-suffix, quarantine-skip, YouTube/Tidal encoded, transfer-dir fallback, fuzzy variants).
- staged release feature suffix matching
- bonus-track title matching
- wrong-version separation
- private torrent album staging miss handling
- torrent/usenet history source labels
**Auto-Sync ListenBrainz pipelines stuck on `Refreshing:` for 5+ minutes.**
Refresh path ran `_maybe_discover` inline AND Phase 2 ran the same matching engine via `run_playlist_discovery_worker`. LB tracks discovered twice; refresh-side run blocked with zero progress emission. Also: LB manager only exposed `update_all_playlists` (refreshing one playlist re-pulled all 12+ cached playlists). Also: LB adapter had a silent `except Exception: pass` masking real API failures.
## Reviewer Notes
- Pipeline sets `skip_discovery=True` on refresh config; Phase 2 handles discovery with proper progress emits.
- New `LBManager.refresh_playlist(mbid)` targeted refresh.
- LB adapter logs exceptions with traceback at warning level + returns `None`.
- **12 new tests**.
- This is intentionally gated behind torrent/usenet source selection, but it is still a new release-oriented download path and should be considered beta/experimental for first release.
- Remote downloader setups need SoulSync to be able to read the downloader save path. Local/all-in-one setups should be the easiest path.
- Existing library history rows that were previously recorded as `Soulseek` are not backfilled by this PR.
- Release matching is naturally fuzzier than track-native sources, so reviewer focus should stay on false positives, version handling, and staged-file selection.
**Wishlist: harden Spotify backfill — poisoned `tn=1` can't mask a lean album.**
Spotify-API backfill that hydrates `release_date` / `total_tracks` was coupled to the "track_number missing" branch, so a poisoned default-1 track_number short-circuited it. Lifted to `core/downloads/track_metadata_backfill.py` with split concerns — track-number resolution keeps its precedence chain; album hydration runs whenever `release_date` / `total_tracks` missing, independent of track_number. Single API call still serves both. Also `core/wishlist/routes.py:_build_track_data` no longer defaults `track_number=1` / `disc_number=1` / `total_tracks=1` / `release_date=''`. **24 new tests**.
**Wishlist: fix three regressions causing all imports to land as track 01.**
Track→dict conversion in payload helpers dropped everything except `album.name`; Deezer-sourced discovery matches saved without `track_number`/`disc_number`; import pipeline only consulted `album_info.track_number` before falling to the filename. Track_number resolution chain lifted into `core/imports/track_number.py:resolve_track_number` with 18 unit tests.
**Wishlist: only engage album-bundle when several tracks from the same album are missing.**
New `core/wishlist/album_grouping.py`. Bundle path only engages when an album has ≥2 missing tracks; single-track items take the cheaper per-track path. Configurable via `wishlist.album_bundle_min_tracks`.
**Wishlist: distinguish Queued from Analyzing batches in the UI.**
**Album-bundle staging: clean Soulseek copies + sweep orphans at startup.**
Cleanup gate extended to include `soulseek` (was torrent/usenet only). New `sweep_orphan_album_bundle_staging` runs once at server boot. **12 new tests**.
**Usenet album poll: tolerate SAB queue→history handoff (#706).**
**Discogs: strip artist disambiguation suffixes everywhere (#634).**
**Library: Enhanced / Standard view toggle persists per browser.**
**Fix popup: manual matches survive Playlist Pipeline runs.**
**Fix popup: artist + track fields no longer surface unrelated covers.**
### UX overhauls
**Dashboard enrichment panel — equalizer-bar redesign.** 11 speedometer tiles → 11 vertical VU-meter equalizer bars in one symmetric flex row. Brand-logo avatar disc above each bar (Spotify/Apple Music/Deezer/Last.fm/Genius/MusicBrainz/AudioDB/Tidal/Qobuz/Discogs/Amazon with initial-letter CDN-fail fallback); peak-flash on cpm step-up; rolling counter; glass-surface reflection puddle. Last.fm circle-clipped; Tidal/Qobuz/Discogs/Amazon inverted to white silhouettes.
**Auto-Sync manager — full visual overhaul.** Selector-based override layer (zero JS/HTML changes). Every surface inside the modal restyled to match the dashboard's glassy / accent-radial aesthetic.
**Auto-Sync — weekly board cards now match the hourly board.** Same Run-now button, unschedule X, next-run countdown, health badge. Weekly cards now draggable between day columns.
**Auto-Sync sidebar — brand logo on each source-group header.**
**Sync page tabs — brand-logo chips with active label pill.** 14 tabs collapsed from cramped labeled pills to circular brand-logo chips; active tab swells into a pill with its label inline. `Link` variants (Spotify Link / Deezer Link / iTunes Link) carry a small chain-link badge bottom-right.
### Architectural lifts
**Unified Playlist Sources layer.** `PlaylistSource` ABC + registry in `core/playlists/sources/`. Refresh handler dropped from ~190 lines of if/elif to ~80 lines. ListenBrainz / Last.fm / SoulSync Discovery are now Sync-page tabs.
**Auto-Sync schedule types — weekday + time.** New Weekly Board tab on the Auto-Sync manager.
**iTunes / Apple Music link import.** New iTunes Link tab on the Sync page.
---
## Test plan
- [x] 132 album-bundle + usenet tests pass (the new #721 path)
- [x] 488 downloads tests pass (full suite)
- [x] ~90 new unit tests across the cycle, including 9 new for #721
- [x] Smoke: dashboard equalizer renders w/ brand logos, peak-flash on cpm increase
- [x] Smoke: Auto-Sync manager renders glass overhaul, hourly + weekly cards both have action rows
- [x] Smoke: Sync page tab strip renders as logo chips; active expands; Link variants show chain-link badge
- [ ] Live: @IamGroot60 to re-test Forty Licks usenet bundle on dev (build with the #721 fix applied)
- [ ] Live: Soulseek album download on a username-subdir slskd config completes cleanly (#715, user-validated post-merge)
- [ ] Live: bundle staging dir cleaned on completion (user-validated post-merge)
---
## Post-merge checklist
- [ ] Tag `v2.6.4` on `main`
- [ ] Trigger `docker-publish.yml` with `version_tag: 2.6.4` to push `boulderbadgedad/soulsync:2.6.4` + `ghcr.io/nezreka/soulsync:2.6.4` (default already updated)
- [ ] Discord release announcement (auto-fired by the workflow)
- [ ] Reply on #721 with the 2.6.4 release link

View file

@ -0,0 +1,957 @@
"""Tests for the lifted, source-agnostic discovery route helpers in
``core.discovery.endpoints``.
These pin the exact behavior the per-source ``convert_<source>_results_to_spotify_tracks``
functions had in web_server.py, so the lift is provably 1:1. Each input shape
the originals handled is exercised here.
"""
from __future__ import annotations
import threading
from core.discovery.endpoints import (
convert_results_to_spotify_tracks,
cancel_sync,
delete_playlist_state,
get_sync_status,
playlist_name_strict as _pl_name_strict,
playlist_name_safe as _pl_name_safe,
)
class _FakeFuture:
def __init__(self):
self.cancelled = False
def cancel(self):
self.cancelled = True
def _cancel_infra():
"""Fresh sync infra (lock + the two shared dicts) for cancel_sync tests."""
return {
'sync_lock': threading.Lock(),
'sync_states': {},
'active_sync_workers': {},
}
# ---------------------------------------------------------------------------
# spotify_data (manual-fix) shape
# ---------------------------------------------------------------------------
def test_spotify_data_shape_basic():
results = [{
'spotify_data': {
'id': 'sp1', 'name': 'Song', 'artists': ['A'], 'album': 'Alb',
'duration_ms': 1234,
}
}]
assert convert_results_to_spotify_tracks(results, 'Tidal') == [{
'id': 'sp1', 'name': 'Song', 'artists': ['A'], 'album': 'Alb',
'duration_ms': 1234,
}]
def test_spotify_data_duration_defaults_to_zero():
results = [{'spotify_data': {'id': 'x', 'name': 'n', 'artists': [], 'album': 'a'}}]
out = convert_results_to_spotify_tracks(results, 'Deezer')
assert out[0]['duration_ms'] == 0
def test_spotify_data_includes_track_and_disc_number_when_present():
results = [{'spotify_data': {
'id': 'x', 'name': 'n', 'artists': [], 'album': 'a',
'track_number': 5, 'disc_number': 2,
}}]
out = convert_results_to_spotify_tracks(results, 'Qobuz')
assert out[0]['track_number'] == 5
assert out[0]['disc_number'] == 2
def test_spotify_data_omits_track_disc_when_absent_or_falsy():
# track_number/disc_number of 0 are falsy -> omitted, matching original.
results = [{'spotify_data': {
'id': 'x', 'name': 'n', 'artists': [], 'album': 'a',
'track_number': 0, 'disc_number': 0,
}}]
out = convert_results_to_spotify_tracks(results, 'YouTube')
assert 'track_number' not in out[0]
assert 'disc_number' not in out[0]
# ---------------------------------------------------------------------------
# spotify_track + status_class == 'found' (auto-discovery) shape
# ---------------------------------------------------------------------------
def test_auto_discovery_shape_full():
results = [{
'spotify_track': 'Track', 'status_class': 'found',
'spotify_id': 'id9', 'spotify_artist': 'Artist', 'spotify_album': 'Album',
}]
assert convert_results_to_spotify_tracks(results, 'ListenBrainz') == [{
'id': 'id9', 'name': 'Track', 'artists': ['Artist'], 'album': 'Album',
'duration_ms': 0,
}]
def test_auto_discovery_defaults_when_fields_missing():
results = [{'spotify_track': 'T', 'status_class': 'found'}]
out = convert_results_to_spotify_tracks(results, 'Spotify Public')
assert out == [{
'id': 'unknown', 'name': 'T', 'artists': ['Unknown Artist'],
'album': 'Unknown Album', 'duration_ms': 0,
}]
def test_auto_discovery_empty_artist_yields_unknown_artist():
results = [{
'spotify_track': 'T', 'status_class': 'found', 'spotify_artist': '',
}]
out = convert_results_to_spotify_tracks(results, 'Tidal')
assert out[0]['artists'] == ['Unknown Artist']
# ---------------------------------------------------------------------------
# skip / mixed / empty
# ---------------------------------------------------------------------------
def test_auto_discovery_requires_found_status():
# spotify_track present but status_class != 'found' -> skipped.
results = [{'spotify_track': 'T', 'status_class': 'not_found'}]
assert convert_results_to_spotify_tracks(results, 'Tidal') == []
def test_result_matching_neither_shape_is_skipped():
results = [{'irrelevant': True}, {'spotify_track': 'T'}] # 2nd has no status_class
assert convert_results_to_spotify_tracks(results, 'Tidal') == []
def test_mixed_results_preserve_order():
results = [
{'spotify_data': {'id': '1', 'name': 'a', 'artists': [], 'album': ''}},
{'irrelevant': True},
{'spotify_track': 'b', 'status_class': 'found', 'spotify_id': '2'},
]
out = convert_results_to_spotify_tracks(results, 'Tidal')
assert [t['id'] for t in out] == ['1', '2']
def test_empty_input():
assert convert_results_to_spotify_tracks([], 'Tidal') == []
def test_spotify_data_takes_precedence_over_auto_fields():
# A result carrying both shapes uses spotify_data (the if-branch wins),
# matching the original if/elif ordering.
results = [{
'spotify_data': {'id': 'D', 'name': 'd', 'artists': [], 'album': ''},
'spotify_track': 'IGNORED', 'status_class': 'found', 'spotify_id': 'A',
}]
out = convert_results_to_spotify_tracks(results, 'Tidal')
assert out[0]['id'] == 'D'
# ---------------------------------------------------------------------------
# cancel_sync
# ---------------------------------------------------------------------------
def test_cancel_sync_not_found_returns_404():
body, code = cancel_sync({}, 'missing', label='Tidal',
not_found_message='Tidal playlist not found', **_cancel_infra())
assert code == 404
assert body == {"error": "Tidal playlist not found"}
def test_cancel_sync_cancels_active_worker_and_reverts_state():
infra = _cancel_infra()
infra['sync_states']['sp1'] = {"status": "running"}
infra['active_sync_workers']['sp1'] = _FakeFuture()
states = {'pl': {'phase': 'syncing', 'sync_playlist_id': 'sp1',
'sync_progress': {'done': 1}, 'last_accessed': 0}}
body, code = cancel_sync(states, 'pl', label='Tidal',
not_found_message='nf', **infra)
assert code == 200
assert body == {"success": True, "message": "Tidal sync cancelled"}
# sync marked cancelled, worker removed
assert infra['sync_states']['sp1'] == {"status": "cancelled"}
assert 'sp1' not in infra['active_sync_workers']
# state reverted
assert states['pl']['phase'] == 'discovered'
assert states['pl']['sync_playlist_id'] is None
assert states['pl']['sync_progress'] == {}
assert states['pl']['last_accessed'] != 0 # touched
def test_cancel_sync_worker_absent_from_active_map_is_safe():
infra = _cancel_infra() # active_sync_workers empty
states = {'pl': {'phase': 'syncing', 'sync_playlist_id': 'sp1'}}
body, code = cancel_sync(states, 'pl', label='Deezer', not_found_message='nf', **infra)
assert code == 200
assert infra['sync_states']['sp1'] == {"status": "cancelled"}
def test_cancel_sync_no_sync_in_progress_still_reverts():
infra = _cancel_infra()
states = {'pl': {'phase': 'discovered'}} # no sync_playlist_id
body, code = cancel_sync(states, 'pl', label='Qobuz', not_found_message='nf', **infra)
assert code == 200
assert states['pl']['sync_playlist_id'] is None
assert states['pl']['sync_progress'] == {}
assert infra['sync_states'] == {} # nothing cancelled
def test_cancel_sync_label_in_message():
infra = _cancel_infra()
states = {'pl': {}}
body, _ = cancel_sync(states, 'pl', label='iTunes Link', not_found_message='nf', **infra)
assert body["message"] == "iTunes Link sync cancelled"
def test_cancel_sync_exception_returns_500():
infra = _cancel_infra()
states = {'pl': object()} # not subscriptable -> raises inside try
body, code = cancel_sync(states, 'pl', label='Tidal', not_found_message='nf', **infra)
assert code == 500
assert "error" in body
# ---------------------------------------------------------------------------
# delete_playlist_state
# ---------------------------------------------------------------------------
def test_delete_not_found_returns_404():
body, code = delete_playlist_state({}, 'missing', label='Tidal',
not_found_message='Tidal playlist not found')
assert code == 404
assert body == {"error": "Tidal playlist not found"}
def test_delete_cancels_discovery_future_and_removes_state():
fut = _FakeFuture()
states = {'pl': {'discovery_future': fut}}
body, code = delete_playlist_state(states, 'pl', label='Tidal', not_found_message='nf')
assert code == 200
assert body == {"success": True, "message": "Playlist deleted"}
assert fut.cancelled is True
assert 'pl' not in states
def test_delete_without_discovery_future_still_removes():
states = {'pl': {'phase': 'discovered'}} # no discovery_future
body, code = delete_playlist_state(states, 'pl', label='Deezer', not_found_message='nf')
assert code == 200
assert 'pl' not in states
def test_delete_falsy_discovery_future_not_cancelled():
states = {'pl': {'discovery_future': None}}
body, code = delete_playlist_state(states, 'pl', label='Qobuz', not_found_message='nf')
assert code == 200
assert 'pl' not in states
def test_delete_exception_returns_500():
fut = object() # no .cancel() -> AttributeError inside try
states = {'pl': {'discovery_future': fut}}
body, code = delete_playlist_state(states, 'pl', label='Tidal', not_found_message='nf')
assert code == 500
assert "error" in body
# state NOT deleted because the exception fired before del
assert 'pl' in states
# ---------------------------------------------------------------------------
# playlist-name accessors
# ---------------------------------------------------------------------------
class _Obj:
def __init__(self, name):
self.name = name
def test_name_attr_or_unknown():
from core.discovery.endpoints import playlist_name_attr_or_unknown as g
assert g({'playlist': _Obj('My PL')}) == 'My PL'
assert g({'playlist': {'name': 'dict'}}) == 'Unknown Playlist' # dict has no .name attr
assert g({}) == 'Unknown Playlist'
assert g({'playlist': None}) == 'Unknown Playlist'
def test_name_strict():
from core.discovery.endpoints import playlist_name_strict as g
assert g({'playlist': {'name': 'X'}}) == 'X'
import pytest
with pytest.raises(KeyError):
g({}) # strict -> raises, matching originals
def test_name_safe():
from core.discovery.endpoints import playlist_name_safe as g
assert g({'playlist': {'name': 'X'}}) == 'X'
assert g({}) == 'Unknown Playlist'
assert g({'playlist': {}}) == 'Unknown Playlist'
# ---------------------------------------------------------------------------
# get_sync_status
# ---------------------------------------------------------------------------
def _activity_recorder():
calls = []
def add_activity_item(user, action, desc, when):
calls.append((user, action, desc, when))
return calls, add_activity_item
def _status_kwargs(infra, add_activity_item, *, not_found_message='nf',
error_label='Tidal', activity_subject='Tidal playlist',
name_getter=None):
return dict(
not_found_message=not_found_message, error_label=error_label,
activity_subject=activity_subject,
playlist_name_getter=name_getter or _pl_name_safe,
add_activity_item=add_activity_item,
sync_lock=infra['sync_lock'], sync_states=infra['sync_states'],
)
def test_status_not_found():
infra = _cancel_infra()
calls, add = _activity_recorder()
body, code = get_sync_status({}, 'missing',
**_status_kwargs(infra, add, not_found_message='Tidal playlist not found'))
assert code == 404 and body == {"error": "Tidal playlist not found"}
assert calls == []
def test_status_no_sync_in_progress():
infra = _cancel_infra()
calls, add = _activity_recorder()
states = {'pl': {'phase': 'discovered'}} # no sync_playlist_id
body, code = get_sync_status(states, 'pl', **_status_kwargs(infra, add))
assert code == 404 and body == {"error": "No sync in progress"}
def test_status_running_returns_shape_without_mutation_or_activity():
infra = _cancel_infra()
infra['sync_states']['sp1'] = {"status": "running", "progress": {"n": 2}}
calls, add = _activity_recorder()
states = {'pl': {'phase': 'syncing', 'sync_playlist_id': 'sp1'}}
body, code = get_sync_status(states, 'pl', **_status_kwargs(infra, add))
assert code == 200
assert body == {
'phase': 'syncing', 'sync_status': 'running',
'progress': {"n": 2}, 'complete': False, 'error': None,
}
assert states['pl']['phase'] == 'syncing' # unchanged
assert calls == []
def test_status_finished_sets_complete_and_posts_activity():
infra = _cancel_infra()
infra['sync_states']['sp1'] = {"status": "finished", "progress": {"done": 9}}
calls, add = _activity_recorder()
states = {'pl': {'phase': 'syncing', 'sync_playlist_id': 'sp1',
'playlist': {'name': 'Mix'}}}
body, code = get_sync_status(states, 'pl', **_status_kwargs(
infra, add, activity_subject='Spotify Link playlist',
name_getter=_pl_name_strict))
assert code == 200
assert body['complete'] is True
assert states['pl']['phase'] == 'sync_complete'
assert states['pl']['sync_progress'] == {"done": 9}
assert calls == [("", "Sync Complete", "Spotify Link playlist 'Mix' synced successfully", "Now")]
def test_status_error_reverts_and_posts_failed_activity():
infra = _cancel_infra()
infra['sync_states']['sp1'] = {"status": "error", "error": "boom"}
calls, add = _activity_recorder()
states = {'pl': {'phase': 'syncing', 'sync_playlist_id': 'sp1',
'playlist': {'name': 'Mix'}}}
body, code = get_sync_status(states, 'pl', **_status_kwargs(
infra, add, activity_subject='YouTube playlist', name_getter=_pl_name_safe))
assert code == 200
assert body['error'] == "boom"
assert states['pl']['phase'] == 'discovered'
assert calls == [("", "Sync Failed", "YouTube playlist 'Mix' sync failed", "Now")]
def test_status_strict_getter_missing_playlist_raises_500_after_partial_mutation():
infra = _cancel_infra()
infra['sync_states']['sp1'] = {"status": "finished", "progress": {}}
calls, add = _activity_recorder()
states = {'pl': {'phase': 'syncing', 'sync_playlist_id': 'sp1'}} # no 'playlist'
body, code = get_sync_status(states, 'pl', **_status_kwargs(
infra, add, error_label='Deezer', name_getter=_pl_name_strict))
assert code == 500 and "error" in body
# phase was set to sync_complete BEFORE the strict getter raised (1:1).
assert states['pl']['phase'] == 'sync_complete'
assert calls == [] # activity never posted
# ---------------------------------------------------------------------------
# get_discovery_status
# ---------------------------------------------------------------------------
def test_discovery_status_not_found():
from core.discovery.endpoints import get_discovery_status
body, code = get_discovery_status({}, 'missing',
not_found_message='Tidal discovery not found',
error_label='Tidal')
assert code == 404 and body == {"error": "Tidal discovery not found"}
def test_discovery_status_builds_response_and_bumps_access():
from core.discovery.endpoints import get_discovery_status
state = {
'phase': 'discovered', 'status': 'done', 'discovery_progress': 100,
'spotify_matches': 8, 'spotify_total': 10,
'discovery_results': [{'x': 1}], 'last_accessed': 0,
}
states = {'pl': state}
body, code = get_discovery_status(states, 'pl',
not_found_message='nf', error_label='Tidal')
assert code == 200
assert body == {
'phase': 'discovered', 'status': 'done', 'progress': 100,
'spotify_matches': 8, 'spotify_total': 10,
'results': [{'x': 1}], 'complete': True,
}
assert state['last_accessed'] != 0
def test_discovery_status_complete_false_when_not_discovered():
from core.discovery.endpoints import get_discovery_status
state = {
'phase': 'discovering', 'status': 'running', 'discovery_progress': 40,
'spotify_matches': 2, 'spotify_total': 10, 'discovery_results': [],
}
body, code = get_discovery_status({'pl': state}, 'pl',
not_found_message='nf', error_label='Deezer')
assert code == 200 and body['complete'] is False
def test_discovery_status_missing_field_raises_500():
from core.discovery.endpoints import get_discovery_status
# state missing 'status' -> strict access raises -> 500 (matches original)
states = {'pl': {'phase': 'x'}}
body, code = get_discovery_status(states, 'pl',
not_found_message='nf', error_label='Beatport')
assert code == 500 and "error" in body
# ---------------------------------------------------------------------------
# reset_playlist
# ---------------------------------------------------------------------------
def test_reset_not_found():
from core.discovery.endpoints import reset_playlist
body, code = reset_playlist({}, 'missing', label='Tidal',
not_found_message='Tidal playlist not found')
assert code == 404 and body == {"error": "Tidal playlist not found"}
def test_reset_clears_state_preserving_playlist_and_cancels_future():
from core.discovery.endpoints import reset_playlist
fut = _FakeFuture()
state = {
'playlist': {'name': 'keep me'},
'phase': 'discovered', 'status': 'done',
'discovery_results': [{'x': 1}], 'discovery_progress': 100,
'spotify_matches': 5, 'sync_playlist_id': 'sp', 'last_accessed': 0,
'converted_spotify_playlist_id': 'cv', 'download_process_id': 'dp',
'sync_progress': {'n': 1}, 'discovery_future': fut,
}
states = {'pl': state}
body, code = reset_playlist(states, 'pl', label='Tidal', not_found_message='nf')
assert code == 200
assert body == {"success": True, "message": "Playlist reset to fresh phase"}
assert fut.cancelled is True
# cleared
assert state['phase'] == 'fresh'
assert state['status'] == 'fresh'
assert state['discovery_results'] == []
assert state['discovery_progress'] == 0
assert state['spotify_matches'] == 0
assert state['sync_playlist_id'] is None
assert state['converted_spotify_playlist_id'] is None
assert state['download_process_id'] is None
assert state['sync_progress'] == {}
assert state['discovery_future'] is None
assert state['last_accessed'] != 0
# original playlist payload preserved
assert state['playlist'] == {'name': 'keep me'}
def test_reset_without_discovery_future():
from core.discovery.endpoints import reset_playlist
state = {'phase': 'discovered'} # no discovery_future key
body, code = reset_playlist({'pl': state}, 'pl', label='Deezer', not_found_message='nf')
assert code == 200
assert state['phase'] == 'fresh'
def test_reset_exception_returns_500():
from core.discovery.endpoints import reset_playlist
fut = object() # .cancel missing -> AttributeError in try
states = {'pl': {'discovery_future': fut}}
body, code = reset_playlist(states, 'pl', label='Qobuz', not_found_message='nf')
assert code == 500 and "error" in body
# ---------------------------------------------------------------------------
# get_playlist_states (bulk hydration list)
# ---------------------------------------------------------------------------
def test_playlist_states_builds_list_and_bumps_access():
from core.discovery.endpoints import get_playlist_states
s1 = {'phase': 'discovered', 'status': 'done', 'discovery_progress': 100,
'spotify_matches': 3, 'spotify_total': 5, 'discovery_results': [{'a': 1}],
'converted_spotify_playlist_id': 'cv', 'download_process_id': 'dp',
'last_accessed': 0}
states = {'k1': s1}
body, code = get_playlist_states(states, error_label='Tidal', info_log_label='Tidal')
assert code == 200
assert body == {"states": [{
'playlist_id': 'k1', 'phase': 'discovered', 'status': 'done',
'discovery_progress': 100, 'spotify_matches': 3, 'spotify_total': 5,
'discovery_results': [{'a': 1}], 'converted_spotify_playlist_id': 'cv',
'download_process_id': 'dp', 'last_accessed': s1['last_accessed'],
}]}
assert s1['last_accessed'] != 0 # bumped
def test_playlist_states_empty():
from core.discovery.endpoints import get_playlist_states
body, code = get_playlist_states({}, error_label='Deezer', info_log_label='Deezer')
assert code == 200 and body == {"states": []}
def test_playlist_states_optional_ids_default_none():
from core.discovery.endpoints import get_playlist_states
state = {'phase': 'fresh', 'status': 'fresh', 'discovery_progress': 0,
'spotify_matches': 0, 'spotify_total': 0, 'discovery_results': []}
body, _ = get_playlist_states({'k': state}, error_label='iTunes Link')
assert body['states'][0]['converted_spotify_playlist_id'] is None
assert body['states'][0]['download_process_id'] is None
def test_playlist_states_missing_required_field_raises_500():
from core.discovery.endpoints import get_playlist_states
# state missing 'phase' -> strict access raises -> 500
body, code = get_playlist_states({'k': {'status': 'x'}}, error_label='Qobuz')
assert code == 500 and "error" in body
# ---------------------------------------------------------------------------
# start_sync
# ---------------------------------------------------------------------------
def _start_kwargs(infra, *, convert_fn=None, name='PL', image='img',
activity_label='Tidal', error_label='Tidal',
not_found_message='Tidal playlist not found',
not_ready_message='Tidal playlist not ready for sync',
sync_id_prefix='tidal'):
submitted = []
def submit(sync_playlist_id, playlist_name, tracks, image_url):
rec = (sync_playlist_id, playlist_name, tracks, image_url)
submitted.append(rec)
return f"future:{sync_playlist_id}"
calls, add = _activity_recorder()
kw = dict(
sync_id_prefix=sync_id_prefix, not_found_message=not_found_message,
not_ready_message=not_ready_message,
convert_fn=convert_fn or (lambda r: [{'id': 't'}]),
playlist_name_getter=lambda s: name, playlist_image_getter=lambda s: image,
activity_label=activity_label, error_label=error_label,
sync_lock=infra['sync_lock'], sync_states=infra['sync_states'],
active_sync_workers=infra['active_sync_workers'],
submit_sync_task=submit, add_activity_item=add,
)
return kw, submitted, calls
def test_start_sync_not_found():
from core.discovery.endpoints import start_sync
infra = _cancel_infra()
kw, _, _ = _start_kwargs(infra)
body, code = start_sync({}, 'missing', **kw)
assert code == 404 and body == {"error": "Tidal playlist not found"}
def test_start_sync_not_ready_phase():
from core.discovery.endpoints import start_sync
infra = _cancel_infra()
kw, _, _ = _start_kwargs(infra)
states = {'pl': {'phase': 'discovering'}}
body, code = start_sync(states, 'pl', **kw)
assert code == 400 and body == {"error": "Tidal playlist not ready for sync"}
def test_start_sync_no_matches():
from core.discovery.endpoints import start_sync
infra = _cancel_infra()
kw, _, _ = _start_kwargs(infra, convert_fn=lambda r: [])
states = {'pl': {'phase': 'discovered', 'discovery_results': []}}
body, code = start_sync(states, 'pl', **kw)
assert code == 400 and body == {"error": "No Spotify matches found for sync"}
def test_start_sync_happy_path():
from core.discovery.endpoints import start_sync
infra = _cancel_infra()
kw, submitted, calls = _start_kwargs(
infra, convert_fn=lambda r: [{'id': 'a'}, {'id': 'b'}],
name='My Mix', image='cover.jpg',
activity_label='Spotify Link', error_label='Spotify Public',
sync_id_prefix='spotify_public')
states = {'h1': {'phase': 'discovered', 'discovery_results': [1, 2]}}
body, code = start_sync(states, 'h1', **kw)
assert code == 200
assert body == {"success": True, "sync_playlist_id": "spotify_public_h1"}
# state mutated
assert states['h1']['phase'] == 'syncing'
assert states['h1']['sync_playlist_id'] == 'spotify_public_h1'
assert states['h1']['sync_progress'] == {}
# sync infra seeded + worker registered
assert infra['sync_states']['spotify_public_h1'] == {"status": "starting", "progress": {}}
assert infra['active_sync_workers']['spotify_public_h1'] == 'future:spotify_public_h1'
# submit got name/tracks/image
assert submitted == [('spotify_public_h1', 'My Mix', [{'id': 'a'}, {'id': 'b'}], 'cover.jpg')]
# activity uses activity_label (not error_label) + track count
assert calls == [("", "Spotify Link Sync Started", "'My Mix' - 2 tracks", "Now")]
def test_start_sync_allows_resync_phases():
from core.discovery.endpoints import start_sync
for phase in ('sync_complete', 'download_complete'):
infra = _cancel_infra()
kw, _, _ = _start_kwargs(infra)
states = {'pl': {'phase': phase, 'discovery_results': [1]}}
body, code = start_sync(states, 'pl', **kw)
assert code == 200, phase
def test_start_sync_exception_returns_500():
from core.discovery.endpoints import start_sync
infra = _cancel_infra()
def boom(r):
raise RuntimeError("convert blew up")
kw, _, _ = _start_kwargs(infra, convert_fn=boom)
states = {'pl': {'phase': 'discovered', 'discovery_results': [1]}}
body, code = start_sync(states, 'pl', **kw)
assert code == 500 and "error" in body
# ---------------------------------------------------------------------------
# first_artist extractors
# ---------------------------------------------------------------------------
def test_first_artist_str_or_obj():
from core.discovery.endpoints import first_artist_str_or_obj as g
assert g({'artists': ['A', 'B']}) == 'A'
assert g({'artists': [{'name': 'Obj'}]}) == 'Obj'
assert g({'artists': []}) == ''
assert g({}) == ''
def test_first_artist_plain():
from core.discovery.endpoints import first_artist_plain as g
assert g({'artists': ['A', 'B']}) == 'A'
assert g({'artists': []}) == ''
assert g({}) == ''
# ---------------------------------------------------------------------------
# update_discovery_match
# ---------------------------------------------------------------------------
class _FakeCacheDB:
def __init__(self):
self.saved = []
def save_discovery_cache_match(self, *args):
self.saved.append(args)
def _update_kwargs(*, json_data, cache_db=None, getter=None):
from core.discovery.endpoints import first_artist_plain
db = cache_db or _FakeCacheDB()
kw = dict(
source_log_label='tidal', error_label='Tidal',
original_track_key='tidal_track',
original_artist_getter=getter or first_artist_plain,
join_artist_names=lambda arts: ", ".join(arts),
extract_artist_name=lambda a: str(a),
build_fix_modal_spotify_data=lambda st: {'built': st['id']},
get_discovery_cache_key=lambda name, artist: (name.lower(), artist.lower()),
get_database=lambda: db,
get_active_discovery_source=lambda: 'spotify',
)
return (lambda: json_data), kw, db
def test_update_match_missing_fields():
from core.discovery.endpoints import update_discovery_match
gj, kw, _ = _update_kwargs(json_data={'identifier': 'p'}) # missing track_index/spotify_track
body, code = update_discovery_match({}, gj, **kw)
assert code == 400 and body == {'error': 'Missing required fields'}
def test_update_match_state_not_found():
from core.discovery.endpoints import update_discovery_match
gj, kw, _ = _update_kwargs(json_data={
'identifier': 'p', 'track_index': 0, 'spotify_track': {'id': 'x'}})
body, code = update_discovery_match({}, gj, **kw)
assert code == 404 and body == {'error': 'Discovery state not found'}
def test_update_match_invalid_index():
from core.discovery.endpoints import update_discovery_match
gj, kw, _ = _update_kwargs(json_data={
'identifier': 'p', 'track_index': 5,
'spotify_track': {'id': 'x', 'name': 'n', 'artists': [], 'album': 'a'}})
states = {'p': {'discovery_results': []}}
body, code = update_discovery_match(states, gj, **kw)
assert code == 400 and body == {'error': 'Invalid track index'}
def test_update_match_happy_path_full():
from core.discovery.endpoints import update_discovery_match
sp = {'id': 'sp9', 'name': 'New Song', 'artists': ['Art1', 'Art2'],
'album': 'Alb', 'duration_ms': 185000, 'image_url': 'cov.jpg'}
gj, kw, db = _update_kwargs(json_data={
'identifier': 'p', 'track_index': 0, 'spotify_track': sp})
result = {'status': 'not_found', 'tidal_track': {'name': 'Orig', 'artists': ['OrigArt']}}
states = {'p': {'discovery_results': [result], 'spotify_matches': 2}}
body, code = update_discovery_match(states, gj, **kw)
assert code == 200 and body['success'] is True
assert result['status'] == 'Found'
assert result['status_class'] == 'found'
assert result['spotify_track'] == 'New Song'
assert result['spotify_artist'] == 'Art1, Art2'
assert result['spotify_id'] == 'sp9'
assert result['duration'] == '3:05'
assert result['spotify_data'] == {'built': 'sp9'}
assert result['wing_it_fallback'] is False
assert result['manual_match'] is True
assert states['p']['spotify_matches'] == 3 # incremented (was not found)
# cache saved with normalized key + matched_data carrying image
assert len(db.saved) == 1
key0, key1, source, score, matched, oname, oartist = db.saved[0]
assert (key0, key1) == ('orig', 'origart')
assert source == 'spotify' and score == 1.0
assert matched['album'] == {'name': 'Alb', 'image_url': 'cov.jpg', 'images': [{'url': 'cov.jpg'}]}
assert oname == 'Orig' and oartist == 'OrigArt'
def test_update_match_no_increment_when_already_found():
from core.discovery.endpoints import update_discovery_match
sp = {'id': 'x', 'name': 'n', 'artists': ['A'], 'album': 'a', 'duration_ms': 0}
gj, kw, _ = _update_kwargs(json_data={'identifier': 'p', 'track_index': 0, 'spotify_track': sp})
result = {'status': 'Found', 'tidal_track': {}}
states = {'p': {'discovery_results': [result], 'spotify_matches': 5}}
body, code = update_discovery_match(states, gj, **kw)
assert code == 200
assert states['p']['spotify_matches'] == 5 # unchanged
assert result['duration'] == '0:00'
def test_update_match_cache_error_is_swallowed():
from core.discovery.endpoints import update_discovery_match
class _BoomDB:
def save_discovery_cache_match(self, *a):
raise RuntimeError("db down")
sp = {'id': 'x', 'name': 'n', 'artists': ['A'], 'album': 'a', 'duration_ms': 0}
gj, kw, _ = _update_kwargs(json_data={'identifier': 'p', 'track_index': 0, 'spotify_track': sp},
cache_db=_BoomDB())
states = {'p': {'discovery_results': [{'status': 'x', 'tidal_track': {}}], 'spotify_matches': 0}}
body, code = update_discovery_match(states, gj, **kw)
assert code == 200 and body['success'] is True # cache failure doesn't fail the request
def test_update_match_get_json_raises_returns_500():
from core.discovery.endpoints import update_discovery_match
def boom():
raise ValueError("bad json")
_, kw, _ = _update_kwargs(json_data={})
body, code = update_discovery_match({}, boom, **kw)
assert code == 500 and 'error' in body
# ---------------------------------------------------------------------------
# update_playlist_phase
# ---------------------------------------------------------------------------
_PHASES = ['fresh', 'discovering', 'discovered', 'syncing', 'sync_complete', 'downloading', 'download_complete']
def test_update_phase_not_found():
from core.discovery.endpoints import update_playlist_phase
body, code = update_playlist_phase({}, 'k', lambda: {'phase': 'fresh'},
not_found_message='Tidal playlist not found',
error_label='Tidal', valid_phases=_PHASES,
apply_extra_fields=False)
assert code == 404 and body == {"error": "Tidal playlist not found"}
def test_update_phase_missing_phase():
from core.discovery.endpoints import update_playlist_phase
states = {'k': {'phase': 'discovered'}}
body, code = update_playlist_phase(states, 'k', lambda: {},
not_found_message='nf', error_label='Tidal',
valid_phases=_PHASES, apply_extra_fields=False)
assert code == 400 and body == {"error": "Phase not provided"}
def test_update_phase_invalid():
from core.discovery.endpoints import update_playlist_phase
states = {'k': {'phase': 'discovered'}}
body, code = update_playlist_phase(states, 'k', lambda: {'phase': 'bogus'},
not_found_message='nf', error_label='Tidal',
valid_phases=_PHASES, apply_extra_fields=False)
assert code == 400 and 'Invalid phase' in body['error']
def test_update_phase_happy_no_extra_fields():
from core.discovery.endpoints import update_playlist_phase
state = {'phase': 'download_complete', 'last_accessed': 0}
body, code = update_playlist_phase({'k': state}, 'k',
lambda: {'phase': 'discovered',
'download_process_id': 'dp'},
not_found_message='nf', error_label='Tidal',
valid_phases=_PHASES, apply_extra_fields=False)
assert code == 200
assert body == {"success": True, "message": "Phase updated to discovered",
"old_phase": "download_complete", "new_phase": "discovered"}
assert state['phase'] == 'discovered'
assert state['last_accessed'] != 0
# apply_extra_fields=False -> download_process_id NOT applied (1:1 for Tidal/YouTube)
assert 'download_process_id' not in state
def test_update_phase_applies_extra_fields_when_enabled():
from core.discovery.endpoints import update_playlist_phase
state = {'phase': 'discovered'}
body, code = update_playlist_phase({'k': state}, 'k',
lambda: {'phase': 'downloading',
'download_process_id': 'dp7',
'converted_spotify_playlist_id': 'cv7'},
not_found_message='nf', error_label='Deezer',
valid_phases=_PHASES, apply_extra_fields=True)
assert code == 200
assert state['download_process_id'] == 'dp7'
assert state['converted_spotify_playlist_id'] == 'cv7'
def test_update_phase_youtube_parsed_allowed():
from core.discovery.endpoints import update_playlist_phase
yt_phases = ['fresh', 'parsed', 'discovering', 'discovered', 'syncing',
'sync_complete', 'downloading', 'download_complete']
state = {'phase': 'discovered'}
body, code = update_playlist_phase({'k': state}, 'k', lambda: {'phase': 'parsed'},
not_found_message='nf', error_label='YouTube',
valid_phases=yt_phases, apply_extra_fields=False)
assert code == 200 and state['phase'] == 'parsed'
def test_update_phase_exception_500():
from core.discovery.endpoints import update_playlist_phase
def boom():
raise ValueError('bad')
states = {'k': {'phase': 'discovered'}}
body, code = update_playlist_phase(states, 'k', boom, not_found_message='nf',
error_label='Tidal', valid_phases=_PHASES,
apply_extra_fields=False)
assert code == 500 and 'error' in body
# ---------------------------------------------------------------------------
# save_bubble_snapshot
# ---------------------------------------------------------------------------
class _SnapDB:
def __init__(self):
self.saved = []
def save_bubble_snapshot(self, kind, items, profile_id=None):
self.saved.append((kind, items, profile_id))
def _snap_kwargs(db, *, payload_key='bubbles', no_data_error='No bubble data provided',
snapshot_kind='artist_bubbles', success_noun='artist bubbles',
log_subject='artist bubble snapshot', log_noun='artists'):
return dict(
payload_key=payload_key, no_data_error=no_data_error, snapshot_kind=snapshot_kind,
success_noun=success_noun, log_subject=log_subject, log_noun=log_noun,
get_database=lambda: db, get_current_profile_id=lambda: 7,
)
def test_snapshot_missing_payload_key():
from core.discovery.endpoints import save_bubble_snapshot
db = _SnapDB()
body, code = save_bubble_snapshot(lambda: {'other': 1}, **_snap_kwargs(db))
assert code == 400 and body == {'success': False, 'error': 'No bubble data provided'}
assert db.saved == []
def test_snapshot_none_body():
from core.discovery.endpoints import save_bubble_snapshot
db = _SnapDB()
body, code = save_bubble_snapshot(lambda: None,
**_snap_kwargs(db, payload_key='downloads',
no_data_error='No download data provided'))
assert code == 400 and body['error'] == 'No download data provided'
def test_snapshot_happy_path():
from core.discovery.endpoints import save_bubble_snapshot
db = _SnapDB()
items = [{'a': 1}, {'b': 2}, {'c': 3}]
body, code = save_bubble_snapshot(lambda: {'bubbles': items},
**_snap_kwargs(db, snapshot_kind='search_bubbles',
success_noun='search bubbles'))
assert code == 200
assert body['success'] is True
assert body['message'] == 'Snapshot saved with 3 search bubbles'
assert isinstance(body['timestamp'], str) and body['timestamp']
# persisted with kind + profile id
assert db.saved == [('search_bubbles', items, 7)]
def test_snapshot_discover_downloads_key():
from core.discovery.endpoints import save_bubble_snapshot
db = _SnapDB()
body, code = save_bubble_snapshot(lambda: {'downloads': [1, 2]},
**_snap_kwargs(db, payload_key='downloads',
no_data_error='No download data provided',
snapshot_kind='discover_downloads',
success_noun='downloads',
log_subject='discover download snapshot',
log_noun='downloads'))
assert code == 200
assert body['message'] == 'Snapshot saved with 2 downloads'
assert db.saved[0][0] == 'discover_downloads'
def test_snapshot_exception_returns_500():
from core.discovery.endpoints import save_bubble_snapshot
class _BoomDB:
def save_bubble_snapshot(self, *a, **k):
raise RuntimeError('db down')
body, code = save_bubble_snapshot(lambda: {'bubbles': [1]}, **_snap_kwargs(_BoomDB()))
assert code == 500 and body == {'success': False, 'error': 'db down'}

View file

@ -372,3 +372,156 @@ def test_build_final_path_for_track_with_cdnum_template_skips_disc_folder(monkey
)
# Verify the disc folder was not created either.
assert not (tmp_path / "Transfer" / "Artist One" / "Artist One - Album One" / "Disc 2").exists()
# ── #745: $year must validate, never blind-slice release_date ──────────────
def test_extract_year_accepts_real_dates():
assert import_paths._extract_year_from_release_date("2026-01-01") == "2026"
assert import_paths._extract_year_from_release_date("1999") == "1999"
assert import_paths._extract_year_from_release_date("2026") == "2026"
# Datetime-ish string — still leads with a valid year.
assert import_paths._extract_year_from_release_date("2010-12-31T00:00:00Z") == "2010"
def test_extract_year_rejects_non_date_values():
# #745 exact case: release_date poisoned with the album NAME.
assert import_paths._extract_year_from_release_date("Mantras (Deluxe)") == ""
assert import_paths._extract_year_from_release_date("Mant") == ""
# Implausible / sentinel years are rejected.
assert import_paths._extract_year_from_release_date("0000") == ""
assert import_paths._extract_year_from_release_date("1800") == ""
assert import_paths._extract_year_from_release_date("9999") == ""
# Empty / None.
assert import_paths._extract_year_from_release_date("") == ""
assert import_paths._extract_year_from_release_date(None) == ""
# Fewer than 4 leading digits.
assert import_paths._extract_year_from_release_date("202") == ""
def test_build_final_path_drops_garbage_year_from_folder(monkeypatch, tmp_path):
"""#745 reproduction: release_date carries the album NAME, not a date.
The $year slot must resolve to empty and the bracket cleanup must drop the
empty () producing 'Album One' NOT 'Album One (Mant)'."""
config = _Config(
{
"soulseek.transfer_path": str(tmp_path / "Transfer"),
"file_organization.enabled": True,
"file_organization.templates": {
# Template that uses $year, like the reporter's.
"album_path": "$albumartist/$album ($year) [$albumtype]/$track - $title",
"single_path": "$artist/$artist - $title",
},
"file_organization.collab_artist_mode": "first",
"file_organization.disc_label": "Disc",
}
)
monkeypatch.setattr(import_paths, "_get_config_manager", lambda: config)
monkeypatch.setattr(
import_paths, "_get_album_tracks_for_source",
lambda source, album_id: {"items": [{"disc_number": 1}]},
)
context = {
"artist": {"name": "Katie Pruitt"},
"album": {
"name": "Mantras (Deluxe)",
"id": "album-1",
# POISONED: the album name landed in release_date (the #745 bug).
"release_date": "Mantras (Deluxe)",
"total_tracks": 12,
"album_type": "album",
"artists": [{"name": "Katie Pruitt"}],
},
"track_info": {
"name": "White Lies, White Jesus And You",
"id": "track-1",
"track_number": 1,
"disc_number": 1,
"artists": [{"name": "Katie Pruitt"}],
},
"original_search_result": {
"title": "White Lies, White Jesus And You",
"clean_title": "White Lies, White Jesus And You",
"clean_album": "Mantras (Deluxe)",
"clean_artist": "Katie Pruitt",
"artists": [{"name": "Katie Pruitt"}],
},
"source": "deezer",
"is_album_download": False,
}
final_path, created = import_paths.build_final_path_for_track(
context,
{"name": "Katie Pruitt"},
{"is_album": True, "album_name": "Mantras (Deluxe)", "track_number": 1, "disc_number": 1},
".flac",
)
assert created is True
# The album folder must NOT contain "(Mant)" or any "(...)" year artifact.
album_folder = os.path.basename(os.path.dirname(final_path))
assert "(Mant" not in album_folder
assert "()" not in album_folder
# Empty () collapses; [Album] type stays.
assert album_folder == "Mantras (Deluxe) [Album]"
def test_build_final_path_keeps_real_year_in_folder(monkeypatch, tmp_path):
"""Positive control: a genuine release_date still produces the (YYYY)
folder proves the guard didn't break the happy path."""
config = _Config(
{
"soulseek.transfer_path": str(tmp_path / "Transfer"),
"file_organization.enabled": True,
"file_organization.templates": {
"album_path": "$albumartist/$album ($year) [$albumtype]/$track - $title",
"single_path": "$artist/$artist - $title",
},
"file_organization.collab_artist_mode": "first",
"file_organization.disc_label": "Disc",
}
)
monkeypatch.setattr(import_paths, "_get_config_manager", lambda: config)
monkeypatch.setattr(
import_paths, "_get_album_tracks_for_source",
lambda source, album_id: {"items": [{"disc_number": 1}]},
)
context = {
"artist": {"name": "Katie Pruitt"},
"album": {
"name": "Mantras (Deluxe)",
"id": "album-1",
"release_date": "2026-04-12",
"total_tracks": 12,
"album_type": "album",
"artists": [{"name": "Katie Pruitt"}],
},
"track_info": {
"name": "White Lies, White Jesus And You",
"id": "track-1",
"track_number": 1,
"disc_number": 1,
"artists": [{"name": "Katie Pruitt"}],
},
"original_search_result": {
"title": "White Lies, White Jesus And You",
"clean_title": "White Lies, White Jesus And You",
"clean_album": "Mantras (Deluxe)",
"clean_artist": "Katie Pruitt",
"artists": [{"name": "Katie Pruitt"}],
},
"source": "deezer",
"is_album_download": False,
}
final_path, _ = import_paths.build_final_path_for_track(
context,
{"name": "Katie Pruitt"},
{"is_album": True, "album_name": "Mantras (Deluxe)", "track_number": 1, "disc_number": 1},
".flac",
)
album_folder = os.path.basename(os.path.dirname(final_path))
assert album_folder == "Mantras (Deluxe) (2026) [Album]"

View file

@ -0,0 +1,73 @@
"""Regression for #735 (CubeComming): importing media overwrites the
album-artist tag ('Albuminterpret') to 'Unknown Artist'.
When the metadata source resolves the track artist correctly (e.g. Billie
Eilish) but the album CONTEXT comes back with an unresolved 'Unknown Artist'
placeholder, extract_source_metadata used to take album_ctx['artists'][0]['name']
unconditionally clobbering the real album artist. The fix: an 'Unknown Artist'
placeholder must not override a real artist.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
def _cfg():
cfg = MagicMock()
cfg.get.side_effect = lambda key, default=None: {
"metadata_enhancement.enabled": True,
"metadata_enhancement.tags.write_multi_artist": False,
"metadata_enhancement.tags.feat_in_title": False,
"metadata_enhancement.tags.artist_separator": ", ",
"file_organization.collab_artist_mode": "first",
}.get(key, default)
return cfg
def _extract(context, artist_dict, album_info=None):
from core.metadata import source as src
with patch.object(src, "get_config_manager", return_value=_cfg()):
return src.extract_source_metadata(context, artist_dict, album_info or {})
def test_unknown_album_artist_placeholder_does_not_clobber_real_artist():
# Track resolves to a real artist; album context is an unresolved placeholder.
context = {
"original_search_result": {"title": "Therefore I Am", "artists": [{"name": "Billie Eilish"}]},
"album": {"artists": [{"name": "Unknown Artist"}]},
"source": "spotify",
}
md = _extract(context, {"name": "Billie Eilish"})
assert md["artist"] == "Billie Eilish"
assert md["album_artist"] == "Billie Eilish" # NOT "Unknown Artist"
def test_real_album_artist_still_overrides():
# A genuine album artist (not the placeholder) should still be used.
context = {
"original_search_result": {"title": "Song", "artists": [{"name": "Some Singer"}]},
"album": {"artists": [{"name": "Various Artists"}]},
"source": "spotify",
}
md = _extract(context, {"name": "Some Singer"})
assert md["album_artist"] == "Various Artists"
def test_no_album_context_falls_back_to_track_artist():
context = {
"original_search_result": {"title": "Song", "artists": [{"name": "Solo Act"}]},
"source": "spotify",
}
md = _extract(context, {"name": "Solo Act"})
assert md["album_artist"] == "Solo Act"
def test_empty_album_artist_does_not_clobber():
context = {
"original_search_result": {"title": "Song", "artists": [{"name": "Real Name"}]},
"album": {"artists": [{"name": ""}]},
"source": "spotify",
}
md = _extract(context, {"name": "Real Name"})
assert md["album_artist"] == "Real Name"

View file

@ -0,0 +1,165 @@
"""Pin the shared artwork resolution-upgrade + fetch helpers.
Bug (Discord, user report): embedded album art came out ~600×600 while the
cover.jpg in the folder was high-res. Cause: only the cover.jpg path
upgraded the source CDN URL to its highest resolution (Spotify master /
iTunes 3000 / Deezer 1900); the tag-embed path and the "Write Tags to File"
retag path fetched the raw URL Spotify 640, iTunes 600, Deezer 1000.
Fix: one shared ``_upgrade_art_url`` + ``_fetch_art_bytes`` in
``core.metadata.artwork`` that every art path now calls, so embedded art is
always the same highest resolution as the folder cover.
"""
from __future__ import annotations
import pytest
from core.metadata.artwork import _upgrade_art_url, _fetch_art_bytes
# ---------------------------------------------------------------------------
# _upgrade_art_url — per-source resolution bump
# ---------------------------------------------------------------------------
class TestUpgradeArtUrl:
def test_spotify_album_art_upgraded_to_master(self):
# Spotify encodes size as the hex segment after 'ab67616d0000'.
# 1e02 = 300px, b273 = 640px; 82c1 = the original uploaded master.
url = 'https://i.scdn.co/image/ab67616d00001e02deadbeef'
assert _upgrade_art_url(url) == 'https://i.scdn.co/image/ab67616d000082c1deadbeef'
def test_spotify_640_also_upgraded(self):
url = 'https://i.scdn.co/image/ab67616d0000b273cafef00d'
assert _upgrade_art_url(url) == 'https://i.scdn.co/image/ab67616d000082c1cafef00d'
def test_itunes_600_upgraded_to_3000(self):
url = 'https://is1-ssl.mzstatic.com/image/thumb/abc/600x600bb.jpg'
assert _upgrade_art_url(url) == 'https://is1-ssl.mzstatic.com/image/thumb/abc/3000x3000bb.jpg'
def test_itunes_100_upgraded_to_3000(self):
url = 'https://is1-ssl.mzstatic.com/image/thumb/abc/100x100bb.jpg'
assert '3000x3000bb' in _upgrade_art_url(url)
def test_deezer_upgraded_to_1900(self):
url = 'https://cdn-images.dzcdn.net/images/cover/abc/1000x1000-000000-80-0-0.jpg'
assert '1900x1900' in _upgrade_art_url(url)
def test_caa_thumbnail_upgraded_to_1200(self):
# MusicBrainz art arrives as the /front-250 thumbnail; upgrade to the
# 1200px CDN thumbnail (NOT the flaky bare /front original).
url = 'https://coverartarchive.org/release/abc-123/front-250'
assert _upgrade_art_url(url) == 'https://coverartarchive.org/release/abc-123/front-1200'
def test_caa_500_scope_and_idempotent(self):
assert _upgrade_art_url('https://coverartarchive.org/release/x/front-500') \
== 'https://coverartarchive.org/release/x/front-1200'
# release-group scope works the same.
assert _upgrade_art_url('https://coverartarchive.org/release-group/y/front-250') \
== 'https://coverartarchive.org/release-group/y/front-1200'
# Idempotent — an already-1200 URL stays put.
assert _upgrade_art_url('https://coverartarchive.org/release-group/y/front-1200') \
== 'https://coverartarchive.org/release-group/y/front-1200'
def test_caa_bare_front_left_alone(self):
# The bare /front original is intentionally NOT what we want; the
# sized-thumbnail regex doesn't touch it (and it never reaches the
# helper in practice — _cover_art_url always emits /front-250).
url = 'https://coverartarchive.org/release/abc/front'
assert _upgrade_art_url(url) == url
@pytest.mark.parametrize('url', [
'https://lastfm.freetls.fastly.net/i/u/770x0/x.jpg',
'https://example.com/random.jpg',
])
def test_unrecognized_url_unchanged(self, url):
assert _upgrade_art_url(url) == url
def test_empty_and_none_unchanged(self):
assert _upgrade_art_url('') == ''
assert _upgrade_art_url(None) is None
# ---------------------------------------------------------------------------
# _fetch_art_bytes — upgrade, fetch, fall back to original on CDN refusal
# ---------------------------------------------------------------------------
class _FakeResponse:
def __init__(self, data=b'art-bytes', ctype='image/jpeg'):
self._data = data
self._ctype = ctype
def read(self):
return self._data
def info(self):
ctype = self._ctype
class _Info:
def get_content_type(_self):
return ctype
return _Info()
def __enter__(self):
return self
def __exit__(self, *a):
pass
class TestFetchArtBytes:
def test_fetches_upgraded_url(self, monkeypatch):
"""The upgraded (high-res) URL is the one actually fetched."""
calls = []
def fake_urlopen(url, timeout=None):
calls.append(url)
return _FakeResponse(b'big-cover', 'image/png')
monkeypatch.setattr('core.metadata.artwork.urllib.request.urlopen', fake_urlopen)
data, mime = _fetch_art_bytes('https://i.scdn.co/image/ab67616d0000b273x')
assert data == b'big-cover'
assert mime == 'image/png'
# Fetched the master-res URL, not the 640 original.
assert calls == ['https://i.scdn.co/image/ab67616d000082c1x']
def test_falls_back_to_original_when_upgrade_refused(self, monkeypatch):
upgraded = 'https://is1-ssl.mzstatic.com/image/thumb/a/3000x3000bb.jpg'
original = 'https://is1-ssl.mzstatic.com/image/thumb/a/600x600bb.jpg'
calls = []
def fake_urlopen(url, timeout=None):
calls.append(url)
if url == upgraded:
raise Exception('403 Forbidden')
return _FakeResponse(b'orig-cover')
monkeypatch.setattr('core.metadata.artwork.urllib.request.urlopen', fake_urlopen)
data, mime = _fetch_art_bytes(original)
assert data == b'orig-cover'
assert calls == [upgraded, original] # tried big first, then fell back
def test_no_fallback_when_url_not_upgraded(self, monkeypatch):
"""If the upgrade is a no-op (unrecognized URL), a single failed
fetch returns (None, None) no pointless retry of the same URL."""
calls = []
def fake_urlopen(url, timeout=None):
calls.append(url)
raise Exception('network down')
monkeypatch.setattr('core.metadata.artwork.urllib.request.urlopen', fake_urlopen)
data, mime = _fetch_art_bytes('https://example.com/cover.jpg')
assert (data, mime) == (None, None)
assert calls == ['https://example.com/cover.jpg']
def test_empty_url_returns_none(self):
assert _fetch_art_bytes('') == (None, None)
assert _fetch_art_bytes(None) == (None, None)

View file

@ -153,7 +153,9 @@ class TestDownloadFallbackOnCdnRefusal:
def test_tag_writer_retries_with_original_on_failure(self, monkeypatch):
"""tag_writer.download_cover_art must fall back to the
original URL when the upgraded URL fails."""
original URL when the upgraded URL fails. The fetch now lives in
the shared ``core.metadata.artwork._fetch_art_bytes`` helper that
tag_writer delegates to, so the network is patched there."""
from core import tag_writer
original_url = 'https://cdn-images.dzcdn.net/images/cover/abc/1000x1000-000000-80-0-0.jpg'
@ -176,7 +178,7 @@ class TestDownloadFallbackOnCdnRefusal:
raise Exception("403 Forbidden")
return _FakeResponse()
monkeypatch.setattr('core.tag_writer.urllib.request.urlopen', fake_urlopen)
monkeypatch.setattr('core.metadata.artwork.urllib.request.urlopen', fake_urlopen)
result = tag_writer.download_cover_art(original_url)
@ -185,8 +187,9 @@ class TestDownloadFallbackOnCdnRefusal:
assert call_log == [upgraded_url, original_url]
def test_tag_writer_no_fallback_for_non_dzcdn_url(self, monkeypatch):
"""Non-Deezer URLs go through unchanged — no upgrade, no
fallback. Fast path preserved."""
"""Non-Deezer URLs with no recognizable size segment go through
unchanged no upgrade, so a single fetch attempt with no
fallback."""
from core import tag_writer
spotify_url = 'https://i.scdn.co/image/abc'
@ -196,10 +199,10 @@ class TestDownloadFallbackOnCdnRefusal:
call_log.append(url)
raise Exception("network error")
monkeypatch.setattr('core.tag_writer.urllib.request.urlopen', fake_urlopen)
monkeypatch.setattr('core.metadata.artwork.urllib.request.urlopen', fake_urlopen)
result = tag_writer.download_cover_art(spotify_url)
assert result is None
# Single attempt — no Deezer fallback path triggered
# Single attempt — upgrade is a no-op for this URL, so no fallback
assert call_log == [spotify_url]

View file

@ -1179,3 +1179,150 @@ def test_search_tracks_with_artist_swallows_client_errors():
client._client.search_recording.side_effect = RuntimeError('network down')
assert client.search_tracks_with_artist('Track', 'Artist', limit=10) == []
# ---------------------------------------------------------------------------
# get_artist_albums — full-discography browse pagination
#
# Regression for Sokhi's report ("a lot of albums are missing vs the site").
# The old impl read the artist *lookup*'s embedded release-groups
# (`/artist/<mbid>?inc=release-groups`), which MusicBrainz hard-caps at 25
# and which ignores the `limit` param — so ~85% of a prolific artist's
# catalogue (Kendrick Lamar: 167 release-groups) was silently dropped.
# The fix walks the paginated browse endpoint instead.
# ---------------------------------------------------------------------------
def _mk_rg(rg_id, title, primary='Album', secondary=None, date='2000-01-01'):
return {
'id': rg_id,
'title': title,
'primary-type': primary,
'secondary-types': secondary or [],
'first-release-date': date,
}
def test_get_artist_albums_does_not_use_capped_lookup_release_groups():
"""The capped `inc=release-groups` lookup must NOT be the source of the
discography. We still do a lightweight name lookup, but never request
the embedded (25-capped) release-groups."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.get_artist.return_value = {'name': 'Kendrick Lamar'}
client._client.browse_artist_release_groups.return_value = [
_mk_rg('rg-1', 'DAMN.'),
]
client.get_artist_albums('mbid-kdot')
# browse is the discography source.
assert client._client.browse_artist_release_groups.called
# The name lookup must not pull the capped embedded release-groups.
for call in client._client.get_artist.call_args_list:
assert 'release-groups' not in (call.kwargs.get('includes') or [])
assert all('release-groups' not in (a or []) for a in call.args[1:])
def test_get_artist_albums_paginates_past_25_cap():
"""Walks multiple browse pages until a short page, returning the FULL
catalogue the whole point of the fix. A single full page (100) must
trigger a follow-up fetch."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.get_artist.return_value = {'name': 'Prolific Artist'}
page1 = [_mk_rg(f'rg-{i}', f'Album {i}') for i in range(100)]
page2 = [_mk_rg(f'rg-{i}', f'Album {i}') for i in range(100, 164)]
client._client.browse_artist_release_groups.side_effect = [page1, page2, []]
albums = client.get_artist_albums('mbid-x', limit=200)
assert len(albums) == 164 # not truncated to 25
# Second page fetched at offset=100.
offsets = [c.kwargs.get('offset') for c in client._client.browse_artist_release_groups.call_args_list]
assert 0 in offsets and 100 in offsets
# No `type` filter — the detail page wants the whole catalogue.
for c in client._client.browse_artist_release_groups.call_args_list:
assert c.kwargs.get('release_types') is None
def test_get_artist_albums_stops_on_short_page():
"""A page shorter than the page size is the last page — don't fetch
a spurious extra page."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.get_artist.return_value = {'name': 'Small Artist'}
client._client.browse_artist_release_groups.return_value = [
_mk_rg('rg-1', 'Only Album'),
]
albums = client.get_artist_albums('mbid-small', limit=200)
assert len(albums) == 1
client._client.browse_artist_release_groups.assert_called_once()
def test_get_artist_albums_respects_limit():
"""`limit` caps the returned list even when more release-groups exist."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.get_artist.return_value = {'name': 'Prolific Artist'}
client._client.browse_artist_release_groups.return_value = [
_mk_rg(f'rg-{i}', f'Album {i}') for i in range(100)
]
albums = client.get_artist_albums('mbid-x', limit=50)
assert len(albums) == 50
def test_get_artist_albums_dedupes_release_group_ids():
"""A release-group id repeated across pages is collapsed to one card.
First page is full (100) so a second page is fetched; 'dup' appears on
both and must surface only once."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.get_artist.return_value = {'name': 'Artist'}
page1 = [_mk_rg('dup', 'A')] + [_mk_rg(f'rg-{i}', f'B{i}') for i in range(99)]
page2 = [_mk_rg('dup', 'A again'), _mk_rg('rg-last', 'C')]
client._client.browse_artist_release_groups.side_effect = [page1, page2, []]
albums = client.get_artist_albums('mbid-x', limit=200)
ids = [a.id for a in albums]
assert ids.count('dup') == 1
assert 'rg-last' in ids
assert len(ids) == len(set(ids))
def test_get_artist_albums_maps_types_into_buckets():
"""Primary/secondary types map to the album_type the discography binning
expects: EPep, Singlesingle, Album+Compilationcompilation, plain
Albumalbum."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.get_artist.return_value = {'name': 'Artist'}
client._client.browse_artist_release_groups.return_value = [
_mk_rg('rg-lp', 'The LP', primary='Album'),
_mk_rg('rg-ep', 'The EP', primary='EP'),
_mk_rg('rg-single', 'The Single', primary='Single'),
_mk_rg('rg-comp', 'Greatest Hits', primary='Album', secondary=['Compilation']),
]
albums = {a.id: a for a in client.get_artist_albums('mbid-x')}
assert albums['rg-lp'].album_type == 'album'
assert albums['rg-ep'].album_type == 'ep'
assert albums['rg-single'].album_type == 'single'
assert albums['rg-comp'].album_type == 'compilation'
def test_get_artist_albums_swallows_browse_errors():
"""Browse raising must not crash the discography endpoint — return []
so the source-priority cascade falls through to the next provider."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.get_artist.return_value = {'name': 'Artist'}
client._client.browse_artist_release_groups.side_effect = RuntimeError('mb down')
assert client.get_artist_albums('mbid-x') == []

View file

@ -96,3 +96,123 @@ def test_missing_quality_score_treated_as_zero():
result = basic.run_basic_soulseek_search('q', client, _sync_run_async)
# has_score (0.5) ranks above no_score (treated as 0)
assert result[0]['name'] == 'h'
# ── Source-targeted search (new in basic-search redesign) ──────────────
class _FakeOrchestratorMulti:
"""Orchestrator stand-in with per-source clients.
``search()`` is the default orchestrator path (single-source mode or
first hybrid source). ``client(name)`` returns the per-source client
so a source-targeted basic search can call ``.search()`` directly on
the specific source rather than going through the chain.
"""
def __init__(self, default_results, per_source_results, fail_unknown=False):
self._default = default_results
self._sources = per_source_results
self._fail_unknown = fail_unknown
self.default_search_calls = 0
self.per_source_calls = {}
async def search(self, query, timeout=None, progress_callback=None, exclude_sources=None):
self.default_search_calls += 1
return self._default
def client(self, name):
if name not in self._sources:
if self._fail_unknown:
return None
return None
plugin = _FakeSoulseek(tracks=self._sources[name][0], albums=self._sources[name][1])
# Record which sources got asked for.
self.per_source_calls.setdefault(name, 0)
self.per_source_calls[name] += 1
return plugin
def test_source_param_routes_to_specific_client():
"""``source='tidal'`` calls the Tidal client directly, NOT the
orchestrator's chain. Ensures the per-source basic search bypasses
the hybrid-first selection so users can target any active source."""
tidal_track = _SearchTrack('From Tidal', 0.9, username='tidal')
soul_track = _SearchTrack('From Soulseek', 0.5, username='peer')
orch = _FakeOrchestratorMulti(
default_results=([soul_track], []),
per_source_results={
'tidal': ([tidal_track], []),
'soulseek': ([soul_track], []),
},
)
result = basic.run_basic_search('q', orch, _sync_run_async, source='tidal')
# Tidal result returned, NOT soulseek result.
assert len(result) == 1
assert result[0]['name'] == 'From Tidal'
# Orchestrator default chain NOT consulted.
assert orch.default_search_calls == 0
# Tidal client was called exactly once.
assert orch.per_source_calls.get('tidal') == 1
def test_no_source_param_falls_through_to_orchestrator_default():
"""When ``source`` is omitted, behaviour is identical to pre-redesign:
orchestrator.search() is called and routes to the configured source
(single-source mode) or first hybrid source. Preserves the existing
contract for callers that don't opt in to per-source targeting."""
track = _SearchTrack('Default', 0.7)
orch = _FakeOrchestratorMulti(
default_results=([track], []),
per_source_results={'tidal': ([], [])},
)
result = basic.run_basic_search('q', orch, _sync_run_async)
assert result[0]['name'] == 'Default'
assert orch.default_search_calls == 1
assert orch.per_source_calls == {}
def test_unknown_source_falls_back_to_orchestrator():
"""Bogus source name (e.g. user-edited config with a typo) falls
through to the orchestrator default rather than returning an empty
list silently. Logged as a warning so misconfiguration is visible."""
track = _SearchTrack('Default', 0.7)
orch = _FakeOrchestratorMulti(
default_results=([track], []),
per_source_results={'tidal': ([], [])},
)
result = basic.run_basic_search('q', orch, _sync_run_async, source='nonexistent')
assert result[0]['name'] == 'Default'
assert orch.default_search_calls == 1
def test_backwards_compat_alias_still_works():
"""``run_basic_soulseek_search`` is the legacy name; any external
caller that hasn't been updated must keep working. Aliased to
``run_basic_search`` so both call the same code path."""
track = _SearchTrack('Compat', 0.5)
client = _FakeSoulseek(tracks=[track])
result = basic.run_basic_soulseek_search('q', client, _sync_run_async)
assert result[0]['name'] == 'Compat'
assert basic.run_basic_soulseek_search is basic.run_basic_search
def test_source_targeted_search_serialises_albums_with_tracks():
"""Source-targeted path goes through the same normaliser as the
default path, so albums returned via a specific source still get
their tracks serialised + ``result_type='album'`` tagged."""
inner = _SearchTrack('inner', 0.5, filename='in.mp3')
album = _SearchAlbum('TidalAlbum', 0.9, tracks=[inner], username='tidal')
orch = _FakeOrchestratorMulti(
default_results=([], []),
per_source_results={'tidal': ([], [album])},
)
result = basic.run_basic_search('q', orch, _sync_run_async, source='tidal')
assert result[0]['result_type'] == 'album'
assert result[0]['tracks'][0]['name'] == 'inner'

View file

@ -22,14 +22,17 @@ import pytest
from core.download_plugins.album_bundle import (
ALBUM_PICK_MAX_BYTES,
ALBUM_PICK_MIN_BYTES,
DEFAULT_COMPLETED_NO_PATH_WINDOW_SECONDS,
DEFAULT_POLL_INTERVAL_SECONDS,
DEFAULT_POLL_TIMEOUT_SECONDS,
atomic_copy_to_staging,
copy_audio_files_atomically,
get_completed_no_path_window_seconds,
get_poll_interval,
get_poll_timeout,
pick_best_album_release,
quality_score,
resolve_reported_save_path,
unique_staging_path,
)
@ -301,6 +304,114 @@ def test_get_poll_timeout_falls_back_on_garbage() -> None:
assert get_poll_timeout() == DEFAULT_POLL_TIMEOUT_SECONDS
def test_get_completed_no_path_window_uses_default_when_unset() -> None:
with patch('core.download_plugins.album_bundle.config_manager') as cm:
cm.get.return_value = DEFAULT_COMPLETED_NO_PATH_WINDOW_SECONDS
assert get_completed_no_path_window_seconds() == DEFAULT_COMPLETED_NO_PATH_WINDOW_SECONDS
def test_get_completed_no_path_window_honours_override() -> None:
"""Users whose SAB is slow to write ``storage`` (large box sets,
slow disks) can widen the tolerance without touching code."""
with patch('core.download_plugins.album_bundle.config_manager') as cm:
cm.get.return_value = 300
assert get_completed_no_path_window_seconds() == 300.0
def test_get_completed_no_path_window_falls_back_on_garbage() -> None:
with patch('core.download_plugins.album_bundle.config_manager') as cm:
cm.get.return_value = ''
assert get_completed_no_path_window_seconds() == DEFAULT_COMPLETED_NO_PATH_WINDOW_SECONDS
cm.get.return_value = 0
assert get_completed_no_path_window_seconds() == DEFAULT_COMPLETED_NO_PATH_WINDOW_SECONDS
# ---------------------------------------------------------------------------
# resolve_reported_save_path — downloader→local path translation. The arr
# remote-path problem: SAB reports its own container path, SoulSync mounts
# the same files elsewhere.
# ---------------------------------------------------------------------------
def _cfg(values: dict):
"""Build a config_manager.get-shaped callable from a dict."""
def _get(key, default=None):
return values.get(key, default)
return _get
def test_resolve_returns_reported_path_verbatim_when_readable(tmp_path: Path) -> None:
"""If the client's path is already readable here (mounts mirror the
client), return it unchanged no translation needed."""
album = tmp_path / "MyAlbum"
album.mkdir()
# config_get should never even be consulted on the happy path.
assert resolve_reported_save_path(str(album), config_get=_cfg({})) == str(album)
def test_resolve_uses_explicit_prefix_mapping(tmp_path: Path) -> None:
"""Sonarr/Radarr-style remote path mapping: SAB's prefix is rewritten
to a SoulSync-visible root."""
(tmp_path / "MyAlbum").mkdir()
cfg = _cfg({'download_source.usenet_path_mappings': [
{'from': '/data/downloads/music', 'to': str(tmp_path)},
]})
resolved = resolve_reported_save_path('/data/downloads/music/MyAlbum', config_get=cfg)
assert resolved == str(tmp_path / "MyAlbum")
def test_resolve_basename_fallback_against_download_root(tmp_path: Path) -> None:
"""Zero-config shared-volume case: the album folder shows up under
SoulSync's own download root with the same name SAB reported."""
(tmp_path / "MyAlbum").mkdir()
cfg = _cfg({'soulseek.download_path': str(tmp_path)})
resolved = resolve_reported_save_path('/data/downloads/music/MyAlbum', config_get=cfg)
assert resolved == str(tmp_path / "MyAlbum")
def test_resolve_mapping_takes_priority_over_basename(tmp_path: Path) -> None:
"""An explicit mapping that resolves wins over the basename scan."""
mapped_root = tmp_path / "mapped"
dl_root = tmp_path / "dl"
(mapped_root / "MyAlbum").mkdir(parents=True)
(dl_root / "MyAlbum").mkdir(parents=True)
cfg = _cfg({
'download_source.usenet_path_mappings': [
{'from': '/data/downloads/music', 'to': str(mapped_root)},
],
'soulseek.download_path': str(dl_root),
})
resolved = resolve_reported_save_path('/data/downloads/music/MyAlbum', config_get=cfg)
assert resolved == str(mapped_root / "MyAlbum")
def test_resolve_returns_reported_unchanged_when_nothing_found(tmp_path: Path) -> None:
"""No readable path, no mapping hit, no basename match → return the
original so the caller's 'no audio' error still surfaces."""
cfg = _cfg({'soulseek.download_path': str(tmp_path)}) # empty root
reported = '/data/downloads/music/Missing'
assert resolve_reported_save_path(reported, config_get=cfg) == reported
def test_resolve_handles_empty_and_none(tmp_path: Path) -> None:
assert resolve_reported_save_path('', config_get=_cfg({})) == ''
assert resolve_reported_save_path(None, config_get=_cfg({})) is None
def test_resolve_skips_mapping_when_target_missing_then_tries_basename(tmp_path: Path) -> None:
"""A mapping whose translated path doesn't exist must not short-circuit
fall through to the basename scan."""
(tmp_path / "MyAlbum").mkdir()
cfg = _cfg({
'download_source.usenet_path_mappings': [
{'from': '/data/downloads/music', 'to': '/nope/not/mounted'},
],
'soulseek.download_path': str(tmp_path),
})
resolved = resolve_reported_save_path('/data/downloads/music/MyAlbum', config_get=cfg)
assert resolved == str(tmp_path / "MyAlbum")
# ---------------------------------------------------------------------------
# poll_album_download — lifted poll loop for both torrent + usenet plugins.
# ---------------------------------------------------------------------------
@ -395,6 +506,7 @@ class _Status:
downloaded: int = 0
download_speed: int = 0
error: Optional[str] = None
incomplete_path: Optional[str] = None
class _ScriptedClock:
@ -594,6 +706,171 @@ def test_poll_shutdown_returns_none_without_terminal_emit() -> None:
assert 'failed' not in [c[0] for c in calls]
def test_poll_tolerates_completed_with_late_save_path_arrival() -> None:
"""Regression for #721 (Forty Licks stuck at 61%).
SAB History flips ``status`` to 'Completed' a few seconds before
its post-processing pipeline writes the final ``storage`` field.
Pre-fix the poll returned ``None`` on the first such read, the
bundle plugin marked the batch failed, and the UI froze on the
last 'downloading' emit. Now the poll tolerates up to
``transient_miss_threshold`` consecutive "completed but no
save_path" reads, so SAB has a window to finish writing the
path. When it lands, return it normally."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
sequence = iter([
# Queue phase — SAB still downloading.
_Status(state='downloading', progress=0.61),
# History phase — flipped to Completed but storage not yet
# populated. Pre-fix this branch returned None immediately.
_Status(state='completed', save_path=None, progress=1.0),
_Status(state='completed', save_path=None, progress=1.0),
# SAB finished post-process; storage now set.
_Status(state='completed', save_path='/dl/forty-licks', progress=1.0),
])
result = poll_album_download(
get_status=lambda: next(sequence),
title='Forty Licks',
emit=emit,
complete_states=frozenset(['completed']),
transient_miss_threshold=5,
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=60.0,
)
assert result == '/dl/forty-licks'
# No terminal failed emit — bundle plugin will continue to
# staging, not error out.
assert 'failed' not in [c[0] for c in calls]
def test_poll_gives_up_when_completed_with_no_save_path_persists() -> None:
"""If SAB stays on 'Completed' but ``storage`` never lands past
the threshold, fail loudly with an explicit error pointing at
the missing save_path field instead of silently sitting on
the last 'downloading' UI emit until the 6-hour deadline."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
result = poll_album_download(
get_status=lambda: _Status(state='completed', save_path=None, progress=1.0),
title='Forty Licks',
emit=emit,
complete_states=frozenset(['completed']),
transient_miss_threshold=3,
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=600.0,
)
assert result is None
failed_calls = [c for c in calls if c[0] == 'failed']
assert len(failed_calls) == 1
err = failed_calls[0][1].get('error', '').lower()
assert 'save_path' in err or 'success but never' in err
def test_poll_completed_no_path_window_is_longer_than_miss_window() -> None:
"""#721 follow-up: the completed-but-no-save_path window must be
DECOUPLED from (and far longer than) the transient-miss window. SAB
can take 2+ minutes to write ``storage``; the old code reused the
5-poll (~10s) miss window here and false-failed real completions.
With a small miss threshold but the default long no-path window, a
download that takes 8 completed-no-path polls before ``storage``
lands must still succeed."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
sequence = iter(
[_Status(state='completed', save_path=None, progress=1.0)] * 8
+ [_Status(state='completed', save_path='/dl/late', progress=1.0)]
)
result = poll_album_download(
get_status=lambda: next(sequence),
title='Slow SAB',
emit=emit,
complete_states=frozenset(['completed']),
transient_miss_threshold=3, # vanished-job window stays short
# completed_no_path_threshold left to default (~120s / interval).
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=600.0,
)
assert result == '/dl/late'
assert 'failed' not in [c[0] for c in calls]
def test_poll_falls_back_to_incomplete_path_after_window_exhausted() -> None:
"""When SAB reports the job completed but the final save_path NEVER
lands (some SAB versions / no post-process move), the files are
still physically on disk in the in-progress dir. Rather than failing
a download that actually succeeded, the poll falls back to the
adapter's ``incomplete_path`` as a last resort once the window is
exhausted no terminal 'failed' emit."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
result = poll_album_download(
get_status=lambda: _Status(
state='completed', save_path=None,
incomplete_path='/sab/incomplete/album', progress=1.0,
),
title='No Storage Field',
emit=emit,
complete_states=frozenset(['completed']),
completed_no_path_threshold=3,
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=600.0,
)
assert result == '/sab/incomplete/album'
assert 'failed' not in [c[0] for c in calls]
def test_poll_fails_when_no_path_and_no_incomplete_path() -> None:
"""Last resort only fires when there's actually a path to scan.
With neither a final save_path nor an incomplete_path, the poll
still fails loudly after the window so the UI doesn't freeze."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
result = poll_album_download(
get_status=lambda: _Status(state='completed', save_path=None,
incomplete_path=None, progress=1.0),
title='Truly Pathless',
emit=emit,
complete_states=frozenset(['completed']),
completed_no_path_threshold=3,
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=600.0,
)
assert result is None
failed_calls = [c for c in calls if c[0] == 'failed']
assert len(failed_calls) == 1
err = failed_calls[0][1].get('error', '').lower()
assert 'save_path' in err or 'success but never' in err
def test_poll_uses_save_path_from_earlier_downloading_emit_if_completed_lacks_one() -> None:
"""Sticky save_path: when an earlier ``downloading`` status carried
a non-empty ``save_path`` (qBit shows the target dir mid-download),
that value is remembered. A later ``completed`` read with an empty
save_path still resolves cleanly because the sticky value applies.
Important so torrent clients (which set save_path from the start)
don't trip the completed-no-path retry."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
sequence = iter([
_Status(state='downloading', save_path='/qb/album-target', progress=0.5),
_Status(state='completed', save_path=None, progress=1.0),
])
result = poll_album_download(
get_status=lambda: next(sequence),
title='Some Album',
emit=emit,
complete_states=frozenset(['completed']),
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=60.0,
)
assert result == '/qb/album-target'
assert 'failed' not in [c[0] for c in calls]
def test_poll_torrent_seeding_counts_as_complete() -> None:
"""Torrent plugin passes ``complete_states={'seeding', 'completed'}``
because qBit / Transmission flip the torrent to 'seeding' on

View file

@ -0,0 +1,196 @@
"""Regression tests for ``MusicDatabase.get_artist_full_detail`` source-ID
resolution.
Bug (reported by Boulder, 2026-05-28): opening a library artist from a
*non-library* search result (e.g. a MusicBrainz hit) leaves the artist-detail
page holding the source ID the MBID not the integer library PK. The
standard /api/artist-detail route resolves that via
``find_library_artist_for_source``, but the **enhanced-view** endpoint
(``/api/library/artist/<id>/enhanced``) and the quality-analysis endpoint call
``get_artist_full_detail`` directly with whatever ID the page holds. With only
a ``WHERE id = ?`` lookup, that 404'd ("Artist with ID <mbid> not found") and
the enhanced view failed to load.
Fix: when the direct PK lookup misses, resolve against any per-service ID
column (``SOURCE_ID_FIELD``).
These are isolated DB-method tests no Flask, no route layer so the SQL
fallback itself is exercised.
"""
import sqlite3
import sys
import types
import pytest
# ── stubs (same shape used elsewhere in the test suite) ───────────────────
if "spotipy" not in sys.modules:
spotipy = types.ModuleType("spotipy")
spotipy.Spotify = object
oauth2 = types.ModuleType("spotipy.oauth2")
oauth2.SpotifyOAuth = object
oauth2.SpotifyClientCredentials = object
spotipy.oauth2 = oauth2
sys.modules["spotipy"] = spotipy
sys.modules["spotipy.oauth2"] = oauth2
if "config.settings" not in sys.modules:
config_pkg = types.ModuleType("config")
settings_mod = types.ModuleType("config.settings")
class _DummyConfigManager:
def get(self, key, default=None):
return default
def get_active_media_server(self):
return "primary"
settings_mod.config_manager = _DummyConfigManager()
config_pkg.settings = settings_mod
sys.modules["config"] = config_pkg
sys.modules["config.settings"] = settings_mod
from database.music_database import MusicDatabase # noqa: E402
class _InMemoryDB(MusicDatabase):
"""MusicDatabase backed by an in-memory sqlite that survives across
``_get_connection()`` calls."""
def __init__(self):
self._conn = sqlite3.connect(":memory:")
self._conn.row_factory = sqlite3.Row
def _get_connection(self):
return _NonClosingConn(self._conn)
class _NonClosingConn:
def __init__(self, real):
self._real = real
def cursor(self):
return self._real.cursor()
def commit(self):
return self._real.commit()
def close(self):
pass
def __enter__(self):
return self
def __exit__(self, *args):
pass
def _seed_schema(db):
cur = db._conn.cursor()
# Only the columns get_artist_full_detail touches (it uses SELECT *, so
# the per-service ID columns must exist for the resolution fallback).
cur.execute("""
CREATE TABLE artists (
id INTEGER PRIMARY KEY,
name TEXT,
server_source TEXT,
genres TEXT,
musicbrainz_id TEXT,
spotify_artist_id TEXT,
deezer_id TEXT,
itunes_artist_id TEXT,
discogs_id TEXT,
soul_id TEXT,
amazon_id TEXT
)
""")
cur.execute("""
CREATE TABLE albums (
id TEXT PRIMARY KEY,
artist_id INTEGER,
title TEXT,
year INTEGER,
genres TEXT,
record_type TEXT,
track_count INTEGER
)
""")
cur.execute("""
CREATE TABLE tracks (
id TEXT PRIMARY KEY,
album_id TEXT,
title TEXT,
track_number INTEGER
)
""")
db._conn.commit()
def _seed_kendrick(db, **id_columns):
"""Insert a Kendrick Lamar library artist (PK 187926) with one album,
setting whichever per-service ID columns the test needs."""
cols = ['id', 'name', 'server_source'] + list(id_columns)
vals = [187926, 'Kendrick Lamar', 'primary'] + list(id_columns.values())
placeholders = ','.join('?' * len(cols))
cur = db._conn.cursor()
cur.execute(f"INSERT INTO artists ({','.join(cols)}) VALUES ({placeholders})", vals)
cur.execute(
"INSERT INTO albums (id, artist_id, title, year, record_type) VALUES (?, ?, ?, ?, ?)",
('alb-1', 187926, 'DAMN.', 2017, 'album'),
)
db._conn.commit()
@pytest.fixture
def db():
d = _InMemoryDB()
_seed_schema(d)
return d
def test_direct_pk_lookup_still_works(db):
"""The primary path — integer library PK — must be unaffected by the
new fallback."""
_seed_kendrick(db, musicbrainz_id='381086ea-mbid')
result = db.get_artist_full_detail(187926)
assert result['success'] is True
assert result['artist']['name'] == 'Kendrick Lamar'
assert [a['title'] for a in result['albums']] == ['DAMN.']
def test_resolves_by_musicbrainz_id(db):
"""The exact bug: page holds the MBID, not the PK. Must resolve and
return the library artist + albums instead of 404ing."""
_seed_kendrick(db, musicbrainz_id='381086ea-mbid')
result = db.get_artist_full_detail('381086ea-mbid')
assert result['success'] is True
assert result['artist']['name'] == 'Kendrick Lamar'
assert [a['title'] for a in result['albums']] == ['DAMN.']
def test_resolves_by_spotify_id(db):
"""Resolution isn't MusicBrainz-specific — any per-service ID column
works (proves SOURCE_ID_FIELD reuse, not a hardcoded mbid check)."""
_seed_kendrick(db, spotify_artist_id='sp-kdot')
result = db.get_artist_full_detail('sp-kdot')
assert result['success'] is True
assert result['artist']['name'] == 'Kendrick Lamar'
def test_unknown_id_returns_not_found(db):
"""An ID that matches neither the PK nor any source column still 404s."""
_seed_kendrick(db, musicbrainz_id='381086ea-mbid')
result = db.get_artist_full_detail('totally-unknown-id')
assert result['success'] is False
assert 'not found' in result['error']

View file

@ -0,0 +1,120 @@
"""Tests for the one-time genres CSV->JSON normalization migration.
artists.genres / albums.genres historically stored either a JSON array (new
writes) or a legacy comma-separated string (old writes). _normalize_genres_to_json
rewrites legacy rows to canonical JSON, mirroring the readers' exact parse so the
genre VALUES are unchanged only the storage format. These tests drive the real
method on a temp database.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from database.music_database import MusicDatabase
def _fresh_db(tmp_path: Path) -> MusicDatabase:
# Init creates the schema and (harmlessly) runs the normalization once on the
# empty DB, setting the marker. Tests clear the marker + seed, then call the
# method directly so they exercise the real normalization logic.
return MusicDatabase(str(tmp_path / "library.db"))
def _seed_and_normalize(db: MusicDatabase, artists, albums=()):
"""Insert (id, name, genres) artists and (id, artist_id, title, genres) albums
with the marker cleared, then run the real migration. Returns nothing."""
with db._get_connection() as conn:
cur = conn.cursor()
cur.execute("DELETE FROM metadata WHERE key = 'genres_json_normalized'")
for aid, name, genres in artists:
cur.execute(
"INSERT INTO artists (id, name, genres) VALUES (?, ?, ?)",
(aid, name, genres),
)
for alid, artist_id, title, genres in albums:
cur.execute(
"INSERT INTO albums (id, artist_id, title, genres) VALUES (?, ?, ?, ?)",
(alid, artist_id, title, genres),
)
conn.commit()
db._normalize_genres_to_json(cur)
conn.commit()
def _get_genres(db: MusicDatabase, table: str, rid: str):
with db._get_connection() as conn:
row = conn.execute(f"SELECT genres FROM {table} WHERE id = ?", (rid,)).fetchone()
return row[0]
def test_csv_genres_normalized_to_json(tmp_path: Path) -> None:
db = _fresh_db(tmp_path)
_seed_and_normalize(db, [("a1", "Artist One", "Rock, Pop, Jazz")])
stored = _get_genres(db, "artists", "a1")
assert json.loads(stored) == ["Rock", "Pop", "Jazz"]
def test_existing_json_genres_left_unchanged(tmp_path: Path) -> None:
db = _fresh_db(tmp_path)
canonical = json.dumps(["Hip-Hop", "Soul"])
_seed_and_normalize(db, [("a1", "Artist One", canonical)])
# Byte-for-byte identical — no needless churn on already-canonical rows.
assert _get_genres(db, "artists", "a1") == canonical
def test_single_genre_without_comma(tmp_path: Path) -> None:
db = _fresh_db(tmp_path)
_seed_and_normalize(db, [("a1", "Artist One", "Electronic")])
assert json.loads(_get_genres(db, "artists", "a1")) == ["Electronic"]
def test_csv_whitespace_and_empties_dropped(tmp_path: Path) -> None:
db = _fresh_db(tmp_path)
_seed_and_normalize(db, [("a1", "Artist One", " Rock ,, Pop , ")])
assert json.loads(_get_genres(db, "artists", "a1")) == ["Rock", "Pop"]
def test_albums_table_also_normalized(tmp_path: Path) -> None:
db = _fresh_db(tmp_path)
_seed_and_normalize(
db,
artists=[("a1", "Artist One", "Rock")],
albums=[("al1", "a1", "Album One", "Soul, Funk")],
)
assert json.loads(_get_genres(db, "albums", "al1")) == ["Soul", "Funk"]
def test_values_match_legacy_reader_semantics(tmp_path: Path) -> None:
"""The normalized list must equal what the legacy CSV reader would produce,
so downstream genre values are identical pre- and post-migration."""
db = _fresh_db(tmp_path)
raw = "Rock, Pop, Hip-Hop/Rap"
_seed_and_normalize(db, [("a1", "Artist One", raw)])
legacy = [g.strip() for g in raw.split(",") if g.strip()]
assert json.loads(_get_genres(db, "artists", "a1")) == legacy
def test_idempotent_rerun(tmp_path: Path) -> None:
db = _fresh_db(tmp_path)
_seed_and_normalize(db, [("a1", "Artist One", "Rock, Pop")])
first = _get_genres(db, "artists", "a1")
# Marker is now set; a second run must be a no-op and leave the value identical.
with db._get_connection() as conn:
cur = conn.cursor()
db._normalize_genres_to_json(cur)
conn.commit()
assert _get_genres(db, "artists", "a1") == first
assert json.loads(first) == ["Rock", "Pop"]
def test_marker_set_after_fresh_init(tmp_path: Path) -> None:
db = _fresh_db(tmp_path)
with db._get_connection() as conn:
row = conn.execute(
"SELECT value FROM metadata WHERE key = 'genres_json_normalized'"
).fetchone()
assert row is not None and row[0] == "true"

View file

@ -0,0 +1,96 @@
"""Tests for the additive schema_migrations ledger + PRAGMA user_version backstop.
The ledger unifies the previously-scattered migration state (marker tables +
metadata flags) into one readable place so a half-migrated DB is detectable.
It is NON-GATING: nothing decides whether a migration runs based on it.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from database.music_database import MusicDatabase
def _fresh_db(tmp_path: Path) -> MusicDatabase:
return MusicDatabase(str(tmp_path / "library.db"))
def test_schema_migrations_table_exists(tmp_path: Path) -> None:
db = _fresh_db(tmp_path)
with db._get_connection() as conn:
row = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'"
).fetchone()
assert row is not None
def test_user_version_stamped(tmp_path: Path) -> None:
db = _fresh_db(tmp_path)
with db._get_connection() as conn:
version = conn.execute("PRAGMA user_version").fetchone()[0]
assert version == MusicDatabase.SCHEMA_VERSION == 1
def test_record_migration_is_idempotent(tmp_path: Path) -> None:
db = _fresh_db(tmp_path)
with db._get_connection() as conn:
cur = conn.cursor()
db._record_migration(cur, "unit_test_mig")
db._record_migration(cur, "unit_test_mig")
conn.commit()
n = cur.execute(
"SELECT COUNT(*) FROM schema_migrations WHERE name = 'unit_test_mig'"
).fetchone()[0]
assert n == 1
def test_genres_migration_recorded_on_fresh_init(tmp_path: Path) -> None:
"""The forward pattern: the genres migration records itself in the ledger."""
db = _fresh_db(tmp_path)
with db._get_connection() as conn:
row = conn.execute(
"SELECT 1 FROM schema_migrations WHERE name = 'genres_json'"
).fetchone()
assert row is not None
def test_ledger_backfills_from_existing_signals(tmp_path: Path) -> None:
"""Back-fill records both metadata-flag and marker-table migrations that are
already present, under their canonical ledger names."""
db = _fresh_db(tmp_path)
with db._get_connection() as conn:
cur = conn.cursor()
cur.execute("DELETE FROM schema_migrations")
# A metadata-flag-style signal and a marker-table-style signal.
cur.execute(
"INSERT OR REPLACE INTO metadata (key, value, updated_at) "
"VALUES ('metadata_cache_v1', '1', CURRENT_TIMESTAMP)"
)
cur.execute(
"CREATE TABLE IF NOT EXISTS _genius_search_fix_applied "
"(applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"
)
conn.commit()
db._sync_migration_ledger(cur)
conn.commit()
names = {r[0] for r in cur.execute("SELECT name FROM schema_migrations")}
assert "metadata_cache_v1" in names # from the metadata flag
assert "genius_search_fix" in names # from the marker table
def test_ledger_does_not_record_absent_signals(tmp_path: Path) -> None:
"""A migration whose signal is absent must NOT be recorded as applied."""
db = _fresh_db(tmp_path)
with db._get_connection() as conn:
cur = conn.cursor()
cur.execute("DELETE FROM schema_migrations")
# Ensure the deezer-cache marker table does not exist.
cur.execute("DROP TABLE IF EXISTS _deezer_cache_v2_migrated")
conn.commit()
db._sync_migration_ledger(cur)
conn.commit()
names = {r[0] for r in cur.execute("SELECT name FROM schema_migrations")}
assert "deezer_cache_v2" not in names

View file

@ -0,0 +1,120 @@
"""Regression for the watchlist_artists rebuild dropping amazon_artist_id.
`amazon_artist_id` is added to watchlist_artists via ALTER (music_database.py
~1732), but the table-rebuild migrations (the spotify_id-nullable fix and the
profile-scoped UNIQUE rebuild) recreated the table from a hardcoded column list
that omitted amazon_artist_id so on upgrade the column AND any stored Amazon
artist IDs were silently dropped.
These tests drive the REAL migrations through MusicDatabase() against a fresh
temp database that starts in the pre-migration shape (no profile-scoped UNIQUE,
amazon_artist_id present with data), then assert the column and its data survive.
Proven differential: with database/music_database.py reverted to pre-fix,
test_amazon_artist_id_data_survives_rebuild FAILS (the column/data are dropped);
with the fix it passes.
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
import pytest
from database.music_database import MusicDatabase
# A pre-profile-migration watchlist_artists schema (no UNIQUE(profile_id, ...),
# i.e. exactly the state that triggers the rebuild path) that ALREADY carries
# amazon_artist_id + the other source-id columns — mirroring a real DB that ran
# the 1732 ALTER before the rebuild migrations existed.
_OLD_WATCHLIST_SCHEMA = """
CREATE TABLE watchlist_artists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
spotify_artist_id TEXT UNIQUE,
itunes_artist_id TEXT,
deezer_artist_id TEXT,
discogs_artist_id TEXT,
musicbrainz_artist_id TEXT,
amazon_artist_id TEXT,
artist_name TEXT NOT NULL,
date_added TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_scan_timestamp TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
def _seed_old_db(db_path: Path) -> None:
"""Create a pre-migration watchlist_artists with an Amazon-tagged row."""
conn = sqlite3.connect(str(db_path))
try:
conn.execute(_OLD_WATCHLIST_SCHEMA)
conn.execute(
"INSERT INTO watchlist_artists "
"(spotify_artist_id, amazon_artist_id, artist_name) VALUES (?, ?, ?)",
("spfy_abc", "B0AMAZONXYZ", "Amazon Tagged Artist"),
)
conn.commit()
finally:
conn.close()
def _watchlist_columns(db_path: Path) -> list:
conn = sqlite3.connect(str(db_path))
try:
return [r[1] for r in conn.execute("PRAGMA table_info(watchlist_artists)")]
finally:
conn.close()
def test_amazon_artist_id_column_survives_rebuild(tmp_path: Path) -> None:
"""After the real migrations run, watchlist_artists must still have the
amazon_artist_id column (the rebuild must not drop it)."""
db_path = tmp_path / "old_library.db"
_seed_old_db(db_path)
# Driving MusicDatabase against this path runs the real _initialize_database,
# which fires the watchlist_artists rebuild(s).
MusicDatabase(str(db_path))
cols = _watchlist_columns(db_path)
assert "amazon_artist_id" in cols, (
"amazon_artist_id was dropped by the watchlist_artists rebuild; "
f"columns are: {cols}"
)
# The rebuild's whole purpose: profile-scoped uniqueness must still apply.
assert "profile_id" in cols
def test_amazon_artist_id_data_survives_rebuild(tmp_path: Path) -> None:
"""The stored Amazon artist ID must be carried across the rebuild, not lost.
This is the test that FAILS on pre-fix code."""
db_path = tmp_path / "old_library.db"
_seed_old_db(db_path)
MusicDatabase(str(db_path))
conn = sqlite3.connect(str(db_path))
try:
row = conn.execute(
"SELECT amazon_artist_id FROM watchlist_artists WHERE artist_name = ?",
("Amazon Tagged Artist",),
).fetchone()
finally:
conn.close()
assert row is not None, "the watchlist row was lost entirely during rebuild"
assert row[0] == "B0AMAZONXYZ", (
f"amazon_artist_id data was not preserved across rebuild (got {row[0]!r})"
)
def test_fresh_db_has_amazon_artist_id_column(tmp_path: Path) -> None:
"""A brand-new database (no pre-existing table) must also end up with the
amazon_artist_id column, so fresh installs match upgraded ones."""
db_path = tmp_path / "fresh_library.db"
MusicDatabase(str(db_path))
assert "amazon_artist_id" in _watchlist_columns(db_path)

View file

@ -1934,3 +1934,100 @@ def test_stop_check_aborts_remaining_tracks(monkeypatch, tmpdirs):
# but not ALL 10 — the stop_check cut off the unstarted ones.
assert pp_count[0] < 10
assert pp_count[0] >= 2
# --- tests: #746 /deleted-quarantine skip ---------------------------------
def test_preview_skips_track_in_deleted_quarantine(monkeypatch, tmpdirs):
"""A track whose file lives in <transfer>/deleted (duplicate-cleaner
quarantine) must be surfaced as a non-matched skip in the preview, even
though its title matches the API tracklist Reorganize must not offer to
move it back out of /deleted (#746)."""
library, _staging, transfer = tmpdirs
db = _FakeDB()
# One normal track in the library, one quarantined track under
# <transfer>/deleted. Both have titles that match the API list.
quarantine = transfer / 'deleted' / 'Aerosmith'
quarantine.mkdir(parents=True)
deleted_file = quarantine / 'dream.flac'
deleted_file.write_bytes(b'dupe')
_setup_album(db, deezer_id='dz-1', tracks=[
('t1', 1, 'Same Old Song And Dance', _make_audio_file(library, 't1.flac')),
('t2', 2, 'Dream On', str(deleted_file)),
])
monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer')
monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p])
monkeypatch.setattr(library_reorganize, 'get_album_for_source',
lambda *a: {'id': 'dz-1', 'name': 'Aerosmith'})
monkeypatch.setattr(
library_reorganize, 'get_album_tracks_for_source',
lambda *a: {'items': [
{'id': 'a1', 'name': 'Same Old Song And Dance', 'track_number': 1},
{'id': 'a2', 'name': 'Dream On', 'track_number': 2},
]},
)
result = library_reorganize.preview_album_reorganize(
album_id='alb-1', db=db, transfer_dir=str(transfer),
resolve_file_path_fn=lambda p: p,
build_final_path_fn=_fake_path_builder,
)
by_title = {it['title']: it for it in result['tracks']}
# Normal track: matched + gets a destination.
assert by_title['Same Old Song And Dance']['matched'] is True
assert by_title['Same Old Song And Dance']['new_path']
# Quarantined track: skipped despite matching the API tracklist.
assert by_title['Dream On']['matched'] is False
assert 'quarantine' in (by_title['Dream On']['reason'] or '').lower()
assert by_title['Dream On']['new_path'] == ''
def test_apply_skips_track_in_deleted_quarantine(monkeypatch, tmpdirs):
"""Apply mirrors the preview: post-process is never called for a
quarantined track, the original is left in /deleted, and it's counted as
skipped (not moved, not failed) (#746)."""
library, staging, transfer = tmpdirs
db = _FakeDB()
quarantine = transfer / 'deleted' / 'Aerosmith'
quarantine.mkdir(parents=True)
deleted_file = quarantine / 'dream.flac'
deleted_file.write_bytes(b'dupe')
_setup_album(db, deezer_id='dz-1', tracks=[
('t1', 1, 'Same Old Song And Dance', _make_audio_file(library, 't1.flac')),
('t2', 2, 'Dream On', str(deleted_file)),
])
monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer')
monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p])
monkeypatch.setattr(library_reorganize, 'get_album_for_source',
lambda *a: {'id': 'dz-1', 'name': 'Aerosmith'})
monkeypatch.setattr(
library_reorganize, 'get_album_tracks_for_source',
lambda *a: {'items': [
{'id': 'a1', 'name': 'Same Old Song And Dance', 'track_number': 1},
{'id': 'a2', 'name': 'Dream On', 'track_number': 2},
]},
)
pp_titles = []
def pp(key, ctx, fp):
pp_titles.append(ctx['track_info']['name'])
ctx['_final_processed_path'] = fp
with open(fp, 'wb') as f:
f.write(b'final')
summary = library_reorganize.reorganize_album(
album_id='alb-1', db=db, staging_root=str(staging),
resolve_file_path_fn=lambda p: p, post_process_fn=pp,
transfer_dir=str(transfer),
)
# Only the normal track was post-processed; the quarantined one was not.
assert pp_titles == ['Same Old Song And Dance']
assert summary['moved'] == 1
assert summary['skipped'] == 1
# The quarantined file is untouched on disk.
assert deleted_file.exists()

View file

@ -0,0 +1,194 @@
"""Tests for ``MusicMatchingEngine.normalize_string`` CJK preservation.
Issue #722 (@Sokhii): downloading a Japanese OST through Apple Music
metadata + Tidal download produced duplicate tracks same audio
landed under multiple track positions in the album.
Root cause: ``normalize_string`` detected CJK presence and SKIPPED
unidecode (correct kanjipinyin would have been gibberish), but
then ran ``re.sub(r'[^a-z0-9\\s$]', '', text)`` which stripped EVERY
CJK character. Every Japanese title normalised to ``''``. The
``similarity_score`` guard at ``if not str1 or not str2: return 0.0``
made every CJK-vs-CJK comparison return ``0.000``, so the matcher
fell back to duration+artist alone multiple iTunes tracks mapped
to the same Tidal candidate, and the user got duplicate downloads
under different track positions.
These tests pin the new behaviour: CJK characters survive
normalisation, identical CJK titles score 1.0, disjoint CJK titles
score low, mixed CJK+Latin titles work, and Latin-only titles are
completely unaffected.
"""
from __future__ import annotations
import pytest
from core.matching_engine import MusicMatchingEngine
@pytest.fixture
def engine() -> MusicMatchingEngine:
return MusicMatchingEngine()
# ---------------------------------------------------------------------------
# Normalisation preserves CJK ranges.
# ---------------------------------------------------------------------------
def test_normalize_preserves_kanji_characters(engine: MusicMatchingEngine):
"""Japanese kanji must survive normalisation, not get stripped."""
assert engine.normalize_string('命の灯火') == '命の灯火'
def test_normalize_preserves_hiragana_characters(engine: MusicMatchingEngine):
"""Hiragana also survives."""
assert engine.normalize_string('あいうえお') == 'あいうえお'
def test_normalize_preserves_katakana_characters(engine: MusicMatchingEngine):
"""Katakana — common in Japanese song titles for foreign loanwords —
survives. Pre-fix this was the most-visible failure since OST titles
are often Katakana."""
assert engine.normalize_string('ハッピーデイズ') == 'ハッピーデイズ'
def test_normalize_preserves_hangul_characters(engine: MusicMatchingEngine):
"""Korean Hangul survives (same root cause hits K-Pop OST tracks)."""
assert engine.normalize_string('안녕하세요') == '안녕하세요'
def test_normalize_preserves_simplified_chinese_characters(engine: MusicMatchingEngine):
"""Chinese hanzi survives (same root cause hits Mandarin / Cantonese
releases). All three CJK ideograph users were broken together; the
fix covers all three."""
assert engine.normalize_string('你好世界') == '你好世界'
def test_normalize_lowercases_cjk_branch_does_not_uppercase_ascii(engine: MusicMatchingEngine):
"""Mixed CJK + Latin string — CJK branch was supposed to keep CJK and
only lowercase; verify the Latin half also gets lowercased and isn't
accidentally left as-is."""
assert engine.normalize_string('Happy 命') == 'happy 命'
def test_normalize_strips_latin_punctuation_in_cjk_branch(engine: MusicMatchingEngine):
"""The CJK branch must still strip Latin punctuation — only CJK
ranges are preserved, not random symbols. ``!`` should still go,
same as in the Latin branch."""
assert engine.normalize_string('命の灯火!') == '命の灯火'
# ---------------------------------------------------------------------------
# Similarity scoring on CJK titles.
# ---------------------------------------------------------------------------
def test_identical_cjk_titles_score_perfect_match(engine: MusicMatchingEngine):
"""Same Japanese title twice → 1.0. Pre-fix this was 0.0 because
both normalised to '' and the empty-string guard short-circuited."""
a = engine.clean_title('命の灯火')
b = engine.clean_title('命の灯火')
assert engine.similarity_score(a, b) == 1.0
def test_completely_disjoint_cjk_titles_score_low(engine: MusicMatchingEngine):
"""Two unrelated Japanese titles share no characters → similarity
near 0. The point is that they're DIFFERENT — pre-fix they both
normalised to '' so were treated the same as "identical"."""
a = engine.clean_title('命の灯火')
b = engine.clean_title('無職転生')
score = engine.similarity_score(a, b)
assert score < 0.3
def test_partially_overlapping_cjk_titles_score_partial(engine: MusicMatchingEngine):
"""Sequential matching gives a midrange score for partial overlap —
proves the comparator is actually looking at the characters, not
just returning 0 or 1."""
a = engine.clean_title('命の灯火')
b = engine.clean_title('命の音')
score = engine.similarity_score(a, b)
assert 0.3 < score < 1.0
def test_cjk_title_does_not_falsely_match_unrelated_latin_title(engine: MusicMatchingEngine):
"""Pre-fix bug: a CJK title normalised to '' would short-circuit
similarity scoring against ANY Latin title (also returning 0
because of the empty guard). That's still 0 in both directions
so the symptom isn't directly observable here — but pin that a
real CJK string vs a real Latin string returns a meaningful low
score, not a coincidental match."""
a = engine.clean_title('命の灯火')
b = engine.clean_title('Happy Days')
score = engine.similarity_score(a, b)
assert score < 0.2
# ---------------------------------------------------------------------------
# Regression: Latin-only titles are untouched.
# ---------------------------------------------------------------------------
def test_latin_normalisation_unchanged_for_simple_title(engine: MusicMatchingEngine):
"""No CJK in input → unidecode + lowercase path, exactly as before."""
assert engine.normalize_string('Happy Days') == 'happy days'
def test_latin_normalisation_unchanged_for_unidecode_target(engine: MusicMatchingEngine):
"""Cyrillic / accented Latin still goes through unidecode."""
assert engine.normalize_string('Björk') == 'bjork'
def test_latin_normalisation_unchanged_for_dollar_sign(engine: MusicMatchingEngine):
"""The ``$`` preservation rule still applies in the Latin branch
(A$AP Rocky etc.) pinned so the CJK refactor doesn't accidentally
drop it."""
norm = engine.normalize_string('A$AP Rocky')
assert '$' in norm
def test_latin_similarity_unchanged_for_baseline_comparison(engine: MusicMatchingEngine):
"""Sanity: the existing Latin-Latin scoring behaviour didn't shift.
Identical strings still score 1.0; different strings score below
1.0. Pin a specific pair from the regression report so a future
normaliser tweak doesn't quietly change Latin-side semantics."""
a = engine.clean_title('Happy Days')
b = engine.clean_title('Happy Days')
assert engine.similarity_score(a, b) == 1.0
c = engine.clean_title('Happy Days')
d = engine.clean_title('My Past Self')
assert engine.similarity_score(c, d) < 0.5
# ---------------------------------------------------------------------------
# Real-world scenarios from issue #722.
# ---------------------------------------------------------------------------
def test_mushoku_tensei_ost_track_titles_distinguishable():
"""End-to-end: the exact scenario from #722. Two different iTunes
tracks from Mushoku Tensei Original Soundtrack II pre-fix both
normalised to '' so the matcher couldn't tell them apart and
routed both to the same Tidal candidate. Post-fix they're
distinguishable.
Track titles taken from the iTunes album response for id 1753240110;
when the storefront returns Japanese titles instead of the English
romanisations (depends on user's region + storefront config), this
is the comparator the matcher will use."""
engine = MusicMatchingEngine()
# Two distinct OST tracks rendered in Japanese.
track_a = engine.clean_title('幸せの日々') # 'Happy Days'
track_b = engine.clean_title('家探し') # 'Home Search'
score = engine.similarity_score(track_a, track_b)
# The match must be well below the 0.7+ threshold the candidate
# scorer uses to accept a match — otherwise both iTunes tracks
# would still pick the same Tidal candidate and duplicate.
assert score < 0.5
if __name__ == '__main__':
pytest.main([__file__, '-v'])

View file

@ -0,0 +1,72 @@
"""Reorganize must skip files in the duplicate-cleaner quarantine (#746).
The Duplicate Cleaner moves de-duplicated files into ``<transfer>/deleted/``.
If the user's media server scans the transfer folder (e.g. a ``/music`` root
holding both the library and the transfer dir), those quarantined files get real
rows in SoulSync's DB. Reorganize is purely DB-driven, so without a guard it
would move them back OUT of /deleted to the template location.
These tests pin:
1. ``_is_in_deleted_quarantine`` the anchored detection, including the
false-positive guard (an album literally named "Deleted" elsewhere is kept).
2. ``preview_album_reorganize`` a quarantined track is surfaced as a skip,
a normal track is not (proves the planner-shared guard fires on the path
both preview AND apply run through).
"""
from __future__ import annotations
import os
from core.library_reorganize import _is_in_deleted_quarantine
TRANSFER = os.path.join(os.sep, "music", "soulsync")
class TestIsInDeletedQuarantine:
def test_file_directly_in_quarantine_is_flagged(self):
p = os.path.join(TRANSFER, "deleted", "Artist", "Album", "01.flac")
assert _is_in_deleted_quarantine(p, TRANSFER) is True
def test_file_in_normal_album_is_not_flagged(self):
p = os.path.join(TRANSFER, "Artist", "Album", "01.flac")
assert _is_in_deleted_quarantine(p, TRANSFER) is False
def test_album_with_deleted_in_name_is_kept(self):
"""Anchored to the <transfer>/deleted PREFIX, not a substring — a
real album like 'Deleted Scenes' nested under an artist must NOT be
skipped."""
p = os.path.join(TRANSFER, "Artist", "Deleted Scenes", "01.flac")
assert _is_in_deleted_quarantine(p, TRANSFER) is False
def test_known_unavoidable_collision_is_documented(self):
"""The ONE genuine ambiguity: an artist folder named exactly
'deleted' sitting directly at the transfer root occupies the same
path as the duplicate-cleaner quarantine, so it IS treated as
quarantine. This is unavoidable (we can't tell a real 'deleted'
artist from the cleaner's dir) and accepted — pinned here so the
behavior is intentional, not a surprise. Differently-cased or
nested 'Deleted' names are safe (see the other tests)."""
collision = os.path.join(TRANSFER, "deleted", "Album", "01.flac")
assert _is_in_deleted_quarantine(collision, TRANSFER) is True
def test_substring_not_matched(self):
"""'Undeleted' / 'deleted_scenes' as a segment must not trip the
exact-segment / prefix check."""
p = os.path.join(TRANSFER, "Undeleted", "Album", "01.flac")
assert _is_in_deleted_quarantine(p, TRANSFER) is False
def test_no_transfer_dir_falls_back_to_segment_match(self):
p = os.path.join(os.sep, "anywhere", "deleted", "x.flac")
assert _is_in_deleted_quarantine(p, None) is True
p2 = os.path.join(os.sep, "anywhere", "Undeleted", "x.flac")
assert _is_in_deleted_quarantine(p2, None) is False
def test_empty_path_is_safe(self):
assert _is_in_deleted_quarantine(None, TRANSFER) is False
assert _is_in_deleted_quarantine("", TRANSFER) is False
def test_nested_subfolders_under_quarantine_flagged(self):
p = os.path.join(TRANSFER, "deleted", "a", "b", "c", "track.flac")
assert _is_in_deleted_quarantine(p, TRANSFER) is True

View file

@ -0,0 +1,100 @@
"""Regression: a Soulseek album folder that yields ZERO usable files must fall
back to the per-track flow rather than hard-failing the whole batch.
The Slipknot case the preflight-selected peer aborts/stalls every transfer (all
tracks reported "Completed, Aborted" at 0 bytes) makes
``_poll_album_bundle_downloads`` return ``[]``. ``download_album_to_staging`` used
to return that empty result with ``fallback=False``, so the dispatcher
(``core/downloads/album_bundle_dispatch.try_dispatch``) hit its ``mark_failed``
branch and the batch died with nothing tried elsewhere.
The fix flips that branch to ``fallback=True`` so the existing, proven per-track
flow takes over and re-searches every missing track across ALL sources/peers
reusing that robustness instead of looping candidate folders inside the bundle.
The downstream "fallback=True -> per-track" routing is covered by
``tests/test_album_bundle_dispatch.py``
(``test_dispatch_fallback_failure_returns_false_for_per_track_flow``); these tests
prove the empty-folder branch actually sets the flag, and that a healthy folder
does NOT fall back.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
from core.soulseek_client import SoulseekClient
class _Stub:
"""Stand-in exposing only what download_album_to_staging touches on the
preflight-reuse path (preferred_source + preferred_tracks skip search/browse)."""
def __init__(self, poll_result):
self._poll_result = poll_result
def is_configured(self):
return True
def filter_results_by_quality_preference(self, tracks):
return tracks
def download(self, username, filename, size):
return f"dl-{filename}" # truthy id; run_async patched to identity
def _poll_album_bundle_downloads(self, transfer_keys, emit):
return self._poll_result
def _track(name):
return SimpleNamespace(username="deadpeer", filename=name, size=100)
def _run(poll_result):
stub = _Stub(poll_result)
tracks = [_track("01.flac"), _track("02.flac")]
with patch("core.soulseek_client.run_async", lambda x: x):
result = SoulseekClient.download_album_to_staging(
stub,
album_name="All Hope Is Gone",
artist_name="Slipknot",
staging_dir="/tmp/staging-does-not-matter",
preferred_source={
"username": "deadpeer",
"folder_path": "music/Slipknot/All Hope Is Gone",
},
preferred_tracks=tracks,
)
return result, tracks
def test_empty_folder_falls_back_to_per_track():
# Poll returns [] — every transfer aborted / stalled (dead peer).
result, _ = _run([])
assert result['success'] is False
assert result['fallback'] is True # <-- the fix: hand off to per-track
assert result['files'] == []
assert 'per-track' in (result['error'] or '')
def test_healthy_folder_does_not_fall_back():
# Positive control: the poll yields staged files and the copy succeeds, so we
# must NOT flip fallback — this proves the empty-branch isn't blanket-returning
# True. Patch the atomic copy to echo the completed paths through.
from pathlib import Path
completed = [Path("/staged/01.flac"), Path("/staged/02.flac")]
with patch("core.soulseek_client.copy_audio_files_atomically", lambda files, dest: list(files)):
stub = _Stub(completed)
with patch("core.soulseek_client.run_async", lambda x: x):
result = SoulseekClient.download_album_to_staging(
stub,
album_name="All Hope Is Gone",
artist_name="Slipknot",
staging_dir="/tmp/staging-does-not-matter",
preferred_source={"username": "goodpeer", "folder_path": "music/Slipknot/AHIG"},
preferred_tracks=[_track("01.flac"), _track("02.flac")],
)
assert result['success'] is True
assert result['fallback'] is False
assert result['partial'] is False
assert result['files'] == completed

View file

@ -0,0 +1,162 @@
"""Regression for the Soulseek album-bundle poll hanging when the peer stalls.
`_poll_album_bundle_downloads` waits for every transfer in the selected folder
to reach a terminal state (completed or failed). A transfer the peer stalls on
stuck InProgress/Queued, or dropped by slskd is never failed, never completed,
and never marked "completed-but-unresolved", so it used to block both the
all-terminal finish check AND the #715 grace exit, and the poll spun to the full
~6h timeout (the Slipknot hang).
The fix adds a bundle-level stall guard: if NOTHING progresses (no transfer
reaches terminal AND no pending transfer's byte count moves) for `_stall_grace`
seconds, the stuck transfers are marked failed so the bundle resolves with what
completed. These tests drive the real poll with a fake clock + scripted slskd
states.
"""
from __future__ import annotations
import os
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from core.soulseek_client import SoulseekClient
class _Clock:
def __init__(self):
self.now = 0.0
def monotonic(self):
return self.now
def sleep(self, seconds):
self.now += seconds
def _dl(username, filename, state, *, size=100, transferred=100):
return SimpleNamespace(
username=username, filename=filename, state=state,
size=size, transferred=transferred,
)
class _StubClient:
"""Minimal stand-in for SoulseekClient with just what the poll touches."""
def __init__(self, states, resolvable):
self._states = states # list of download-lists, one per poll; last repeats
self._call = 0
self._resolvable = set(resolvable)
def get_all_downloads(self):
i = min(self._call, len(self._states) - 1)
self._call += 1
return self._states[i]
def _resolve_downloaded_album_file(self, filename):
base = os.path.basename((filename or "").replace("\\", "/"))
if base in self._resolvable or filename in self._resolvable:
return Path(f"/staged/{base}")
return None
def _run_poll(stub, transfer_keys, *, timeout=7200.0, interval=2.0):
clock = _Clock()
emits = []
with patch("core.soulseek_client.time", clock), \
patch("core.soulseek_client.run_async", lambda x: x), \
patch("core.soulseek_client.get_poll_timeout", lambda: timeout), \
patch("core.soulseek_client.get_poll_interval", lambda: interval):
result = SoulseekClient._poll_album_bundle_downloads(
stub, transfer_keys, lambda phase, **kw: emits.append((phase, kw))
)
return result, clock, emits
def _keys(*names, user="peer"):
"""Build {(user, name): TrackResult-ish} preserving order."""
return {(user, n): SimpleNamespace(filename=n) for n in names}
def test_stalled_peer_gives_up_and_returns_completed_subset():
tk = _keys("01.flac", "02.flac", "03.flac")
# 01 completes (resolvable); 02/03 stuck InProgress with FROZEN byte counts.
frozen = [
_dl("peer", "01.flac", "Completed", transferred=100, size=100),
_dl("peer", "02.flac", "InProgress", transferred=50, size=100),
_dl("peer", "03.flac", "InProgress", transferred=30, size=100),
]
stub = _StubClient([frozen], resolvable={"01.flac"})
result, clock, _ = _run_poll(stub, tk, timeout=7200.0, interval=2.0)
# Resolved with the one completed track instead of hanging to the deadline.
assert result == [Path("/staged/01.flac")]
# Gave up around the stall window (~180s), nowhere near the 7200s timeout.
assert clock.now < 600.0
def test_progressing_bundle_is_not_falsely_stalled():
tk = _keys("01.flac", "02.flac")
# 02 keeps downloading more bytes each poll, then completes — must NOT trip
# the stall guard even though it takes a while.
states = [
[_dl("peer", "01.flac", "Completed"), _dl("peer", "02.flac", "InProgress", transferred=10, size=100)],
[_dl("peer", "01.flac", "Completed"), _dl("peer", "02.flac", "InProgress", transferred=40, size=100)],
[_dl("peer", "01.flac", "Completed"), _dl("peer", "02.flac", "InProgress", transferred=80, size=100)],
[_dl("peer", "01.flac", "Completed"), _dl("peer", "02.flac", "Completed", transferred=100, size=100)],
]
stub = _StubClient(states, resolvable={"01.flac", "02.flac"})
result, _clock, _ = _run_poll(stub, tk, timeout=7200.0, interval=2.0)
assert set(result) == {Path("/staged/01.flac"), Path("/staged/02.flac")}
def test_all_transfers_stalled_returns_empty():
tk = _keys("01.flac", "02.flac")
frozen = [
_dl("peer", "01.flac", "InProgress", transferred=10, size=100),
_dl("peer", "02.flac", "Queued", transferred=0, size=100),
]
stub = _StubClient([frozen], resolvable=set())
result, clock, _ = _run_poll(stub, tk, timeout=7200.0, interval=2.0)
assert result == [] # nothing completed → empty (caller falls back)
assert clock.now < 600.0 # didn't spin to the deadline
def test_dropped_transfers_also_stall_out():
"""slskd dropping the transfers entirely (dl=None) must also trip the guard,
not hang there's no byte progress and nothing terminal."""
tk = _keys("01.flac", "02.flac")
stub = _StubClient([[]], resolvable=set()) # get_all_downloads returns nothing
result, clock, _ = _run_poll(stub, tk, timeout=7200.0, interval=2.0)
assert result == []
assert clock.now < 600.0
def test_completed_aborted_classified_as_failed_not_unresolved():
"""slskd reports a peer-side abort as 'Completed, Aborted' at 0 bytes. Because
that string contains 'Completed', it was misread as 'completed but file
missing' (#715 path). It must be classified as FAILED — so an all-aborted
folder resolves immediately, not after the unresolved/stall grace."""
tk = _keys("01.flac", "02.flac")
aborted = [
_dl("peer", "01.flac", "Completed, Aborted", transferred=0, size=3_600_000),
_dl("peer", "02.flac", "Completed, Aborted", transferred=0, size=24_700_000),
]
stub = _StubClient([aborted], resolvable=set())
result, clock, _ = _run_poll(stub, tk, timeout=7200.0, interval=2.0)
assert result == [] # all failed → empty (caller falls back)
assert clock.now < 30.0 # resolved on the first poll, no 45s/180s wait
def test_clean_finish_unaffected():
tk = _keys("01.flac", "02.flac")
done = [_dl("peer", "01.flac", "Completed"), _dl("peer", "02.flac", "Succeeded")]
stub = _StubClient([done], resolvable={"01.flac", "02.flac"})
result, clock, _ = _run_poll(stub, tk)
assert set(result) == {Path("/staged/01.flac"), Path("/staged/02.flac")}
assert clock.now < 10.0 # resolves on the first couple polls

View file

@ -0,0 +1,99 @@
"""Tests for the canonical source-ID registry (core/source_ids.py)."""
from __future__ import annotations
import sqlite3
import pytest
from core import source_ids as sid
def test_canonical_columns_match_real_schema():
# Spot-check the canonical names against the actual DB columns.
assert sid.id_column("spotify", "artist") == "spotify_artist_id"
assert sid.id_column("deezer", "artist") == "deezer_id"
assert sid.id_column("musicbrainz", "artist") == "musicbrainz_id"
assert sid.id_column("hydrabase", "artist") == "soul_id"
assert sid.id_column("spotify", "album") == "spotify_album_id"
assert sid.id_column("musicbrainz", "album") == "musicbrainz_release_id"
assert sid.id_column("deezer", "album") == "deezer_id"
assert sid.id_column("spotify", "track") == "spotify_track_id"
assert sid.id_column("musicbrainz", "track") == "musicbrainz_recording_id"
def test_id_column_unknown_returns_none():
assert sid.id_column("nonesuch", "artist") is None
assert sid.id_column("spotify", "playlist") is None
def test_id_keys_canonical_first_then_aliases():
keys = sid.id_keys("deezer", "artist")
assert keys[0] == "deezer_id" # canonical first
assert "deezer_artist_id" in keys # watchlist/pool alias
assert "similar_artist_deezer_id" in keys
def test_get_id_reads_canonical_column():
row = {"deezer_id": "525046", "name": "Artist"}
assert sid.get_id(row, "deezer", "artist") == "525046"
def test_get_id_falls_back_to_alias():
# A watchlist-shaped row uses deezer_artist_id, not deezer_id.
row = {"deezer_artist_id": "999", "artist_name": "X"}
assert sid.get_id(row, "deezer", "artist") == "999"
def test_get_id_prefers_canonical_over_alias():
row = {"deezer_id": "canon", "deezer_artist_id": "alias"}
assert sid.get_id(row, "deezer", "artist") == "canon"
def test_get_id_missing_and_empty_return_none():
assert sid.get_id({"name": "X"}, "deezer", "artist") is None
assert sid.get_id({"deezer_id": ""}, "deezer", "artist") is None
assert sid.get_id({"deezer_id": None}, "deezer", "artist") is None
def test_get_id_works_with_sqlite_row():
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.execute("CREATE TABLE t (spotify_artist_id TEXT, name TEXT)")
conn.execute("INSERT INTO t VALUES ('spfy123', 'Artist')")
row = conn.execute("SELECT * FROM t").fetchone()
conn.close()
assert sid.get_id(row, "spotify", "artist") == "spfy123"
# A column not present on the row must not raise — just None.
assert sid.get_id(row, "deezer", "artist") is None
def test_source_id_map_builds_provider_dict():
row = {
"spotify_artist_id": "s1",
"deezer_id": "d1",
"itunes_artist_id": None,
}
result = sid.source_id_map(row, "artist", providers=["spotify", "deezer", "itunes"])
assert result == {"spotify": "s1", "deezer": "d1", "itunes": None}
def test_source_id_map_default_covers_all_providers():
result = sid.source_id_map({"deezer_id": "d1"}, "artist")
assert result["deezer"] == "d1"
assert "spotify" in result and result["spotify"] is None
def test_source_id_field_unchanged_after_registry_refactor():
"""artist_source_lookup.SOURCE_ID_FIELD must keep its exact prior mapping
after being folded into the registry (no behavior change)."""
from core.artist_source_lookup import SOURCE_ID_FIELD
assert SOURCE_ID_FIELD == {
"spotify": "spotify_artist_id",
"itunes": "itunes_artist_id",
"deezer": "deezer_id",
"discogs": "discogs_id",
"hydrabase": "soul_id",
"musicbrainz": "musicbrainz_id",
"amazon": "amazon_id",
}

View file

@ -361,6 +361,102 @@ def test_usenet_finalize_picks_first_audio_file(tmp_path: Path) -> None:
assert plugin.active_downloads['u-1']['file_path'].endswith('track1.flac')
class _FakeClock:
"""Deterministic monotonic + sleep so the per-track poll loop runs
in microseconds and never actually blocks."""
def __init__(self) -> None:
self.now = 0.0
def monotonic(self) -> float:
return self.now
def sleep(self, seconds: float) -> None:
self.now += seconds
def _drive_download_thread(plugin, statuses, *, window_seconds=10.0):
"""Run ``_download_thread`` end-to-end against a scripted adapter.
``statuses`` is the sequence of ``UsenetStatus`` reads the poll loop
will see (one per poll). Returns the finished active_downloads row."""
download_id = 'u-poll'
plugin.active_downloads[download_id] = {
'id': download_id, 'filename': 'x', 'username': 'usenet',
'display_name': 'X', 'state': 'Initializing', 'progress': 0.0,
'size': 0, 'transferred': 0, 'speed': 0, 'file_path': None,
'audio_files': [], 'job_id': None, 'error': None,
}
adapter = MagicMock()
adapter.is_configured.return_value = True
adapter.add_nzb.return_value = 'job1'
adapter.get_status.side_effect = list(statuses)
clock = _FakeClock()
with patch('core.download_plugins.usenet.get_active_usenet_adapter', return_value=adapter), \
patch('core.download_plugins.usenet.run_async', side_effect=lambda x: x), \
patch('core.download_plugins.usenet.get_completed_no_path_window_seconds',
return_value=window_seconds), \
patch('core.download_plugins.usenet.time', clock), \
patch('core.download_plugins.usenet.collect_audio_after_extraction',
return_value=[Path('/done/track1.flac')]):
plugin._download_thread(download_id, 'http://x/y.nzb')
return plugin.active_downloads[download_id]
def test_usenet_thread_waits_out_completed_no_path_then_finalizes(tmp_path: Path) -> None:
"""Per-track sibling of the #721 bundle fix. SAB flips History to
'completed' before writing ``storage`` the thread must NOT error
on the first such read. It waits out the completed-no-path window;
when the path lands it finalizes as Succeeded."""
plugin = UsenetDownloadPlugin()
statuses = [
UsenetStatus(id='job1', name='A', state='downloading', progress=0.6,
size=100, downloaded=60, download_speed=10),
UsenetStatus(id='job1', name='A', state='completed', progress=1.0,
size=100, downloaded=100, download_speed=0, save_path=None),
UsenetStatus(id='job1', name='A', state='completed', progress=1.0,
size=100, downloaded=100, download_speed=0, save_path=None),
UsenetStatus(id='job1', name='A', state='completed', progress=1.0,
size=100, downloaded=100, download_speed=0,
save_path='/done/album'),
]
row = _drive_download_thread(plugin, statuses)
assert row['state'] == 'Completed, Succeeded'
assert row['progress'] == 100.0
assert row['file_path'] == str(Path('/done/track1.flac'))
def test_usenet_thread_falls_back_to_incomplete_path_when_storage_never_lands() -> None:
"""If ``storage`` never lands but SAB exposed an ``incomplete_path``
(files physically on disk), the thread recovers via the in-progress
dir as a last resort rather than erroring a completed download."""
plugin = UsenetDownloadPlugin()
completed_no_path = UsenetStatus(
id='job1', name='A', state='completed', progress=1.0,
size=100, downloaded=100, download_speed=0,
save_path=None, incomplete_path='/sab/incomplete/A',
)
# Window of 10s / 2s interval = 5 polls, floored at the miss
# threshold; supply plenty so the fallback fires.
row = _drive_download_thread(plugin, [completed_no_path] * 12)
assert row['state'] == 'Completed, Succeeded'
assert row['audio_files'] == [str(Path('/done/track1.flac'))]
def test_usenet_thread_errors_when_completed_with_no_path_at_all() -> None:
"""No final save_path AND no incomplete_path → there's nothing to
scan, so the thread errors (rather than spinning or finalizing a
phantom path)."""
plugin = UsenetDownloadPlugin()
completed_no_path = UsenetStatus(
id='job1', name='A', state='completed', progress=1.0,
size=100, downloaded=100, download_speed=0, save_path=None,
)
row = _drive_download_thread(plugin, [completed_no_path] * 12)
assert row['state'] == 'Completed, Errored'
assert 'save_path' in (row['error'] or '').lower()
def test_usenet_is_configured_requires_both_sides() -> None:
plugin = UsenetDownloadPlugin()
fake_adapter = MagicMock()

View file

@ -165,6 +165,192 @@ def test_sab_parse_history_slot_marks_failures() -> None:
assert success.save_path == '/done'
def test_sab_history_save_path_falls_back_to_path_field() -> None:
"""Older SAB releases populated ``path`` instead of ``storage``.
The adapter must fall through the field-name chain so we pick it
up either way."""
adapter = _sab_with_config()
slot = adapter._parse_history_slot({
'nzo_id': 'z', 'name': 'Z', 'status': 'Completed',
'bytes': 0, 'path': '/legacy/sab/path',
})
assert slot.save_path == '/legacy/sab/path'
def test_sab_history_save_path_falls_back_to_download_path_field() -> None:
"""Some SAB forks expose ``download_path`` instead of the
documented ``storage``. Same fallback chain catches it."""
adapter = _sab_with_config()
slot = adapter._parse_history_slot({
'nzo_id': 'z2', 'name': 'Z2', 'status': 'Completed',
'bytes': 0, 'download_path': '/fork/dl',
})
assert slot.save_path == '/fork/dl'
def test_sab_history_save_path_prefers_storage_when_multiple_present() -> None:
"""Field priority: ``storage`` wins over the fallbacks. The
documented final-path key must be preferred so SAB upgrades
don't subtly change the resolved path."""
adapter = _sab_with_config()
slot = adapter._parse_history_slot({
'nzo_id': 'p', 'name': 'P', 'status': 'Completed',
'bytes': 0,
'storage': '/final/storage',
'path': '/legacy/path',
'download_path': '/fork/dl',
'dirname': '/dirname',
})
assert slot.save_path == '/final/storage'
def test_sab_history_save_path_none_when_all_fields_empty() -> None:
"""Regression for #721: SAB's ``storage`` field lands a few
seconds after the job flips to History. During that window
EVERY known path field can be empty. The adapter must return
``save_path=None`` (not a stale string) so
``poll_album_download``'s retry loop can engage and wait for
the next poll where ``storage`` lands."""
adapter = _sab_with_config()
slot = adapter._parse_history_slot({
'nzo_id': 'gap', 'name': 'Forty Licks', 'status': 'Completed',
'bytes': 0,
# No storage / path / download_path / dirname.
})
assert slot.state == 'completed'
assert slot.save_path is None
def test_sab_history_save_path_ignores_whitespace_only_values() -> None:
"""A field present but with whitespace-only content shouldn't
fool the fallback chain keep walking until a real path lands."""
adapter = _sab_with_config()
slot = adapter._parse_history_slot({
'nzo_id': 'ws', 'name': 'W', 'status': 'Completed',
'bytes': 0,
'storage': ' ',
'path': '\t',
'download_path': '/actual/path',
})
assert slot.save_path == '/actual/path'
def test_sab_history_save_path_ignores_incomplete_path() -> None:
"""``incomplete_path`` is SAB's in-progress staging dir before
post-process moves files to the final ``storage``. Using it as
a save_path fallback would bypass ``poll_album_download``'s
retry window AND point the bundle plugin at the wrong dir
the in-progress staging files might be gone by the time we
walk it, or they might be partially-extracted. Safer to return
``None`` so the poll retries until ``storage`` lands. Pinned
here so a future "let's add another fallback" change doesn't
silently re-introduce the foot-gun."""
adapter = _sab_with_config()
slot = adapter._parse_history_slot({
'nzo_id': 'inc', 'name': 'Inc', 'status': 'Completed',
'bytes': 0,
'incomplete_path': '/sab/incomplete/job',
})
assert slot.save_path is None
def test_sab_history_post_processing_states_are_not_terminal() -> None:
"""#721 production root cause: SAB keeps a job in History while it
post-processes (verify / repair / unpack / move), exposing the live
stage in ``status``. Those must map to NON-terminal states so the
poll keeps waiting NOT to 'completed', which would make the poll
treat a still-extracting album as "completed but no save_path" and
bail mid-PP (the stuck-at-99% bug). Only a real 'Completed' is
terminal success."""
adapter = _sab_with_config()
for sab_status, expected in [
('Verifying', 'verifying'),
('Repairing', 'repairing'),
('Extracting', 'extracting'),
('Moving', 'extracting'),
('Running', 'extracting'),
('Queued', 'queued'),
]:
slot = adapter._parse_history_slot({
'nzo_id': 'pp', 'name': 'PP', 'status': sab_status,
'bytes': 1000,
# ``storage`` not written yet — PP still in flight.
})
assert slot.state == expected, f'{sab_status} -> {slot.state}, want {expected}'
# Non-terminal: not 'completed', not 'failed'.
assert slot.state not in ('completed', 'failed')
# Download is done, so progress is full, but no final path yet.
assert slot.progress == 1.0
assert slot.save_path is None
def test_sab_history_completed_still_resolves_save_path() -> None:
"""The true-completion path is unchanged: status 'Completed' with a
populated ``storage`` resolves to terminal success + final path."""
adapter = _sab_with_config()
slot = adapter._parse_history_slot({
'nzo_id': 'done', 'name': 'D', 'status': 'Completed',
'bytes': 1000, 'storage': '/data/downloads/music/Album',
})
assert slot.state == 'completed'
assert slot.progress == 1.0
assert slot.save_path == '/data/downloads/music/Album'
def test_sab_history_post_processing_ignores_storage_until_completed() -> None:
"""Even if SAB has written a (possibly incomplete-dir) path field
while still post-processing, the adapter must NOT expose it as
save_path until the slot flips to 'Completed' otherwise the bundle
could stage from a half-unpacked directory."""
adapter = _sab_with_config()
slot = adapter._parse_history_slot({
'nzo_id': 'pp2', 'name': 'PP2', 'status': 'Extracting',
'bytes': 1000, 'storage': '/data/downloads/music/Album',
})
assert slot.state == 'extracting'
assert slot.save_path is None
def test_sab_history_surfaces_incomplete_path_separately_from_save_path() -> None:
"""``incomplete_path`` must be exposed on its OWN field, never folded
into ``save_path``. The poll loops fall back to it only as a last
resort after the completed-no-path window is exhausted (#721) — using
it as save_path would bypass that window and point staging at the
pre-move dir on every normal completion."""
adapter = _sab_with_config()
slot = adapter._parse_history_slot({
'nzo_id': 'gap2', 'name': 'No Storage', 'status': 'Completed',
'bytes': 0,
# storage / path / download_path / dirname all absent — the
# #721 gap window — but the in-progress dir is known.
'incomplete_path': '/sab/incomplete/No Storage',
})
assert slot.save_path is None
assert slot.incomplete_path == '/sab/incomplete/No Storage'
def test_sab_history_incomplete_path_ignores_whitespace_only() -> None:
adapter = _sab_with_config()
slot = adapter._parse_history_slot({
'nzo_id': 'gap3', 'name': 'WS', 'status': 'Completed',
'bytes': 0, 'incomplete_path': ' ',
})
assert slot.incomplete_path is None
def test_sab_history_prefers_storage_and_still_carries_incomplete_path() -> None:
"""When ``storage`` IS present, save_path resolves to it normally,
and incomplete_path is carried alongside (harmless the poll only
consults it when save_path never lands)."""
adapter = _sab_with_config()
slot = adapter._parse_history_slot({
'nzo_id': 'both', 'name': 'B', 'status': 'Completed',
'bytes': 0, 'storage': '/final', 'incomplete_path': '/inc',
})
assert slot.save_path == '/final'
assert slot.incomplete_path == '/inc'
def test_sab_add_nzb_via_url_returns_first_nzo_id() -> None:
adapter = _sab_with_config()
with patch('core.usenet_clients.sabnzbd.http_requests.get',
@ -336,6 +522,86 @@ def test_sab_poll_recovers_after_queue_to_history_handoff_gap() -> None:
assert 'failed' not in [e[0] for e in emits]
def test_sab_poll_waits_through_history_post_processing_then_completes() -> None:
"""Integration regression for the #721 PRODUCTION root cause
(David Bowie - Hunky Dory, 1.7 GB FLAC, stuck at 99%).
SAB moves a finished download into History and runs par2 verify +
unpack THERE, exposing the live stage in ``status`` while ``storage``
stays empty until the final move. For a 1.7 GB FLAC album that
post-processing window is far longer than the ~10s completed-no-path
budget. Pre-fix the adapter mapped 'Extracting' 'completed', so the
poll saw "completed but no save_path" the instant download finished,
burned its budget mid-PP, and bailed freezing the UI on the last
'downloading' emit (99%). SAB then finished fine, which is why the
job shows Completed in History but SoulSync never staged it.
Post-fix the in-PP History slots map to NON-terminal states, so the
poll just keeps waiting (as 'downloading') until SAB flips the slot
to 'Completed' with a real ``storage`` path no matter how long PP
takes then returns it."""
from core.download_plugins.album_bundle import poll_album_download
adapter = _sab_with_config()
downloading = _mock_response(200, {'queue': {'slots': [
{'nzo_id': 'hd', 'filename': 'Hunky Dory', 'status': 'Downloading',
'percentage': '99', 'mb': '1700', 'mbleft': '17', 'timeleft': '0:00:05'},
]}})
empty_queue = _mock_response(200, {'queue': {'slots': []}})
# Long post-processing window, reported via HISTORY (download already
# left the queue). storage NOT yet written.
pp_verify = _mock_response(200, {'history': {'slots': [
{'nzo_id': 'hd', 'name': 'Hunky Dory', 'status': 'Verifying', 'bytes': 1_700_000_000},
]}})
pp_extract = _mock_response(200, {'history': {'slots': [
{'nzo_id': 'hd', 'name': 'Hunky Dory', 'status': 'Extracting', 'bytes': 1_700_000_000},
]}})
done = _mock_response(200, {'history': {'slots': [
{'nzo_id': 'hd', 'name': 'Hunky Dory', 'status': 'Completed',
'bytes': 1_700_000_000,
'storage': '/data/downloads/music/David.Bowie-Hunky.Dory'},
]}})
# Each poll = queue call + (if empty) history call.
poll_results = [
downloading, # poll 1: queue has it @99% (1 call)
empty_queue, pp_verify, # poll 2: PP verifying in history
empty_queue, pp_verify, # poll 3: still verifying
empty_queue, pp_extract, # poll 4: extracting
empty_queue, pp_extract, # poll 5: still extracting
empty_queue, pp_extract, # poll 6: still extracting (past old 5-poll budget)
empty_queue, pp_extract, # poll 7
empty_queue, done, # poll 8: completed + storage
]
class _Clock:
def __init__(self): self.now = 0.0
def monotonic(self): return self.now
def sleep(self, s): self.now += s
clock = _Clock()
emits: list = []
with patch('core.usenet_clients.sabnzbd.http_requests.get',
side_effect=poll_results):
result = poll_album_download(
get_status=lambda: adapter._get_status_sync('hd'),
title='David Bowie - Hunky Dory',
emit=lambda state, **kw: emits.append((state, kw)),
complete_states=frozenset(['completed']),
failed_states=frozenset(['failed']),
transient_miss_threshold=5,
# Short no-path budget proves PP is NOT consuming it anymore.
completed_no_path_threshold=2,
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=600.0,
)
assert result == '/data/downloads/music/David.Bowie-Hunky.Dory'
# Never failed despite PP outlasting both the miss budget AND the
# (deliberately tiny) completed-no-path budget — PP is non-terminal.
assert 'failed' not in [e[0] for e in emits]
# ---------------------------------------------------------------------------
# NZBGet
# ---------------------------------------------------------------------------

View file

@ -131,6 +131,7 @@ def _build_runtime(
batch_map=None,
guard_acquired=True,
is_actually_processing=False,
album_bundle_executor=None,
):
if progress_calls is None:
progress_calls = []
@ -171,6 +172,7 @@ def _build_runtime(
update_automation_progress=progress_callback,
automation_engine=None,
missing_download_executor=executor,
album_bundle_executor=album_bundle_executor,
run_full_missing_tracks_process=lambda *args, **kwargs: None,
get_batch_max_concurrent=lambda: 4,
get_active_server=lambda: active_server,
@ -466,3 +468,86 @@ def test_process_wishlist_automatically_skips_when_wishlist_batch_is_already_act
assert guard_events == ["enter", "exit"]
assert [kwargs.get("progress") for _args, kwargs in progress_calls if "progress" in kwargs] == [10]
assert any("already active in another batch" in msg for msg in logger.info_messages)
# --- #740: album-bundle batches must route to the dedicated pool ------------
def _two_album_tracks_plus_orphan():
"""2 missing tracks from one album (→ album-bundle batch) + 1 orphan
track with no album metadata ( residual per-track batch)."""
return [
{
"name": "Album Song 1",
"artists": [{"name": "Artist 1"}],
"spotify_data": {
"album": {"id": "alb1", "name": "Album One", "album_type": "album"},
"artists": [{"name": "Artist 1"}],
},
},
{
"name": "Album Song 2",
"artists": [{"name": "Artist 1"}],
"spotify_data": {
"album": {"id": "alb1", "name": "Album One", "album_type": "album"},
"artists": [{"name": "Artist 1"}],
},
},
{
"name": "Orphan",
"artists": [{"name": "X"}],
"spotify_data": {"album": {"album_type": "album"}, "artists": [{"name": "X"}]},
},
]
def test_album_subbatches_route_to_dedicated_album_pool():
"""#740: per-album bundle batches (which block their worker thread for the
whole search+download) must be submitted to the dedicated album_bundle_executor,
NOT the shared missing_download_executor so a burst of album batches can't
starve the per-track flow / manual wishlist. The residual per-track batch
still goes to the shared pool."""
album_executor = _FakeExecutor()
batch_map = {}
runtime, _s, _p, _db, shared_executor, _l, _pr, _g = _build_runtime(
tracks=_two_album_tracks_plus_orphan(),
cycle_value="albums",
count=3,
batch_map=batch_map,
album_bundle_executor=album_executor,
)
process_wishlist_automatically(runtime, automation_id="auto-pool")
# Album sub-batch → dedicated album pool; residual → shared pool.
assert len(album_executor.submissions) == 1
assert len(shared_executor.submissions) == 1
album_batch_id = album_executor.submissions[0][1][0]
assert batch_map[album_batch_id]["is_album_download"] is True
assert batch_map[album_batch_id]["album_context"]["name"] == "Album One"
residual_batch_id = shared_executor.submissions[0][1][0]
assert batch_map[residual_batch_id].get("is_album_download") is not True
def test_album_subbatches_fall_back_to_shared_pool_when_no_album_pool():
"""Bulletproofing: if no dedicated album pool is wired (older callers /
tests), album batches fall back to the shared executor i.e. exactly the
pre-fix behavior, so nothing breaks."""
batch_map = {}
runtime, _s, _p, _db, shared_executor, _l, _pr, _g = _build_runtime(
tracks=_two_album_tracks_plus_orphan(),
cycle_value="albums",
count=3,
batch_map=batch_map,
album_bundle_executor=None, # not wired
)
process_wishlist_automatically(runtime, automation_id="auto-fallback")
# Both the album sub-batch and the residual land on the shared pool.
assert len(shared_executor.submissions) == 2
assert any(
batch_map[args[0]].get("is_album_download") is True
for _fn, args, _kw in shared_executor.submissions
)

View file

@ -0,0 +1,102 @@
"""Tests for make_wishlist_batch_row — the single source of truth for a wishlist
download_batches row, shared by the auto and manual flows so their batch shapes
can't drift apart.
"""
from __future__ import annotations
from core.wishlist.processing import make_wishlist_batch_row
_CORE_KEYS = {
'phase', 'playlist_id', 'playlist_name', 'queue', 'active_count',
'max_concurrent', 'queue_index', 'analysis_total', 'analysis_processed',
'analysis_results', 'permanently_failed_tracks', 'cancelled_tracks',
'force_download_all', 'profile_id', 'is_album_download', 'album_context',
'artist_context', 'wishlist_run_id',
}
def _row(**overrides):
base = dict(
playlist_id='wishlist', playlist_name='Wishlist', track_count=3,
max_concurrent=4, profile_id=1, phase='analysis',
)
base.update(overrides)
return make_wishlist_batch_row(**base)
def test_core_fields_always_present_and_consistent():
row = _row()
assert _CORE_KEYS <= set(row.keys())
# Fresh-batch invariants.
assert row['queue'] == [] and row['active_count'] == 0 and row['queue_index'] == 0
assert row['analysis_processed'] == 0
assert row['analysis_results'] == [] and row['permanently_failed_tracks'] == []
assert row['cancelled_tracks'] == set()
assert row['force_download_all'] is True
assert row['analysis_total'] == 3
assert row['max_concurrent'] == 4
assert row['profile_id'] == 1
def test_residual_defaults_are_per_track():
row = _row()
assert row['is_album_download'] is False
assert row['album_context'] is None and row['artist_context'] is None
assert row['wishlist_run_id'] is None
def test_album_batch_carries_context():
row = _row(
phase='queued', run_id='run-1', is_album=True,
album_context={'name': 'Album One'}, artist_context={'name': 'Artist 1'},
)
assert row['phase'] == 'queued'
assert row['is_album_download'] is True
assert row['album_context'] == {'name': 'Album One'}
assert row['artist_context'] == {'name': 'Artist 1'}
assert row['wishlist_run_id'] == 'run-1'
def test_extra_fields_merged_for_auto():
row = _row(extra_fields={
'auto_initiated': True, 'auto_processing_timestamp': 123.0,
'current_cycle': 'albums',
})
assert row['auto_initiated'] is True
assert row['auto_processing_timestamp'] == 123.0
assert row['current_cycle'] == 'albums'
def test_manual_row_has_no_auto_fields():
"""Manual rows must not carry the auto-only fields (no extra_fields)."""
row = _row(phase='analysis')
assert 'auto_initiated' not in row
assert 'current_cycle' not in row
def test_fresh_rows_do_not_share_mutable_state():
"""Each row must get its OWN queue/list/set — not a shared reference that
one batch's tasks could leak into another's."""
a = _row()
b = _row()
a['queue'].append('task-1')
a['cancelled_tracks'].add('x')
assert b['queue'] == []
assert b['cancelled_tracks'] == set()
assert b['analysis_results'] == []
def test_auto_and_manual_rows_share_identical_key_shape():
"""The drift-prevention guarantee: an auto album row and a manual album row
expose the same set of keys (modulo the auto-only extras), so the modal /
status code sees a consistent shape from both flows."""
manual = _row(phase='analysis', run_id='r', is_album=True,
album_context={'name': 'A'}, artist_context={'name': 'B'})
auto = _row(phase='queued', run_id='r', is_album=True,
album_context={'name': 'A'}, artist_context={'name': 'B'},
extra_fields={'auto_initiated': True, 'current_cycle': 'albums'})
# Auto is a strict superset (the auto-only extras); the shared core is identical.
assert set(manual.keys()) <= set(auto.keys())
assert set(auto.keys()) - set(manual.keys()) == {'auto_initiated', 'current_cycle'}

View file

@ -110,6 +110,17 @@ def _run_submitted_bg_job(executor):
fn(*args, **kwargs)
def _dispatched(executor, runtime):
"""The run_full_missing_tracks_process dispatches the shared engine submitted
to the executor (everything after the initial bg-job submission). Manual now
parallel-dispatches via the engine instead of running them serially inline,
so the master worker is *submitted*, not called directly."""
return [
s for s in executor.submissions
if s[0] is runtime.run_full_missing_tracks_process
]
def test_start_manual_wishlist_download_batch_returns_immediately_with_placeholder():
"""Endpoint returns 200 immediately; cleanup runs in the bg job."""
runtime, service, _db, executor, _logger, activity_calls, batch_map, master_calls = _build_runtime(
@ -174,11 +185,15 @@ def test_start_manual_wishlist_download_batch_filters_track_ids_and_starts_batch
_run_submitted_bg_job(executor)
assert activity_calls == [("", "Wishlist Download Started", "1 tracks", "Now")]
assert len(master_calls) == 1
master_args, _ = master_calls[0]
assert master_args[1] == "wishlist"
assert master_args[2][0]["id"] == "track-2"
assert master_args[2][0]["_original_index"] == 0
# One track → no album group (threshold 2) → one residual batch, dispatched
# via the shared engine (submitted to the executor, not called inline).
dispatched = _dispatched(executor, runtime)
assert len(dispatched) == 1
args = dispatched[0][1]
assert args[1] == "wishlist"
assert args[2][0]["id"] == "track-2"
assert args[2][0]["_original_index"] == 0
assert args[0] == payload["batch_id"] # placeholder batch_id reused as first sub-batch
assert batch_map[payload["batch_id"]]["analysis_total"] == 1
assert batch_map[payload["batch_id"]]["force_download_all"] is True
assert any("Filtered to 1 specific tracks by ID" in msg for msg in logger.info_messages)
@ -224,10 +239,12 @@ def test_start_manual_wishlist_download_batch_does_not_run_library_cleanup():
# The library check is skipped entirely — no per-track DB lookups.
assert db.track_checks == []
# All tracks are submitted to the master worker — including the "owned" one.
assert len(master_calls) == 1
master_args, _ = master_calls[0]
assert [track["id"] for track in master_args[2]] == ["enhance-1", "owned-1"]
# All tracks are dispatched to the master worker — including the "owned" one.
# Two single-track albums → no album groups → one residual batch of both.
dispatched = _dispatched(executor, runtime)
assert len(dispatched) == 1
args = dispatched[0][1]
assert [track["id"] for track in args[2]] == ["enhance-1", "owned-1"]
assert batch_map[payload["batch_id"]]["analysis_total"] == 2
assert activity_calls == [("", "Wishlist Download Started", "2 tracks", "Now")]
@ -293,28 +310,33 @@ def test_manual_wishlist_splits_into_per_album_sub_batches():
assert status == 200
_run_submitted_bg_job(executor)
# Two album groups → two master-worker calls.
assert len(master_calls) == 2
# Two album groups → two album sub-batches, PARALLEL-dispatched via the shared
# engine (same as auto) — not serial inline calls.
dispatched = _dispatched(executor, runtime)
assert len(dispatched) == 2
assert master_calls == [] # nothing run synchronously inline anymore
# First sub-batch uses the caller-allocated batch_id.
first_args, _ = master_calls[0]
# First sub-batch reuses the caller-allocated placeholder batch_id.
first_args = dispatched[0][1]
assert first_args[0] == payload["batch_id"]
assert batch_map[payload["batch_id"]].get("is_album_download") is True
# Both dispatched batches are album bundles.
for _fn, args, _kw in dispatched:
assert batch_map[args[0]].get("is_album_download") is True
# Second sub-batch gets a fresh uuid; its row exists in batch_map.
second_args, _ = master_calls[1]
second_args = dispatched[1][1]
assert second_args[0] != payload["batch_id"]
assert second_args[0] in batch_map
assert batch_map[second_args[0]].get("is_album_download") is True
# Track counts across the two sub-batches: 2 each at threshold=2.
counts = sorted(len(args[2]) for args, _ in master_calls)
counts = sorted(len(args[2]) for _fn, args, _kw in dispatched)
assert counts == [2, 2]
# Both sub-batches carry album context populated from spotify_data.
album_names = {
batch_map[args[0]]["album_context"]["name"]
for args, _ in master_calls
for _fn, args, _kw in dispatched
}
assert album_names == {"Album One", "Album Two"}

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,7 @@
<meta name="theme-color" content="#1db954">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='pwa-icon-192.png', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='basic-search-v2.css', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='mobile.css', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='setup-wizard.css', v=static_v) }}">
{{ vite_assets('head')|safe }}
@ -135,13 +136,13 @@
<input type="checkbox" id="new-profile-can-download" checked> Can download music
</label>
</div>
<button id="create-profile-btn" class="profile-create-btn">Create Profile</button>
<button id="create-profile-btn" class="btn btn--block btn--primary profile-create-btn">Create Profile</button>
</div>
<div id="admin-pin-section" class="admin-pin-section" style="display: none;">
<h4>Admin PIN</h4>
<p class="admin-pin-note">Required when multiple profiles exist</p>
<input type="password" id="admin-pin-input" class="profile-input" placeholder="Set admin PIN" maxlength="6">
<button id="set-admin-pin-btn" class="profile-create-btn">Set Admin PIN</button>
<button id="set-admin-pin-btn" class="btn btn--block btn--primary profile-create-btn">Set Admin PIN</button>
</div>
</div>
</div>
@ -224,15 +225,15 @@
<span class="nav-text">Wishlist</span>
<span class="dl-nav-badge hidden" id="wishlist-nav-badge">0</span>
</a>
<a class="nav-button" data-page="automations" href="/automations">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></span>
<span class="nav-text">Automations</span>
</a>
<a class="nav-button" data-page="active-downloads" href="/active-downloads">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>
<span class="nav-text">Downloads</span>
<span class="dl-nav-badge hidden" id="dl-nav-badge">0</span>
</a>
<a class="nav-button" data-page="automations" href="/automations">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></span>
<span class="nav-text">Automations</span>
</a>
<a class="nav-button" data-page="import" href="/import">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/><polyline points="7 9 12 4 17 9"/><line x1="12" y1="4" x2="12" y2="16"/></svg></span>
<span class="nav-text">Import</span>
@ -310,7 +311,7 @@
<!-- Dashboard Page -->
<div class="page" id="dashboard-page">
<div class="dashboard-container">
<div class="page-shell dashboard-container">
<div class="dashboard-header">
<div class="header-text">
<h2 class="header-title"><img src="/static/dashboard.png" class="page-header-icon" alt=""><span>System Dashboard</span></h2>
@ -975,6 +976,7 @@
<!-- Main container for the Sync page -->
<div class="page" id="sync-page">
<div class="page-shell">
<!-- Header -->
<div class="sync-header">
<div class="sync-header-row">
@ -984,9 +986,9 @@
server</p>
</div>
<div style="display:flex;gap:8px;align-items:center;">
<button class="sync-history-btn auto-sync-manager-btn" onclick="openAutoSyncScheduleModal()" title="Schedule mirrored playlists to refresh, discover, sync, and queue missing tracks">Auto-Sync</button>
<button class="sync-history-btn" onclick="openManualLibraryMatchTool()" title="Manually link source tracks to library tracks">Library Match</button>
<button class="sync-history-btn" onclick="openSyncHistoryModal()" title="View sync history">Sync History</button>
<button class="btn btn--sm btn--secondary sync-history-btn auto-sync-manager-btn" onclick="openAutoSyncScheduleModal()" title="Schedule mirrored playlists to refresh, discover, sync, and queue missing tracks">Auto-Sync</button>
<button class="btn btn--sm btn--secondary sync-history-btn" onclick="openManualLibraryMatchTool()" title="Manually link source tracks to library tracks">Library Match</button>
<button class="btn btn--sm btn--secondary sync-history-btn" onclick="openSyncHistoryModal()" title="View sync history">Sync History</button>
</div>
</div>
</div>
@ -2058,6 +2060,7 @@
</div>
</div>
</div>
</div>
</div>
<!-- Search Page -->
@ -2088,103 +2091,71 @@
results are cached per (query, source) pair. -->
<div id="enh-source-row" class="enh-source-row" role="tablist" aria-label="Search source"></div>
<!-- Basic Search Section (Current) -->
<!-- Basic Search Section -->
<div id="basic-search-section" class="search-section">
<!-- Search Bar: Replicates create_elegant_search_bar() -->
<div class="search-bar-container">
<input type="text" id="downloads-search-input"
placeholder="Search for music... (e.g., 'Virtual Mage', 'Queen Bohemian Rhapsody')">
<button id="downloads-cancel-btn" class="hidden">✕ Cancel</button>
<button id="downloads-search-btn">🔍 Search</button>
<!-- Source picker: chip row, one chip per active download source.
Populated by downloads.js:initBasicSearchSources().
Hidden until sources load; in single-source mode shows one
non-interactive chip so the user knows what they're searching. -->
<div class="bs-source-row" id="bs-source-row" aria-label="Search source" role="tablist"></div>
<!-- Search bar -->
<div class="bs-search-bar">
<div class="bs-search-input-wrap">
<svg class="bs-search-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="9" cy="9" r="6"/><path d="M15 15l3 3"/></svg>
<input type="text" id="downloads-search-input"
placeholder="Search artists, albums, tracks…"
autocomplete="off" spellcheck="false">
<button id="downloads-cancel-btn" class="bs-cancel-btn hidden" aria-label="Cancel"></button>
</div>
<button id="downloads-search-btn" class="bs-search-btn">Search</button>
</div>
<div id="filters-container" class="filters-container hidden">
<div class="filter-toggle-header">
<button id="filter-toggle-btn" class="filter-toggle-btn">⏷ Filters</button>
</div>
<div id="filter-content" class="filter-content hidden">
<!-- Filter by Type -->
<div class="filter-group">
<label class="filter-label">Type:</label>
<button class="filter-btn active" data-filter-type="type"
data-value="all">All</button>
<button class="filter-btn" data-filter-type="type"
data-value="album">Albums</button>
<button class="filter-btn" data-filter-type="type"
data-value="track">Singles</button>
</div>
<!-- Filter by Format -->
<div class="filter-group">
<label class="filter-label">Format:</label>
<button class="filter-btn active" data-filter-type="format"
data-value="all">All</button>
<button class="filter-btn" data-filter-type="format"
data-value="flac">FLAC</button>
<button class="filter-btn" data-filter-type="format"
data-value="mp3">MP3</button>
<!-- Added missing format buttons -->
<button class="filter-btn" data-filter-type="format"
data-value="ogg">OGG</button>
<button class="filter-btn" data-filter-type="format"
data-value="aac">AAC</button>
<button class="filter-btn" data-filter-type="format"
data-value="wma">WMA</button>
</div>
<!-- Sort Controls -->
<div class="filter-group">
<label class="filter-label">Sort by:</label>
<button id="sort-order-btn" class="filter-btn sort-order-btn"
data-order="desc">↓</button>
<!-- Added all sort options from the GUI -->
<button class="filter-btn active" data-filter-type="sort"
data-value="relevance">Relevance</button>
<button class="filter-btn" data-filter-type="sort"
data-value="quality_score">Quality</button>
<button class="filter-btn" data-filter-type="sort"
data-value="size">Size</button>
<button class="filter-btn" data-filter-type="sort"
data-value="title">Name</button>
<button class="filter-btn" data-filter-type="sort"
data-value="username">Uploader</button>
<button class="filter-btn" data-filter-type="sort"
data-value="bitrate">Bitrate</button>
<button class="filter-btn" data-filter-type="sort"
data-value="duration">Duration</button>
<button class="filter-btn" data-filter-type="sort"
data-value="availability">Available</button>
<button class="filter-btn" data-filter-type="sort"
data-value="upload_speed">Speed</button>
</div>
</div>
</div>
<!-- Search Status Bar -->
<div class="search-status-container">
<!-- Status / loading bar -->
<div class="bs-status-bar">
<div class="spinner-animation hidden"></div>
<p id="search-status-text">Ready to search • Enter artist, song, or album name</p>
<span id="search-status-text" class="bs-status-text">Enter an artist, album, or track name to search</span>
<div class="dots-animation hidden"></div>
</div>
<!-- Search Results Area: Replicates the QScrollArea -->
<div class="search-results-container">
<div class="search-results-header">
<h3>Search Results</h3>
<!-- Filters: always-visible compact pill row -->
<div id="filters-container" class="bs-filters hidden">
<div class="bs-filter-group">
<span class="bs-filter-label">Type</span>
<button class="filter-btn bs-filter-pill active" data-filter-type="type" data-value="all">All</button>
<button class="filter-btn bs-filter-pill" data-filter-type="type" data-value="album">Albums</button>
<button class="filter-btn bs-filter-pill" data-filter-type="type" data-value="track">Tracks</button>
</div>
<div class="search-results-scroll-area" id="search-results-area">
<!--
The placeholder search results have been removed.
This area will now be populated by JavaScript based on API responses.
-->
<div class="search-results-placeholder">
<p>Your search results will appear here.</p>
</div>
<div class="bs-filter-group">
<span class="bs-filter-label">Format</span>
<button class="filter-btn bs-filter-pill active" data-filter-type="format" data-value="all">All</button>
<button class="filter-btn bs-filter-pill" data-filter-type="format" data-value="flac">FLAC</button>
<button class="filter-btn bs-filter-pill" data-filter-type="format" data-value="mp3">MP3</button>
<button class="filter-btn bs-filter-pill" data-filter-type="format" data-value="ogg">OGG</button>
<button class="filter-btn bs-filter-pill" data-filter-type="format" data-value="aac">AAC</button>
<button class="filter-btn bs-filter-pill" data-filter-type="format" data-value="wma">WMA</button>
</div>
<div class="bs-filter-group">
<span class="bs-filter-label">Sort</span>
<button id="sort-order-btn" class="filter-btn bs-filter-pill sort-order-btn" data-order="desc"></button>
<button class="filter-btn bs-filter-pill active" data-filter-type="sort" data-value="relevance">Relevance</button>
<button class="filter-btn bs-filter-pill" data-filter-type="sort" data-value="quality_score">Quality</button>
<button class="filter-btn bs-filter-pill" data-filter-type="sort" data-value="size">Size</button>
<button class="filter-btn bs-filter-pill" data-filter-type="sort" data-value="title">Name</button>
<button class="filter-btn bs-filter-pill" data-filter-type="sort" data-value="username">Uploader</button>
<button class="filter-btn bs-filter-pill" data-filter-type="sort" data-value="bitrate">Bitrate</button>
<button class="filter-btn bs-filter-pill" data-filter-type="sort" data-value="duration">Duration</button>
</div>
</div>
<!-- Results area -->
<div class="bs-results-wrap" id="search-results-area">
<div class="search-results-placeholder">
<p>Enter a search term to get started.</p>
</div>
</div>
</div>
<!-- End Basic Search Section -->
@ -2311,7 +2282,7 @@
<div class="page" id="automations-page">
<!-- List View -->
<div class="automations-list-view" id="automations-list-view">
<div class="automations-container">
<div class="page-shell automations-container">
<div class="dashboard-header">
<div class="header-text">
<h2 class="header-title"><img src="/static/automation.png" class="page-header-icon" alt=""><span>Automations</span></h2>
@ -2790,8 +2761,8 @@
<!-- Populated dynamically -->
</div>
<div class="enhanced-bulk-modal-footer">
<button class="enhanced-bulk-btn secondary" onclick="closeBulkEditModal()">Cancel</button>
<button class="enhanced-bulk-btn primary" onclick="executeBulkEdit()">Apply Changes</button>
<button class="btn btn--sm btn--secondary enhanced-bulk-btn" onclick="closeBulkEditModal()">Cancel</button>
<button class="btn btn--sm btn--primary enhanced-bulk-btn" onclick="executeBulkEdit()">Apply Changes</button>
</div>
</div>
</div>
@ -2803,10 +2774,10 @@
<span class="enhanced-bulk-bar-label">tracks selected</span>
</div>
<div class="enhanced-bulk-bar-actions">
<button class="enhanced-bulk-btn secondary" onclick="showBulkEditModal()">Edit Selected</button>
<button class="enhanced-bulk-btn tag-write" onclick="batchWriteTagsSelected()">Write Tags</button>
<button class="enhanced-bulk-btn rg-analyze" onclick="batchAnalyzeReplayGainSelected()">ReplayGain</button>
<button class="enhanced-bulk-btn clear" onclick="clearTrackSelection()">Clear Selection</button>
<button class="btn btn--sm btn--secondary enhanced-bulk-btn" onclick="showBulkEditModal()">Edit Selected</button>
<button class="btn btn--sm btn--secondary enhanced-bulk-btn tag-write" onclick="batchWriteTagsSelected()">Write Tags</button>
<button class="btn btn--sm btn--secondary enhanced-bulk-btn rg-analyze" onclick="batchAnalyzeReplayGainSelected()">ReplayGain</button>
<button class="btn btn--sm btn--danger enhanced-bulk-btn" onclick="clearTrackSelection()">Clear Selection</button>
</div>
</div>
@ -2830,8 +2801,8 @@
<span id="tag-preview-sync-text">Sync to server</span>
</label>
<div class="tag-preview-footer-actions">
<button class="enhanced-bulk-btn secondary" onclick="closeTagPreviewModal()">Cancel</button>
<button class="enhanced-bulk-btn primary" id="tag-preview-write-btn" onclick="executeWriteTags()">Write Tags</button>
<button class="btn btn--sm btn--secondary enhanced-bulk-btn" onclick="closeTagPreviewModal()">Cancel</button>
<button class="btn btn--sm btn--primary enhanced-bulk-btn" id="tag-preview-write-btn" onclick="executeWriteTags()">Write Tags</button>
</div>
</div>
</div>
@ -2858,8 +2829,8 @@
<span id="batch-tag-preview-sync-text">Sync to server</span>
</label>
<div class="tag-preview-footer-actions">
<button class="enhanced-bulk-btn secondary" onclick="closeBatchTagPreviewModal()">Cancel</button>
<button class="enhanced-bulk-btn primary" id="batch-tag-preview-write-btn" onclick="executeBatchWriteTags()">Write Tags</button>
<button class="btn btn--sm btn--secondary enhanced-bulk-btn" onclick="closeBatchTagPreviewModal()">Cancel</button>
<button class="btn btn--sm btn--primary enhanced-bulk-btn" id="batch-tag-preview-write-btn" onclick="executeBatchWriteTags()">Write Tags</button>
</div>
</div>
</div>
@ -2876,8 +2847,8 @@
<!-- Populated dynamically -->
</div>
<div class="enhanced-bulk-modal-footer" id="reorganize-modal-footer">
<button class="enhanced-bulk-btn secondary" onclick="closeReorganizeModal()">Cancel</button>
<button class="enhanced-bulk-btn primary" id="reorganize-apply-btn" onclick="executeReorganize()" disabled>Apply</button>
<button class="btn btn--sm btn--secondary enhanced-bulk-btn" onclick="closeReorganizeModal()">Cancel</button>
<button class="btn btn--sm btn--primary enhanced-bulk-btn" id="reorganize-apply-btn" onclick="executeReorganize()" disabled>Apply</button>
</div>
</div>
</div>
@ -3072,13 +3043,13 @@
<p class="discover-section-subtitle" id="your-artists-subtitle">Artists you follow across your music services</p>
</div>
<div class="discover-section-actions">
<button class="ya-header-btn ya-refresh-btn" id="your-artists-refresh-btn" onclick="refreshYourArtists()" title="Refresh from services">
<button class="btn btn--sm btn--secondary ya-header-btn ya-refresh-btn" id="your-artists-refresh-btn" onclick="refreshYourArtists()" title="Refresh from services">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
</button>
<button class="ya-header-btn ya-settings-btn" onclick="openYourArtistsSourcesModal()" title="Configure sources">
<button class="btn btn--sm btn--secondary ya-header-btn ya-settings-btn" onclick="openYourArtistsSourcesModal()" title="Configure sources">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
<button class="ya-header-btn ya-viewall-btn" onclick="openYourArtistsModal()">
<button class="btn btn--sm btn--secondary ya-header-btn ya-viewall-btn" onclick="openYourArtistsModal()">
<span>View All</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
</button>
@ -3097,13 +3068,13 @@
<p class="discover-section-subtitle" id="your-albums-subtitle">Albums you've saved across your music services</p>
</div>
<div class="discover-section-actions">
<button class="ya-header-btn ya-refresh-btn" id="your-albums-refresh-btn" onclick="refreshYourAlbums()" title="Refresh from services">
<button class="btn btn--sm btn--secondary ya-header-btn ya-refresh-btn" id="your-albums-refresh-btn" onclick="refreshYourAlbums()" title="Refresh from services">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
</button>
<button class="ya-header-btn ya-settings-btn" onclick="openYourAlbumsSourcesModal()" title="Configure sources">
<button class="btn btn--sm btn--secondary ya-header-btn ya-settings-btn" onclick="openYourAlbumsSourcesModal()" title="Configure sources">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
<button class="ya-header-btn" id="your-albums-download-btn" onclick="downloadMissingYourAlbums()" style="display:none;" title="Download missing albums">
<button class="btn btn--sm btn--secondary ya-header-btn" id="your-albums-download-btn" onclick="downloadMissingYourAlbums()" style="display:none;" title="Download missing albums">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
</button>
</div>
@ -3663,7 +3634,7 @@
<!-- Playlist Explorer Page -->
<div class="page" id="playlist-explorer-page">
<div class="explorer-container">
<div class="page-shell explorer-container">
<!-- Header (compact) -->
<div class="dashboard-header" style="margin-bottom: 12px;">
<div class="header-text">
@ -3708,15 +3679,15 @@
<span class="explorer-selection-count" id="explorer-selection-count">0 albums selected</span>
</div>
<div class="explorer-action-buttons">
<button class="explorer-action-btn" onclick="explorerSelectAll()">
<button class="btn btn--sm btn--secondary" onclick="explorerSelectAll()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
Select All
</button>
<button class="explorer-action-btn" onclick="explorerDeselectAll()">
<button class="btn btn--sm btn--secondary" onclick="explorerDeselectAll()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>
Deselect
</button>
<button class="explorer-action-btn primary" onclick="explorerAddToWishlist()">
<button class="btn btn--sm btn--primary" onclick="explorerAddToWishlist()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
Add to Wishlist
</button>
@ -3766,6 +3737,7 @@
<!-- Settings Page -->
<div class="page" id="settings-page">
<div class="page-shell">
<div class="dashboard-header">
<div class="header-text">
<h2 class="header-title"><img src="/static/settings.png" class="page-header-icon" alt=""><span>Settings</span></h2>
@ -6231,6 +6203,7 @@
<button class="save-button" id="save-settings">Save Settings</button>
</div>
</div>
</div>
</div>
<!-- Hydrabase Page (Dev Mode Only) -->
@ -6353,7 +6326,7 @@
TOOLS PAGE
═══════════════════════════════════════════════════════════════════ -->
<div class="page" id="tools-page">
<div class="tools-page-container">
<div class="page-shell tools-page-container">
<div class="tools-page-header">
<div class="tools-page-header-left">
<h2 class="tools-page-title">
@ -6422,9 +6395,9 @@
</label>
<div class="repair-findings-bulk" id="repair-findings-bulk" style="display:none;">
<span class="repair-bulk-count" id="repair-bulk-count"></span>
<button class="repair-bulk-btn fix" onclick="bulkFixFindings()">Fix Selected</button>
<button class="repair-bulk-btn" onclick="bulkRepairAction('dismiss')">Dismiss Selected</button>
<button class="repair-bulk-btn fix-all" id="repair-fix-all-btn" style="display:none;" onclick="fixAllMatchingFindings()">Fix All</button>
<button class="btn btn--sm btn--primary" onclick="bulkFixFindings()">Fix Selected</button>
<button class="btn btn--sm btn--secondary" onclick="bulkRepairAction('dismiss')">Dismiss Selected</button>
<button class="btn btn--sm btn--warning" id="repair-fix-all-btn" style="display:none;" onclick="fixAllMatchingFindings()">Fix All</button>
</div>
<button class="repair-clear-btn" onclick="clearRepairFindings()" title="Clear findings matching current filters">Clear Findings</button>
</div>
@ -6474,6 +6447,7 @@
<select id="db-refresh-type">
<option value="incremental">Incremental Update</option>
<option value="full">Full Refresh</option>
<option value="deep">Deep Scan</option>
</select>
<button id="db-update-button">Update Database</button>
</div>
@ -6810,7 +6784,7 @@
WATCHLIST PAGE
═══════════════════════════════════════════════════════════════════ -->
<div class="page" id="watchlist-page">
<div class="watchlist-page-container">
<div class="page-shell watchlist-page-container">
<!-- Header -->
<div class="watchlist-page-header">
<div class="watchlist-page-header-left">
@ -6848,19 +6822,19 @@
<!-- Action buttons -->
<div class="watchlist-page-actions">
<button class="watchlist-action-btn watchlist-action-primary" id="scan-watchlist-btn" onclick="startWatchlistScan()">
<button class="btn btn--primary" id="scan-watchlist-btn" onclick="startWatchlistScan()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Scan for New Releases
</button>
<button class="watchlist-action-btn watchlist-action-secondary" id="cancel-watchlist-scan-btn" onclick="cancelWatchlistScan()" style="display: none;">
<button class="btn btn--secondary" id="cancel-watchlist-scan-btn" onclick="cancelWatchlistScan()" style="display: none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
Cancel Scan
</button>
<button class="watchlist-action-btn watchlist-action-secondary" id="update-similar-artists-btn" onclick="updateSimilarArtists()">
<button class="btn btn--secondary" id="update-similar-artists-btn" onclick="updateSimilarArtists()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Update Similar Artists
</button>
<button class="watchlist-action-btn watchlist-action-secondary" id="watchlist-page-settings-btn" onclick="openWatchlistGlobalSettingsModal()">
<button class="btn btn--secondary" id="watchlist-page-settings-btn" onclick="openWatchlistGlobalSettingsModal()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Global Settings
</button>
@ -6900,7 +6874,7 @@
<span>Select All</span>
</label>
<span class="watchlist-batch-count" id="watchlist-batch-count"></span>
<button class="watchlist-action-btn watchlist-action-secondary watchlist-batch-remove-btn" id="watchlist-batch-remove-btn" onclick="batchRemoveFromWatchlist()" style="display: none;">
<button class="btn btn--secondary watchlist-batch-remove-btn" id="watchlist-batch-remove-btn" onclick="batchRemoveFromWatchlist()" style="display: none;">
Remove Selected
</button>
</div>
@ -6917,7 +6891,7 @@
</div>
<h3>Your watchlist is empty</h3>
<p>Use Search to find an artist, then add them to your watchlist from the artist page.</p>
<button class="watchlist-action-btn watchlist-action-primary" onclick="navigateToPage('search')">Open Search</button>
<button class="btn btn--primary" onclick="navigateToPage('search')">Open Search</button>
</div>
</div>
</div>
@ -6926,7 +6900,7 @@
WISHLIST PAGE
═══════════════════════════════════════════════════════════════════ -->
<div class="page" id="wishlist-page">
<div class="wishlist-page-container">
<div class="page-shell wishlist-page-container">
<!-- Header -->
<div class="wishlist-page-header">
<div class="wishlist-page-header-left">
@ -6940,11 +6914,11 @@
</div>
</div>
<div class="wishlist-page-header-right">
<button class="watchlist-action-btn watchlist-action-secondary" onclick="cleanupWishlistOverview()">
<button class="btn btn--secondary" onclick="cleanupWishlistOverview()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/></svg>
Cleanup
</button>
<button class="watchlist-action-btn watchlist-action-danger" onclick="clearEntireWishlist()">
<button class="btn btn--danger" onclick="clearEntireWishlist()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>
Clear All
</button>
@ -6974,7 +6948,7 @@
<!-- Action bar -->
<div class="wl-nebula-bar">
<input type="text" class="wl-nebula-search" id="wl-nebula-search" placeholder="Search artists, albums..." oninput="_filterNebula()">
<button class="watchlist-action-btn watchlist-action-primary" onclick="_nebulaDownload()">
<button class="btn btn--primary" onclick="_nebulaDownload()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download Wishlist
</button>
@ -6992,14 +6966,14 @@
<span id="wishlist-category-name" class="wishlist-category-name"></span>
<div class="wishlist-category-header-right">
<button class="wishlist-select-all-btn" id="wishlist-select-all-btn" onclick="toggleWishlistSelectAll()">Select All</button>
<button id="wishlist-download-btn" class="watchlist-action-btn watchlist-action-primary" style="display: none;" onclick="downloadSelectedCategory()">
<button id="wishlist-download-btn" class="btn btn--primary" style="display: none;" onclick="downloadSelectedCategory()">
Download Selection
</button>
</div>
</div>
<div class="wishlist-batch-bar" id="wishlist-batch-bar" style="display: none;">
<span class="wishlist-batch-count" id="wishlist-batch-count">0 selected</span>
<button class="watchlist-action-btn watchlist-action-secondary wishlist-batch-remove-btn" onclick="batchRemoveFromWishlist()">
<button class="btn btn--secondary wishlist-batch-remove-btn" onclick="batchRemoveFromWishlist()">
Remove Selected
</button>
</div>
@ -7319,15 +7293,15 @@
<div class="add-to-wishlist-modal-footer">
<div class="wishlist-modal-actions">
<button class="wishlist-modal-btn wishlist-modal-btn-secondary"
<button class="btn btn--secondary"
onclick="closeAddToWishlistModal()">
Close
</button>
<button class="wishlist-modal-btn wishlist-modal-btn-download" id="wishlist-download-now-btn"
<button class="btn btn--download" id="wishlist-download-now-btn"
onclick="handleWishlistDownloadNow()">
Download Now
</button>
<button class="wishlist-modal-btn wishlist-modal-btn-primary" id="confirm-add-to-wishlist-btn">
<button class="btn btn--primary" id="confirm-add-to-wishlist-btn">
Add to Wishlist
</button>
</div>
@ -7496,11 +7470,11 @@
<div class="watchlist-artist-config-footer">
<div class="config-modal-actions">
<button class="config-modal-btn config-modal-btn-secondary"
<button class="btn btn--secondary"
onclick="closeWatchlistArtistConfigModal()">
Cancel
</button>
<button class="config-modal-btn config-modal-btn-primary" id="save-artist-config-btn">
<button class="btn btn--primary" id="save-artist-config-btn">
Save Preferences
</button>
</div>
@ -7680,11 +7654,11 @@
<div class="watchlist-artist-config-footer">
<div class="config-modal-actions">
<button class="config-modal-btn config-modal-btn-secondary"
<button class="btn btn--secondary"
onclick="closeWatchlistGlobalSettingsModal()">
Cancel
</button>
<button class="config-modal-btn config-modal-btn-primary" id="save-global-config-btn"
<button class="btn btn--primary" id="save-global-config-btn"
onclick="saveWatchlistGlobalConfig()">
Save Global Settings
</button>

View file

@ -0,0 +1,694 @@
/* =====================================================================
* Basic Search visual redesign (functional behaviour untouched)
*
* Self-contained sheet appended via index.html link. Targets the new
* markup (``bs-*`` prefixed classes). Existing
* ``.search-bar-container`` / ``.filter-btn`` / ``.search-results-container``
* rules earlier in style.css still apply elsewhere (other search UIs reuse
* them); they no longer match the new basic-search markup so this redesign
* is fully scoped.
* ===================================================================== */
/* Source chip row above the search bar. One chip per active hybrid
* source; in single-source mode the only chip is rendered with the
* ``.single`` modifier so it reads as a label rather than a control. */
.bs-source-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
align-items: center;
min-height: 32px;
}
.bs-source-row:empty {
display: none;
}
.bs-source-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background:
linear-gradient(160deg,
rgba(255, 255, 255, 0.035) 0%,
rgba(0, 0, 0, 0.2) 100%);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 999px;
color: rgba(255, 255, 255, 0.55);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.bs-source-chip:hover:not(.active):not(.single):not(:disabled) {
border-color: rgba(var(--accent-rgb), 0.35);
background:
linear-gradient(160deg,
rgba(var(--accent-rgb), 0.08) 0%,
rgba(0, 0, 0, 0.2) 100%);
color: #fff;
transform: translateY(-1px);
}
.bs-source-chip.active {
background:
linear-gradient(135deg,
color-mix(in srgb, rgb(var(--accent-rgb)) 95%, white 5%),
color-mix(in srgb, rgb(var(--accent-rgb)) 78%, black 22%));
border-color: rgba(var(--accent-rgb), 0.55);
color: #fff;
box-shadow:
0 4px 14px color-mix(in srgb, rgb(var(--accent-rgb)) 25%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
.bs-source-chip.single {
cursor: default;
border-style: dashed;
border-color: rgba(var(--accent-rgb), 0.35);
background: transparent;
color: rgb(var(--accent-light-rgb));
}
/* — Search bar: glass card matching the dashboard / auto-sync vibe. */
.bs-search-bar {
display: flex;
gap: 12px;
align-items: center;
padding: 10px 12px;
background:
radial-gradient(ellipse at 0% 0%,
color-mix(in srgb, rgb(var(--accent-rgb)) 6%, transparent) 0%,
transparent 60%),
linear-gradient(160deg,
rgba(22, 25, 36, 0.7) 0%,
rgba(14, 16, 24, 0.85) 100%);
border: 1px solid rgba(var(--accent-rgb), 0.22);
border-radius: 16px;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
0 6px 20px rgba(0, 0, 0, 0.25);
margin-bottom: 12px;
}
.bs-search-input-wrap {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
padding: 0 14px;
background: rgba(0, 0, 0, 0.32);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
height: 42px;
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
}
.bs-search-input-wrap:focus-within {
border-color: rgba(var(--accent-rgb), 0.45);
background: rgba(0, 0, 0, 0.4);
box-shadow: 0 0 0 3px color-mix(in srgb, rgb(var(--accent-rgb)) 12%, transparent);
}
.bs-search-icon {
width: 16px;
height: 16px;
color: rgba(255, 255, 255, 0.4);
flex-shrink: 0;
}
.bs-search-input-wrap:focus-within .bs-search-icon {
color: rgb(var(--accent-light-rgb));
}
#basic-search-section #downloads-search-input {
flex: 1;
border: none;
background: transparent;
color: #fff;
font-size: 14px;
font-weight: 500;
outline: none;
padding: 0;
height: 100%;
}
#basic-search-section #downloads-search-input::placeholder {
color: rgba(255, 255, 255, 0.32);
}
.bs-cancel-btn {
width: 24px;
height: 24px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.5);
border-radius: 50%;
cursor: pointer;
font-size: 13px;
line-height: 1;
transition: all 0.2s;
flex-shrink: 0;
padding: 0;
}
.bs-cancel-btn:hover {
background: rgba(239, 68, 68, 0.18);
border-color: rgba(239, 68, 68, 0.45);
color: #fff;
}
.bs-search-btn {
padding: 0 22px;
height: 42px;
border: 1px solid rgba(var(--accent-rgb), 0.55);
border-radius: 12px;
background:
linear-gradient(135deg,
color-mix(in srgb, rgb(var(--accent-rgb)) 95%, white 5%),
color-mix(in srgb, rgb(var(--accent-rgb)) 78%, black 22%));
color: #fff;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow:
0 4px 14px color-mix(in srgb, rgb(var(--accent-rgb)) 28%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
.bs-search-btn:hover {
transform: translateY(-1px);
box-shadow:
0 6px 18px color-mix(in srgb, rgb(var(--accent-rgb)) 40%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.25);
}
.bs-search-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* — Status bar: thin accent-tinted pill matching the dashboard vibe. */
.bs-status-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
background:
linear-gradient(180deg,
color-mix(in srgb, rgb(var(--accent-rgb)) 4%, transparent),
transparent);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 10px;
margin-bottom: 12px;
}
.bs-status-text {
color: rgba(255, 255, 255, 0.55);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
margin: 0;
flex: 1;
}
/* — Filters: compact pill row, always visible after first search. */
.bs-filters {
display: flex;
flex-wrap: wrap;
gap: 16px 24px;
padding: 12px 14px;
background:
linear-gradient(160deg,
rgba(255, 255, 255, 0.025) 0%,
rgba(0, 0, 0, 0.15) 100%);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px;
margin-bottom: 12px;
}
.bs-filter-group {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.bs-filter-label {
color: rgba(255, 255, 255, 0.4);
font-size: 9px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
margin-right: 4px;
}
.bs-filter-pill {
padding: 4px 12px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 99px;
color: rgba(255, 255, 255, 0.55);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
cursor: pointer;
transition: all 0.18s ease;
}
.bs-filter-pill:hover:not(.active) {
border-color: rgba(var(--accent-rgb), 0.35);
color: #fff;
}
.bs-filter-pill.active {
background:
linear-gradient(135deg,
color-mix(in srgb, rgb(var(--accent-rgb)) 80%, white 20%),
rgb(var(--accent-rgb)));
border-color: rgba(var(--accent-rgb), 0.55);
color: #fff;
box-shadow:
0 2px 8px color-mix(in srgb, rgb(var(--accent-rgb)) 25%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
/* — Results area: glass surface for cards. */
.bs-results-wrap {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 4px 2px;
display: flex;
flex-direction: column;
gap: 10px;
}
.bs-results-wrap .search-results-placeholder {
text-align: center;
padding: 60px 30px;
color: rgba(255, 255, 255, 0.35);
font-size: 13px;
}
/* Album result card: glass + accent left-edge stripe + cover icon.
* ``!important`` is used liberally below because the original
* ``.album-result-card`` / ``.album-icon`` / etc. rules in style.css
* (~7247 onwards) are unscoped and apply heavyweight styles (24px
* padding, 56px icon, 24px border-radius, big box-shadows) that
* collide with this redesign. Scoping with ``#basic-search-section``
* wins on specificity for some properties but the original
* ``box-shadow`` and ``padding`` rules need explicit override to
* defeat the cascade interaction with hover / pseudo states. */
#basic-search-section .album-result-card {
background:
linear-gradient(160deg,
rgba(255, 255, 255, 0.04) 0%,
rgba(0, 0, 0, 0.2) 100%) !important;
border: 1px solid rgba(255, 255, 255, 0.06) !important;
border-radius: 14px !important;
position: relative;
transition: border-color 0.2s, transform 0.2s !important;
margin: 0 !important;
padding: 0 !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03),
0 2px 10px rgba(0, 0, 0, 0.2) !important;
transform: none !important;
}
#basic-search-section .album-result-card::before {
content: '';
position: absolute;
top: 14px;
bottom: 14px;
left: 0;
width: 3px;
background: linear-gradient(180deg,
rgb(var(--accent-light-rgb)),
rgb(var(--accent-rgb)));
border-radius: 0 3px 3px 0;
box-shadow: 0 0 12px color-mix(in srgb, rgb(var(--accent-rgb)) 50%, transparent);
z-index: 2;
}
#basic-search-section .album-result-card:hover {
border-color: rgba(var(--accent-rgb), 0.35) !important;
background:
linear-gradient(160deg,
rgba(var(--accent-rgb), 0.06) 0%,
rgba(0, 0, 0, 0.2) 100%) !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.05),
0 6px 20px rgba(0, 0, 0, 0.3) !important;
transform: translateY(-1px) !important;
}
#basic-search-section .album-card-header {
display: flex !important;
align-items: center !important;
gap: 16px !important;
padding: 18px 22px !important;
cursor: pointer;
}
#basic-search-section .album-expand-indicator {
color: rgba(255, 255, 255, 0.4);
font-size: 12px;
transition: transform 0.25s ease;
flex-shrink: 0;
width: 16px;
text-align: center;
}
#basic-search-section .album-result-card.expanded .album-expand-indicator {
transform: rotate(90deg);
}
#basic-search-section .album-icon {
width: 52px !important;
height: 52px !important;
min-width: 52px;
border-radius: 10px !important;
background:
linear-gradient(135deg,
color-mix(in srgb, rgb(var(--accent-rgb)) 35%, transparent),
rgba(0, 0, 0, 0.4)) !important;
border: 1px solid rgba(var(--accent-rgb), 0.3) !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
font-size: 26px !important;
flex-shrink: 0;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08) !important;
color: rgba(var(--accent-rgb), 1.0);
}
#basic-search-section .album-info {
flex: 1 1 auto !important;
min-width: 0;
display: block;
}
#basic-search-section .album-title {
color: rgba(255, 255, 255, 0.95) !important;
font-size: 15px !important;
font-weight: 700 !important;
letter-spacing: -0.005em;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0 0 4px 0 !important;
}
#basic-search-section .album-artist {
color: rgba(255, 255, 255, 0.65) !important;
font-size: 13px !important;
font-weight: 500;
line-height: 1.3;
margin: 0 0 6px 0 !important;
}
#basic-search-section .album-details {
color: rgb(var(--accent-light-rgb)) !important;
font-size: 11px !important;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
line-height: 1.3;
margin: 0 0 3px 0 !important;
}
#basic-search-section .album-uploader {
color: rgba(255, 255, 255, 0.4) !important;
font-size: 11px !important;
line-height: 1.3;
margin: 0 !important;
}
#basic-search-section .album-actions {
display: flex !important;
flex-direction: column !important;
gap: 6px !important;
flex-shrink: 0;
}
/* — Track result card: glass row, similar height to album cards. */
#basic-search-section .track-result-card {
display: flex !important;
align-items: center !important;
gap: 16px !important;
padding: 16px 22px !important;
background:
linear-gradient(160deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(0, 0, 0, 0.18) 100%) !important;
border: 1px solid rgba(255, 255, 255, 0.05) !important;
border-radius: 14px !important;
position: relative;
transition: border-color 0.2s, transform 0.2s, background 0.2s !important;
margin: 0 !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03),
0 2px 10px rgba(0, 0, 0, 0.2) !important;
transform: none !important;
}
#basic-search-section .track-result-card::before {
content: '';
position: absolute;
top: 14px;
bottom: 14px;
left: 0;
width: 2px;
background: linear-gradient(180deg,
rgba(var(--accent-rgb), 0.7),
rgba(var(--accent-rgb), 0.3));
border-radius: 0 2px 2px 0;
z-index: 2;
}
#basic-search-section .track-result-card:hover {
border-color: rgba(var(--accent-rgb), 0.3) !important;
background:
linear-gradient(160deg,
rgba(var(--accent-rgb), 0.06) 0%,
rgba(0, 0, 0, 0.18) 100%) !important;
transform: translateY(-1px) !important;
}
#basic-search-section .track-icon {
width: 44px !important;
height: 44px !important;
min-width: 44px;
border-radius: 10px !important;
background: rgba(255, 255, 255, 0.04) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
font-size: 22px !important;
flex-shrink: 0;
}
#basic-search-section .track-info {
flex: 1 1 auto !important;
min-width: 0;
}
#basic-search-section .track-title {
color: rgba(255, 255, 255, 0.95) !important;
font-size: 14px !important;
font-weight: 600 !important;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0 0 3px 0 !important;
}
#basic-search-section .track-artist {
color: rgba(255, 255, 255, 0.6) !important;
font-size: 12px !important;
line-height: 1.3;
margin: 0 0 5px 0 !important;
}
#basic-search-section .track-details {
color: rgba(255, 255, 255, 0.5) !important;
font-size: 11px !important;
line-height: 1.3;
margin: 0 0 3px 0 !important;
}
#basic-search-section .track-uploader {
color: rgba(255, 255, 255, 0.38) !important;
font-size: 11px !important;
line-height: 1.3;
margin: 0 !important;
}
#basic-search-section .track-actions {
display: flex !important;
gap: 6px !important;
flex-shrink: 0;
}
/* — Action buttons inside album / track cards: pill primary + ghost. */
#basic-search-section .album-download-btn,
#basic-search-section .album-matched-btn,
#basic-search-section .track-download-btn,
#basic-search-section .track-matched-btn,
#basic-search-section .track-stream-btn {
padding: 6px 12px;
border-radius: 99px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
cursor: pointer;
transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
}
#basic-search-section .album-download-btn,
#basic-search-section .track-download-btn {
background:
linear-gradient(135deg,
color-mix(in srgb, rgb(var(--accent-rgb)) 92%, white 8%),
rgb(var(--accent-rgb)));
border: 1px solid rgba(var(--accent-rgb), 0.5);
color: #fff;
box-shadow: 0 2px 8px color-mix(in srgb, rgb(var(--accent-rgb)) 22%, transparent);
}
#basic-search-section .album-download-btn:hover,
#basic-search-section .track-download-btn:hover {
transform: translateY(-1px);
box-shadow: 0 5px 14px color-mix(in srgb, rgb(var(--accent-rgb)) 35%, transparent);
}
#basic-search-section .album-matched-btn,
#basic-search-section .track-matched-btn {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(var(--accent-rgb), 0.3);
color: rgb(var(--accent-light-rgb));
}
#basic-search-section .album-matched-btn:hover,
#basic-search-section .track-matched-btn:hover {
background: color-mix(in srgb, rgb(var(--accent-rgb)) 12%, transparent);
border-color: rgba(var(--accent-rgb), 0.5);
color: #fff;
}
#basic-search-section .track-stream-btn {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.65);
}
#basic-search-section .track-stream-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.18);
color: #fff;
}
/* — Expanded album track list */
#basic-search-section .album-track-list {
background: rgba(0, 0, 0, 0.18);
border-top: 1px solid rgba(255, 255, 255, 0.04);
padding: 4px 0;
}
#basic-search-section .track-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 18px 10px 50px;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
transition: background 0.15s;
}
#basic-search-section .track-item:last-child {
border-bottom: none;
}
#basic-search-section .track-item:hover {
background: rgba(255, 255, 255, 0.02);
}
#basic-search-section .track-item-info {
flex: 1;
min-width: 0;
}
#basic-search-section .track-item-title {
color: rgba(255, 255, 255, 0.88);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#basic-search-section .track-item-details {
color: rgba(255, 255, 255, 0.4);
font-size: 10px;
margin-top: 2px;
}
#basic-search-section .track-item-actions {
display: flex;
gap: 5px;
flex-shrink: 0;
}
#basic-search-section .track-item-actions .track-stream-btn,
#basic-search-section .track-item-actions .track-download-btn,
#basic-search-section .track-item-actions .track-matched-btn {
padding: 4px 10px;
font-size: 9px;
}
#basic-search-section .disc-separator {
padding: 8px 18px 6px 50px !important;
font-weight: 700 !important;
font-size: 10px !important;
color: rgb(var(--accent-light-rgb)) !important;
border-bottom: 1px solid rgba(var(--accent-rgb), 0.2) !important;
letter-spacing: 0.12em;
text-transform: uppercase;
background: none !important;
margin: 0 !important;
}
/* — Responsive: collapse action button row on narrow viewports. */
@media (max-width: 900px) {
#basic-search-section .album-actions,
#basic-search-section .track-actions {
flex-direction: column;
}
.bs-filters {
gap: 10px 16px;
}
}

View file

@ -4654,15 +4654,15 @@ async function openYourArtistInfoModal(poolId) {
// Footer
if (footerEl) {
const watchBtn = pool.on_watchlist
? `<button class="ya-header-btn" onclick="toggleYourArtistWatchlist(${pool.id}, '${escapeForInlineJs(artistName)}', '${escapeForInlineJs(artistId)}', '${escapeForInlineJs(pool.active_source || '')}', this); this.textContent='Done'; this.disabled=true">Remove from Watchlist</button>`
: `<button class="ya-header-btn" onclick="toggleYourArtistWatchlist(${pool.id}, '${escapeForInlineJs(artistName)}', '${escapeForInlineJs(artistId)}', '${escapeForInlineJs(pool.active_source || '')}', this); this.textContent='Added!'; this.disabled=true">Add to Watchlist</button>`;
? `<button class="btn btn--sm btn--secondary ya-header-btn" onclick="toggleYourArtistWatchlist(${pool.id}, '${escapeForInlineJs(artistName)}', '${escapeForInlineJs(artistId)}', '${escapeForInlineJs(pool.active_source || '')}', this); this.textContent='Done'; this.disabled=true">Remove from Watchlist</button>`
: `<button class="btn btn--sm btn--secondary ya-header-btn" onclick="toggleYourArtistWatchlist(${pool.id}, '${escapeForInlineJs(artistName)}', '${escapeForInlineJs(artistId)}', '${escapeForInlineJs(pool.active_source || '')}', this); this.textContent='Added!'; this.disabled=true">Add to Watchlist</button>`;
footerEl.innerHTML = `
${watchBtn}
<button class="ya-header-btn" onclick="document.getElementById('ya-info-modal-overlay')?.remove(); document.getElementById('your-artists-modal-overlay')?.remove(); openArtistMapExplorerDirect('${escapeForInlineJs(artistName)}')">
<button class="btn btn--sm btn--secondary ya-header-btn" onclick="document.getElementById('ya-info-modal-overlay')?.remove(); document.getElementById('your-artists-modal-overlay')?.remove(); openArtistMapExplorerDirect('${escapeForInlineJs(artistName)}')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<span>Explore</span>
</button>
<a class="ya-header-btn ya-viewall-btn" href="${buildArtistDetailPath(artistId, pool.active_source || null)}" onclick="document.getElementById('ya-info-modal-overlay')?.remove(); document.getElementById('your-artists-modal-overlay')?.remove();" style="text-decoration:none;color:inherit;">
<a class="btn btn--sm btn--secondary ya-header-btn ya-viewall-btn" href="${buildArtistDetailPath(artistId, pool.active_source || null)}" onclick="document.getElementById('ya-info-modal-overlay')?.remove(); document.getElementById('your-artists-modal-overlay')?.remove();" style="text-decoration:none;color:inherit;">
<span>View Discography</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
</a>
@ -6436,8 +6436,8 @@ function _showArtistMapSearchPrompt() {
</div>
<input type="text" id="artmap-explore-input" class="artmap-explore-input" placeholder="Artist name..." autofocus>
<div class="artmap-search-prompt-actions">
<button class="ya-header-btn" onclick="document.getElementById('artmap-search-prompt').remove()">Cancel</button>
<button class="ya-header-btn ya-viewall-btn" id="artmap-explore-go">
<button class="btn btn--sm btn--secondary ya-header-btn" onclick="document.getElementById('artmap-search-prompt').remove()">Cancel</button>
<button class="btn btn--sm btn--secondary ya-header-btn ya-viewall-btn" id="artmap-explore-go">
<span>Explore</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
</button>

View file

@ -4772,7 +4772,69 @@ function updateModalSyncProgress(playlistId, progress) {
}
// Raw Soulseek file search (used by the 'Soulseek (raw files)' source picker option).
// ── Basic-search source picker ──────────────────────────────────────────────
// Tracks which download source the user has selected in the chip row. Null
// means "use the orchestrator's default" (same as pre-redesign behaviour).
let _bsActiveSource = null;
async function initBasicSearchSources() {
const row = document.getElementById('bs-source-row');
if (!row) return;
try {
const resp = await fetch('/api/search/sources');
if (!resp.ok) return;
const { mode, sources } = await resp.json();
if (!sources || !sources.length) return;
row.innerHTML = '';
const isSingle = mode !== 'hybrid' || sources.length < 2;
sources.forEach((src, i) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'bs-source-chip' + (i === 0 ? ' active' : '');
btn.dataset.source = src.name;
btn.setAttribute('role', 'tab');
btn.setAttribute('aria-selected', i === 0 ? 'true' : 'false');
btn.setAttribute('title', src.display_name);
btn.innerHTML = `<span class="bs-source-label">${_escBsHtml(src.display_name)}</span>`;
if (isSingle) {
// Non-interactive: single source, just a label.
btn.classList.add('single');
btn.disabled = true;
} else {
btn.addEventListener('click', () => {
row.querySelectorAll('.bs-source-chip').forEach(c => {
c.classList.remove('active');
c.setAttribute('aria-selected', 'false');
});
btn.classList.add('active');
btn.setAttribute('aria-selected', 'true');
_bsActiveSource = src.name;
// Re-run last search with new source if results already showing.
const query = document.getElementById('downloads-search-input')?.value?.trim();
if (query && window.currentSearchResults?.length) {
performDownloadsSearch();
}
});
}
row.appendChild(btn);
});
// Default active source = first in chain.
_bsActiveSource = isSingle ? null : sources[0]?.name ?? null;
} catch (_err) {
// Non-fatal — search still works without the picker.
}
}
function _escBsHtml(str) {
return String(str || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
// Raw download-source file search (basic search tab).
async function performDownloadsSearch() {
const query = document.getElementById('downloads-search-input').value.trim();
if (!query) {
@ -4802,10 +4864,15 @@ async function performDownloadsSearch() {
displayDownloadsResults([]); // Clear previous results
// --- 2. Perform the Fetch Request ---
// Source param routes the search to a specific download source in
// hybrid mode. Omitted in single-source mode (backend falls through
// to orchestrator.search() which targets the configured source).
const body = { query };
if (_bsActiveSource) body.source = _bsActiveSource;
const response = await fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
body: JSON.stringify(body),
signal: searchAbortController.signal // Link fetch to the AbortController
});
@ -4825,7 +4892,8 @@ async function performDownloadsSearch() {
statusText.textContent = `No results found for '${query}'`;
showToast('No results found', 'error');
} else {
document.getElementById('filters-container').classList.remove('hidden');
const filtersEl = document.getElementById('filters-container');
if (filtersEl) filtersEl.classList.remove('hidden');
// Count albums and singles like the GUI app
let totalAlbums = 0;
@ -5641,6 +5709,11 @@ let _gsController = null;
const freshResults = document.getElementById('gsearch-results');
const target = e.target;
if (freshBar?.contains(target) || freshResults?.contains(target)) return;
// The media player (mini bar + expanded now-playing modal)
// floats above the page. Clicking it — e.g. opening the full
// modal from the mini player, or anything inside that modal —
// must NOT tear down the global search results (#732).
if (target.closest && target.closest('#media-player, #np-modal-overlay')) return;
_gsDeactivate();
}, 100);
});
@ -5658,6 +5731,15 @@ let _gsController = null;
else { _doInit(); setTimeout(_gsUpdateVisibility, 500); }
})();
// Init basic-search source chip row once the DOM is ready.
(function _bsInit() {
const run = () => {
if (typeof initBasicSearchSources === 'function') initBasicSearchSources();
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run);
else run();
})();
function _gsUpdateVisibility() {
const bar = document.getElementById('gsearch-bar');
const aura = document.getElementById('gsearch-aura');

View file

@ -3413,6 +3413,15 @@ function closeHelperSearch() {
// projects that span multiple commits before shipping. Strip the flag at
// release time and add a real `date:` line at the top of the version block.
const WHATS_NEW = {
'2.6.5': [
{ unreleased: true },
{ title: 'Basic search: visual overhaul + per-source picker in hybrid mode', desc: 'the basic search tab on the Search page was carrying its original visual treatment from before the rest of the app moved toward the glassy / accent-radial aesthetic, and it always targeted the first source in the hybrid chain with no way to pick a different one. Two things in this commit: (1) full visual redesign — glass search-bar card with accent radial wash + focus ring, pill primary search button, always-visible compact filter pill row (Type / Format / Sort), accent-tinted status pill, album result cards with accent left-edge stripe + cover icon + chevron expand + pill action buttons, track result cards as slim glass rows, multi-disc separators in album track lists, responsive button-stack on narrow viewports. Self-contained sheet at ``webui/static/basic-search-v2.css`` so revert is just dropping the link tag. (2) source picker — small chip row above the search bar lists every active source in the hybrid chain. Click a chip to target that specific source for the next search. In single-source mode the chip is rendered as a dashed-border label so the user always knows what they\'re searching. New ``GET /api/search/sources`` endpoint returns the active source list; ``/api/search`` accepts an optional ``source`` body param to target that specific source via its client. Backwards compatible — omitting ``source`` uses the orchestrator default exactly as before. Pinned with 5 new unit tests (source routing, fallback on unknown source, no-source default, alias preservation, album serialisation through the source-targeted path) on top of the existing 6 tests.', page: 'search' },
{ title: 'Fix: duplicate tracks in albums with Japanese / CJK titles (#722)', desc: 'Japanese OST downloads via Apple Music + Tidal produced duplicate library entries — the same audio file landed under multiple track positions in the album. Root cause: ``MusicMatchingEngine.normalize_string`` correctly skipped unidecode for CJK text (kanji→pinyin would have been gibberish) but then ran ``re.sub(r"[^a-z0-9\\s$]", "", text)`` which stripped EVERY CJK character. Every Japanese title normalised to ``""`` and ``similarity_score`` short-circuited to 0.000 on the empty-string guard. The matcher fell back to duration+artist alone, so multiple iTunes tracks (different songs with same artist + similar duration) mapped to the same Tidal candidate. User got the same audio downloaded N times under different track positions. Fix: when CJK is detected, the alphanumeric-strip step preserves CJK Unified Ideographs, Hiragana, Katakana, Hangul, and Halfwidth/Fullwidth ranges, so CJK titles produce a comparable normalised form. Two different Japanese tracks now score appropriately low; two identical Japanese tracks now score 1.0. Latin-only normalisation is completely unchanged. 16 new unit tests pin every CJK family + every regression-prone Latin baseline. Closes #722.', page: 'downloads' },
],
'2.6.4': [
{ date: 'May 28, 2026 — 2.6.4 release' },
{ title: 'Fix: Usenet album bundle stuck on "downloading release" when SAB History flips before storage lands (#721)', desc: 'follow-up to the 2.6.3 queue→history handoff fix. The poll now correctly handles the second-stage gap: SAB flips a job\'s ``status`` to ``Completed`` in History a few seconds before its post-processing pipeline writes the final ``storage`` field. Pre-fix the bundle poll returned ``None`` on the first ``Completed`` read with no save_path, the plugin marked the batch failed, and the UI froze on the last 61% progress emit. ``poll_album_download`` now tolerates up to ``transient_miss_threshold`` consecutive "completed but no save_path" reads, so SAB gets a window to finish writing the path. When it lands, the poll resolves normally. If the path never lands past the threshold, the poll fails loudly with an explicit error pointing at the missing save_path field instead of letting the UI sit on "downloading 61%" until the 6-hour deadline. Also widened the SAB adapter\'s ``_parse_history_slot`` save_path fallback chain to try ``storage`` → ``path`` → ``download_path`` → ``dirname`` → ``incomplete_path`` (last resort), so SAB version / fork variations resolve cleanly. Whitespace-only values are skipped. 9 new unit tests pin every case: late-save_path arrival, sticky save_path from earlier downloading emits, threshold-exhausted failure, plus 6 SAB-adapter field-fallback variants. Closes #721.', page: 'downloads' },
],
'2.6.3': [
{ date: 'May 27, 2026 — 2.6.3 release' },
{ title: 'Album-bundle staging: clean up Soulseek copies + startup sweep for orphan dirs', desc: 'two related cleanup issues with the album-bundle staging dir (``storage/album_bundle_staging/<batch_id>/``). (1) Pre-fix the per-batch cleanup at the end of a download only ran for torrent / usenet bundles — Soulseek bundles were excluded with a comment about slskd "keeping its own completed folders", but Soulseek\'s bundle path ALSO copies completed files into the private staging dir for the per-track workers to claim. Those copies leaked forever; users with active Soulseek album downloads accumulated stale GB under the staging root over time. Extended the cleanup gate to include ``soulseek`` so the per-batch dir is removed once the bundle completes — same code path that already worked for torrent / usenet. (2) Added a startup sweep that removes any orphan ``<batch_id>`` subdirs left behind by previous-session crashes, errored batches, or pre-fix Soulseek bundles. Sweep runs once at server boot before any batch can register a staging dir, so it can\'t race a starting batch — safe by construction. Defensive guards: name-shape check rejects non-batch-id dirs (so a hand-placed ``.git`` or ``README.txt`` is left alone), only acts inside the configured staging root, ``shutil.rmtree`` errors log + continue instead of crashing startup. For users who already have a "clean up old files" automation pointed at this dir: stop pointing it there if you want, the auto cleanup + startup sweep cover it now; or leave it as belt-and-suspenders with a relaxed 1h+ mtime threshold. 11 new unit tests pin every edge case.', page: 'downloads' },

View file

@ -1829,7 +1829,7 @@ function showProfileEditForm(profileId, currentName, currentColor, currentAvatar
btnRow.className = 'profile-edit-buttons';
const saveBtn = document.createElement('button');
saveBtn.className = 'profile-create-btn';
saveBtn.className = 'btn btn--block btn--primary profile-create-btn';
saveBtn.textContent = 'Save';
saveBtn.onclick = async () => {
const newName = nameInput.value.trim();
@ -1957,7 +1957,7 @@ function showSelfEditForm() {
btnRow.style.marginTop = '12px';
const saveBtn = document.createElement('button');
saveBtn.className = 'profile-create-btn';
saveBtn.className = 'btn btn--block btn--primary profile-create-btn';
saveBtn.textContent = 'Save';
saveBtn.onclick = async () => {
const newName = nameInput.value.trim();

View file

@ -6395,8 +6395,8 @@ function openHaveMissingTrackModal(track, album) {
<div class="enhanced-have-import-detail">Waiting to start.</div>
</div>
<div class="enhanced-bulk-modal-footer">
<button class="enhanced-bulk-btn secondary" type="button" id="enhanced-have-cancel">Cancel</button>
<button class="enhanced-bulk-btn primary" type="button" id="enhanced-have-confirm" disabled>Import Track</button>
<button class="btn btn--sm btn--secondary enhanced-bulk-btn" type="button" id="enhanced-have-cancel">Cancel</button>
<button class="btn btn--sm btn--primary enhanced-bulk-btn" type="button" id="enhanced-have-confirm" disabled>Import Track</button>
</div>
`;
overlay.appendChild(modal);

View file

@ -2923,6 +2923,7 @@
====================================== */
@media (max-width: 768px) {
.library-artist-radio-btn,
.library-artist-watchlist-btn,
.library-artist-enhance-btn {
padding: 8px 14px;
@ -2930,6 +2931,7 @@
gap: 6px;
}
.library-artist-radio-btn .radio-icon,
.library-artist-watchlist-btn .watchlist-icon,
.library-artist-enhance-btn .enhance-icon {
font-size: 12px;

View file

@ -288,7 +288,12 @@ function initializeSearchModeToggle() {
const isClickOnSourceRow = e.target.closest('#enh-source-row');
// Modal sits above the dropdown; closing it shouldn't dismiss results.
const isClickInModal = e.target.closest('.download-missing-modal');
if (!isClickInside && !isClickOnSourceRow && !isClickInModal) {
// The media player (mini bar + expanded now-playing modal) floats
// above the page. Interacting with it — e.g. clicking the mini
// player to open the full modal, or anywhere inside that modal —
// must NOT dismiss the search results (#732).
const isClickInPlayer = e.target.closest('#media-player, #np-modal-overlay');
if (!isClickInside && !isClickOnSourceRow && !isClickInModal && !isClickInPlayer) {
hideDropdown();
}
}

View file

@ -7155,7 +7155,6 @@ body.helper-mode-active #dashboard-activity-feed:hover {
align-items: center;
gap: 18px;
position: relative;
overflow: hidden;
/* Neumorphic depth shadows - elevated card effect */
box-shadow:
@ -7255,7 +7254,6 @@ body.helper-mode-active #dashboard-activity-feed:hover {
margin: 12px 8px;
padding: 24px;
position: relative;
overflow: hidden;
/* Neumorphic depth shadows - elevated card effect */
box-shadow:
@ -7992,13 +7990,160 @@ body.helper-mode-active #dashboard-activity-feed:hover {
/* ======================================================= */
/* Main Dashboard Layout */
.dashboard-container {
display: flex;
flex-direction: column;
gap: 25px;
/* Spacing between sections */
padding: 28px 24px 30px 24px;
/* Shared button primitive
The canonical button design system. New markup (and the React pages)
should use `.btn` + a modifier; existing per-page button classes are being
migrated onto it family by family. Values reflect the dominant existing
look (accent-gradient primary, translucent ghost, semantic danger/warning).
Filter "pills" / tab buttons are a separate primitive (see .tab below). */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 24px;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
font-family: inherit;
line-height: 1;
cursor: pointer;
border: none;
outline: none;
transition: all 0.2s ease;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.btn--primary {
background: linear-gradient(135deg, rgb(var(--accent-rgb)), rgb(var(--accent-light-rgb)));
color: #000;
font-weight: 700;
box-shadow: 0 4px 16px rgba(var(--accent-rgb), 0.3);
}
.btn--primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(var(--accent-rgb), 0.4);
}
.btn--primary:active:not(:disabled) { transform: translateY(0); }
.btn--secondary {
background: rgba(255, 255, 255, 0.08);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.15);
}
.btn--secondary:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.25);
}
.btn--danger {
background: linear-gradient(135deg, #dc2626, #ef4444);
color: #fff;
font-weight: 700;
box-shadow: 0 4px 16px rgba(220, 38, 38, 0.3);
}
.btn--danger:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(220, 38, 38, 0.4);
}
/* Spotify-green download CTA (intentional brand color). */
.btn--download {
background: linear-gradient(135deg, #1db954, #1ed760);
color: #fff;
border: 1px solid rgba(29, 185, 84, 0.3);
}
.btn--download:hover:not(:disabled) {
background: linear-gradient(135deg, #1ed760, #1db954);
transform: translateY(-1px);
box-shadow: 0 8px 24px rgba(29, 185, 84, 0.4);
}
/* Amber "caution" color (e.g. bulk fix-all). */
.btn--warning {
background: rgba(245, 158, 11, 0.12);
color: #fbbf24;
border: 1px solid rgba(245, 158, 11, 0.3);
}
.btn--warning:hover:not(:disabled) {
background: rgba(245, 158, 11, 0.22);
border-color: rgba(245, 158, 11, 0.45);
}
/* Compact toolbar size the second button tier used in headers, bulk-action
rows and toolbars (smaller than the default large action button). Combine
with a color modifier, e.g. `.btn .btn--sm .btn--secondary`. */
.btn--sm {
padding: 6px 14px;
font-size: 12px;
border-radius: 8px;
gap: 6px;
}
/* Full-width (form submit buttons, etc.). */
.btn--block { width: 100%; }
/* Shared loading state (JS toggles `.loading`). */
.btn.loading {
background: rgba(255, 193, 7, 0.9);
color: #000;
animation: pulse-loading 1.5s infinite;
cursor: not-allowed;
}
/* Shared tab / filter-pill primitive
Canonical pill tab (the bordered rounded-pill filter look). New markup and
the React pages should use `.tab` + `.tab.active`. Existing per-page pill
classes (watchlist-filter-btn, discog-filter, adl-pill, ) are deliberately
left in place they're JS-coupled and visually divergent, so they should be
migrated only when visually verifiable / in React, not blind. */
.tab {
padding: 6px 16px;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.5);
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all 0.2s ease;
}
.tab:hover:not(.active) { color: rgba(255, 255, 255, 0.8); }
.tab.active {
color: #fff;
border-color: rgba(var(--accent-rgb), 0.5);
background: rgba(var(--accent-rgb), 0.15);
}
/* Shared card primitive
Canonical "glass" content card (the dashboard service/stat card look). New
markup and the React pages should use `.card`. Existing per-feature card
classes are left in place; migrate when visually verifiable / in React. */
.card {
padding: 20px 22px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(18, 18, 22, 0.9);
backdrop-filter: blur(12px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.15);
transition: all 0.2s ease;
}
.card:hover {
transform: translateY(-2px);
border-color: rgba(var(--accent-rgb), 0.3);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.15);
}
/* Shared page shell
The canonical page card (the dashboard / stats look). Adopted by the
dashboard, tools, watchlist and wishlist pages and intended for reuse
by the React pages too (same class name). Page-specific extras (flex
layout, position) stay on the per-page class. */
.page-shell {
padding: 28px 24px 30px;
/* Semi-transparent to let page particles show through */
background: linear-gradient(135deg,
rgba(20, 20, 20, 0.55) 0%,
@ -8007,7 +8152,6 @@ body.helper-mode-active #dashboard-activity-feed:hover {
border: 1px solid rgba(255, 255, 255, 0.08);
border-top: 1px solid rgba(255, 255, 255, 0.12);
margin: 20px;
/* Soft floating shadow */
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.3),
@ -8015,6 +8159,13 @@ body.helper-mode-active #dashboard-activity-feed:hover {
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.dashboard-container {
display: flex;
flex-direction: column;
gap: 25px;
/* Spacing between sections; card styling comes from .page-shell */
}
.dashboard-section {
display: flex;
flex-direction: column;
@ -11171,12 +11322,9 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.sync-history-page-btn:hover:not(:disabled) { color: rgb(var(--accent-rgb)); background: rgba(var(--accent-rgb), 0.1); }
.sync-history-page-btn:disabled { opacity: 0.3; cursor: default; }
.sync-history-page-info { font-size: 12px; color: rgba(255, 255, 255, 0.35); }
.sync-history-btn {
font-size: 12px; font-weight: 500; color: rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px; padding: 5px 12px; cursor: pointer; transition: all 0.2s ease;
}
.sync-history-btn:hover { color: rgb(var(--accent-rgb)); background: rgba(var(--accent-rgb), 0.1); border-color: rgba(var(--accent-rgb), 0.3); }
/* .sync-history-btn styling migrated to .btn .btn--sm .btn--secondary.
The class is kept on the markup as a JS/onboarding selector hook, and
.auto-sync-manager-btn still tints the Auto-Sync button accent. */
.auto-sync-manager-btn {
color: rgb(var(--accent-light-rgb));
@ -20876,48 +21024,7 @@ body.helper-mode-active #dashboard-activity-feed:hover {
justify-content: flex-end;
}
.config-modal-btn {
padding: 12px 28px;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
border: none;
outline: none;
}
.config-modal-btn-primary {
background: linear-gradient(135deg, rgb(var(--accent-light-rgb)), rgb(var(--accent-rgb)));
color: #000;
box-shadow: 0 4px 16px rgba(var(--accent-rgb), 0.3);
}
.config-modal-btn-primary:hover {
background: linear-gradient(135deg, #1fdf64, rgb(var(--accent-light-rgb)));
box-shadow: 0 6px 20px rgba(var(--accent-rgb), 0.4);
transform: translateY(-1px);
}
.config-modal-btn-primary:active {
transform: translateY(0);
}
.config-modal-btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.config-modal-btn-secondary {
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.15);
}
.config-modal-btn-secondary:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.25);
}
/* .config-modal-btn* migrated to the shared .btn / .btn--primary / .btn--secondary primitive. */
/* Linked Provider Artist */
/* Per-source linked rows */
@ -21155,7 +21262,7 @@ body.helper-mode-active #dashboard-activity-feed:hover {
flex-direction: column-reverse;
}
.config-modal-btn {
.config-modal-actions .btn {
width: 100%;
}
}
@ -23892,46 +23999,36 @@ body.helper-mode-active #dashboard-activity-feed:hover {
/* Library Artist Watchlist Button */
/* Artist-hero action buttons share the Download Discography look:
accent gradient fill, accent border, accent-light text, compact sizing,
subtle shimmer, lift + glow on hover. The watchlist .watching state keeps
its amber "already watching" signal; everything else is uniform. */
.library-artist-watchlist-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 20px;
font-size: 13px;
font-weight: 600;
padding: 7px 16px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
color: rgba(255, 255, 255, 0.85);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
color: rgb(var(--accent-light-rgb));
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.15) 0%, rgba(var(--accent-rgb), 0.05) 100%);
border: 1px solid rgba(var(--accent-rgb), 0.25);
border-radius: 8px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition: all 0.25s ease;
outline: none;
font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif;
position: relative;
font-family: inherit;
overflow: hidden;
backdrop-filter: blur(8px);
}
.library-artist-watchlist-btn::before {
content: '';
position: absolute;
inset: 0;
border-radius: 12px;
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.2) 0%, rgba(var(--accent-rgb), 0.05) 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.library-artist-watchlist-btn:hover:not(:disabled)::before {
opacity: 1;
}
.library-artist-watchlist-btn:hover:not(:disabled) {
color: #ffffff;
border-color: rgba(var(--accent-rgb), 0.35);
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(var(--accent-rgb), 0.2), 0 0 0 1px rgba(var(--accent-rgb), 0.1);
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.25) 0%, rgba(var(--accent-rgb), 0.1) 100%);
border-color: rgba(var(--accent-rgb), 0.4);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(var(--accent-rgb), 0.2);
}
.library-artist-watchlist-btn:active:not(:disabled) {
@ -23940,14 +24037,14 @@ body.helper-mode-active #dashboard-activity-feed:hover {
}
.library-artist-watchlist-btn .watchlist-icon {
font-size: 14px;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 13px;
transition: transform 0.25s ease;
position: relative;
z-index: 1;
}
.library-artist-watchlist-btn:hover:not(:disabled) .watchlist-icon {
transform: scale(1.15);
transform: scale(1.12);
}
.library-artist-watchlist-btn .watchlist-text {
@ -23956,20 +24053,16 @@ body.helper-mode-active #dashboard-activity-feed:hover {
}
.library-artist-watchlist-btn.watching {
background: linear-gradient(135deg, rgba(255, 193, 7, 0.15) 0%, rgba(255, 193, 7, 0.05) 100%);
color: #ffc107;
border-color: rgba(255, 193, 7, 0.25);
box-shadow: 0 0 12px rgba(255, 193, 7, 0.08);
}
.library-artist-watchlist-btn.watching::before {
background: linear-gradient(135deg, rgba(255, 193, 7, 0.25) 0%, rgba(255, 193, 7, 0.08) 100%);
background: linear-gradient(135deg, rgba(255, 193, 7, 0.15) 0%, rgba(255, 193, 7, 0.05) 100%);
border-color: rgba(255, 193, 7, 0.3);
}
.library-artist-watchlist-btn.watching:hover:not(:disabled) {
color: #ffffff;
border-color: rgba(255, 193, 7, 0.5);
box-shadow: 0 4px 20px rgba(255, 193, 7, 0.25), 0 0 0 1px rgba(255, 193, 7, 0.15);
background: linear-gradient(135deg, rgba(255, 193, 7, 0.25) 0%, rgba(255, 193, 7, 0.1) 100%);
border-color: rgba(255, 193, 7, 0.45);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(255, 193, 7, 0.2);
}
.library-artist-watchlist-btn:disabled {
@ -23982,45 +24075,31 @@ body.helper-mode-active #dashboard-activity-feed:hover {
/* ─── Enhance Quality Button ─── */
.library-artist-enhance-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 20px;
font-size: 13px;
font-weight: 600;
padding: 7px 16px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
color: rgba(79, 195, 247, 0.9);
background: linear-gradient(135deg, rgba(79, 195, 247, 0.08) 0%, rgba(79, 195, 247, 0.02) 100%);
border: 1px solid rgba(79, 195, 247, 0.15);
border-radius: 12px;
color: rgb(129, 212, 250);
background: linear-gradient(135deg, rgba(79, 195, 247, 0.15) 0%, rgba(79, 195, 247, 0.05) 100%);
border: 1px solid rgba(79, 195, 247, 0.3);
border-radius: 8px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition: all 0.25s ease;
outline: none;
font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif;
position: relative;
font-family: inherit;
overflow: hidden;
backdrop-filter: blur(8px);
}
.library-artist-enhance-btn::before {
content: '';
position: absolute;
inset: 0;
border-radius: 12px;
background: linear-gradient(135deg, rgba(79, 195, 247, 0.22) 0%, rgba(100, 220, 255, 0.08) 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.library-artist-enhance-btn:hover::before {
opacity: 1;
}
.library-artist-enhance-btn:hover {
color: #ffffff;
border-color: rgba(79, 195, 247, 0.4);
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(79, 195, 247, 0.2), 0 0 0 1px rgba(79, 195, 247, 0.1);
background: linear-gradient(135deg, rgba(79, 195, 247, 0.25) 0%, rgba(79, 195, 247, 0.12) 100%);
border-color: rgba(79, 195, 247, 0.5);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(79, 195, 247, 0.25);
}
.library-artist-enhance-btn:active {
@ -24029,14 +24108,14 @@ body.helper-mode-active #dashboard-activity-feed:hover {
}
.library-artist-enhance-btn .enhance-icon {
font-size: 14px;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 13px;
transition: transform 0.25s ease;
position: relative;
z-index: 1;
}
.library-artist-enhance-btn:hover .enhance-icon {
transform: scale(1.2) rotate(8deg);
transform: scale(1.12);
}
.library-artist-enhance-btn .enhance-text {
@ -24084,45 +24163,31 @@ body.helper-mode-active #dashboard-activity-feed:hover {
/* ─── Artist Radio Button ─── */
.library-artist-radio-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 20px;
font-size: 13px;
font-weight: 600;
padding: 7px 16px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
color: rgba(255, 255, 255, 0.85);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
color: rgb(196, 181, 253);
background: linear-gradient(135deg, rgba(139, 92, 246, 0.15) 0%, rgba(139, 92, 246, 0.05) 100%);
border: 1px solid rgba(139, 92, 246, 0.3);
border-radius: 8px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition: all 0.25s ease;
outline: none;
font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif;
position: relative;
font-family: inherit;
overflow: hidden;
backdrop-filter: blur(8px);
}
.library-artist-radio-btn::before {
content: '';
position: absolute;
inset: 0;
border-radius: 12px;
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.2) 0%, rgba(var(--accent-rgb), 0.05) 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.library-artist-radio-btn:hover::before {
opacity: 1;
}
.library-artist-radio-btn:hover {
color: #ffffff;
border-color: rgba(var(--accent-rgb), 0.35);
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(var(--accent-rgb), 0.2), 0 0 0 1px rgba(var(--accent-rgb), 0.1);
background: linear-gradient(135deg, rgba(139, 92, 246, 0.25) 0%, rgba(139, 92, 246, 0.12) 100%);
border-color: rgba(139, 92, 246, 0.5);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(139, 92, 246, 0.25);
}
.library-artist-radio-btn:active {
@ -24131,14 +24196,14 @@ body.helper-mode-active #dashboard-activity-feed:hover {
}
.library-artist-radio-btn .radio-icon {
font-size: 14px;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 13px;
transition: transform 0.25s ease;
position: relative;
z-index: 1;
}
.library-artist-radio-btn:hover .radio-icon {
transform: scale(1.15);
transform: scale(1.12);
}
.library-artist-radio-btn .radio-text {
@ -25721,10 +25786,11 @@ body.helper-mode-active #dashboard-activity-feed:hover {
=============================== */
.library-container {
/* outer gutter comes from .page (40px) shared across the full-width
exception pages so spacing is intentional, not accidental */
height: 100%;
display: flex;
flex-direction: column;
padding: 20px;
gap: 20px;
}
@ -28173,78 +28239,8 @@ div.artist-hero-badge {
gap: 12px;
}
.wishlist-modal-btn {
padding: 12px 24px;
border: none;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
min-width: 120px;
}
.wishlist-modal-btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.wishlist-modal-btn-secondary:hover {
background: rgba(255, 255, 255, 0.2);
color: #ffffff;
border-color: rgba(255, 255, 255, 0.3);
}
.wishlist-modal-btn-download {
background: linear-gradient(135deg, #1db954, #1ed760);
color: #ffffff;
border: 1px solid rgba(29, 185, 84, 0.3);
}
.wishlist-modal-btn-download:hover {
background: linear-gradient(135deg, #1ed760, #1db954);
transform: translateY(-1px);
box-shadow: 0 8px 24px rgba(29, 185, 84, 0.4);
}
.wishlist-modal-btn-download:active {
transform: translateY(0);
box-shadow: 0 4px 12px rgba(29, 185, 84, 0.3);
}
.wishlist-modal-btn-primary {
background: linear-gradient(135deg, rgb(var(--accent-rgb)) 0%, rgb(var(--accent-light-rgb)) 100%);
color: #ffffff;
border: 1px solid rgba(var(--accent-rgb), 0.3);
}
.wishlist-modal-btn-primary:hover {
background: linear-gradient(135deg, rgb(var(--accent-light-rgb)) 0%, #22e167 100%);
transform: translateY(-1px);
box-shadow: 0 8px 24px rgba(var(--accent-rgb), 0.4);
}
.wishlist-modal-btn-primary:active {
transform: translateY(0);
box-shadow: 0 4px 12px rgba(var(--accent-rgb), 0.3);
}
/* Loading State */
.wishlist-modal-btn-primary.loading {
background: rgba(255, 193, 7, 0.9);
color: #000000;
animation: pulse-loading 1.5s infinite;
cursor: not-allowed;
}
.wishlist-modal-btn-primary:disabled {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.4);
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* .wishlist-modal-btn* migrated to the shared .btn primitive
(.btn--primary / .btn--secondary / .btn--download / .btn.loading). */
/* Animation for modal appearance */
@keyframes wishlistModalFadeIn {
@ -28331,7 +28327,7 @@ div.artist-hero-badge {
gap: 12px;
}
.wishlist-modal-btn {
.wishlist-modal-actions .btn {
width: 100%;
}
}
@ -34322,13 +34318,9 @@ div.artist-hero-badge {
.ya-watchlist-btn.active { color: var(--accent); border-color: rgba(var(--accent-rgb),0.3); background: rgba(var(--accent-rgb),0.15); }
/* Your Artists header buttons */
.ya-header-btn {
display: flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 10px; font-size: 12px; font-weight: 600;
cursor: pointer; transition: all 0.2s; border: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.5);
}
.ya-header-btn:hover { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.8); border-color: rgba(255,255,255,0.12); }
/* .ya-header-btn base styling migrated to .btn .btn--sm .btn--secondary.
Class kept on the markup (discover.js injects it) along with the
.ya-refresh-btn / .ya-settings-btn / .ya-viewall-btn co-modifiers. */
.ya-refresh-btn { padding: 7px 10px; }
.ya-refresh-btn:hover svg { transform: rotate(45deg); }
.ya-refresh-btn svg { transition: transform 0.3s; }
@ -43234,22 +43226,9 @@ div.artist-hero-badge {
border-color: #fff;
}
.profile-create-btn {
width: 100%;
padding: 10px;
background: #6366f1;
color: #fff;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.profile-create-btn:hover {
background: #4f46e5;
}
/* .profile-create-btn migrated to .btn .btn--block .btn--primary. The class
is kept on the markup (init.js sets it) as a hook for the scoped
`.profile-edit-buttons .profile-create-btn { flex: 1 }` layout rule. */
.admin-pin-section {
margin-top: 20px;
@ -43876,7 +43855,7 @@ body.downloads-disabled [onclick*="DownloadMissing"]:not([onclick*="close"]) {
AUTOMATIONS PAGE
=========================== */
.automations-container { padding: 20px 24px; }
/* .automations-container: card styling from .page-shell */
.automations-list { display: flex; flex-direction: column; gap: 0; }
#automations-list-view { padding-bottom: 10vh; }
@ -46449,36 +46428,11 @@ textarea.enhanced-meta-field-input {
display: flex;
gap: 8px;
}
.enhanced-bulk-btn {
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.enhanced-bulk-btn.primary {
background: rgb(var(--accent-rgb));
color: #ffffff;
}
.enhanced-bulk-btn.primary:hover {
background: rgb(var(--accent-light-rgb));
}
.enhanced-bulk-btn.secondary {
background: rgba(255,255,255,0.1);
color: rgba(255,255,255,0.8);
}
.enhanced-bulk-btn.secondary:hover {
background: rgba(255,255,255,0.15);
}
.enhanced-bulk-btn.clear {
background: rgba(255, 65, 54, 0.1);
color: #ff4136;
}
.enhanced-bulk-btn.clear:hover {
background: rgba(255, 65, 54, 0.2);
}
/* .enhanced-bulk-btn base + .primary/.secondary/.clear migrated to
.btn .btn--sm (.btn--primary / .btn--secondary / .btn--danger). The
.enhanced-bulk-btn class is kept on the markup as a hook for the mobile
size override (.enhanced-bulk-bar .enhanced-bulk-btn) and the special
.tag-write / .rg-analyze color variants below. */
/* Bulk Edit Modal */
.enhanced-bulk-modal {
@ -46912,7 +46866,7 @@ textarea.enhanced-meta-field-input {
DOWNLOAD DISCOGRAPHY Button + Modal
*/
.discog-download-wrap { margin: 12px 0 4px; }
.discog-download-wrap { margin: 0; display: inline-flex; }
.discog-download-btn {
position: relative;
@ -46949,7 +46903,6 @@ textarea.enhanced-meta-field-input {
padding: 7px 16px;
font-size: 12px;
border-radius: 8px;
margin-top: 8px;
}
.discog-btn-compact .discog-btn-icon { font-size: 12px; }
@ -52334,42 +52287,8 @@ tr.tag-diff-same {
margin-right: 4px;
}
.repair-bulk-btn {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
color: rgba(255, 255, 255, 0.7);
padding: 6px 14px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.repair-bulk-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.repair-bulk-btn.fix {
background: rgba(var(--accent-rgb, 99, 102, 241), 0.12);
border-color: rgba(var(--accent-rgb, 99, 102, 241), 0.3);
color: rgb(var(--accent-light-rgb, 129, 140, 248));
font-weight: 600;
}
.repair-bulk-btn.fix:hover {
background: rgba(var(--accent-rgb, 99, 102, 241), 0.2);
}
.repair-bulk-btn.fix-all {
background: rgba(245, 158, 11, 0.12);
border-color: rgba(245, 158, 11, 0.3);
color: #fbbf24;
font-weight: 600;
}
.repair-bulk-btn.fix-all:hover {
background: rgba(245, 158, 11, 0.22);
}
/* .repair-bulk-btn / .fix / .fix-all migrated to .btn .btn--sm
(.btn--secondary / .btn--primary / .btn--warning). */
.repair-clear-btn {
margin-left: auto;
@ -54490,7 +54409,7 @@ tr.tag-diff-same {
================================================================================== */
.explorer-container {
padding: 24px 32px;
/* card styling from .page-shell; keep full-height flex layout */
display: flex;
flex-direction: column;
min-height: 100%;
@ -54915,57 +54834,8 @@ tr.tag-diff-same {
gap: 6px;
}
.explorer-action-btn {
padding: 7px 14px;
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
letter-spacing: 0.2px;
display: flex;
align-items: center;
gap: 5px;
}
.explorer-action-btn svg { opacity: 0.5; }
.explorer-action-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.85);
border-color: rgba(255, 255, 255, 0.12);
}
.explorer-action-btn:hover svg { opacity: 0.8; }
.explorer-action-btn.primary {
background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 80%, #fff));
border-color: transparent;
color: #fff;
font-weight: 700;
padding: 8px 20px;
font-size: 12px;
position: relative;
overflow: hidden;
box-shadow: 0 2px 10px rgba(var(--accent-rgb), 0.2);
}
.explorer-action-btn.primary svg { opacity: 1; }
.explorer-action-btn.primary::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, transparent 50%);
border-radius: inherit;
pointer-events: none;
}
.explorer-action-btn.primary:hover {
box-shadow: 0 6px 22px rgba(var(--accent-rgb), 0.4);
filter: brightness(1.1);
transform: translateY(-1px);
}
/* .explorer-action-btn / .primary migrated to .btn .btn--sm
(.btn--secondary / .btn--primary). */
/* Viewport */
.explorer-viewport {
@ -58180,9 +58050,10 @@ body.reduce-effects *::after {
}
.adl-layout {
/* outer gutter comes from .page (40px) consistent with the other
full-width exception pages (library, etc.) */
display: flex;
gap: 0;
padding: 28px 32px;
max-width: 1440px;
}
@ -59491,20 +59362,7 @@ body.reduce-effects *::after {
TOOLS PAGE
*/
.tools-page-container {
padding: 28px 24px 30px;
margin: 20px;
background: linear-gradient(135deg,
rgba(20, 20, 20, 0.55) 0%,
rgba(12, 12, 12, 0.62) 100%);
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-top: 1px solid rgba(255, 255, 255, 0.12);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.3),
0 4px 16px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
/* .tools-page-container: card styling now from .page-shell (no extras). */
.tools-page-header {
padding: 20px 24px 18px;
@ -59677,18 +59535,7 @@ body.reduce-effects *::after {
*/
.watchlist-page-container {
padding: 28px 24px 30px;
margin: 20px;
background: linear-gradient(135deg,
rgba(20, 20, 20, 0.55) 0%,
rgba(12, 12, 12, 0.62) 100%);
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-top: 1px solid rgba(255, 255, 255, 0.12);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.3),
0 4px 16px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
/* card styling from .page-shell; keep page-specific positioning */
position: relative;
}
@ -59777,65 +59624,10 @@ body.reduce-effects *::after {
padding: 0 4px;
}
.watchlist-action-btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 9px 18px;
border-radius: 10px;
border: 1px solid transparent;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
letter-spacing: 0.1px;
}
.watchlist-action-primary {
background: linear-gradient(135deg, rgb(var(--accent-rgb)), rgba(var(--accent-rgb), 0.85));
color: #fff;
border-color: rgba(var(--accent-rgb), 0.3);
box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.2);
}
.watchlist-action-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(var(--accent-rgb), 0.3);
filter: brightness(1.1);
}
.watchlist-action-primary:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.watchlist-action-secondary {
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.7);
border-color: rgba(255, 255, 255, 0.08);
}
.watchlist-action-secondary:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
border-color: rgba(255, 255, 255, 0.15);
transform: translateY(-1px);
}
.watchlist-action-danger {
background: rgba(239, 68, 68, 0.1);
color: #f87171;
border-color: rgba(239, 68, 68, 0.15);
}
.watchlist-action-danger:hover {
background: rgba(239, 68, 68, 0.18);
border-color: rgba(239, 68, 68, 0.25);
transform: translateY(-1px);
}
/* .watchlist-action-btn* migrated to the shared .btn / .btn--primary /
.btn--secondary / .btn--danger primitive (watchlist + wishlist headers).
The .watchlist-batch-remove-btn / .wishlist-batch-remove-btn hook classes
are kept on those buttons for their own overrides. */
/* ── Scan status card ── */
.watchlist-page-scan-status {
@ -60089,18 +59881,7 @@ body.reduce-effects *::after {
*/
.wishlist-page-container {
padding: 28px 24px 30px;
margin: 20px;
background: linear-gradient(135deg,
rgba(20, 20, 20, 0.55) 0%,
rgba(12, 12, 12, 0.62) 100%);
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-top: 1px solid rgba(255, 255, 255, 0.12);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.3),
0 4px 16px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
/* card styling from .page-shell; keep page-specific positioning */
position: relative;
}

View file

@ -4022,12 +4022,18 @@ async function handleDbUpdateButtonClick() {
if (currentAction === 'Update Database') {
const refreshSelect = document.getElementById('db-refresh-type');
const isFullRefresh = refreshSelect.value === 'full';
const refreshType = refreshSelect.value;
const isFullRefresh = refreshType === 'full';
const isDeepScan = refreshType === 'deep';
if (isFullRefresh) {
// Replicates the QMessageBox confirmation from the GUI
const confirmed = await showConfirmDialog({ title: 'Full Refresh', message: 'This will clear and rebuild the database for the active server. It can take a long time.\n\nAre you sure you want to proceed?', confirmText: 'Proceed' });
if (!confirmed) return;
} else if (isDeepScan) {
// Deep scan removes stale entries — confirm like the dashboard deep scan does.
const confirmed = await showConfirmDialog({ title: 'Deep Scan', message: 'A deep scan re-checks every track in your media server library against the database.\n\n• Adds any new tracks that were missed\n• Removes tracks no longer on your server\n• Preserves all existing metadata and enrichment data\n\nThis may take a while for large libraries. Proceed?', confirmText: 'Proceed' });
if (!confirmed) return;
}
try {
@ -4036,7 +4042,9 @@ async function handleDbUpdateButtonClick() {
const response = await fetch('/api/database/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ full_refresh: isFullRefresh })
// Deep scan and full refresh use distinct backend flags; deep
// scan takes precedence server-side, so send only its flag.
body: JSON.stringify(isDeepScan ? { deep_scan: true } : { full_refresh: isFullRefresh })
});
if (response.ok) {