diff --git a/.dockerignore b/.dockerignore index cd63220a..4b77bb74 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,8 @@ # Docker ignore file for SoulSync WebUI +# Hidden folders and files +.* + # Git .git .gitignore diff --git a/.github/workflows/cleanup-dev-images.yml b/.github/workflows/cleanup-dev-images.yml index 19597992..e065c9ce 100644 --- a/.github/workflows/cleanup-dev-images.yml +++ b/.github/workflows/cleanup-dev-images.yml @@ -8,6 +8,7 @@ on: jobs: cleanup: + if: github.repository == 'Nezreka/SoulSync' runs-on: ubuntu-latest permissions: packages: write diff --git a/.github/workflows/dev-nightly.yml b/.github/workflows/dev-nightly.yml index d5312d29..6165350d 100644 --- a/.github/workflows/dev-nightly.yml +++ b/.github/workflows/dev-nightly.yml @@ -13,6 +13,7 @@ on: jobs: nightly: + if: github.repository == 'Nezreka/SoulSync' runs-on: ubuntu-latest # Skip scheduled runs if dev branch has no new commits in the last 24h # (pushes and manual triggers always run) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b18d1b26..44e766dd 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -15,6 +15,7 @@ on: jobs: build-and-push: + if: github.repository == 'Nezreka/SoulSync' runs-on: ubuntu-latest permissions: diff --git a/core/deezer_worker.py b/core/deezer_worker.py index b5e942fc..4940d16d 100644 --- a/core/deezer_worker.py +++ b/core/deezer_worker.py @@ -8,7 +8,7 @@ from datetime import datetime, timedelta from utils.logging_config import get_logger from database.music_database import MusicDatabase from core.deezer_client import DeezerClient -from core.worker_utils import interruptible_sleep +from core.worker_utils import interruptible_sleep, set_album_api_track_count logger = get_logger("deezer_worker") @@ -579,6 +579,17 @@ class DeezerWorker: WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]') """, (json.dumps(genre_names), album_id)) + # Cache the authoritative expected track count for the Album + # Completeness repair job. Deezer's field is `nb_tracks`; prefer + # full_data over search_data so we pick up the richer count when + # the full album lookup ran. Helper handles the int conversion + # and skip-on-missing semantics. + set_album_api_track_count( + cursor, + album_id, + (full_data.get('nb_tracks') if full_data else None) or search_data.get('nb_tracks'), + ) + conn.commit() except Exception as e: diff --git a/core/discogs_worker.py b/core/discogs_worker.py index 99014882..84270de9 100644 --- a/core/discogs_worker.py +++ b/core/discogs_worker.py @@ -18,11 +18,34 @@ from datetime import datetime, timedelta from utils.logging_config import get_logger from database.music_database import MusicDatabase from core.discogs_client import DiscogsClient -from core.worker_utils import interruptible_sleep +from core.worker_utils import interruptible_sleep, set_album_api_track_count logger = get_logger("discogs_worker") +def count_discogs_real_tracks(tracklist) -> int: + """Count actual songs in a Discogs tracklist response. + + Discogs tracklists interleave real tracks with section headings + (``type_=='heading'``), index markers (``type_=='index'``), + and sub-tracks (``type_=='sub_track'``) that aren't themselves + songs. We count anything that's explicitly typed as ``'track'`` OR + has an empty/missing ``type_`` field — matching exactly what + :meth:`core.discogs_client.DiscogsClient.get_album_tracks` itself + treats as a real track (`type_ in ('track', '')`). Counting any + narrower set silently disagrees with the repair job's fallback + `_get_expected_total` path, which calls `get_album_tracks_for_source` + under the hood and therefore uses the client's count. + + Reported by kettui on PR #374 — original filter only counted + ``type_=='track'`` and undercounted releases where the discogs + response left ``type_`` empty for some real tracks. + """ + if not tracklist: + return 0 + return sum(1 for t in tracklist if (t.get('type_') or '') in ('track', '')) + + class DiscogsWorker: """Background worker for enriching library artists and albums with Discogs metadata.""" @@ -454,6 +477,16 @@ class DiscogsWorker: WHERE id = ? AND (thumb_url IS NULL OR thumb_url = '') """, (image_url, album_id)) + # Cache the authoritative expected track count for the Album + # Completeness repair job. See `count_discogs_real_tracks` + # for why we accept both `type_ == 'track'` and empty `type_` + # (kettui's PR #374 review — narrower filter undercounted). + set_album_api_track_count( + cursor, + album_id, + count_discogs_real_tracks(data.get('tracklist')), + ) + conn.commit() except Exception as e: diff --git a/core/itunes_worker.py b/core/itunes_worker.py index 9a4769b4..1e1c2e9d 100644 --- a/core/itunes_worker.py +++ b/core/itunes_worker.py @@ -8,7 +8,7 @@ from datetime import datetime, timedelta from utils.logging_config import get_logger from database.music_database import MusicDatabase from core.itunes_client import iTunesClient -from core.worker_utils import interruptible_sleep +from core.worker_utils import interruptible_sleep, set_album_api_track_count logger = get_logger("itunes_worker") @@ -669,6 +669,10 @@ class iTunesWorker: WHERE id = ? AND (year IS NULL OR year = '' OR year = '0') """, (year, album_id)) + # Cache the authoritative expected track count for the Album + # Completeness repair job (see set_album_api_track_count docstring). + set_album_api_track_count(cursor, album_id, getattr(album_obj, 'total_tracks', 0)) + conn.commit() except Exception as e: logger.error(f"Error updating album #{album_id} with iTunes data: {e}") diff --git a/core/jellyfin_client.py b/core/jellyfin_client.py index 4cf1500e..47ca8152 100644 --- a/core/jellyfin_client.py +++ b/core/jellyfin_client.py @@ -1194,7 +1194,49 @@ class JellyfinClient: stats['bulk_tracks_cached'] = len(self._all_tracks_cache) return stats - + + def search_tracks(self, title: str, artist: str, limit: int = 15) -> List[JellyfinTrack]: + """Search for tracks by title and artist on the Jellyfin server.""" + if not self.ensure_connection(): + return [] + + try: + search_term = f"{artist} {title}".strip() + params = { + 'ParentId': self.music_library_id, + 'IncludeItemTypes': 'Audio', + 'Recursive': True, + 'SearchTerm': search_term, + 'Fields': 'AlbumId,ArtistItems,Path,MediaSources', + 'Limit': limit, + } + + response = self._make_request(f'/Users/{self.user_id}/Items', params) + if not response: + return [] + + results = [] + lower_title = title.lower() + lower_artist = artist.lower() + for item in response.get('Items', []): + track = JellyfinTrack(item, self) + # Basic relevance filter: title should appear in track name + if lower_title and lower_title not in track.title.lower(): + continue + # If artist provided, check artist names + if lower_artist: + artist_names = [a.get('Name', '').lower() for a in item.get('ArtistItems', [])] + album_artist = (item.get('AlbumArtist') or '').lower() + if not any(lower_artist in n for n in artist_names) and lower_artist not in album_artist: + continue + results.append(track) + + return results[:limit] + + except Exception as e: + logger.error(f"Error searching Jellyfin tracks for '{title}' by '{artist}': {e}") + return [] + def get_all_playlists(self) -> List[JellyfinPlaylistInfo]: """Get all playlists from Jellyfin server""" if not self.ensure_connection(): diff --git a/core/library_reorganize.py b/core/library_reorganize.py new file mode 100644 index 00000000..6d62da98 --- /dev/null +++ b/core/library_reorganize.py @@ -0,0 +1,1496 @@ +"""Re-route a library album's existing files through the same +post-processing pipeline that handles fresh downloads. + +The old reorganize endpoint reinvented several wheels — its own template +engine, its own disc-number resolution from file tags, its own sidecar +sweep, its own collision detection. Each of those drifted from the +canonical post-processing path over time, producing reorganize-only +bugs (multi-disc deluxe collapsing to single-disc when even one file's +tag was missing; tracks silently skipped when their file paths didn't +resolve on disk; etc.). + +The new design follows the import page's pattern: copy each file to a +staging folder, build the same context dict the download workers +build, then call ``_post_process_matched_download`` for each one. +Post-processing already knows how to pick the right destination, write +the right tags, handle multi-disc subfolders, recreate sidecars (cover +art, lyrics), and run AcoustID verification — there's nothing for +reorganize to add on top. + +Hard requirement: the album must have at least one stored +metadata-source ID (spotify_album_id / itunes_album_id / deezer_id / +discogs_id / soul_id). With no source ID we have nothing authoritative +to ask for the canonical tracklist, and silently degrading to file +tags is exactly the failure mode the old code path produced. Albums +without a source ID are reported back to the caller and skipped +entirely. +""" + +import os +import shutil +import threading +import time +import uuid +from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional, Set + +# Per-album track concurrency. Matches the download workers' per-batch +# concurrency (3) so reorganize feels comparable to a fresh download. +# +# Operational note: post-processing can spawn an ffmpeg subprocess per +# track if `lossy_copy.downsample_hires` is enabled. With 3 workers +# that's up to 3 concurrent ffmpeg processes. Acceptable for typical +# album sizes (10-20 tracks); on a giant single-album reorganize +# (50+ tracks) ffmpeg's transient memory could be noticeable but each +# subprocess is short-lived so total RAM doesn't pile up. If we ever +# see resource issues from this, drop to 2 here rather than disabling +# concurrency entirely. +_REORGANIZE_MAX_WORKERS = 3 + +# Watchdog interval — how often the orchestrator checks the worker +# pool while waiting for tasks to finish. Setting this to 30s means +# we log a warning naming any track that's been in flight longer than +# `_HUNG_WORKER_THRESHOLD` (so an operator can investigate) without +# burning CPU on a tight poll. Doesn't kill stuck threads (Python +# can't), just surfaces them. +_WATCHDOG_INTERVAL_SECONDS = 30 +_HUNG_WORKER_THRESHOLD_SECONDS = 300 # 5 min — generous; real worst-case + # is ffmpeg downsampling a long + # hi-res FLAC, ~30-60s typically. + +from core.metadata_service import ( + get_album_for_source, + get_album_tracks_for_source, + get_client_for_source, + get_primary_source, + get_source_priority, +) +from utils.logging_config import get_logger + +logger = get_logger("library_reorganize") + + +def _safe_filename(name: str) -> str: + """Strip path-illegal characters so we can use the value as a + filename component on the staging path.""" + return ''.join(c for c in (name or 'unknown') if c not in '<>:"/\\|?*').strip() or 'unknown' + + +def _normalize_album_tracks(api_tracks): + """Normalize the various provider tracklist shapes (dict-with-`items`, + bare list, ``None``) to a single list of item dicts.""" + if not api_tracks: + return [] + if isinstance(api_tracks, dict): + items = api_tracks.get('items') or [] + return items if items else [] + if isinstance(api_tracks, list): + return api_tracks + return [] + + +SUPPORTED_SOURCES = ('spotify', 'itunes', 'deezer', 'discogs', 'hydrabase') + +# Per-source album-ID column mapping on the `albums` table row. +_ALBUM_ID_COLUMNS = { + 'spotify': 'spotify_album_id', + 'itunes': 'itunes_album_id', + 'deezer': 'deezer_id', + 'discogs': 'discogs_id', + 'hydrabase': 'soul_id', +} + +# Human-facing label for each source. +SOURCE_LABELS = { + 'spotify': 'Spotify', + 'itunes': 'Apple Music (iTunes)', + 'deezer': 'Deezer', + 'discogs': 'Discogs', + 'hydrabase': 'Hydrabase', +} + + +def _extract_source_ids(album_data: dict) -> Dict[str, str]: + """Pull the per-source album-ID strings off an album row.""" + return { + source: (album_data.get(column) or '') + for source, column in _ALBUM_ID_COLUMNS.items() + } + + +def available_sources_for_album(album_data: dict) -> List[dict]: + """Return the list of metadata sources the user can pick for this + album's reorganize. Every entry has both (a) a stored album ID on + the local row AND (b) an authenticated / configured client on this + SoulSync instance. + + Returns entries in source-priority order (preferred source first). + Each entry is ``{'source': str, 'label': str}``. No API calls — + purely local inspection. + """ + source_ids = _extract_source_ids(album_data) + try: + primary = get_primary_source() + except Exception: + primary = 'deezer' + + out = [] + for source in get_source_priority(primary): + if source not in SUPPORTED_SOURCES: + continue + if not source_ids.get(source): + continue + if get_client_for_source(source) is None: + continue + out.append({ + 'source': source, + 'label': SOURCE_LABELS.get(source, source), + }) + return out + + +def authed_sources() -> List[dict]: + """Return all metadata sources the user has authed/configured on + this SoulSync instance. Doesn't require any album-specific stored + ID — used by the bulk "Reorganize All" picker where each album + has its own ID coverage and we just want to know which sources + are reachable. Returned in priority order.""" + try: + primary = get_primary_source() + except Exception: + primary = 'deezer' + + out = [] + for source in get_source_priority(primary): + if source not in SUPPORTED_SOURCES: + continue + if get_client_for_source(source) is None: + continue + out.append({ + 'source': source, + 'label': SOURCE_LABELS.get(source, source), + }) + return out + + +def _resolve_source(album_data: dict, primary_source: str, strict_source: bool = False): + """Walk the configured source priority looking for the first source + we have an ID for AND that returns a usable tracklist. + + When ``strict_source`` is True, only the caller-provided + ``primary_source`` is tried — no fallback. Used when the user has + explicitly picked a source in the reorganize modal: picking Spotify + means "use Spotify or fail", not "use Spotify and silently fall + back to Deezer". + + Returns ``(source_name, album_meta, tracks_list)`` or ``(None, None, None)``. + """ + source_ids = _extract_source_ids(album_data) + + if strict_source: + sources_to_try = [primary_source] if primary_source else [] + else: + sources_to_try = get_source_priority(primary_source) + + for source in sources_to_try: + sid = source_ids.get(source) or '' + if not sid: + continue + try: + api_album = get_album_for_source(source, sid) + api_tracks = get_album_tracks_for_source(source, sid) + except Exception as e: + logger.warning(f"[Reorganize] {source} lookup raised: {e}") + continue + items = _normalize_album_tracks(api_tracks) + if not items or not api_album: + continue + return source, api_album, items + + return None, None, None + + +# Tokens that indicate a *different recording* of a track — when one +# side of a comparison has these and the other doesn't, the two are NOT +# the same track (e.g. "Bitch Don't Kill My Vibe" vs "Bitch Don't Kill +# My Vibe (Remix)" are different recordings; the tier 4 substring match +# would silently merge them otherwise). "Bonus track" is intentionally +# NOT here — it's a marketing annotation, not a recording difference. +_VERSION_DIFFERENTIATORS = frozenset({ + 'remix', 'remixed', + 'live', 'unplugged', 'concert', + 'acoustic', + 'demo', + 'extended', 'edit', + 'instrumental', 'karaoke', + 'remaster', 'remastered', 'remastering', + 'mono', 'stereo', + 'acapella', 'cappella', + 'cover', + 'reprise', + 'alternate', 'alt', + 'rehearsal', +}) + + +def _differentiators_in(norm_title: str) -> frozenset: + """Return the set of version-differentiator tokens present in a + normalized title. Used by the tier-4 matcher to reject substring + matches across different recordings of the same song.""" + if not norm_title: + return frozenset() + return frozenset(t for t in norm_title.split() if t in _VERSION_DIFFERENTIATORS) + + +def _normalize_title(value) -> str: + """Lowercase + strip cosmetic punctuation and treat brackets / dashes + / slashes as word separators so the same track named slightly + differently across providers and user libraries still matches. + + Examples that should normalize equal: + + - ``Bitch, Don't Kill My Vibe - Remix`` ↔ ``Bitch, Don't Kill My Vibe (Remix)`` + - ``Don't Stop Believin'`` ↔ ``Don’t Stop Believin’`` + - ``Swimming Pools (Drank) - Extended Version`` + ↔ ``Swimming Pools (Drank) (Extended Version)`` + """ + if value is None: + return '' + out = str(value).strip().lower() + # Strip characters that don't carry meaning across providers. + for ch in ('"', "'", '‘', '’', '“', '”', '.', ',', '!', '?', + '(', ')', '[', ']', '{', '}'): + out = out.replace(ch, '') + # Treat separators as whitespace so "foo - bar" and "foo (bar)" align. + for ch in ('-', '–', '—', ':', '/', '\\'): + out = out.replace(ch, ' ') + return ' '.join(out.split()) + + +# Title-match scoring grid. Each component's weight was picked to +# satisfy these design rules: +# +# 1. EXACT title alone is enough to win. +# 2. SUBSTRING at the high-confidence floor (≥0.6) is enough to win. +# 3. SUBSTRING at the lower with-tn-match floor (≥0.3) needs the +# track_number bonus to win — track_number provides the missing +# confidence. +# 4. TRACK-NUMBER alone is NOT enough — never falls through to a +# blind track-number lookup on multi-disc albums (that's the +# bug that mis-routed winecountrygames's bonus tracks). +# 5. Different version-differentiator tokens (Remix vs no-remix) +# hard-reject before scoring (see `_score_candidate`). +# +# Worked examples (with threshold = 50): +# +# exact title + tn match 100 + 20 = 120 → match +# exact title alone 100 = 100 → match +# substring ratio 1.0 (no tn match) 50 + 40 = 90 → match +# substring ratio 0.6 (no tn match) 50 + 0 = 50 → match +# substring ratio 0.5 (no tn match) 0 = 0 → no match +# substring ratio 0.45 + tn match 40 + 20 = 60 → match +# substring ratio 0.28 + tn match 0 + 20 = 20 → no match +# (Real vs "Real Real Real") +# track_number alone (no title signal) 0 + 20 = 20 → no match +# different version diffs (any inputs) hard-reject → 0 +# +# Weights are deliberately spaced so each gate is well-clear of the +# threshold; small ratio adjustments don't flip a borderline case +# unexpectedly. + +_MATCH_SCORE_THRESHOLD = 50 + +_W_EXACT_TITLE = 100 +_W_TRACK_NUMBER = 20 + +# Standalone substring (no tn match required): floor + scaled bonus. +# At ratio = floor: contribute base only. At ratio = 1.0: contribute +# base + range. Linear in between. +_W_SUBSTRING_BASE_STANDALONE = 50 +_W_SUBSTRING_RATIO_RANGE = 40 +_SUBSTRING_RATIO_FLOOR_STANDALONE = 0.6 + +# With-tn-match substring: lower floor (0.3) but slightly reduced +# base (40) so this path never beats a standalone high-ratio match +# on equal-tn ties. +_W_SUBSTRING_BASE_WITH_TN = 40 +_SUBSTRING_RATIO_FLOOR_WITH_TN = 0.3 + + +def _score_candidate( + norm_local: str, + local_tn: Optional[int], + local_diffs: frozenset, + api_norm: str, + api_tn: Optional[int], +) -> int: + """Score a single API candidate against the local track. Higher + means more confident match; 0 means no usable signal. The orchestrator + picks the highest-scoring candidate above + :data:`_MATCH_SCORE_THRESHOLD` and treats sub-threshold tracks as + unmatched (the "trust the source — if it doesn't have the track, + skip it" design policy). + + Components: + + - **Exact normalized-title match** is the strongest signal — usually + enough on its own, especially because local titles SoulSync wrote + should already match the source's text after normalization. + - **Substring containment** with a length-ratio guard handles + annotation drift like ``"The Recipe - Bonus Track"`` (local) + matching ``"The Recipe"`` (API). The ratio bonus rewards more + specific matches, so longer common prefixes win over shorter ones. + - **Track-number agreement** is a tiebreaker, never enough alone + (track_number-only would mis-route on multi-disc). + - **Version-differentiator mismatch** is a hard reject — if local + has ``Remix`` and API doesn't (or vice versa), they're different + recordings, not annotation drift. Returns 0 unconditionally. + """ + if not norm_local or not api_norm: + return 0 + + # Hard reject: version differentiators must agree exactly. ``Remix`` + # vs no-remix means different recordings, regardless of how + # otherwise-similar the titles are. + if _differentiators_in(api_norm) != local_diffs: + return 0 + + score = 0 + tn_match = local_tn is not None and api_tn == local_tn + + if api_norm == norm_local: + score += _W_EXACT_TITLE + else: + if api_norm in norm_local: + ratio = len(api_norm) / max(len(norm_local), 1) + elif norm_local in api_norm: + ratio = len(norm_local) / max(len(api_norm), 1) + else: + ratio = 0.0 + if ratio >= _SUBSTRING_RATIO_FLOOR_STANDALONE: + # Strong substring — credit regardless of tn agreement. + normalized = ( + (ratio - _SUBSTRING_RATIO_FLOOR_STANDALONE) + / (1.0 - _SUBSTRING_RATIO_FLOOR_STANDALONE) + ) + score += _W_SUBSTRING_BASE_STANDALONE + int(normalized * _W_SUBSTRING_RATIO_RANGE) + elif tn_match and ratio >= _SUBSTRING_RATIO_FLOOR_WITH_TN: + # Weaker substring (e.g., "the recipe" in "the recipe bonus + # track" at ratio 0.45) — accept ONLY because track_number + # also matches, and at slightly reduced base score. + score += _W_SUBSTRING_BASE_WITH_TN + + if tn_match: + score += _W_TRACK_NUMBER + + return score + + +def _prenormalize_api_tracks(api_tracks: List[dict]) -> List[tuple]: + """Compute ``(item, normalized_title, parsed_track_number)`` once + per API track so the matcher doesn't redo this work on every local + track. Callers that match many local tracks against the same API + list (the orchestrator's per-album loop) should hold this list and + pass it to :func:`_find_api_track`. + + For a 17-track local library matched against a 22-track API list, + avoiding re-normalization saves 17×22 = 374 normalize calls per + album reorganize.""" + out = [] + for item in api_tracks: + api_norm = _normalize_title(item.get('name') or item.get('title')) + try: + api_tn = int(item.get('track_number')) if item.get('track_number') is not None else None + except (TypeError, ValueError): + api_tn = None + out.append((item, api_norm, api_tn)) + return out + + +def _find_api_track(api_tracks, db_title: str, db_track_number) -> Optional[dict]: + """Find the API track that corresponds to a given local track row. + + ``api_tracks`` may be either a raw list of API dicts (will be + normalized internally) OR a list of pre-normalized 3-tuples from + :func:`_prenormalize_api_tracks`. The orchestrator uses the + pre-normalized form to avoid O(n*m) normalization calls; tests + use the raw list for convenience. + + Local rows carry (title, track_number) but NOT disc_number. + Multi-disc albums repeat track_numbers across discs, so a + track_number-only join would collapse the mapping. Title is the + natural disambiguator (each disc's track 1 has a different title), + but local titles drift from API titles in predictable ways: + trailing ``- Bonus Track`` annotations, ``- Remix`` vs ``(Remix)``, + etc. + + Implementation: each candidate is scored by :func:`_score_candidate`; + the highest-scoring one above :data:`_MATCH_SCORE_THRESHOLD` wins. + If nothing clears the threshold the source genuinely doesn't have a + plausible match and we return ``None`` — the orchestrator surfaces + that as ``"not in tracklist, left in place"`` rather than silently + mis-routing. + """ + norm_local = _normalize_title(db_title) + if not norm_local: + return None + try: + tn = int(db_track_number) if db_track_number is not None else None + except (TypeError, ValueError): + tn = None + local_diffs = _differentiators_in(norm_local) + + # Accept either pre-normalized candidates or raw API dicts. + if api_tracks and isinstance(api_tracks[0], tuple): + candidates = api_tracks # type: ignore[assignment] + else: + candidates = _prenormalize_api_tracks(api_tracks) # type: ignore[arg-type] + + best_item: Optional[dict] = None + best_score = 0 + best_tn_match = False + + for item, api_norm, api_tn in candidates: + score = _score_candidate(norm_local, tn, local_diffs, api_norm, api_tn) + if score < _MATCH_SCORE_THRESHOLD: + continue + tn_match = tn is not None and api_tn == tn + if score > best_score or (score == best_score and tn_match and not best_tn_match): + best_item = item + best_score = score + best_tn_match = tn_match + + return best_item + + +def load_album_and_tracks(db, album_id): + """Load the album row + all its track rows from the local DB. + + Returns ``(album_dict | None, tracks_list)``. ``album_dict`` is None + when the album doesn't exist; tracks_list is empty when the album + has no tracks. The caller decides what status to surface for each + state. + """ + conn = None + try: + conn = db._get_connection() + cursor = conn.cursor() + cursor.execute( + """ + SELECT al.*, ar.name as artist_name + FROM albums al + JOIN artists ar ON al.artist_id = ar.id + WHERE al.id = ? + """, + (str(album_id),), + ) + album_row = cursor.fetchone() + if not album_row: + return None, [] + album_data = dict(album_row) + + cursor.execute( + """ + SELECT t.*, ar.name as artist_name + FROM tracks t + JOIN artists ar ON t.artist_id = ar.id + WHERE t.album_id = ? + ORDER BY t.track_number + """, + (str(album_id),), + ) + tracks = [dict(r) for r in cursor.fetchall()] + return album_data, tracks + finally: + if conn is not None: + try: + conn.close() + except Exception: + pass + + +def plan_album_reorganize( + album_data: dict, + tracks: List[dict], + primary_source: Optional[str] = None, + strict_source: bool = False, +) -> dict: + """Compute the per-track plan for an album reorganize without doing + any file IO. Both the actual reorganize orchestrator and the preview + endpoint share this so the preview is guaranteed to match what would + happen on apply. + + Returns: + ``{'status': 'planned' | 'no_source_id' | 'no_tracks', + 'source': str | None, + 'api_album': dict | None, + 'total_discs': int, + 'items': [{'track': dict, 'api_track': dict | None, + 'matched': bool, 'reason': str | None}, ...]}`` + + Per-track behavior matches the orchestrator exactly: + - Match by `(normalized_title, track_number)`, then title alone, then + track_number alone. + - Tracks with no match are reported with `matched=False` and a reason. + - `disc_number` for each track comes from its matched API entry; if + unmatched, `api_track is None` and the caller decides what to do. + """ + if not tracks: + return { + 'status': 'no_tracks', 'source': None, 'api_album': None, + 'total_discs': 1, 'items': [], + } + + if primary_source is None: + try: + primary_source = get_primary_source() + except Exception: + primary_source = 'deezer' + + source, api_album, api_tracks = _resolve_source( + album_data, primary_source, strict_source=strict_source + ) + if not source: + reason = ( + f"Source '{primary_source}' has no usable tracklist for this album" + if strict_source else + "No metadata source ID for this album" + ) + return { + 'status': 'no_source_id', 'source': None, 'api_album': None, + 'total_discs': 1, + 'items': [{ + 'track': t, 'api_track': None, 'matched': False, + 'reason': reason, + } for t in tracks], + } + + total_discs = max( + (int(item.get('disc_number') or 1) for item in api_tracks), + default=1, + ) + + # Pre-normalize once so the matcher doesn't redo the work per track. + prenormalized = _prenormalize_api_tracks(api_tracks) + items = [] + for track in tracks: + api_track = _find_api_track(prenormalized, track.get('title', ''), track.get('track_number')) + if api_track is None: + items.append({ + 'track': track, 'api_track': None, 'matched': False, + 'reason': f"No matching track in {source} tracklist (likely a bonus / non-canonical track)", + }) + else: + items.append({ + 'track': track, 'api_track': api_track, 'matched': True, + 'reason': None, + }) + + return { + 'status': 'planned', + 'source': source, + 'api_album': api_album, + 'total_discs': total_discs, + 'items': items, + } + + +def _build_post_process_context( + api_album: dict, + api_track: dict, + artist_name: str, + album_title: str, + total_discs: int, +) -> dict: + """Build the same shape `import_album_process` builds so post-process + treats this exactly like a fresh download with full Spotify-style + metadata in hand.""" + track_number = int(api_track.get('track_number') or 1) + disc_number = int(api_track.get('disc_number') or 1) + track_artists = api_track.get('artists') or [artist_name] + normalized_artists = [ + ({'name': a} if isinstance(a, str) else a) for a in track_artists + ] + + api_album_id = api_album.get('id') or api_album.get('album_id') or '' + api_album_name = api_album.get('name') or api_album.get('title') or album_title + api_album_release = ( + api_album.get('release_date') + or api_album.get('releaseDate') + or '' + ) + api_album_total_tracks = ( + api_album.get('total_tracks') + or api_album.get('totalTracks') + or 0 + ) + # Spotify shape: {'images': [{'url': ...}, ...]}. + # Deezer shape: {'image_url': '...'}. + api_album_image = api_album.get('image_url') or '' + if not api_album_image: + images = api_album.get('images') + if isinstance(images, list) and images: + first = images[0] + if isinstance(first, dict): + api_album_image = first.get('url') or '' + + track_name = api_track.get('name') or api_track.get('title') or '' + + return { + 'spotify_artist': { + 'name': artist_name, + 'id': '', + 'genres': [], + }, + 'spotify_album': { + 'id': api_album_id, + 'name': api_album_name, + 'release_date': api_album_release, + 'total_tracks': api_album_total_tracks, + 'total_discs': total_discs, + 'image_url': api_album_image, + }, + 'track_info': { + 'name': track_name, + 'id': api_track.get('id', ''), + 'track_number': track_number, + 'disc_number': disc_number, + 'duration_ms': api_track.get('duration_ms', 0), + 'artists': normalized_artists, + 'uri': api_track.get('uri', ''), + }, + 'original_search_result': { + 'title': track_name, + 'artist': artist_name, + 'album': api_album_name, + 'track_number': track_number, + 'disc_number': disc_number, + 'spotify_clean_title': track_name, + 'spotify_clean_album': api_album_name, + 'artists': normalized_artists, + }, + 'is_album_download': True, + 'has_clean_spotify_data': True, + 'has_full_spotify_metadata': True, + } + + +def preview_album_reorganize( + *, + album_id: str, + db, + transfer_dir: str, + resolve_file_path_fn: Callable[[Optional[str]], Optional[str]], + build_final_path_fn: Callable, + primary_source: Optional[str] = None, + strict_source: bool = False, +) -> dict: + """Compute the planned destination paths for a reorganize WITHOUT + moving any files. The preview UI uses this to show users what the + "Apply" run would do. + + Critically: the destination per track comes from + ``build_final_path_fn(context, spotify_artist, None, file_ext)`` — + the same shared helper post-processing uses. So the preview is + guaranteed to match what the orchestrator would actually produce. + + Args: + album_id: Library album ID. + db: Database object exposing ``_get_connection()``. + transfer_dir: Configured transfer directory (for trimming the + display-relative current-path string). + resolve_file_path_fn: Resolves a DB-stored file path to the + actual on-disk path (or ``None`` if missing). + build_final_path_fn: ``_build_final_path_for_track`` from + web_server. Signature is + ``(context, spotify_artist, album_info_or_none, file_ext) -> (path, ok)``. + Injected so this module stays Flask-free. + primary_source: Optional override for the configured primary + source. + + Returns: + ``{ + 'success': bool, + 'status': str, # 'planned' | 'no_album' | 'no_tracks' | 'no_source_id' + 'source': str | None, + 'album': str, + 'artist': str, + 'transfer_dir': str, + 'tracks': [ + {'track_id', 'title', 'track_number', 'current_path', + 'new_path', 'file_exists', 'unchanged', 'collision', + 'matched', 'reason', 'disc_number'}, + ... + ], + }`` + """ + album_data, tracks = load_album_and_tracks(db, album_id) + if album_data is None: + return {'success': False, 'status': 'no_album', 'tracks': []} + + if not tracks: + return { + 'success': False, 'status': 'no_tracks', + 'album': album_data.get('title', ''), + 'artist': album_data.get('artist_name', ''), + 'tracks': [], + } + + plan = plan_album_reorganize( + album_data, tracks, + primary_source=primary_source, strict_source=strict_source, + ) + artist_name = album_data.get('artist_name') or 'Unknown Artist' + album_title = album_data.get('title') or 'Unknown Album' + + common = { + 'album': album_title, + 'artist': artist_name, + 'transfer_dir': transfer_dir, + 'source': plan['source'], + } + + if plan['status'] == 'no_source_id': + return { + 'success': False, 'status': 'no_source_id', + **common, + 'tracks': [{ + 'track_id': t.get('id'), + 'title': t.get('title', ''), + 'track_number': t.get('track_number', 0), + 'current_path': t.get('file_path', ''), + 'new_path': '', + 'file_exists': False, 'unchanged': False, 'collision': False, + 'matched': False, + 'reason': 'No metadata source ID — run enrichment first', + 'disc_number': None, + } for t in tracks], + } + + total_discs = plan['total_discs'] + api_album = plan['api_album'] or {} + preview_tracks = [] + + for plan_item in plan['items']: + track = plan_item['track'] + title = track.get('title', '') + db_path = track.get('file_path') + resolved = resolve_file_path_fn(db_path) if db_path else None + file_ext = os.path.splitext(resolved or db_path or '.flac')[1] or '.flac' + + item = { + 'track_id': track.get('id'), + 'title': title, + 'track_number': track.get('track_number', 0), + 'current_path': _trim_to_transfer(db_path, resolved, transfer_dir), + 'new_path': '', + 'file_exists': resolved is not None, + 'unchanged': False, + 'collision': False, + 'matched': plan_item['matched'], + 'reason': plan_item.get('reason'), + 'disc_number': None, + } + + if not plan_item['matched']: + preview_tracks.append(item) + continue + + api_track = plan_item['api_track'] + item['disc_number'] = int(api_track.get('disc_number') or 1) + # Build the same context the orchestrator builds so the path + # builder produces the same destination it would on apply. + context = _build_post_process_context( + api_album, api_track, artist_name, album_title, total_discs + ) + # `_build_final_path_for_track` switches between ALBUM and SINGLE + # modes based on `album_info.get('is_album')` — must be passed, + # not None, otherwise multi-disc deluxes degrade to single-track + # folders (the exact bug winecountrygames hit). + album_info = _build_album_info(context) + try: + spotify_artist = context['spotify_artist'] + new_full, _ok = build_final_path_fn(context, spotify_artist, album_info, file_ext) + item['new_path'] = ( + os.path.relpath(new_full, transfer_dir) + if transfer_dir and new_full and new_full.startswith(transfer_dir) + else new_full or '' + ) + if resolved and new_full and os.path.normpath(resolved) == os.path.normpath(new_full): + item['unchanged'] = True + except Exception as e: + item['reason'] = f"Couldn't compute destination path: {e}" + + preview_tracks.append(item) + + # Collision detection: multiple matched tracks mapping to the same + # destination would overwrite each other on apply. + seen = {} + for it in preview_tracks: + if not it['matched'] or it['unchanged'] or not it['new_path']: + continue + norm = os.path.normpath(it['new_path']) + if norm in seen: + it['collision'] = True + seen[norm]['collision'] = True + else: + seen[norm] = it + + return { + 'success': True, 'status': 'planned', + **common, + 'tracks': preview_tracks, + } + + +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.""" + if resolved and transfer_dir and resolved.startswith(transfer_dir): + return resolved[len(transfer_dir):].lstrip(os.sep).lstrip('/') + return db_path or 'No file' + + +def _build_album_info(context: dict) -> dict: + """Build the ``album_info`` dict that ``_build_final_path_for_track`` + consumes to enter ALBUM MODE. Without this (passing None) the path + builder falls through to SINGLE MODE and produces per-track folders + named after each track title — the exact bug we're fixing. + + Mirrors the shape the download path produces at write time. + """ + spotify_album = context.get('spotify_album', {}) or {} + track_info = context.get('track_info', {}) or {} + return { + 'is_album': True, + 'album_name': spotify_album.get('name') or 'Unknown Album', + 'clean_track_name': track_info.get('name') or 'Unknown Track', + 'track_number': track_info.get('track_number') or 1, + 'disc_number': track_info.get('disc_number') or 1, + 'album_image_url': spotify_album.get('image_url') or '', + 'spotify_album_id': spotify_album.get('id') or '', + } + + +@dataclass +class _RunContext: + """Bundles all state + injected dependencies a single + ``_process_one_track`` call needs. + + Hoisted out of orchestrator-local closures so the per-track + helpers can be unit-tested directly with a fake ctx, and so a + stack trace into a failing helper is intelligible (closures + captured 16+ values, none of which were visible in tracebacks). + + Thread-safety contract — read this before adding new fields: + + - ``state_lock`` MUST be held when mutating any of the + lock-protected fields below. The provided ``record_error`` + method already takes the lock; direct mutation outside that + method is the only place where future contributors might + forget. Add new mutable shared state with the same discipline. + + Lock-protected fields (mutate only inside ``state_lock``): + + summary dict — counts and errors list + src_dirs_touched set — populated by `_finalize_track` + dst_dirs_touched set — populated by `_finalize_track` + + Read-only after construction (safe to read without locking): + + album_id, api_album, artist_name, album_title, total_discs, + staging_album_dir, resolve_file_path_fn, post_process_fn, + update_track_path_fn, on_progress, stop_check, state_lock + + Side-effecting methods that take the lock internally: + + record_error() — records a per-track failure + emit() — fires on_progress callback (no lock; + assumes caller holds it when also + passing summary fields, which the + record_error and orchestrator-success + paths both do) + """ + album_id: str + api_album: dict + artist_name: str + album_title: str + total_discs: int + staging_album_dir: str + state_lock: threading.Lock # required to mutate lock-protected fields + summary: dict # LOCK-PROTECTED + src_dirs_touched: Set[str] # LOCK-PROTECTED + dst_dirs_touched: Set[str] # LOCK-PROTECTED + resolve_file_path_fn: Callable[[Optional[str]], Optional[str]] + post_process_fn: Callable[[str, dict, str], None] + update_track_path_fn: Optional[Callable[[Any, str], None]] = None + on_progress: Optional[Callable[[dict], None]] = None + stop_check: Optional[Callable[[], bool]] = None + + def emit(self, **updates) -> None: + """Fire the progress callback. Caller is responsible for + holding ``state_lock`` when the updates payload includes + snapshots of lock-protected fields (so the snapshot is + coherent). Currently always called from inside the lock by + ``record_error`` and the orchestrator's success path.""" + if self.on_progress is None: + return + try: + self.on_progress(updates) + except Exception: + pass + + def record_error(self, track_id, title, message, kind: str = 'skipped') -> None: + with self.state_lock: + self.summary['errors'].append({ + 'track_id': track_id, + 'title': title, + 'error': message, + }) + self.summary[kind] += 1 + self.emit(**{ + kind: self.summary[kind], + 'errors': list(self.summary['errors']), + 'processed': ( + self.summary['moved'] + + self.summary['skipped'] + + self.summary['failed'] + ), + }) + + +def _stage_track(ctx: _RunContext, track_id, title, resolved_src) -> Optional[str]: + """Stage a copy of ``resolved_src`` into a per-track UUID + subdirectory under ``ctx.staging_album_dir``. + + Per-track subdirs are required for concurrent safety: post-process + calls ``_cleanup_empty_directories`` after each move, which walks + UP from the source file removing empty dirs. With a shared + ``staging_album_dir`` that walk would race with other workers' + in-flight ``makedirs``/``copy2`` calls — worker A finishing could + nuke the dir between worker B's ``makedirs`` and ``copy2``, + causing intermittent ``[WinError 3]`` / ``ENOENT`` failures. + + With per-track subdirs: + + - Worker A's cleanup walks: per-track subdir (empty after move → + removed) → ``staging_album_dir`` (still has other workers' + subdirs → not empty → walk stops). ✓ + - Worker B's stage-in: makedirs its OWN subdir, copies into + it. No interference from worker A. ✓ + """ + worker_dir = os.path.join(ctx.staging_album_dir, uuid.uuid4().hex[:8]) + try: + os.makedirs(worker_dir, exist_ok=True) + except OSError as mk_err: + ctx.record_error(track_id, title, + f"Couldn't create staging subdirectory: {mk_err}", + kind='failed') + return None + staging_file = os.path.join(worker_dir, os.path.basename(resolved_src)) + try: + shutil.copy2(resolved_src, staging_file) + except OSError as copy_err: + ctx.record_error(track_id, title, + f"Couldn't copy to staging: {copy_err}", + kind='failed') + return None + return staging_file + + +def _run_post_process_for_track(ctx: _RunContext, track_id, title, api_track, staging_file) -> Optional[str]: + """Build the per-track context, hand it to post-processing, and + return the final on-disk path it produced. Returns None on any + failure (exception, AcoustID rejection, internal skip); the caller + leaves the original file alone.""" + context = _build_post_process_context( + ctx.api_album, api_track, ctx.artist_name, ctx.album_title, ctx.total_discs + ) + context_key = f"reorganize_{ctx.album_id}_{track_id}_{uuid.uuid4().hex[:8]}" + try: + ctx.post_process_fn(context_key, context, staging_file) + except Exception as pp_err: + ctx.record_error(track_id, title, + f"Post-processing failed: {pp_err}", + kind='failed') + return None + new_path = context.get('_final_processed_path') + if not new_path or not os.path.exists(new_path): + ctx.record_error(track_id, title, + 'Post-processing did not produce a final file ' + '(AcoustID rejection, quarantine, or skip).', + kind='failed') + return None + return new_path + + +def _finalize_track(ctx: _RunContext, track_id, resolved_src, new_path) -> bool: + """Update the DB row, then remove the original (in that order — DB + failure leaves the file at both locations, recoverable by library + scan; the reverse would orphan the row). Records src/dst dirs for + end-of-run cleanup, deletes per-track sidecars. + + Returns ``True`` if the track is fully landed (DB row points to + ``new_path`` AND the original is dealt with), ``False`` if DB + update failed. Caller MUST treat False as a failure for counting + purposes — the file is at both locations, the DB still points to + the old path, and counting it as "moved" overstates how many + tracks the user can actually find via the UI.""" + if ctx.update_track_path_fn: + try: + ctx.update_track_path_fn(track_id, new_path) + except Exception as db_err: + logger.warning( + f"[Reorganize] DB path update failed for {track_id}: {db_err} " + f"— leaving original at {resolved_src} so the library scan can recover." + ) + return False + if os.path.normpath(resolved_src) == os.path.normpath(new_path): + return True # in-place edit; DB already correct, nothing to remove + with ctx.state_lock: + ctx.src_dirs_touched.add(os.path.dirname(resolved_src)) + ctx.dst_dirs_touched.add(os.path.dirname(new_path)) + try: + os.remove(resolved_src) + except OSError as rm_err: + logger.warning(f"[Reorganize] Couldn't remove original {resolved_src}: {rm_err}") + _delete_track_sidecars(resolved_src) + return True + + +def _process_one_track(ctx: _RunContext, plan_item: dict) -> None: + """Process a single plan item end-to-end. Safe to call concurrently + from multiple workers — all shared-state mutations go through + ``ctx.state_lock`` (via ``record_error`` and ``_finalize_track``).""" + if ctx.stop_check and ctx.stop_check(): + return + track = plan_item['track'] + title = track.get('title', 'Unknown') + track_id = track.get('id') + ctx.emit(current_track=title) + + if not plan_item['matched']: + ctx.record_error(track_id, title, + plan_item.get('reason') or 'No matching API track') + return + + db_path = track.get('file_path') + resolved_src = ctx.resolve_file_path_fn(db_path) if db_path else None + if not resolved_src: + ctx.record_error(track_id, title, + f"File not found on disk — DB path: {db_path or '(empty)'}") + return + + staging_file = _stage_track(ctx, track_id, title, resolved_src) + if staging_file is None: + return + + new_path = _run_post_process_for_track(ctx, track_id, title, plan_item['api_track'], staging_file) + if new_path is None: + return + + finalized = _finalize_track(ctx, track_id, resolved_src, new_path) + if not finalized: + # File landed at new_path but DB row + original-removal didn't. + # User can still find the track (library scan will re-index from + # new_path), but we can't honestly count it as "moved" — that + # would overstate how many tracks the UI knows are at their new + # locations. Surfacing as failed lets the user see something + # needs attention (per kettui's PR #377 review). + ctx.record_error( + track_id, title, + 'Track landed at new location but DB update failed — ' + 'file is at both old and new paths until library scan re-indexes.', + kind='failed', + ) + return + + with ctx.state_lock: + ctx.summary['moved'] += 1 + ctx.emit( + moved=ctx.summary['moved'], + processed=ctx.summary['moved'] + ctx.summary['skipped'] + ctx.summary['failed'], + ) + + +def reorganize_album( + *, + album_id: str, + db, + staging_root: str, + resolve_file_path_fn: Callable[[Optional[str]], Optional[str]], + post_process_fn: Callable[[str, dict, str], None], + update_track_path_fn: Optional[Callable[[object, str], None]] = None, + cleanup_empty_dir_fn: Optional[Callable[[str], None]] = None, + transfer_dir: Optional[str] = None, + on_progress: Optional[Callable[[dict], None]] = None, + primary_source: Optional[str] = None, + strict_source: bool = False, + stop_check: Optional[Callable[[], bool]] = None, +) -> dict: + """Run a single album through the post-processing pipeline. + + See module docstring for the rationale. Dependencies (file + resolution, post-processing, DB-path update, empty-dir cleanup) + are injected so the orchestrator stays in ``core/`` and is unit + testable without spinning up the Flask app. + + Args: + album_id: Library album ID. + db: Database object exposing ``_get_connection()``. + staging_root: Root staging directory under the user's download + path. A per-album subfolder is created beneath it; the + whole subfolder is removed at the end of the run. + resolve_file_path_fn: Resolves a DB-stored file path to the + actual on-disk path (or ``None`` if missing). Injected + because the resolution logic lives in ``web_server``. + post_process_fn: ``_post_process_matched_download``. Must set + ``context['_final_processed_path']`` on success. + update_track_path_fn: Called as + ``update_track_path_fn(track_id, new_path)`` after each + successful post-process to update the DB row. ``None`` to + skip (e.g. in tests). + cleanup_empty_dir_fn: Called with each source directory we + emptied so the caller can prune empty parents. ``None`` to + skip. + on_progress: Optional callback for live status updates. + Receives a dict with any subset of the standard reorganize + state keys (``current_track``, ``processed``, ``moved``, + ``skipped``, ``failed``, ``errors``). + primary_source: Override for the configured primary source. + Defaults to ``get_primary_source()``. + stop_check: Returns True when the caller wants the reorganize + to abort early (e.g. server shutdown). + + Returns: + Status summary dict with ``status`` ∈ ``{'completed', + 'no_album', 'no_tracks', 'no_source_id'}`` plus per-track + counters. + """ + summary = { + 'status': 'completed', + 'source': None, + 'total': 0, + 'moved': 0, + 'skipped': 0, + 'failed': 0, + 'errors': [], + } + + state_lock = threading.Lock() + + def _emit(**updates): + if on_progress is None: + return + try: + on_progress(updates) + except Exception: + pass + + # Load album + tracks + album_data, tracks = load_album_and_tracks(db, album_id) + if album_data is None: + summary['status'] = 'no_album' + return summary + + if not tracks: + summary['status'] = 'no_tracks' + return summary + + summary['total'] = len(tracks) + _emit(total=len(tracks)) + + # Build the per-track plan (same logic the preview uses). + plan = plan_album_reorganize( + album_data, tracks, + primary_source=primary_source, strict_source=strict_source, + ) + if plan['status'] == 'no_source_id': + summary['status'] = 'no_source_id' + summary['errors'].append({ + 'error': ( + f"No reachable metadata source ID for '{album_data.get('title', '?')}' — " + "run enrichment first to populate at least one of " + "spotify_album_id / itunes_album_id / deezer_id / discogs_id / soul_id." + ), + }) + return summary + + source = plan['source'] + api_album = plan['api_album'] + total_discs = plan['total_discs'] + summary['source'] = source + logger.info( + f"[Reorganize] Album '{album_data.get('title')}' resolved via {source}: " + f"{len(plan['items'])} item(s) planned" + ) + + # Per-album staging dir under the configured download path. Cleaned + # up (best-effort) at the end of the run regardless of outcome. + artist_name = album_data.get('artist_name') or 'Unknown Artist' + album_title = album_data.get('title') or 'Unknown Album' + staging_album_dir = os.path.join( + staging_root, + f"{_safe_filename(artist_name)} - {_safe_filename(album_title)}_{uuid.uuid4().hex[:8]}", + ) + try: + os.makedirs(staging_album_dir, exist_ok=True) + except OSError as e: + summary['status'] = 'setup_failed' + summary['errors'].append({ + 'error': f"Couldn't create staging directory '{staging_album_dir}': {e}", + }) + return summary + + src_dirs_touched: Set[str] = set() + dst_dirs_touched: Set[str] = set() + + ctx = _RunContext( + album_id=str(album_id), + api_album=api_album or {}, + artist_name=artist_name, + album_title=album_title, + total_discs=total_discs, + staging_album_dir=staging_album_dir, + state_lock=state_lock, + summary=summary, + src_dirs_touched=src_dirs_touched, + dst_dirs_touched=dst_dirs_touched, + resolve_file_path_fn=resolve_file_path_fn, + post_process_fn=post_process_fn, + update_track_path_fn=update_track_path_fn, + on_progress=on_progress, + stop_check=stop_check, + ) + + try: + # 3 concurrent workers per album — matches the download-side + # batch worker count. Post-process has its own per-context-key + # lock so concurrent calls don't race on the same file, and + # all shared-state mutations here are inside `state_lock`. + # + # Wait loop with a periodic watchdog: instead of blocking + # indefinitely on `as_completed`, we wake every + # `_WATCHDOG_INTERVAL_SECONDS` so we can react to stop_check + # promptly AND log a warning if any track has been processing + # for longer than `_HUNG_WORKER_THRESHOLD_SECONDS`. We can't + # kill the thread (Python doesn't allow that cleanly), but + # surfacing it lets operators investigate. + with ThreadPoolExecutor( + max_workers=_REORGANIZE_MAX_WORKERS, + thread_name_prefix='Reorganize', + ) as executor: + future_to_item = { + executor.submit(_process_one_track, ctx, item): item + for item in plan['items'] + } + future_started_at = {f: time.monotonic() for f in future_to_item} + pending = set(future_to_item.keys()) + warned_about: Set[Any] = set() + + while pending: + if stop_check and stop_check(): + for f in pending: + f.cancel() + break + + done, pending = wait( + pending, + timeout=_WATCHDOG_INTERVAL_SECONDS, + return_when=FIRST_COMPLETED, + ) + for finished in done: + try: + finished.result() + except Exception as worker_err: + logger.error( + f"[Reorganize] Worker raised: {worker_err}", + exc_info=True, + ) + + # Watchdog pass — log once per stuck future. + now = time.monotonic() + for f in pending: + if f in warned_about: + continue + elapsed = now - future_started_at[f] + if elapsed >= _HUNG_WORKER_THRESHOLD_SECONDS: + item = future_to_item.get(f, {}) + track_title = (item.get('track') or {}).get('title', 'Unknown') + logger.warning( + f"[Reorganize] Worker stuck for {elapsed:.0f}s on track " + f"'{track_title}' — leaving it running, other workers continuing." + ) + warned_about.add(f) + + finally: + # Best-effort cleanup of the staging dir. + try: + if os.path.isdir(staging_album_dir): + shutil.rmtree(staging_album_dir, ignore_errors=True) + except Exception: + pass + + # Best-effort cleanup of source directories. For each touched dir + # that has no audio files left (i.e. every track in this dir was + # successfully moved), delete album-level sidecars (cover.jpg, + # folder.jpg, etc.) so the dir is empty enough for the empty-dir + # pruner to take it. If audio remains (a track failed to move), + # leave everything alone so the user can see what's still there. + for src_dir in src_dirs_touched: + try: + if _has_remaining_audio(src_dir): + continue + _delete_album_sidecars(src_dir) + except Exception: + pass + + if cleanup_empty_dir_fn: + for src_dir in src_dirs_touched: + try: + cleanup_empty_dir_fn(src_dir) + except Exception: + pass + + # Prune empty *destination* siblings — e.g. when a previous + # failed reorganize attempt left ``Artist/Album-Sibling/`` dirs + # behind that we never end up using, OR when a current-run + # post-process created a destination dir then failed AcoustID + # before landing the file. Walk up from any successful + # destination to the artist folder, then prune one level of + # empty children. Bounded depth = safer than recursive sweep. + if transfer_dir and dst_dirs_touched: + artist_dirs = set() + for dst in dst_dirs_touched: + artist = _find_artist_dir(dst, transfer_dir) + if artist: + artist_dirs.add(artist) + for artist_dir in artist_dirs: + _prune_empty_album_dirs(artist_dir) + + return summary + + +def _find_artist_dir(dest_path: str, transfer_dir: str) -> Optional[str]: + """Walk up from ``dest_path`` until the parent equals ``transfer_dir``; + the directory at that point is the artist folder. Returns None if + ``dest_path`` isn't inside ``transfer_dir`` at all.""" + if not transfer_dir: + return None + transfer_norm = os.path.normpath(transfer_dir) + cur = os.path.normpath(dest_path) + while True: + parent = os.path.dirname(cur) + if parent == cur: + return None # filesystem root + if os.path.normpath(parent) == transfer_norm: + return cur + cur = parent + + +def _prune_empty_album_dirs(artist_dir: str) -> None: + """Remove direct subdirectories of ``artist_dir`` that are empty. + Single-level prune: deliberately doesn't recurse — we want to + catch leftover album-sibling folders without aggressively touching + the user's nested directory tree. + + Also walks one level deeper into each album dir to remove empty + Disc-N subfolders that previous runs may have created.""" + if not os.path.isdir(artist_dir): + return + try: + children = list(os.listdir(artist_dir)) + except OSError: + return + for entry in children: + album_path = os.path.join(artist_dir, entry) + if not os.path.isdir(album_path): + continue + # First pass: prune empty Disc-N subfolders inside this album. + try: + for sub in list(os.listdir(album_path)): + disc_path = os.path.join(album_path, sub) + if os.path.isdir(disc_path): + try: + if not os.listdir(disc_path): + os.rmdir(disc_path) + except OSError: + pass + except OSError: + pass + # Then: if the whole album dir is now empty, prune it. + try: + if not os.listdir(album_path): + os.rmdir(album_path) + logger.info(f"[Reorganize] Pruned empty album dir: {album_path}") + except OSError: + pass + + +# Sidecar / cleanup helpers -------------------------------------------------- + +# Sidecars that live alongside ONE audio file (same filename stem). +_TRACK_SIDECAR_EXTS = ('.lrc', '.nfo', '.txt', '.cue', '.json') + +# Sidecars that live at the ALBUM level (one per directory). +_ALBUM_SIDECARS = ( + 'cover.jpg', 'cover.jpeg', 'cover.png', + 'folder.jpg', 'folder.png', + 'front.jpg', 'front.png', + 'album.jpg', 'album.png', + 'artwork.jpg', 'artwork.png', +) + +# Audio extensions used to decide whether a source directory still has +# tracks the user might care about (i.e. a per-track failure left audio +# behind that we shouldn't strip the cover art from). +_AUDIO_EXTS = frozenset( + {'.flac', '.mp3', '.m4a', '.ogg', '.opus', '.wav', '.aac', '.wma', '.mp4'} +) + + +def _delete_track_sidecars(audio_path: str) -> None: + """Delete per-track sidecars (.lrc / .nfo / .txt / .cue / .json) that + sit alongside `audio_path` and share its filename stem. Best-effort — + individual failures are logged at debug and never raised.""" + src_dir = os.path.dirname(audio_path) + stem = os.path.splitext(os.path.basename(audio_path))[0] + for ext in _TRACK_SIDECAR_EXTS: + sidecar = os.path.join(src_dir, stem + ext) + if os.path.isfile(sidecar): + try: + os.remove(sidecar) + except OSError as e: + logger.debug(f"[Reorganize] Couldn't remove sidecar {sidecar}: {e}") + + +def _delete_album_sidecars(src_dir: str) -> None: + """Delete album-level sidecars (cover.jpg, folder.jpg, etc.) from + `src_dir`. Used during end-of-run cleanup when no audio files remain + in the directory. Best-effort — individual failures are debug-logged.""" + for name in _ALBUM_SIDECARS: + sidecar = os.path.join(src_dir, name) + if os.path.isfile(sidecar): + try: + os.remove(sidecar) + except OSError as e: + logger.debug(f"[Reorganize] Couldn't remove album sidecar {sidecar}: {e}") + + +def _has_remaining_audio(directory: str) -> bool: + """Return True if `directory` contains any audio files. Used as the + safety check before stripping album-level sidecars: if a track + failed to move, leave its cover art and friends in place.""" + if not os.path.isdir(directory): + return False + try: + for name in os.listdir(directory): + full = os.path.join(directory, name) + if not os.path.isfile(full): + continue + if os.path.splitext(name)[1].lower() in _AUDIO_EXTS: + return True + except OSError: + return True # Safer to assume "yes, leave it" if we can't check + return False diff --git a/core/metadata_service.py b/core/metadata_service.py index 15387201..e2636d0f 100644 --- a/core/metadata_service.py +++ b/core/metadata_service.py @@ -1783,8 +1783,14 @@ def get_artist_image_url( artist_id: str, source_override: Optional[str] = None, plugin: Optional[str] = None, + artist_name: Optional[str] = None, ) -> Optional[str]: - """Resolve an artist image URL using the configured source priority.""" + """Resolve an artist image URL using the configured source priority. + + `artist_name` is used when the source-of-record doesn't store artist + images (MusicBrainz) — the resolver then searches fallback sources + (iTunes/Deezer) by name for a matching artist and returns their image. + """ if not artist_id: return None @@ -1801,6 +1807,14 @@ def get_artist_image_url( return _get_artist_image_from_source('itunes', artist_id) return None + # MusicBrainz doesn't store artist images directly — use the artist + # name (passed by the frontend) to look up the image on a fallback + # source that does. Without a name we can't resolve. + if source_override == 'musicbrainz': + if not artist_name: + return None + return _lookup_artist_image_by_name(artist_name) + if source_override: return _get_artist_image_from_source(source_override, artist_id) @@ -1812,6 +1826,41 @@ def get_artist_image_url( return None +def _lookup_artist_image_by_name(name: str) -> Optional[str]: + """Look up an artist image by NAME (not MBID) across fallback sources. + Used when the primary source doesn't store artist images (MusicBrainz). + + Tries configured sources in priority order, searches each for the + artist name, and returns the first matching result's image URL. + """ + name = (name or '').strip() + if not name: + return None + + # Skip sources that don't do artist-name search or don't have images. + _SKIP_SOURCES = {'musicbrainz', 'soulseek', 'youtube_videos', 'hydrabase'} + for source in get_source_priority(get_primary_source()): + if source in _SKIP_SOURCES: + continue + client = get_client_for_source(source) + if not client or not hasattr(client, 'search_artists'): + continue + try: + results = client.search_artists(name, limit=1) or [] + if results: + top = results[0] + img = getattr(top, 'image_url', None) or ( + top.get('image_url') if isinstance(top, dict) else None + ) + if img: + return img + except Exception as exc: + logger.debug("Artist image lookup by name failed on %s for %r: %s", + source, name, exc) + continue + return None + + def get_deezer_client(): """Get cached Deezer client. diff --git a/core/musicbrainz_client.py b/core/musicbrainz_client.py index b7b2725b..b152f28f 100644 --- a/core/musicbrainz_client.py +++ b/core/musicbrainz_client.py @@ -44,65 +44,85 @@ def rate_limited(func): class MusicBrainzClient: """Client for interacting with MusicBrainz API""" - + BASE_URL = "https://musicbrainz.org/ws/2" - + # MusicBrainz mandates a meaningful User-Agent with contact info. Falling back + # to a bare name/version risks IP blocking under load — include the project + # URL so MB operators have a way to reach us if we misbehave. + DEFAULT_CONTACT = "https://github.com/Nezreka/SoulSync" + def __init__(self, app_name: str = "SoulSync", app_version: str = "1.0", contact_email: str = ""): """ Initialize MusicBrainz client - + Args: app_name: Name of the application app_version: Version of the application - contact_email: Contact email (optional but recommended) + contact_email: Contact email or URL (defaults to project URL when empty) """ - self.user_agent = f"{app_name}/{app_version}" - if contact_email: - self.user_agent += f" ( {contact_email} )" - + contact = contact_email or self.DEFAULT_CONTACT + self.user_agent = f"{app_name}/{app_version} ( {contact} )" + self.session = requests.Session() self.session.headers.update({ 'User-Agent': self.user_agent, 'Accept': 'application/json' }) - + logger.info(f"MusicBrainz client initialized with user agent: {self.user_agent}") @rate_limited - def search_artist(self, artist_name: str, limit: int = 10) -> List[Dict[str, Any]]: + def search_artist(self, artist_name: str, limit: int = 10, strict: bool = True) -> List[Dict[str, Any]]: """ - Search for artists by name - + Search for artists by name. + Args: artist_name: Name of the artist to search for limit: Maximum number of results to return - + strict: When True (default), builds a phrase-match query against + the `artist` field only — correct for enrichment flows that + already know the exact name. When False, sends a bare query + which MusicBrainz matches against the alias, artist, AND + sortname indexes — the right behavior for user-facing fuzzy + search (finds "Metallica" from typing "metalica", matches + aliased names, etc.). + Returns: - List of artist results with id, name, score, etc. + List of artist results with id, name, score, etc. MusicBrainz + assigns each result a `score` 0-100; the list is pre-sorted + score-descending by the server. """ try: # Escape quotes and backslashes for Lucene query safe_name = artist_name.replace('\\', '\\\\').replace('"', '\\"') - + + if strict: + query = f'artist:"{safe_name}"' + else: + # Bare query hits alias/artist/sortname indexes — much better + # recall for user typing. Still Lucene-escaped via the API's + # query parser. + query = safe_name + params = { - 'query': f'artist:"{safe_name}"', + 'query': query, 'fmt': 'json', 'limit': limit } - + response = self.session.get( f"{self.BASE_URL}/artist", params=params, timeout=10 ) response.raise_for_status() - + data = response.json() artists = data.get('artists', []) - + logger.debug(f"Found {len(artists)} artists for query: {artist_name}") return artists - + except Exception as e: logger.error(f"Error searching for artist '{artist_name}': {e}") return [] @@ -197,6 +217,98 @@ class MusicBrainzClient: logger.error(f"Error searching for recording '{track_name}': {e}") return [] + @rate_limited + def browse_artist_release_groups(self, artist_mbid: str, + release_types: Optional[List[str]] = None, + limit: int = 100, + offset: int = 0) -> List[Dict[str, Any]]: + """Browse release-groups linked to an artist MBID. + + This is the correct MusicBrainz pattern for "give me this artist's + discography" — text-based `/release?query=...` search would look at + release TITLES (matching unrelated releases literally titled after + the artist name), while browse walks the artist→release-group link + directly. + + Args: + artist_mbid: Artist's MusicBrainz ID + release_types: Filter by primary type — any of 'album', 'single', + 'ep', 'compilation', 'soundtrack', 'live', etc. Combined with + `|` per MB spec, e.g. `['album', 'ep']` → `type=album|ep`. + None returns all types. + limit: 1-100 (MB hard cap) + offset: Pagination offset + + Returns: + List of release-group dicts. Each has `id`, `title`, `primary-type`, + `secondary-types`, `first-release-date`, `disambiguation`. + """ + try: + params = {'artist': artist_mbid, 'fmt': 'json', 'limit': min(limit, 100), 'offset': offset} + if release_types: + params['type'] = '|'.join(release_types) + + response = self.session.get( + f"{self.BASE_URL}/release-group", + params=params, + timeout=10 + ) + response.raise_for_status() + + data = response.json() + rgs = data.get('release-groups', []) + logger.debug(f"Browsed {len(rgs)} release-groups for artist {artist_mbid}") + return rgs + except Exception as e: + logger.error(f"Error browsing release-groups for artist {artist_mbid}: {e}") + return [] + + @rate_limited + def search_recordings_by_artist_mbid(self, artist_mbid: str, + limit: int = 100) -> List[Dict[str, Any]]: + """Search for recordings linked to an artist via Lucene `arid:` query. + + This is the counterpart to `browse_artist_release_groups` for tracks. + The proper "browse" endpoint (`/recording?artist=`) rejects + `inc=releases`, so we can't get album context per recording from + browse — only the track title/length/MBID. Without release info the + user would see tracks with no album, which is useless. + + The search endpoint with a fielded `arid:` query returns + recordings with the `releases` array already embedded (including + release-group, date, and media info), which is what the search-tab + UI needs. + + Args: + artist_mbid: Artist's MusicBrainz ID + limit: 1-100 (MB hard cap) + + Returns: + List of recording dicts with `id`, `title`, `length`, `score`, + `artist-credit`, and `releases` (each with release-group + date). + """ + try: + params = { + 'query': f'arid:{artist_mbid}', + 'fmt': 'json', + 'limit': min(limit, 100), + } + + response = self.session.get( + f"{self.BASE_URL}/recording", + params=params, + timeout=10 + ) + response.raise_for_status() + + data = response.json() + recs = data.get('recordings', []) + logger.debug(f"Found {len(recs)} recordings for artist {artist_mbid}") + return recs + except Exception as e: + logger.error(f"Error searching recordings for artist {artist_mbid}: {e}") + return [] + @rate_limited def get_artist(self, mbid: str, includes: Optional[List[str]] = None) -> Optional[Dict[str, Any]]: """ @@ -257,6 +369,38 @@ class MusicBrainzClient: logger.error(f"Error fetching release {mbid}: {e}") return None + @rate_limited + def get_release_group(self, mbid: str, includes: Optional[List[str]] = None) -> Optional[Dict[str, Any]]: + """Get full release-group details by MBID. + + Release-groups are the 'canonical album' entity in MusicBrainz — + they group every edition/reissue/region-specific release of the + same logical album under one MBID. Use `inc=releases` to list the + individual releases this group contains (each with its own + tracklist); use `inc=artist-credits` for artist info. + + Args: + mbid: Release-group's MusicBrainz ID + includes: Optional list, e.g. ['releases', 'artist-credits'] + + Returns: + Release-group data or None if not found. + """ + try: + params = {'fmt': 'json'} + if includes: + params['inc'] = '+'.join(includes) + response = self.session.get( + f"{self.BASE_URL}/release-group/{mbid}", + params=params, + timeout=10 + ) + response.raise_for_status() + return response.json() + except Exception as e: + logger.error(f"Error fetching release-group {mbid}: {e}") + return None + @rate_limited def get_recording(self, mbid: str, includes: Optional[List[str]] = None) -> Optional[Dict[str, Any]]: """ diff --git a/core/musicbrainz_search.py b/core/musicbrainz_search.py index f9d93aab..965317be 100644 --- a/core/musicbrainz_search.py +++ b/core/musicbrainz_search.py @@ -6,7 +6,7 @@ enabling MusicBrainz as a search tab in enhanced and global search. Album art is fetched from Cover Art Archive (free, linked by release MBID). """ -import requests +import threading from dataclasses import dataclass from typing import Any, Dict, List, Optional @@ -59,29 +59,24 @@ class Album: external_urls: Optional[Dict[str, str]] = None -def _get_cover_art_url(release_mbid: str) -> Optional[str]: - """Fetch album art URL from Cover Art Archive. Returns None if not available.""" - try: - # CAA redirects to the actual image URL — just get the front image URL - url = f"{COVER_ART_ARCHIVE_URL}/release/{release_mbid}/front-250" - resp = requests.head(url, timeout=3, allow_redirects=True) - if resp.status_code == 200: - return resp.url # The redirect target is the actual image - return None - except Exception: - return None +def _cover_art_url(mbid: str, scope: str = 'release') -> Optional[str]: + """Build a Cover Art Archive URL without hitting the network. + CAA URLs are deterministic from the MBID: the endpoint either 307-redirects + to the image or returns 404. Previously we fired `requests.head(timeout=3)` + per result during search — 10 results × 3s worst-case = up to 30s of + blocking HEAD calls before a search returned. The frontend's tag + handles the 404 case via onerror fallback, so the HEAD round-trip was + pure overhead. -def _get_release_group_art(release_group_mbid: str) -> Optional[str]: - """Fetch album art from release group (covers all editions).""" - try: - url = f"{COVER_ART_ARCHIVE_URL}/release-group/{release_group_mbid}/front-250" - resp = requests.head(url, timeout=3, allow_redirects=True) - if resp.status_code == 200: - return resp.url - return None - except Exception: + `scope` is 'release' (most specific) or 'release-group' (covers all + editions — better hit rate). + """ + if not mbid: return None + if scope not in ('release', 'release-group'): + scope = 'release' + return f"{COVER_ART_ARCHIVE_URL}/{scope}/{mbid}/front-250" def _extract_artist_credit(artist_credit) -> List[str]: @@ -97,6 +92,28 @@ def _extract_artist_credit(artist_credit) -> List[str]: return [n for n in names if n] +def _extract_title_hint(query: str, artist_name: str) -> Optional[str]: + """If `query` starts with `artist_name` followed by more words, return + the trailing portion. Used to pick out the album/track title the user + typed after the artist name (e.g. "The Beatles Abbey Road" → "Abbey + Road"). Returns None when the query is just the artist name. + + Case-insensitive prefix match on whitespace-normalized versions of + both strings, so "the beatles abbey road" → "abbey road" and + "The Beatles" → None. + """ + if not query or not artist_name: + return None + q_norm = ' '.join(query.split()).lower() + a_norm = ' '.join(artist_name.split()).lower() + if q_norm == a_norm: + return None + # Require a word boundary between the artist name and the trailing bit. + if q_norm.startswith(a_norm + ' '): + return query[len(artist_name):].strip() or None + return None + + def _map_release_type(primary_type: str, secondary_types: List[str] = None) -> str: """Map MusicBrainz release group type to standard album_type.""" pt = (primary_type or '').lower() @@ -116,49 +133,304 @@ class MusicBrainzSearchClient: def __init__(self): from core.musicbrainz_client import MusicBrainzClient - self._client = MusicBrainzClient("SoulSync", "2.3") - self._art_cache: Dict[str, Optional[str]] = {} # mbid -> url + # Client defaults to the project URL as its User-Agent contact, + # which is what MusicBrainz wants. Version stays generic ("2") — + # the exact UI minor version would add noise to every request. + self._client = MusicBrainzClient("SoulSync", "2") + # Per-instance cache for "top artist MBID for this query". The + # backend fires artists/albums/tracks searches in parallel against + # one client instance, and albums+tracks both need the same artist + # lookup. Without this cache, we'd fire 3 identical artist-search + # HTTP calls (each serialized by the 1-rps rate limit = 3 wasted + # seconds). The _Sentinel marks "we already looked and found + # nothing" to prevent repeat no-hit lookups. + self._artist_mbid_cache: Dict[str, Optional[Dict[str, Any]]] = {} + self._artist_mbid_lock = threading.Lock() def _cached_art(self, release_mbid: str, release_group_mbid: str = '') -> Optional[str]: - """Get cover art with caching. Tries release first, then release group.""" - if release_mbid in self._art_cache: - return self._art_cache[release_mbid] + """Build a Cover Art Archive URL for a release / release-group MBID. - url = _get_cover_art_url(release_mbid) - if not url and release_group_mbid: - url = _get_release_group_art(release_group_mbid) - self._art_cache[release_mbid] = url - return url + Prefers release-group scope when provided — better hit rate because + it covers all editions of the same album. No network call; the + frontend's fallback handles 404s. + """ + preferred = release_group_mbid or release_mbid + if not preferred: + return None + scope = 'release-group' if release_group_mbid else 'release' + return _cover_art_url(preferred, scope=scope) + + # Score threshold for user-facing search results. MusicBrainz returns a + # Lucene score 0-100 on every match; exact name/alias hits score 100, + # partial/typo matches trend lower, and tribute bands / random + # lookalikes score 40-65. 80 is the cutoff that keeps the true artist + # and close variants while dropping unrelated noise. + _MIN_SCORE = 80 def search_artists(self, query: str, limit: int = 10) -> List[Artist]: - """MusicBrainz search tab doesn't show artists — only albums and tracks.""" - return [] + """Search MusicBrainz for artists by name. + + Uses a bare Lucene query (no field prefix) so MusicBrainz searches + the alias, artist, AND sortname indexes together — much better + recall than strict `artist:"..."` phrase matching. Results are + filtered by score (>= 80) to drop tribute bands and unrelated + lookalikes. + """ + try: + # Fetch extra so dedup below has enough to pick from. For + # common names (Michael Jackson, John Williams, etc.) MB returns + # many same-named people; without a larger pool, capping at + # `limit` before dedup can leave us with fewer results than + # requested. + raw = self._client.search_artist(query, limit=max(limit * 3, 10), strict=False) + + # Dedupe by normalized name. MusicBrainz has many different + # people with the same canonical name (7 entries for "Michael + # Jackson" — the singer + poet + photographer + didgeridoo + # player + ...), all scoring 80+ on exact-name match. Rendered + # as identical cards since the fallback image lookup hits the + # same fallback-source result for each. Keep the highest- + # scoring entry per normalized name so the user sees one card + # per distinct artist. + seen = {} + for a in raw: + score = a.get('score', 0) or 0 + if score < self._MIN_SCORE: + continue + mbid = a.get('id', '') + name = a.get('name', '') + if not mbid or not name: + continue + key = name.lower().strip() + if key not in seen or (seen[key].get('score', 0) or 0) < score: + seen[key] = a + + # Sort the survivors score-descending and cap at the caller's + # limit. `seen` only holds top-per-name, so ordering is stable. + top = sorted(seen.values(), key=lambda r: -(r.get('score', 0) or 0))[:limit] + + artists = [] + for a in top: + mbid = a.get('id', '') + name = a.get('name', '') + + # Genres from MB tags (user-applied categorical labels). Each + # tag has {name, count}; keep the top-weighted ones. + tags = a.get('tags', []) or [] + genres = [t.get('name') for t in tags if t.get('name')][:5] + + external_urls = { + 'musicbrainz': f'https://musicbrainz.org/artist/{mbid}' + } + + artists.append(Artist( + id=mbid, + name=name, + popularity=a.get('score', 0) or 0, # Reuse score as popularity (0-100) + genres=genres, + followers=0, # MusicBrainz doesn't track followers + image_url=None, # MB doesn't store artist images directly + external_urls=external_urls, + )) + return artists + except Exception as e: + logger.warning(f"MusicBrainz artist search failed: {e}") + return [] + + def _split_structured_query(self, query: str): + """Split 'Artist - Title' / 'Artist – Title' / 'Artist — Title' if + a separator is present. Returns (artist_name, title) or (None, query).""" + for sep in [' - ', ' – ', ' — ']: + if sep in query: + parts = query.split(sep, 1) + return parts[0].strip(), parts[1].strip() + return None, query + + def _resolve_top_artist(self, query: str) -> Optional[Dict[str, Any]]: + """Return the top-scoring artist for a bare-name query, or None if + nothing scores above threshold. Cached per instance so parallel + album/track searches don't each refetch.""" + if not query: + return None + key = query.strip().lower() + with self._artist_mbid_lock: + if key in self._artist_mbid_cache: + return self._artist_mbid_cache[key] + # Do the HTTP call OUTSIDE the lock so other threads can still + # check the cache while we wait on the network. + raw = self._client.search_artist(query, limit=1, strict=False) + top = None + if raw and (raw[0].get('score', 0) or 0) >= self._MIN_SCORE: + top = raw[0] + with self._artist_mbid_lock: + self._artist_mbid_cache[key] = top + return top + + # Secondary-type tags on MB release-groups that indicate NOT a studio + # release. Used by both the album browse (filter out) and the track + # browse (prefer studio release for album context). + _NON_STUDIO_SECONDARY_TYPES = { + 'Live', 'Compilation', 'Soundtrack', 'Remix', 'Demo', + 'Mixtape/Street', 'Interview', 'Audiobook', 'Audio drama', + } + + def _release_preference_key(self, rel: Dict[str, Any]): + """Sort key: studio releases first, then by date ASC. + + Recordings in MB often have 10+ releases (studio album, live, best-of, + reissues, anniversary editions). The first one in the API response is + arbitrary — it's often a recent live bootleg because MB users add new + live recordings all the time. Re-sorting before `_recording_to_track` + reads the first release means tracks show their canonical studio + album, not a random live compilation. + """ + rg = rel.get('release-group') or {} + secs = set(rg.get('secondary-types') or []) + is_studio = 0 if not (secs & self._NON_STUDIO_SECONDARY_TYPES) else 1 + date = (rel.get('date') or '')[:4] + year = int(date) if date.isdigit() else 9999 + return (is_studio, year) + + def _has_studio_release(self, recording: Dict[str, Any]) -> bool: + """True when at least one of the recording's releases is on a + release-group with no non-studio secondary type.""" + for rel in (recording.get('releases') or []): + rg = rel.get('release-group') or {} + secs = set(rg.get('secondary-types') or []) + if not (secs & self._NON_STUDIO_SECONDARY_TYPES): + return True + return False + + def _release_group_to_album(self, rg: Dict[str, Any], artist_name: str) -> Album: + """Project a MusicBrainz release-group into our Album dataclass.""" + rg_mbid = rg.get('id', '') + title = rg.get('title', '') or '' + primary_type = rg.get('primary-type', '') or '' + secondary_types = rg.get('secondary-types', []) or [] + album_type = _map_release_type(primary_type, secondary_types) + release_date = rg.get('first-release-date', '') or '' + # Release-group browse doesn't link directly to a single release, + # so we can't get per-release track counts cheaply. Leave 0 — the + # frontend treats it as "unknown" gracefully. + image_url = self._cached_art(rg_mbid, rg_mbid) + return Album( + id=rg_mbid, + name=title, + artists=[artist_name] if artist_name else ['Unknown Artist'], + release_date=release_date, + total_tracks=0, + album_type=album_type, + image_url=image_url, + external_urls={'musicbrainz': f'https://musicbrainz.org/release-group/{rg_mbid}'} if rg_mbid else {}, + ) def search_albums(self, query: str, limit: int = 10) -> List[Album]: - """Search MusicBrainz for releases (albums).""" + """Search MusicBrainz for releases (albums). + + Primary path: when the query looks like a bare artist name, resolve + it to an artist MBID and BROWSE that artist's release-groups. This + returns the artist's actual discography instead of unrelated + releases that happen to be titled after them. + + Fallback path: when the query is structured as "Artist - Album" or + the artist lookup fails, drop back to text search with the + existing Lucene strategy. + """ try: - # Try to split "Artist Album" for better matching - artist_name = None - album_name = query - for sep in [' - ', ' – ', ' — ']: - if sep in query: - parts = query.split(sep, 1) - artist_name = parts[0].strip() - album_name = parts[1].strip() - break + artist_name, title = self._split_structured_query(query) + # Structured "Artist - Album" query → respect user's intent; + # text-search with both terms is more precise than browsing all + # of that artist's discography. + if artist_name: + return self._search_albums_text(title, artist_name, limit) + + # Bare name query → try artist-first → browse path. + top = self._resolve_top_artist(query) + if top: + mbid = top.get('id', '') + tname = top.get('name', '') or query + # If the query has words beyond the artist name (e.g. "The + # Beatles Abbey Road"), extract the leftover as a title hint. + # We'll use it below to narrow browse results to the specific + # album the user typed rather than dumping the full back + # catalogue. kettui flagged the regression — bare-name browse + # was burying a specific-album query inside a discography list. + title_hint = _extract_title_hint(query, tname) + rgs = self._client.browse_artist_release_groups( + mbid, + # 'compilation' is a SECONDARY type, not a primary type + # — including it in the OR filter causes MB to return + # only 82 matches instead of the actual 1076 because + # the filter silently breaks. Actual compilations + # (primary-type=Album with secondary-types=[Compilation]) + # are handled by the studio-preference filter below. + release_types=['album', 'ep', 'single'], + limit=100, + ) + + # Prefer studio releases — MusicBrainz tags live bootlegs + # and best-of compilations with secondary-types. For mega- + # artists like Metallica, 83 of 100 browse results are live + # broadcast bootlegs; the 12 studio albums are buried. A + # release-group with no secondary-types (or an explicit + # studio-only type) is the "original studio" shape users + # expect to see first. + def _is_studio(rg): + secs = set((rg.get('secondary-types') or [])) + return not (secs & {'Live', 'Compilation', 'Soundtrack', + 'Remix', 'Demo', 'Mixtape/Street', + 'Interview', 'Audiobook', 'Audio drama'}) + studio = [rg for rg in rgs if _is_studio(rg)] + # If filtering leaves us empty (niche live-only artist), + # fall back to the unfiltered list — better than no results. + rgs = studio or rgs + + # Narrow to the title-hint if the user gave one ("The Beatles + # Abbey Road" → filter to RGs whose title contains "abbey + # road"). If no RG matches, fall back to text-search so the + # user finds the specific album instead of either seeing the + # full discography or getting zero results. (kettui flagged + # this regression — artist-first alone was burying specific- + # album queries inside the unfiltered discography list.) + if title_hint: + hint_lower = title_hint.lower() + matched = [rg for rg in rgs if hint_lower in (rg.get('title') or '').lower()] + if matched: + rgs = matched + else: + fallback = self._search_albums_text(title_hint, tname, limit) + if fallback: + return fallback + # Text-search also missed — fall through and show the + # full (unfiltered) discography rather than nothing. + + # Sort by primary-type priority first (album > ep > single > + # compilation), then chronologically ASC — the standard way + # discographies are listed ("their debut was X, then Y, then Z"). + type_priority = {'album': 0, 'ep': 1, 'single': 2, 'compilation': 3} + def _sort_key(rg): + pt = (rg.get('primary-type') or '').lower() + date = rg.get('first-release-date') or '' + year = int(date[:4]) if date[:4].isdigit() else 9999 + return (type_priority.get(pt, 9), year) + rgs.sort(key=_sort_key) + albums = [self._release_group_to_album(rg, tname) for rg in rgs[:limit]] + return albums + + # No artist match → text search on the whole query. + return self._search_albums_text(query, None, limit) + except Exception as e: + logger.warning(f"MusicBrainz album search failed: {e}") + return [] + + def _search_albums_text(self, album_name: str, artist_name: Optional[str], limit: int) -> List[Album]: + """Fallback text-search path for structured/fuzzy album queries.""" + try: results = self._client.search_release(album_name, artist_name=artist_name, limit=limit) - - # If no separator, try word-boundary splitting - if not results and not artist_name: - words = query.split() - for i in range(1, len(words)): - possible_artist = ' '.join(words[:i]) - possible_album = ' '.join(words[i:]) - if len(possible_album) >= 2: - results = self._client.search_release(possible_album, artist_name=possible_artist, limit=limit) - if results: - break + # Score filter — same threshold as artists. Drops garbage + # title-match hits from unrelated releases. + results = [r for r in results if (r.get('score', 0) or 0) >= self._MIN_SCORE] albums = [] for r in results: @@ -223,165 +495,300 @@ class MusicBrainzSearchClient: logger.warning(f"MusicBrainz album search failed: {e}") return [] + def _recording_to_track(self, r: Dict[str, Any], fallback_artist_name: str) -> Optional[Track]: + """Project a MusicBrainz recording into our Track dataclass. Returns + None when the recording lacks required fields.""" + mbid = r.get('id', '') + title = r.get('title', '') + if not title: + return None + + artists = _extract_artist_credit(r.get('artist-credit', [])) + if not artists and fallback_artist_name: + artists = [fallback_artist_name] + + duration_ms = r.get('length', 0) or 0 + album_name = '' + album_id = '' + release_date = '' + image_url = None + album_type = 'single' + # Initialized to 0 and summed from the release's media track-counts. + # Previously initialized to 1, which made every track-with-release + # report one more than the album actually has (kettui caught this). + total_tracks = 0 + + releases = r.get('releases', []) or [] + if releases: + rel = releases[0] + album_name = rel.get('title', '') or '' + album_id = rel.get('id', '') or '' + release_date = rel.get('date', '') or '' + + rg = rel.get('release-group', {}) or {} + primary_type = rg.get('primary-type', '') or '' + secondary_types = rg.get('secondary-types', []) or [] + album_type = _map_release_type(primary_type, secondary_types) + + for m in rel.get('media', []) or []: + total_tracks += m.get('track-count', 0) + + rg_mbid = rg.get('id', '') or '' + image_url = self._cached_art(album_id, rg_mbid) if album_id else None + + # Tracks with no release info are standalone recordings — give them + # total_tracks=1 (the track itself). Keeps the old shape for that + # edge case but fixes the off-by-one for every normal case. + if not releases: + total_tracks = 1 + + return Track( + id=mbid, + name=title, + artists=artists if artists else ['Unknown Artist'], + album=album_name or title, + duration_ms=duration_ms, + popularity=r.get('score', 0) or 0, + image_url=image_url, + release_date=release_date, + external_urls={'musicbrainz': f'https://musicbrainz.org/recording/{mbid}'} if mbid else {}, + album_type=album_type, + total_tracks=total_tracks, + album_id=album_id, + ) + def search_tracks(self, query: str, limit: int = 10) -> List[Track]: - """Search MusicBrainz for recordings (tracks).""" + """Search MusicBrainz for recordings (tracks). + + Same strategy as `search_albums`: bare name → artist-first → browse + recordings; structured "Artist - Title" stays on text search so the + user's explicit title intent is respected. + """ try: - # Try to split "Artist - Title" for better matching - artist_name = None - track_name = query - for sep in [' - ', ' – ', ' — ']: - if sep in query: - parts = query.split(sep, 1) - artist_name = parts[0].strip() - track_name = parts[1].strip() - break + artist_name, title = self._split_structured_query(query) + # Structured query → text search with both fields. + if artist_name: + return self._search_tracks_text(title, artist_name, limit) + + # Bare name → artist-first → arid: search. + top = self._resolve_top_artist(query) + if top: + mbid = top.get('id', '') + tname = top.get('name', '') or query + # /recording?artist= (browse) rejects inc=releases, + # so we use the fielded Lucene search arid: instead — + # that returns recordings with release context inline. + recs = self._client.search_recordings_by_artist_mbid(mbid, limit=100) + + # Re-order each recording's releases to prefer studio over + # live/compilation. Without this, the first release (which + # the adapter uses for album info + date) is often a random + # live bootleg — Metallica has 10+ live versions of "One" + # ranked ahead of the studio release. Mutates in place so + # `_recording_to_track` sees the preferred release first. + for r in recs: + rels = r.get('releases') or [] + if not rels: + continue + rels.sort(key=self._release_preference_key) + r['releases'] = rels + + # Prefer recordings that have at least one studio release. + # Falls back to the full set if the artist is live-only. + studio = [r for r in recs if self._has_studio_release(r)] + recs = studio or recs + + # Dedupe by normalized title (MB has many versions of the + # same song — live, remaster, re-recording, etc.). Because + # we sorted releases above, `_recording_to_track` will pick + # the studio release for album info on the first keeper. + seen = set() + deduped = [] + for r in recs: + key = (r.get('title') or '').lower().strip() + if not key or key in seen: + continue + seen.add(key) + deduped.append(r) + + # Sort by studio-release year ASC so classic tracks surface + # first. For a user typing "metallica", this means "Seek + # and Destroy" (1983) before "Atlas, Rise!" (2016) — which + # matches how most discography views order by release. + def _track_sort_key(r): + rels = r.get('releases') or [] + for rel in rels: + date = (rel.get('date') or '')[:4] + if date.isdigit(): + return int(date) + return 9999 + deduped.sort(key=_track_sort_key) + + tracks = [] + for r in deduped[:limit]: + t = self._recording_to_track(r, tname) + if t: + tracks.append(t) + return tracks + + # No artist match → fall back to text search on whole query. + return self._search_tracks_text(query, None, limit) + except Exception as e: + logger.warning(f"MusicBrainz track search failed: {e}") + return [] + + def _search_tracks_text(self, track_name: str, artist_name: Optional[str], limit: int) -> List[Track]: + """Fallback text-search path for structured/fuzzy track queries.""" + try: results = self._client.search_recording(track_name, artist_name=artist_name, limit=limit) + # Score filter matches the artist/album logic — cuts garbage + # title collisions from unrelated recordings. + results = [r for r in results if (r.get('score', 0) or 0) >= self._MIN_SCORE] - # If no separator found or structured search failed, try the full query - # as both a recording search and an artist+recording combined search - if not results and not artist_name: - # Try each word split as potential artist/title boundary - words = query.split() - for i in range(1, len(words)): - possible_artist = ' '.join(words[:i]) - possible_track = ' '.join(words[i:]) - if len(possible_track) >= 2: - results = self._client.search_recording(possible_track, artist_name=possible_artist, limit=limit) - if results: - break tracks = [] for r in results: - mbid = r.get('id', '') - title = r.get('title', '') - if not title: - continue - - artists = _extract_artist_credit(r.get('artist-credit', [])) - duration_ms = r.get('length', 0) or 0 - - # Get album from first release - album_name = '' - album_id = '' - release_date = '' - image_url = None - album_type = 'single' - total_tracks = 1 - track_number = None - - releases = r.get('releases', []) - if releases: - rel = releases[0] - album_name = rel.get('title', '') - album_id = rel.get('id', '') - release_date = rel.get('date', '') or '' - - rg = rel.get('release-group', {}) - primary_type = rg.get('primary-type', '') or '' - secondary_types = rg.get('secondary-types', []) or [] - album_type = _map_release_type(primary_type, secondary_types) - - media = rel.get('media', []) - for m in media: - total_tracks += m.get('track-count', 0) - # Find track number - for t in m.get('tracks', []): - if t.get('id') == mbid or t.get('recording', {}).get('id') == mbid: - try: - track_number = int(t.get('number', t.get('position', 0))) - except (ValueError, TypeError): - pass - - # Cover art - rg_mbid = rg.get('id', '') - image_url = self._cached_art(album_id, rg_mbid) if album_id else None - - external_urls = {'musicbrainz': f'https://musicbrainz.org/recording/{mbid}'} if mbid else {} - - tracks.append(Track( - id=mbid, - name=title, - artists=artists if artists else ['Unknown Artist'], - album=album_name or title, - duration_ms=duration_ms, - popularity=r.get('score', 0), - image_url=image_url, - release_date=release_date, - external_urls=external_urls, - track_number=track_number, - album_type=album_type, - total_tracks=total_tracks, - album_id=album_id, - )) + t = self._recording_to_track(r, artist_name or '') + if t: + tracks.append(t) return tracks except Exception as e: logger.warning(f"MusicBrainz track search failed: {e}") return [] - def get_album(self, release_mbid: str) -> Optional[Dict[str, Any]]: - """Get full album details with track listing for download modal.""" - try: - release = self._client.get_release(release_mbid, includes=['recordings', 'artist-credits', 'release-groups']) - if not release: - return None + def _pick_representative_release(self, releases: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """Pick the best release out of a release-group's editions. - title = release.get('title', '') - artists_raw = _extract_artist_credit(release.get('artist-credit', [])) - release_date = release.get('date', '') or '' - - rg = release.get('release-group', {}) - primary_type = rg.get('primary-type', '') or '' - secondary_types = rg.get('secondary-types', []) or [] - album_type = _map_release_type(primary_type, secondary_types) - - # Cover art - rg_mbid = rg.get('id', '') - image_url = self._cached_art(release_mbid, rg_mbid) - - # Build tracks from media - tracks = [] - total_tracks = 0 - media_list = release.get('media', []) - for media_idx, media in enumerate(media_list): - disc_number = media.get('position', media_idx + 1) - for track in media.get('tracks', []): - total_tracks += 1 - recording = track.get('recording', {}) - track_artists = _extract_artist_credit(recording.get('artist-credit', [])) - if not track_artists: - track_artists = artists_raw - - try: - track_num = int(track.get('number', track.get('position', total_tracks))) - except (ValueError, TypeError): - track_num = total_tracks - - tracks.append({ - 'id': recording.get('id', track.get('id', '')), - 'name': recording.get('title', track.get('title', '')), - 'artists': [{'name': a} for a in track_artists], - 'duration_ms': recording.get('length', 0) or track.get('length', 0) or 0, - 'track_number': track_num, - 'disc_number': disc_number, - }) - - images = [{'url': image_url, 'height': 250, 'width': 250}] if image_url else [] - - return { - 'id': release_mbid, - 'name': title, - 'artists': [{'name': a, 'id': ''} for a in (artists_raw or ['Unknown Artist'])], - 'release_date': release_date, - 'total_tracks': total_tracks, - 'album_type': album_type, - 'images': images, - 'tracks': tracks, - 'external_urls': {'musicbrainz': f'https://musicbrainz.org/release/{release_mbid}'}, - } - except Exception as e: - logger.error(f"MusicBrainz album detail failed for {release_mbid}: {e}") + Release-groups often contain 5-20+ releases (original, reissues, + remasters, regional editions, bonus-track editions). We want a + single canonical version to show the user as 'the album.' Prefer: + 1. Official releases (not promo/bootleg) + 2. Earliest date (the original) + 3. Any release with media (skip entries that are just stubs) + """ + if not releases: return None + def _key(r): + status = (r.get('status') or '').lower() + status_rank = 0 if status == 'official' else 1 # Official first + has_media = 0 if r.get('media') else 1 # Real tracklists first + date = (r.get('date') or '9999-99-99')[:10] + return (has_media, status_rank, date) + + return sorted(releases, key=_key)[0] + + def get_album(self, album_mbid: str) -> Optional[Dict[str, Any]]: + """Get full album details with track listing for download modal. + + The MBID passed in could be either: + - A release-group MBID (from `search_albums` browse path — the + common case now that bare-name searches route artist-first → + browse), or + - A release MBID (from the text-search fallback path). + + Try release-group first since that's the majority; if it 404s, + fall back to direct release lookup. Release-group resolution adds + one extra API call (~1s at the 1-rps rate limit) to pick a + representative release and then fetch its tracklist. + """ + try: + # Path A: release-group MBID (new browse-based search default) + rg = self._client.get_release_group( + album_mbid, includes=['releases', 'artist-credits'] + ) + if rg: + releases = rg.get('releases') or [] + rep = self._pick_representative_release(releases) + if rep and rep.get('id'): + album = self._render_release_as_album( + rep['id'], + rg_fallback=rg, + ) + if album: + # Keep the release-group MBID as the canonical + # Album.id so downstream code can re-fetch with + # the same URL. + album['id'] = album_mbid + album['external_urls'] = { + 'musicbrainz': f'https://musicbrainz.org/release-group/{album_mbid}' + } + return album + + # Path B: release MBID (text-search fallback path) + return self._render_release_as_album(album_mbid) + except Exception as e: + logger.error(f"MusicBrainz album detail failed for {album_mbid}: {e}") + return None + + def _render_release_as_album(self, release_mbid: str, + rg_fallback: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]: + """Fetch a specific release and project it to the album-detail dict + shape the download modal expects. `rg_fallback` supplies release-group + metadata (type, artist credits) when resolving from a release-group + whose releases may be lightly populated.""" + release = self._client.get_release( + release_mbid, includes=['recordings', 'artist-credits', 'release-groups'] + ) + if not release: + return None + + title = release.get('title', '') + artists_raw = _extract_artist_credit(release.get('artist-credit', [])) + if not artists_raw and rg_fallback: + artists_raw = _extract_artist_credit(rg_fallback.get('artist-credit', [])) + release_date = release.get('date', '') or '' + if not release_date and rg_fallback: + release_date = rg_fallback.get('first-release-date', '') or '' + + rg = release.get('release-group', rg_fallback or {}) or {} + primary_type = rg.get('primary-type', '') or '' + secondary_types = rg.get('secondary-types', []) or [] + album_type = _map_release_type(primary_type, secondary_types) + + rg_mbid = rg.get('id', '') + image_url = self._cached_art(release_mbid, rg_mbid) + + tracks = [] + total_tracks = 0 + media_list = release.get('media', []) + for media_idx, media in enumerate(media_list): + disc_number = media.get('position', media_idx + 1) + for track in media.get('tracks', []): + total_tracks += 1 + recording = track.get('recording', {}) + track_artists = _extract_artist_credit(recording.get('artist-credit', [])) + if not track_artists: + track_artists = artists_raw + + try: + track_num = int(track.get('number', track.get('position', total_tracks))) + except (ValueError, TypeError): + track_num = total_tracks + + tracks.append({ + 'id': recording.get('id', track.get('id', '')), + 'name': recording.get('title', track.get('title', '')), + 'artists': [{'name': a} for a in track_artists], + 'duration_ms': recording.get('length', 0) or track.get('length', 0) or 0, + 'track_number': track_num, + 'disc_number': disc_number, + }) + + images = [{'url': image_url, 'height': 250, 'width': 250}] if image_url else [] + + return { + 'id': release_mbid, + 'name': title, + 'artists': [{'name': a, 'id': ''} for a in (artists_raw or ['Unknown Artist'])], + 'release_date': release_date, + 'total_tracks': total_tracks, + 'album_type': album_type, + 'images': images, + 'tracks': tracks, + 'external_urls': {'musicbrainz': f'https://musicbrainz.org/release/{release_mbid}'}, + } + def get_artist_albums(self, artist_mbid: str, album_type: str = 'album,single') -> List: """Get artist's releases for discography view.""" try: diff --git a/core/reorganize_queue.py b/core/reorganize_queue.py new file mode 100644 index 00000000..6b50afb0 --- /dev/null +++ b/core/reorganize_queue.py @@ -0,0 +1,453 @@ +"""FIFO queue for library album reorganize requests. + +Replaces the single-slot "one reorganize at a time, return 409 on +collision" model with a queue: clicks always succeed (or surface +"already queued" on dedupe), the user can fan-out clicks across +albums or hit "Reorganize All", and a single background worker +chews through the queue in submission order. + +Design rules: + +- **Single global queue**, single worker thread. Reorganize is + I/O-heavy (file copy, mutagen tagging, AcoustID, possibly ffmpeg) + and post-process is not designed for cross-album concurrency. + In-album track parallelism still happens inside `reorganize_album` + (3 worker threads — see `_REORGANIZE_MAX_WORKERS`). + +- **Dedupe on enqueue**: an album that's already queued or currently + running is rejected silently. Stops the user from spamming the + same album N times by clicking the button repeatedly. + +- **Per-item source**: each queued item carries its own `source` + string (the user's per-album modal pick). Worker passes it + through to `reorganize_album(primary_source=..., strict_source=...)`. + +- **Continue on failure**: a failed item doesn't stop the queue. + Worker logs the failure, marks the item `failed`, moves on. + +- **Cancel queued items**: items in `queued` state can be cancelled + (drop from queue). The currently-running item can NOT be cancelled + mid-flight — Python threads aren't cleanly killable, and post- + process spawns subprocesses we can't safely interrupt. Cancel + changes the item's status to `cancelled` and removes it from the + active queue. + +- **In-memory only**: queue state lives in a module-level singleton. + A server restart loses the queue (in-flight item likely also lost + half-way through post-process). DB persistence is a follow-up if + this turns out to matter operationally. +""" + +import threading +import time +import uuid +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional + +from utils.logging_config import get_logger + +logger = get_logger("reorganize_queue") + + +# How many recently-completed items to retain for the snapshot endpoint. +# The status panel uses these to show "just-finished" cards briefly so +# the user sees outcomes scroll past instead of items vanishing. +_RECENT_HISTORY_CAP = 30 + + +@dataclass +class QueueItem: + """One album waiting (or being processed) in the reorganize queue.""" + + queue_id: str # uuid; how the API references this item + album_id: str + album_title: str # captured at enqueue time for UI display + artist_id: Optional[str] + artist_name: str # captured at enqueue time for UI display + source: Optional[str] # the user's per-modal pick (None = auto) + enqueued_at: float + status: str = 'queued' # queued | running | done | failed | cancelled + started_at: Optional[float] = None + finished_at: Optional[float] = None + # Populated by the worker after each item finishes — surfaced to the + # status panel so users see counts + per-item error messages. + result_status: Optional[str] = None # mirrors `reorganize_album` summary['status'] + result_source: Optional[str] = None # which source the orchestrator actually used + moved: int = 0 + skipped: int = 0 + failed: int = 0 + error: Optional[str] = None # shorthand for the first error, for the toast + # Live-progress fields for the currently-running item; cleared when + # the worker moves on so the snapshot stays small. + current_track: Optional[str] = None + progress_total: int = 0 + progress_processed: int = 0 + + def to_snapshot(self) -> dict: + return { + 'queue_id': self.queue_id, + 'album_id': self.album_id, + 'album_title': self.album_title, + 'artist_id': self.artist_id, + 'artist_name': self.artist_name, + 'source': self.source, + 'enqueued_at': self.enqueued_at, + 'started_at': self.started_at, + 'finished_at': self.finished_at, + 'status': self.status, + 'result_status': self.result_status, + 'result_source': self.result_source, + 'moved': self.moved, + 'skipped': self.skipped, + 'failed': self.failed, + 'error': self.error, + 'current_track': self.current_track, + 'progress_total': self.progress_total, + 'progress_processed': self.progress_processed, + } + + +class ReorganizeQueue: + """Module-level singleton that owns the queue + worker thread. + + Use the module-level :func:`get_queue` accessor — don't construct + directly. The class is documented public-style so tests can spin + up isolated instances. + """ + + def __init__(self, *, runner: Optional[Callable[[QueueItem], dict]] = None): + """ + Args: + runner: Callable that takes a `QueueItem` and runs the + actual reorganize, returning a summary dict with + ``status``, ``source``, ``moved``, ``skipped``, + ``failed``, ``errors`` keys (the shape + ``reorganize_album`` already returns). Tests inject + a fake runner; production wires the real one in + via :func:`set_runner`. + """ + # Single Condition variable owns both mutual exclusion and the + # idle-worker wait. Using a Condition (vs Lock + Event) closes a + # race where the worker could clear an event right after enqueue + # set it, causing the new item to sleep for the timeout window. + # cond.wait() releases the lock and re-acquires on notify, so + # state checks and waits are properly interleaved. + self._cond = threading.Condition() + self._items: List[QueueItem] = [] # everything ever submitted (active + recent) + self._runner = runner + self._worker: Optional[threading.Thread] = None + self._stopped = False + + # -- public API -------------------------------------------------- + + def set_runner(self, runner: Callable[[QueueItem], dict]) -> None: + """Inject the function that does the actual reorganize work. + Web_server calls this once at startup with a closure over the + injected dependencies (post-process fn, db, etc.).""" + with self._cond: + self._runner = runner + + def enqueue( + self, + *, + album_id: str, + album_title: str, + artist_id: Optional[str], + artist_name: str, + source: Optional[str] = None, + ) -> dict: + """Add an album to the queue. Returns a result dict: + + {'queued': True, 'queue_id': '...', 'position': N} + {'queued': False, 'reason': 'already_queued', 'queue_id': '...'} + + Dedupe: if this album is already in `queued` or `running` + status, returns the existing entry's queue_id rather than + adding a duplicate. ``cancelled`` / ``done`` / ``failed`` + items don't block re-enqueue (user retried after a failure). + """ + with self._cond: + for existing in self._items: + if existing.album_id == album_id and existing.status in ('queued', 'running'): + return { + 'queued': False, + 'reason': 'already_queued', + 'queue_id': existing.queue_id, + } + + item = QueueItem( + queue_id=uuid.uuid4().hex[:12], + album_id=album_id, + album_title=album_title, + artist_id=artist_id, + artist_name=artist_name, + source=source, + enqueued_at=time.time(), + ) + self._items.append(item) + position = sum(1 for i in self._items if i.status == 'queued') + self._ensure_worker() + self._cond.notify_all() + logger.info( + f"[Queue] Enqueued '{album_title}' (album_id={album_id}, " + f"queue_id={item.queue_id}, position={position}, source={source or 'auto'})" + ) + return { + 'queued': True, + 'queue_id': item.queue_id, + 'position': position, + } + + def enqueue_many(self, items: List[Dict[str, Any]]) -> Dict[str, int]: + """Bulk-enqueue a list of items. Each ``item`` is a dict with + the same keys :meth:`enqueue` accepts (``album_id``, + ``album_title``, ``artist_id``, ``artist_name``, ``source``). + Dedupe still applies per-album-id. + + Holds the queue lock for the entire batch so two things hold: + (1) the worker can't start draining mid-batch, and (2) duplicate + album_ids inside the same batch get deduped against each other, + not just against pre-existing items. Without (2), a fast runner + could finish the first copy before the loop reached the second + and both would enqueue. + + Returns a tally dict ``{'enqueued': N, 'already_queued': M, + 'total': len(items)}`` so the caller can report bulk results + without doing the counting themselves. Used by the bulk + Reorganize-All endpoint and any future maintenance jobs that + enqueue at scale. + """ + enqueued = 0 + already = 0 + seen_in_batch: set = set() + with self._cond: + # Snapshot album_ids that already block re-enqueue so we don't + # rescan self._items per row. + blocked = { + i.album_id for i in self._items if i.status in ('queued', 'running') + } + for raw in items: + album_id = str(raw['album_id']) + if album_id in blocked or album_id in seen_in_batch: + already += 1 + continue + seen_in_batch.add(album_id) + item = QueueItem( + queue_id=uuid.uuid4().hex[:12], + album_id=album_id, + album_title=raw.get('album_title') or 'Unknown Album', + artist_id=str(raw['artist_id']) if raw.get('artist_id') is not None else None, + artist_name=raw.get('artist_name') or 'Unknown Artist', + source=raw.get('source'), + enqueued_at=time.time(), + ) + self._items.append(item) + enqueued += 1 + logger.info( + f"[Queue] Bulk-enqueued '{item.album_title}' (album_id={album_id}, " + f"queue_id={item.queue_id}, source={item.source or 'auto'})" + ) + if enqueued: + self._ensure_worker() + self._cond.notify_all() + return {'enqueued': enqueued, 'already_queued': already, 'total': len(items)} + + def cancel(self, queue_id: str) -> dict: + """Cancel a queued item. The currently-running item cannot be + cancelled (Python threads aren't cleanly killable; post-process + may have spawned ffmpeg).""" + with self._cond: + for item in self._items: + if item.queue_id != queue_id: + continue + if item.status == 'queued': + item.status = 'cancelled' + item.finished_at = time.time() + logger.info(f"[Queue] Cancelled queued item {queue_id} ('{item.album_title}')") + return {'cancelled': True} + if item.status == 'running': + return {'cancelled': False, 'reason': 'running_cant_cancel'} + return {'cancelled': False, 'reason': 'not_active'} + return {'cancelled': False, 'reason': 'not_found'} + + def clear_queued(self) -> int: + """Cancel ALL queued items (running item continues). Returns + the count of items cancelled.""" + cancelled = 0 + with self._cond: + now = time.time() + for item in self._items: + if item.status == 'queued': + item.status = 'cancelled' + item.finished_at = now + cancelled += 1 + if cancelled: + logger.info(f"[Queue] Bulk-cancelled {cancelled} queued items") + return cancelled + + def snapshot(self) -> dict: + """Current queue state for the status panel. Returns: + + { + 'active': item dict | None, + 'queued': [item dicts in FIFO order], + 'recent': [item dicts in finish order, newest first, capped], + 'totals': {'queued': N, 'running': M, 'done_today': K, ...}, + } + """ + with self._cond: + active = next((i for i in self._items if i.status == 'running'), None) + queued = [i for i in self._items if i.status == 'queued'] + recent = [i for i in self._items if i.status in ('done', 'failed', 'cancelled')] + recent.sort(key=lambda i: i.finished_at or 0, reverse=True) + recent = recent[:_RECENT_HISTORY_CAP] + + return { + 'active': active.to_snapshot() if active else None, + 'queued': [i.to_snapshot() for i in queued], + 'recent': [i.to_snapshot() for i in recent], + 'totals': { + 'queued': len(queued), + 'running': 1 if active else 0, + 'done': sum(1 for i in self._items if i.status == 'done'), + 'failed': sum(1 for i in self._items if i.status == 'failed'), + 'cancelled': sum(1 for i in self._items if i.status == 'cancelled'), + }, + } + + def stop(self) -> None: + """Stop the worker (called on server shutdown).""" + with self._cond: + self._stopped = True + self._cond.notify_all() + + # -- internals --------------------------------------------------- + + def _ensure_worker(self) -> None: + """Lazy worker start — only spawn the thread when there's + actually something to process. Caller MUST hold ``_cond``.""" + if self._worker is not None and self._worker.is_alive(): + return + self._worker = threading.Thread( + target=self._run, daemon=True, name='ReorganizeQueueWorker' + ) + self._worker.start() + + def _claim_next_or_wait(self) -> Optional[QueueItem]: + """Atomically pick the next queued item AND flip it to 'running' + under a single lock acquisition. If the queue is empty, block + on ``_cond.wait()`` (which releases the lock while sleeping) + and return None when we're notified or timeout. Returning the + item already-marked-running closes the cancel-vs-run race: a + cancel() call now sees status='running' and is rejected.""" + with self._cond: + while not self._stopped: + for item in self._items: + if item.status == 'queued': + item.status = 'running' + item.started_at = time.time() + return item + # No queued items — wait for an enqueue or shutdown. + # 60s timeout so a stuck notify (shouldn't happen, but + # defensive) doesn't park the worker forever. + self._cond.wait(timeout=60) + return None + + def _run(self) -> None: + """Worker loop: pull next queued, run it, mark done, repeat. + Idles on `_cond.wait()` when queue is empty.""" + logger.info("[Queue] Worker thread started") + while not self._stopped: + item = self._claim_next_or_wait() + if item is None: + # Only happens on shutdown — `_claim_next_or_wait` only + # returns None once `_stopped` is True. Loop back to the + # `while not self._stopped` check, which exits. + continue + logger.info(f"[Queue] Starting '{item.album_title}' (queue_id={item.queue_id})") + + try: + runner = self._runner + if runner is None: + raise RuntimeError("Queue has no runner configured — call set_runner() at startup") + summary = runner(item) + except Exception as e: + logger.error( + f"[Queue] Runner raised for '{item.album_title}': {e}", + exc_info=True, + ) + with self._cond: + item.status = 'failed' + item.error = str(e) + item.finished_at = time.time() + continue + + with self._cond: + item.moved = int(summary.get('moved', 0)) + item.skipped = int(summary.get('skipped', 0)) + item.failed = int(summary.get('failed', 0)) + item.result_status = summary.get('status') + item.result_source = summary.get('source') + errors = summary.get('errors') or [] + if errors: + first_err = errors[0] if isinstance(errors[0], dict) else {'error': str(errors[0])} + item.error = first_err.get('error') or first_err.get('reason') + # 'failed' status only when the run produced concrete failed tracks + # OR ended in a non-completed state (no_source_id / no_album / etc). + item.status = 'failed' if (item.failed > 0 or item.result_status not in (None, 'completed')) else 'done' + item.finished_at = time.time() + # Clear live-progress fields — done items don't need them. + item.current_track = None + item.progress_total = 0 + item.progress_processed = 0 + + logger.info( + f"[Queue] Finished '{item.album_title}' — status={item.status}, " + f"moved={item.moved}, skipped={item.skipped}, failed={item.failed}" + ) + logger.info("[Queue] Worker thread exiting") + + # Called by the runner (or test) to push live progress onto the + # currently-running item. Safe to call from worker thread inside + # reorganize_album's on_progress callback. + def update_active_progress(self, *, queue_id: str, **fields) -> None: + with self._cond: + for item in self._items: + if item.queue_id == queue_id and item.status == 'running': + if 'current_track' in fields: + item.current_track = fields['current_track'] + if 'total' in fields: + item.progress_total = int(fields['total']) + if 'processed' in fields: + item.progress_processed = int(fields['processed']) + if 'moved' in fields: + item.moved = int(fields['moved']) + if 'skipped' in fields: + item.skipped = int(fields['skipped']) + if 'failed' in fields: + item.failed = int(fields['failed']) + return + + +# Module-level singleton accessor --------------------------------------------- + +_singleton: Optional[ReorganizeQueue] = None +_singleton_lock = threading.Lock() + + +def get_queue() -> ReorganizeQueue: + global _singleton + with _singleton_lock: + if _singleton is None: + _singleton = ReorganizeQueue() + return _singleton + + +def reset_queue_for_tests() -> None: + """Test-only: drop the singleton so the next get_queue() returns + a fresh instance. Production code never calls this.""" + global _singleton + with _singleton_lock: + if _singleton is not None: + _singleton.stop() + _singleton = None diff --git a/core/reorganize_runner.py b/core/reorganize_runner.py new file mode 100644 index 00000000..b5fba687 --- /dev/null +++ b/core/reorganize_runner.py @@ -0,0 +1,123 @@ +"""Builds the per-item runner closure that the reorganize queue worker +invokes. Lives outside ``web_server`` so the wiring is unit-testable +and the monolith stays small. + +The runner ties three subsystems together: + +* :func:`core.library_reorganize.reorganize_album` — the orchestrator + that copies files to staging, matches them against the metadata + source, and routes each through the post-process pipeline. +* :func:`core.reorganize_queue.get_queue` — the queue this runner is + registered with; we forward live progress updates back into the + active queue item so the status panel can show per-track state. +* The dependency callbacks injected by ``web_server`` (DB accessor, + resolve-file-path, post-process function, empty-dir cleanup, + shutdown signal). These are passed in rather than imported so the + module stays testable in isolation. + +Config (download path / transfer path) is read **per run**, not at +module load. That way a user changing their download path in settings +takes effect on the next reorganize without needing a server restart. +""" + +import os +from typing import Callable, Optional + +from utils.logging_config import get_logger + +logger = get_logger("reorganize_runner") + + +def build_runner( + *, + get_database: Callable[[], object], + resolve_file_path_fn: Callable[[Optional[str]], Optional[str]], + post_process_fn: Callable[[str, dict, str], None], + cleanup_empty_directories_fn: Callable[[str, str], None], + is_shutting_down_fn: Callable[[], bool], + get_download_path: Callable[[], str], + get_transfer_path: Callable[[], str], +) -> Callable[[object], dict]: + """Return the closure the queue worker invokes per item. + + Args: + get_database: Returns the live MusicDatabase singleton. + resolve_file_path_fn: Resolves a DB-stored file path to the + actual on-disk path (or ``None`` if missing). + post_process_fn: ``_post_process_matched_download``. Must set + ``context['_final_processed_path']`` on success. + cleanup_empty_directories_fn: Called as + ``cleanup_empty_directories_fn(transfer_dir, marker_path)`` + to prune empty source dirs after a track is moved. + is_shutting_down_fn: Returns True when the server is shutting + down so the orchestrator can abort early. + get_download_path: Resolves the user's configured download + path *at call time* (so config changes apply live). + get_transfer_path: Same, for the transfer path. + + Returns: + A callable ``runner(item)`` suitable for + :meth:`core.reorganize_queue.ReorganizeQueue.set_runner`. + """ + from core.library_reorganize import reorganize_album + from core.reorganize_queue import get_queue + + def _update_track_path(track_id, new_path): + try: + db = get_database() + with db._get_connection() as conn: + conn.execute( + "UPDATE tracks SET file_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (new_path, str(track_id)), + ) + conn.commit() + except Exception as db_err: + logger.warning(f"[Reorganize] DB path update failed for {track_id}: {db_err}") + + def runner(item): + # Read config per-run so the user changing their download path + # in Settings takes effect on the next reorganize without a + # server restart. + download_dir = get_download_path() + transfer_dir = get_transfer_path() + staging_root = os.path.join(download_dir, 'ssync_staging') + try: + os.makedirs(staging_root, exist_ok=True) + except OSError as mk_err: + logger.error(f"[Reorganize] Cannot create staging dir {staging_root}: {mk_err}") + return { + 'status': 'setup_failed', + 'source': None, + 'total': 0, 'moved': 0, 'skipped': 0, 'failed': 0, + 'errors': [{'error': f'Could not create staging dir: {mk_err}'}], + } + + def _cleanup_empty(src_dir): + try: + cleanup_empty_directories_fn(transfer_dir, os.path.join(src_dir, '_')) + except Exception: + pass + + def _on_progress(updates): + try: + get_queue().update_active_progress(queue_id=item.queue_id, **updates) + except Exception: + # Progress fan-out failures must never break a run. + pass + + return reorganize_album( + album_id=item.album_id, + db=get_database(), + staging_root=staging_root, + resolve_file_path_fn=resolve_file_path_fn, + post_process_fn=post_process_fn, + update_track_path_fn=_update_track_path, + cleanup_empty_dir_fn=_cleanup_empty, + transfer_dir=transfer_dir, + on_progress=_on_progress, + primary_source=item.source, + strict_source=bool(item.source), + stop_check=is_shutting_down_fn, + ) + + return runner diff --git a/core/repair_jobs/album_completeness.py b/core/repair_jobs/album_completeness.py index 999597dd..c92363f3 100644 --- a/core/repair_jobs/album_completeness.py +++ b/core/repair_jobs/album_completeness.py @@ -7,6 +7,7 @@ from core.metadata_service import ( ) from core.repair_jobs import register_job from core.repair_jobs.base import JobContext, JobResult, RepairJob +from core.worker_utils import set_album_api_track_count from utils.logging_config import get_logger logger = get_logger("repair_job.album_complete") @@ -19,9 +20,10 @@ class AlbumCompletenessJob(RepairJob): description = 'Checks if all tracks from albums are present' help_text = ( 'Compares the number of tracks you have for each album against the expected total ' - 'from the active metadata provider first, then other supported sources if needed. ' - 'Albums where tracks are missing get flagged as findings with details about which ' - 'tracks are absent.\n\n' + 'from your configured metadata sources. Counts cached during normal enrichment are ' + 'used when available; otherwise the job queries a metadata source directly. Albums ' + 'where tracks are missing get flagged as findings with details about which tracks ' + 'are absent.\n\n' 'Useful for catching partial downloads or albums where some tracks failed to download. ' 'You can use the Download Missing feature from the album page to fill gaps.\n\n' 'Settings:\n' @@ -53,6 +55,7 @@ class AlbumCompletenessJob(RepairJob): conn = None has_itunes = False has_deezer = False + has_api_track_count = False try: conn = context.db._get_connection() cursor = conn.cursor() @@ -65,17 +68,31 @@ class AlbumCompletenessJob(RepairJob): has_discogs = 'discogs_id' in columns has_hydrabase = 'soul_id' in columns - # Build SELECT with available source ID columns + # Detect the `api_track_count` column — older DBs may not have it + # yet (migration runs on app start, but repair-job code mustn't + # assume it's present). When absent, fall back to the pre-column + # behavior: look up expected total via API every scan, don't try + # to persist it. + has_api_track_count = 'api_track_count' in columns + + # Build SELECT with available source ID columns. + # NOTE: `al.track_count` is deliberately NOT selected. That + # column holds the OBSERVED track count written by server syncs + # (Plex leafCount, SoulSync standalone len(tracks)) — always + # equal to COUNT(t.id), so it's worthless for completeness. + # The expected total comes from `al.api_track_count` (cached + # from metadata-source enrichment) or a live API lookup. select_cols = [ ('al.id', 'album_id'), ('al.title', 'album_title'), ('ar.name', 'artist_name'), ('al.spotify_album_id', 'spotify_album_id'), - ('al.track_count', 'track_count'), ('COUNT(t.id)', 'actual_count'), ('al.thumb_url', 'album_thumb_url'), ('ar.thumb_url', 'artist_thumb_url'), ] + if has_api_track_count: + select_cols.append(('al.api_track_count', 'api_track_count')) if has_itunes: select_cols.append(('al.itunes_album_id', 'itunes_album_id')) if has_deezer: @@ -135,7 +152,6 @@ class AlbumCompletenessJob(RepairJob): title = row[column_index['album_title']] artist_name = row[column_index['artist_name']] spotify_album_id = row[column_index['spotify_album_id']] - db_track_count = row[column_index['track_count']] actual_count = row[column_index['actual_count']] album_thumb = row[column_index['album_thumb_url']] artist_thumb = row[column_index['artist_thumb_url']] @@ -143,6 +159,9 @@ class AlbumCompletenessJob(RepairJob): deezer_album_id = row[column_index['deezer_album_id']] if 'deezer_album_id' in column_index else None discogs_album_id = row[column_index['discogs_album_id']] if 'discogs_album_id' in column_index else None hydrabase_album_id = row[column_index['hydrabase_album_id']] if 'hydrabase_album_id' in column_index else None + # Cached authoritative track count from a prior API lookup (NULL + # on unscanned albums and on DBs predating the column migration). + cached_api_count = row[column_index['api_track_count']] if 'api_track_count' in column_index else None result.scanned += 1 @@ -154,9 +173,6 @@ class AlbumCompletenessJob(RepairJob): log_type='info' ) - # If we don't know the expected track count, try to get it from an API - expected_total = db_track_count - album_ids = { 'spotify': spotify_album_id or '', 'itunes': itunes_album_id or '', @@ -165,8 +181,20 @@ class AlbumCompletenessJob(RepairJob): 'hydrabase': hydrabase_album_id or '', } + # Expected total comes from the metadata provider, NOT from + # al.track_count — that column holds the observed count from + # server syncs (Plex leafCount, SoulSync standalone len(tracks)) + # which by definition always equals actual_count and made the + # job skip every album. Use the cached api_track_count if a + # prior scan already looked it up; otherwise hit the API and + # persist the answer for next time. + expected_total = cached_api_count if not expected_total: expected_total = self._get_expected_total(context, primary_source, album_ids) + # Only persist positive results. Zero/None would keep + # re-triggering the lookup on every scan. + if expected_total and expected_total > 0 and has_api_track_count: + self._save_api_track_count(context, album_id, expected_total) # Skip singles/EPs based on expected track count (not local count) if expected_total and expected_total < min_tracks: @@ -251,6 +279,27 @@ class AlbumCompletenessJob(RepairJob): result.scanned, result.findings_created) return result + def _save_api_track_count(self, context, album_id, count): + """Persist a metadata-API track count via the shared worker helper. + + Enrichment workers call `set_album_api_track_count` inside their own + `_update_album` transaction. Here we're in the repair job's fallback + path (the album wasn't enriched yet), so we own the connection + + commit ourselves. A cache-write failure must never break the scan, + so all errors are swallowed into the debug log. + """ + conn = None + try: + conn = context.db._get_connection() + cursor = conn.cursor() + set_album_api_track_count(cursor, album_id, count) + conn.commit() + except Exception as e: + logger.debug("Failed to cache api_track_count for album %s: %s", album_id, e) + finally: + if conn: + conn.close() + def _get_expected_total(self, context, primary_source, album_ids): """Try to get the expected track count from the active metadata provider first.""" for source in get_source_priority(primary_source): diff --git a/core/seasonal_discovery.py b/core/seasonal_discovery.py index 0e7dfadc..c14ee8e2 100644 --- a/core/seasonal_discovery.py +++ b/core/seasonal_discovery.py @@ -371,8 +371,13 @@ class SeasonalDiscoveryService: config = SEASONAL_CONFIG[season_key] keywords = config['keywords'] - # Use the right track ID column based on source - track_id_col = 'spotify_track_id' if source == 'spotify' else 'itunes_track_id' + # Each source stores IDs in its own column + if source == 'itunes': + track_id_col = 'itunes_track_id' + elif source == 'deezer': + track_id_col = 'deezer_track_id' + else: + track_id_col = 'spotify_track_id' seasonal_tracks = [] diff --git a/core/socketio_cors.py b/core/socketio_cors.py new file mode 100644 index 00000000..425a464e --- /dev/null +++ b/core/socketio_cors.py @@ -0,0 +1,256 @@ +"""Socket.IO CORS allow-list resolution + rejection logging. + +Three concerns lifted out of `web_server.py`: + +- :func:`resolve_cors_origins` — read the user's + ``security.cors_origins`` config setting (string, list, or unset) and + return what to hand to Flask-SocketIO's ``cors_allowed_origins`` + parameter: ``None`` (engineio same-origin default — the secure + default), the literal ``'*'`` (wildcard, opt-in), or a list of + explicit origin URLs. + +- :func:`will_reject` — predict whether engineio's CORS check will + reject a request, given the resolved allow-list, the request's + ``Origin`` header, and the request's ``Host`` header. Used to log a + helpful warning *before* engineio silently 403s a WebSocket upgrade. + (Without this, the user just sees a half-broken UI with no live + updates and nothing in the logs explaining why.) + +- :class:`RejectionLogger` — threadsafe dedup wrapper around the warning + emitter. Each unique origin is logged once per process so a malicious + site repeatedly hammering the WS endpoint can't spam logs. + +Pure logic, no Flask app dependency. Web_server.py imports these and +wires them into the SocketIO init + a Flask ``before_request`` hook. +""" + +from __future__ import annotations + +import threading +from typing import Any, List, Optional, Set, Union + + +# What ``cors_allowed_origins`` accepts and what we hand to Flask-SocketIO: +# +# - ``None`` → engineio's same-origin default. engineio computes the +# allowed origin list from the request itself: ``scheme://HTTP_HOST`` +# plus ``X-Forwarded-Proto://X-Forwarded-Host`` when those headers are +# present. Reverse proxies that set X-Forwarded-Host (Nginx with +# ``proxy_set_header X-Forwarded-Host`` — and Caddy/Traefik by default) +# work transparently. THE SECURE DEFAULT. +# +# - ``'*'`` → allow any origin. Insecure; opt-in only. +# +# - ``[origin, ...]`` → explicit allow-list. For setups whose Origin +# matches neither the backend's Host nor any forwarded header. +# +# IMPORTANT: do NOT use ``[]``. In engineio that means "disable CORS +# handling entirely" (server.py:202: ``if cors_allowed_origins != []:``) +# which is identical to the ``'*'`` wildcard from a security standpoint. +ResolvedOrigins = Union[List[str], str, None] + + +def resolve_cors_origins(config_manager: Any) -> ResolvedOrigins: + """Resolve the configured Socket.IO allow-list. + + Reads ``security.cors_origins`` from ``config_manager`` and normalizes + whatever shape the user typed (or didn't) into one of three values: + + - ``None`` (the secure default). Hand to Flask-SocketIO and engineio + enforces same-origin, with automatic support for X-Forwarded-Host + so reverse-proxy users don't need to configure anything. + - ``'*'`` — literal wildcard. Allows any origin. Insecure; opt-in. + - ``[origin, ...]`` — list of explicit origin URLs. For users behind + a proxy that doesn't send the forwarded headers OR for custom + contexts (Electron wrappers, browser extensions). + + Accepts the config value as either a string (comma OR newline + separated, since the settings UI is a textarea) or a list. Anything + else falls back to ``None`` — the secure default. + """ + raw = config_manager.get('security.cors_origins', None) if config_manager else None + if raw is None: + return None + if isinstance(raw, str): + if not raw.strip(): + return None + parts = [p.strip() for p in raw.replace('\n', ',').split(',')] + elif isinstance(raw, (list, tuple)): + # Drop non-string entries instead of stringifying — `[None]` would + # otherwise coerce to ``['None']`` and become a junk allow-list entry. + parts = [p.strip() for p in raw if isinstance(p, str)] + else: + return None + parts = [p for p in parts if p] + if not parts: + return None + if any(p == '*' for p in parts): + return '*' + return parts + + +def will_reject( + allowed: ResolvedOrigins, + origin: Optional[str], + host: str, + request_scheme: str = '', + forwarded_host: str = '', + forwarded_proto: str = '', +) -> bool: + """Predict whether engineio's CORS check will reject this request. + + Mirrors engineio's allow-list / same-origin logic so callers can log + a helpful warning *before* the rejection happens. Returns ``True`` + when the request will be rejected. + + Same-origin check: engineio builds full ``{scheme}://{host}`` strings + from the request URL — and adds a second candidate from the + forwarded headers when EITHER ``X-Forwarded-Proto`` OR + ``X-Forwarded-Host`` is present (engineio falls back to the request + Host / scheme for whichever forwarded header is missing). We mirror + that exactly. Comparing scheme matters: a TLS-terminating proxy can + leave the backend seeing ``http://soulsync.foo`` while the browser's + Origin is ``https://soulsync.foo`` — engineio treats those as + different strings and rejects, so we should too. + + Defensive against ``None`` / empty origin: returns ``False`` (allow), + matching engineio's actual behavior (server.py:207: ``if origin:`` + skips the validation block entirely when no Origin header is sent). + Browsers always send Origin for WebSocket upgrades, so this only + matters for non-browser clients like ``curl`` — which engineio + intentionally permits. + + ``request_scheme`` is required for an accurate same-origin match — + engineio compares full ``{scheme}://{host}`` strings, so callers + that omit it default to ``'http'``. Production wires Flask's + ``request.scheme`` here, which WSGI guarantees to be non-empty. + """ + if allowed == '*': + return False + if not origin: + return False # Engineio skips CORS validation when no Origin header + if isinstance(allowed, list) and origin in allowed: + return False + + # Engineio's same-origin check builds full {scheme}://{host} strings. + # Build the candidate set from the request + any forwarded headers. + candidates = [] + if host: + scheme = request_scheme or 'http' + candidates.append(f"{scheme}://{host}") + if forwarded_host or forwarded_proto: + # Mirror engineio: when EITHER forwarded header is present, build + # a candidate from both, falling back to the request value for + # whichever is missing. (engineio/base_server.py:_cors_allowed_origins.) + f_host = forwarded_host.split(',')[0].strip() if forwarded_host else host + if f_host: + f_scheme = (forwarded_proto.split(',')[0].strip() + if forwarded_proto + else (request_scheme or 'http')) + candidates.append(f"{f_scheme}://{f_host}") + return origin not in candidates + + +class RejectionLogger: + """Threadsafe dedup wrapper that logs each rejected origin only once. + + Engineio silently 403s WebSocket upgrades from disallowed origins. + Without a log line the user sees a half-broken UI (no live progress, + no toasts) and has no idea what's wrong. This class watches incoming + requests via :meth:`maybe_log` and emits a clear warning the first + time each unique origin appears, telling the user where to add it. + + The dedup set is capped (default 100 unique origins) so a hostile + actor opening connections from many distinct fake origins can't grow + memory unbounded. When the cap is hit, a single overflow warning is + emitted and further rejections are silently dropped until the next + process restart (or :meth:`reset_for_tests` for tests). + """ + + DEFAULT_DEDUP_CAP = 100 + + def __init__(self, logger: Any, dedup_cap: int = DEFAULT_DEDUP_CAP): + self._logger = logger + self._seen: Set[str] = set() + self._lock = threading.Lock() + try: + self._cap = max(1, int(dedup_cap)) + except (TypeError, ValueError): + self._cap = self.DEFAULT_DEDUP_CAP + self._overflow_warned = False + + def maybe_log( + self, + allowed: ResolvedOrigins, + origin: Optional[str], + host: str, + request_scheme: str = '', + forwarded_host: str = '', + forwarded_proto: str = '', + ) -> bool: + """Log a rejection warning if applicable, deduped. + + Returns ``True`` if a warning was emitted this call. Designed to + be safe to call from a Flask ``before_request`` hook on every + Socket.IO request — it short-circuits early on requests that + won't be rejected (no Origin header, allowed origin, same-origin + match against Host / X-Forwarded-Host with proper scheme). + """ + if not will_reject(allowed, origin, host, request_scheme, + forwarded_host, forwarded_proto): + return False + + # Pick the message to emit (or bail) under the lock. Actual + # logger.warning() call happens AFTER the lock releases — keeps + # the critical section minimal and avoids holding our lock while + # the logging framework acquires its own internal locks. + msg: Optional[str] = None + with self._lock: + if origin in self._seen: + return False + if len(self._seen) >= self._cap: + if self._overflow_warned: + return False # Already emitted overflow notice; suppress. + self._overflow_warned = True + msg = ( + f"[Socket.IO] Rejection-log dedup cache hit cap " + f"({self._cap} unique origins). Suppressing further " + f"rejection warnings this session — likely indicates " + f"hostile traffic or a misconfigured client. Restart " + f"to reset the cache." + ) + else: + self._seen.add(origin) + msg = ( + f"[Socket.IO] Rejecting WebSocket connection from origin " + f"'{origin}' (request Host='{host}'). If this is your " + f"reverse-proxy or custom domain, add it to " + f"Settings → Security → Allowed WebSocket Origins." + ) + self._logger.warning(msg) + return True + + def reset_for_tests(self) -> None: + """Clear the dedup cache. Test-only.""" + with self._lock: + self._seen.clear() + self._overflow_warned = False + + +def log_startup_status(allowed: ResolvedOrigins, logger: Any) -> None: + """Emit a one-shot startup log line describing the resolved policy. + + - For ``'*'`` (wildcard) → warning, since it's a security risk. + - For a non-empty list → info, so the user can confirm their config + took effect. + - For ``None`` (same-origin default) → silent. That's the default; + nothing noteworthy. + """ + if allowed == '*': + logger.warning( + "[Socket.IO] cors_allowed_origins is set to '*' — any website can open " + "a WebSocket to this instance. Set Settings → Security → Allowed Origins " + "to a specific list (or leave empty for same-origin only) to lock this down." + ) + elif allowed: + logger.info(f"[Socket.IO] Allowed cross-origin connections from: {allowed}") diff --git a/core/spotify_client.py b/core/spotify_client.py index 90c2f336..322e9021 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -63,9 +63,15 @@ _rate_limit_first_hit = 0 # Timestamp of the first hit in the current escalat _LONG_RATE_LIMIT_THRESHOLD = 60 # seconds # After a ban expires, wait this long before making any auth probe calls. -# This prevents the "immediate re-probe → re-ban" cycle where Spotify's server-side -# cooldown outlasts the Retry-After value they sent us. -_POST_BAN_COOLDOWN = 300 # 5 minutes +# This prevents the "immediate re-probe → re-ban" cycle where Spotify's +# server-side cooldown outlasts the Retry-After (or our default ban +# duration) we used. A user who'd just sat through a 4-hour MAX_RETRIES +# ban had it expire, hit our 5-minute cooldown, made a single +# get_artist_albums call 32 seconds after the cooldown ended, and got +# slapped with another 4-hour ban — the post-ban cooldown was too short +# for Spotify's server to forget the previous offense. 30 minutes is a +# better empirical floor; can be revisited if reports persist. +_POST_BAN_COOLDOWN = 1800 # 30 minutes # Escalation: if we get rate limited again within this window, increase ban duration _ESCALATION_WINDOW = 3600 # 1 hour — if re-limited within this, escalate diff --git a/core/spotify_worker.py b/core/spotify_worker.py index 89a7935b..dde78781 100644 --- a/core/spotify_worker.py +++ b/core/spotify_worker.py @@ -8,7 +8,7 @@ from datetime import datetime, date, timedelta from utils.logging_config import get_logger from database.music_database import MusicDatabase from core.spotify_client import SpotifyClient, SpotifyRateLimitError -from core.worker_utils import interruptible_sleep +from core.worker_utils import interruptible_sleep, set_album_api_track_count logger = get_logger("spotify_worker") @@ -782,6 +782,10 @@ class SpotifyWorker: WHERE id = ? AND (year IS NULL OR year = '' OR year = '0') """, (year, album_id)) + # Cache the authoritative expected track count for the Album + # Completeness repair job (see set_album_api_track_count docstring). + set_album_api_track_count(cursor, album_id, getattr(album_obj, 'total_tracks', 0)) + conn.commit() except Exception as e: logger.error(f"Error updating album #{album_id} with Spotify data: {e}") diff --git a/core/tidal_download_client.py b/core/tidal_download_client.py index 31884e5c..0823e56d 100644 --- a/core/tidal_download_client.py +++ b/core/tidal_download_client.py @@ -76,6 +76,86 @@ if tidalapi is not None: QUALITY_MAP['hires']['tidal_quality'] = tidalapi.Quality.hi_res_lossless +# Ordering of Tidal's audioQuality values, worst to best. Used to accept +# tier upgrades (Tidal serving higher than the user asked) while still +# rejecting downgrades. Values are the strings tidalapi's `Quality` enum +# exposes — and the strings Tidal's API returns in the `audioQuality` +# field. `HI_RES` (legacy MQA) isn't in the modern `Quality` enum but +# may still come back for old catalog tracks; we rank it below +# `HI_RES_LOSSLESS` so it's treated as a downgrade when the user asked +# for true HiRes lossless. +_QUALITY_RANK = { + 'LOW': 1, + 'HIGH': 2, + 'LOSSLESS': 3, + 'HI_RES': 4, + 'HI_RES_LOSSLESS': 5, +} + + +def _verify_stream_tier(stream, q_info: dict, q_key: str) -> Tuple[bool, Optional[str]]: + """Return ``(True, None)`` when the tier Tidal actually served is + acceptable (same as requested, or a higher tier), ``(False, reason)`` + when Tidal silently downgraded. + + Tidal's API degrades quality without raising: ask for HI_RES_LOSSLESS + on a track that's only in LOW_320K and you get LOW_320K back with no + error. The downloader used to accept that and write the resulting + AAC file, which defeated "HiRes only" with no fallback and made the + worker's fallback chain ineffective (every tier "succeeded" at the + first one that returned anything). + + We accept upgrades because Tidal occasionally serves a higher tier + than requested on tracks flagged as such in its catalog — rejecting + a higher quality than asked for would be user-hostile. + + Defensive paths: + - No ``audio_quality`` on the stream (older tidalapi builds): pass + through, let the pre-existing codec / file-size guards decide. + - QUALITY_MAP entry without ``tidal_quality`` (tidalapi wasn't + importable at module load): pass through for the same reason. + - Unrecognized served quality value (new Tidal tier we haven't + mapped yet): reject, surfacing a "can't verify" reason so the + next tier gets a chance or the final diagnostic names the + unknown value. + """ + served = getattr(stream, 'audio_quality', None) + expected = q_info.get('tidal_quality') + if served is None or expected is None: + return True, None + + # Both sides may be enum instances (str subclass) or plain strings; + # coerce to str to compare values only. + served_str = str(served) + expected_str = str(expected) + + if served_str == expected_str: + return True, None + + served_rank = _QUALITY_RANK.get(served_str) + expected_rank = _QUALITY_RANK.get(expected_str) + + if expected_rank is None: + # Shouldn't happen — every entry in QUALITY_MAP resolves to a + # known tier. If it does, don't reject valid downloads. + return True, None + + if served_rank is None: + return False, ( + f"{q_key}: Tidal returned unrecognized audioQuality " + f"'{served_str}' — can't verify the tier matches '{expected_str}'" + ) + + if served_rank >= expected_rank: + return True, None + + return False, ( + f"{q_key}: Tidal served '{served_str}' instead of " + f"'{expected_str}' — account tier, track licensing, " + f"or region doesn't permit {q_key} for this track" + ) + + class TidalDownloadClient: """ Tidal download client using tidalapi. @@ -185,8 +265,16 @@ class TidalDownloadClient: login, future = self.session.login_oauth() self._device_auth_future = future + # tidalapi returns `verification_uri_complete` as a schemeless + # string like `link.tidal.com/ABCDE`. Passing that straight to + # an makes the browser treat it as a relative URL and + # route it back to the SoulSync origin, so normalize to a + # full https:// URL here. + raw_uri = login.verification_uri_complete or f"link.tidal.com/{login.user_code}" + if not raw_uri.startswith(('http://', 'https://')): + raw_uri = f"https://{raw_uri}" self._device_auth_link = { - 'verification_uri': login.verification_uri_complete or f"https://link.tidal.com/{login.user_code}", + 'verification_uri': raw_uri, 'user_code': login.user_code, } logger.info(f"Tidal device auth started — code: {login.user_code}") @@ -654,6 +742,13 @@ class TidalDownloadClient: logger.warning(f"Quality {q_key} returned no stream, trying next") quality_error_reasons.append(reason) continue + + ok, reason = _verify_stream_tier(stream, q_info, q_key) + if not ok: + logger.warning(reason) + quality_error_reasons.append(reason) + continue + logger.info(f"Got Tidal stream at quality: {q_key}") except Exception as e: reason = f"{q_key}: {type(e).__name__}: {e}" @@ -672,7 +767,8 @@ class TidalDownloadClient: download_url = urls[0] - # Determine file extension from manifest + # Determine file extension from manifest codec (HiRes FLAC + # can arrive wrapped in MP4 — unwrapped at Step 4). codec = manifest.get_codecs() if codec and 'flac' in codec.lower(): extension = 'flac' @@ -683,20 +779,6 @@ class TidalDownloadClient: else: extension = q_info.get('extension', 'flac') - # Verify quality wasn't silently downgraded: if HiRes was requested but the - # codec/manifest points to standard FLAC, log a clear warning. - if q_key == 'hires' and codec: - codec_lower = codec.lower() - if 'flac' in codec_lower or 'alac' in codec_lower: - # HiRes should be 24-bit — we can't confirm bit-depth from the codec - # string alone, but we log the received codec so users can diagnose. - logger.info(f"HiRes stream codec: {codec} (verify file bit-depth after download)") - elif 'mp4a' in codec_lower or 'aac' in codec_lower: - logger.warning( - f"HiRes requested but received AAC stream (codec: {codec}) — " - f"account may not have HiRes subscription or track isn't available in HiRes" - ) - # Build output filename safe_name = re.sub(r'[<>:"/\\|?*]', '_', display_name) out_filename = f"{safe_name}.{extension}" diff --git a/core/worker_utils.py b/core/worker_utils.py index 0c61fb0a..d6f42523 100644 --- a/core/worker_utils.py +++ b/core/worker_utils.py @@ -1,7 +1,10 @@ """Shared helpers for background workers.""" +import logging import threading +logger = logging.getLogger(__name__) + def interruptible_sleep(stop_event: threading.Event, seconds: float, step: float = 0.5) -> bool: """Sleep in chunks so shutdown can interrupt long waits.""" @@ -15,3 +18,52 @@ def interruptible_sleep(stop_event: threading.Event, seconds: float, step: float break remaining -= wait_for return stop_event.is_set() + + +def set_album_api_track_count(cursor, album_id, count): + """Cache an album's authoritative track count from a metadata source. + + Called by enrichment workers (Spotify / iTunes / Deezer / Discogs) after + they fetch album metadata. The count is the EXPECTED total tracks + according to that source — distinct from `albums.track_count`, which + server syncs (Plex `leafCount`, SoulSync standalone `len(tracks)`) + populate with the OBSERVED count SoulSync already has indexed. The + Album Completeness repair job reads `albums.api_track_count` as the + expected total; populating it here during enrichment avoids a second + round of API calls during the repair scan. + + Skips the write when the source didn't supply a positive numeric count + (None, 0, negative, or non-numeric) — that way a source lacking track + info doesn't overwrite a good value another source already wrote. If + multiple sources report different counts (rare, usually deluxe vs. + standard edition), last-write-wins across enrichment cycles; that's + fine since any metadata-source count is strictly better than the + observed-count fallback that the repair job used before this column + existed. + + Caller owns the cursor (and its connection / transaction) — this + helper does not commit. Integrates with each worker's existing + `_update_album` method, which already batches several UPDATEs into + one transaction. + """ + try: + count = int(count or 0) + except (TypeError, ValueError): + return + if count <= 0: + return + # Swallow SQL errors — each worker batches several album UPDATEs into + # one transaction, and we don't want a failure here (e.g., the + # migration somehow hasn't run yet and the column is missing) to + # rollback the worker's other writes (spotify_album_id, thumb_url, + # etc.). The repair job's fallback path will eventually populate the + # column via its own save path once the column exists. + try: + cursor.execute( + "UPDATE albums SET api_track_count = ? WHERE id = ?", + (count, album_id), + ) + except Exception as e: + logger.warning( + "Failed to cache api_track_count for album %s: %s", album_id, e + ) diff --git a/database/music_database.py b/database/music_database.py index 7b2be55f..a75a0b61 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -636,7 +636,8 @@ class MusicDatabase: playlist_folder_mode INTEGER DEFAULT 0, started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, completed_at TIMESTAMP, - track_results TEXT + track_results TEXT, + server_push_status TEXT ) """) cursor.execute("CREATE INDEX IF NOT EXISTS idx_sh_started_at ON sync_history (started_at DESC)") @@ -662,6 +663,16 @@ class MusicDatabase: except Exception: pass + # Migration: add server_push_status column to sync_history + try: + cursor.execute("SELECT server_push_status FROM sync_history LIMIT 1") + except Exception: + try: + cursor.execute("ALTER TABLE sync_history ADD COLUMN server_push_status TEXT") + logger.info("Added server_push_status column to sync_history table") + except Exception: + pass + # Migration: add track_artist column for per-track artist on compilations/DJ mixes try: cursor.execute("SELECT track_artist FROM tracks LIMIT 1") @@ -3122,6 +3133,19 @@ class MusicDatabase: cursor.execute("ALTER TABLE albums ADD COLUMN soul_id TEXT DEFAULT NULL") logger.info("Added soul_id column to albums table") + # Albums: api_track_count — cached expected track count from the + # metadata provider, separate from track_count which is the + # OBSERVED count written by server syncs (Plex leafCount, + # SoulSync standalone len(tracks)). Without a separate column, + # the Album Completeness job can't tell apart "you have all the + # tracks" from "Plex says this album has N tracks and you have + # N tracks" — the latter looks complete but might be missing + # material the metadata source knows about. NULL = not yet + # looked up; the repair job fills it as it runs. + if 'api_track_count' not in album_cols: + cursor.execute("ALTER TABLE albums ADD COLUMN api_track_count INTEGER DEFAULT NULL") + logger.info("Added api_track_count column to albums table") + # Tracks: soul_id (song-level) + album_soul_id (release-specific) cursor.execute("PRAGMA table_info(tracks)") track_cols = [c[1] for c in cursor.fetchall()] @@ -4715,6 +4739,10 @@ class MusicDatabase: 'audiodb_id', 'audiodb_match_status', 'audiodb_last_attempted', 'style', 'mood', 'label', 'explicit', 'record_type', 'deezer_id', 'deezer_match_status', 'deezer_last_attempted', + # api_track_count is metadata-source-derived enrichment cache; + # losing it on a ratingKey rekey would force the next + # completeness scan back to live API lookups (kettui PR #374). + 'api_track_count', ] # Read enrichment data from old album @@ -4778,6 +4806,63 @@ class MusicDatabase: logger.error(f"Error inserting/updating {server_source} album {getattr(album_obj, 'title', 'Unknown')}: {e}") return False + def get_album_display_meta(self, album_id) -> Optional[Dict[str, Any]]: + """Return ``{album_title, artist_id, artist_name}`` for an album row. + + Used by the reorganize queue enqueue endpoint to capture display + strings at submission time so the status panel can render + without a DB lookup per poll. Returns None when the album row + does not exist; lets DB errors bubble up so callers can surface + a real failure instead of swallowing it as "album not found". + """ + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + """ + SELECT al.title AS album_title, + ar.id AS artist_id, + ar.name AS artist_name + FROM albums al + JOIN artists ar ON al.artist_id = ar.id + WHERE al.id = ? + """, + (str(album_id),), + ) + row = cursor.fetchone() + if not row: + return None + return { + 'album_title': row['album_title'] or 'Unknown Album', + 'artist_id': str(row['artist_id']) if row['artist_id'] is not None else None, + 'artist_name': row['artist_name'] or 'Unknown Artist', + } + + def get_artist_albums_for_reorganize(self, artist_id) -> List[Dict[str, Any]]: + """Return ``[{album_id, album_title, artist_id, artist_name}, ...]`` + for every album owned by ``artist_id``, ordered by year then + title. Used by the bulk Reorganize-All endpoint to pull the + full tracklist server-side instead of trusting whatever the + frontend cached. Returns an empty list when the artist has no + albums; lets DB errors bubble so a real failure surfaces as a + 500 rather than masquerading as "no albums found". + """ + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + """ + SELECT al.id AS album_id, + al.title AS album_title, + ar.id AS artist_id, + ar.name AS artist_name + FROM albums al + JOIN artists ar ON al.artist_id = ar.id + WHERE ar.id = ? + ORDER BY al.year ASC, al.title ASC + """, + (str(artist_id),), + ) + return [dict(r) for r in cursor.fetchall()] + def get_albums_by_artist(self, artist_id: int) -> List[DatabaseAlbum]: """Get all albums by artist ID""" try: @@ -10497,6 +10582,20 @@ class MusicDatabase: logger.debug(f"Error updating sync history track results: {e}") return False + def update_sync_history_push_status(self, batch_id, status): + """Update the server push status for a sync_history entry.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE sync_history SET server_push_status = ? WHERE batch_id = ? + """, (status, batch_id)) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.debug(f"Error updating sync history push status: {e}") + return False + def refresh_sync_history_entry(self, entry_id, tracks_found=0, tracks_downloaded=0, tracks_failed=0): """Update an existing sync_history entry with new stats and reset timestamps to move it to the top.""" try: @@ -10611,7 +10710,8 @@ class MusicDatabase: cursor.execute(""" SELECT id, batch_id, playlist_name, source, sync_type, source_page, total_tracks, tracks_found, tracks_downloaded, tracks_failed, - thumb_url, is_album_download, started_at, completed_at + thumb_url, is_album_download, started_at, completed_at, + server_push_status FROM sync_history WHERE completed_at IS NOT NULL AND started_at >= datetime('now', ? || ' days') diff --git a/entrypoint.sh b/entrypoint.sh index 4845aff1..1c9f1382 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -69,10 +69,6 @@ chown -R soulsync:soulsync /app/config /app/data /app/logs /app/downloads /app/T echo "✅ Configuration initialized successfully" -# Auto-update yt-dlp — YouTube changes their API frequently and stale versions break downloads -echo "🔄 Updating yt-dlp..." -pip install -U yt-dlp --quiet --no-cache-dir 2>/dev/null && echo " ✅ yt-dlp updated" || echo " ⚠️ yt-dlp update failed (will use existing version)" - # Display final user info echo "👤 Running as:" echo " User: $(id -u soulsync):$(id -g soulsync) ($(id -un soulsync):$(id -gn soulsync))" diff --git a/requirements.txt b/requirements.txt index 92cdcc06..16d2b801 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,8 +27,8 @@ beautifulsoup4>=4.12.0 # System monitoring psutil>=6.0.0 -# YouTube support -yt-dlp>=2024.12.13 +# YouTube support — pinned for reproducible builds; bump per release. See #367. +yt-dlp==2026.3.17 # Lyrics support lrclibapi>=0.3.1 diff --git a/tests/test_album_completeness_job.py b/tests/test_album_completeness_job.py index f3f8145c..5451839f 100644 --- a/tests/test_album_completeness_job.py +++ b/tests/test_album_completeness_job.py @@ -306,3 +306,311 @@ def test_album_completeness_supports_hydrabase_primary(monkeypatch): assert [track["track_number"] for track in missing_tracks] == [2, 3, 4] assert missing_tracks[0]["source"] == "hydrabase" assert missing_tracks[0]["source_track_id"] == "hy-2" + + +# --------------------------------------------------------------------------- +# api_track_count caching — the fix for the "0.1s / 0 findings" bug +# --------------------------------------------------------------------------- + +class _ApiCountCursor: + """Records UPDATE statements so we can verify the cache write.""" + + def __init__(self): + self.updates = [] + + def execute(self, query, params=None): + if query.strip().startswith("UPDATE albums"): + self.updates.append((query, params)) + return self + + def fetchall(self): + return [] + + +class _ApiCountConnection: + def __init__(self, cursor): + self._cursor = cursor + self.commits = 0 + + def cursor(self): + return self._cursor + + def commit(self): + self.commits += 1 + + def close(self): + return None + + +class _ApiCountDB: + def __init__(self): + self.cursor = _ApiCountCursor() + + def _get_connection(self): + return _ApiCountConnection(self.cursor) + + +def test_save_api_track_count_writes_update_to_db(): + """The helper persists the resolved count so subsequent scans don't + refetch the expected total from the API.""" + job = AlbumCompletenessJob() + db = _ApiCountDB() + context = types.SimpleNamespace(db=db) + + job._save_api_track_count(context, "album-42", 12) + + assert len(db.cursor.updates) == 1 + query, params = db.cursor.updates[0] + assert "UPDATE albums" in query + assert "api_track_count = ?" in query + assert params == (12, "album-42") + + +def test_save_api_track_count_swallows_errors(): + """A cache-write failure must not break the scan — the job falls back + to the pre-cache behavior (API call next time).""" + job = AlbumCompletenessJob() + + class _Boom: + def _get_connection(self): + raise RuntimeError("db is gone") + + context = types.SimpleNamespace(db=_Boom()) + # Should not raise. + job._save_api_track_count(context, "album-x", 10) + + +# --------------------------------------------------------------------------- +# Integration tests — run the full scan loop against a real sqlite in-memory +# DB so SELECT/PRAGMA/UPDATE go through actual SQL. Catches wiring mistakes +# between the SELECT, column_index, loop, and finding creation that the +# isolated helper tests wouldn't surface. +# --------------------------------------------------------------------------- + +import sqlite3 +import uuid + + +class _SharedMemoryDB: + """Tiny shim matching MusicDatabase's `_get_connection()` contract, + backed by a shared-cache sqlite in-memory DB so `close()` on a + per-call connection doesn't destroy the data.""" + + def __init__(self): + self.uri = f"file:testdb_{uuid.uuid4().hex}?mode=memory&cache=shared" + # Keepalive conn holds the in-memory DB alive while other conns + # open/close. Without this, the DB is garbage-collected when the + # last conn closes. + self._keepalive = sqlite3.connect(self.uri, uri=True) + self._keepalive.executescript( + """ + CREATE TABLE artists ( + id TEXT PRIMARY KEY, + name TEXT, + thumb_url TEXT + ); + CREATE TABLE albums ( + id TEXT PRIMARY KEY, + artist_id TEXT, + title TEXT, + thumb_url TEXT, + spotify_album_id TEXT, + itunes_album_id TEXT, + deezer_id TEXT, + discogs_id TEXT, + soul_id TEXT, + track_count INTEGER, + api_track_count INTEGER + ); + CREATE TABLE tracks ( + id TEXT PRIMARY KEY, + album_id TEXT, + track_number INTEGER + ); + """ + ) + self._keepalive.commit() + + def _get_connection(self): + return sqlite3.connect(self.uri, uri=True) + + def insert_artist(self, artist_id, name, thumb=None): + self._keepalive.execute( + "INSERT INTO artists (id, name, thumb_url) VALUES (?, ?, ?)", + (artist_id, name, thumb), + ) + self._keepalive.commit() + + def insert_album(self, album_id, artist_id, title, *, spotify_id=None, + track_count=None, api_track_count=None): + self._keepalive.execute( + """INSERT INTO albums + (id, artist_id, title, spotify_album_id, track_count, api_track_count) + VALUES (?, ?, ?, ?, ?, ?)""", + (album_id, artist_id, title, spotify_id, track_count, api_track_count), + ) + self._keepalive.commit() + + def insert_tracks(self, album_id, count): + rows = [(f"{album_id}-t{i}", album_id, i) for i in range(1, count + 1)] + self._keepalive.executemany( + "INSERT INTO tracks (id, album_id, track_number) VALUES (?, ?, ?)", + rows, + ) + self._keepalive.commit() + + def fetch_api_track_count(self, album_id): + row = self._keepalive.execute( + "SELECT api_track_count FROM albums WHERE id = ?", (album_id,) + ).fetchone() + return row[0] if row else None + + +def _make_job_context(db, *, create_finding): + """Minimal JobContext stand-in covering the fields scan() touches.""" + return types.SimpleNamespace( + db=db, + transfer_folder='', + config_manager=_DummyConfigManager(), + spotify_client=None, + is_spotify_rate_limited=lambda: False, + stop_event=None, + create_finding=create_finding, + should_stop=None, + is_paused=None, + update_progress=None, + report_progress=None, + check_stop=lambda: False, + wait_if_paused=lambda: False, + ) + + +def test_scan_uses_cached_api_track_count_without_expected_total_lookup(monkeypatch): + """Integration: when api_track_count is populated, the scan reads the + expected total from the cache. `_get_expected_total` must NOT be called + for albums with a cached value. (The missing-tracks lookup may still + hit the API for incomplete albums — that's a separate call path used + only after we've decided the album is incomplete.)""" + db = _SharedMemoryDB() + db.insert_artist('a1', 'Test Artist') + db.insert_album('alb-incomplete', 'a1', 'Incomplete Album', + spotify_id='sp-1', track_count=10, api_track_count=12) + db.insert_tracks('alb-incomplete', 10) + db.insert_album('alb-complete', 'a1', 'Complete Album', + spotify_id='sp-2', track_count=8, api_track_count=8) + db.insert_tracks('alb-complete', 8) + + # Stub the track-lookup used by _find_missing_tracks (needed for the + # incomplete album's finding details). We're not asserting on it. + monkeypatch.setattr( + album_completeness_module, + "get_album_tracks_for_source", + lambda source, album_id: {"items": [ + {"track_number": i, "name": f"T{i}", "artists": []} for i in range(1, 13) + ]}, + ) + monkeypatch.setattr(album_completeness_module, "get_primary_source", lambda: "spotify") + monkeypatch.setattr( + album_completeness_module, "get_source_priority", + lambda primary: ["spotify", "itunes", "deezer", "discogs", "hydrabase"], + ) + + # Spy on _get_expected_total specifically — that's the call path the + # cache is supposed to short-circuit. + job = AlbumCompletenessJob() + expected_total_calls = [] + original_get_expected = job._get_expected_total + + def spy(context_, primary_, album_ids_): + expected_total_calls.append(album_ids_.get('spotify')) + return original_get_expected(context_, primary_, album_ids_) + job._get_expected_total = spy + + findings = [] + context = _make_job_context(db, create_finding=lambda **kwargs: findings.append(kwargs)) + + result = job.scan(context) + + # _get_expected_total was NOT called — both albums had cached counts. + assert expected_total_calls == [] + # Exactly one finding for the incomplete album. + assert result.findings_created == 1 + assert len(findings) == 1 + finding = findings[0] + assert finding['entity_id'] == 'alb-incomplete' + assert finding['details']['expected_tracks'] == 12 + assert finding['details']['actual_tracks'] == 10 + + +def test_scan_falls_back_to_api_and_persists_count_on_cache_miss(monkeypatch): + """Integration: when api_track_count is NULL, scan calls the API, + gets the expected total, caches it, and creates the finding.""" + db = _SharedMemoryDB() + db.insert_artist('a1', 'Test Artist') + # api_track_count is NULL — will need API lookup + db.insert_album('alb-fresh', 'a1', 'Fresh Album', + spotify_id='sp-fresh', track_count=8, api_track_count=None) + db.insert_tracks('alb-fresh', 8) + + # API returns 14 tracks (so 8 owned out of 14 → finding) + monkeypatch.setattr( + album_completeness_module, + "get_album_tracks_for_source", + lambda source, album_id: { + "items": [{"track_number": i, "name": f"T{i}", "artists": []} for i in range(1, 15)], + } if source == "spotify" and album_id == "sp-fresh" else None, + ) + monkeypatch.setattr(album_completeness_module, "get_primary_source", lambda: "spotify") + monkeypatch.setattr( + album_completeness_module, "get_source_priority", + lambda primary: ["spotify"], + ) + + findings = [] + context = _make_job_context(db, create_finding=lambda **kwargs: findings.append(kwargs)) + + job = AlbumCompletenessJob() + result = job.scan(context) + + # Finding for 8/14. + assert result.findings_created == 1 + finding = findings[0] + assert finding['details']['expected_tracks'] == 14 + assert finding['details']['actual_tracks'] == 8 + # Crucially: the scan persisted the count so the next scan won't refetch. + assert db.fetch_api_track_count('alb-fresh') == 14 + + +def test_scan_ignores_track_count_completely(monkeypatch): + """Regression: the observed `track_count` (Plex's leafCount) must + NOT influence the expected-total comparison. Before the fix, an + album with track_count=actual_count was skipped as 'complete' even + when the metadata source said it had more tracks.""" + db = _SharedMemoryDB() + db.insert_artist('a1', 'Test Artist') + # track_count=10 matches actual=10 (sassmastawillis's bug scenario). + # api_track_count=15 says the album actually has 15 tracks. + db.insert_album('alb-bug', 'a1', 'Bug Reproduction', + spotify_id='sp-bug', track_count=10, api_track_count=15) + db.insert_tracks('alb-bug', 10) + + monkeypatch.setattr( + album_completeness_module, "get_album_tracks_for_source", + lambda source, album_id: None, # should NOT be called + ) + monkeypatch.setattr(album_completeness_module, "get_primary_source", lambda: "spotify") + monkeypatch.setattr( + album_completeness_module, "get_source_priority", + lambda primary: ["spotify"], + ) + + findings = [] + context = _make_job_context(db, create_finding=lambda **kwargs: findings.append(kwargs)) + + job = AlbumCompletenessJob() + result = job.scan(context) + + # The album MUST be flagged (10/15), not silently skipped. + assert result.findings_created == 1 + assert findings[0]['details']['expected_tracks'] == 15 + assert findings[0]['details']['actual_tracks'] == 10 diff --git a/tests/test_discogs_track_count.py b/tests/test_discogs_track_count.py new file mode 100644 index 00000000..0ad04986 --- /dev/null +++ b/tests/test_discogs_track_count.py @@ -0,0 +1,129 @@ +"""Tests for `discogs_worker.count_discogs_real_tracks` — the filter +that decides which entries in a Discogs tracklist count as real songs +when caching the authoritative track count for the Album Completeness +repair job. + +Reported by kettui on PR #374: the original inline filter only kept +``type_ == 'track'`` rows, but `discogs_client.get_album_tracks` itself +keeps both ``type_ == 'track'`` AND rows with an empty/missing +``type_``. The narrower filter would undercount releases whose Discogs +response left ``type_`` blank for some real tracks — and the repair +job's fallback path (`_get_expected_total`) would silently disagree +with the cached count. +""" + +from core.discogs_worker import count_discogs_real_tracks + + +# --------------------------------------------------------------------------- +# The kettui case: empty type_ counts as a real track +# --------------------------------------------------------------------------- + +def test_empty_type_counts_as_track(): + tracklist = [ + {'title': 'Track 1', 'type_': 'track'}, + {'title': 'Track 2', 'type_': ''}, # <-- the bug + {'title': 'Track 3', 'type_': 'track'}, + ] + assert count_discogs_real_tracks(tracklist) == 3 + + +def test_missing_type_field_counts_as_track(): + """Discogs sometimes omits the field entirely rather than sending + an empty string. Both shapes mean 'real track'.""" + tracklist = [ + {'title': 'Track 1'}, # no type_ key at all + {'title': 'Track 2', 'type_': 'track'}, + ] + assert count_discogs_real_tracks(tracklist) == 2 + + +def test_none_type_counts_as_track(): + """And the field may be present-but-None on some clients/versions.""" + tracklist = [ + {'title': 'Track 1', 'type_': None}, + {'title': 'Track 2', 'type_': 'track'}, + ] + assert count_discogs_real_tracks(tracklist) == 2 + + +# --------------------------------------------------------------------------- +# Non-track rows are excluded (Discogs's structural markers) +# --------------------------------------------------------------------------- + +def test_headings_excluded(): + tracklist = [ + {'title': 'Disc 1', 'type_': 'heading'}, + {'title': 'Track 1', 'type_': 'track'}, + {'title': 'Track 2', 'type_': 'track'}, + {'title': 'Disc 2', 'type_': 'heading'}, + {'title': 'Track 3', 'type_': 'track'}, + ] + assert count_discogs_real_tracks(tracklist) == 3 + + +def test_indices_excluded(): + tracklist = [ + {'title': 'Side A', 'type_': 'index'}, + {'title': 'Track 1', 'type_': 'track'}, + {'title': 'Side B', 'type_': 'index'}, + {'title': 'Track 2', 'type_': 'track'}, + ] + assert count_discogs_real_tracks(tracklist) == 2 + + +def test_sub_tracks_excluded(): + """Sub-tracks (parts of a medley) shouldn't double-count against + the parent track.""" + tracklist = [ + {'title': 'Medley', 'type_': 'track'}, + {'title': 'Part A', 'type_': 'sub_track'}, + {'title': 'Part B', 'type_': 'sub_track'}, + {'title': 'Encore', 'type_': 'track'}, + ] + assert count_discogs_real_tracks(tracklist) == 2 + + +def test_unknown_type_excluded(): + """Conservative — if Discogs adds a new structural marker we + haven't seen, don't count it as a track until we explicitly add + it to the allowlist.""" + tracklist = [ + {'title': 'Track 1', 'type_': 'track'}, + {'title': 'Some Future Marker', 'type_': 'experimental_thing'}, + ] + assert count_discogs_real_tracks(tracklist) == 1 + + +# --------------------------------------------------------------------------- +# Defensive — bad input shouldn't raise +# --------------------------------------------------------------------------- + +def test_empty_tracklist_returns_zero(): + assert count_discogs_real_tracks([]) == 0 + + +def test_none_tracklist_returns_zero(): + assert count_discogs_real_tracks(None) == 0 + + +# --------------------------------------------------------------------------- +# Realistic mixed tracklist (the kettui case in context) +# --------------------------------------------------------------------------- + +def test_realistic_multi_disc_tracklist(): + """A 2-disc release with headings, real tracks, AND a few rows + with empty type_ — should count all real tracks but no markers.""" + tracklist = [ + {'title': 'Disc 1', 'type_': 'heading'}, + {'title': 'Track 1', 'type_': 'track'}, + {'title': 'Track 2', 'type_': 'track'}, + {'title': 'Track 3', 'type_': ''}, # the kettui case + {'title': 'Track 4', 'type_': 'track'}, + {'title': 'Disc 2', 'type_': 'heading'}, + {'title': 'Track 5', 'type_': 'track'}, + {'title': 'Track 6'}, # missing type_ + {'title': 'Bonus Index', 'type_': 'index'}, + {'title': 'Track 7', 'type_': 'track'}, + ] + assert count_discogs_real_tracks(tracklist) == 7 diff --git a/tests/test_library_reorganize_orchestrator.py b/tests/test_library_reorganize_orchestrator.py new file mode 100644 index 00000000..62537b3c --- /dev/null +++ b/tests/test_library_reorganize_orchestrator.py @@ -0,0 +1,1936 @@ +"""Tests for `core.library_reorganize.reorganize_album` — the new +post-processing-pipeline approach (the orchestrator that copies files +to staging and routes them through the same code that handles fresh +downloads, instead of doing per-album template work in web_server). + +Contract this test file pins: + +1. Albums without ANY metadata-source ID return ``status='no_source_id'`` + without staging anything, copying anything, or calling post-process. + Silent degradation to file tags is the failure mode the previous + implementation had; the new contract is "we have the source of + truth or we don't touch the album." +2. Source resolution honors the configured primary first, then walks + ``get_source_priority`` until something returns a tracklist. +3. Each library track is matched to the API tracklist by + ``track_number``. Tracks not in the API response (bonus tracks on a + deluxe edition, etc.) are reported as skipped and left in place — + they are NOT force-fed wrong context to post-process. +4. Files that don't resolve on disk are surfaced as skipped errors + with the offending DB path, not silently dropped. +5. After a successful post-process the original file is removed and + the DB row is updated to the new path. A failed post-process leaves + the original alone so the user doesn't lose data. +6. Staging directory is cleaned up regardless of how the run ends. +""" + +import os +import shutil +import sqlite3 +import sys +import types + +import pytest + + +# --- module stubs (same shape used elsewhere in the test suite) ----------- +if "spotipy" not in sys.modules: + spotipy = types.ModuleType("spotipy") + + class _DummySpotify: + def __init__(self, *args, **kwargs): + pass + + oauth2 = types.ModuleType("spotipy.oauth2") + + class _DummyOAuth: + def __init__(self, *args, **kwargs): + pass + + spotipy.Spotify = _DummySpotify + oauth2.SpotifyOAuth = _DummyOAuth + oauth2.SpotifyClientCredentials = _DummyOAuth + 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 core import library_reorganize # noqa: E402 + + +# --- helpers -------------------------------------------------------------- + +class _FakeDB: + """Wraps a sqlite3 in-memory connection that survives `close()` calls + so the tests can reuse it for assertions after the orchestrator runs.""" + + def __init__(self): + self._conn = sqlite3.connect(":memory:") + self._conn.row_factory = sqlite3.Row + + def _get_connection(self): + return _NonClosingConnWrapper(self._conn) + + +class _NonClosingConnWrapper: + def __init__(self, real): + self._real = real + + def cursor(self): + return self._real.cursor() + + def execute(self, *args, **kwargs): + return self._real.execute(*args, **kwargs) + + def commit(self): + return self._real.commit() + + def close(self): + # Underlying connection survives — tests reuse it. + pass + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + +def _setup_album(db, *, album_id='alb-1', spotify_id='', deezer_id='', + itunes_id='', discogs_id='', soul_id='', tracks=()): + """Build a minimal artists/albums/tracks schema and seed one album. + + `tracks` is a list of `(track_id, track_number, title, file_path)`. + """ + cur = db._conn.cursor() + cur.execute("CREATE TABLE artists (id TEXT PRIMARY KEY, name TEXT)") + cur.execute(""" + CREATE TABLE albums ( + id TEXT PRIMARY KEY, + artist_id TEXT, + title TEXT, + spotify_album_id TEXT, + deezer_id TEXT, + itunes_album_id TEXT, + discogs_id TEXT, + soul_id TEXT + ) + """) + cur.execute(""" + CREATE TABLE tracks ( + id TEXT PRIMARY KEY, + album_id TEXT, + artist_id TEXT, + title TEXT, + track_number INTEGER, + file_path TEXT, + updated_at TEXT + ) + """) + cur.execute("INSERT INTO artists VALUES (?, ?)", ('artist-1', 'Aerosmith')) + cur.execute( + "INSERT INTO albums (id, artist_id, title, spotify_album_id, deezer_id, " + "itunes_album_id, discogs_id, soul_id) VALUES (?,?,?,?,?,?,?,?)", + (album_id, 'artist-1', 'Aerosmith (1973)', spotify_id, deezer_id, + itunes_id, discogs_id, soul_id), + ) + for tid, tn, title, fp in tracks: + cur.execute( + "INSERT INTO tracks (id, album_id, artist_id, title, track_number, file_path) " + "VALUES (?,?,?,?,?,?)", + (tid, album_id, 'artist-1', title, tn, fp), + ) + db._conn.commit() + + +@pytest.fixture +def tmpdirs(tmp_path): + """Three working directories: original library files, staging root, + transfer destination.""" + library = tmp_path / "library" + staging = tmp_path / "staging" + transfer = tmp_path / "transfer" + library.mkdir() + staging.mkdir() + transfer.mkdir() + return library, staging, transfer + + +def _make_audio_file(library_dir, name='song.flac', content=b'fakeflacdata'): + p = library_dir / name + p.write_bytes(content) + return str(p) + + +# --- tests: source resolution --------------------------------------------- + +def test_returns_no_source_id_when_album_has_none(monkeypatch, tmpdirs): + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, tracks=[ + ('t1', 1, 'Same Old Song And Dance', _make_audio_file(library)), + ]) + + pp_calls = [] + + def pp(key, ctx, fp): + pp_calls.append(key) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'spotify') + monkeypatch.setattr(library_reorganize, 'get_source_priority', + lambda p: [p, 'deezer', 'itunes', 'discogs', 'hydrabase']) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', lambda *a: None) + monkeypatch.setattr(library_reorganize, 'get_album_tracks_for_source', lambda *a: None) + + 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, + ) + + assert summary['status'] == 'no_source_id' + assert summary['moved'] == 0 + assert pp_calls == [] + + +def test_falls_through_to_next_source_when_primary_returns_nothing(monkeypatch, tmpdirs): + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, spotify_id='sp-1', deezer_id='dz-1', tracks=[ + ('t1', 1, 'Same Old Song And Dance', _make_audio_file(library)), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'spotify') + monkeypatch.setattr(library_reorganize, 'get_source_priority', + lambda p: [p, 'deezer']) + + def fake_album(src, sid): + return {'id': sid, 'name': 'Aerosmith', 'release_date': '1973-01-01'} \ + if src == 'deezer' else None + + def fake_tracks(src, sid): + return {'items': [{'id': 'dz-t1', 'name': 'Same Old Song And Dance', + 'track_number': 1, 'disc_number': 1}]} \ + if src == 'deezer' else None + + monkeypatch.setattr(library_reorganize, 'get_album_for_source', fake_album) + monkeypatch.setattr(library_reorganize, 'get_album_tracks_for_source', fake_tracks) + + def pp(key, ctx, fp): + ctx['_final_processed_path'] = str(library / 'final.flac') + with open(ctx['_final_processed_path'], '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, + ) + + assert summary['source'] == 'deezer' + assert summary['moved'] == 1 + + +# --- tests: per-track behavior -------------------------------------------- + +def test_multi_disc_album_disambiguates_by_title(monkeypatch, tmpdirs): + """The whole point of moving from track_number-only to title-based + matching: a 2-disc album has track_number=1 on BOTH discs, but the + titles differ. Each library track must end up routed to the API + entry with the matching title — and therefore to the correct + disc_number in the post-process context.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + # Disc 1 track 1: 'Same Old Song And Dance' + ('t1d1', 1, 'Same Old Song And Dance', _make_audio_file(library, 'd1t1.flac')), + # Disc 2 track 1: 'Dream On' + ('t1d2', 1, 'Dream On', _make_audio_file(library, 'd2t1.flac')), + ]) + + 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': 'd1t1', 'name': 'Same Old Song And Dance', 'track_number': 1, 'disc_number': 1}, + {'id': 'd2t1', 'name': 'Dream On', 'track_number': 1, 'disc_number': 2}, + ]}, + ) + + title_to_disc = {} + + def pp(key, ctx, fp): + # Capture which disc_number landed in the per-track context + title_to_disc[ctx['track_info']['name']] = ctx['track_info']['disc_number'] + # Also record total_discs so we can assert it's correct + title_to_disc.setdefault('_total_discs', ctx['spotify_album']['total_discs']) + ctx['_final_processed_path'] = str(library / f"out_{ctx['track_info']['disc_number']}_{ctx['track_info']['track_number']}.flac") + with open(ctx['_final_processed_path'], '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, + ) + + assert summary['moved'] == 2 + # The crucial assertion: each track must get the disc_number of + # its title-matched API entry, NOT a collapsed last-write-wins value. + assert title_to_disc['Same Old Song And Dance'] == 1 + assert title_to_disc['Dream On'] == 2 + # And the album-level total_discs must be 2 so post-process inserts the subfolder + assert title_to_disc['_total_discs'] == 2 + + +def test_title_match_tolerates_smart_quotes_and_punctuation(monkeypatch, tmpdirs): + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, "Don't Stop Believin'", _make_audio_file(library, 't1.flac')), + ]) + + 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': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + # API uses smart quotes — historically a common mismatch source + lambda *a: {'items': [ + {'id': 'a1', 'name': 'Don’t Stop Believin’', 'track_number': 1, 'disc_number': 1}, + ]}, + ) + + pp_calls = [] + + def pp(key, ctx, fp): + pp_calls.append(ctx['track_info']['name']) + ctx['_final_processed_path'] = str(library / 'out.flac') + with open(ctx['_final_processed_path'], '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, + ) + + assert summary['moved'] == 1 + assert len(pp_calls) == 1 + + +def test_bonus_track_routes_to_correct_disc_via_substring_match(monkeypatch, tmpdirs): + """Real-world scenario from winecountrygames's Kendrick Lamar deluxe: + user has ``The Recipe - Bonus Track`` (track 1, disc 2 in his library) + AND ``Sherane`` (track 1, disc 1). The API returns the bonus track as + plain ``The Recipe`` (no suffix). Without substring matching, the + bonus track falls through to track-number-only and lands on disc 1. + With substring matching (gated on track_number), it correctly routes + to disc 2.""" + library, _staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + # Disc 1, track 1 + ('t1d1', 1, 'Sherane', _make_audio_file(library, 'd1t1.flac')), + # Disc 2, track 1 — local title has " - Bonus Track" suffix + ('t1d2', 1, 'The Recipe - Bonus Track', _make_audio_file(library, 'd2t1.flac')), + ]) + + 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': 'good kid m.A.A.d city (Deluxe)'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': 'a1', 'name': 'Sherane', 'track_number': 1, 'disc_number': 1}, + {'id': 'a2', 'name': 'The Recipe', 'track_number': 1, 'disc_number': 2}, + ]}, + ) + + title_to_disc = {} + + def pp(key, ctx, fp): + title_to_disc[ctx['track_info']['name']] = ctx['track_info']['disc_number'] + ctx['_final_processed_path'] = str(library / f"out_{ctx['track_info']['disc_number']}.flac") + with open(ctx['_final_processed_path'], 'wb') as f: + f.write(b'final') + + 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, + ) + + # The local "The Recipe - Bonus Track" must route to the API's + # disc-2 entry (which is named just "The Recipe"), via substring + # match + track_number tiebreaker. + assert title_to_disc['Sherane'] == 1 + assert title_to_disc['The Recipe'] == 2 + + +def test_dash_vs_parens_normalize_equally_for_remix_versions(monkeypatch, tmpdirs): + """Local file has ``Bitch, Don't Kill My Vibe - Remix`` (dash style), + API has the same track as ``Bitch, Don't Kill My Vibe (Remix)`` + (parens style). Both must normalize to the same string so tier 1 + matches without falling to substring or track_number fallbacks.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 5, "Bitch, Don't Kill My Vibe - Remix", _make_audio_file(library, 't1.flac')), + ]) + + 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': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': 'a1', 'name': "Bitch, Don't Kill My Vibe (Remix)", + 'track_number': 5, 'disc_number': 2}, + ]}, + ) + + matched = [] + + def pp(key, ctx, fp): + matched.append((ctx['track_info']['name'], ctx['track_info']['disc_number'])) + ctx['_final_processed_path'] = str(library / 'out.flac') + with open(ctx['_final_processed_path'], 'wb') as f: + f.write(b'final') + + 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, + ) + + assert matched == [("Bitch, Don't Kill My Vibe (Remix)", 2)] + + +def test_substring_match_handles_track_number_disagreement(monkeypatch, tmpdirs): + """Real-world Kendrick Lamar deluxe case: the user's library has + ``The Recipe (Black Hippy Remix) - Bonus Track`` numbered as track + 4 of disc 2, but Deezer has the same track at disc 2 track 5 (and + has ``Bitch... (Remix)`` at disc 2 track 4). Track_number-gated + containment misses; length-ratio containment must pick the right + one without false-positive risk.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t4', 4, 'The Recipe (Black Hippy Remix) - Bonus Track', + _make_audio_file(library, 't4.flac')), + ]) + + 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': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + # API has the bonus tracks in a different order than the user + {'id': 'd1t4', 'name': 'The Art of Peer Pressure', + 'track_number': 4, 'disc_number': 1}, + {'id': 'd2t4', 'name': "Bitch, Don't Kill My Vibe (Remix)", + 'track_number': 4, 'disc_number': 2}, + {'id': 'd2t5', 'name': 'The Recipe (Black Hippy Remix)', + 'track_number': 5, 'disc_number': 2}, + ]}, + ) + + matched = [] + + def pp(key, ctx, fp): + matched.append((ctx['track_info']['name'], ctx['track_info']['disc_number'])) + ctx['_final_processed_path'] = str(library / 'out.flac') + with open(ctx['_final_processed_path'], 'wb') as f: + f.write(b'final') + + 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, + ) + + # The local Black Hippy Remix Bonus Track must end up in disc 2, + # NOT collide with disc 1's "Art of Peer Pressure" via track_number. + assert matched == [('The Recipe (Black Hippy Remix)', 2)] + + +def test_remix_does_not_substring_match_to_original_recording(monkeypatch, tmpdirs): + """winecountrygames's iTunes case: iTunes doesn't have the remix, + just the original ``Bitch Don't Kill My Vibe``. Substring + ratio + alone would merge the local remix bonus track into the original + via tier 4 (ratio 0.78). Reject because they have different version + differentiators ('remix' vs none) — they're different recordings.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, itunes_id='it-1', tracks=[ + # Original — should match cleanly via tier 1 to iTunes' entry + ('t2', 2, "Bitch, Don't Kill My Vibe", _make_audio_file(library, 't2.flac')), + # Remix — iTunes doesn't have it; must report unmatched, NOT + # collide with the original via substring + ('t5', 5, "Bitch, Don't Kill My Vibe - Remix", _make_audio_file(library, 't5.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'itunes') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'it-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': 'it2', 'name': "Bitch, Don't Kill My Vibe", + 'track_number': 2, 'disc_number': 1}, + ]}, + ) + + matched_titles = [] + skipped_titles = [] + + def pp(key, ctx, fp): + matched_titles.append(ctx['track_info']['name']) + ctx['_final_processed_path'] = str(library / f'out_{ctx["track_info"]["name"]}.flac') + with open(ctx['_final_processed_path'], '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, + ) + + skipped_titles = [e['title'] for e in summary['errors']] + # Only the original should have been processed + assert matched_titles == ["Bitch, Don't Kill My Vibe"] + # The remix should be reported as unmatched, NOT merged with the original + assert "Bitch, Don't Kill My Vibe - Remix" in skipped_titles + assert summary['moved'] == 1 + assert summary['skipped'] == 1 + + +def test_substring_match_does_not_false_positive_across_discs(monkeypatch, tmpdirs): + """Safety: ``Real`` (substring) must not silently map to a longer + track like ``Real Real Real`` on a different disc. Substring match + is gated on matching track_number; if the only API entry whose + title contains the local one has a different track_number, the + matcher must fall through to last-resort track_number-only.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t11', 11, 'Real', _make_audio_file(library, 't11.flac')), + ]) + + 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': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + # Real-the-track on disc 1, position 11 — the right answer + {'id': 'a1', 'name': 'Real', 'track_number': 11, 'disc_number': 1}, + # A nearby longer title on disc 2 that contains "real" — must NOT win + {'id': 'a2', 'name': 'Real Real Real', 'track_number': 1, 'disc_number': 2}, + ]}, + ) + + matched = [] + + def pp(key, ctx, fp): + matched.append((ctx['track_info']['name'], ctx['track_info']['disc_number'])) + ctx['_final_processed_path'] = str(library / 'out.flac') + with open(ctx['_final_processed_path'], 'wb') as f: + f.write(b'final') + + 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, + ) + + # Tier 1 (exact + track_number) wins for the legitimate disc 1 entry + assert matched == [('Real', 1)] + + +def test_skips_track_when_source_tracklist_doesnt_contain_it(monkeypatch, tmpdirs): + """winecountrygames's actual scenario: Deezer's response for the + Kendrick deluxe was missing 'The Recipe (Black Hippy Remix)' — the + user has 17 local tracks, Deezer knows 16. The 17th local track + has no title-based match anywhere in the API tracklist. Per the + design policy 'trust the source', we must NOT fall back to + track_number-only matching (which would falsely route the missing + bonus track to whatever disc-1 entry shares its track_number, + causing a collision with a totally unrelated song).""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + # Local tn=4 — but API doesn't have this track at all; the only + # API entry with track_number=4 is "The Art of Peer Pressure" + # (a completely different song). Old tier-5 fallback would have + # silently routed our bonus track to that entry → collision. + ('t4', 4, 'The Recipe (Black Hippy Remix) - Bonus Track', + _make_audio_file(library, 't4.flac')), + ]) + + 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': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + # Same tn=4, completely different title — must NOT capture + # the local track via track_number fallback. + {'id': 'd1t4', 'name': 'The Art of Peer Pressure', + 'track_number': 4, 'disc_number': 1}, + ]}, + ) + + pp_calls = [] + + def pp(*a, **k): + pp_calls.append(a) + + 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, + ) + + # Track must be skipped, NOT routed to The Art of Peer Pressure. + assert summary['moved'] == 0 + assert summary['skipped'] == 1 + assert pp_calls == [] + assert 'not in' in summary['errors'][0]['error'].lower() \ + or 'bonus' in summary['errors'][0]['error'].lower() \ + or 'non-canonical' in summary['errors'][0]['error'].lower() + + +def test_skips_track_not_in_api_tracklist(monkeypatch, tmpdirs): + """Bonus track scenario: user has 12 tracks, source's catalog version + only has 10. Tracks not in the API response must be skipped, NOT + force-fed wrong context to post-process.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), + ('t2', 2, 'Track 2', _make_audio_file(library, 't2.flac')), + ('t11', 11, 'Bonus Track', _make_audio_file(library, 't11.flac')), + ]) + + 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': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': 'a1', 'name': 'Track 1', 'track_number': 1, 'disc_number': 1}, + {'id': 'a2', 'name': 'Track 2', 'track_number': 2, 'disc_number': 1}, + ]}, + ) + + pp_for = [] + + def pp(key, ctx, fp): + pp_for.append(ctx['track_info']['track_number']) + ctx['_final_processed_path'] = str(library / f"out_{ctx['track_info']['track_number']}.flac") + with open(ctx['_final_processed_path'], '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, + ) + + assert sorted(pp_for) == [1, 2] + assert summary['moved'] == 2 + assert summary['skipped'] == 1 + assert any('Bonus Track' in e['title'] for e in summary['errors']) + + +def test_surfaces_unresolved_file_path(monkeypatch, tmpdirs): + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', '/nonexistent/file.flac'), + ]) + + 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': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]}, + ) + + pp_calls = [] + + def pp(*a, **k): + pp_calls.append(a) + + summary = library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: None, # nothing resolves + post_process_fn=pp, + ) + + assert summary['skipped'] == 1 + assert summary['moved'] == 0 + assert pp_calls == [] + assert '/nonexistent/file.flac' in summary['errors'][0]['error'] + + +def test_failed_post_process_leaves_original_in_place(monkeypatch, tmpdirs): + """If post-process fails (AcoustID rejection, exception, anything), + the original file must remain at its location and the DB must NOT + be updated. Worst-case the user retries; we don't lose data.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + src_file = _make_audio_file(library, 't1.flac') + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', src_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': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]}, + ) + + def pp(key, ctx, fp): + # Simulate AcoustID rejection: don't set _final_processed_path + return + + db_updates = [] + + def update_path(track_id, new_path): + db_updates.append((track_id, new_path)) + + 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, + update_track_path_fn=update_path, + ) + + assert summary['failed'] == 1 + assert summary['moved'] == 0 + assert os.path.exists(src_file) + assert db_updates == [] + + +def test_post_process_exception_is_caught_and_original_preserved(monkeypatch, tmpdirs): + library, staging, _transfer = tmpdirs + db = _FakeDB() + src_file = _make_audio_file(library, 't1.flac') + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', src_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': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]}, + ) + + def pp(*a, **k): + raise RuntimeError("boom") + + 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, + ) + + assert summary['failed'] == 1 + assert os.path.exists(src_file) + + +def test_recreates_staging_dir_when_post_process_cleans_it(monkeypatch, tmpdirs): + """Regression test for the "1 moved, 15 failed (path not found)" bug + winecountrygames hit on his first reorganize run. + + Post-processing calls `_cleanup_empty_directories` after each move. + That walks up from the source file removing empties — and since the + only thing in our staging_album_dir is the staged file we just had + post-process consume, the dir is empty after the move and gets + nuked. The next track's `shutil.copy2` then failed with WinError 3 + because the destination directory no longer existed. + + The orchestrator must recreate staging_album_dir before each copy.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), + ('t2', 2, 'Track 2', _make_audio_file(library, 't2.flac')), + ('t3', 3, 'Track 3', _make_audio_file(library, 't3.flac')), + ]) + + 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': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': 'a1', 'name': 'Track 1', 'track_number': 1}, + {'id': 'a2', 'name': 'Track 2', 'track_number': 2}, + {'id': 'a3', 'name': 'Track 3', 'track_number': 3}, + ]}, + ) + + final_dir = library / 'final' + final_dir.mkdir() + pp_count = [0] + + def pp_with_aggressive_cleanup(key, ctx, fp): + """Mimic real post-process: move the file, then walk up from + the source directory removing empties (which includes our + staging_album_dir).""" + pp_count[0] += 1 + final = str(final_dir / f"final_{pp_count[0]}.flac") + shutil.move(fp, final) + ctx['_final_processed_path'] = final + + # Walk up from the staged file's old directory, deleting + # any empty dir until we hit the staging root. + dir_to_check = os.path.dirname(fp) + while os.path.normpath(dir_to_check) != os.path.normpath(str(staging)): + try: + os.rmdir(dir_to_check) + except OSError: + break + dir_to_check = os.path.dirname(dir_to_check) + + 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_with_aggressive_cleanup, + ) + + # All three tracks must succeed despite the staging dir being + # nuked between each one. + assert summary['moved'] == 3 + assert summary['failed'] == 0 + + +def test_db_update_failure_leaves_original_in_place(monkeypatch, tmpdirs): + """Safety property: a failing DB write must NOT trigger the original + file's deletion. Otherwise we'd have a library row pointing at a + now-deleted path with no easy recovery. Better: leave the file at + BOTH locations (original + new) so the next library scan re-indexes + from the new path and the user doesn't lose data.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + src = _make_audio_file(library, 't1.flac') + final_dir = library / 'final' + final_dir.mkdir() + + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', src), + ]) + + 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': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]}, + ) + + final_path = str(final_dir / 't1.flac') + + def pp(key, ctx, fp): + shutil.move(fp, final_path) + ctx['_final_processed_path'] = final_path + + def update_path_explodes(track_id, new_path): + raise RuntimeError("simulated DB failure") + + 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, + update_track_path_fn=update_path_explodes, + ) + + assert os.path.exists(src), "Original must still exist when DB update failed" + assert os.path.exists(final_path), "New path file should also exist (post-process succeeded)" + # kettui PR #377 review: a DB-update failure must NOT increment + # `moved` — that would overstate how many tracks the UI knows are + # at their new locations. Track is reported as failed instead. + assert summary['moved'] == 0 + assert summary['failed'] == 1 + assert any('DB update failed' in e['error'] for e in summary['errors']) + + +def test_successful_run_removes_original_and_updates_db(monkeypatch, tmpdirs): + library, staging, _transfer = tmpdirs + db = _FakeDB() + src = _make_audio_file(library, 't1.flac') + final_dir = library / 'final' + final_dir.mkdir() + + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', src), + ]) + + 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': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]}, + ) + + def pp(key, ctx, fp): + # Pretend post-processing moved the staged file to a final location + final = str(final_dir / 't1.flac') + shutil.move(fp, final) + ctx['_final_processed_path'] = final + + db_updates = [] + + def update_path(track_id, new_path): + db_updates.append((track_id, new_path)) + + 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, + update_track_path_fn=update_path, + ) + + assert summary['moved'] == 1 + assert summary['failed'] == 0 + assert not os.path.exists(src) + assert os.path.exists(str(final_dir / 't1.flac')) + assert db_updates == [('t1', str(final_dir / 't1.flac'))] + + +# --- tests: cleanup ------------------------------------------------------- + +def test_staging_dir_cleaned_up_on_success(monkeypatch, tmpdirs): + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), + ]) + + 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': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]}, + ) + + def pp(key, ctx, fp): + final = str(library / 'final.flac') + shutil.move(fp, final) + ctx['_final_processed_path'] = final + + 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, + ) + + assert os.listdir(str(staging)) == [] + + +def test_staging_dir_cleaned_up_even_on_failure(monkeypatch, tmpdirs): + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), + ]) + + 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': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]}, + ) + + def pp(key, ctx, fp): + raise RuntimeError("simulated post-process explosion") + + 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, + ) + + assert os.listdir(str(staging)) == [] + + +# --- tests: misc ---------------------------------------------------------- + +def test_deletes_per_track_sidecars_after_successful_move(monkeypatch, tmpdirs): + """Real-world Kendrick-Lamar-deluxe shape: each FLAC has a same-stem + `.lrc` sidecar in the source folder. After the audio is moved to its + new location, the original `.lrc` should be removed too — post-process + handles whatever sidecar policy exists at the new destination.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + audio = _make_audio_file(library, '01 - Sherane.flac') + lrc_path = library / '01 - Sherane.lrc' + lrc_path.write_text('lyrics') + nfo_path = library / '01 - Sherane.nfo' + nfo_path.write_text('metadata') + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Sherane', audio), + ]) + + 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': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{'id': 'a1', 'name': 'Sherane', 'track_number': 1}]}, + ) + + final_dir = library / 'final' + final_dir.mkdir() + + def pp(key, ctx, fp): + final = str(final_dir / 'out.flac') + shutil.move(fp, final) + ctx['_final_processed_path'] = final + + 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, + ) + + assert not os.path.exists(audio) + assert not lrc_path.exists() + assert not nfo_path.exists() + + +def test_keeps_track_sidecars_when_track_fails_to_move(monkeypatch, tmpdirs): + """If post-process fails (AcoustID rejection), the original audio is + preserved — and so is its sidecar, because the user might want to + investigate or recover the track.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + audio = _make_audio_file(library, '01 - Sherane.flac') + lrc_path = library / '01 - Sherane.lrc' + lrc_path.write_text('lyrics') + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Sherane', audio), + ]) + + 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': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{'id': 'a1', 'name': 'Sherane', 'track_number': 1}]}, + ) + + def pp_rejects(key, ctx, fp): + return # don't set _final_processed_path = AcoustID-style rejection + + 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_rejects, + ) + + assert os.path.exists(audio) + assert lrc_path.exists() + + +def test_deletes_album_level_sidecars_when_directory_emptied(monkeypatch, tmpdirs): + """After every track in a source dir is successfully moved out, the + leftover album-level sidecars (cover.jpg, folder.jpg, etc.) should be + removed too so the empty-dir pruner can take the dir. If even one + track failed to move, leave them — the user might want the cover.""" + library, staging, _transfer = tmpdirs + disc1_dir = library / 'Disc 1' + disc1_dir.mkdir() + a1 = _make_audio_file(disc1_dir, '01.flac') + a2 = _make_audio_file(disc1_dir, '02.flac') + cover = disc1_dir / 'cover.jpg' + cover.write_bytes(b'JPEGdata') + folder = disc1_dir / 'folder.jpg' + folder.write_bytes(b'JPEGdata') + + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', a1), + ('t2', 2, 'Track 2', a2), + ]) + + 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': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': 'a1', 'name': 'Track 1', 'track_number': 1}, + {'id': 'a2', 'name': 'Track 2', 'track_number': 2}, + ]}, + ) + + final_dir = library / 'final' + final_dir.mkdir() + + def pp(key, ctx, fp): + final = str(final_dir / f"{ctx['track_info']['track_number']}.flac") + shutil.move(fp, final) + ctx['_final_processed_path'] = final + + 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, + ) + + assert not cover.exists() + assert not folder.exists() + + +def test_keeps_album_sidecars_when_a_track_failed_to_move(monkeypatch, tmpdirs): + """If even one track in the dir failed to move out, leave the album + art alone — user might still want to look at / recover the album.""" + library, staging, _transfer = tmpdirs + a1 = _make_audio_file(library, '01.flac') + a2 = _make_audio_file(library, '02.flac') + cover = library / 'cover.jpg' + cover.write_bytes(b'JPEGdata') + + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', a1), + ('t2', 2, 'Track 2', a2), + ]) + + 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': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': 'a1', 'name': 'Track 1', 'track_number': 1}, + {'id': 'a2', 'name': 'Track 2', 'track_number': 2}, + ]}, + ) + + final_dir = library / 'final' + final_dir.mkdir() + + def pp(key, ctx, fp): + # Track 1 succeeds, track 2 fails (no _final_processed_path set) + if ctx['track_info']['track_number'] == 1: + final = str(final_dir / '1.flac') + shutil.move(fp, final) + ctx['_final_processed_path'] = final + + 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, + ) + + # Track 2 still in place → cover preserved + assert os.path.exists(a2) + assert cover.exists() + + +# --- preview function (shared planning with the orchestrator) ----------- + +def _fake_path_builder(context, spotify_artist, _album_info, file_ext): + """Stand-in for `_build_final_path_for_track`. Inserts Disc N/ when + total_discs > 1 — same convention the real builder uses.""" + album = context['spotify_album']['name'] + artist = spotify_artist['name'] + track_info = context['track_info'] + title = track_info['name'] + tn = track_info['track_number'] + dn = track_info['disc_number'] + total = context['spotify_album']['total_discs'] + parts = ['/transfer', artist, album] + if total > 1: + parts.append(f'Disc {dn}') + parts.append(f"{tn:02d} - {title}{file_ext}") + return '/'.join(parts), True + + +def _path_builder_album_vs_single(context, spotify_artist, album_info, file_ext): + """Stand-in that emulates the real `_build_final_path_for_track` + branch on `album_info.get('is_album')`. ALBUM mode produces an + album folder with disc subfolder + numbered file; SINGLE mode + produces a per-track folder named after the title (the bug + output).""" + artist = spotify_artist['name'] + if album_info and album_info.get('is_album'): + album = album_info['album_name'] + title = album_info['clean_track_name'] + tn = album_info['track_number'] + dn = album_info['disc_number'] + total = context['spotify_album']['total_discs'] + if total > 1: + return (f'/transfer/{artist}/{artist} - {album}/Disc {dn}/{tn:02d} - {title}{file_ext}', True) + return (f'/transfer/{artist}/{artist} - {album}/{tn:02d} - {title}{file_ext}', True) + title = context['track_info']['name'] + return (f'/transfer/{artist}/{artist} - {title}/{title}{file_ext}', True) + + +def test_preview_uses_album_mode_not_single_mode(monkeypatch, tmpdirs): + """Regression for the bug where every track ended up in its own + track-named folder (SINGLE MODE) because we passed None for + album_info to the path builder. Multi-disc deluxe must produce + one shared album folder, not N single folders.""" + library, _staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Sherane', _make_audio_file(library, 't1.flac')), + ('t2', 2, 'Bitch Dont Kill My Vibe', _make_audio_file(library, 't2.flac')), + ]) + + 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': 'good kid, m.A.A.d city'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': 'a1', 'name': 'Sherane', 'track_number': 1, 'disc_number': 1}, + {'id': 'a2', 'name': 'Bitch Dont Kill My Vibe', 'track_number': 2, 'disc_number': 1}, + ]}, + ) + + result = library_reorganize.preview_album_reorganize( + album_id='alb-1', db=db, transfer_dir='/transfer', + resolve_file_path_fn=lambda p: p, + build_final_path_fn=_path_builder_album_vs_single, + ) + + paths = [it['new_path'] for it in result['tracks']] + # Both tracks land under the SAME album folder, not per-track folders + assert all('good kid, m.A.A.d city' in p for p in paths) + # Files use track-number prefix (album mode), not bare title (single mode) + assert any('01 - Sherane' in p for p in paths) + assert any('02 - Bitch Dont Kill My Vibe' in p for p in paths) + # Reject the single-mode shape explicitly + assert not any(p.endswith('/Sherane.flac') for p in paths) + + +def test_preview_emits_disc_subfolders_for_multi_disc_albums(monkeypatch, tmpdirs): + """The bug winecountrygames hit: preview showed all tracks at the + album root with no Disc N/ subfolders, even on a deluxe edition. + Verify the new planner-backed preview produces disc folders.""" + library, _staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1d1', 1, 'Sherane', _make_audio_file(library, 'd1t1.flac')), + ('t1d2', 1, 'The Recipe', _make_audio_file(library, 'd2t1.flac')), + ]) + + 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': 'good kid, m.A.A.d city (Deluxe)'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': 'a1', 'name': 'Sherane', 'track_number': 1, 'disc_number': 1}, + {'id': 'a2', 'name': 'The Recipe', 'track_number': 1, 'disc_number': 2}, + ]}, + ) + + result = library_reorganize.preview_album_reorganize( + album_id='alb-1', db=db, transfer_dir='/transfer', + resolve_file_path_fn=lambda p: p, + build_final_path_fn=_fake_path_builder, + ) + + assert result['success'] is True + assert result['status'] == 'planned' + + by_title = {it['title']: it for it in result['tracks']} + assert 'Disc 1' in by_title['Sherane']['new_path'] + assert 'Disc 2' in by_title['The Recipe']['new_path'] + # And per-track disc_number is propagated for UI display + assert by_title['Sherane']['disc_number'] == 1 + assert by_title['The Recipe']['disc_number'] == 2 + + +def test_preview_status_no_source_id_when_album_lacks_ids(monkeypatch, tmpdirs): + library, _staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, tracks=[ + ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', + lambda p: [p, 'spotify', 'itunes', 'discogs', 'hydrabase']) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', lambda *a: None) + monkeypatch.setattr(library_reorganize, 'get_album_tracks_for_source', lambda *a: None) + + result = library_reorganize.preview_album_reorganize( + album_id='alb-1', db=db, transfer_dir='/transfer', + resolve_file_path_fn=lambda p: p, + build_final_path_fn=_fake_path_builder, + ) + + assert result['status'] == 'no_source_id' + assert result['success'] is False + + +def test_preview_marks_unmatched_tracks(monkeypatch, tmpdirs): + """Tracks with no plausible API match (no exact title, no substring, + no track_number) get reported as unmatched with a reason.""" + library, _staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'A Real Track', _make_audio_file(library, 't1.flac')), + # Use a track_number with no API counterpart and a title that + # has no substring overlap with anything in the API list — so + # no tier matches. + ('t99', 99, 'Completely Unrelated Side Quest', + _make_audio_file(library, 't99.flac')), + ]) + + 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': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{'id': 'a1', 'name': 'A Real Track', 'track_number': 1}]}, + ) + + result = library_reorganize.preview_album_reorganize( + album_id='alb-1', db=db, transfer_dir='/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']} + assert by_title['A Real Track']['matched'] is True + assert by_title['A Real Track']['new_path'] + assert by_title['Completely Unrelated Side Quest']['matched'] is False + assert by_title['Completely Unrelated Side Quest']['reason'] + assert by_title['Completely Unrelated Side Quest']['new_path'] == '' + + +def test_preview_uses_same_logic_as_apply(monkeypatch, tmpdirs): + """Sanity check: a multi-disc album previewed and then applied + should show the same destinations. If preview drift creeps in + again, this fails.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1d1', 1, 'D1T1', _make_audio_file(library, 'd1t1.flac')), + ('t1d2', 1, 'D2T1', _make_audio_file(library, 'd2t1.flac')), + ]) + + 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': 'Test Album'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': 'a1', 'name': 'D1T1', 'track_number': 1, 'disc_number': 1}, + {'id': 'a2', 'name': 'D2T1', 'track_number': 1, 'disc_number': 2}, + ]}, + ) + + preview = library_reorganize.preview_album_reorganize( + album_id='alb-1', db=db, transfer_dir='/transfer', + resolve_file_path_fn=lambda p: p, + build_final_path_fn=_fake_path_builder, + ) + + # Now apply with the same matching logic; assert apply uses the + # same disc_number per track that the preview reported. + apply_disc_per_title = {} + + def pp(key, ctx, fp): + apply_disc_per_title[ctx['track_info']['name']] = ctx['track_info']['disc_number'] + ctx['_final_processed_path'] = fp + with open(fp, 'wb') as f: + f.write(b'final') + + 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, + ) + + preview_disc_per_title = {it['title']: it['disc_number'] for it in preview['tracks']} + assert preview_disc_per_title == apply_disc_per_title + + +def test_available_sources_only_lists_authed_sources_with_stored_ids(monkeypatch): + """The reorganize modal needs to know which sources the user can + actually pick. A source is pickable iff: (a) we have an album ID + for that source on the local row, AND (b) the user has the source + authed/configured. Empty-ID sources and unauthed sources are + omitted.""" + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', + lambda p: [p, 'spotify', 'itunes', 'discogs', 'hydrabase']) + + # Authed: deezer + spotify only. + auth = {'deezer': object(), 'spotify': object()} + monkeypatch.setattr(library_reorganize, 'get_client_for_source', + lambda src: auth.get(src)) + + album = { + 'spotify_album_id': 'sp-1', + 'deezer_id': 'dz-1', + 'itunes_album_id': 'it-1', # has ID but user not authed + 'discogs_id': '', # no ID + 'soul_id': '', # no ID + } + + sources = library_reorganize.available_sources_for_album(album) + names = [s['source'] for s in sources] + + assert names == ['deezer', 'spotify'] + assert all('label' in s for s in sources) + + +def test_authed_sources_lists_all_authed_regardless_of_album_ids(monkeypatch): + """Bulk reorganize uses this — needs the authed sources without + requiring per-album ID coverage. Each album in the bulk run will + do its own per-album ID check at apply time.""" + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'spotify') + monkeypatch.setattr(library_reorganize, 'get_source_priority', + lambda p: [p, 'deezer', 'itunes', 'discogs', 'hydrabase']) + + # Authed: spotify + deezer + itunes; discogs + hydrabase NOT authed. + auth = {'spotify': object(), 'deezer': object(), 'itunes': object()} + monkeypatch.setattr(library_reorganize, 'get_client_for_source', + lambda src: auth.get(src)) + + sources = library_reorganize.authed_sources() + names = [s['source'] for s in sources] + + # Primary first, then rest of priority chain — only authed ones + assert names == ['spotify', 'deezer', 'itunes'] + assert all('label' in s for s in sources) + + +def test_strict_source_does_not_fall_back(monkeypatch, tmpdirs): + """When the user picks a specific source in the modal, we must NOT + silently fall back to another source if their pick fails. Picking + Spotify means 'use Spotify or fail' — falling back would defeat + the picker's purpose.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', spotify_id='sp-1', tracks=[ + ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', + lambda p: [p, 'deezer', 'itunes']) + + fetched = [] + + def fake_album(src, sid): + fetched.append(('album', src)) + if src == 'deezer': + return {'id': 'dz-1', 'name': 'Album'} + return None # spotify "fails" + + def fake_tracks(src, sid): + fetched.append(('tracks', src)) + if src == 'deezer': + return {'items': [{'id': 'd1', 'name': 'Track 1', 'track_number': 1}]} + return None + + monkeypatch.setattr(library_reorganize, 'get_album_for_source', fake_album) + monkeypatch.setattr(library_reorganize, 'get_album_tracks_for_source', fake_tracks) + + pp_calls = [] + + def pp(*a, **k): + pp_calls.append(a) + + 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, + primary_source='spotify', strict_source=True, + ) + + # Spotify failed; with strict_source we must NOT have queried Deezer. + assert summary['status'] == 'no_source_id' + assert summary['moved'] == 0 + assert pp_calls == [] + assert all(src == 'spotify' for _kind, src in fetched) + + +def test_non_strict_falls_back_when_primary_returns_nothing(monkeypatch, tmpdirs): + """When the user did NOT pick a specific source (default behavior), + the orchestrator walks the priority chain as before.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', spotify_id='sp-1', tracks=[ + ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'spotify') + monkeypatch.setattr(library_reorganize, 'get_source_priority', + lambda p: [p, 'deezer', 'itunes']) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda src, sid: ({'id': sid, 'name': 'A'} if src == 'deezer' else None)) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda src, sid: ({'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]} + if src == 'deezer' else None), + ) + + def pp(key, ctx, fp): + ctx['_final_processed_path'] = str(library / 'out.flac') + with open(ctx['_final_processed_path'], '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, + # No strict_source → uses default fallback chain + ) + assert summary['source'] == 'deezer' + assert summary['moved'] == 1 + + +def test_returns_no_album_when_id_does_not_exist(tmpdirs): + _library, staging, _transfer = tmpdirs + db = _FakeDB() + cur = db._conn.cursor() + cur.execute("CREATE TABLE artists (id TEXT, name TEXT)") + cur.execute( + "CREATE TABLE albums (id TEXT, artist_id TEXT, title TEXT, " + "spotify_album_id TEXT, deezer_id TEXT, itunes_album_id TEXT, " + "discogs_id TEXT, soul_id TEXT)" + ) + cur.execute( + "CREATE TABLE tracks (id TEXT, album_id TEXT, artist_id TEXT, " + "title TEXT, track_number INTEGER, file_path TEXT, updated_at TEXT)" + ) + db._conn.commit() + + summary = library_reorganize.reorganize_album( + album_id='does-not-exist', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=lambda *a: None, + ) + + assert summary['status'] == 'no_album' + + +def test_returns_no_tracks_when_album_has_none(tmpdirs): + _library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[]) + + 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=lambda *a: None, + ) + + assert summary['status'] == 'no_tracks' + + +def test_processes_tracks_concurrently_with_consistent_state(monkeypatch, tmpdirs): + """Reorganize should run multiple tracks in parallel (matching the + download-side worker count). Verify both the parallelism (we observe + overlapping post-process calls) AND the state consistency (all + tracks are accounted for, no double-counting from races).""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + track_count = 6 + rows = [] + for i in range(1, track_count + 1): + rows.append((f't{i}', i, f'Track {i}', _make_audio_file(library, f't{i}.flac'))) + _setup_album(db, deezer_id='dz-1', tracks=rows) + + 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': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': f'a{i}', 'name': f'Track {i}', 'track_number': i} + for i in range(1, track_count + 1) + ]}, + ) + + import threading + import time + + in_flight = 0 + max_in_flight = 0 + in_flight_lock = threading.Lock() + final_dir = library / 'final' + final_dir.mkdir() + + def slow_pp(key, ctx, fp): + nonlocal in_flight, max_in_flight + with in_flight_lock: + in_flight += 1 + max_in_flight = max(max_in_flight, in_flight) + # Hold the worker briefly so concurrency is observable + time.sleep(0.05) + with in_flight_lock: + in_flight -= 1 + out = str(final_dir / f"out_{ctx['track_info']['track_number']}.flac") + shutil.move(fp, out) + ctx['_final_processed_path'] = out + + 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=slow_pp, + ) + + # All 6 tracks processed; counts are consistent (no race-induced duplicates) + assert summary['moved'] == track_count + assert summary['skipped'] == 0 + assert summary['failed'] == 0 + # Should have observed at least 2 workers in flight at once + # (3 is the configured cap; some overlap should always occur with 6 slow tracks) + assert max_in_flight >= 2, f"Expected concurrent workers, only saw {max_in_flight} in flight" + + +def test_prunes_empty_destination_album_dirs(monkeypatch, tmpdirs): + """When transfer_dir is provided, the orchestrator must clean up + empty sibling album folders in the artist directory after the run. + Catches both (a) leftovers from previous failed reorganize attempts + that created standalone single-track folders, and (b) dirs created + by `_build_final_path_for_track` that ended up empty when post- + process failed AcoustID. Uses a single-level prune scoped to the + artist folder — won't touch unrelated user dirs.""" + library, staging, transfer = tmpdirs + db = _FakeDB() + src = _make_audio_file(library, 't1.flac') + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', src), + ]) + + 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': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]}, + ) + + # Simulate the user's actual situation: transfer dir already has + # an artist folder with leftover empty single-track album folders + # from previous failed runs, plus an empty Disc-N subfolder. + artist_dir = transfer / 'Artist' + artist_dir.mkdir() + (artist_dir / 'Artist - 2013 Backseat Freestyle').mkdir() + (artist_dir / 'Artist - 2013 Compton').mkdir() + leftover_with_disc = artist_dir / 'Artist - 2012 Old Single-Disc' + leftover_with_disc.mkdir() + (leftover_with_disc / 'Disc 1').mkdir() # empty disc subfolder + + # Successful track lands in the real album folder + real_album = artist_dir / 'Artist - 2013 Real Album' + real_album.mkdir() + + def pp(key, ctx, fp): + final = str(real_album / 'Disc 1' / '01 - Track 1.flac') + os.makedirs(os.path.dirname(final), exist_ok=True) + shutil.move(fp, final) + ctx['_final_processed_path'] = final + + 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), + ) + + # Empty leftover single-track album folders should be gone + assert not (artist_dir / 'Artist - 2013 Backseat Freestyle').exists() + assert not (artist_dir / 'Artist - 2013 Compton').exists() + # The album with an empty Disc subfolder should also be cleaned + # (Disc 1/ is empty → pruned, then Old Single-Disc/ is empty → pruned) + assert not leftover_with_disc.exists() + # Real album with successful track must still exist + assert real_album.exists() + assert (real_album / 'Disc 1' / '01 - Track 1.flac').exists() + # Artist folder itself (still has the real album) untouched + assert artist_dir.exists() + + +def test_context_dict_satisfies_post_process_contract(monkeypatch, tmpdirs): + """Integration-style test: assert the per-track context dict the + orchestrator hands to post-process contains every key + `_post_process_matched_download` and `_build_final_path_for_track` + actually read in production. If the real post-process starts + requiring a new key in a future refactor, this test catches it + BEFORE the user does — unit-mock tests would not. + + Keys verified are taken from a grep of the real functions in + web_server.py at the time this test was written. The list is the + contract; if it grows, the orchestrator's `_build_post_process_context` + needs to grow too.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), + ]) + + 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': 'Test Album', + 'release_date': '2024-03-15', + 'total_tracks': 12, + 'image_url': 'https://example.com/cover.jpg', + }) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{ + 'id': 'a1', 'name': 'Track 1', + 'track_number': 1, 'disc_number': 1, + 'duration_ms': 240000, + 'artists': [{'name': 'Aerosmith'}], + 'uri': 'spotify:track:abc', + }]}, + ) + + captured_context = {} + + def assert_contract(key, ctx, fp): + captured_context.update(ctx) + # Mimic the bits of real post-process this test cares about + ctx['_final_processed_path'] = str(library / 'out.flac') + with open(ctx['_final_processed_path'], 'wb') as f: + f.write(b'final') + + library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=assert_contract, + ) + + # Top-level keys the real post-process reads + assert captured_context.get('is_album_download') is True + assert captured_context.get('has_clean_spotify_data') is True + assert captured_context.get('has_full_spotify_metadata') is True + + # spotify_artist (album-level artist context — not per-track) + spotify_artist = captured_context.get('spotify_artist') + assert isinstance(spotify_artist, dict) + assert 'name' in spotify_artist + assert 'id' in spotify_artist + assert 'genres' in spotify_artist + + # spotify_album (used by `_build_final_path_for_track`) + spotify_album = captured_context.get('spotify_album') + assert isinstance(spotify_album, dict) + assert spotify_album.get('id') == 'dz-1' + assert spotify_album.get('name') == 'Test Album' + assert 'release_date' in spotify_album # year extraction + assert 'total_tracks' in spotify_album # ALBUM/EP/Single inference + assert 'total_discs' in spotify_album # Disc N/ subfolder gate + assert 'image_url' in spotify_album # album art + + # track_info (per-track signal — populates filename, tags, disc subfolder) + track_info = captured_context.get('track_info') + assert isinstance(track_info, dict) + assert 'name' in track_info # filename + assert 'id' in track_info # source track id + assert 'track_number' in track_info # filename + tag + assert 'disc_number' in track_info # disc subfolder + tag + assert 'duration_ms' in track_info # tag + assert isinstance(track_info.get('artists'), list) # tag — must be list + assert all(isinstance(a, dict) and 'name' in a for a in track_info['artists']) + + # original_search_result (post-process reads this for fallbacks) + osr = captured_context.get('original_search_result') + assert isinstance(osr, dict) + assert 'title' in osr + assert 'spotify_clean_title' in osr # `_build_final_path_for_track` reads this + assert 'spotify_clean_album' in osr # ditto + assert 'track_number' in osr + assert 'disc_number' in osr + assert 'artists' in osr + + +def test_progress_callback_receives_updates(monkeypatch, tmpdirs): + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), + ('t2', 2, 'Track 2', _make_audio_file(library, 't2.flac')), + ]) + + 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': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': 'a1', 'name': 'Track 1', 'track_number': 1}, + {'id': 'a2', 'name': 'Track 2', 'track_number': 2}, + ]}, + ) + + def pp(key, ctx, fp): + final = str(library / f"final_{ctx['track_info']['track_number']}.flac") + shutil.move(fp, final) + ctx['_final_processed_path'] = final + + progress_log = [] + + def on_progress(updates): + progress_log.append(dict(updates)) + + 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, + on_progress=on_progress, + ) + + assert any('total' in u for u in progress_log) + assert any('current_track' in u for u in progress_log) + assert any(u.get('moved') == 2 for u in progress_log) + + +def test_watchdog_warns_about_stuck_workers(monkeypatch, tmpdirs, caplog): + """When a worker exceeds the hung-threshold, the orchestrator must + log a warning naming the stuck track. Real threshold is 5 minutes; + we monkeypatch it down to ~50ms so the test runs in well under a + second. Watchdog is passive (doesn't kill threads), so the worker + should still complete normally after the warning.""" + import threading + library, staging, _transfer = tmpdirs + + # Tiny watchdog so the test is fast. Interval shorter than threshold + # so the loop checks at least once before the threshold trips. + monkeypatch.setattr(library_reorganize, '_WATCHDOG_INTERVAL_SECONDS', 0.02) + monkeypatch.setattr(library_reorganize, '_HUNG_WORKER_THRESHOLD_SECONDS', 0.05) + + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Stuck Track', _make_audio_file(library, 't1.flac')), + ]) + + 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': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{'id': 'a1', 'name': 'Stuck Track', 'track_number': 1}]}, + ) + + release = threading.Event() + + def slow_pp(key, ctx, fp): + # Hold long enough for the watchdog to trip the threshold + emit. + # 0.2s vs 0.05s threshold + 0.02s interval = at least one warn pass. + release.wait(timeout=0.25) + ctx['_final_processed_path'] = fp + with open(fp, 'wb') as f: + f.write(b'final') + + caplog.set_level('WARNING', logger='library_reorganize') + + 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=slow_pp, + ) + release.set() + + # Track still completed (watchdog is passive — it doesn't abort) + assert summary['moved'] == 1 + + # And the watchdog warning was logged with the stuck track's title + warnings = [ + r.getMessage() for r in caplog.records + if r.levelname == 'WARNING' and 'Worker stuck' in r.getMessage() + ] + assert any('Stuck Track' in msg for msg in warnings), ( + f"Expected a 'Worker stuck' warning naming the track; got: {warnings}" + ) + + +def test_stop_check_aborts_remaining_tracks(monkeypatch, tmpdirs): + """With concurrent workers, stop_check can't cancel a task that's + already running — but it MUST prevent tasks that haven't started + yet from running. Use enough tracks that the worker pool can't + drain them all before stop_check trips.""" + import threading + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + (f't{i}', i, f'Track {i}', _make_audio_file(library, f't{i}.flac')) + for i in range(1, 11) + ]) + + 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': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': f'a{i}', 'name': f'Track {i}', 'track_number': i} + for i in range(1, 11) + ]}, + ) + + pp_count = [0] + pp_lock = threading.Lock() + + def pp(key, ctx, fp): + with pp_lock: + pp_count[0] += 1 + ctx['_final_processed_path'] = fp + with open(fp, 'wb') as f: + f.write(b'fake-final') + + stop = [False] + def check_stop(): + with pp_lock: + if pp_count[0] >= 2: + stop[0] = True + return stop[0] + + 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, + stop_check=check_stop, + ) + + # Some tracks ran (the ones already in flight when stop tripped), + # but not ALL 10 — the stop_check cut off the unstarted ones. + assert pp_count[0] < 10 + assert pp_count[0] >= 2 diff --git a/tests/test_musicbrainz_search.py b/tests/test_musicbrainz_search.py new file mode 100644 index 00000000..719f02ae --- /dev/null +++ b/tests/test_musicbrainz_search.py @@ -0,0 +1,767 @@ +"""Tests for the MusicBrainz search adapter (core/musicbrainz_search.py). + +Covers the behavior changes from the search-overhaul PR: +- Artist search is re-enabled and score-filtered +- Bare name queries route through artist-first → browse +- Structured 'Artist - Title' queries stay on text search +- Top-artist resolution is memoized per instance +- Cover Art URLs are constructed, not probed +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from core.musicbrainz_search import ( + MusicBrainzSearchClient, + _cover_art_url, + _extract_title_hint, +) + + +# --------------------------------------------------------------------------- +# Cover art URL construction +# --------------------------------------------------------------------------- + +def test_cover_art_url_release_scope(): + assert _cover_art_url('abc-123') == 'https://coverartarchive.org/release/abc-123/front-250' + + +def test_cover_art_url_release_group_scope(): + assert _cover_art_url('abc-123', scope='release-group') == \ + 'https://coverartarchive.org/release-group/abc-123/front-250' + + +def test_cover_art_url_empty_mbid_returns_none(): + assert _cover_art_url('') is None + assert _cover_art_url(None) is None + + +def test_cover_art_url_unknown_scope_falls_back_to_release(): + assert _cover_art_url('abc', scope='garbage') == 'https://coverartarchive.org/release/abc/front-250' + + +# --------------------------------------------------------------------------- +# Structured query splitting +# --------------------------------------------------------------------------- + +def test_split_structured_query_hyphen(): + client = MusicBrainzSearchClient() + assert client._split_structured_query('Metallica - Master of Puppets') == ('Metallica', 'Master of Puppets') + + +def test_split_structured_query_en_dash(): + client = MusicBrainzSearchClient() + assert client._split_structured_query('Metallica – One') == ('Metallica', 'One') + + +def test_split_structured_query_em_dash(): + client = MusicBrainzSearchClient() + assert client._split_structured_query('Metallica — Battery') == ('Metallica', 'Battery') + + +def test_split_structured_query_bare_name(): + client = MusicBrainzSearchClient() + assert client._split_structured_query('metallica') == (None, 'metallica') + + +def test_split_structured_query_no_separator_with_hyphens_in_word(): + # A hyphen inside a word (no surrounding spaces) should not split. + client = MusicBrainzSearchClient() + assert client._split_structured_query('t-pain') == (None, 't-pain') + + +# --------------------------------------------------------------------------- +# Artist search — score filtering and shape +# --------------------------------------------------------------------------- + +def _mk_artist(name, mbid, score=100, tags=None): + return { + 'id': mbid, + 'name': name, + 'score': score, + 'tags': tags or [], + } + + +def test_search_artists_filters_by_score_threshold(): + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_artist.return_value = [ + _mk_artist('Metallica', 'mb-real', score=100), + _mk_artist('Metallica Tribute', 'mb-tribute', score=60), + _mk_artist('Metallica Jam', 'mb-jam', score=58), + ] + results = client.search_artists('metallica', limit=10) + assert len(results) == 1 + assert results[0].name == 'Metallica' + assert results[0].id == 'mb-real' + + +def test_search_artists_uses_strict_false_for_fuzzy_match(): + """The adapter must use strict=False so MusicBrainz searches + alias+artist+sortname together — strict mode would miss aliased names. + + Adapter fetches `limit * 3` (min 10) so dedup-by-name below has enough + candidates to pick from. + """ + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_artist.return_value = [] + client.search_artists('metallica') + client._client.search_artist.assert_called_once_with('metallica', limit=30, strict=False) + + +def test_search_artists_dedupes_same_named_homonyms(): + """MusicBrainz has many different PEOPLE sharing a canonical name + (7 Michael Jacksons: singer, poet, photographer, mashup artist, ...). + Since they all render as "Michael Jackson" with the same fallback image, + dedupe to the highest-scoring entry per name.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_artist.return_value = [ + {'id': 'mb-king', 'name': 'Michael Jackson', 'score': 100, + 'tags': [{'name': 'pop'}]}, + {'id': 'mb-poet', 'name': 'Michael Jackson', 'score': 81}, + {'id': 'mb-mashup', 'name': 'Michael Jackson', 'score': 80}, + {'id': 'mb-photog', 'name': 'Michael Jackson', 'score': 80}, + {'id': 'mb-other', 'name': 'Michael Jackson', 'score': 80}, + ] + + results = client.search_artists('michael jackson', limit=10) + + # Should collapse to one entry — the highest-scoring one. + assert len(results) == 1 + assert results[0].id == 'mb-king' + assert results[0].popularity == 100 + + +def test_search_artists_dedup_normalized_case_and_whitespace(): + """Dedup key is case-insensitive and whitespace-normalized.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_artist.return_value = [ + {'id': 'mb-1', 'name': 'The Band', 'score': 100}, + {'id': 'mb-2', 'name': 'THE BAND', 'score': 85}, + {'id': 'mb-3', 'name': 'the band', 'score': 82}, + ] + results = client.search_artists('the band', limit=5) + assert len(results) == 1 + assert results[0].id == 'mb-1' + + +def test_search_artists_keeps_distinct_names(): + """Dedup only collapses identical normalized names, not similar names.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_artist.return_value = [ + {'id': 'mb-1', 'name': 'The Beatles', 'score': 100}, + {'id': 'mb-2', 'name': 'The Beatles Revival', 'score': 85}, + ] + results = client.search_artists('the beatles', limit=5) + assert {r.name for r in results} == {'The Beatles', 'The Beatles Revival'} + + +def test_search_artists_returns_empty_on_exception(): + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_artist.side_effect = RuntimeError('network down') + assert client.search_artists('metallica') == [] + + +def test_search_artists_extracts_tags_as_genres(): + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_artist.return_value = [ + _mk_artist('Metallica', 'mb-real', score=100, + tags=[{'name': 'thrash metal', 'count': 20}, + {'name': 'heavy metal', 'count': 15}]), + ] + results = client.search_artists('metallica') + assert results[0].genres == ['thrash metal', 'heavy metal'] + + +def test_search_artists_skips_entries_without_mbid_or_name(): + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_artist.return_value = [ + {'id': 'mb-1', 'name': 'Good', 'score': 100}, + {'id': '', 'name': 'Missing MBID', 'score': 100}, + {'id': 'mb-2', 'name': '', 'score': 100}, + ] + results = client.search_artists('x') + assert [r.name for r in results] == ['Good'] + + +# --------------------------------------------------------------------------- +# Top-artist resolution — memoization +# --------------------------------------------------------------------------- + +def test_resolve_top_artist_memoizes_by_normalized_query(): + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)] + + first = client._resolve_top_artist('metallica') + second = client._resolve_top_artist(' Metallica ') # Whitespace / case variant + + assert first is not None + assert first['id'] == 'mb-1' + assert first is second + # HTTP call happens once despite two resolve calls. + assert client._client.search_artist.call_count == 1 + + +def test_resolve_top_artist_returns_none_below_threshold(): + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_artist.return_value = [_mk_artist('Tribute', 'mb-trib', score=50)] + assert client._resolve_top_artist('obscure') is None + + +def test_resolve_top_artist_caches_negative_result(): + """After a lookup finds no good match, subsequent calls don't refetch.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_artist.return_value = [] + first = client._resolve_top_artist('nonexistent band') + second = client._resolve_top_artist('nonexistent band') + assert first is None + assert second is None + assert client._client.search_artist.call_count == 1 + + +def test_resolve_top_artist_empty_query_returns_none_without_http(): + client = MusicBrainzSearchClient() + client._client = MagicMock() + assert client._resolve_top_artist('') is None + client._client.search_artist.assert_not_called() + + +# --------------------------------------------------------------------------- +# Album search — routing +# --------------------------------------------------------------------------- + +def test_search_albums_bare_query_uses_browse_path(): + """When a bare name resolves to an artist, we browse their release-groups + instead of text-searching release titles.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)] + client._client.browse_artist_release_groups.return_value = [ + {'id': 'rg-1', 'title': 'Master of Puppets', 'primary-type': 'Album', + 'first-release-date': '1986-03-03', 'secondary-types': []}, + {'id': 'rg-2', 'title': 'Ride the Lightning', 'primary-type': 'Album', + 'first-release-date': '1984-07-27', 'secondary-types': []}, + ] + + albums = client.search_albums('metallica', limit=10) + + client._client.browse_artist_release_groups.assert_called_once() + # Text-search path must NOT be taken. + client._client.search_release.assert_not_called() + # Chronological ASC — debut first, so the album list reads like a + # standard discography (Wikipedia-style: earliest release on top). + assert [a.name for a in albums] == ['Ride the Lightning', 'Master of Puppets'] + assert all(a.artists == ['Metallica'] for a in albums) + + +def test_search_albums_structured_query_uses_text_path(): + """'Artist - Title' shape should text-search the title rather than + browsing all of the artist's discography.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_release.return_value = [ + {'id': 'rel-1', 'title': 'Master of Puppets', 'score': 100, + 'date': '1986', 'media': [{'track-count': 8}], + 'release-group': {'id': 'rg-1', 'primary-type': 'Album'}, + 'artist-credit': [{'name': 'Metallica'}]}, + ] + + albums = client.search_albums('Metallica - Master of Puppets', limit=10) + + client._client.search_release.assert_called_once() + # Artist-first path must NOT be taken. + client._client.search_artist.assert_not_called() + client._client.browse_artist_release_groups.assert_not_called() + assert len(albums) == 1 + assert albums[0].name == 'Master of Puppets' + + +def test_search_albums_falls_back_to_text_when_no_artist_match(): + """No artist above threshold → text-search the whole query.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + # Artist lookup returns nothing above threshold. + client._client.search_artist.return_value = [_mk_artist('X', 'mb-x', score=40)] + client._client.search_release.return_value = [] + + client.search_albums('very obscure band') + + client._client.search_release.assert_called_once_with('very obscure band', artist_name=None, limit=10) + client._client.browse_artist_release_groups.assert_not_called() + + +def test_search_albums_filters_live_and_compilation_secondary_types(): + """Mega-artists' browse results are dominated by live bootlegs and + best-of compilations — they should be filtered out so the studio + discography surfaces.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)] + client._client.browse_artist_release_groups.return_value = [ + {'id': 'rg-live-1', 'title': 'Live Bootleg 2019', 'primary-type': 'Album', + 'first-release-date': '2019-01-01', 'secondary-types': ['Live']}, + {'id': 'rg-studio-1', 'title': 'Kill Em All', 'primary-type': 'Album', + 'first-release-date': '1983-07-25', 'secondary-types': []}, + {'id': 'rg-comp-1', 'title': 'Greatest Hits', 'primary-type': 'Album', + 'first-release-date': '2010-01-01', 'secondary-types': ['Compilation']}, + {'id': 'rg-studio-2', 'title': 'Master of Puppets', 'primary-type': 'Album', + 'first-release-date': '1986-03-03', 'secondary-types': []}, + ] + + albums = client.search_albums('metallica', limit=10) + + titles = [a.name for a in albums] + assert titles == ['Kill Em All', 'Master of Puppets'] + assert 'Live Bootleg 2019' not in titles + assert 'Greatest Hits' not in titles + + +def test_search_albums_falls_back_to_all_when_no_studio(): + """Niche live-only artist: if no studio releases exist, show live ones + rather than returning empty.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_artist.return_value = [_mk_artist('LiveBand', 'mb-1', score=100)] + client._client.browse_artist_release_groups.return_value = [ + {'id': 'rg-live-1', 'title': 'Live at X', 'primary-type': 'Album', + 'first-release-date': '2019-01-01', 'secondary-types': ['Live']}, + {'id': 'rg-live-2', 'title': 'Live at Y', 'primary-type': 'Album', + 'first-release-date': '2020-01-01', 'secondary-types': ['Live']}, + ] + + albums = client.search_albums('liveband', limit=10) + + assert len(albums) == 2 + + +def test_search_tracks_prefers_studio_release_in_album_field(): + """When a recording has both a studio release and a live release, the + Track.album should reflect the studio release (canonical album), + regardless of the order MB returned them in.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)] + client._client.search_recordings_by_artist_mbid.return_value = [ + { + 'id': 'rec-master', + 'title': 'Master of Puppets', + 'length': 516000, + 'artist-credit': [{'name': 'Metallica'}], + # Live release first (what MB often returns), studio second. + 'releases': [ + {'id': 'rel-live', 'title': 'Live Bootleg', 'date': '2023-01-01', + 'release-group': {'id': 'rg-live', 'primary-type': 'Album', + 'secondary-types': ['Live']}}, + {'id': 'rel-studio', 'title': 'Master of Puppets', 'date': '1986-03-03', + 'release-group': {'id': 'rg-studio', 'primary-type': 'Album', + 'secondary-types': []}}, + ], + }, + ] + + tracks = client.search_tracks('metallica', limit=10) + + assert len(tracks) == 1 + # Album must be the studio release, not the live bootleg. + assert tracks[0].album == 'Master of Puppets' + assert tracks[0].release_date == '1986-03-03' + + +def test_search_tracks_filters_recordings_without_studio_releases(): + """A recording that only exists on live/compilation releases should be + dropped when we have studio alternatives.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)] + client._client.search_recordings_by_artist_mbid.return_value = [ + {'id': 'rec-studio', 'title': 'Seek and Destroy', 'length': 390000, + 'artist-credit': [{'name': 'Metallica'}], + 'releases': [ + {'id': 'rel-studio', 'title': 'Kill Em All', 'date': '1983-07-25', + 'release-group': {'id': 'rg-studio', 'primary-type': 'Album', + 'secondary-types': []}}, + ]}, + {'id': 'rec-live-only', 'title': 'Fight Fire With Fire', 'length': 450000, + 'artist-credit': [{'name': 'Metallica'}], + 'releases': [ + {'id': 'rel-live', 'title': 'Live Shit', 'date': '1993-01-01', + 'release-group': {'id': 'rg-live', 'primary-type': 'Album', + 'secondary-types': ['Live']}}, + ]}, + ] + + tracks = client.search_tracks('metallica', limit=10) + + titles = [t.name for t in tracks] + assert 'Seek and Destroy' in titles + assert 'Fight Fire With Fire' not in titles + + +def test_search_albums_text_path_filters_by_score(): + client = MusicBrainzSearchClient() + client._client = MagicMock() + # Force text-search path by using a structured query. + client._client.search_release.return_value = [ + {'id': 'rel-good', 'title': 'Good', 'score': 95, + 'release-group': {'id': 'rg-1', 'primary-type': 'Album'}, + 'artist-credit': [{'name': 'Foo'}]}, + {'id': 'rel-bad', 'title': 'Bad', 'score': 40, + 'release-group': {'id': 'rg-2', 'primary-type': 'Album'}, + 'artist-credit': [{'name': 'Foo'}]}, + ] + + albums = client.search_albums('Foo - Good', limit=10) + + titles = [a.name for a in albums] + assert 'Good' in titles + assert 'Bad' not in titles + + +# --------------------------------------------------------------------------- +# Track search — routing +# --------------------------------------------------------------------------- + +def test_search_tracks_bare_query_uses_browse_path(): + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)] + client._client.search_recordings_by_artist_mbid.return_value = [ + {'id': 'rec-1', 'title': 'One', 'length': 446000, + 'releases': [{'id': 'rel-1', 'title': '...And Justice for All', 'date': '1988', + 'release-group': {'id': 'rg-1', 'primary-type': 'Album'}}], + 'artist-credit': [{'name': 'Metallica'}]}, + {'id': 'rec-2', 'title': 'Battery', 'length': 312000, + 'releases': [{'id': 'rel-2', 'title': 'Master of Puppets', 'date': '1986', + 'release-group': {'id': 'rg-2', 'primary-type': 'Album'}}], + 'artist-credit': [{'name': 'Metallica'}]}, + ] + + tracks = client.search_tracks('metallica', limit=10) + + client._client.search_recordings_by_artist_mbid.assert_called_once() + client._client.search_recording.assert_not_called() + assert len(tracks) == 2 + assert {t.name for t in tracks} == {'One', 'Battery'} + + +def test_search_tracks_dedupes_by_title(): + """MusicBrainz has many live/compilation variants of the same song. + Browse results should be deduped by normalized title so we don't show + 'One' three times.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)] + client._client.search_recordings_by_artist_mbid.return_value = [ + {'id': 'rec-1', 'title': 'One', 'length': 446000, + 'releases': [{'id': 'rel-1', 'title': '...And Justice for All', 'date': '1988'}], + 'artist-credit': [{'name': 'Metallica'}]}, + {'id': 'rec-1-live', 'title': 'One', 'length': 490000, + 'releases': [{'id': 'rel-live', 'title': 'Live Shit', 'date': '1993'}], + 'artist-credit': [{'name': 'Metallica'}]}, + ] + + tracks = client.search_tracks('metallica', limit=10) + + assert len(tracks) == 1 + assert tracks[0].name == 'One' + + +def test_search_tracks_structured_query_uses_text_path(): + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_recording.return_value = [ + {'id': 'rec-1', 'title': 'One', 'score': 100, + 'releases': [{'id': 'rel-1', 'title': '...And Justice for All', 'date': '1988'}], + 'artist-credit': [{'name': 'Metallica'}]}, + ] + + tracks = client.search_tracks('Metallica - One', limit=10) + + client._client.search_recording.assert_called_once() + client._client.search_artist.assert_not_called() + client._client.search_recordings_by_artist_mbid.assert_not_called() + assert len(tracks) == 1 + + +def test_get_album_resolves_release_group_mbid_to_release(): + """When the album ID is a release-group MBID (from the browse path), + get_album must look up the release-group, pick a canonical release, + and fetch that release's tracklist. Fetching /release/ + directly 404s.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + # Release-group lookup returns two editions — an Official release and + # a promo. The Official earlier release should win. + client._client.get_release_group.return_value = { + 'id': 'rg-damn', + 'title': 'DAMN.', + 'primary-type': 'Album', + 'secondary-types': [], + 'first-release-date': '2017-04-14', + 'artist-credit': [{'name': 'Kendrick Lamar'}], + 'releases': [ + {'id': 'rel-promo', 'status': 'Promotion', 'date': '2017-04-01', + 'media': [{'track-count': 14, 'tracks': []}]}, + {'id': 'rel-official', 'status': 'Official', 'date': '2017-04-14', + 'media': [{'track-count': 14, 'tracks': []}]}, + ], + } + # Release lookup returns a full release with tracklist. + client._client.get_release.return_value = { + 'id': 'rel-official', + 'title': 'DAMN.', + 'date': '2017-04-14', + 'artist-credit': [{'name': 'Kendrick Lamar'}], + 'release-group': {'id': 'rg-damn', 'primary-type': 'Album', 'secondary-types': []}, + 'media': [ + {'position': 1, 'tracks': [ + {'id': 't1', 'number': '1', 'position': 1, 'length': 50000, + 'recording': {'id': 'rec-1', 'title': 'BLOOD.', + 'artist-credit': [{'name': 'Kendrick Lamar'}], 'length': 50000}}, + ]}, + ], + } + + album = client.get_album('rg-damn') + + # Must have called release-group first, then release for the picked edition. + client._client.get_release_group.assert_called_once_with( + 'rg-damn', includes=['releases', 'artist-credits'] + ) + client._client.get_release.assert_called_once_with( + 'rel-official', includes=['recordings', 'artist-credits', 'release-groups'] + ) + assert album is not None + assert album['id'] == 'rg-damn' # Canonical ID stays the release-group MBID. + assert album['name'] == 'DAMN.' + assert len(album['tracks']) == 1 + assert album['tracks'][0]['name'] == 'BLOOD.' + assert 'release-group' in album['external_urls']['musicbrainz'] + + +def test_get_album_falls_back_to_release_lookup_on_rg_miss(): + """When the MBID is a release (from the text-search fallback path) the + release-group lookup 404s, but the direct release lookup works.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + # Release-group lookup returns None (simulating 404). + client._client.get_release_group.return_value = None + client._client.get_release.return_value = { + 'id': 'rel-abc', + 'title': 'Some Album', + 'date': '2020-01-01', + 'artist-credit': [{'name': 'Some Artist'}], + 'release-group': {'id': 'rg-abc', 'primary-type': 'Album', 'secondary-types': []}, + 'media': [{'position': 1, 'tracks': []}], + } + + album = client.get_album('rel-abc') + + client._client.get_release_group.assert_called_once() + client._client.get_release.assert_called_once() + assert album is not None + assert album['id'] == 'rel-abc' # Falls back to release MBID since rg lookup missed. + + +# --------------------------------------------------------------------------- +# Title-hint extraction — for "Artist Album Title" bare queries +# --------------------------------------------------------------------------- + +def test_extract_title_hint_basic(): + assert _extract_title_hint('The Beatles Abbey Road', 'The Beatles') == 'Abbey Road' + + +def test_extract_title_hint_case_insensitive(): + assert _extract_title_hint('the beatles abbey road', 'The Beatles') == 'abbey road' + + +def test_extract_title_hint_preserves_original_casing(): + # Query slicing should return the original casing of the title portion. + assert _extract_title_hint('The Beatles Abbey Road', 'The Beatles') == 'Abbey Road' + + +def test_extract_title_hint_whitespace_tolerant(): + assert _extract_title_hint('The Beatles Abbey Road', 'The Beatles') == 'Abbey Road' + + +def test_extract_title_hint_bare_artist_returns_none(): + assert _extract_title_hint('The Beatles', 'The Beatles') is None + + +def test_extract_title_hint_artist_not_prefix_returns_none(): + # Query where the artist name isn't the prefix — nothing to extract. + assert _extract_title_hint('Abbey Road', 'The Beatles') is None + + +def test_extract_title_hint_word_boundary_required(): + # "Metallicasomething" shouldn't split as artist=Metallica + hint=something + assert _extract_title_hint('Metallicasomething', 'Metallica') is None + + +def test_search_albums_filters_browse_results_by_title_hint(): + """Regression: 'The Beatles Abbey Road' used to return the whole + discography; should now narrow to Abbey Road specifically.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_artist.return_value = [_mk_artist('The Beatles', 'mb-1', score=100)] + client._client.browse_artist_release_groups.return_value = [ + {'id': 'rg-abbey', 'title': 'Abbey Road', 'primary-type': 'Album', + 'first-release-date': '1969-09-26', 'secondary-types': []}, + {'id': 'rg-white', 'title': 'The Beatles', 'primary-type': 'Album', + 'first-release-date': '1968-11-22', 'secondary-types': []}, + {'id': 'rg-revolver', 'title': 'Revolver', 'primary-type': 'Album', + 'first-release-date': '1966-08-05', 'secondary-types': []}, + ] + + albums = client.search_albums('The Beatles Abbey Road', limit=10) + + # Filtered to only the album whose title matches the hint. + assert [a.name for a in albums] == ['Abbey Road'] + + +def test_search_albums_falls_back_to_text_when_hint_matches_nothing(): + """If the title hint doesn't match any browse result, fall back to + text-search rather than returning the full discography or nothing.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_artist.return_value = [_mk_artist('The Beatles', 'mb-1', score=100)] + # Browse returns albums that don't match the hint. + client._client.browse_artist_release_groups.return_value = [ + {'id': 'rg-1', 'title': 'Some Other Album', 'primary-type': 'Album', + 'first-release-date': '1965-01-01', 'secondary-types': []}, + ] + # Text-search fallback (_search_albums_text → search_release) returns the album. + client._client.search_release.return_value = [ + {'id': 'rel-abbey', 'title': 'Abbey Road', 'score': 100, + 'release-group': {'id': 'rg-abbey', 'primary-type': 'Album'}, + 'artist-credit': [{'name': 'The Beatles'}]}, + ] + + albums = client.search_albums('The Beatles Totally Fake Album Name', limit=10) + + # Browse had no hit for the title hint, then fallback kicks in when + # the filter results are also empty (after studio-pref filter etc.). + # NOTE: in this test the hint filter returns empty, so we fall through + # to search_release. + client._client.search_release.assert_called_once() + assert any(a.name == 'Abbey Road' for a in albums) + + +def test_search_albums_bare_artist_no_hint_no_filter(): + """Bare artist name (no title hint) returns full discography — the + filter only kicks in when the user types extra words.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_artist.return_value = [_mk_artist('The Beatles', 'mb-1', score=100)] + client._client.browse_artist_release_groups.return_value = [ + {'id': 'rg-abbey', 'title': 'Abbey Road', 'primary-type': 'Album', + 'first-release-date': '1969-09-26', 'secondary-types': []}, + {'id': 'rg-revolver', 'title': 'Revolver', 'primary-type': 'Album', + 'first-release-date': '1966-08-05', 'secondary-types': []}, + ] + + albums = client.search_albums('the beatles', limit=10) + + # No filter — full discography. + titles = {a.name for a in albums} + assert 'Abbey Road' in titles + assert 'Revolver' in titles + + +def test_recording_to_track_total_tracks_matches_media_count(): + """Regression: total_tracks was initialized at 1 and summed with media + track-counts, producing an off-by-one. An 11-track album reported 12.""" + client = MusicBrainzSearchClient() + recording = { + 'id': 'rec-1', + 'title': 'Song', + 'length': 300000, + 'artist-credit': [{'name': 'Band'}], + 'releases': [{ + 'id': 'rel-1', + 'title': 'Album', + 'date': '2020-01-01', + 'release-group': {'id': 'rg-1', 'primary-type': 'Album', 'secondary-types': []}, + 'media': [{'track-count': 11}], + }], + } + track = client._recording_to_track(recording, 'Band') + assert track is not None + assert track.total_tracks == 11 + + +def test_recording_to_track_multi_disc_sums_media(): + """Two-disc album with 14 tracks total should report 14, not 15 (off by one) + or 3 (missing the sum).""" + client = MusicBrainzSearchClient() + recording = { + 'id': 'rec-1', + 'title': 'Song', + 'artist-credit': [{'name': 'Band'}], + 'releases': [{ + 'id': 'rel-1', 'title': 'Album', + 'release-group': {'id': 'rg-1', 'primary-type': 'Album'}, + 'media': [{'track-count': 7}, {'track-count': 7}], + }], + } + track = client._recording_to_track(recording, 'Band') + assert track.total_tracks == 14 + + +def test_recording_to_track_no_release_defaults_total_tracks_to_one(): + """A recording with no release info is a standalone track — report 1.""" + client = MusicBrainzSearchClient() + recording = { + 'id': 'rec-1', + 'title': 'Standalone', + 'artist-credit': [{'name': 'Band'}], + 'releases': [], + } + track = client._recording_to_track(recording, 'Band') + assert track.total_tracks == 1 + + +def test_pick_representative_release_prefers_official_with_media(): + """The release picker should skip stub releases (no media) and pick + Official over Promotion status.""" + client = MusicBrainzSearchClient() + releases = [ + {'id': 'stub', 'status': 'Official', 'date': '2020-01-01'}, # No media + {'id': 'promo', 'status': 'Promotion', 'date': '2019-12-01', + 'media': [{'track-count': 10}]}, + {'id': 'official', 'status': 'Official', 'date': '2020-01-05', + 'media': [{'track-count': 10}]}, + ] + picked = client._pick_representative_release(releases) + assert picked['id'] == 'official' + + +def test_search_tracks_text_path_filters_by_score(): + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_recording.return_value = [ + {'id': 'rec-good', 'title': 'Good', 'score': 95, + 'releases': [{'id': 'rel-1', 'title': 'X', 'date': '2020'}], + 'artist-credit': [{'name': 'Foo'}]}, + {'id': 'rec-bad', 'title': 'Bad', 'score': 40, + 'releases': [{'id': 'rel-2', 'title': 'Y', 'date': '2021'}], + 'artist-credit': [{'name': 'Foo'}]}, + ] + + tracks = client.search_tracks('Foo - Good', limit=10) + + titles = [t.name for t in tracks] + assert 'Good' in titles + assert 'Bad' not in titles diff --git a/tests/test_reorganize_db_methods.py b/tests/test_reorganize_db_methods.py new file mode 100644 index 00000000..27e4609d --- /dev/null +++ b/tests/test_reorganize_db_methods.py @@ -0,0 +1,213 @@ +"""Tests for the reorganize-queue DB helpers on `MusicDatabase`: + +- ``get_album_display_meta(album_id)`` — returns the title/artist tuple + the queue uses for status-panel display, or None when not found. +- ``get_artist_albums_for_reorganize(artist_id)`` — returns the + bulk-enqueue list ordered by year then title. + +These are isolated DB-method tests so the SQL itself is verified +without spinning up Flask, the queue worker, or the orchestrator. +""" + +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 + + +# ── helpers ─────────────────────────────────────────────────────────────── + + +class _InMemoryDB(MusicDatabase): + """MusicDatabase that uses an in-memory sqlite that survives across + `_get_connection()` calls. Lets tests seed rows once and have the + methods under test see them.""" + + def __init__(self): + # Skip the real __init__ — it would try to migrate a real db. + self._conn = sqlite3.connect(":memory:") + self._conn.row_factory = sqlite3.Row + + def _get_connection(self): + return _NonClosingConn(self._conn) + + +class _NonClosingConn: + """Wraps the shared sqlite connection so `with db._get_connection() + as conn:` doesn't close the underlying handle between calls.""" + 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(db, *, artists=(), albums=()): + cur = db._conn.cursor() + cur.execute("CREATE TABLE artists (id TEXT PRIMARY KEY, name TEXT)") + cur.execute(""" + CREATE TABLE albums ( + id TEXT PRIMARY KEY, + artist_id TEXT, + title TEXT, + year INTEGER + ) + """) + for ar in artists: + cur.execute("INSERT INTO artists VALUES (?, ?)", ar) + for al in albums: + cur.execute( + "INSERT INTO albums (id, artist_id, title, year) VALUES (?, ?, ?, ?)", + al, + ) + db._conn.commit() + + +@pytest.fixture +def db(): + return _InMemoryDB() + + +# ── get_album_display_meta ──────────────────────────────────────────────── + + +def test_get_album_display_meta_returns_dict_for_known_album(db): + _seed(db, + artists=[('ar-1', 'Kendrick Lamar')], + albums=[('alb-1', 'ar-1', 'good kid, m.A.A.d city', 2012)]) + meta = db.get_album_display_meta('alb-1') + assert meta == { + 'album_title': 'good kid, m.A.A.d city', + 'artist_id': 'ar-1', + 'artist_name': 'Kendrick Lamar', + } + + +def test_get_album_display_meta_returns_none_for_missing_album(db): + _seed(db, artists=[('ar-1', 'Aerosmith')]) + assert db.get_album_display_meta('does-not-exist') is None + + +def test_get_album_display_meta_falls_back_for_blank_strings(db): + """Albums with empty title or artist name in the DB still need a + safe display value — the queue UI should never render '(blank)'.""" + _seed(db, + artists=[('ar-1', '')], + albums=[('alb-1', 'ar-1', '', 2015)]) + meta = db.get_album_display_meta('alb-1') + assert meta['album_title'] == 'Unknown Album' + assert meta['artist_name'] == 'Unknown Artist' + assert meta['artist_id'] == 'ar-1' + + +# ── get_artist_albums_for_reorganize ────────────────────────────────────── + + +def test_get_artist_albums_for_reorganize_orders_by_year_then_title(db): + _seed(db, + artists=[('ar-1', 'Aerosmith')], + albums=[ + ('alb-c', 'ar-1', 'Toys in the Attic', 1975), + ('alb-a', 'ar-1', 'Aerosmith', 1973), + ('alb-b', 'ar-1', 'Get Your Wings', 1974), + ]) + rows = db.get_artist_albums_for_reorganize('ar-1') + assert [r['album_id'] for r in rows] == ['alb-a', 'alb-b', 'alb-c'] + assert all(r['artist_name'] == 'Aerosmith' for r in rows) + + +def test_get_artist_albums_for_reorganize_secondary_sorts_by_title(db): + """Same release year → tiebreak on title alphabetically.""" + _seed(db, + artists=[('ar-1', 'X')], + albums=[ + ('alb-z', 'ar-1', 'Zebra', 1990), + ('alb-a', 'ar-1', 'Apple', 1990), + ('alb-m', 'ar-1', 'Mango', 1990), + ]) + rows = db.get_artist_albums_for_reorganize('ar-1') + assert [r['album_title'] for r in rows] == ['Apple', 'Mango', 'Zebra'] + + +def test_get_artist_albums_for_reorganize_returns_empty_for_unknown_artist(db): + _seed(db, artists=[('ar-1', 'Aerosmith')]) + assert db.get_artist_albums_for_reorganize('not-a-real-artist') == [] + + +def test_get_artist_albums_for_reorganize_isolates_by_artist(db): + """Pulling albums for artist A must NOT leak in albums from artist B.""" + _seed(db, + artists=[('ar-1', 'A'), ('ar-2', 'B')], + albums=[ + ('alb-1', 'ar-1', 'A1', 2000), + ('alb-2', 'ar-2', 'B1', 2000), + ('alb-3', 'ar-1', 'A2', 2001), + ]) + rows = db.get_artist_albums_for_reorganize('ar-1') + assert {r['album_id'] for r in rows} == {'alb-1', 'alb-3'} + + +# ── error propagation ──────────────────────────────────────────────────── +# Regression for review feedback on the original PR: helpers used to +# swallow every Exception and return None / [], so a real DB outage +# masqueraded as "album not found" / "no albums". Now they let the +# error bubble — the route layer turns it into a 500 — so the user sees +# a real failure instead of a phantom empty state. + + +def test_get_album_display_meta_propagates_db_errors(db): + """If the underlying tables don't exist, the helper must raise + rather than swallow it as a missing-album result.""" + # Don't seed — the schema is empty, so the SELECT will fail with + # OperationalError ("no such table: albums"). + with pytest.raises(sqlite3.OperationalError): + db.get_album_display_meta('alb-1') + + +def test_get_artist_albums_for_reorganize_propagates_db_errors(db): + with pytest.raises(sqlite3.OperationalError): + db.get_artist_albums_for_reorganize('ar-1') diff --git a/tests/test_reorganize_queue.py b/tests/test_reorganize_queue.py new file mode 100644 index 00000000..8e2707b4 --- /dev/null +++ b/tests/test_reorganize_queue.py @@ -0,0 +1,479 @@ +"""Tests for `core.reorganize_queue.ReorganizeQueue`. + +Contract this test file pins: + +1. **Dedupe on enqueue** — re-submitting an album that's already queued or + running returns ``{'queued': False, 'reason': 'already_queued'}`` and + the existing queue_id, never a duplicate. +2. **FIFO order** — the worker drains items in submission order. +3. **Per-item source preserved** — the source string the user picked at + enqueue time is what the runner sees, even when multiple items with + different sources are interleaved. +4. **Continue on failure** — a runner that raises (or one whose summary + reports a non-completed status) marks that item failed and the + worker moves to the next item, it does not stall. +5. **Cancel queued** — items in `queued` state can be dropped before + they reach the runner. +6. **Cancel running rejected** — the currently-running item can NOT be + cancelled, the API returns `running_cant_cancel`. +7. **Clear queued** — bulk-cancels all `queued` items at once, leaves + the running item alone. +8. **Snapshot shape** — `active`, `queued`, `recent`, and `totals` keys + are always present and reflect the current state. +9. **update_active_progress** — live progress fields propagate onto the + running item (and only the running item). +10. **Setting runner late** — items enqueued before `set_runner()` was + called still get processed once the runner shows up. +""" + +import threading +import time + +import pytest + +from core.reorganize_queue import ReorganizeQueue, QueueItem + + +# --- helpers --------------------------------------------------------------- + + +def _make_runner(record, *, raise_on=None, summary_factory=None, + block_event=None, runtime=0.0): + """Build a runner closure that records what it was called with. + + Args: + record: list to append `(queue_id, source)` to per call. + raise_on: queue_id (or set of queue_ids) for which the runner + should raise — used to test continue-on-failure. + summary_factory: optional callable `(item) -> summary dict` to + override the default `{'status': 'completed', ...}` shape. + block_event: optional `threading.Event` the runner blocks on + before returning — used to keep an item in 'running' state + while the test pokes at it. + runtime: seconds the runner sleeps before returning. + """ + raise_set = set() + if isinstance(raise_on, str): + raise_set = {raise_on} + elif raise_on: + raise_set = set(raise_on) + + def runner(item): + record.append((item.queue_id, item.source)) + if block_event is not None: + block_event.wait(timeout=2.0) + if runtime: + time.sleep(runtime) + if item.queue_id in raise_set: + raise RuntimeError(f"Simulated failure for {item.queue_id}") + if summary_factory is not None: + return summary_factory(item) + return { + 'status': 'completed', + 'source': item.source or 'spotify', + 'total': 1, + 'moved': 1, + 'skipped': 0, + 'failed': 0, + 'errors': [], + } + return runner + + +def _enqueue(queue, *, album_id, source=None, title=None, artist='Aerosmith'): + return queue.enqueue( + album_id=album_id, + album_title=title or f"Album {album_id}", + artist_id='artist-1', + artist_name=artist, + source=source, + ) + + +def _wait_for(predicate, timeout=2.0, interval=0.02): + """Poll until predicate() is truthy or timeout elapses.""" + deadline = time.time() + timeout + while time.time() < deadline: + if predicate(): + return True + time.sleep(interval) + return False + + +@pytest.fixture +def queue(): + q = ReorganizeQueue() + yield q + q.stop() + + +# --- tests ----------------------------------------------------------------- + + +def test_enqueue_returns_queued_with_position(queue): + block = threading.Event() + queue.set_runner(_make_runner([], block_event=block)) + r1 = _enqueue(queue, album_id='alb-1') + # Wait for the worker to actually pick up alb-1 so r2 lands while + # alb-1 is running, not while it's still queued — otherwise the + # position number depends on thread-scheduling timing. + assert _wait_for(lambda: queue.snapshot()['active'] is not None) + r2 = _enqueue(queue, album_id='alb-2') + assert r1['queued'] is True + assert r1['position'] == 1 + assert r2['queued'] is True + assert r2['position'] == 1 + block.set() + + +def test_enqueue_same_album_dedupes(queue): + queue.set_runner(_make_runner([], block_event=threading.Event())) + r1 = _enqueue(queue, album_id='alb-1', source='spotify') + r2 = _enqueue(queue, album_id='alb-1', source='deezer') # different source + assert r1['queued'] is True + assert r2['queued'] is False + assert r2['reason'] == 'already_queued' + assert r2['queue_id'] == r1['queue_id'] + + +def test_dedupe_releases_after_completion(queue): + """Once an item finishes (done/failed/cancelled), the same album_id + can be re-enqueued. Otherwise users couldn't retry after a fix.""" + record = [] + queue.set_runner(_make_runner(record)) + r1 = _enqueue(queue, album_id='alb-1') + assert _wait_for(lambda: any(r[0] == r1['queue_id'] for r in record)) + # Wait for the item to flip into the recent bucket. + assert _wait_for(lambda: queue.snapshot()['active'] is None) + r2 = _enqueue(queue, album_id='alb-1') + assert r2['queued'] is True + assert r2['queue_id'] != r1['queue_id'] + + +def test_fifo_order(queue): + record = [] + queue.set_runner(_make_runner(record)) + ids = [_enqueue(queue, album_id=f'alb-{i}')['queue_id'] for i in range(5)] + assert _wait_for(lambda: len(record) == 5) + assert [r[0] for r in record] == ids + + +def test_per_item_source_preserved(queue): + record = [] + queue.set_runner(_make_runner(record)) + sources = ['spotify', 'deezer', 'itunes', None, 'discogs'] + for i, src in enumerate(sources): + _enqueue(queue, album_id=f'alb-{i}', source=src) + assert _wait_for(lambda: len(record) == len(sources)) + assert [r[1] for r in record] == sources + + +def test_continue_on_runner_exception(queue): + """A runner that raises must not stall the queue — the item is + marked failed and the next item runs.""" + record = [] + # Pre-allocate queue_ids by enqueuing first, then point the runner + # at the middle one. Block the runner so all three sit in the queue + # before any actually run. + block = threading.Event() + raise_target = {} + + def runner(item): + record.append((item.queue_id, item.source)) + block.wait(timeout=2.0) + if item.queue_id == raise_target.get('id'): + raise RuntimeError(f"Simulated failure for {item.queue_id}") + return { + 'status': 'completed', 'source': 'spotify', + 'total': 1, 'moved': 1, 'skipped': 0, 'failed': 0, 'errors': [], + } + + queue.set_runner(runner) + ids = [_enqueue(queue, album_id=f'alb-{i}')['queue_id'] for i in range(3)] + raise_target['id'] = ids[1] + block.set() + + assert _wait_for(lambda: len(record) == 3) + assert [r[0] for r in record] == ids + + assert _wait_for(lambda: queue.snapshot()['active'] is None) + snap = queue.snapshot() + recent_by_id = {r['queue_id']: r for r in snap['recent']} + assert recent_by_id[ids[0]]['status'] == 'done' + assert recent_by_id[ids[1]]['status'] == 'failed' + assert recent_by_id[ids[2]]['status'] == 'done' + + +def test_failed_status_when_runner_reports_failed_tracks(queue): + """A summary with ``failed > 0`` should mark the queue item as + 'failed' even if the runner returned normally.""" + queue.set_runner(_make_runner([], summary_factory=lambda item: { + 'status': 'completed', + 'source': 'spotify', + 'total': 5, + 'moved': 4, + 'skipped': 0, + 'failed': 1, + 'errors': [{'track_id': 't-1', 'title': 'X', 'error': 'boom'}], + })) + qid = _enqueue(queue, album_id='alb-1')['queue_id'] + # Wait for the item to land in `recent` (active is None both before + # the worker picks up the item and after it's done — only the + # presence in recent is unambiguous). + assert _wait_for(lambda: any(r['queue_id'] == qid for r in queue.snapshot()['recent'])) + snap = queue.snapshot() + item = next(i for i in snap['recent'] if i['queue_id'] == qid) + assert item['status'] == 'failed' + assert item['moved'] == 4 + assert item['failed'] == 1 + assert item['error'] == 'boom' + + +def test_failed_status_when_runner_reports_non_completed_status(queue): + """``status='no_source_id'`` and friends are setup-failures — they + leave failed=0 but the item is still NOT a success.""" + queue.set_runner(_make_runner([], summary_factory=lambda item: { + 'status': 'no_source_id', + 'source': None, + 'total': 0, + 'moved': 0, + 'skipped': 0, + 'failed': 0, + 'errors': [], + })) + qid = _enqueue(queue, album_id='alb-1')['queue_id'] + assert _wait_for(lambda: any(r['queue_id'] == qid for r in queue.snapshot()['recent'])) + snap = queue.snapshot() + item = next(r for r in snap['recent'] if r['queue_id'] == qid) + assert item['status'] == 'failed' + assert item['result_status'] == 'no_source_id' + + +def test_cancel_queued_item(queue): + """Cancel BEFORE the worker reaches the item drops it cleanly.""" + block = threading.Event() + queue.set_runner(_make_runner([], block_event=block)) + first = _enqueue(queue, album_id='alb-1')['queue_id'] # gets pulled to running, blocks + second = _enqueue(queue, album_id='alb-2')['queue_id'] # sits in queued + + # Wait for first to be running so we know the worker is parked on it. + assert _wait_for(lambda: queue.snapshot()['active'] is not None) + + result = queue.cancel(second) + assert result['cancelled'] is True + + snap = queue.snapshot() + assert all(i['queue_id'] != second for i in snap['queued']) + # And the cancelled one shows up in recent with status 'cancelled'. + assert any(i['queue_id'] == second and i['status'] == 'cancelled' for i in snap['recent']) + + block.set() # release the running item + + +def test_cancel_running_rejected(queue): + block = threading.Event() + queue.set_runner(_make_runner([], block_event=block)) + qid = _enqueue(queue, album_id='alb-1')['queue_id'] + assert _wait_for(lambda: queue.snapshot()['active'] is not None) + + result = queue.cancel(qid) + assert result['cancelled'] is False + assert result['reason'] == 'running_cant_cancel' + block.set() + + +def test_cancel_unknown_id(queue): + result = queue.cancel('does-not-exist') + assert result['cancelled'] is False + assert result['reason'] == 'not_found' + + +def test_clear_queued_bulk_cancel(queue): + block = threading.Event() + queue.set_runner(_make_runner([], block_event=block)) + _enqueue(queue, album_id='alb-1') # running, blocked + queued_ids = [_enqueue(queue, album_id=f'alb-{i}')['queue_id'] for i in range(2, 6)] + + assert _wait_for(lambda: queue.snapshot()['active'] is not None) + assert _wait_for(lambda: len(queue.snapshot()['queued']) == 4) + + cancelled = queue.clear_queued() + assert cancelled == 4 + + snap = queue.snapshot() + assert len(snap['queued']) == 0 + # Running item is untouched. + assert snap['active'] is not None + cancelled_in_recent = [i for i in snap['recent'] if i['status'] == 'cancelled'] + assert {i['queue_id'] for i in cancelled_in_recent} == set(queued_ids) + block.set() + + +def test_snapshot_shape(queue): + snap = queue.snapshot() + assert set(snap.keys()) == {'active', 'queued', 'recent', 'totals'} + assert set(snap['totals'].keys()) >= {'queued', 'running', 'done', 'failed', 'cancelled'} + assert snap['active'] is None + assert snap['queued'] == [] + assert snap['recent'] == [] + + +def test_update_active_progress_only_targets_running(queue): + block = threading.Event() + queue.set_runner(_make_runner([], block_event=block)) + qid = _enqueue(queue, album_id='alb-1')['queue_id'] + assert _wait_for(lambda: queue.snapshot()['active'] is not None) + + queue.update_active_progress( + queue_id=qid, + current_track='Dream On', + total=8, + processed=3, + moved=3, + skipped=0, + failed=0, + ) + snap = queue.snapshot() + assert snap['active']['current_track'] == 'Dream On' + assert snap['active']['progress_total'] == 8 + assert snap['active']['progress_processed'] == 3 + assert snap['active']['moved'] == 3 + block.set() + + +def test_update_progress_for_unknown_id_is_noop(queue): + """Calling update_active_progress for an item that isn't running + must not raise, must not corrupt other items.""" + block = threading.Event() + queue.set_runner(_make_runner([], block_event=block)) + qid = _enqueue(queue, album_id='alb-1')['queue_id'] + assert _wait_for(lambda: queue.snapshot()['active'] is not None) + + queue.update_active_progress(queue_id='not-a-real-id', current_track='X', total=999) + snap = queue.snapshot() + assert snap['active']['queue_id'] == qid + assert snap['active']['progress_total'] == 0 # unchanged + block.set() + + +def test_enqueue_many_tallies_enqueued_and_dedupes(queue): + """Bulk enqueue returns ``{enqueued, already_queued, total}`` so + the route handler doesn't have to count itself. Re-enqueuing the + same album-id twice in the same batch dedupes.""" + block = threading.Event() + queue.set_runner(_make_runner([], block_event=block)) + + # Pre-existing item — should appear as already_queued. + queue.enqueue(album_id='alb-existing', album_title='X', + artist_id='ar-1', artist_name='A', source=None) + # Wait for it to be running so the dedupe path triggers. + assert _wait_for(lambda: queue.snapshot()['active'] is not None) + + items = [ + {'album_id': 'alb-existing', 'album_title': 'X', 'artist_id': 'ar-1', 'artist_name': 'A'}, + {'album_id': 'alb-new-1', 'album_title': 'Y', 'artist_id': 'ar-1', 'artist_name': 'A'}, + {'album_id': 'alb-new-2', 'album_title': 'Z', 'artist_id': 'ar-1', 'artist_name': 'A'}, + ] + result = queue.enqueue_many(items) + assert result == {'enqueued': 2, 'already_queued': 1, 'total': 3} + block.set() + + +def test_enqueue_many_carries_source_per_item(queue): + """Each dict's ``source`` is honoured independently — the bulk + helper doesn't collapse them to one value.""" + record = [] + queue.set_runner(_make_runner(record)) + items = [ + {'album_id': 'a', 'album_title': 'A', 'artist_id': 'x', 'artist_name': 'X', 'source': 'spotify'}, + {'album_id': 'b', 'album_title': 'B', 'artist_id': 'x', 'artist_name': 'X', 'source': 'deezer'}, + {'album_id': 'c', 'album_title': 'C', 'artist_id': 'x', 'artist_name': 'X', 'source': None}, + ] + queue.enqueue_many(items) + assert _wait_for(lambda: len(record) == 3) + assert [r[1] for r in record] == ['spotify', 'deezer', None] + + +def test_enqueue_many_handles_empty_list(queue): + queue.set_runner(_make_runner([])) + assert queue.enqueue_many([]) == {'enqueued': 0, 'already_queued': 0, 'total': 0} + + +def test_enqueue_many_dedupes_batch_internal_duplicates(queue): + """Same album_id appearing twice in the same bulk request must be + deduped against each other — not just against pre-existing items. + Regression for the race where a fast runner finishes the first copy + before the loop reaches the second, letting both slip through.""" + record = [] + queue.set_runner(_make_runner(record)) + items = [ + {'album_id': 'alb-x', 'album_title': 'X', 'artist_id': 'ar-1', 'artist_name': 'A'}, + {'album_id': 'alb-y', 'album_title': 'Y', 'artist_id': 'ar-1', 'artist_name': 'A'}, + {'album_id': 'alb-x', 'album_title': 'X (dup)', 'artist_id': 'ar-1', 'artist_name': 'A'}, + ] + result = queue.enqueue_many(items) + assert result == {'enqueued': 2, 'already_queued': 1, 'total': 3} + # Wait for the queue to drain, then give the worker a moment to + # try (and fail) to pick a phantom third item. If the dedupe leaked, + # a third runner call would land here. + assert _wait_for(lambda: queue.snapshot()['active'] is None and not queue.snapshot()['queued']) + time.sleep(0.05) + assert len(record) == 2 + + +def test_cancel_and_run_are_mutually_exclusive(queue): + """Regression for kettui's ``_next_queued() → status flip`` race: + a successfully-cancelled item must NEVER have its runner invoked. + With the old non-atomic pick + flip, cancel could land between + the worker's pick and its flip-to-running, leaving the item + marked 'cancelled' but the worker still runs it. + + Hammers many enqueue-then-immediately-cancel pairs to exercise the + race window. After draining, every queue_id whose cancel returned + ``cancelled: True`` must NOT appear in the runner's record.""" + runner_called: set = set() + runner_lock = threading.Lock() + + def runner(item): + with runner_lock: + runner_called.add(item.queue_id) + # Slight runtime widens the window where overlapping cancels + # could (incorrectly) fire on a running item. + time.sleep(0.002) + return { + 'status': 'completed', 'source': 'spotify', + 'total': 1, 'moved': 1, 'skipped': 0, 'failed': 0, 'errors': [], + } + + queue.set_runner(runner) + + successful_cancels: set = set() + for i in range(50): + r = _enqueue(queue, album_id=f'alb-race-{i}') + # Immediately try to cancel — half will land while item is still + # 'queued', half will land after worker has flipped to 'running'. + if queue.cancel(r['queue_id'])['cancelled']: + successful_cancels.add(r['queue_id']) + + assert _wait_for( + lambda: queue.snapshot()['active'] is None and not queue.snapshot()['queued'], + timeout=5.0, + ) + + leaked = successful_cancels & runner_called + assert not leaked, f"Runner ran for cancelled items: {leaked}" + + +def test_no_runner_marks_item_failed(queue): + """If the worker pulls an item but no runner has been set, the item + must be marked failed (not silently dropped). In practice + web_server.py wires the runner at module load before any request + can land, so this is a defensive-failure path more than a real + one — but the failure mode must be loud.""" + queue.set_runner(None) + qid = _enqueue(queue, album_id='alb-orphan')['queue_id'] + assert _wait_for(lambda: any(r['queue_id'] == qid for r in queue.snapshot()['recent'])) + snap = queue.snapshot() + failed = next(i for i in snap['recent'] if i['queue_id'] == qid) + assert failed['status'] == 'failed' + assert 'runner' in (failed['error'] or '').lower() diff --git a/tests/test_reorganize_runner.py b/tests/test_reorganize_runner.py new file mode 100644 index 00000000..f38bffd1 --- /dev/null +++ b/tests/test_reorganize_runner.py @@ -0,0 +1,235 @@ +"""Tests for `core.reorganize_runner.build_runner`. + +Contract this test file pins: + +1. **Runner is a closure** — calling `build_runner` returns a callable + that takes a queue item and returns a summary dict matching + `reorganize_album`'s shape. +2. **Config is read per-run, not at factory time** — changing the + download/transfer path between runs is honoured. Web server config + should never need a restart for this to take effect. +3. **Setup failure surfaces a clean summary** — if the staging dir + cannot be created, the runner returns `status='setup_failed'` + instead of raising (so the queue marks the item failed cleanly). +4. **Progress callbacks fan out into the queue** — the runner wires + `reorganize_album`'s `on_progress` to `update_active_progress` on + the live singleton queue, so the status panel sees per-track state. +5. **Dependencies are injected, not imported** — the factory takes + every external dependency as a callable so tests can run without + spinning up Flask, the DB, or the post-process pipeline. +""" + +import sys +import types +from unittest.mock import MagicMock + +import pytest + + +# Stub config.settings so importing core.reorganize_runner -> core.library_reorganize doesn't blow up +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 + +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 + + +from core.reorganize_runner import build_runner # noqa: E402 + + +@pytest.fixture(autouse=True) +def reset_queue_singleton(): + """Each test gets a fresh queue singleton so update_active_progress + in one test doesn't leak into another.""" + from core.reorganize_queue import reset_queue_for_tests + reset_queue_for_tests() + yield + reset_queue_for_tests() + + +def _make_item(*, queue_id='qid-1', album_id='alb-1', source=None): + """Mock queue item — only needs the fields the runner reads.""" + item = MagicMock() + item.queue_id = queue_id + item.album_id = album_id + item.source = source + return item + + +def _build(monkeypatch, *, download_path_fn, transfer_path_fn, + reorganize_album_fn, get_database=lambda: object()): + """Helper: stub out the heavy reorganize_album call so we can test + the wiring without a real DB / post-process pipeline.""" + # Patch the import inside reorganize_runner.build_runner. + import core.reorganize_runner as mod + monkeypatch.setattr( + 'core.library_reorganize.reorganize_album', + reorganize_album_fn, + raising=True, + ) + + return build_runner( + get_database=get_database, + resolve_file_path_fn=lambda p: p, + post_process_fn=lambda *a, **k: None, + cleanup_empty_directories_fn=lambda *a, **k: None, + is_shutting_down_fn=lambda: False, + get_download_path=download_path_fn, + get_transfer_path=transfer_path_fn, + ) + + +def test_runner_invokes_reorganize_album_with_injected_deps(monkeypatch, tmp_path): + captured = {} + + def fake_reorganize_album(**kwargs): + captured.update(kwargs) + return { + 'status': 'completed', 'source': 'spotify', + 'total': 1, 'moved': 1, 'skipped': 0, 'failed': 0, 'errors': [], + } + + runner = _build( + monkeypatch, + download_path_fn=lambda: str(tmp_path), + transfer_path_fn=lambda: str(tmp_path / 'transfer'), + reorganize_album_fn=fake_reorganize_album, + ) + item = _make_item(album_id='alb-X', source='deezer') + summary = runner(item) + + assert summary['status'] == 'completed' + assert captured['album_id'] == 'alb-X' + assert captured['primary_source'] == 'deezer' + assert captured['strict_source'] is True + # staging_root is download_path / ssync_staging + assert captured['staging_root'].endswith('ssync_staging') + assert callable(captured['on_progress']) + assert callable(captured['stop_check']) + + +def test_runner_reads_config_per_call(monkeypatch, tmp_path): + """Path that the runner sees should reflect the value returned by + the path-resolver lambda AT call time — not at build_runner time. + This is the explicit fix for kettui-style "config change requires + server restart" feedback.""" + seen_staging_roots = [] + + def fake_reorganize_album(**kwargs): + seen_staging_roots.append(kwargs['staging_root']) + return { + 'status': 'completed', 'source': None, + 'total': 0, 'moved': 0, 'skipped': 0, 'failed': 0, 'errors': [], + } + + current_path = {'value': str(tmp_path / 'first')} + runner = _build( + monkeypatch, + download_path_fn=lambda: current_path['value'], + transfer_path_fn=lambda: '/tmp/transfer', + reorganize_album_fn=fake_reorganize_album, + ) + + runner(_make_item()) + current_path['value'] = str(tmp_path / 'second') + runner(_make_item()) + + assert len(seen_staging_roots) == 2 + assert 'first' in seen_staging_roots[0] + assert 'second' in seen_staging_roots[1] + + +def test_runner_returns_setup_failed_on_unwritable_path(monkeypatch, tmp_path): + """If the staging dir can't be created (permission denied, etc.), + the runner returns a clean ``setup_failed`` summary so the queue + marks the item failed without an unhandled exception.""" + def fake_reorganize_album(**kwargs): + pytest.fail("reorganize_album should not run when setup fails") + + # Point at a child of an existing FILE — makedirs will raise OSError. + blocking_file = tmp_path / 'blocker' + blocking_file.write_text('x') + + runner = _build( + monkeypatch, + download_path_fn=lambda: str(blocking_file), # makedirs fails here + transfer_path_fn=lambda: '/tmp/transfer', + reorganize_album_fn=fake_reorganize_album, + ) + summary = runner(_make_item()) + assert summary['status'] == 'setup_failed' + assert summary['errors'] + + +def test_runner_progress_callback_forwards_to_queue(monkeypatch, tmp_path): + """When reorganize_album fires its on_progress callback, the runner + must forward into the live queue's update_active_progress so the + status panel sees per-track updates.""" + from core.reorganize_queue import get_queue, ReorganizeQueue + import threading + + # Use a real queue that's blocked on a runner — gives us a known + # 'running' item to propagate progress into. + block = threading.Event() + + def fake_reorganize_album(*, on_progress, **kwargs): + # Simulate per-track progress emissions like the real + # orchestrator does. + on_progress({'current_track': 'Backseat Freestyle', 'total': 12, 'processed': 1}) + on_progress({'moved': 1, 'processed': 1}) + return { + 'status': 'completed', 'source': 'spotify', + 'total': 12, 'moved': 1, 'skipped': 0, 'failed': 0, 'errors': [], + } + + runner = _build( + monkeypatch, + download_path_fn=lambda: str(tmp_path), + transfer_path_fn=lambda: str(tmp_path / 'transfer'), + reorganize_album_fn=fake_reorganize_album, + ) + + # Wire our runner into the singleton queue and enqueue an item, so + # update_active_progress has a 'running' item to write into. + q = get_queue() + q.set_runner(runner) + enq = q.enqueue(album_id='alb-1', album_title='good kid', + artist_id='ar-1', artist_name='Kendrick Lamar', source=None) + + # Wait for the worker to finish (fake_reorganize_album is fast). + deadline_passes = 0 + import time + while deadline_passes < 50: + snap = q.snapshot() + if any(r['queue_id'] == enq['queue_id'] for r in snap['recent']): + break + time.sleep(0.02) + deadline_passes += 1 + + snap = q.snapshot() + finished = next(r for r in snap['recent'] if r['queue_id'] == enq['queue_id']) + assert finished['status'] == 'done' + assert finished['moved'] == 1 + # The progress fan-out happened *while* the item was running. The + # final snapshot shows the worker-set values — what we're really + # asserting is that progress callbacks didn't raise. diff --git a/tests/test_socketio_cors.py b/tests/test_socketio_cors.py new file mode 100644 index 00000000..b54d94d3 --- /dev/null +++ b/tests/test_socketio_cors.py @@ -0,0 +1,442 @@ +"""Tests for `core.socketio_cors` — the resolver, rejection predictor, +and dedup logger that gate Socket.IO WebSocket origins. + +These pin the security-relevant behavior: + +- The resolver returns ``None`` (engineio's same-origin default — also + the secure default) for anything other than an explicit allow-list or + the wildcard. CRITICAL: the resolver must NEVER return ``[]`` — in + engineio that means "disable CORS handling" which is identical to the + ``'*'`` wildcard from a security standpoint (engineio/server.py:202: + ``if cors_allowed_origins != []``). And it must never silently turn + into ``'*'`` from a misshapen config value. +- The rejection predictor must mirror engineio's same-origin check + exactly so the warning we log is accurate. This includes accepting + matches against ``X-Forwarded-Host`` since engineio honors that + automatically when ``cors_allowed_origins`` is ``None``. +- The dedup logger must emit each unique origin only once so a malicious + site repeatedly hammering the WS endpoint can't spam logs. + +Pure unit tests — no Flask, no engineio, no network. Just the logic. +""" + +import threading +from typing import Any, List + +import pytest + +from core.socketio_cors import ( + RejectionLogger, + log_startup_status, + resolve_cors_origins, + will_reject, +) + + +# ── helpers ─────────────────────────────────────────────────────────────── + + +class _FakeConfig: + """Minimal config_manager stub that returns one canned value for the + `security.cors_origins` key. Anything else returns the default.""" + + def __init__(self, value: Any): + self._value = value + + def get(self, key: str, default: Any = None) -> Any: + if key == 'security.cors_origins': + return self._value + return default + + +class _CapturingLogger: + """Stand-in logger that records every warning/info call so tests can + assert what was emitted (and how many times).""" + + def __init__(self): + self.warnings: List[str] = [] + self.infos: List[str] = [] + + def warning(self, msg: str) -> None: + self.warnings.append(msg) + + def info(self, msg: str) -> None: + self.infos.append(msg) + + +# ── resolve_cors_origins ────────────────────────────────────────────────── + + +@pytest.mark.parametrize("value, expected", [ + # Unset / empty / whitespace / bogus types → None (engineio same-origin default) + (None, None), + ('', None), + (' ', None), + ('\n\n', None), + (',,,', None), + (12345, None), # numeric — invalid type + ({'a': 1}, None), # dict — invalid type + ([], None), # explicit empty list + ([' ', ''], None), # list of all-empty strings + + # Wildcard + ('*', '*'), + (' * ', '*'), + (['*'], '*'), + (['https://x.com', '*'], '*'), # wildcard in a list still wins + + # Single origin + ('https://x.com', ['https://x.com']), + (['https://x.com'], ['https://x.com']), + + # Multiple origins, comma-separated + ('https://x.com, http://y.com', ['https://x.com', 'http://y.com']), + + # Multiple origins, newline-separated (textarea input) + ('https://x.com\nhttp://y.com', ['https://x.com', 'http://y.com']), + + # Mixed separators + extra commas / whitespace get cleaned + ('https://x.com,, http://y.com,\n http://z.com', ['https://x.com', 'http://y.com', 'http://z.com']), + + # List with mixed types (bytes-like → str coerce) + (['https://x.com', ' ', 'http://y.com'], ['https://x.com', 'http://y.com']), +]) +def test_resolve_cors_origins_normalizes_input(value, expected): + assert resolve_cors_origins(_FakeConfig(value)) == expected + + +def test_resolve_cors_origins_handles_missing_config_manager(): + """Defensive: if config_manager is None (e.g., very early init), the + resolver must fall back to the secure default rather than crashing.""" + assert resolve_cors_origins(None) is None + + +def test_resolve_cors_origins_never_returns_empty_list(): + """SECURITY CRITICAL: ``cors_allowed_origins=[]`` in engineio means + "disable CORS handling entirely" — identical security to ``'*'`` + (engineio/server.py:202). The resolver must return ``None`` for the + secure default, never ``[]``, regardless of what the user typed.""" + edge_cases = [None, '', ' ', '\n\n', ',,,', 12345, 3.14, {'a': 1}, + object(), True, False, [], [' '], ['', ' '], (' ',)] + for value in edge_cases: + result = resolve_cors_origins(_FakeConfig(value)) + assert result != [], ( + f"resolve_cors_origins({value!r}) returned [] — that disables " + f"engineio's CORS check entirely, allowing all origins. Must be None." + ) + + +def test_resolve_cors_origins_never_silently_returns_wildcard_for_garbage(): + """Security-critical: a misshapen config value must NEVER turn into + `'*'` by accident. Anything we can't parse falls back to same-origin.""" + for bogus in [12345, 3.14, {'a': 1}, object(), True, False]: + assert resolve_cors_origins(_FakeConfig(bogus)) is None, ( + f"resolve_cors_origins({bogus!r}) returned a non-None value — " + f"bogus inputs must default to same-origin only" + ) + + +# ── will_reject ─────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("allowed, origin, host, scheme, expected_reject", [ + # Same-origin (Origin's full {scheme}://{host} matches request) — allow + (None, 'http://localhost:8888', 'localhost:8888', 'http', False), + (None, 'http://192.168.1.5:8888', '192.168.1.5:8888', 'http', False), + (None, 'https://soulsync.foo', 'soulsync.foo', 'https', False), + + # Cross-origin with default allow-list — reject + (None, 'https://x.com', 'localhost:8888', 'http', True), + (None, 'https://soulsync.foo', 'localhost:8888', 'http', True), # reverse proxy NOT forwarding Host + # Scheme mismatch — engineio rejects, so do we + (None, 'https://soulsync.foo', 'soulsync.foo', 'http', True), + + # Wildcard short-circuit — allow + ('*', 'https://x.com', 'localhost:8888', 'http', False), + ('*', 'https://anything.evil', 'localhost:8888', 'http', False), + + # Origin in allow-list — allow + (['https://x.com'], 'https://x.com', 'localhost:8888', 'http', False), + (['https://soulsync.foo'], 'https://soulsync.foo', 'localhost:8888', 'http', False), + + # Cross-origin not in allow-list — reject + (['https://x.com'], 'https://y.com', 'localhost:8888', 'http', True), + + # Same-origin still works even when allow-list has other entries + (['https://x.com'], 'http://localhost:8888', 'localhost:8888', 'http', False), +]) +def test_will_reject_predicts_engineio_decision(allowed, origin, host, scheme, expected_reject): + assert will_reject(allowed, origin, host, request_scheme=scheme) is expected_reject + + +def test_will_reject_with_empty_host_only_uses_allowlist(): + """If the request somehow has no Host header (shouldn't happen but be + safe), same-origin can't be checked — fall through to allow-list only.""" + assert will_reject(None, 'https://x.com', '', request_scheme='https') is True + assert will_reject(['https://x.com'], 'https://x.com', '', request_scheme='https') is False + assert will_reject('*', 'https://x.com', '', request_scheme='https') is False + + +def test_will_reject_honors_x_forwarded_host(): + """Engineio honors X-Forwarded-Host automatically when + cors_allowed_origins is None (engineio/base_server.py:_cors_allowed_origins). + Our predictor must mirror that — otherwise reverse-proxy users with + proper proxy headers would trigger spurious "rejected" log lines.""" + # Same-origin via X-Forwarded-Host (typical TLS-terminating reverse proxy) + assert will_reject(None, 'https://soulsync.foo', 'internal:8888', + request_scheme='http', + forwarded_host='soulsync.foo', + forwarded_proto='https') is False + + # X-Forwarded-Host with comma list (proxy chain) — first entry wins + assert will_reject(None, 'https://soulsync.foo', 'internal:8888', + request_scheme='http', + forwarded_host='soulsync.foo, edge.proxy', + forwarded_proto='https') is False + + # X-Forwarded-Host doesn't match either — still reject + assert will_reject(None, 'https://attacker.com', 'internal:8888', + request_scheme='http', + forwarded_host='soulsync.foo', + forwarded_proto='https') is True + + # X-Forwarded-Host empty — falls back to Host check (the unset case) + assert will_reject(None, 'https://soulsync.foo', 'soulsync.foo', + request_scheme='https', + forwarded_host='') is False + + +def test_will_reject_compares_full_scheme_when_known(): + """When the caller provides scheme info, engineio compares full + {scheme}://{host} strings. A TLS-terminating proxy can leave the + backend seeing http while the browser's Origin is https — engineio + rejects, our predictor must too (otherwise we miss logging it).""" + # Backend sees http, browser sent https → engineio rejects → we predict reject + assert will_reject(None, 'https://soulsync.foo', 'soulsync.foo', + request_scheme='http') is True + + # Backend sees http, browser sent http → match → allow + assert will_reject(None, 'http://soulsync.foo', 'soulsync.foo', + request_scheme='http') is False + + # X-Forwarded-Proto says the public request was https → match origin's https + assert will_reject(None, 'https://soulsync.foo', 'internal:8888', + request_scheme='http', + forwarded_host='soulsync.foo', + forwarded_proto='https') is False + + # X-Forwarded-Proto says https but Origin is http → mismatch → reject + assert will_reject(None, 'http://soulsync.foo', 'internal:8888', + request_scheme='http', + forwarded_host='soulsync.foo', + forwarded_proto='https') is True + + # Comma-separated X-Forwarded-Proto (proxy chain) — first wins, like engineio + assert will_reject(None, 'https://soulsync.foo', 'internal:8888', + request_scheme='http', + forwarded_host='soulsync.foo', + forwarded_proto='https, http') is False + + +def test_will_reject_allows_missing_origin_matching_engineio(): + """Engineio (server.py:207: ``if origin:``) skips CORS validation + entirely when no Origin header is sent — non-browser clients (curl, + server-to-server) are intentionally permitted. Our predictor must + match that or we'd log spurious "rejected" warnings for legitimate + non-browser traffic. Must also not raise on None input.""" + # Wildcard permits missing origin — and so does the default policy + # (matches engineio's actual behavior). + assert will_reject('*', None, 'localhost:8888') is False + assert will_reject('*', '', 'localhost:8888') is False + assert will_reject(None, None, 'localhost:8888') is False + assert will_reject(None, '', 'localhost:8888') is False + assert will_reject(['https://x.com'], None, 'localhost:8888') is False + + +def test_will_reject_honors_forwarded_proto_alone(): + """Engineio adds the forwarded candidate when EITHER X-Forwarded-Proto + OR X-Forwarded-Host is present (it falls back to HTTP_HOST for the + missing one). Our predictor must mirror that — otherwise a misconfig + sending only X-Forwarded-Proto would look like a rejection in our + log even though engineio actually allows it.""" + # forwarded_proto alone: backend host stands in for forwarded_host + assert will_reject(None, 'https://localhost:8888', 'localhost:8888', + request_scheme='http', + forwarded_proto='https') is False + + # forwarded_proto alone but origin's host doesn't match the backend host + assert will_reject(None, 'https://attacker.com', 'localhost:8888', + request_scheme='http', + forwarded_proto='https') is True + + +# ── RejectionLogger ─────────────────────────────────────────────────────── + + +def test_rejection_logger_emits_once_per_unique_origin(): + log = _CapturingLogger() + rl = RejectionLogger(log) + + # Same origin three times — only one warning + for _ in range(3): + rl.maybe_log(None, 'https://attacker.com', 'localhost:8888') + assert len(log.warnings) == 1 + assert 'attacker.com' in log.warnings[0] + + # Different origin — separate warning + rl.maybe_log(None, 'https://other.evil', 'localhost:8888') + assert len(log.warnings) == 2 + assert 'other.evil' in log.warnings[1] + + +def test_rejection_logger_silent_when_request_would_be_allowed(): + log = _CapturingLogger() + rl = RejectionLogger(log) + + # Same-origin — no warning + rl.maybe_log(None, 'http://localhost:8888', 'localhost:8888') + # Wildcard — no warning + rl.maybe_log('*', 'https://x.com', 'localhost:8888') + # In allow-list — no warning + rl.maybe_log(['https://x.com'], 'https://x.com', 'localhost:8888') + # Same-origin via X-Forwarded-Host (with proxy scheme info) — no warning + rl.maybe_log(None, 'https://soulsync.foo', 'internal:8888', + request_scheme='http', + forwarded_host='soulsync.foo', + forwarded_proto='https') + + assert log.warnings == [] + + +def test_rejection_logger_silent_when_no_origin_header(): + """Non-browser clients (curl, server-to-server) don't send Origin — + they should not trigger the warning.""" + log = _CapturingLogger() + rl = RejectionLogger(log) + + rl.maybe_log(None, None, 'localhost:8888') + rl.maybe_log(None, '', 'localhost:8888') + + assert log.warnings == [] + + +def test_rejection_logger_warning_message_points_user_to_settings(): + """The warning is the ONLY signal users get when their reverse proxy + setup is broken. It must name the origin AND tell them where to fix it.""" + log = _CapturingLogger() + rl = RejectionLogger(log) + + rl.maybe_log(None, 'https://soulsync.example.com', 'internal-host:8888') + + assert len(log.warnings) == 1 + msg = log.warnings[0] + assert 'soulsync.example.com' in msg, "warning must include the rejected origin" + assert 'internal-host:8888' in msg, "warning must include the request Host so users can debug proxy config" + assert 'Settings' in msg, "warning must point users to Settings" + assert 'Allowed' in msg, "warning must name the field they need to edit" + + +def test_rejection_logger_dedup_is_threadsafe(): + """Two threads racing on the same novel origin must result in exactly + one warning, not two. Locks the dedup set internally.""" + log = _CapturingLogger() + rl = RejectionLogger(log) + barrier = threading.Barrier(8) + + def hammer(): + barrier.wait() + for _ in range(50): + rl.maybe_log(None, 'https://race.test', 'localhost:8888') + + threads = [threading.Thread(target=hammer) for _ in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert len(log.warnings) == 1 + + +def test_rejection_logger_reset_for_tests_clears_dedup(): + log = _CapturingLogger() + rl = RejectionLogger(log) + + rl.maybe_log(None, 'https://x.com', 'localhost:8888') + assert len(log.warnings) == 1 + + rl.reset_for_tests() + rl.maybe_log(None, 'https://x.com', 'localhost:8888') + assert len(log.warnings) == 2 # logged again after reset + + +def test_rejection_logger_caps_dedup_set_at_configured_limit(): + """A hostile actor opening connections from many distinct fake origins + would otherwise grow the dedup set unbounded. After the cap is hit, + further rejections are silently dropped (after one overflow notice).""" + log = _CapturingLogger() + rl = RejectionLogger(log, dedup_cap=5) + + # Fill the cap + for i in range(5): + rl.maybe_log(None, f'https://fake{i}.com', 'localhost:8888') + assert len(log.warnings) == 5 + + # Next unique origin → overflow notice, NOT a per-origin warning + rl.maybe_log(None, 'https://fake5.com', 'localhost:8888') + assert len(log.warnings) == 6 + assert 'cap' in log.warnings[5].lower() or 'suppress' in log.warnings[5].lower() + + # Further unique origins → silently dropped (overflow notice already emitted) + for i in range(6, 20): + rl.maybe_log(None, f'https://fake{i}.com', 'localhost:8888') + assert len(log.warnings) == 6 # unchanged + + # After reset, cap restarts + rl.reset_for_tests() + rl.maybe_log(None, 'https://fake0.com', 'localhost:8888') + assert len(log.warnings) == 7 + + +def test_rejection_logger_default_cap_is_reasonable(): + """The default cap should be high enough that legitimate-but-unusual + setups (e.g., a power user with a dozen reverse-proxy domains rotating) + don't hit the overflow notice during normal use.""" + assert RejectionLogger.DEFAULT_DEDUP_CAP >= 50, ( + "default dedup cap should fit normal usage" + ) + + +# ── log_startup_status ──────────────────────────────────────────────────── + + +def test_startup_status_warns_on_wildcard(): + """The wildcard is a security risk — startup must log a warning that + points users to the settings page, not just an info line.""" + log = _CapturingLogger() + log_startup_status('*', log) + + assert len(log.warnings) == 1 + assert "'*'" in log.warnings[0] + assert 'Settings' in log.warnings[0] + assert log.infos == [] + + +def test_startup_status_info_logs_nonempty_allowlist(): + """Non-empty allow-list → info, so users can confirm their config + actually took effect.""" + log = _CapturingLogger() + log_startup_status(['https://x.com', 'https://y.com'], log) + + assert log.warnings == [] + assert len(log.infos) == 1 + assert 'https://x.com' in log.infos[0] + + +def test_startup_status_silent_on_default_same_origin(): + """None (default) → no log. Same-origin-only is the default; + nothing noteworthy to announce on every startup.""" + log = _CapturingLogger() + log_startup_status(None, log) + + assert log.warnings == [] + assert log.infos == [] diff --git a/tests/test_tidal_search_shortening.py b/tests/test_tidal_search_shortening.py index 50920fad..334b3610 100644 --- a/tests/test_tidal_search_shortening.py +++ b/tests/test_tidal_search_shortening.py @@ -16,11 +16,15 @@ if 'tidalapi' not in sys.modules: _fake = types.ModuleType('tidalapi') class _FakeQuality: - low_96k = 'low_96k' - low_320k = 'low_320k' - high_lossless = 'high_lossless' - hi_res = 'hi_res' - hi_res_lossless = 'hi_res_lossless' + # Values mirror the real tidalapi Quality enum (the strings the + # Tidal API returns in `audioQuality`). Keeping these honest + # lets sibling tests that actually compare quality values rely + # on the same stub regardless of pytest collection order. + low_96k = 'LOW' + low_320k = 'HIGH' + high_lossless = 'LOSSLESS' + hi_res = 'HI_RES' + hi_res_lossless = 'HI_RES_LOSSLESS' _fake.Quality = _FakeQuality _fake.media = types.SimpleNamespace(Track=object) diff --git a/tests/test_tidal_stream_tier_verification.py b/tests/test_tidal_stream_tier_verification.py new file mode 100644 index 00000000..73898a55 --- /dev/null +++ b/tests/test_tidal_stream_tier_verification.py @@ -0,0 +1,162 @@ +"""Tests for `_verify_stream_tier` — the guard that rejects silent Tidal +quality downgrades so the fallback chain (or "HiRes only" with fallback +disabled) behaves the way users configure it to. + +Without this check, a user with "HiRes only, no quality fallback" who +asks Tidal for a track that's only available in AAC 320kbps would +receive the 320kbps stream silently — Tidal never raises, it just +serves the highest tier available — and the downloader would accept +the m4a file and report success. Reported by Netti93. + +Tiers ranked worst-to-best: + LOW < HIGH < LOSSLESS < HI_RES < HI_RES_LOSSLESS + +Accepting matches and upgrades, rejecting downgrades, rejecting +unrecognized values. + +Note on the fake Quality values: tidalapi's real Quality enum has +VALUES that differ from the member names (e.g., `low_320k.value == +'HIGH'`, `high_lossless.value == 'LOSSLESS'`). The stub mirrors real +values so the tests catch case-sensitivity regressions. +""" + +import sys +import types + + +if 'tidalapi' not in sys.modules: + _fake = types.ModuleType('tidalapi') + + class _FakeQuality: + low_96k = 'LOW' + low_320k = 'HIGH' + high_lossless = 'LOSSLESS' + hi_res = 'HI_RES' + hi_res_lossless = 'HI_RES_LOSSLESS' + + _fake.Quality = _FakeQuality + _fake.media = types.SimpleNamespace(Track=object) + sys.modules['tidalapi'] = _fake + + +from core.tidal_download_client import QUALITY_MAP, _verify_stream_tier # noqa: E402 + + +class _FakeStream: + """Minimal stand-in for tidalapi.media.Stream.""" + + def __init__(self, audio_quality=None): + if audio_quality is not None: + self.audio_quality = audio_quality + + +# --------------------------------------------------------------------------- +# Match — served quality is exactly what was requested +# --------------------------------------------------------------------------- + +def test_served_quality_matches_request(): + stream = _FakeStream(audio_quality='HI_RES_LOSSLESS') + ok, reason = _verify_stream_tier(stream, QUALITY_MAP['hires'], 'hires') + assert ok is True + assert reason is None + + +# --------------------------------------------------------------------------- +# Upgrades — Tidal serving a higher tier than requested is accepted +# --------------------------------------------------------------------------- + +def test_lossless_request_upgraded_to_hires_is_accepted(): + """If Tidal serves HI_RES_LOSSLESS on a LOSSLESS-tier request (rare + but possible on tracks flagged as such in Tidal's catalog), we take + the upgrade — rejecting a better-than-asked tier would be user- + hostile.""" + stream = _FakeStream(audio_quality='HI_RES_LOSSLESS') + ok, reason = _verify_stream_tier(stream, QUALITY_MAP['lossless'], 'lossless') + assert ok is True + assert reason is None + + +def test_lossless_request_upgraded_to_mqa_hires_is_accepted(): + stream = _FakeStream(audio_quality='HI_RES') + ok, reason = _verify_stream_tier(stream, QUALITY_MAP['lossless'], 'lossless') + assert ok is True + assert reason is None + + +def test_low_request_upgraded_to_any_higher_tier_is_accepted(): + stream = _FakeStream(audio_quality='LOSSLESS') + ok, reason = _verify_stream_tier(stream, QUALITY_MAP['low'], 'low') + assert ok is True + assert reason is None + + +# --------------------------------------------------------------------------- +# Downgrades — the reported bug +# --------------------------------------------------------------------------- + +def test_hires_downgraded_to_aac_is_rejected(): + """The exact case Netti93 reported: asked HiRes, Tidal served + AAC 320kbps (`'HIGH'` in Tidal's API vocabulary).""" + stream = _FakeStream(audio_quality='HIGH') + ok, reason = _verify_stream_tier(stream, QUALITY_MAP['hires'], 'hires') + assert ok is False + assert 'HIGH' in reason + assert 'HI_RES_LOSSLESS' in reason + + +def test_hires_lossless_downgraded_to_mqa_hires_is_rejected(): + """User explicitly asked for HI_RES_LOSSLESS (true lossless HiRes). + Getting MQA-encoded HI_RES is a downgrade even though both are + "HiRes tier" marketing-wise — MQA is lossy.""" + stream = _FakeStream(audio_quality='HI_RES') + ok, reason = _verify_stream_tier(stream, QUALITY_MAP['hires'], 'hires') + assert ok is False + assert 'HI_RES_LOSSLESS' in reason + + +def test_lossless_downgraded_to_aac_is_rejected(): + stream = _FakeStream(audio_quality='HIGH') + ok, reason = _verify_stream_tier(stream, QUALITY_MAP['lossless'], 'lossless') + assert ok is False + assert 'LOSSLESS' in reason + + +# --------------------------------------------------------------------------- +# Unknown quality strings — reject conservatively +# --------------------------------------------------------------------------- + +def test_unknown_served_quality_is_rejected(): + """If Tidal introduces a new tier we haven't mapped yet, we can't + prove it's acceptable — reject rather than silently pass through, + so the next fallback tier gets a chance and the final diagnostic + log names the unknown value.""" + stream = _FakeStream(audio_quality='SPATIAL_360_DREAM_TIER') + ok, reason = _verify_stream_tier(stream, QUALITY_MAP['hires'], 'hires') + assert ok is False + assert 'SPATIAL_360_DREAM_TIER' in reason + assert 'unrecognized' in reason.lower() or 'can\'t verify' in reason.lower() + + +# --------------------------------------------------------------------------- +# Defensive — missing attributes must not spuriously fail downloads +# --------------------------------------------------------------------------- + +def test_stream_without_audio_quality_attr_is_accepted(): + """Older tidalapi versions may not expose audio_quality — treat as + "can't verify" and let pre-existing codec / file-size guards decide. + Better to miss a downgrade than break every Tidal download after a + library upgrade.""" + stream = _FakeStream() + assert not hasattr(stream, 'audio_quality') + ok, reason = _verify_stream_tier(stream, QUALITY_MAP['hires'], 'hires') + assert ok is True + assert reason is None + + +def test_quality_info_without_tidal_quality_is_accepted(): + """If QUALITY_MAP somehow lacks 'tidal_quality' (tidalapi failed to + import at module load), don't spuriously reject streams.""" + stream = _FakeStream(audio_quality='HI_RES_LOSSLESS') + ok, reason = _verify_stream_tier(stream, {'label': 'x'}, 'hires') + assert ok is True + assert reason is None diff --git a/tests/test_worker_utils_album_track_count.py b/tests/test_worker_utils_album_track_count.py new file mode 100644 index 00000000..4b988d70 --- /dev/null +++ b/tests/test_worker_utils_album_track_count.py @@ -0,0 +1,116 @@ +"""Tests for `worker_utils.set_album_api_track_count` — the shared helper +enrichment workers call to cache authoritative track counts.""" + +from core.worker_utils import set_album_api_track_count + + +class _RecordingCursor: + """Minimal cursor stand-in that captures execute() calls.""" + + def __init__(self): + self.calls = [] + + def execute(self, query, params=None): + self.calls.append((query, params)) + + +# --------------------------------------------------------------------------- +# Happy-path writes +# --------------------------------------------------------------------------- + +def test_writes_positive_int_count(): + cursor = _RecordingCursor() + set_album_api_track_count(cursor, "album-1", 12) + assert len(cursor.calls) == 1 + query, params = cursor.calls[0] + assert "UPDATE albums SET api_track_count = ?" in query + assert "WHERE id = ?" in query + assert params == (12, "album-1") + + +def test_coerces_numeric_string_to_int(): + """Deezer / raw API dicts often have track counts as strings.""" + cursor = _RecordingCursor() + set_album_api_track_count(cursor, "album-2", "14") + assert cursor.calls[0][1] == (14, "album-2") + + +def test_writes_one_for_single_track_album(): + cursor = _RecordingCursor() + set_album_api_track_count(cursor, "album-single", 1) + assert cursor.calls[0][1] == (1, "album-single") + + +# --------------------------------------------------------------------------- +# Skip-write cases (don't overwrite good values with bad ones) +# --------------------------------------------------------------------------- + +def test_skips_write_when_count_is_zero(): + """A source that doesn't report track counts must not clobber a value + written by another source.""" + cursor = _RecordingCursor() + set_album_api_track_count(cursor, "album-x", 0) + assert cursor.calls == [] + + +def test_skips_write_when_count_is_none(): + cursor = _RecordingCursor() + set_album_api_track_count(cursor, "album-x", None) + assert cursor.calls == [] + + +def test_skips_write_when_count_is_negative(): + cursor = _RecordingCursor() + set_album_api_track_count(cursor, "album-x", -1) + assert cursor.calls == [] + + +def test_skips_write_on_non_numeric_string(): + cursor = _RecordingCursor() + set_album_api_track_count(cursor, "album-x", "not a number") + assert cursor.calls == [] + + +def test_skips_write_on_non_numeric_object(): + cursor = _RecordingCursor() + set_album_api_track_count(cursor, "album-x", object()) + assert cursor.calls == [] + + +# --------------------------------------------------------------------------- +# Does not commit — caller owns the transaction +# --------------------------------------------------------------------------- + +def test_helper_does_not_commit(): + """Workers batch multiple UPDATEs into one transaction. The helper + must not call commit() or it would break that batching.""" + + class _StrictCursor(_RecordingCursor): + commits = 0 + + def commit(self): # pragma: no cover — asserts it's never called + _StrictCursor.commits += 1 + + cursor = _StrictCursor() + set_album_api_track_count(cursor, "album-y", 5) + assert _StrictCursor.commits == 0 + + +# --------------------------------------------------------------------------- +# Error isolation — a cursor.execute failure must not poison the worker's +# other UPDATEs in the same transaction +# --------------------------------------------------------------------------- + +def test_swallows_cursor_execute_errors(): + """If the column doesn't exist yet (e.g., migration hasn't run) or + the DB is otherwise unhappy, the helper must not propagate the error. + Otherwise the worker's other UPDATEs (spotify_album_id, thumb_url, + etc.) batched in the same transaction would roll back.""" + + class _BrokenCursor: + def execute(self, query, params=None): + raise RuntimeError("no such column: api_track_count") + + cursor = _BrokenCursor() + # Should not raise. + set_album_api_track_count(cursor, "album-z", 10) diff --git a/web_server.py b/web_server.py index a6aa1cfb..426a5ab7 100644 --- a/web_server.py +++ b/web_server.py @@ -17,11 +17,12 @@ import re import sqlite3 import types import collections +import functools from pathlib import Path -from urllib.parse import urljoin +from urllib.parse import quote, urljoin, urlparse from concurrent.futures import ThreadPoolExecutor, as_completed -from flask import Flask, render_template, request, jsonify, redirect, send_file, Response, session, g, abort +from flask import Flask, render_template, request, jsonify, redirect, send_file, send_from_directory, Response, session, g, abort from flask_socketio import SocketIO, emit, join_room, leave_room from utils.logging_config import get_logger, setup_logging from utils.async_helpers import run_async @@ -36,8 +37,9 @@ _log_path = config_manager.get('logging.path', 'logs/app.log') _log_dir = Path(_log_path).parent logger = setup_logging(_log_level, _log_path) -# App version — single source of truth for backup metadata, version-info endpoint, etc. -_SOULSYNC_BASE_VERSION = "2.39" +# App version — single source of truth for backup metadata, system-info, update check, etc. +# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release. +_SOULSYNC_BASE_VERSION = "2.4.0" def _build_version_string(): """Append short commit hash to version when available (e.g. 2.35+abc1234).""" @@ -169,6 +171,33 @@ app = Flask( ) app.config['TEMPLATES_AUTO_RELOAD'] = DEV_STATIC_NO_CACHE app.jinja_env.auto_reload = DEV_STATIC_NO_CACHE +# Static assets (library.js / style.css / etc.) get aggressive browser +# caching (1 year). Safe because every static URL is bust-tagged with +# `?v=static_v` (computed once per process start — see below) so each +# server restart effectively invalidates every cached asset for every +# user. Within a single deploy, repeat page loads hit zero round-trips +# on static files — was a 304 round-trip per asset under the old +# max-age=0 setting. +# +# In dev, DEV_STATIC_NO_CACHE flips this back to 0 so iterating on JS +# / CSS doesn't require a server restart between edits. +app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 if DEV_STATIC_NO_CACHE else 31536000 + + +# Cache-bust query string for static assets — appended to every +# url_for('static', ...) URL via the context processor below. Computed +# once per process start so each server restart invalidates the +# browser's cached copy of every JS/CSS file. This is the surefire +# fix for "user has stale JS even after Ctrl+Shift+R" — the URL +# itself changes, so the browser cannot reuse a previously-cached +# response no matter what its Cache-Control header said. +import time as _cache_bust_time +_STATIC_CACHE_BUST = str(int(_cache_bust_time.time())) + + +@app.context_processor +def _inject_static_cache_bust(): + return {'static_v': _STATIC_CACHE_BUST} # --- Flask Session Setup (for multi-profile support) --- import secrets as _secrets @@ -187,12 +216,43 @@ def _init_flask_secret_key(): app.secret_key = _init_flask_secret_key() # --- WebSocket (Socket.IO) Setup --- -socketio = SocketIO(app, async_mode='threading', cors_allowed_origins='*') +from core.socketio_cors import ( + resolve_cors_origins as _resolve_socketio_cors_origins, + RejectionLogger as _SocketIORejectionLogger, + log_startup_status as _log_socketio_startup_status, +) +_socketio_cors_origins = _resolve_socketio_cors_origins(config_manager) +socketio = SocketIO(app, async_mode='threading', cors_allowed_origins=_socketio_cors_origins) +_log_socketio_startup_status(_socketio_cors_origins, logger) +_socketio_rejection_logger = _SocketIORejectionLogger(logger) # Plex PIN auth requests stored in memory for polling _plex_pin_requests = {} _plex_pin_requests_lock = threading.Lock() +@app.before_request +def _log_rejected_socketio_origin(): + """Hook the WS upgrade path so users see a clear log line when their + Origin is about to be rejected (engineio otherwise just silently 403s + the upgrade). Dedup + threading lives in `core/socketio_cors`. + + Note: Flask's ``before_request`` runs on every HTTP request to every + endpoint — there's no path-scoped equivalent for arbitrary URL + prefixes. We early-return on non-/socket.io/ paths to keep the + overhead to one string compare per request. + """ + if not request.path.startswith('/socket.io/'): + return + _socketio_rejection_logger.maybe_log( + _socketio_cors_origins, + request.headers.get('Origin'), + request.headers.get('Host', ''), + request.scheme, + request.headers.get('X-Forwarded-Host', ''), + request.headers.get('X-Forwarded-Proto', ''), + ) + + # --- Profile Context (before_request hook) --- @app.before_request def _set_profile_context(): @@ -251,6 +311,45 @@ def _log_slow_request(response): return response + +@app.after_request +def _add_discover_cache_headers(response): + """Browser-cache discover GETs for 5 minutes. + + The discover surface (hero, similar artists, recent releases, release + radar, deep cuts, etc.) returns semi-stable data that's expensive to + compute and not user-action-driven within a session. A short browser + cache eliminates redundant fetches when the user toggles between + Discover sections or navigates back. + + Scope: only `/api/discover/` and `/api/discovery/` paths, only GET, + only successful 2xx responses. Any endpoint that explicitly sets + its own Cache-Control wins (we don't override). + + Uses `private` not `public` because discover data is user-specific + (hero artists from your watchlist, similar artists from your taste, + etc.). `private` keeps it browser-only — intermediate proxies + (corporate caching proxies, Cloudflare with cache rules, Nginx + proxy_cache) won't store one user's response and serve it to another. + """ + try: + if request.method != 'GET': + return response + if not (request.path.startswith('/api/discover/') + or request.path.startswith('/api/discovery/')): + return response + if not (200 <= response.status_code < 300): + return response + if response.headers.get('Cache-Control'): + return response + response.headers['Cache-Control'] = 'private, max-age=300' + except Exception as exc: + # Don't let a header-tagging bug turn a successful response into + # a 500 — log and ship the response without the cache header. + logger.warning(f"[discover-cache-headers] failed for {request.path}: {exc}") + return response + + def get_current_profile_id() -> int: """Get the current profile ID from Flask g context or default to 1""" try: @@ -258,6 +357,32 @@ def get_current_profile_id() -> int: except AttributeError: return 1 + +def admin_only(view_fn): + """Restrict a Flask view to the admin profile (profile_id == 1). + + Settings-class endpoints expose / mutate service tokens, OAuth + secrets, and API keys. Non-admin profiles must not see them. + + NOTE on the underlying auth model: `get_current_profile_id()` + defaults to 1 (admin) when no session is present, which means + single-admin / no-multi-profile installs have no actual gate here — + any request from the local network is treated as admin. This + decorator's job is to gate non-admin profiles in MULTI-profile + setups, not to authenticate the network. The "trust local network" + posture is the project's existing model; tightening it (real auth + on every request) is out of scope for this decorator. + """ + @functools.wraps(view_fn) + def wrapper(*args, **kwargs): + if get_current_profile_id() != 1: + return jsonify({ + "success": False, + "error": "Admin access required", + }), 403 + return view_fn(*args, **kwargs) + return wrapper + # ── Per-profile Spotify client cache ── _profile_spotify_clients = {} # profile_id -> SpotifyClient _profile_spotify_lock = threading.Lock() @@ -1631,7 +1756,10 @@ def _register_automation_handlers(): hybrid_order = config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek']) soulseek_active = (dl_mode == 'soulseek' or (dl_mode == 'hybrid' and 'soulseek' in hybrid_order)) - if not soulseek_active or not soulseek_client or not soulseek_client.base_url: + # soulseek_client is a DownloadOrchestrator; the real client lives on + # .soulseek. Match the getattr pattern used at the other call sites. + slskd = getattr(soulseek_client, 'soulseek', None) if soulseek_client else None + if not soulseek_active or not slskd or not slskd.base_url: _update_automation_progress(automation_id, log_line='Soulseek not active — skipped', log_type='skip') return {'status': 'skipped'} @@ -2546,7 +2674,8 @@ def _get_file_lock(file_path): # Thread-safe state tracking for modal download functionality with batch management missing_download_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="MissingTrackWorker") # Separate executor for analysis to prevent starvation when download workers are busy -analysis_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="AnalysisWorker") +MAX_CONCURRENT_ANALYSIS = 3 +analysis_executor = ThreadPoolExecutor(max_workers=MAX_CONCURRENT_ANALYSIS, thread_name_prefix="AnalysisWorker") download_tasks = {} # task_id -> task state dict download_batches = {} # batch_id -> {queue, active_count, max_concurrent} tasks_lock = threading.Lock() @@ -4455,6 +4584,12 @@ SERVICE_CONFIG_REGISTRY = { 'acoustid': {'required': ['api_key']}, 'listenbrainz': {'required': ['token']}, 'hydrabase': {'required': ['url', 'api_key']}, + # Soulseek (slskd) needs a base URL. Used by the search source picker + # to dim Soulseek and redirect to Settings when the user has no slskd + # configured — clicking it would otherwise fire searches that always + # fail. URL field lives on Settings → Downloads, gated behind the + # download-source-mode dropdown. + 'soulseek': {'required': ['slskd_url']}, } @@ -5091,6 +5226,25 @@ def run_detection(server_type): def index(): return render_template('index.html') + +@app.route('/sw.js') +def service_worker(): + """Serve sw.js from root scope so the service worker can intercept + fetches site-wide. A service worker only controls URLs at or below + its own served path — `/static/sw.js` would scope to `/static/*` + only. Serving from `/sw.js` (with the file living under static/) + grants full-site scope without needing the Service-Worker-Allowed + header dance. + + Cache-Control: no-cache so deploys that change the SW propagate on + the next page load instead of being pinned by the 1-year static + cache the rest of /static/ uses. + """ + response = send_from_directory(app.static_folder, 'sw.js', mimetype='application/javascript') + response.headers['Cache-Control'] = 'no-cache' + return response + + @app.route('/') def spa_catch_all(page): # Serve index.html for client-side routes; let Flask handle real routes first. @@ -6226,6 +6380,7 @@ def revoke_api_key_internal(key_id): @app.route('/api/settings', methods=['GET', 'POST']) +@admin_only def handle_settings(): global tidal_client # Declare that we might modify the global instance if not config_manager: @@ -6524,6 +6679,7 @@ def hydrabase_send(): return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/settings/log-level', methods=['GET', 'POST']) +@admin_only def handle_log_level(): """Get or set the application log level""" from utils.logging_config import set_log_level, get_current_log_level @@ -7383,6 +7539,7 @@ def test_connection_endpoint(): @app.route('/api/settings/config-status', methods=['GET']) +@admin_only def settings_config_status_endpoint(): """Return per-service config state for the Settings → Connections page. Drives the green/yellow header gradient. No API calls — just config reads. @@ -7456,6 +7613,7 @@ def _run_single_verify(service: str): @app.route('/api/settings/verify', methods=['POST']) +@admin_only def settings_verify_endpoint(): """Run connection verification for one or more services. @@ -11121,7 +11279,7 @@ def maintain_search_history(): return jsonify({"success": False, "error": str(e)}), 500 def fix_artist_image_url(thumb_url): - """Convert localhost URLs to proper server URLs using config""" + """Convert media-server image URLs into browser-safe URLs.""" if not thumb_url: return None @@ -11130,6 +11288,11 @@ def fix_artist_image_url(thumb_url): needs_fixing = ( thumb_url.startswith('http://localhost:') or thumb_url.startswith('https://localhost:') or + thumb_url.startswith('http://127.0.0.1:') or + thumb_url.startswith('https://127.0.0.1:') or + thumb_url.startswith('http://host.docker.internal:') or + thumb_url.startswith('https://host.docker.internal:') or + (thumb_url.startswith('http://') and _is_internal_image_host(thumb_url)) or thumb_url.startswith('/library/') or # Plex relative paths thumb_url.startswith('/Items/') or # Jellyfin relative paths thumb_url.startswith('/api/') or # Old Navidrome API paths @@ -11160,7 +11323,7 @@ def fix_artist_image_url(thumb_url): # Construct proper Plex URL with token fixed_url = f"{plex_base_url.rstrip('/')}{path}?X-Plex-Token={plex_token}" logger.info(f"Fixed URL: {fixed_url}") - return fixed_url + return _browser_safe_image_url(fixed_url) elif active_server == 'jellyfin': jellyfin_config = config_manager.get_jellyfin_config() @@ -11186,7 +11349,7 @@ def fix_artist_image_url(thumb_url): else: fixed_url = f"{jellyfin_base_url.rstrip('/')}{path}" logger.info(f"Fixed URL: {fixed_url}") - return fixed_url + return _browser_safe_image_url(fixed_url) elif active_server == 'navidrome': navidrome_config = config_manager.get_navidrome_config() @@ -11219,16 +11382,57 @@ def fix_artist_image_url(thumb_url): # Construct proper Navidrome Subsonic URL fixed_url = f"{navidrome_base_url.rstrip('/')}{path}{separator}{auth_params}" logger.info(f"Fixed URL: {fixed_url}") - return fixed_url + return _browser_safe_image_url(fixed_url) logger.warning(f"No configuration found for {active_server} or unsupported server type") - # Return original URL if no fixing needed/possible - return thumb_url + # Return a browser-safe URL even if no server-specific rebuild was possible. + return _browser_safe_image_url(thumb_url) except Exception as e: logger.error(f"Error fixing image URL '{thumb_url}': {e}") - return thumb_url + return _browser_safe_image_url(thumb_url) + + +def _is_internal_image_host(url: str) -> bool: + """Return True when an image URL points at a host the browser likely cannot reach directly.""" + try: + parsed = urlparse(url) + host = (parsed.hostname or '').strip('[]').lower() + if not host: + return False + + if host in {'localhost', '127.0.0.1', '::1', 'host.docker.internal'}: + return True + + # Single-label hosts are usually Docker service names or local LAN aliases. + if '.' not in host: + return True + + try: + ip = ipaddress.ip_address(host) + return ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_reserved + except ValueError: + return False + except Exception: + return False + + +def _browser_safe_image_url(url: str) -> str: + """Return a browser-safe image URL, proxying internal hosts through SoulSync.""" + if not url: + return url + + if url.startswith('/api/image-proxy?url='): + return url + + if url.startswith('http://') or url.startswith('https://'): + if _is_internal_image_host(url): + return f"/api/image-proxy?url={quote(url, safe='')}" + return url + + # Relative media-server paths should already have been expanded before this point. + return url @app.route('/api/library/history') def get_library_history(): @@ -11683,10 +11887,15 @@ def get_artist_image(artist_id): source_override = request.args.get('source', '').strip().lower() or None plugin = request.args.get('plugin', '').strip().lower() or None + # `name` is optional but required for sources that don't store + # artist images directly (MusicBrainz) — the resolver falls back + # to searching iTunes/Deezer by name. + artist_name = request.args.get('name', '').strip() or None image_url = _get_artist_image_url( artist_id, source_override=source_override, plugin=plugin, + artist_name=artist_name, ) return jsonify({"success": True, "image_url": image_url}) except Exception as e: @@ -13535,158 +13744,72 @@ def get_tracks_replaygain_batch_status(): return jsonify(state) -# ── Reorganize Album Files endpoint ── +# ── Reorganize Album Files endpoints ── +# +# Reorganize requests flow through ``core.reorganize_queue`` — a FIFO +# queue with a single background worker. The endpoints here are thin +# enqueue / snapshot / cancel wrappers; the heavy lifting is in +# :mod:`core.library_reorganize`. -_reorganize_state = { - 'status': 'idle', - 'total': 0, - 'processed': 0, - 'moved': 0, - 'skipped': 0, - 'failed': 0, - 'current_track': '', - 'errors': [], -} -_reorganize_lock = threading.Lock() + +@app.route('/api/library/reorganize/sources', methods=['GET']) +def reorganize_sources_global(): + """List metadata sources the user has authed on this instance. + Used by the bulk "Reorganize All" modal where per-album ID coverage + varies. No network calls.""" + try: + from core.library_reorganize import authed_sources + return jsonify({"success": True, "sources": authed_sources()}) + except Exception as e: + logger.error(f"Reorganize sources (global) error: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/library/album//reorganize/sources', methods=['GET']) +def reorganize_album_sources(album_id): + """List metadata sources the user can pick for this album's + reorganize — every entry has both a stored album ID on the local + row AND an authenticated client. No network calls.""" + try: + from core.library_reorganize import available_sources_for_album, load_album_and_tracks + album_data, _tracks = load_album_and_tracks(get_database(), album_id) + if album_data is None: + return jsonify({"success": False, "error": "Album not found"}), 404 + return jsonify({"success": True, "sources": available_sources_for_album(album_data)}) + except Exception as e: + logger.error(f"Reorganize sources error: {e}") + return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/album//reorganize/preview', methods=['POST']) def reorganize_album_preview(album_id): - """Preview file reorganization for an album — returns current vs proposed paths without moving anything.""" + """Preview file reorganization for an album — returns current vs + proposed paths without moving anything. Implementation lives in + :mod:`core.library_reorganize` and shares the planning logic with + the apply endpoint, so the preview is guaranteed to match what + apply would actually produce. + + Optional body param ``source``: when provided, only that metadata + source is queried (no fallback chain).""" try: - database = get_database() + from core.library_reorganize import preview_album_reorganize data = request.get_json() or {} - template = data.get('template', '').strip() - if not template: - return jsonify({"success": False, "error": "Template is required"}), 400 - - conn = database._get_connection() - cursor = conn.cursor() - - # Get album + artist info - cursor.execute(""" - SELECT al.*, a.name as artist_name - FROM albums al - JOIN artists a ON al.artist_id = a.id - WHERE al.id = ? - """, (str(album_id),)) - album_row = cursor.fetchone() - if not album_row: - return jsonify({"success": False, "error": "Album not found"}), 404 - album_data = dict(album_row) - - # Get all tracks for this album - cursor.execute(""" - SELECT t.*, a.name as artist_name - FROM tracks t - JOIN artists a ON t.artist_id = a.id - WHERE t.album_id = ? - ORDER BY t.track_number - """, (str(album_id),)) - tracks = [dict(r) for r in cursor.fetchall()] - - if not tracks: - return jsonify({"success": False, "error": "No tracks found for this album"}), 404 - + chosen_source = data.get('source') or None transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer')) - - # Pre-scan disc numbers so every track's template context carries the - # same total_discs. Needed by $cdnum (smart CD label) so the template - # can decide whether to emit "CDxx" or stay empty for single-disc. - track_disc_numbers = {} - for _t in tracks: - _rp = _resolve_library_file_path(_t.get('file_path')) if _t.get('file_path') else None - _dn = 1 - if _rp: - try: - from core.tag_writer import read_file_tags - _dn = read_file_tags(_rp).get('disc_number') or 1 - except Exception: - _dn = 1 - track_disc_numbers[_t.get('id')] = int(_dn) - total_discs = max(track_disc_numbers.values(), default=1) if track_disc_numbers else 1 - - preview_items = [] - for track in tracks: - file_path = track.get('file_path') - resolved = _resolve_library_file_path(file_path) if file_path else None - - # Reuse the disc number captured in the pre-scan (avoids re-reading tags) - disc_number = track_disc_numbers.get(track.get('id'), 1) - - # Get file extension from current path - file_ext = os.path.splitext(resolved or file_path or '.mp3')[1] - - # Detect quality using the same format as the download pipeline - quality = _get_audio_quality_string(resolved) if resolved else '' - - # Build context for template - year_val = album_data.get('year') or '' - context = { - 'artist': track.get('artist_name') or 'Unknown Artist', - 'albumartist': album_data.get('artist_name') or track.get('artist_name') or 'Unknown Artist', - 'album': album_data.get('title') or 'Unknown Album', - 'title': track.get('title') or 'Unknown Track', - 'track_number': track.get('track_number') or 1, - 'disc_number': disc_number, - 'total_discs': total_discs, - 'year': year_val, - 'quality': quality, - 'albumtype': _get_album_type_display( - album_data.get('record_type'), - album_data.get('track_count') or len(tracks) - ), - } - - # Build new path using the template - folder_path, filename = _get_file_path_from_template_raw(template, context) - new_relative = os.path.join(folder_path, f"{filename}{file_ext}") if folder_path else f"{filename}{file_ext}" - new_full = os.path.join(transfer_dir, new_relative) - - # Current path relative to transfer dir for display - current_display = file_path or 'No file' - if resolved and transfer_dir and resolved.startswith(transfer_dir): - current_display = resolved[len(transfer_dir):].lstrip(os.sep).lstrip('/') - - same = resolved and os.path.normpath(resolved) == os.path.normpath(new_full) - - preview_items.append({ - 'track_id': track['id'], - 'title': track.get('title', ''), - 'track_number': track.get('track_number', 0), - 'current_path': current_display, - 'new_path': new_relative, - 'new_full_normalized': os.path.normpath(new_full) if resolved else None, - 'file_exists': resolved is not None, - 'unchanged': same, - 'collision': False, - }) - - # Detect collisions: multiple tracks mapping to the same destination - seen_paths = {} - for item in preview_items: - norm = item.get('new_full_normalized') - if not norm or not item['file_exists'] or item['unchanged']: - continue - if norm in seen_paths: - item['collision'] = True - # Also mark the first one that claimed this path - seen_paths[norm]['collision'] = True - else: - seen_paths[norm] = item - - # Remove internal field from response - for item in preview_items: - item.pop('new_full_normalized', None) - - return jsonify({ - "success": True, - "album": album_data.get('title', ''), - "artist": album_data.get('artist_name', ''), - "tracks": preview_items, - "transfer_dir": transfer_dir, - }) - + result = preview_album_reorganize( + album_id=album_id, + db=get_database(), + transfer_dir=transfer_dir, + resolve_file_path_fn=_resolve_library_file_path, + build_final_path_fn=_build_final_path_for_track, + primary_source=chosen_source, + strict_source=bool(chosen_source), + ) + if result.get('status') == 'no_album': + return jsonify({"success": False, "error": "Album not found"}), 404 + if result.get('status') == 'no_tracks': + return jsonify({"success": False, "error": "No tracks found for this album"}), 404 + return jsonify(result) except Exception as e: logger.error(f"Reorganize preview error: {e}") return jsonify({"success": False, "error": str(e)}), 500 @@ -13694,289 +13817,143 @@ def reorganize_album_preview(album_id): @app.route('/api/library/album//reorganize', methods=['POST']) def reorganize_album_files(album_id): - """Move album files to new paths based on the provided template.""" + """Enqueue an album for reorganize. Returns immediately — the + queue worker processes items FIFO. Repeat clicks for an album + that's already queued or running are deduped (returns + ``{queued: false, reason: 'already_queued'}``). + + Body params: + source (optional): per-album source pick (Spotify / iTunes / + Deezer / Discogs / Hydrabase). When omitted, the + orchestrator uses the configured primary with fallback. + """ try: + from core.reorganize_queue import get_queue data = request.get_json() or {} - template = data.get('template', '').strip() - if not template: - return jsonify({"success": False, "error": "Template is required"}), 400 + chosen_source = data.get('source') or None - # Atomic check-and-set to prevent concurrent reorganizations - with _reorganize_lock: - if _reorganize_state['status'] == 'running': - return jsonify({"success": False, "error": "A reorganization is already in progress"}), 409 - _reorganize_state['status'] = 'running' - - database = get_database() - conn = database._get_connection() - cursor = conn.cursor() - - # Get album + artist info - cursor.execute(""" - SELECT al.*, a.name as artist_name - FROM albums al - JOIN artists a ON al.artist_id = a.id - WHERE al.id = ? - """, (str(album_id),)) - album_row = cursor.fetchone() - if not album_row: - with _reorganize_lock: - _reorganize_state['status'] = 'idle' + # Capture display fields at enqueue time so the status panel + # can render them without a DB lookup later. + meta = get_database().get_album_display_meta(album_id) + if meta is None: return jsonify({"success": False, "error": "Album not found"}), 404 - album_data = dict(album_row) - - # Get all tracks - cursor.execute(""" - SELECT t.*, a.name as artist_name - FROM tracks t - JOIN artists a ON t.artist_id = a.id - WHERE t.album_id = ? - ORDER BY t.track_number - """, (str(album_id),)) - tracks = [dict(r) for r in cursor.fetchall()] - - if not tracks: - with _reorganize_lock: - _reorganize_state['status'] = 'idle' - return jsonify({"success": False, "error": "No tracks found"}), 404 - - transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer')) - - # Initialize state (already set to 'running' above) - with _reorganize_lock: - _reorganize_state.update({ - 'total': len(tracks), - 'processed': 0, - 'moved': 0, - 'skipped': 0, - 'failed': 0, - 'current_track': '', - 'errors': [], - }) - - def _run_reorganize(): - bg_conn = None - try: - # Single DB connection for the background thread - bg_db = get_database() - bg_conn = bg_db._get_connection() - - # Pre-scan disc numbers for every track so total_discs is the - # same for all template contexts in this album. Needed by the - # $cdnum template variable to decide multi-disc vs single-disc. - track_disc_numbers = {} - for _t in tracks: - _rp = _resolve_library_file_path(_t.get('file_path')) if _t.get('file_path') else None - _dn = 1 - if _rp: - try: - from core.tag_writer import read_file_tags - _dn = read_file_tags(_rp).get('disc_number') or 1 - except Exception: - _dn = 1 - track_disc_numbers[_t.get('id')] = int(_dn) - total_discs = max(track_disc_numbers.values(), default=1) if track_disc_numbers else 1 - - # Pre-compute all destination paths to detect collisions - dest_paths = {} # normalized_new_path -> track_id - for track in tracks: - file_path = track.get('file_path') - resolved = _resolve_library_file_path(file_path) if file_path else None - if not resolved: - continue - - # Reuse the disc number from the pre-scan pass above - disc_number = track_disc_numbers.get(track.get('id'), 1) - - file_ext = os.path.splitext(resolved)[1] - quality = _get_audio_quality_string(resolved) - - year_val = album_data.get('year') or '' - context = { - 'artist': track.get('artist_name') or 'Unknown Artist', - 'albumartist': album_data.get('artist_name') or track.get('artist_name') or 'Unknown Artist', - 'album': album_data.get('title') or 'Unknown Album', - 'title': track.get('title') or 'Unknown Track', - 'track_number': track.get('track_number') or 1, - 'disc_number': disc_number, - 'total_discs': total_discs, - 'year': year_val, - 'quality': quality, - 'albumtype': _get_album_type_display( - album_data.get('record_type'), - album_data.get('track_count') or len(tracks) - ), - } - - folder_path, filename = _get_file_path_from_template_raw(template, context) - new_relative = os.path.join(folder_path, f"{filename}{file_ext}") if folder_path else f"{filename}{file_ext}" - new_full = os.path.join(transfer_dir, new_relative) - norm_new = os.path.normpath(new_full) - - # Check for collision: two tracks mapping to same destination - if norm_new in dest_paths and dest_paths[norm_new] != str(track['id']): - # Mark as collision so the move pass skips it - track['_collision'] = True - with _reorganize_lock: - _reorganize_state['failed'] += 1 - _reorganize_state['processed'] += 1 - _reorganize_state['errors'].append({ - 'track_id': track['id'], - 'title': track.get('title', 'Unknown'), - 'error': "Path collision with another track — add $track or $disc to template" - }) - continue - - dest_paths[norm_new] = str(track['id']) - - # Store computed info on the track dict for the move pass - track['_resolved'] = resolved - track['_new_full'] = new_full - track['_disc_number'] = disc_number - - # Now do the actual moves - moved_dirs = {} # src_dir → dest_dir for post-pass sidecar sweep - for track in tracks: - resolved = track.get('_resolved') - new_full = track.get('_new_full') - track_title = track.get('title', 'Unknown') - - with _reorganize_lock: - _reorganize_state['current_track'] = track_title - - # Skip tracks already handled (collision or file not found) - if track.get('_collision'): - continue - - if not resolved or not new_full: - # File not found — only count if not already handled in pre-computation - if '_resolved' not in track: - with _reorganize_lock: - _reorganize_state['skipped'] += 1 - _reorganize_state['processed'] += 1 - _reorganize_state['errors'].append({ - 'track_id': track['id'], - 'title': track_title, - 'error': 'File not found on disk' - }) - continue - - # Skip if already at target - if os.path.normpath(resolved) == os.path.normpath(new_full): - with _reorganize_lock: - _reorganize_state['skipped'] += 1 - _reorganize_state['processed'] += 1 - continue - - try: - # Move file - _safe_move_file(resolved, new_full) - - # Track source→dest directory mapping for post-pass sidecar sweep - src_dir = os.path.dirname(resolved) - dest_dir = os.path.dirname(new_full) - if src_dir not in moved_dirs: - moved_dirs[src_dir] = dest_dir - - # Move track-level sidecars (same filename stem as audio) - src_stem = os.path.splitext(os.path.basename(resolved))[0] - new_stem = os.path.splitext(os.path.basename(new_full))[0] - for sidecar_ext in ('.lrc', '.nfo', '.txt', '.cue'): - sidecar_src = os.path.join(src_dir, src_stem + sidecar_ext) - if os.path.isfile(sidecar_src): - sidecar_dst = os.path.join(dest_dir, new_stem + sidecar_ext) - try: - shutil.move(sidecar_src, sidecar_dst) - except Exception: - pass - - # Update DB file_path - bg_cursor = bg_conn.cursor() - bg_cursor.execute( - "UPDATE tracks SET file_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", - (new_full, str(track['id'])) - ) - bg_conn.commit() - - with _reorganize_lock: - _reorganize_state['moved'] += 1 - _reorganize_state['processed'] += 1 - - except Exception as move_err: - logger.error(f"Reorganize move error for {track_title}: {move_err}") - with _reorganize_lock: - _reorganize_state['failed'] += 1 - _reorganize_state['processed'] += 1 - _reorganize_state['errors'].append({ - 'track_id': track['id'], - 'title': track_title, - 'error': str(move_err) - }) - - # Post-pass: sweep source directories for leftover album-level sidecars. - # The per-track loop can't reliably move cover.jpg because multiple tracks - # share the same source dir — the first track's move may fail silently, - # or the file may be in a parent directory. This single pass catches them all. - _album_sidecars = ('cover.jpg', 'cover.jpeg', 'cover.png', 'folder.jpg', - 'folder.png', 'front.jpg', 'front.png', 'album.jpg', 'album.png') - for src_dir, dest_dir in moved_dirs.items(): - if not os.path.isdir(src_dir): - continue - # Check if any audio files remain (don't steal sidecars from a dir that still has tracks) - audio_exts = {'.flac', '.mp3', '.m4a', '.ogg', '.opus', '.wav', '.aac', '.wma'} - has_audio = any(os.path.splitext(f)[1].lower() in audio_exts - for f in os.listdir(src_dir) if os.path.isfile(os.path.join(src_dir, f))) - if has_audio: - continue - for sidecar in _album_sidecars: - sidecar_src = os.path.join(src_dir, sidecar) - if os.path.isfile(sidecar_src): - sidecar_dst = os.path.join(dest_dir, sidecar) - if not os.path.exists(sidecar_dst): - try: - shutil.move(sidecar_src, sidecar_dst) - logger.info(f"[Reorganize] Moved {sidecar} to {dest_dir}") - except Exception as sc_err: - logger.error(f"[Reorganize] Failed to move {sidecar}: {sc_err}") - - # Clean up empty directories left behind (after sidecars moved) - for src_dir in moved_dirs: - try: - _cleanup_empty_directories(transfer_dir, os.path.join(src_dir, '_')) - except Exception: - pass - - except Exception as e: - logger.error(f"Reorganize background error: {e}") - finally: - if bg_conn: - try: - bg_conn.close() - except Exception: - pass - with _reorganize_lock: - _reorganize_state['status'] = 'done' - _reorganize_state['current_track'] = '' - - thread = threading.Thread(target=_run_reorganize, daemon=True, name="ReorganizeAlbum") - thread.start() - - return jsonify({"success": True, "message": "Reorganization started", "total": len(tracks)}) + result = get_queue().enqueue( + album_id=str(album_id), + album_title=meta['album_title'], + artist_id=meta['artist_id'], + artist_name=meta['artist_name'], + source=chosen_source, + ) + return jsonify({"success": True, **result}) except Exception as e: - logger.error(f"Reorganize error: {e}") - with _reorganize_lock: - _reorganize_state['status'] = 'idle' + logger.error(f"Reorganize enqueue error: {e}", exc_info=True) return jsonify({"success": False, "error": str(e)}), 500 -@app.route('/api/library/album/reorganize/status', methods=['GET']) -def get_reorganize_status(): - """Poll the status of a running reorganization.""" - with _reorganize_lock: - state = dict(_reorganize_state) - state['errors'] = list(_reorganize_state['errors']) - return jsonify(state) +@app.route('/api/library/artist//reorganize-all', methods=['POST']) +def reorganize_all_artist_albums(artist_id): + """Enqueue every album for an artist. Replaces the old frontend + bulk-loop. Each album becomes its own queue item, processed FIFO. + Albums already queued or running are deduped silently. + + Body params: + source (optional): same pick applied to every album. Per-album + overrides aren't supported here — use the per-album modal + for that. + """ + try: + from core.reorganize_queue import get_queue + data = request.get_json() or {} + chosen_source = data.get('source') or None + + albums = get_database().get_artist_albums_for_reorganize(artist_id) + if not albums: + return jsonify({"success": False, "error": "No albums found for this artist"}), 404 + + # Apply the user's chosen source to every album, then hand off + # to the queue's bulk-enqueue helper which owns the loop+tally. + for album in albums: + album['source'] = chosen_source + result = get_queue().enqueue_many(albums) + + return jsonify({ + "success": True, + "enqueued": result['enqueued'], + "already_queued": result['already_queued'], + "total_albums": result['total'], + }) + except Exception as e: + logger.error(f"Reorganize-all enqueue error: {e}", exc_info=True) + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/library/reorganize/queue', methods=['GET']) +def reorganize_queue_snapshot(): + """Snapshot of the reorganize queue — what's running, what's queued, + recent completions. Polled by the status panel.""" + try: + from core.reorganize_queue import get_queue + return jsonify({"success": True, **get_queue().snapshot()}) + except Exception as e: + logger.error(f"Reorganize queue snapshot error: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/library/reorganize/queue//cancel', methods=['POST']) +def reorganize_queue_cancel(queue_id): + """Cancel a queued item (running items can't be cleanly cancelled — + see the queue module's design rules).""" + try: + from core.reorganize_queue import get_queue + result = get_queue().cancel(queue_id) + status_code = 200 if result.get('cancelled') else 409 + return jsonify({"success": result.get('cancelled', False), **result}), status_code + except Exception as e: + logger.error(f"Reorganize cancel error: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/library/reorganize/queue/clear', methods=['POST']) +def reorganize_queue_clear(): + """Cancel all queued items at once (the running item continues).""" + try: + from core.reorganize_queue import get_queue + cancelled = get_queue().clear_queued() + return jsonify({"success": True, "cancelled": cancelled}) + except Exception as e: + logger.error(f"Reorganize clear error: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +# Wire the reorganize queue worker to its runner at module load. The +# runner factory lives in :mod:`core.reorganize_runner` so this monolith +# stays small. Config (paths) is read **per run** inside the closure, +# so changing your download path in Settings takes effect on the next +# reorganize without a server restart. +# +# The injected callables are wrapped in lambdas because the underlying +# helpers (``_resolve_library_file_path`` etc.) are defined LATER in +# this file. Lambdas defer name resolution to call time so module-load +# import order works regardless of definition order. +try: + from core.reorganize_queue import get_queue as _get_reorganize_queue + from core.reorganize_runner import build_runner as _build_reorganize_runner + _get_reorganize_queue().set_runner(_build_reorganize_runner( + get_database=get_database, + resolve_file_path_fn=lambda p: _resolve_library_file_path(p), + post_process_fn=lambda *a, **kw: _post_process_matched_download(*a, **kw), + cleanup_empty_directories_fn=lambda *a, **kw: _cleanup_empty_directories(*a, **kw), + is_shutting_down_fn=lambda: bool(IS_SHUTTING_DOWN), + get_download_path=lambda: docker_resolve_path( + config_manager.get('soulseek.download_path', './downloads') + ), + get_transfer_path=lambda: docker_resolve_path( + config_manager.get('soulseek.transfer_path', './Transfer') + ), + )) +except Exception as _runner_init_err: + logger.error(f"Failed to register reorganize queue runner: {_runner_init_err}") # ── Library Issues endpoints ── @@ -16189,7 +16166,9 @@ def stream_audio(): response = send_file(file_path, as_attachment=False, mimetype=mimetype) response.headers.add('Accept-Ranges', 'bytes') response.headers.add('Content-Length', str(file_size)) - response.headers.add('Cache-Control', 'no-cache') + # Override the default static-cache max-age — streaming media + # bypasses caching (range requests, mid-track seeks). + response.headers['Cache-Control'] = 'no-cache' return response except Exception as e: @@ -22622,7 +22601,11 @@ def _check_and_remove_from_wishlist(context): # Try to extract Spotify track ID from various sources in the context spotify_track_id = None - + # Populated lazily by Method 3 or Method 4. Initialized here so Method 4's + # `if not wishlist_tracks` guard doesn't UnboundLocalError when Methods 1/2 + # found nothing and Method 3 never ran (no wishlist_id in track_info). + wishlist_tracks = [] + # Method 1: Direct track_info with id track_info = context.get('track_info', {}) if track_info.get('id'): @@ -22915,1488 +22898,6 @@ def check_for_update(): 'is_docker': os.path.exists('/.dockerenv'), }) -@app.route('/api/version-info', methods=['GET']) -def get_version_info(): - """ - Returns version information and release notes, matching the GUI's VersionInfoModal content. - This provides the same data that the GUI version modal displays. - """ - version_data = { - "version": SOULSYNC_VERSION, - "title": "What's New in SoulSync", - "subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes", - "sections": [ - { - "title": "Fix Wrong-Artist Tracks Silently Downloading", - "description": "A critical bug where searching for a track could silently download a completely different artist's song with the same name", - "features": [ - "• Example: searching 'Maduk — Leave A Light On' on Tidal was downloading Tom Walker's unrelated song of the same name, then embedding Maduk's metadata into Tom Walker's audio", - "• Root cause 1: candidate artist gate used `< 0.4` similarity but Maduk/Tom Walker scored exactly 0.400, slipping past the fencepost — raised to `< 0.5`", - "• Root cause 2: AcoustID verification returned SKIP (accept) instead of FAIL (quarantine) when title matched but artist was clearly different — now FAILs when artist similarity is below 0.3", - "• Preserves SKIP for the ambiguous 0.3–0.6 range (covers, collabs, formatting differences) so legitimate tracks aren't falsely quarantined", - "• Both pre-download candidate validation AND post-download verification are now fixed — defense in depth", - ], - }, - { - "title": "Tidal Search Falls Back on Long Queries", - "description": "Tidal's search chokes on long remix-credit queries — now retries with progressively-shortened variants when the original returns 0 results", - "features": [ - "• Example: 'maduk transformations remixed fire away fred v remix' returned 0; now falls back to shorter queries until Tidal finds the track", - "• Up to 4 shortened variants tried, capped total 5 requests, 100ms between attempts", - "• Qualifier-safe: Live/Remix/Acoustic/Extended searches only accept fallback results that still contain the qualifier — studio version never replaces a '(Live)' request", - "• Returns empty if no variant preserves qualifiers — same outcome as before", - ], - }, - { - "title": "Manual Discovery Fixes Persist Across Restart", - "description": "When you manually fix a discovery match, the fix is now saved under your active metadata source instead of always 'spotify' — so Deezer/iTunes/Discogs/Hydrabase users' fixes actually survive restart and re-scan", - "features": [ - "• Affected Tidal, Deezer, Spotify Public, YouTube, and Discovery Pool manual fixes", - "• Symmetric with how the auto-discovery worker saves — no more mismatch", - "• Existing Spotify-primary users unaffected (the hardcoded value matched their source)", - ], - }, - { - "title": "Watchlist Content Filters Fixed", - "description": "Global Override settings and live-version detection now behave the way the UI implies", - "features": [ - "• Scheduled auto-watchlist now honors Watchlist → Global Override (was bypassing it and using per-artist defaults)", - "• 'Live' detection tightened — no more false positives on titles like 'What We Live For' or 'Live Forever'", - "• Same fix applies to the Library Maintenance Live/Commentary Cleaner", - "• Still catches (Live), - Live, Live at/from/in/on/version/session/recording, Unplugged, In Concert", - ], - }, - { - "title": "Discography Backfill", - "description": "New maintenance job that fills gaps in your library — scans each artist's full discography and finds what you're missing", - "features": [ - "• Scans each artist in your library against metadata source discographies", - "• Creates findings for missing tracks — review and click 'Add to Wishlist' to queue downloads", - "• Respects all content filters (live, remix, acoustic, compilation, instrumental)", - "• Release type filters (album, EP, single) with configurable defaults", - "• Optional 'auto-add to wishlist' setting — create findings AND push to wishlist in one pass", - "• 3-option fix prompt (Add to Wishlist / Just Clear / Cancel) for manual review", - "• Batched in-memory library matching — same fast path the Library pages use", - "• Opt-in, disabled by default — runs weekly, processes up to 50 artists per run", - "• Rate-limited to avoid hammering metadata APIs", - ], - }, - { - "title": "Repair 'Run Now' Honored While Paused", - "description": "Force-running a repair job no longer stalls forever when the master repair worker is paused", - "features": [ - "• Jobs queued via 'Run Now' run to completion even if the master worker is paused", - "• Fixes silent stalls where Discography Backfill logged 'scanning 50 artists' then did nothing", - "• Master-pause still blocks scheduled runs — this only affects explicit user-triggered runs", - ], - }, - { - "title": "Multi-Artist Tagging", - "description": "Enhanced control over how multiple artists are written to audio file tags", - "features": [ - "• Configurable artist separator: comma, semicolon, or slash", - "• Multi-value ARTISTS tag for Navidrome/Jellyfin multi-artist linking", - "• 'Move featured artists to title' mode — primary artist in ARTIST tag, others as (feat. ...) in title", - "• All opt-in with defaults matching current behavior", - ], - }, - { - "title": "Enriched Downloads Page", - "description": "Download cards now show rich metadata instead of just filenames", - "features": [ - "• Album artwork thumbnail on each download card", - "• Artist name, album name, and source badge", - "• Quality badge appears after post-processing", - "• Falls back gracefully for transfers without metadata context", - ], - }, - { - "title": "Template Variable Delimiters", - "description": "Use ${var} syntax to append literal text to template variables", - "features": [ - "• ${albumtype}s produces 'Albums', 'Singles', 'EPs'", - "• Both $var and ${var} syntaxes work in all templates", - "• Validation updated to accept delimited variables", - ], - }, - { - "title": "Reorganize All Albums", - "description": "Bulk reorganize all albums for an artist from the enhanced library view", - "features": [ - "• New 'Reorganize All' button in the artist header", - "• Processes albums sequentially with progress toasts", - "• Continues on error — one failed album doesn't block the rest", - "• Uses the same template and endpoint as per-album reorganize", - ], - }, - { - "title": "SoulSync Standalone Library", - "description": "Use SoulSync without Plex, Jellyfin, or Navidrome — manage your library directly", - "features": [ - "• New 'Standalone' server option in Settings → Connections", - "• Downloads and imports write artist/album/track to the library database immediately", - "• Pre-populated enrichment IDs (Spotify, Deezer, MusicBrainz) — workers skip re-discovery", - "• Deep scan finds untracked files in Transfer → moves to Staging for processing", - "• Deep scan removes stale DB records when files are deleted from disk", - "• Sync page and sync buttons hidden automatically in standalone mode", - "• Full library page, artist detail, discography, and enhanced view all work standalone", - ], - "usage_note": "Go to Settings → Connections and click the 'Standalone' button. No media server needed." - }, - { - "title": "Auto-Import", - "description": "Background import folder watcher that automatically identifies and imports music into your library", - "features": [ - "• Recursive scan — any folder depth (Artist/Album/tracks, Album/tracks, loose files)", - "• Single file support — loose audio files identified via tags, filename, or AcoustID", - "• Tag-based identification preferred over weak metadata matches (85% confidence for tagged files)", - "• AcoustID fingerprinting fallback for untagged or ambiguous files", - "• Stats bar, filter pills (All/Review/Imported/Failed), Scan Now, Approve All, Clear History", - "• Expandable track match details with per-track confidence scores", - "• Race condition fix prevents duplicate processing during multi-track albums", - ], - "usage_note": "Enable on the Import page Auto tab. Set your import folder in Settings." - }, - { - "title": "Wishlist Nebula", - "description": "Wishlist redesigned as an interactive artist orb visualization", - "features": [ - "• Each artist is a glowing orb with their photo — album fans and single moons orbit around them", - "• Click orbs to expand and see albums/singles, download directly from the nebula", - "• Processing state shows live progress with spinning ring animation", - "• Stats strip at top shows total artists, albums, singles, and tracks", - ], - "usage_note": "Click Wishlist in the sidebar to see the Nebula view." - }, - { - "title": "Automation Group Management", - "description": "Organize and manage your automation groups with full control", - "features": [ - "• Rename, delete, and bulk-toggle automation groups from group headers", - "• Drag-and-drop automations between groups to reorganize", - "• Delete confirmation dialog with group name and automation count", - ], - "usage_note": "Right-click or use the action buttons on group headers in the Automations page." - }, - { - "title": "Bidirectional Artist Sync & Server Playlists", - "description": "Artist sync now goes both ways, and server playlists show full coverage", - "features": [ - "• Artist Sync pulls new content from your media server AND removes stale library entries", - "• Deep scan mode fetches full metadata for newly discovered tracks", - "• Server playlist view shows all playlists with clear synced vs unsynced visual separation", - ], - }, - { - "title": "Provider-Agnostic Discovery", - "description": "Discovery features work with any configured metadata source instead of requiring Spotify", - "features": [ - "• Similar artist matching, discovery pool, and incremental updates use source priority", - "• Falls back through Spotify, iTunes, and Deezer in configured order", - "• MusicMap URL encoding fixed for artists with special characters", - "• Freshness check simplified to age-based — backfill handles missing IDs separately", - ], - }, - { - "title": "Dashboard & Navigation", - "description": "Dashboard improvements and sidebar navigation enhancements", - "features": [ - "• Library Status card on Dashboard — shows server state, track counts, scan buttons", - "• Tools page in sidebar — all maintenance tools moved from Dashboard modal", - "• Watchlist and Wishlist promoted to full sidebar pages with live count badges", - "• AcoustID scanner scans full library with actionable fix options (retag, redownload, delete)", - ], - }, - { - "title": "MusicBrainz & Metadata Fixes", - "description": "Critical tag embedding fix and Picard-style album consistency", - "features": [ - "• Fix: source ID tags (Spotify, MusicBrainz, Deezer, AudioDB) were silently skipped on every download — now embed correctly", - "• Picard-style release preference scoring prevents Navidrome album splits", - "• Source tags wiped when metadata enhancement is skipped or fails", - "• Spotify API no longer called when Deezer/iTunes is the configured primary source", - ], - }, - { - "title": "Downloads & Soulseek Improvements", - "description": "Better download management, search accuracy, and queue control", - "features": [ - "• Downloads batch panel — color-coded batch cards with progress, cancel, expand, and 7-day history", - "• Soulseek search queries now include album name — reduces wrong-artist downloads", - "• Reject Soulseek results from Various Artists/VA/Unknown Artist folders", - "• Clearing wishlist now cancels the active wishlist download batch", - "• Album delete with 'Delete Files Too' option on enhanced library page", - "• Fix download modal freezing mid-download — M3U auto-save was exhausting server threads", - "• Fix Unknown Artist when adding playlist tracks to wishlist", - "• Fix slskd timeout spam when Soulseek is not the active download source", - ], - }, - { - "title": "Recent Fixes", - "description": "Bug fixes from recent releases and community reports", - "features": [ - "• Fix watchlist scan false failures — empty discography no longer reported as error", - "• Fix deezer_artist_id column error on enhanced library sync", - "• Fix wishlist button intermittently not navigating to page", - "• Fix worker orb tooltips rendering behind dashboard content", - "• Fix OAuth callback port hardcoding — custom ports now respected", - "• Fix allow duplicates setting not saving", - "• Fix wishlist dropping cross-album tracks when duplicates enabled", - "• Fix replace lower quality setting not persisting", - "• Fix Spotify enrichment worker infinite loop on pre-matched artists", - "• Reject Qobuz 30-second sample/preview downloads", - "• Fix library page crash on All filter — non-string soul_id broke card rendering", - "• Auto Wing It fallback for failed discovery — unmatched tracks download via Soulseek with raw metadata", - "• Lidarr download source now production-ready — full orchestrator integration", - "• Fix album track lookup hardcoded to Spotify — now uses configured primary source", - "• Fix M3U showing all tracks as missing — regenerate with real paths after post-processing", - "• Fix AcoustID retag not writing corrected tags to audio file", - "• Fix wishlist albums cycle stuck at 1 concurrent worker instead of configured value", - "• Fix downloads badge dropping to 300 after opening Downloads page", - "• Fix server playlist Find & Add inserting at wrong position on Plex", - "• Smarter Fix modal results — standard album versions sorted above live/remix/cover/soundtrack variants", - "• Unmatch discovery tracks — red ✕ button to remove bad matches from playlist discovery", - "• Customizable music video naming — path template with $artist, $title, $year variables", - "• Fix soulseek log spam when not configured as download source", - ], - }, - { - "title": "Earlier in v2.3", - "description": "Major features from earlier in this release cycle", - "features": [ - "• Centralized Downloads page with live-updating list and filter pills", - "• First-Run Setup Wizard — 7-step guided configuration", - "• Music Videos — search and download from YouTube", - "• Inbound Music Request API for external tools (Discord bots, Home Assistant)", - "• Lidarr download source (development) — 7th source for Usenet/torrent via Lidarr", - "• Graceful shutdown — all workers respond to shutdown signals immediately", - "• Unknown Artist prevention with 3-tier metadata fallback", - "• Deezer multi-artist tagging using contributors field", - "• Artist Map — Watchlist Constellation, Genre Map, and Artist Explorer canvas modes", - "• Discogs integration — enrichment worker, fallback source, enhanced search tab", - "• Wing It mode, Global Search Bar, Redesigned Notifications", - "• Server Playlist Manager, Sync History Dashboard, Playlist Explorer", - "• Enhanced Library Manager with inline tag editing and write-to-file", - "• Automation Signals, Multi-Source Search Tabs, Rich Artist Profiles", - ], - }, - ] - } - return jsonify(version_data) - -_OLD_V22_NOTES = """ - { - "title": "Wing It — Download or Sync Without Discovery", - "description": "Bypass metadata discovery and use raw track names directly", - "features": [ - "• Wing It button on all discovery modals and ListenBrainz Discover page cards", - "• Choose Download or Sync from a compact dropdown — no extra dialogs", - "• Download: sends raw artist/title to the download engine with full post-processing", - "• Sync: creates playlist on media server by matching raw names against library", - "• Failed tracks are NOT added to wishlist — clean wing-it behavior", - "• Live sync progress displayed inline just like normal sync", - "• Download creates a bubble on the dashboard for progress tracking" - ], - "usage_note": "Click the Wing It button next to Start Discovery or Download Missing in any playlist modal." - }, - { - "title": "Global Search Bar — Search From Anywhere", - "description": "Spotlight-style search bar accessible from every page", - "features": [ - "• Persistent search bar at the bottom of the screen — faded when idle, expands on focus", - "• Full enhanced search parity — artists, albums, singles/EPs, tracks with source tabs", - "• Keyboard shortcuts: / or Ctrl+K to focus, Escape to close", - "• Click artists to navigate to their detail page, albums to open download modal", - "• In Library badges and green play buttons for tracks you already own", - "• Source tabs (Spotify, iTunes, Deezer) with result counts", - "• Results collapse on navigation, search bar stays visible" - ], - "usage_note": "Press / or Ctrl+K from any page, or click the search bar at the bottom of the screen." - }, - { - "title": "Redesigned Notification System", - "description": "Modern compact toasts with notification history and bell button", - "features": [ - "• Compact pill-shaped toasts in the bottom-right — one at a time, auto-dismiss after 3.5s", - "• Notification bell button with unread badge counter next to the help button", - "• Click the bell to open the notification history panel with the last 50 notifications", - "• Each notification shows type icon, message, relative timestamp, and optional 'Learn more' link", - "• Unread dot indicators — panel marks all as read when opened", - "• Clear All button to empty notification history", - "• Click any toast to dismiss it immediately" - ] - }, - { - "title": "Track Redownload — Fix Mismatched Downloads", - "description": "Replace wrong downloads with the correct version using manual source selection", - "features": [ - "• Redownload button (↻) on each track in the enhanced library view", - "• Step 1: All metadata sources (Spotify, iTunes, Deezer) searched in columns side-by-side", - "• Step 2: All download sources searched simultaneously — results stream in as each source responds", - "• Step 3: Download with real progress bar, old file deleted, DB path updated automatically", - "• Full pipeline parity — track number, album context, metadata tagging all work correctly", - "• Source Info (ℹ) button shows where each track was downloaded from", - "• Download provenance tracking — every download's source is recorded for future reference", - "• Smart Delete: choose to remove from library only or delete the file from disk too", - "• Download Blacklist: block specific sources from the Source Info popover — blacklisted sources skipped in all future downloads", - "• Blacklist viewer on dashboard Tools section with remove capability" - ], - "usage_note": "In the enhanced library view, click ↻ to redownload, ℹ for source info, or to delete." - }, - { - "title": "Spotify API Rate Limit Improvements", - "description": "Reduced Spotify API usage through caching and smart worker management", - "features": [ - "• get_artist_albums now cached — discography views, completion badges hit cache instead of API", - "• Watchlist scans bypass cache with skip_cache flag to always detect new releases", - "• All 5 discovery workers (Tidal, YouTube, ListenBrainz, Beatport, playlist search) now use cached search methods", - "• Eliminated duplicate API calls — discovery workers were calling sp.search() AND search_tracks() per query", - "• Auth probe cache TTL increased from 5 to 15 minutes — reduces /v1/me calls by 66%", - "• Spotify, Last.fm, and Genius enrichment workers auto-pause during active downloads to preserve rate limit headroom", - "• Dashboard shows 'Yielding for downloads' when workers are auto-paused" - ] - }, - { - "title": "Additional Fixes", - "description": "Bug fixes and quality-of-life improvements", - "features": [ - "• $discnum template variable — unpadded disc number for multi-disc album path templates", - "• Media player no longer collapses in sidebar on short viewports and mobile", - "• Playlist Explorer controls redesigned — prominent Explore button, icons, polish", - "• YouTube '- Topic' suffix stripped from auto-generated channel names (#231)", - "• Cover Art Archive album art now opt-in via Settings toggle (#232)", - "• cover.jpg now correctly uses Cover Art Archive when enabled (was silently failing)", - "• Genius artist search returns multiple results for manual matching (#233)", - "• Genius API interval increased from 1.5s to 2s to reduce 429 rate limits", - "• MusicBrainz cache now visible in Cache Browser with browse, clear, and clear-failed-only options", - "• Cache Health popup shows MusicBrainz alongside other sources, 'Failed Lookups' clarified as MB-specific", - "• Block artists from discovery — hover any track in a discovery playlist and click to permanently exclude that artist", - "• Configurable concurrent downloads (1-10) — Settings → Downloads, Soulseek albums stay at 1", - "• Streaming search sources — Apple Music results load progressively instead of blocking for 9+ seconds", - "• API Rate Monitor — real-time speedometer gauges for all services on Dashboard, click for 24h history", - "• Spotify pagination throttled — prevents 429 bans during watchlist scans with large discographies", - "• Import now triggers full scan → DB update chain through automation engine", - "• Track source-info and redownload work with Jellyfin string IDs (#237)", - "• Clear Match button to undo wrong manual matches (#236)", - "• Tidal auth no longer crashes when download orchestrator not initialized", - "• Download orchestrator hardened — one failing client no longer kills all download sources", - "• Webhook THEN action — send HTTP POST to any URL (Gotify, Home Assistant, Slack, n8n) from automations", - "• M3U auto-export now skips albums — only generates for playlists (#241)", - "• Copy Debug Info includes API call rates, Spotify rate limit state, and download client failures", - "• Discogs integration — new metadata source with enrichment worker, fallback source, search tabs, watchlist, cache", - "• Discogs enriches: genres/styles (400+ taxonomy), labels, catalog numbers, bios, community ratings", - "• Track provenance preserved through lossy transcoding with bit depth/sample rate/bitrate (#245)", - "• spotify_public playlists use full API when authenticated, no longer overwrite discovery data", - "• Watchlist backfills all sources (Spotify, iTunes, Deezer, Discogs) at start of every scan", - "• Collectors edition album matching for library completion checks", - "• Mobile responsive styles for rate monitor, notifications, and global search" - ] - }, - { - "title": "Server Playlist Manager — Compare & Fix Matches", - "description": "Review and fix track matches between your source playlists and media server", - "features": [ - "• New Server Playlists tab (default on Sync page) — shows server playlists that match your mirrored playlists", - "• Dual-column comparison view — source tracks on the left, server tracks on the right with match status", - "• Click any track to highlight and auto-scroll to its pair in the other column", - "• Find & Add — click empty slots to search your library and add tracks at the correct position", - "• Swap — replace a matched track with a different version from your library", - "• Remove — delete incorrect tracks from server playlists with confirmation", - "• Title similarity percentage shown on each match (exact, high, or fuzzy)", - "• Disambiguation modal when multiple mirrored playlists share the same name", - "• Album art shown for source tracks, server tracks, and search results", - "• Smart matching — exact title match first, then fuzzy artist+title match (≥75% threshold)", - "• Works with Plex, Jellyfin, and Navidrome" - ], - "usage_note": "Navigate to Sync → Server Playlists tab. Click any playlist card to open the comparison editor." - }, - { - "title": "Sync History Dashboard with Per-Track Details", - "description": "Dashboard shows recent syncs as visual cards with full per-track match data", - "features": [ - "• Recent Syncs section on dashboard with scrolling cards showing match percentage and health indicators", - "• Click any sync card to see per-track match details — status, confidence score, album art, download/wishlist status", - "• Filter by All, Matched, Unmatched, or Downloaded tracks in the detail modal", - "• Per-track data cached for all sync types — playlist-to-server, download missing tracks, wishlist processing", - "• Auto-refreshes every 30 seconds when viewing dashboard" - ] - }, - { - "title": "Fix Japanese Song Searches Producing Gibberish", - "description": "CJK text no longer mangled by unidecode in Soulseek search queries", - "features": [ - "• Japanese kanji, hiragana, katakana, and Korean hangul preserved in search queries", - "• unidecode was converting Japanese to Chinese pinyin (e.g. 命の灯火 → 'tvanimedei')", - "• Soulseek users typically share files with original CJK characters in filenames" - ] - }, - { - "title": "Fix Partial Name Matching False Positives (#225)", - "description": "Track ownership check no longer falsely matches prefix/suffix variations", - "features": [ - "• 'Believe' no longer matches 'Believe In Me' — length ratio penalty prevents partial title matches", - "• Titles differing in length by more than 30% get their similarity score penalized proportionally", - "• Exact matches and cleaned matches (e.g. remastered tags stripped) are unaffected" - ] - }, - { - "title": "Fix Pipeline Stops When Metadata Match Fails (#224)", - "description": "Playlist sync no longer drops tracks that failed iTunes/Apple Music discovery", - "features": [ - "• Tracks that fail metadata discovery now continue through the pipeline using original playlist data", - "• Track name and artist from the source playlist are used for Soulseek search when discovery fails", - "• Only tracks with completely missing name/artist are skipped (not tracks that simply failed matching)" - ] - }, - { - "title": "Playlist Explorer — Visual Discovery Tree", - "description": "Use playlists as seeds to discover full albums and discographies", - "features": [ - "• New Explorer page with interactive tree visualization", - "• Select any mirrored playlist and choose Albums or Discographies mode", - "• Tree builds progressively — artist nodes appear as Spotify data streams in", - "• Click artists to expand and see all their albums with art, year, and track counts", - "• Select individual albums or entire branches, then add to wishlist in one click", - "• Albums mode shows only albums containing playlist tracks; Discographies shows everything", - "• SVG connecting lines with animated draw-in effect", - "• 'In Library' and 'In Playlist' badges on album cards" - ] - }, - { - "title": "Fix .LRC Files Written Without Timestamps", - "description": "Plain lyrics now saved as .txt instead of invalid .lrc files", - "features": [ - "• Synced (timestamped) lyrics → .lrc file — valid format for Plex, Navidrome, Jellyfin", - "• Plain (unsynced) lyrics → .txt file — no longer written with incorrect .lrc extension", - "• Lyrics still embedded in audio file tags regardless of type (players can display both)", - "• File move/rename operations updated to handle both .lrc and .txt sidecars" - ] - }, - { - "title": "Fix Collaborative Album Artist Not Applied to Singles (#215)", - "description": "Single path template now respects the First Listed Artist setting", - "features": [ - "• Single downloads now include structured artists list for collab artist extraction", - "• $albumartist variable now works in single and playlist path templates", - "• Settings UI updated to show $albumartist as available for single and playlist templates" - ] - }, - { - "title": "Fix Enrichment Overwriting Manual Matches (#221)", - "description": "Enriching an entity that was manually matched no longer reverts the status to not_found", - "features": [ - "• Genius and AudioDB workers now check for existing service IDs before searching by name", - "• Manual matches are used for direct API lookup instead of re-searching by name", - "• If the direct lookup succeeds, metadata is enriched and match status is preserved", - "• If the direct lookup fails, the manual match status is preserved (not overwritten to not_found)", - "• Added AudioDB lookup-by-ID methods for artist, album, and track" - ] - }, - { - "title": "Fix Spotify OAuth ERR_EMPTY_RESPONSE in Docker (#220)", - "description": "OAuth callback server hardened for Docker/SSH tunnel setups", - "features": [ - "• Top-level error handler ensures an HTTP response is always sent (no more ERR_EMPTY_RESPONSE)", - "• All callback logging now goes to app.log (was only in Docker stdout before)", - "• Health check at http://localhost:8888/ to verify the callback server is running", - "• Startup logs the actual bind address for diagnosing port conflicts", - "• Port-in-use errors now logged clearly with explanation" - ] - }, - { - "title": "Show All Services on Dashboard (#219)", - "description": "Dashboard now shows connection status for all external services, not just the core three", - "features": [ - "• Enrichment services shown as color-coded chips below core service cards", - "• API call counts per service: 1-hour and 24-hour windowed totals shown on each chip", - "• Spotify chip includes daily budget bar (used/3000) with color-coded fill", - "• Unconfigured services show dashed border — click to jump directly to their Settings section", - "• All configurable services clickable — navigates to Settings → Connections and scrolls to the service", - "• Spotify card always labeled 'Spotify' — no longer confusingly switches to 'Apple Music'", - "• Fallback state (using iTunes/Deezer) shown with amber indicator when Spotify is not connected" - ] - }, - { - "title": "Add Qobuz to Connections Tab (#218)", - "description": "Qobuz credentials now available on the Connections tab for metadata enrichment", - "features": [ - "• New Qobuz section on Settings → Connections tab for enrichment auth", - "• Users can connect Qobuz for metadata enrichment regardless of download source", - "• Auth status syncs between Connections and Downloads tabs" - ] - }, - { - "title": "Fix Enrichment Widget Showing 'Running' When Rate Limited", - "description": "Enrichment tooltip now shows Rate Limited or Daily Limit Reached instead of stuck on Running", - "features": [ - "• Shows 'Rate Limited' with countdown when Spotify rate limit is active", - "• Shows 'Daily Limit Reached' with reset time when daily budget is exhausted", - "• Shows 'Waiting for next item...' instead of blank when no current item" - ] - }, - { - "title": "Metadata Cache Maintenance", - "description": "The cache evictor now runs four maintenance phases to keep the metadata cache clean", - "features": [ - "• Input validation prevents junk entities (Unknown Artist, empty names) from being cached", - "• Junk cleanup removes existing placeholder entries from the cache", - "• Orphan cleanup removes search results pointing to deleted entities", - "• MusicBrainz null cleanup removes failed lookups after 30 days (was 90) so they get retried", - "• Health stats available in the repair dashboard" - ] - }, - { - "title": "Fix Wishlist Download Selection Ignoring Checkboxes", - "description": "Download Selection now respects which tracks are checked in the wishlist overview", - "features": [ - "• Selected track IDs are collected before closing the overview modal", - "• Only checked tracks are sent to the download analysis board", - "• If nothing is checked, downloads the full category (same as before)" - ] - }, - { - "title": "Fix Tidal OAuth Redirect URI in Docker", - "description": "Tidal OAuth now uses the configured redirect URI instead of the Docker container hostname", - "features": [ - "• Respects the redirect URI set in Settings instead of overriding with request hostname", - "• Falls back to dynamic host detection only if no redirect URI is configured", - "• Fixes Tidal authentication failing in Docker due to internal hostname in OAuth URL" - ] - }, - { - "title": "High-Resolution Cover Art from Cover Art Archive", - "description": "Album art now sourced from Cover Art Archive when available — often 1200x1200+ original quality", - "features": [ - "• Tries Cover Art Archive first using MusicBrainz release ID (full resolution)", - "• Falls back to Spotify/iTunes/Deezer URL (640x640) if CAA unavailable", - "• Source ID embedding now runs before art embedding to make release ID available" - ] - }, - { - "title": "Embedded Lyrics in Audio Files", - "description": "Lyrics are now embedded directly in audio file tags alongside the .lrc sidecar file", - "features": [ - "• Lyrics embedded as USLT (MP3), lyrics (FLAC/OGG), or ©lyr (M4A) tags", - "• Navidrome, Jellyfin, and Plex can now display lyrics without .lrc file support", - "• .lrc sidecar files are still created for compatibility with other players" - ] - }, - { - "title": "Fix AcoustID False Positives for Non-English Tracks", - "description": "AcoustID no longer quarantines correct files when titles are in different languages", - "features": [ - "• High-confidence fingerprint matches (95%+) now SKIP instead of FAIL when title/artist don't match", - "• Prevents Japanese, Chinese, Korean, and other non-Latin tracks from being falsely quarantined", - "• Audio fingerprint confirms the recording is correct — metadata mismatch is just a language difference" - ] - }, - { - "title": "Fix Soulseek Junk Tags Surviving Post-Processing", - "description": "Tags from Soulseek source files are now wiped to disk immediately, before metadata enhancement", - "features": [ - "• Clears and saves tags before any API calls or metadata extraction", - "• If enhancement fails, file has clean empty tags instead of inconsistent junk", - "• Fixes album fragmentation in Navidrome/Jellyfin/Plex caused by partial MusicBrainz data", - "• Happy path unchanged — full metadata still written on success" - ] - }, - { - "title": "Watch All Unwatched Preview Modal", - "description": "The Watch All Unwatched button now opens a modal showing exactly which artists will be added", - "features": [ - "• Preview list shows all eligible artists with images, track counts, and matched sources", - "• Clear separation of eligible vs ineligible artists (no external ID)", - "• Collapsible section explains why some artists can't be added yet", - "• Confirm before adding — no more silent 'Added 0' surprises", - "• Results summary shown after completion" - ] - }, - { - "title": "Fix Watch All Unwatched Skipping Deezer Artists", - "description": "Watch All Unwatched now supports Deezer as an ID source", - "features": [ - "• Added Deezer ID support to the bulk watchlist add flow", - "• Source detection based on actual ID field used instead of numeric heuristic", - "• Fallback chain: active source first, then Spotify, iTunes, Deezer" - ] - }, - { - "title": "Fix Library Maintenance Path Fixes Failing Silently", - "description": "Path mismatch fixes now use fresh config and report errors to the UI", - "features": [ - "• Output folder path is re-read from config before each fix attempt", - "• Fix failure reasons are now shown in the toast notification", - "• Bulk fix failures are logged individually with finding ID and error details" - ] - }, - { - "title": "Fix Spotify Manual Match Storing Wrong IDs", - "description": "Manual match modals no longer store iTunes/Deezer IDs in Spotify ID columns", - "features": [ - "• Detects actual provider from result IDs — Spotify IDs are alphanumeric, iTunes/Deezer are numeric", - "• Match button now stores IDs in the correct service column (itunes_artist_id vs spotify_artist_id)", - "• Results show provider label when falling back (e.g. 'ID: 312095 (itunes)')", - "• Fixes broken Spotify links on artist pages caused by stored iTunes IDs" - ] - }, - { - "title": "Spotify Enrichment Daily Budget", - "description": "The background enrichment worker now caps itself at 3,000 items per day to prevent rate limit bans", - "features": [ - "• Worker-only daily budget — user-initiated searches, playlist operations, etc. are unaffected", - "• Counter resets automatically at midnight each day", - "• Worker sleeps when budget is exhausted and resumes the next day", - "• Budget status exposed in the enrichment worker dashboard widget" - ] - }, - { - "title": "Deezer Download Source", - "description": "Download music directly from Deezer with ARL authentication", - "features": [ - "• New download source: Deezer joins Soulseek, YouTube, Tidal, Qobuz, and HiFi", - "• FLAC lossless, MP3 320, and MP3 128 with automatic quality fallback", - "• ARL token authentication — paste from browser cookies, test connection in Settings", - "• Full hybrid mode support — use Deezer as primary, fallback, or in any priority order", - "• Blowfish CBC decryption handles Deezer's encrypted streams transparently", - "• AcoustID verification automatically skipped for Deezer (and Tidal/Qobuz/HiFi) — trusted API sources" - ] - }, - { - "title": "Cache-Powered Discovery", - "description": "Five new discover sections mined from your metadata cache — zero API calls", - "features": [ - "• Undiscovered Albums: albums by your most-played artists that aren't in your library", - "• New In Your Genres: recently released albums matching your top genres", - "• From Your Labels: popular albums on labels already in your library", - "• Deep Cuts: low-popularity tracks from artists you listen to — find the hidden gems", - "• Genre Explorer: genre landscape pills with artist counts — tap to deep dive", - "• All data sourced from local metadata cache — instant, no API rate limits" - ] - }, - { - "title": "Genre Deep Dive Modal", - "description": "Tap any genre pill to explore artists, tracks, and albums in that genre", - "features": [ - "• Artists section with scaled avatars — top artist gets largest, 'In Library' badges", - "• Click any artist → navigates directly to their page on the Artists tab", - "• Popular tracks list with album art, duration, click to open album download modal", - "• Albums carousel with 'In Library' badges and full download flow on click", - "• Related genres pills — click to seamlessly switch to a sibling genre", - "• Header shows counts: '12 artists · 15 tracks · 20 albums'", - "• Accent gradient header with animated light sweep" - ] - }, - { - "title": "Database Storage Visualization", - "description": "See how your database space is distributed across tables", - "features": [ - "• Donut chart on Stats page showing storage breakdown by table", - "• Uses SQLite dbstat for real byte sizes, falls back to row counts", - "• Top 8 tables shown individually, rest grouped as 'Other'", - "• Center label shows total database file size" - ] - }, - { - "title": "Library Page Performance", - "description": "Library artist grid loads significantly faster with smoother animations", - "features": [ - "• innerHTML batch rendering replaces per-card DOM manipulation — near-instant grid population", - "• Database query split into 3 steps: paginate first, then batch-fetch counts for visible page only", - "• Event delegation — single click listener instead of 75+ individual handlers", - "• Staggered card fade-in animation on page load" - ] - }, - { - "title": "Per-Artist Enrichment Rings", - "description": "See metadata coverage for each artist on their detail page", - "features": [ - "• SVG ring indicators for all 9 enrichment services below the album/EP/singles bars", - "• Rings animate on page load with staggered fill-in effect", - "• Hover glow in each service's brand color", - "• Stats page enrichment coverage also expanded to all 9 services" - ] - }, - { - "title": "Mobile Responsive Overhaul", - "description": "Comprehensive mobile layout fixes across all pages", - "features": [ - "• Stats, Automations, Hydrabase, Issues, Help pages now fully mobile responsive", - "• Artist hero section stacks properly with compact image, wrapping badges, bio clamp", - "• Enhanced library track table: action columns collapse into iOS-style bottom sheet popover", - "• Genre explorer, enrichment rings, filter bars all adapt to narrow screens" - ] - }, - { - "title": "Album Split Fix (Navidrome)", - "description": "Prevent deluxe/standard editions from splitting into separate albums", - "features": [ - "• MusicBrainz release cache key normalized — strips edition suffixes (Deluxe, Remastered, etc.)", - "• First track's MBID locked in for all subsequent tracks in the same album", - "• Handles both parenthetical '(Deluxe Edition)' and bare 'Deluxe Edition' suffixes", - "• Opus bitrate capped at 256kbps to prevent encoding failures" - ] - }, - { - "title": "Picard-Style Album Tagging", - "description": "All tracks in an album now get the same MusicBrainz release ID automatically", - "features": [ - "• Pre-flight MB release lookup before album tracks start downloading", - "• Picks ONE release, validates track count, caches for all tracks in the batch", - "• Strips Spotify edition suffixes (Super Deluxe, Remastered) for better MB matching", - "• New Album Tag Consistency repair job: scan and fix existing albums with mismatched tags" - ] - }, - { - "title": "Enrichment & Repair Fixes", - "description": "Critical fixes for background workers and maintenance jobs", - "features": [ - "• All 9 enrichment workers: error status items no longer auto-retry in infinite loops", - "• Cover art filler: findings no longer recreated after being fixed", - "• Spotify rate limit respected by search_tracks, search_albums, and cover art scanner", - "• Config save: 30s timeout + WAL mode fixes 'database is locked' on busy systems", - "• Enrichment workers auto-pause during DB scans and resume when complete" - ] - }, - { - "title": "Automation Signal Chain Fix", - "description": "Event-triggered automations now receive playlist context properly", - "features": [ - "• playlist_id forwarded from events to action handlers (fixes silent 'No playlist specified')", - "• Mirrored playlist discovery no longer pre-marks tracks as discovered with wrong album art", - "• Reorganize modal now loads saved path template instead of hardcoded default", - "• Spotify enrichment worker starts unpaused by default like all other workers" - ] - }, - { - "title": "Unified Glass UI Redesign", - "description": "Consistent visual style across all cards, modals, and buttons", - "features": [ - "• Dashboard tool cards, service cards, and stat cards: unified glass style", - "• Sync page playlist cards: all sources (Spotify, YouTube, Tidal, Deezer, Mirrored, Beatport)", - "• Download missing and wishlist modals: cleaner backgrounds, softer shadows", - "• Watchlist and enhance quality buttons: glass hover with accent glow", - "• Library page: innerHTML rendering + staggered card animation for faster loads" - ] - }, - { - "title": "Scrobbling to Last.fm & ListenBrainz", - "description": "Automatically scrobble your plays from Plex, Jellyfin, or Navidrome", - "features": [ - "• Listen on your media server — SoulSync automatically scrobbles to Last.fm and/or ListenBrainz", - "• Last.fm: full web auth flow, ListenBrainz: simple token-based", - "• Batch scrobbling with dedup tracking — events only scrobbled once" - ] - }, - { - "title": "Personalized Discovery + Listening Stats", - "description": "Discovery playlists use your listening history, plus a full stats dashboard", - "features": [ - "• Release Radar, Discovery Weekly, and Because You Listen To: personalized by play history", - "• Listening Stats page: timeline chart, genre breakdown, top artists/albums/tracks", - "• Database storage donut chart in Library Health section", - "• Play buttons on stats page tracks with cover art" - ] - }, - { - "title": "Interactive Help System", - "description": "Full contextual help platform accessible from the floating ? button", - "features": [ - "• 200+ contextual help entries — click any UI element to learn what it does", - "• 11 guided tours covering every page (97 steps total) with spotlight overlay", - "• Page-aware menu suggests the relevant tour for your current page", - "• Search across all help topics, tours, and keyboard shortcuts (Ctrl+K)", - "• Setup Progress tracker with auto-detection — checks your services, library, and watchlist", - "• What's New panel with version-tagged highlights and 'Show me' navigation", - "• Troubleshoot mode scans for disconnected services and shows fix steps", - "• Keyboard shortcut overlay showing all hotkeys grouped by scope", - "• Quick action buttons in popovers (e.g., 'Open Settings' on service cards)", - "• First-launch welcome prompt for new users" - ] - }, - { - "title": "Rich Artist Profiles", - "description": "Full-bleed hero section on the Artists page with deep metadata", - "features": [ - "• Large portrait image with blurred background, glassmorphic design", - "• Bio, genres, listening stats from Last.fm, service logo badges", - "• Multi-source genre explorer with Deezer genre support", - "• Similar artist cards with full-bleed library-card styling" - ] - }, - { - "title": "Enhanced Library Manager", - "description": "Inline metadata editing and tag writing from the library view", - "features": [ - "• Toggle between Standard and Enhanced view on any artist detail page", - "• Inline-edit track title, number, BPM; album and artist fields editable", - "• Write tags directly to audio files (MP3, FLAC, OGG, M4A) with diff preview", - "• Bulk select tracks across albums for batch edit and batch tag write", - "• Server sync after writes — Plex per-track, Jellyfin library scan" - ] - }, - { - "title": "In Library Badges + Search Improvements", - "description": "Know what you already own before downloading", - "features": [ - "• 'In Library' badges on enhanced search album and track results", - "• Async post-render matching — search results appear instantly, badges fill in", - "• Multi-source search tabs: compare results from Spotify, iTunes, and Deezer", - "• Clickable artist name in download modal navigates to discography" - ] - }, - { - "title": "FLAC Bit Depth + Quality Filter", - "description": "Finer control over audio quality preferences", - "features": [ - "• Quality profile enforces 16-bit vs 24-bit FLAC preference", - "• Bit depth fallback option: accept other bit depth if preferred unavailable", - "• 1450 kbps threshold separates 16-bit from 24-bit FLAC", - "• Sort prioritizes audio quality (effective kbps) over peer speed" - ] - }, - { - "title": "Enrichment Worker Improvements", - "description": "Better name matching and quieter logs across all 8+ workers", - "features": [ - "• Dash-suffix normalization: 'Title - Remix' now matches 'Title (Remix)' across all workers", - "• AcoustID log noise reduced — individual recording matches moved to DEBUG", - "• Streaming source verification: artist/title fuzzy match prevents wrong track downloads", - "• Deezer enrichment worker caches API calls through metadata cache", - "• Per-source quality fallback toggles for streaming download sources" - ] - }, - { - "title": "Launch PIN Lock Screen", - "description": "Protect SoulSync access with a PIN on every page load", - "features": [ - "• Toggle in Settings → Advanced → Security to require PIN on launch", - "• Full-screen lock overlay with PIN input — closing the tab requires re-entry", - "• PIN validated server-side against admin profile (bcrypt hashed)", - "• Inline PIN creation if admin has no PIN set", - "• Shake animation on wrong PIN, auto-focus input" - ] - }, - { - "title": "Stream Source Setting", - "description": "Choose where track previews come from — independent of download source", - "features": [ - "• New dropdown in Settings → Downloads: YouTube (instant, default) or Active Download Source", - "• If active source is Soulseek, automatically falls back to YouTube", - "• YouTube streams require no auth — instant playback" - ] - }, - { - "title": "YouTube Download Fix", - "description": "Fixed 'Requested format not available' errors affecting all YouTube downloads", - "features": [ - "• Removed stale player_client and HLS/DASH skip overrides that blocked audio formats", - "• Browser cookie fallback — retries without cookies when authenticated sessions restrict formats", - "• Docker containers auto-update yt-dlp on every start" - ] - }, - { - "title": "Accurate Album Completion Badges", - "description": "Album completion now uses exact track counts instead of percentage rounding", - "features": [ - "• Exact match: 'Complete' only when all tracks are present — no more 90% rounding", - "• Deduplicated counting: duplicate album entries don't inflate track counts", - "• Multi-artist album detection: finds albums filed under different artists in your library", - "• Censored title matching: 'B*****t Faucet' now matches 'Bullshit Faucet' (Apple Music)" - ] - }, - { - "title": "Collaborative Album Handling", - "description": "Smart folder naming and matching for albums with multiple artists", - "features": [ - "• New setting: Collaborative Album Artist — use first listed artist or all combined", - "• Spotify: picks first from separate artist objects. Deezer: already first-only", - "• iTunes: resolves primary artist via artistId API lookup (safe for 'Tyler, the Creator')", - "• Album-aware track matching prevents re-downloads of collab albums filed under different artists" - ] - }, - { - "title": "Per-Artist Library Sync", - "description": "Validate and clean up individual artist library entries", - "features": [ - "• New 'Sync' button on enhanced library view", - "• Checks each track's file exists on disk, removes stale entries", - "• Cleans empty albums and updates track counts", - "• Per-artist watchlist lookback period override" - ] - }, - { - "title": "Stability & Bug Fixes", - "description": "Various fixes for crashes, data integrity, and UX", - "features": [ - "• Enrichment worker pause state persists across restarts", - "• Soulseek timeout spam prevention — skips API calls when disconnected", - "• Navidrome playlist sync uses POST (fixes truncation on large playlists)", - "• Deezer metadata cache no longer serves stale data missing track numbers/year", - "• Track numbering fix for non-Spotify metadata sources (Deezer/iTunes)", - "• Album delete endpoint accepts all ID formats (fixes Navidrome string IDs)", - "• Hydrabase auto-reconnect when server restarts", - "• Wishlist process API endpoint for external apps" - ] - } -""" # end of _OLD_V22_NOTES - -_OLD_V2_NOTES = r""" - "features": [ - "• Generates soul IDs using SHA-256 hash of normalized names", - "• Artists: hash(name + debut_year) — debut year from iTunes + Deezer API verification", - "• Albums: hash(artist + album), Tracks: dual IDs (song + album-specific)", - "• Dashboard worker button with rainbow spinner and hover tooltip", - "• SoulSync badge on library artist cards when matched" - ] - }, - { - "title": "Lossy Codec Expansion + Retroactive Converter", - "description": "Opus and AAC support for post-download conversion, plus a repair job for existing files", - "features": [ - "• Lossy copy now supports MP3, Opus, and AAC (M4A) — configurable codec and bitrate", - "• Opus: -map 0:a for clean audio extraction, cover art embedded via METADATA_BLOCK_PICTURE", - "• AAC: MP4Cover embedding, -movflags +faststart for streaming optimization", - "• New Lossy Converter repair job: scans FLAC library, creates findings, Fix/Fix All converts", - "• Job reads codec/bitrate from current settings at fix time (change settings after scanning)", - "• Independent Blasphemy Mode toggle per job (separate from download-time setting)" - ] - }, - { - "title": "Smarter Staging Import", - "description": "Tag-first matching and auto-grouping for the import workflow", - "features": [ - "• Tags take priority over filename parsing — no more '08' as artist name", - "• Auto-detected album groups from file tags shown as one-click import cards", - "• Match scoring rebalanced: title (0.45) + artist (0.15) + track# (0.30) + album bonus (0.10)", - "• Filename parser pattern order fixed — track numbers no longer misidentified as artists" - ] - }, - { - "title": "Library Artist Hero Redesign", - "description": "Expanded artist detail section with Last.fm integration", - "features": [ - "• Horizontal service badge row with hover lift animations", - "• Last.fm bio with Read More toggle, listener/play count stats", - "• Scrollable top 100 tracks from Last.fm in sidebar card", - "• Last.fm tags merged with existing genres", - "• Compact inline progress bars for Albums/EPs/Singles completion" - ] - }, - { - "title": "Hydrabase Search & Routing", - "description": "Hydrabase shows as a search tab with proper ID routing", - "features": [ - "• Hydrabase appears as a source tab on enhanced search when connected", - "• Plugin-aware ID routing: numeric IDs → iTunes, alphanumeric → Spotify", - "• Artist images fetched from iTunes for Hydrabase results", - "• Full Spotify-compatible interface: get_album, get_artist, get_track_details" - ] - }, - { - "title": "Orphan File Detector + MusicBrainz Fixes", - "description": "Better orphan detection and album version matching", - "features": [ - "• Orphan detector: normalized tag matching strips feat./parentheticals to reduce false positives", - "• Orphan fix now prompts 'Move to Staging' or 'Delete' instead of auto-deleting", - "• MusicBrainz release matching: version qualifier scoring prevents deluxe → standard MBID mismatch", - "• Playlist sync crash fixed: profile ID captured at request time, not in background thread" - ] - }, - { - "title": "Release Year Collection", - "description": "Post-processing now collects release year from all metadata sources", - "features": [ - "• Year extracted from MusicBrainz, Deezer, Tidal, Qobuz during post-processing", - "• First source to find a year wins — written to ORIGINALDATE/DATE tags and album DB year", - "• Library Reorganize API year lookup cap raised from 50 to 200" - ] - }, - { - "title": "Multi-Source Search Tabs", - "description": "View search results from Spotify, iTunes, and Deezer side by side", - "features": [ - "• Enhanced search now fires parallel queries against all available metadata sources", - "• Switchable tabs above results — click to view results from Spotify, Apple Music, or Deezer", - "• Tabs load progressively — primary source shows instantly, alternates appear as they complete", - "• Click an artist or album from any tab to browse that source's data temporarily", - "• Downloads use the metadata from whichever source tab you're viewing", - "• Similar artists always use your primary source — no accidental cross-source mixing" - ] - }, - { - "title": "Per-Profile Service Credentials", - "description": "Each profile can connect their own Spotify, Tidal, and media server library", - "features": [ - "• Non-admin profiles can enter their own Spotify credentials and authenticate their own account", - "• Per-profile Tidal authentication — connect your own Tidal account through the shared app", - "• Per-profile media server library selection — choose which Plex library or Jellyfin user playlists sync to", - "• Tabbed personal settings modal: Music Services, Server, and Scrobbling tabs", - "• Server tab auto-detects active server and shows library name dropdowns (not raw IDs)", - "• All credentials encrypted. Admin users see zero change — fully backwards compatible" - ] - }, - { - "title": "Modern Settings Redesign", - "description": "Settings page rebuilt with tabbed single-column layout", - "features": [ - "• Horizontal tab bar: Connections, Downloads, Library, Appearance, Advanced", - "• Single centered column replaces 3-column wall of cards", - "• Clean row layout — label left, control right", - "• Custom styled dropdowns with SVG arrows and hover states", - "• Mobile responsive — rows stack, tab bar scrolls" - ] - }, - { - "title": "Hybrid N-Source Download Priority", - "description": "Hybrid mode now supports all 5 download sources with drag-to-reorder priority", - "features": [ - "• Enable/disable any combination of Soulseek, YouTube, Tidal, Qobuz, and HiFi", - "• Up/down arrows to reorder source priority — downloads try each enabled source in order", - "• Source icons with toggle switches and priority numbers", - "• Configurable download timeout and max peer queue length for Soulseek", - "• Peer quality (upload speed, free slots, queue length) now factors into result ranking" - ] - }, - { - "title": "Automation Hub Pipelines", - "description": "One-click deployment of multi-automation pipelines", - "features": [ - "• 11 pre-built pipelines: Release Radar, Discovery Weekly, Playlist Auto-Sync, Nightly Operations, and more", - "• Each pipeline deploys 2-5 linked automations with signal chaining in one click", - "• Visual pipeline cards with connected flow nodes and accent-colored design", - "• Pipeline detail modal shows full WHEN/DO/THEN breakdown for each automation", - "• Deploy prompts for notification config (Discord/Telegram/Pushbullet) when pipeline includes alerts" - ] - }, - { - "title": "Staging Folder Pre-Download Check", - "description": "Check your import folder for existing files before downloading", - "features": [ - "• Before searching Soulseek/YouTube, checks the import folder for a matching file", - "• Tag-based matching (Mutagen) with filename parsing fallback", - "• On match, copies the file to transfer and runs normal post-processing", - "• Staging scan cached per batch — only scans once for the entire download" - ] - }, - { - "title": "Library Safety & Repair Fixes", - "description": "Critical fixes for library maintenance jobs and safety guards", - "features": [ - "• Mass orphan safety guard — 'witness me' confirmation required when >50% of files flagged as orphans", - "• Tag-based orphan fallback — reads file metadata before marking as orphan to prevent false positives", - "• Album Completeness expanded to support iTunes and Deezer (was Spotify-only)", - "• Album Completeness min completion % filter — skip playlist imports, catch real failed downloads", - "• Fix Track Number Repair returning 400 on fix (entity_id was NULL for file-based findings)", - "• Fix Library Reorganize producing (_) in paths when year is empty", - "• Fix Fix All ignoring Single/Album Dedup findings", - "• Fix enrichment workers looping infinitely on tracks with NULL IDs", - "• Fix Tidal token refresh hammering API when credentials removed", - "• Fix YouTube playlist parsing capped at ~100 tracks", - "• Allow re-sync from download_complete state with Rediscover button" - ] - }, - { - "title": "Deezer Metadata Source", - "description": "Deezer added as a configurable free metadata fallback alongside iTunes/Apple Music", - "features": [ - "• New setting to choose between iTunes and Deezer as your fallback metadata source — switch anytime from Settings", - "• All metadata lookups, watchlist scans, discovery, and enrichment seamlessly use whichever fallback is configured", - "• On-the-fly artist ID resolution — switching sources auto-matches existing watchlist artists by name on the next scan", - "• Source badges on watchlist artist cards show which services (Spotify, iTunes, Deezer) each artist is matched to", - "• Full backward compatibility — existing iTunes users experience zero changes on upgrade", - "• Name-based duplicate detection prevents adding the same artist twice across different metadata sources" - ] - }, - { - "title": "Library History", - "description": "Persistent record of every download and server import — viewable from the dashboard", - "features": [ - "• History button next to Recent Activity opens a modal with Downloads and Server Imports tabs", - "• Every completed SoulSync download is logged with title, artist, album, quality, and cover art", - "• Every new track imported from Plex, Jellyfin, or Navidrome is logged automatically", - "• Paginated browsing with tab count badges and relative timestamps", - "• History persists across restarts — unlike the in-memory activity feed" - ] - }, - { - "title": "MusicBrainz MBID Mismatch Repair", - "description": "New repair job to detect and fix wrong MusicBrainz recording IDs on library tracks", - "features": [ - "• Detects tracks where the stored MusicBrainz recording ID resolves to a different title than expected", - "• Fix action clears the bad MBID so enrichment can re-match correctly", - "• Also fixes MusicBrainz recording matching returning wrong titles due to unstable MBID lookups" - ] - }, - { - "title": "HiFi Download Source", - "description": "Free lossless downloads via public hifi-api instances — no account or subscription required", - "features": [ - "• New download mode alongside Soulseek, YouTube, Tidal, and Qobuz — select HiFi Only or use in hybrid mode", - "• Quality selection: Hi-Res, Lossless, High, or Low with automatic fallback chain (hires → lossless → high → low)", - "• Automatic instance rotation across 6 public API servers — any server error triggers failover to the next instance", - "• Full search, download, streaming, and post-processing support — works identically to other download sources", - "• Test connection button in Settings to verify instance availability" - ] - }, - { - "title": "Spotify Link (No API Credentials)", - "description": "Scrape Spotify playlists and albums by URL without needing Spotify API credentials", - "features": [ - "• New Spotify Link tab on the playlist sync page — paste any public Spotify playlist or album URL", - "• Extracts all track metadata (title, artist, album, duration, cover art) via web scraping", - "• Works without Spotify client ID/secret — great for users who don't want to set up a Spotify developer app", - "• Full download and sync support — tracks are matched and downloaded like any other playlist source" - ] - }, - { - "title": "Library Maintenance Suite", - "description": "Full-featured library repair system with 9 automated jobs, fix actions, and rich findings UI", - "features": [ - "• 9 repair jobs: track number mismatch, dead files, duplicates, metadata gaps, album completeness, missing cover art, AcoustID scanner, orphan files, fake lossless detection", - "• One-click fix actions for findings — remove dead entries, delete orphans, resolve duplicates, apply metadata, update track numbers", - "• Findings dashboard with per-job filter chips, summary stats, and expandable detail panels", - "• Album art and artist images displayed in findings with labeled media cards", - "• Real-time progress on job cards via WebSocket — phase, log lines, and per-item activity", - "• Visual detail renderers: cover art previews, KEEP/REMOVE badges for duplicates, completion progress bars, spectral analysis for fake lossless", - "• Job help text modals explaining what each repair job checks and how to interpret findings" - ] - }, - { - "title": "Post-Processing Enhancements", - "description": "Granular control over post-processing and richer file tagging", - "features": [ - "• Granular toggles for each post-processing step — enable/disable metadata services, cover art, and lyrics individually", - "• Embed Tidal, Qobuz, Last.fm, and Genius metadata directly into audio file tags during post-processing", - "• FLAC bit depth fallback option in quality profiles — accept lower bit depth when preferred isn't available" - ] - }, - { - "title": "Per-Profile ListenBrainz", - "description": "Each profile can connect their own ListenBrainz account for personalized playlists", - "features": [ - "• Personal settings modal with ListenBrainz connect/disconnect flow", - "• Per-profile playlist caching — switching profiles shows that user's playlists", - "• Graceful fallback to global ListenBrainz token when no personal token is set", - "• Stale playlist cache recovery for interrupted syncs" - ] - }, - { - "title": "Quality Enhance", - "description": "Upgrade existing library tracks to higher quality versions", - "features": [ - "• Quality enhance button on library tracks — find and download a higher quality version", - "• iTunes fallback for quality enhance when Spotify metadata isn't available", - "• Full metadata source parity between Spotify and iTunes for upgrade searches" - ] - }, - { - "title": "Hi-Res FLAC Downsampling", - "description": "Automatically convert 24-bit hi-res downloads to 16-bit/44.1kHz CD quality", - "features": [ - "• New toggle in Settings → Post-Download Conversion: downsample hi-res FLAC to CD quality after download", - "• Converts 24-bit and/or high sample rate FLAC files to 16-bit/44.1kHz — saves ~50% disk space with no audible difference", - "• Safe in-place replacement: writes to temp file, verifies output, then atomic swap — original untouched on failure", - "• Runs before lossy copy so MP3s are created from the downsampled version when both are enabled", - "• Automatically updates $quality in filenames and QUALITY tags after conversion", - "• Overrides strict bit depth rejection — files are accepted and converted instead of quarantined" - ] - }, - { - "title": "Recent Bug Fixes & Improvements", - "description": "Stability fixes, UX improvements, and edge case handling", - "features": [ - "• Fix $year template variable empty for playlist/sync downloads — album metadata now backfilled from Spotify API", - "• Fix dead file cleaner reporting 66k+ false positives — transfer path fell back to ./Transfer under DB contention", - "• Fix library reorganize not updating database paths after moving files — suffix-based matching with SQL LIKE escaping", - "• Fix library reorganize not moving cover.jpg and other album-level sidecar files with tracks", - "• Fix orphaned sidecar files left behind after reorganize — post-pass sweep moves remaining non-audio files", - "• Fix Navidrome library scan was a no-op — now triggers actual scan via Subsonic startScan API", - "• Select All, Fix Selected, and Fix All bulk actions for Library Maintenance findings", - "• Fix empty brackets in folder names ($year, $quality etc.) not being cleaned when template variables resolve to empty", - "• Fix missing album cover art in download progress bubbles for redownload and issue modal downloads", - "• Cancel button for watchlist scans — stop manual or automation-triggered scans mid-run", - "• Fix HiFi client not failing over to next instance on HTTP 500 — previously only 502/503/504 triggered rotation", - "• Fix service status labels missing HiFi and Qobuz display names", - "• Redownload button on enhanced library view — re-download any album directly from the library manager", - "• Hemisphere setting for seasonal playlists — southern hemisphere users get correct seasonal recommendations", - "• Play button on repair findings — preview tracks directly from the maintenance findings list", - "• Spotify rate limit guards added to all repair jobs — prevents ban escalation during library maintenance", - "• Fix watchlist migration dropping profile_id & fix profile delete dialog hidden behind overlay", - "• Fix watchlist NOT NULL constraint blocking iTunes-only artists from being added", - "• Fix Windows path mangling for artist names with trailing dots (e.g. Fred again..)", - "• Fix watchlist scan failing entirely when Spotify is rate limited — iTunes provider fallback added", - "• Fix per-profile ListenBrainz playlist cache scoping and stale data recovery", - "• Harden metadata cache — prevent simplified data from overwriting full entries, fix connection leaks", - "• Scope automation-triggered watchlist scans to the calling profile", - "• Fix watchlist scan silently skipping all albums due to metadata cache returning incomplete data", - "• Optimized enhanced library view performance with event delegation and scoped DOM queries", - "• Fix Qobuz and HiFi streaming source checks that blocked playback with 'format not supported' error" - ] - }, - { - "title": "Library Issue Reporting", - "description": "Report and track issues for tracks, albums, and artists directly from the library", - "features": [ - "• Report issues on any library item — tracks, albums, or artists — with category, priority, and notes", - "• Actionable issue detail modal with album art, artist photo overlay, genre tags, and format badges", - "• Download Album and Add to Wishlist buttons directly from the issue modal (admin only)", - "• Enhanced-library-style track listing with format and bitrate indicators", - "• Smart album fetch — uses Spotify ID when available, falls back to enhanced search" - ] - }, - { - "title": "Album File Reorganization", - "description": "Reorganize album files on disk from the Enhanced Library Manager", - "features": [ - "• Move and rename album files to match your configured folder template", - "• Preview the reorganization with before/after file paths before applying", - "• Supports multi-disc albums with automatic disc subfolder creation", - "• Database paths updated automatically after files are moved" - ] - }, - { - "title": "Interactive REST API Docs", - "description": "Full API documentation with a built-in endpoint tester", - "features": [ - "• Comprehensive docs for all API endpoints organized by category", - "• Built-in endpoint tester — execute API calls directly from the docs page", - "• JSON response viewer with syntax highlighting and copy support", - "• Complete metadata serialization for all entity types" - ] - }, - { - "title": "Watchlist Improvements", - "description": "Smarter cross-provider matching, manual artist linking, and scan timestamp fixes", - "features": [ - "• Cross-provider artist matching now uses fuzzy name comparison instead of blindly taking the first result", - "• Manual artist linking UI — change the linked Spotify/iTunes artist from the watchlist config modal", - "• Mismatch warning when the linked provider artist name differs from the watchlist entry", - "• Watchlist settings gear button accessible from artist detail page and artist cards", - "• Scan timestamps preserved for UI display — 'Never scanned' no longer shows after lookback changes", - "• Lookback period changes use a one-time rescan flag instead of wiping all timestamps" - ] - }, - { - "title": "AcoustID Verification Fix", - "description": "More accurate audio file verification with broader title normalization", - "features": [ - "• Strip ALL parentheticals in title normalization — fixes false mismatches for parody, soundtrack, and featured artist suffixes", - "• Previously only whitelisted suffixes like (Live) and (Remastered) were stripped" - ] - }, - { - "title": "Deezer Playlist Sync", - "description": "Full Deezer integration for playlist sync alongside Spotify, Tidal, and YouTube", - "features": [ - "• Import and sync Deezer playlists with full track matching and discovery", - "• Deezer discovery worker with Spotify/iTunes match caching", - "• Fix modal for unmatched Deezer tracks with manual search", - "• Manual fixes persist to discovery cache across restarts" - ] - }, - { - "title": "Discovery Page Improvements", - "description": "Better playlist generation, caching, and iTunes parity", - "features": [ - "• iTunes discovery playlists now produce quality results — synthetic popularity scoring replaces broken 0-popularity tiering", - "• EPs included in iTunes discovery pool (previously excluded)", - "• Popular Picks and Hidden Gems playlists now work correctly for iTunes users", - "• Seasonal playlists fully work with iTunes — album search, watchlist search, and track fetching", - "• Similar artist metadata (images, genres, popularity) cached at scan time — no more redundant API calls", - "• Hero slider loads instantly from cache instead of making 10 Spotify API calls per page load", - "• View Recommended modal uses cached data — only uncached artists trigger API calls", - "• Album art now displays in discovery pool modal for both Spotify and iTunes matches" - ] - }, - { - "title": "Rate Limit Detection Fix", - "description": "Rate limit handling completely overhauled — escalating bans, no more rate limit loops", - "features": [ - "• Fixed rate limits going undetected in get_album, get_artist, and batch artist enrichment", - "• These methods previously swallowed 429 exceptions — global ban was never activated", - "• Escalating ban durations — repeated rate limits within 1 hour double the ban (30m → 1h → 2h → 4h max)", - "• Default ban raised from 10 minutes to 30 minutes — prevents rapid re-ban cycling", - "• Exhausted-retry detection — 5 consecutive 429s trigger a 1-hour ban instead of re-raising", - "• Rate limit modal with live countdown timer, ban duration, and triggering endpoint", - "• Redundant get_album_tracks API call removed from iTunes discovery pool population" - ] - }, - { - "title": "Download & Matching Fixes", - "description": "Accuracy improvements for album downloads and track matching", - "features": [ - "• Album download pre-flight search finds complete album folders before track-by-track downloading", - "• Fix wrong track downloads when album name matches a track title in hybrid mode", - "• Improved album download analysis with album-scoped track matching", - "• Fix Tidal playlist sync dropping remix/version info from track titles", - "• Race guard verification extended to all download source monitors" - ] - }, - { - "title": "Security & Config", - "description": "Encryption at rest and config improvements", - "features": [ - "• Sensitive config values (API keys, passwords, tokens) encrypted at rest with Fernet", - "• Transparent migration — existing plaintext values auto-encrypt on first load", - "• Tidal OAuth fix — override Accept header on token requests" - ] - }, - { - "title": "Recent Bug Fixes", - "description": "Stability and UX fixes", - "features": [ - "• Fix sync stuck at 80% — serialize datetime in SyncResult for WebSocket emit", - "• Fix automated scans for non-Plex servers and incremental scan performance", - "• Fix Tidal/Qobuz enrichment backfill failing on dict-type copyright and isrc fields", - "• Fix false positive track matching and tag writing visibility for library files", - "• Stop unnecessary Spotify API call every 60s from enrichment status polling", - "• Spotify rate limit UX — persistent modal with countdown, dismiss, and disconnect buttons", - "• Navidrome ReportRealPath guidance when library files can't be found", - "• Enhanced library write-all modal and confirmation dialog improvements" - ] - }, - { - "title": "Tidal & Qobuz Enrichment Workers", - "description": "Two new background enrichment workers for Tidal and Qobuz metadata", - "features": [ - "• Tidal worker enriches artists, albums, and tracks with Tidal IDs, thumbnails, and metadata", - "• Qobuz worker enriches artists, albums, and tracks with Qobuz IDs, labels, genres, and metadata", - "• Dashboard buttons with real-time status, progress tracking, and pause/resume controls", - "• Smart no-auth detection — buttons grey out when not authenticated to either service", - "• Module-level rate limiting for Qobuz — shared throttle across all client instances", - "• Full Enhanced Library Manager integration — status chips, manual matching, clickable service badges", - "• Library artist card badges and discography 'View on' buttons for both services", - "• Total enrichment worker count now at 9: Spotify, iTunes, MusicBrainz, AudioDB, Deezer, Last.fm, Genius, Tidal, Qobuz" - ] - }, - { - "title": "Full Qobuz Support", - "description": "Qobuz added as a first-class download source alongside Tidal and Soulseek", - "features": [ - "• Search, browse, and download from Qobuz with quality selection up to Hi-Res 24-bit/192kHz", - "• Qobuz appears as a download source in hybrid mode with configurable priority", - "• Playlist import from Qobuz URLs with mirrored playlist support", - "• Settings page integration with conditional source visibility" - ] - }, - { - "title": "Hybrid Mode Redesign", - "description": "Overhauled download source selection and priority system", - "features": [ - "• Redesigned hybrid mode with drag-and-drop source priority ordering", - "• Tidal, Qobuz, and Soulseek sources with per-source quality preferences", - "• Conditional settings — source-specific options only appear when that source is enabled", - "• Reorganized settings page with clearer Download Source section" - ] - }, - { - "title": "Spotify Rate Limit Protection", - "description": "Smart detection and handling of Spotify API rate limits with escalating bans", - "features": [ - "• Automatic detection of long rate limit bans (Retry-After > 60s) from Spotify", - "• Escalating ban durations — repeated hits within 1 hour double the ban (30m → 1h → 2h → 4h)", - "• Global suppression of all Spotify API calls during a ban — no wasted requests", - "• Seamless iTunes/Apple Music fallback for searches while Spotify is rate limited", - "• Enrichment worker auto-pauses during rate limit and resumes when ban expires", - "• Rate limit modal with live countdown timer, ban duration, triggering endpoint, and dismiss/disconnect buttons", - "• One-click Disconnect Spotify button to clear ban, pause enrichment, and delete cache", - "• Auth probe no longer makes API calls during ban — prevents extending the ban", - "• Cooldown-to-restored transition auto-closes modal and refreshes discover page" - ] - }, - { - "title": "Profile Permissions & Page Access Control", - "description": "Granular admin controls over what each profile can see and do", - "features": [ - "• Admin can control which sidebar pages each profile can access", - "• Per-profile download toggle — disable downloading for specific users (frontend + backend enforced)", - "• Per-user home page — every user can choose their own landing page", - "• Enhanced Library Manager restricted to admin profiles only", - "• Non-admin users default to Discover page instead of Dashboard" - ] - }, - { - "title": "Now Playing Overhaul", - "description": "Redesigned media player with expanded Now Playing modal and smart radio", - "features": [ - "• Expanded Now Playing modal — click the sidebar player to open a full-screen experience", - "• Album art ambient glow — dominant color from cover art tints the modal background", - "• Smart Radio mode — auto-queue up to 50 similar tracks based on genre, mood, and style", - "• Queue system — add tracks from the library, manage queue in Now Playing modal", - "• Web Audio visualizer — real frequency-driven bars responding to actual playback", - "• Repeat modes (off, repeat-all, repeat-one), shuffle, Media Session API controls", - "• Keyboard shortcuts — Space, arrows, M (mute), Escape (close)" - ] - }, - { - "title": "Enhanced Library Manager", - "description": "Professional-grade library management with tag writing and server sync", - "features": [ - "• Toggle between Standard and Enhanced views on any artist's discography", - "• Inline metadata editing — click any field to edit artist, album, or track data", - "• Per-service manual matching for all 9 enrichment services", - "• Write Tags to File — sync database metadata to audio file tags (MP3/FLAC/OGG/M4A)", - "• Tag preview modal showing a diff of file vs database values before writing", - "• Batch write tags for entire albums or bulk-selected tracks with live progress", - "• Optional cover art embedding with per-album caching", - "• Server sync after tag writes — push updated metadata to Plex, Jellyfin, or Navidrome", - "• Bulk select and batch edit tracks across albums", - "• Sortable track table columns, multi-disc support, play tracks from library" - ], - "usage_note": "Open any artist's detail page and click 'Enhanced' in the view toggle to access the library manager." - }, - { - "title": "Last.fm & Genius Enrichment Workers", - "description": "Background enrichment workers for Last.fm and Genius metadata", - "features": [ - "• Last.fm worker enriches artists, albums, and tracks with listener counts, play counts, tags, and bios", - "• Genius worker enriches artists and tracks with descriptions, alternate names, and lyrics", - "• Dashboard buttons with status, progress, and pause/resume controls", - "• No-auth detection — buttons grey out with guidance when API keys are missing", - "• Settings reload — changing API keys takes effect immediately without restarting" - ] - }, - { - "title": "UI & Visual Overhaul", - "description": "Per-page particle animations, sidebar visualizer, watchlist redesign, and design refresh", - "features": [ - "• Per-page particle animations with unique themes for each page", - "• Particle toggle in Settings — disable background particles to reduce GPU usage", - "• Sidebar audio visualizer with 5 reactive styles and settings toggle", - "• Sidebar SVG icons with accent-colored navigation and ambient aura", - "• Watchlist modal redesign — gradient overlay cards, staggered entrance animations, SVG icon buttons, glassmorphic styling", - "• Settings page visual refresh — premium header, custom toggle switches, refined input styling", - "• Page headers with sidebar icons and gradient shimmer styling", - "• Service badges on library artist cards for all 9 enrichment services", - "• Glassmorphic 'View on' buttons on artist discography pages", - "• Help & Docs page — comprehensive in-app documentation covering every feature" - ] - }, - { - "title": "Tidal Download Improvements", - "description": "Stability and accuracy fixes for Tidal downloads", - "features": [ - "• Tidal download validation — detect and clean up unplayable hi-res stubs", - "• Tidal playlist pagination rate limiting with exponential backoff", - "• Include Tidal version field in track names — fixes remixes resolving to base title", - "• Direct single-playlist fetch instead of redundantly re-fetching all playlists" - ] - }, - { - "title": "Bug Fixes & Stability", - "description": "Reliability improvements across the board", - "features": [ - "• Fix Genius search blindly matching wrong artists — all bad matches auto-reset", - "• Fix library page albums merging across different artists with same album title/year", - "• Fix post-processing race condition on files already moved by another thread", - "• iTunes storefront fallback — ID lookups automatically try 10 regional storefronts", - "• Fix infinite Spotify rate limit loop from unguarded auth probes", - "• Fix playlist folder downloads marked as failed despite successful processing", - "• Fix Docker upgrade crashes from stale volume mounts and partial DB migrations", - "• Isolate service client initialization so one failure doesn't break the app", - "• Explicit content filter with configurable toggle to skip explicit tracks", -""" # end of _OLD_V2_NOTES_REMOVED - def _simple_monitor_task(): """The actual monitoring task that runs in the background thread. @@ -26705,7 +25206,11 @@ def download_backup_endpoint(filename): backup_path = os.path.join(os.path.dirname(db_path), filename) if not os.path.exists(backup_path): return jsonify({"success": False, "error": "Backup not found"}), 404 - return send_file(backup_path, as_attachment=True, download_name=filename) + # Override the default static-cache max-age — this is a sensitive + # DB backup, browsers should never cache it. + response = send_file(backup_path, as_attachment=True, download_name=filename) + response.headers['Cache-Control'] = 'no-store' + return response except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @@ -28867,14 +27372,8 @@ def _on_download_completed(batch_id, task_id, success=True): except Exception: pass - # Push discover playlists to media server after downloads complete + # Push is handled in _check_batch_completion_v2 (once per batch). playlist_id = batch.get('playlist_id') - if playlist_id and playlist_id.startswith('discover_'): - threading.Thread( - target=_push_discover_playlist_to_server, - args=(batch_id, batch), - daemon=True - ).start() # Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist if playlist_id and playlist_id.startswith('youtube_'): @@ -28987,11 +27486,11 @@ def _on_download_completed(batch_id, task_id, success=True): def _submit_or_queue_batch(batch_id, playlist_id, tracks): - """Submit a batch for analysis, or queue it if 3 analysis slots are full.""" + """Submit a batch for analysis, or queue it if all analysis slots are full.""" with tasks_lock: active_analysis_count = sum(1 for b in download_batches.values() if b.get('phase') == 'analysis') - if active_analysis_count >= 3: + if active_analysis_count >= MAX_CONCURRENT_ANALYSIS: download_batches[batch_id]['phase'] = 'queued' download_batches[batch_id]['_queued_tracks'] = tracks download_batches[batch_id]['_queued_playlist_id'] = playlist_id @@ -29006,7 +27505,7 @@ def _promote_queued_batches(): with tasks_lock: active_analysis_count = sum(1 for b in download_batches.values() if b.get('phase') == 'analysis') - if active_analysis_count >= 3: + if active_analysis_count >= MAX_CONCURRENT_ANALYSIS: return # Find batches waiting in queue, ordered by creation (dict insertion order) for bid, batch in list(download_batches.items()): @@ -29018,7 +27517,7 @@ def _promote_queued_batches(): logger.info(f"[Queue] Promoting batch {bid} ('{batch.get('playlist_name')}') from queued -> analysis") analysis_executor.submit(_run_full_missing_tracks_process, bid, queued_pid, queued_tracks) active_analysis_count += 1 - if active_analysis_count >= 3: + if active_analysis_count >= MAX_CONCURRENT_ANALYSIS: break @@ -31142,6 +29641,8 @@ def start_playlist_missing_downloads(playlist_id): 'active_count': 0, 'max_concurrent': _get_max_concurrent(), 'queue_index': 0, + 'playlist_id': playlist_id, + 'playlist_name': playlist_name, # Track state management (replicating sync.py) 'permanently_failed_tracks': [], 'cancelled_tracks': set(), @@ -32271,11 +30772,17 @@ def _check_batch_completion_v2(batch_id): except Exception: pass - # Push discover playlists to media server after downloads complete + # Push playlists to media server after downloads complete playlist_id = batch.get('playlist_id') - if playlist_id and playlist_id.startswith('discover_'): + _push_prefixes = ( + 'discover_', 'auto_mirror_', 'youtube_mirrored_', + 'youtube_', 'tidal_', 'deezer_', 'spotify_public_', + 'listenbrainz_', 'beatport_', + ) + if playlist_id and playlist_id.startswith(_push_prefixes): + get_database().update_sync_history_push_status(batch_id, 'pending') threading.Thread( - target=_push_discover_playlist_to_server, + target=_push_playlist_to_server, args=(batch_id, batch), daemon=True ).start() @@ -32753,9 +31260,9 @@ def _record_sync_history_completion(batch_id, batch): completed_count = 0 failed_count = len(batch.get('permanently_failed_tracks', [])) - logger.warning(f"[SyncHistory] Recording completion for batch {batch_id}: " - f"analysis_results={len(analysis_results)}, tracks_found={tracks_found}, " - f"queue_len={len(queue)}, failed={failed_count}") + logger.info(f"[SyncHistory] Recording completion for batch {batch_id}: " + f"analysis_results={len(analysis_results)}, tracks_found={tracks_found}, " + f"queue_len={len(queue)}, failed={failed_count}") # Build download status map: track_index → status download_status_map = {} @@ -32767,8 +31274,8 @@ def _record_sync_history_completion(batch_id, batch): if task.get('status') == 'completed': completed_count += 1 - logger.warning(f"[SyncHistory] Batch {batch_id}: completed_downloads={completed_count}, " - f"download_status_map_size={len(download_status_map)}") + logger.info(f"[SyncHistory] Batch {batch_id}: completed_downloads={completed_count}, " + f"download_status_map_size={len(download_status_map)}") # Build per-track results from analysis track_results = [] @@ -32810,12 +31317,12 @@ def _record_sync_history_completion(batch_id, batch): db = MusicDatabase() updated = db.update_sync_history_completion(batch_id, tracks_found, completed_count, failed_count) - logger.warning(f"[SyncHistory] DB update for batch {batch_id}: updated={updated}") + logger.info(f"[SyncHistory] DB update for batch {batch_id}: updated={updated}") # Save per-track results if track_results: tr_updated = db.update_sync_history_track_results(batch_id, json.dumps(track_results)) - logger.warning(f"[SyncHistory] Track results saved for batch {batch_id}: updated={tr_updated}, count={len(track_results)}") + logger.info(f"[SyncHistory] Track results saved for batch {batch_id}: updated={tr_updated}, count={len(track_results)}") except Exception as e: logger.warning(f"Failed to record sync history completion: {e}") @@ -32823,18 +31330,40 @@ def _record_sync_history_completion(batch_id, batch): traceback.print_exc() -def _push_discover_playlist_to_server(batch_id, batch): - """After a discover batch completes, push the playlist to the media server. - Runs in a background thread. Triggers a scan, waits, then searches for tracks and creates/updates the playlist.""" +def _push_playlist_to_server(batch_id, batch): + """After a batch completes, push the playlist to the active media server. + Runs in a background thread. Triggers a scan, waits, then searches for tracks and creates/updates the playlist. + Supports Navidrome, Plex, and Jellyfin.""" + database = get_database() try: + # Resolve the active media server client + active_server = config_manager.get_active_media_server() + server_client = None + if active_server == 'navidrome' and navidrome_client and navidrome_client.is_connected(): + server_client = navidrome_client + elif active_server == 'plex' and plex_client and plex_client.is_connected(): + server_client = plex_client + elif active_server == 'jellyfin' and jellyfin_client and jellyfin_client.is_connected(): + server_client = jellyfin_client + + if not server_client: + logger.info(f"[PlaylistPush] Server push skipped — no connected media server (active: '{active_server}')") + database.update_sync_history_push_status(batch_id, 'skipped') + return + playlist_id = batch.get('playlist_id', '') playlist_name = batch.get('playlist_name', '') if not playlist_name: + logger.info(f"[PlaylistPush] No playlist_name for batch {batch_id} - skipping server push") + database.update_sync_history_push_status(batch_id, 'skipped') return + database.update_sync_history_push_status(batch_id, 'pushing') + analysis_results = batch.get('analysis_results', []) if not analysis_results: - logger.info(f"[DiscoverPush] No analysis results for {playlist_name} - skipping server push") + logger.info(f"[PlaylistPush] No analysis results for {playlist_name} - skipping server push") + database.update_sync_history_push_status(batch_id, 'skipped') return # Build list of tracks that should be in the playlist (found in library OR successfully downloaded) @@ -32867,59 +31396,57 @@ def _push_discover_playlist_to_server(batch_id, batch): }) if not tracks_to_find: - logger.info(f"[DiscoverPush] No tracks to push for {playlist_name}") + logger.info(f"[PlaylistPush] No tracks to push for {playlist_name}") + database.update_sync_history_push_status(batch_id, 'skipped') return - logger.info(f"[DiscoverPush] {playlist_name}: {len(tracks_to_find)} tracks to push to server, triggering scan first") + logger.info(f"[PlaylistPush] {playlist_name}: {len(tracks_to_find)} tracks to push to {active_server}, triggering scan first") # Trigger a library scan so newly downloaded tracks are indexed - if navidrome_client and navidrome_client.is_connected(): - navidrome_client.trigger_library_scan() - elif hasattr(web_scan_manager, 'request_scan'): - web_scan_manager.request_scan(f"Discover playlist push: {playlist_name}") + server_client.trigger_library_scan() # Wait for scan to finish (poll every 5s, up to 90s) - if navidrome_client and navidrome_client.is_connected(): - for _ in range(18): - time.sleep(5) - if not navidrome_client.is_library_scanning(): - break - logger.info(f"[DiscoverPush] Scan complete, searching for tracks") - else: - time.sleep(30) + for _ in range(18): + time.sleep(5) + if not server_client.is_library_scanning(): + break + logger.info(f"[PlaylistPush] Scan complete, searching for tracks on {active_server}") # Search for each track on the media server matched_server_tracks = [] - if navidrome_client and navidrome_client.is_connected(): - for t in tracks_to_find: - results = navidrome_client.search_tracks(t['title'], t['artist'], limit=5) - if results: - # Use the first result's underlying NavidromeTrack for playlist creation - best = results[0] - nav_track = getattr(best, '_original_navidrome_track', None) - if nav_track: - matched_server_tracks.append(nav_track) - logger.debug(f"[DiscoverPush] Matched: '{t['title']}' by '{t['artist']}' → {best.id}") - else: - matched_server_tracks.append(best) - else: - logger.info(f"[DiscoverPush] No match for: '{t['title']}' by '{t['artist']}'") + for t in tracks_to_find: + results = server_client.search_tracks(t['title'], t['artist'], limit=5) + if results: + best = results[0] + # Navidrome/Plex store the original server object for playlist creation + original = getattr(best, '_original_navidrome_track', None) or getattr(best, '_original_plex_track', None) + matched_server_tracks.append(original if original else best) + logger.debug(f"[PlaylistPush] Matched: '{t['title']}' by '{t['artist']}' → {getattr(best, 'id', getattr(best, 'ratingKey', '?'))}") + else: + logger.info(f"[PlaylistPush] No match for: '{t['title']}' by '{t['artist']}'") if not matched_server_tracks: - logger.warning(f"[DiscoverPush] No tracks matched on server for {playlist_name}") + logger.warning(f"[PlaylistPush] No tracks matched on {active_server} for {playlist_name}") + database.update_sync_history_push_status(batch_id, 'failed') return - logger.info(f"[DiscoverPush] Pushing {len(matched_server_tracks)}/{len(tracks_to_find)} tracks to '{playlist_name}' on server") - success = navidrome_client.update_playlist(playlist_name, matched_server_tracks) + logger.info(f"[PlaylistPush] Pushing {len(matched_server_tracks)}/{len(tracks_to_find)} tracks to '{playlist_name}' on {active_server}") + success = server_client.update_playlist(playlist_name, matched_server_tracks) if success: - logger.info(f"[DiscoverPush] Successfully pushed '{playlist_name}' to server with {len(matched_server_tracks)} tracks") + logger.info(f"[PlaylistPush] Successfully pushed '{playlist_name}' to {active_server} with {len(matched_server_tracks)} tracks") + database.update_sync_history_push_status(batch_id, 'success') else: - logger.warning(f"[DiscoverPush] Failed to push '{playlist_name}' to server") + logger.warning(f"[PlaylistPush] Failed to push '{playlist_name}' to {active_server}") + database.update_sync_history_push_status(batch_id, 'failed') except Exception as e: - logger.error(f"[DiscoverPush] Error pushing playlist to server: {e}") + logger.error(f"[PlaylistPush] Error pushing playlist to server: {e}") import traceback traceback.print_exc() + try: + database.update_sync_history_push_status(batch_id, 'failed') + except Exception: + pass # =============================== @@ -33661,6 +32188,8 @@ def start_missing_downloads(): 'active_count': 0, 'max_concurrent': _get_max_concurrent(), 'queue_index': 0, + 'playlist_id': playlist_id, + 'playlist_name': 'Legacy Modal', # Track state management (replicating sync.py) 'permanently_failed_tracks': [], 'cancelled_tracks': set(), @@ -44157,17 +42686,41 @@ def _auto_sync_discover_playlists(profile_id, active_source): 'duration_ms': t.duration_ms or 0 }) elif ptype == 'seasonal_playlist': - from core.seasonal_discovery import SeasonalDiscoveryService - seasonal_svc = SeasonalDiscoveryService(database) - season_data = seasonal_svc.get_current_season_playlist() - if season_data and season_data.get('tracks'): - tracks = [{ - 'id': t.get('spotify_track_id', ''), - 'name': t.get('track_name', ''), - 'artists': [t.get('artist_name', '')], - 'album': t.get('album_name', ''), - 'duration_ms': t.get('duration_ms', 0) - } for t in season_data['tracks']] + from core.seasonal_discovery import get_seasonal_discovery_service, SEASONAL_CONFIG + seasonal_svc = get_seasonal_discovery_service(spotify_client, database) + current_season = seasonal_svc.get_current_season() + if current_season and current_season in SEASONAL_CONFIG: + track_ids = seasonal_svc.get_curated_seasonal_playlist(current_season, source=active_source) + if track_ids: + if active_source == 'itunes': + s_id_col = 'itunes_track_id' + elif active_source == 'deezer': + s_id_col = 'deezer_track_id' + else: + s_id_col = 'spotify_track_id' + with database._get_connection() as conn: + cursor = conn.cursor() + for tid in track_ids: + cursor.execute(f""" + SELECT {s_id_col} as track_id, track_name, artist_name, album_name, duration_ms + FROM seasonal_tracks WHERE {s_id_col} = ? AND source = ? + """, (tid, active_source)) + row = cursor.fetchone() + if not row: + cursor.execute(f""" + SELECT {s_id_col} as track_id, track_name, artist_name, album_name, duration_ms + FROM discovery_pool WHERE {s_id_col} = ? AND source = ? + """, (tid, active_source)) + row = cursor.fetchone() + if row: + r = dict(row) + tracks.append({ + 'id': r.get('track_id', ''), + 'name': r.get('track_name', ''), + 'artists': [r.get('artist_name', '')], + 'album': r.get('album_name', ''), + 'duration_ms': r.get('duration_ms', 0) + }) else: from core.personalized_playlists import PersonalizedPlaylistsService service = PersonalizedPlaylistsService(database) @@ -44180,7 +42733,7 @@ def _auto_sync_discover_playlists(profile_id, active_source): if ptype in method_map: raw_tracks = method_map[ptype](limit=50) tracks = [{ - 'id': t.get('spotify_track_id', ''), + 'id': t.get('track_id') or t.get('spotify_track_id') or t.get('deezer_track_id') or t.get('itunes_track_id') or '', 'name': t.get('track_name', ''), 'artists': [t.get('artist_name', '')], 'album': t.get('album_name', ''), @@ -44229,7 +42782,7 @@ def get_discover_synced_playlists(): try: with database._get_connection() as conn: pool_count = conn.execute( - "SELECT COUNT(*) FROM discovery_pool WHERE source = ?", (active_source,) + "SELECT COUNT(*) FROM discovery_pool WHERE source = ? AND profile_id = ?", (active_source, pid) ).fetchone()[0] except Exception: pool_count = 0 @@ -44246,19 +42799,20 @@ def get_discover_synced_playlists(): curated_ids = database.get_curated_playlist(ptype, profile_id=pid) track_count = len(curated_ids) if curated_ids else 0 elif ptype == 'seasonal_playlist': - from core.seasonal_discovery import SeasonalDiscoveryService + from core.seasonal_discovery import get_seasonal_discovery_service try: - seasonal_svc = SeasonalDiscoveryService(database) - season_data = seasonal_svc.get_current_season_playlist() - track_count = len(season_data.get('tracks', [])) if season_data else 0 + seasonal_svc = get_seasonal_discovery_service(spotify_client, database) + current_season = seasonal_svc.get_current_season() + if current_season: + curated = seasonal_svc.get_curated_seasonal_playlist(current_season, source=active_source) + track_count = len(curated) if curated else 0 + else: + track_count = 0 except Exception: track_count = 0 else: # Personalized playlists come from the discovery pool - # familiar_favorites is not implemented — always report 0 - if ptype == 'familiar_favorites': - track_count = 0 - elif pool_count > 0: + if pool_count > 0: track_count = min(50, pool_count) else: track_count = 0 @@ -44354,10 +42908,13 @@ def manage_discover_auto_update(): settings[key] = bool(val) return jsonify({"success": True, "settings": settings}) - data = request.get_json() + data = request.get_json(silent=True) or {} playlist_type = data.get('playlist_type') enabled = data.get('enabled', False) + if not playlist_type: + return jsonify({"success": False, "error": "Missing playlist_type"}), 400 + is_lb_type = playlist_type and playlist_type.startswith('listenbrainz_') if playlist_type not in valid_types and not is_lb_type: return jsonify({"success": False, "error": f"Invalid playlist type: {playlist_type}"}), 400 @@ -44460,7 +43017,14 @@ def get_current_seasonal_playlist(): if not track_ids: return jsonify({"success": True, "tracks": []}) - track_id_col = 'spotify_track_id' if active_source == 'spotify' else 'itunes_track_id' + # itunes stores IDs in itunes_track_id; all other sources + # Each source stores IDs in its own column + if active_source == 'itunes': + track_id_col = 'itunes_track_id' + elif active_source == 'deezer': + track_id_col = 'deezer_track_id' + else: + track_id_col = 'spotify_track_id' tracks = [] with database._get_connection() as conn: cursor = conn.cursor() @@ -44589,8 +43153,13 @@ def get_seasonal_playlist(season_key): if not track_ids: return jsonify({"success": True, "tracks": []}) - # Use source-appropriate ID column for lookups - track_id_col = 'spotify_track_id' if active_source == 'spotify' else 'itunes_track_id' + # Each source stores IDs in its own column + if active_source == 'itunes': + track_id_col = 'itunes_track_id' + elif active_source == 'deezer': + track_id_col = 'deezer_track_id' + else: + track_id_col = 'spotify_track_id' # Fetch track details from seasonal tracks or discovery pool (filtered by source) tracks = [] @@ -45830,8 +44399,7 @@ def image_proxy(): url = request.args.get('url', '') if not url or not url.startswith('http'): return '', 400 - # Only allow known image CDNs - from urllib.parse import urlparse + host = urlparse(url).hostname or '' allowed_hosts = [ 'i.scdn.co', 'mosaic.scdn.co', # Spotify @@ -45840,8 +44408,9 @@ def image_proxy(): 'is1-ssl.mzstatic.com', 'is2-ssl.mzstatic.com', 'is3-ssl.mzstatic.com', 'is4-ssl.mzstatic.com', 'is5-ssl.mzstatic.com', # iTunes/Apple 'img.discogs.com', 'i.discogs.com', # Discogs + 'localhost', '127.0.0.1', 'host.docker.internal', # Local/Docker media servers ] - if not any(host == h or host.endswith('.' + h) for h in allowed_hosts): + if not any(host == h or host.endswith('.' + h) for h in allowed_hosts) and not _is_internal_image_host(url): return '', 403 try: resp = requests.get(url, timeout=10, stream=True, headers={ diff --git a/webui/index.html b/webui/index.html index 4105959c..f61f9122 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5,10 +5,13 @@ SoulSync - Music Sync & Manager - - - - + + + + + + + @@ -270,7 +273,7 @@
- +
@@ -1872,23 +1875,10 @@ - -
- -
- - -
-
+ +
@@ -2001,10 +1991,6 @@ placeholder="Search for artists, albums, or tracks...">
- @@ -2030,8 +2016,9 @@ @@ -7881,28 +7884,33 @@ - + - + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + +
@@ -7979,10 +7987,10 @@ ? - - - - + + + + \ No newline at end of file diff --git a/webui/static/discover.js b/webui/static/discover.js index e056c772..8ecbf6de 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -278,6 +278,12 @@ function displayDiscoverHeroArtist(artist) { if (discographyBtn && artistId) { discographyBtn.setAttribute('data-artist-id', artistId); discographyBtn.setAttribute('data-artist-name', artist.artist_name); + // Source the click handler will pass to navigateToArtistDetail. Without + // this, source-only hero artists (which is the typical case — they + // come from discover similar-artists, not the library) get looked up + // as library IDs and 404. Backend always includes artist.source. + if (artist.source) discographyBtn.setAttribute('data-source', artist.source); + else discographyBtn.removeAttribute('data-source'); // Also store both IDs for cross-source operations if (artist.spotify_artist_id) discographyBtn.setAttribute('data-spotify-id', artist.spotify_artist_id); if (artist.itunes_artist_id) discographyBtn.setAttribute('data-itunes-id', artist.itunes_artist_id); @@ -815,14 +821,18 @@ async function viewDiscoverHeroDiscography() { const artistId = button.getAttribute('data-artist-id'); const artistName = button.getAttribute('data-artist-name'); + // Pass the source so /api/artist-detail knows to synthesize from that + // metadata provider instead of doing a local DB lookup. Hero similar + // artists are almost always source-only (not in the library). + const source = button.getAttribute('data-source') || null; if (!artistId || !artistName) { console.error('No artist data found for discography view'); return; } - console.log(`🎵 Navigating to artist detail for: ${artistName}`); - navigateToArtistDetail(artistId, artistName); + console.log(`🎵 Navigating to artist detail for: ${artistName} (source: ${source || 'library'})`); + navigateToArtistDetail(artistId, artistName, source); } function showDiscoverHeroEmpty() { @@ -7849,88 +7859,9 @@ function checkForActiveDiscoverDownloads() { } async function startDiscoverPlaylistSync(playlistType, playlistName) { - console.log(`🔄 Starting sync for ${playlistName}`); + console.log(`🔄 Starting sync for ${playlistName} (fire-and-forget from Discover page)`); - // Get tracks based on playlist type - let tracks = []; - if (playlistType === 'release_radar') { - tracks = discoverReleaseRadarTracks; - } else if (playlistType === 'discovery_weekly') { - tracks = discoverWeeklyTracks; - } else if (playlistType === 'seasonal_playlist') { - tracks = discoverSeasonalTracks; - } else if (playlistType === 'popular_picks') { - tracks = personalizedPopularPicks; - } else if (playlistType === 'hidden_gems') { - tracks = personalizedHiddenGems; - } else if (playlistType === 'discovery_shuffle') { - tracks = personalizedDiscoveryShuffle; - } else if (playlistType === 'familiar_favorites') { - tracks = personalizedFamiliarFavorites; - } else if (playlistType === 'build_playlist') { - tracks = buildPlaylistTracks; - } - - if (!tracks || tracks.length === 0) { - showToast(`No tracks available for ${playlistName}`, 'warning'); - return; - } - - // Convert to format expected by sync API - const spotifyTracks = tracks.map(track => { - let spotifyTrack; - - // Use track_data_json if available - if (track.track_data_json) { - spotifyTrack = track.track_data_json; - } else { - // Fallback: construct track object - spotifyTrack = { - id: track.spotify_track_id, - name: track.track_name, - artists: [{ name: track.artist_name }], - album: { - name: track.album_name, - images: track.album_cover_url ? [{ url: track.album_cover_url }] : [] - }, - duration_ms: track.duration_ms || 0 - }; - } - - // Normalize artists to array of strings for sync compatibility - if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) { - spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a); - } - - return spotifyTrack; - }); - - // Create virtual playlist ID - const virtualPlaylistId = `discover_${playlistType}`; - - // Store in cache for sync function - playlistTrackCache[virtualPlaylistId] = spotifyTracks; - - // Create virtual playlist object - const virtualPlaylist = { - id: virtualPlaylistId, - name: playlistName, - track_count: spotifyTracks.length - }; - - // Add to spotify playlists array if not already there - if (!spotifyPlaylists.find(p => p.id === virtualPlaylistId)) { - spotifyPlaylists.push(virtualPlaylist); - } - - // Show sync status display (convert underscores to hyphens for ID) - const statusId = playlistType.replace(/_/g, '-') + '-sync-status'; - const statusDisplay = document.getElementById(statusId); - if (statusDisplay) { - statusDisplay.style.display = 'block'; - } - - // Disable sync button to prevent duplicate syncs (convert underscores to hyphens for ID) + // Disable the sync button on the Discover page const buttonId = playlistType.replace(/_/g, '-') + '-sync-btn'; const syncButton = document.getElementById(buttonId); if (syncButton) { @@ -7939,23 +7870,104 @@ async function startDiscoverPlaylistSync(playlistType, playlistName) { syncButton.style.cursor = 'not-allowed'; } - // Start sync using existing function - await startPlaylistSync(virtualPlaylistId); - - // Extract image URL from first track for download bar bubble - let imageUrl = null; - if (spotifyTracks && spotifyTracks.length > 0) { - const firstTrack = spotifyTracks[0]; - if (firstTrack.album && firstTrack.album.images && firstTrack.album.images.length > 0) { - imageUrl = firstTrack.album.images[0].url; + try { + // Fetch tracks from API + const apiUrl = _discoverPlaylistApiUrl(playlistType); + if (!apiUrl) { + showToast(`Unknown playlist type: ${playlistType}`, 'error'); + if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; } + return; } + + const tracksResponse = await fetch(apiUrl); + let tracks = []; + if (tracksResponse.ok) { + const data = await tracksResponse.json(); + tracks = data.tracks || []; + } + + if (!tracks.length) { + showToast(`No tracks available for ${playlistName}`, 'warning'); + if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; } + return; + } + + // Convert to sync format + const syncTracks = tracks.map(track => { + if (track.track_data_json) { + const t = track.track_data_json; + if (t.artists && Array.isArray(t.artists)) { + t.artists = t.artists.map(a => a.name || a); + } + return t; + } + return { + id: track.spotify_track_id || track.track_id || '', + name: track.track_name || track.name || '', + artists: [track.artist_name || 'Unknown Artist'], + album: track.album_name || '', + duration_ms: track.duration_ms || 0, + image_url: track.album_cover_url || track.image_url || '' + }; + }); + + const virtualPlaylistId = `discover_${playlistType}`; + + // Fire the batch download + const batchResponse = await fetch(`/api/playlists/${virtualPlaylistId}/start-missing-process`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tracks: syncTracks, playlist_name: playlistName }) + }); + + const result = await batchResponse.json(); + if (result.success) { + // Show toast with clickable link to Sync → Discover tab + _showSyncToastWithLink( + `${playlistName} (${syncTracks.length} tracks) syncing...`, + 'info', + 'View in Sync \u2192', + () => navigateToSyncTab('discover', { highlight: `discover-sync-card-${playlistType}` }) + ); + } else { + showToast(`Failed to start sync: ${result.error || 'Unknown error'}`, 'error'); + if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; } + } + } catch (error) { + console.error(`Error syncing ${playlistName}:`, error); + showToast(`Failed to sync ${playlistName}`, 'error'); + if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; } } +} - // Add to discover download bar - addDiscoverDownload(virtualPlaylistId, playlistName, playlistType, imageUrl); +/** + * Show a toast with a clickable action link (like showToast but with a custom link). + */ +function _showSyncToastWithLink(message, type, linkText, onClick) { + const container = document.getElementById('toast-container'); + if (!container) { showToast(message, type); return; } - // Start polling for progress updates - startDiscoverSyncPolling(playlistType, virtualPlaylistId); + const icon = { success: '\u2705', error: '\u274c', warning: '\u26a0\ufe0f', info: '\u2139\ufe0f' }[type] || '\u2139\ufe0f'; + const toast = document.createElement('div'); + toast.className = `toast-compact toast-${type}`; + toast.innerHTML = `${icon}${_escToast(message)}`; + + const link = document.createElement('span'); + link.className = 'toast-compact-link'; + link.textContent = linkText; + link.onclick = e => { e.stopPropagation(); onClick(); }; + toast.appendChild(link); + + toast.onclick = () => { toast.classList.add('toast-exit'); setTimeout(() => { if (container.contains(toast)) container.removeChild(toast); }, 200); }; + container.appendChild(toast); + requestAnimationFrame(() => toast.classList.add('toast-enter')); + + setTimeout(() => { + if (container.contains(toast)) { + toast.classList.add('toast-exit'); + setTimeout(() => { if (container.contains(toast)) container.removeChild(toast); }, 300); + } + }, 6000); } // Track active discover sync pollers @@ -9098,17 +9110,17 @@ function renderDiscoverSyncCard(playlist, container, sourceLabel) { const trackLabel = isEmpty ? 'No tracks yet' : `${playlist.track_count} tracks`; card.innerHTML = ` -
${playlist.icon}
+
${_esc(playlist.icon)}
-
${playlist.name} +
${_esc(playlist.name)} - ${sourceLabel || 'unknown'} + ${_esc(sourceLabel || 'unknown')} \u00b7 - ${trackLabel} + ${_esc(trackLabel)} \u00b7 - ${statusText} + ${_esc(statusText)} \u00b7 - ${lastSyncedText} + ${_esc(lastSyncedText)}
@@ -9116,19 +9128,43 @@ function renderDiscoverSyncCard(playlist, container, sourceLabel) {
-
+
`; + // Bind event listeners instead of inline handlers (avoids XSS from playlist names) + const autoUpdateToggle = card.querySelector('.discover-auto-update-toggle'); + if (autoUpdateToggle) { + autoUpdateToggle.addEventListener('change', function() { + toggleDiscoverAutoUpdate(playlist.type, this.checked); + }); + } + + const anyQualityToggle = card.querySelector('.discover-any-quality-toggle'); + if (anyQualityToggle) { + anyQualityToggle.id = `discover-any-quality-${playlist.type}`; + } + + const syncButton = card.querySelector('.discover-sync-btn'); + if (syncButton) { + syncButton.id = `discover-sync-btn-${playlist.type}`; + syncButton.addEventListener('click', () => syncDiscoverPlaylistFromTab(playlist.type, playlist.name)); + } + // Make the icon + info area clickable to view tracks if (!isEmpty) { const clickArea = card.querySelector('.discover-sync-card-info'); @@ -9193,18 +9229,21 @@ async function _doSyncDiscoverPlaylist(playlistType, playlistName) { } try { - let tracksResponse; - - // Use unified URL helper (handles ListenBrainz + standard discover types) - const apiUrl = _discoverPlaylistApiUrl(playlistType); - if (apiUrl) { - tracksResponse = await fetch(apiUrl); - } - let tracks = []; - if (tracksResponse && tracksResponse.ok) { - const data = await tracksResponse.json(); - tracks = data.tracks || []; + + if (playlistType === 'build_playlist') { + // Build Playlist tracks are assembled client-side; no API endpoint. + tracks = (typeof buildPlaylistTracks !== 'undefined' && buildPlaylistTracks) || []; + } else { + // Use unified URL helper (handles ListenBrainz + standard discover types) + const apiUrl = _discoverPlaylistApiUrl(playlistType); + if (apiUrl) { + const tracksResponse = await fetch(apiUrl); + if (tracksResponse.ok) { + const data = await tracksResponse.json(); + tracks = data.tracks || []; + } + } } if (!tracks.length) { @@ -9235,14 +9274,15 @@ async function _doSyncDiscoverPlaylist(playlistType, playlistName) { // Use the download batch endpoint directly so the batch is labeled // as "Discover" instead of going through sync → wishlist → "Wishlist" batch. - // Omit force_download_all so it checks the library first and only downloads missing tracks. + const bodyPayload = { + tracks: syncTracks, + playlist_name: playlistName + }; + const batchResponse = await fetch(`/api/playlists/${virtualPlaylistId}/start-missing-process`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - tracks: syncTracks, - playlist_name: playlistName - }) + body: JSON.stringify(bodyPayload) }); const result = await batchResponse.json(); @@ -9315,7 +9355,23 @@ function pollDiscoverSyncFromTab(playlistType, virtualPlaylistId, playlistName) } function pollDiscoverBatchFromTab(playlistType, batchId, playlistName) { + // Clear any existing poller for this playlist type + if (discoverSyncPollers[playlistType]) { + clearInterval(discoverSyncPollers[playlistType]); + delete discoverSyncPollers[playlistType]; + } + + let ticks = 0; + const maxTicks = 600; // 30 min at 3s intervals + const pollInterval = setInterval(async () => { + ticks++; + // Stall guard — stop polling after maxTicks or if the card is no longer in DOM + if (ticks > maxTicks || !document.getElementById(`discover-sync-card-${playlistType}`)) { + clearInterval(pollInterval); + delete discoverSyncPollers[playlistType]; + return; + } try { const resp = await fetch(`/api/playlists/${batchId}/download_status`); if (!resp.ok) { clearInterval(pollInterval); return; } @@ -9324,6 +9380,7 @@ function pollDiscoverBatchFromTab(playlistType, batchId, playlistName) { if (phase === 'complete' || phase === 'error' || phase === 'cancelled') { clearInterval(pollInterval); + delete discoverSyncPollers[playlistType]; const btn = document.getElementById(`discover-sync-btn-${playlistType}`); if (btn) { btn.disabled = false; btn.textContent = '\u27f3 Sync Now'; } @@ -9370,8 +9427,12 @@ function pollDiscoverBatchFromTab(playlistType, batchId, playlistName) { } } catch (error) { clearInterval(pollInterval); + delete discoverSyncPollers[playlistType]; } }, 3000); + + // Register so page-leave cleanup can clear it + discoverSyncPollers[playlistType] = pollInterval; } /** diff --git a/webui/static/downloads.js b/webui/static/downloads.js index f13bfb2e..1720038b 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -5013,33 +5013,57 @@ function _gsClickVideo(cardEl) { // GLOBAL SEARCH BAR — Spotlight-style search from anywhere // ================================================================================== +// Popover-only state. Query/source/cache/config all live in `_gsController` +// (shared with the Search page via createSearchController in shared-helpers.js). const _gsState = { active: false, - query: '', - data: null, - sources: {}, - activeSource: null, - abortCtrl: null, - altAbortCtrl: null, + _lastInteraction: 0, debounceTimer: null, }; +// Shared source-picker controller — built on DOM-ready in `_doInit`. +let _gsController = null; + (function initGlobalSearch() { // Defer init until DOM is ready const _doInit = () => { const bar = document.getElementById('gsearch-bar'); const input = document.getElementById('gsearch-input'); const results = document.getElementById('gsearch-results'); - if (!input || !bar) return; + if (!input || !bar || !results) return; + + // Build the stable results-panel structure up front so the controller + // has a sourceRow element to render into on its first _notify(). + results.innerHTML = ` +
+ +
+ `; + + _gsController = createSearchController({ + sourceRowElement: document.getElementById('gsearch-source-row'), + iconClassPrefix: 'gsearch', + onStateChange: _gsRenderFromState, + onSoulseekSelected: (query) => _gsNavigateToSearchPage(query, 'soulseek'), + onUnconfiguredClick: (src) => { + _gsDeactivate(); + openSettingsForSource(src); + }, + }); bar.addEventListener('click', () => input.focus()); input.addEventListener('focus', () => { bar.classList.add('active'); + const aura = document.getElementById('gsearch-aura'); + if (aura) aura.classList.add('active'); _gsState.active = true; const shortcut = document.getElementById('gsearch-shortcut'); if (shortcut) shortcut.style.display = 'none'; - if (_gsState.data && _gsState.query) _gsShowResults(); + // Always redraw on focus so the source icon row is current + // (cache dots, active state, etc.). init() is a no-op after the + // first call — safe to invoke on every focus. + _gsController.init().then(() => _gsRenderFromState(_gsController.state)); }); // No blur handler — closing is handled by click-outside and Escape only @@ -5049,20 +5073,26 @@ const _gsState = { input.addEventListener('input', () => { const q = input.value.trim(); - _gsState.query = q; if (clearBtn) clearBtn.style.display = q.length > 0 ? '' : 'none'; if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer); if (q.length < 2) { _gsHideResults(); return; } - _gsState.debounceTimer = setTimeout(() => _gsPerformSearch(q), 300); + _gsState.debounceTimer = setTimeout(() => _gsController.submitQuery(q), 300); }); if (clearBtn) { clearBtn.addEventListener('click', e => { e.stopPropagation(); input.value = ''; - _gsState.query = ''; - _gsState.data = null; clearBtn.style.display = 'none'; + // Drop cache so the next search starts clean, but don't + // auto-fire a fetch for an empty query. + if (_gsController) { + _gsController.state.query = ''; + _gsController.state.sources = {}; + _gsController.state.fallbacks = {}; + _gsController.state.loadingSources = new Set(); + _gsController.renderSourceRow(); + } _gsHideResults(); input.focus(); }); @@ -5073,7 +5103,7 @@ const _gsState = { e.preventDefault(); if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer); const q = input.value.trim(); - if (q.length >= 2) _gsPerformSearch(q); + if (q.length >= 2) _gsController.submitQuery(q); } else if (e.key === 'Escape') { _gsDeactivate(); input.blur(); @@ -5116,18 +5146,22 @@ const _gsState = { function _gsUpdateVisibility() { const bar = document.getElementById('gsearch-bar'); + const aura = document.getElementById('gsearch-aura'); if (!bar) return; // Hide on the Search page where the unified search already exists. Accept the // legacy 'downloads' id for callers that predate the page rename. const onSearchPage = typeof currentPage !== 'undefined' && (currentPage === 'search' || currentPage === 'downloads'); bar.style.display = onSearchPage ? 'none' : ''; + if (aura) aura.classList.toggle('hidden', onSearchPage); if (onSearchPage && _gsState.active) _gsDeactivate(); } function _gsDeactivate() { const bar = document.getElementById('gsearch-bar'); + const aura = document.getElementById('gsearch-aura'); const shortcut = document.getElementById('gsearch-shortcut'); if (bar) bar.classList.remove('active'); + if (aura) aura.classList.remove('active'); if (shortcut) shortcut.style.display = ''; _gsState.active = false; _gsHideResults(); @@ -5143,113 +5177,116 @@ function _gsShowResults() { if (r && r.innerHTML.trim()) r.classList.add('visible'); } -async function _gsPerformSearch(query) { - if (_gsState.abortCtrl) _gsState.abortCtrl.abort(); - if (_gsState.altAbortCtrl) _gsState.altAbortCtrl.abort(); - _gsState.abortCtrl = new AbortController(); - _gsState.altAbortCtrl = new AbortController(); +function _gsNavigateToSearchPage(query, src) { + _gsDeactivate(); + if (typeof navigateToPage !== 'function') return; + navigateToPage('search'); + // After the page mounts, mirror the query into whichever input drives the + // requested source. Soulseek goes through the basic-search file flow, not + // the enhanced metadata flow — without this branch the Search page would + // run /api/enhanced-search instead of /api/search and the user would get + // metadata results when they clicked the Soulseek icon. + setTimeout(() => { + if (src === 'soulseek') { + const basicInput = document.getElementById('downloads-search-input'); + if (basicInput && query) basicInput.value = query; - const results = document.getElementById('gsearch-results'); - if (!results) return; - - results.innerHTML = '
Searching...
'; - results.classList.add('visible'); - - try { - const data = await enhancedSearchFetch(query, { signal: _gsState.abortCtrl.signal }); - _gsState.data = data; - _gsState.activeSource = data.primary_source || 'spotify'; - _gsState.sources = {}; - _gsState.sources[_gsState.activeSource] = { - artists: data.spotify_artists || [], - albums: data.spotify_albums || [], - tracks: data.spotify_tracks || [], - }; - - _gsRender(data); - - // Async library ownership check — adds badges + swaps play buttons for library tracks - setTimeout(() => _gsLibraryCheck(), 200); - - // Fetch alternate sources — stream NDJSON so slow sources render incrementally - const alts = data.alternate_sources || []; - for (const src of alts) { - if (src === _gsState.activeSource) continue; - _gsFetchSourceStream(src, query); - } - } catch (e) { - if (e.name !== 'AbortError') results.innerHTML = '
Search failed
'; - } -} - -async function _gsFetchSourceStream(src, query) { - try { - const res = await fetch(`/api/enhanced-search/source/${src}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }), - signal: _gsState.altAbortCtrl.signal, - }); - if (!res.ok) return; - - if (!_gsState.sources[src]) { - const loadingSet = src === 'youtube_videos' ? new Set(['videos']) : new Set(['artists', 'albums', 'tracks']); - _gsState.sources[src] = { artists: [], albums: [], tracks: [], videos: [], available: true, _loading: loadingSet }; - } - const sourceData = _gsState.sources[src]; - - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - - let idx; - while ((idx = buffer.indexOf('\n')) !== -1) { - const line = buffer.slice(0, idx).trim(); - buffer = buffer.slice(idx + 1); - if (!line) continue; - try { - const chunk = JSON.parse(line); - if (chunk.type === 'artists') { sourceData.artists = chunk.data; if (sourceData._loading) sourceData._loading.delete('artists'); } - else if (chunk.type === 'albums') { sourceData.albums = chunk.data; if (sourceData._loading) sourceData._loading.delete('albums'); } - else if (chunk.type === 'tracks') { sourceData.tracks = chunk.data; if (sourceData._loading) sourceData._loading.delete('tracks'); } - else if (chunk.type === 'videos') { sourceData.videos = chunk.data; if (sourceData._loading) sourceData._loading.delete('videos'); } - if (chunk.type === 'done') delete sourceData._loading; - _gsRenderTabs(); - // Re-render content if this is the active source tab - if (_gsState.activeSource === src && _gsState.data) { - _gsRender(_gsState.data); - } - } catch (e) { } + // Sync the Search page controller's state.query to the widget's + // query BEFORE clicking the Soulseek icon. Otherwise the icon + // click fires onSoulseekSelected(state.query) where state.query + // is whatever the user last typed on /search (often stale), and + // the callback would overwrite basicInput.value with that stale + // value before running performDownloadsSearch. + if (typeof _searchPageController !== 'undefined' && _searchPageController) { + _searchPageController.state.query = query || ''; } + + const soulseekIcon = document.querySelector('#enh-source-row [data-source="soulseek"]'); + if (soulseekIcon) { + soulseekIcon.click(); + return; + } + // Fallback: controller hasn't initialized yet (slow /api/settings + // fetches at first /search visit). Run the search directly + swap + // sections so the user still gets results. Icon row will catch up + // visually on the next render. + const basicSection = document.getElementById('basic-search-section'); + const enhancedSection = document.getElementById('enhanced-search-section'); + if (basicSection) basicSection.classList.add('active'); + if (enhancedSection) enhancedSection.classList.remove('active'); + if (basicInput && basicInput.value && typeof performDownloadsSearch === 'function') { + performDownloadsSearch(); + } + return; } - _gsRenderTabs(); - } catch (e) { - if (e.name !== 'AbortError') console.debug(`GS alt source ${src} failed:`, e); - } + const input = document.getElementById('enhanced-search-input'); + if (input && query) { + input.value = query; + input.dispatchEvent(new Event('input', { bubbles: true })); + } + }, 300); } -function _gsRender(data) { +// Re-render the results body + fallback banner whenever the controller's +// state changes (cache hit, fetch settle, query reset). The icon row itself +// is rendered by the controller into `#gsearch-source-row`. +function _gsRenderFromState(state) { const results = document.getElementById('gsearch-results'); - if (!results) return; + const body = document.getElementById('gsearch-body'); + if (!results || !body) return; - // Music Videos tab — render video grid instead of regular results - if (_gsState.activeSource === 'youtube_videos') { - const src = _gsState.sources['youtube_videos'] || {}; - const videos = src.videos || []; - const isLoading = src._loading && src._loading.size > 0; - let h = ''; - h += `
Results${videos.length} videos
`; - h += '
'; + // Fallback banner — independent of body content. + const banner = document.getElementById('gsearch-fallback-banner'); + const activeSrc = state.activeSource; + const actual = state.fallbacks[activeSrc]; + if (banner) { + if (actual && actual !== activeSrc) { + const clicked = (SOURCE_LABELS[activeSrc] || {}).text || activeSrc; + const served = (SOURCE_LABELS[actual] || {}).text || actual; + banner.textContent = `${clicked} unavailable — showing ${served}.`; + banner.classList.remove('hidden'); + } else { + banner.classList.add('hidden'); + } + } + + // Soulseek has its own dedicated handler (navigate to /search); there's + // nothing to render in the popover. + if (activeSrc === 'soulseek') return; + + const cached = state.sources[activeSrc]; + const isLoading = state.loadingSources.has(activeSrc); + const query = state.query; + + // No query yet — prompt. + if (!query) { + body.innerHTML = '
Type to search…
'; + results.classList.add('visible'); + return; + } + + // In-flight, nothing cached yet — loading state. + if (isLoading && !cached) { + const info = SOURCE_LABELS[activeSrc]; + body.innerHTML = `
Searching ${_escToast((info && info.text) || activeSrc)}...
`; + results.classList.add('visible'); + return; + } + + // No cache, not loading — source switch before fetch fired (e.g. empty query). + if (!cached) { + body.innerHTML = '
Click the source above to search.
'; + results.classList.add('visible'); + return; + } + + // Music Videos — video grid instead of regular sections. + if (activeSrc === 'youtube_videos') { + const videos = cached.videos || []; + let h = `
Results${videos.length} videos
`; h += '
'; - if (isLoading) { - h += '
Searching YouTube...
'; - } else if (videos.length === 0) { - h += `
No music videos found for "${_escToast(_gsState.query)}"
`; + if (videos.length === 0) { + h += `
No music videos found for "${_escToast(query)}"
`; } else { h += '
🎬 Music Videos
'; h += '
'; @@ -5268,74 +5305,61 @@ function _gsRender(data) { h += '
'; } h += '
'; - results.innerHTML = h; + body.innerHTML = h; results.classList.add('visible'); - _gsRenderTabs(); return; } - const src = _gsState.sources[_gsState.activeSource] || {}; - const loading = src._loading || new Set(); - const dbArtists = data?.db_artists || []; - const artists = src.artists || []; - const allAlbums = src.albums || []; + // Standard metadata source — library + artists + albums + singles + tracks. + const dbArtists = cached.db_artists || []; + const artists = cached.artists || []; + const allAlbums = cached.albums || []; const albums = allAlbums.filter(a => !a.album_type || a.album_type === 'album' || a.album_type === 'compilation'); const singles = allAlbums.filter(a => a.album_type === 'single' || a.album_type === 'ep'); - const tracks = src.tracks || []; + const tracks = cached.tracks || []; const total = dbArtists.length + artists.length + albums.length + singles.length + tracks.length; - const isLoading = loading.size > 0; - if (total === 0 && !isLoading) { - results.innerHTML = `
No results for "${_escToast(_gsState.query)}"
Try different keywords or check spelling
`; + if (total === 0) { + body.innerHTML = `
No results for "${_escToast(query)}"
Try different keywords or check spelling
`; results.classList.add('visible'); return; } - const sourceLabels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase', youtube_videos: 'Music Videos', musicbrainz: 'MusicBrainz' }; - const srcLabel = sourceLabels[_gsState.activeSource] || _gsState.activeSource || ''; + const srcLabel = (SOURCE_LABELS[activeSrc] || {}).text || activeSrc || ''; let h = ''; h += `
Results${total} items
`; - h += '
'; h += '
'; if (dbArtists.length) { h += '
📚 In Your Library
'; - h += dbArtists.map(a => `
${a.image_url ? `` : '🎤'}
${_escToast(a.name)}
Library
`).join(''); + h += dbArtists.map(a => `
${a.image_url ? `` : '🎤'}
${_escToast(a.name)}
Library
`).join(''); h += '
'; } if (artists.length) { h += `
🎤 Artists ${srcLabel}
`; - h += artists.map(a => `
${a.image_url ? `` : '🎤'}
${_escToast(a.name)}
`).join(''); + h += artists.map(a => `
${a.image_url ? `` : '🎤'}
${_escToast(a.name)}
`).join(''); h += '
'; - } else if (loading.has('artists')) { - h += `
🎤 Artists ${srcLabel}
Loading artists...
`; } - const activeSrc = _gsState.activeSource || 'spotify'; - if (albums.length) { h += `
💿 Albums ${srcLabel}
`; h += albums.map(a => { const ar = a.artist || (a.artists ? a.artists.join(', ') : ''); const yr = a.release_date ? a.release_date.substring(0, 4) : ''; const img = (a.image_url || '').replace(/'/g, "\\'"); - return `
${a.image_url ? `` : '💿'}
${_escToast(a.name)}
${_escToast(ar)}${yr ? ` · ${yr}` : ''}
`; + return `
${a.image_url ? `` : '💿'}
${_escToast(a.name)}
${_escToast(ar)}${yr ? ` · ${yr}` : ''}
`; }).join(''); h += '
'; } - if (!albums.length && !singles.length && loading.has('albums')) { - h += `
💿 Albums ${srcLabel}
Loading albums...
`; - } - if (singles.length) { h += `
🎶 Singles & EPs ${srcLabel}
`; h += singles.map(a => { const ar = a.artist || (a.artists ? a.artists.join(', ') : ''); const img = (a.image_url || '').replace(/'/g, "\\'"); - return `
${a.image_url ? `` : '🎶'}
${_escToast(a.name)}
${_escToast(ar)}
`; + return `
${a.image_url ? `` : '🎶'}
${_escToast(a.name)}
${_escToast(ar)}
`; }).join(''); h += '
'; } @@ -5345,20 +5369,22 @@ function _gsRender(data) { h += tracks.map(t => { const ar = t.artist || (t.artists ? t.artists.join(', ') : ''); const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : ''; - return `
${t.image_url ? `` : '🎵'}
${_escToast(t.name)}
${_escToast(ar)}${t.album ? ` · ${_escToast(t.album)}` : ''}
${dur}
`; + return `
${t.image_url ? `` : '🎵'}
${_escToast(t.name)}
${_escToast(ar)}${t.album ? ` · ${_escToast(t.album)}` : ''}
${dur}
`; }).join(''); h += '
'; - } else if (loading.has('tracks')) { - h += `
🎵 Tracks ${srcLabel}
Loading tracks...
`; } h += '
'; - results.innerHTML = h; + body.innerHTML = h; results.classList.add('visible'); - _gsRenderTabs(); - // Lazy load artist images for sources that don't provide them (iTunes/Deezer) + // Lazy load artist images for sources that don't provide them (iTunes/Deezer). _gsLazyLoadArtistImages(); + + // Library ownership check — adds "In Library" badges + swaps play buttons. + // Idempotent enough to run on every render with a cache hit; the old flow + // also fired it on both cache-hit and fetch-settle. + setTimeout(() => _gsLibraryCheck(), 200); } async function _gsLazyLoadArtistImages() { @@ -5366,66 +5392,31 @@ async function _gsLazyLoadArtistImages() { if (!grid) return; const cards = grid.querySelectorAll('[data-needs-image="true"]'); if (cards.length === 0) return; - const activeSrc = _gsState.activeSource || 'spotify'; + const activeSrc = (_gsController && _gsController.state.activeSource) || 'spotify'; for (const card of cards) { const artistId = card.dataset.artistId; if (!artistId) continue; try { - const res = await fetch(`/api/artist/${artistId}/image?source=${activeSrc}`); + // Pass the artist name so MusicBrainz lookups (which have no + // artist art) can resolve the image by name on a fallback source. + const params = new URLSearchParams({ source: activeSrc }); + if (card.dataset.artistName) params.set('name', card.dataset.artistName); + const res = await fetch(`/api/artist/${artistId}/image?${params}`); const data = await res.json(); if (data.success && data.image_url) { const artDiv = card.querySelector('.gsearch-item-art'); - if (artDiv) artDiv.innerHTML = ``; + if (artDiv) artDiv.innerHTML = ``; card.removeAttribute('data-needs-image'); } } catch (e) { /* ignore */ } } } -function _gsRenderTabs() { - const el = document.getElementById('gsearch-tabs'); - if (!el) return; - const sources = Object.keys(_gsState.sources); - const labels = { - spotify: 'Spotify', - itunes: 'Apple Music', - deezer: 'Deezer', - discogs: 'Discogs', - hydrabase: 'Hydrabase', - youtube_videos: 'Music Videos', - musicbrainz: 'MusicBrainz', - }; - const visibleSources = sources.filter(s => { - const d = _gsState.sources[s] || {}; - const count = s === 'youtube_videos' - ? (d.videos?.length || 0) - : (d.artists?.length || 0) + (d.albums?.length || 0) + (d.tracks?.length || 0); - const isLoading = !!(d._loading && d._loading.size > 0); - return isLoading || count > 0 || s === _gsState.activeSource; - }); - if (visibleSources.length < 2) { el.style.display = 'none'; return; } - el.style.display = 'flex'; - el.innerHTML = visibleSources.map(s => { - const d = _gsState.sources[s]; - const c = s === 'youtube_videos' - ? (d.videos?.length || 0) - : (d.artists?.length || 0) + (d.albums?.length || 0) + (d.tracks?.length || 0); - return ``; - }).join(''); -} - -function _gsSwitchSource(src) { - _gsState._lastInteraction = Date.now(); - _gsState.activeSource = src; - _gsRender(_gsState.data); - const input = document.getElementById('gsearch-input'); - if (input) input.focus(); -} - function _gsClickArtist(id, name, isLibrary) { _gsDeactivate(); - const source = isLibrary ? null : (_gsState.activeSource || null); + const activeSource = _gsController && _gsController.state.activeSource; + const source = isLibrary ? null : (activeSource || null); navigateToArtistDetail(id, name, source); } @@ -5557,7 +5548,8 @@ async function _gsPlayTrack(trackName, artistName, albumName) { // Async library check for global search results — adds badges + swaps play buttons async function _gsLibraryCheck() { try { - const src = _gsState.sources[_gsState.activeSource] || {}; + if (!_gsController) return; + const src = _gsController.state.sources[_gsController.state.activeSource] || {}; const allAlbums = src.albums || []; const albums = allAlbums.filter(a => !a.album_type || a.album_type === 'album' || a.album_type === 'compilation'); const singles = allAlbums.filter(a => a.album_type === 'single' || a.album_type === 'ep'); @@ -5760,27 +5752,26 @@ async function showVersionInfo() { } catch (e) { /* ignore */ } } + // Build version data straight from helper.js — single source of truth. + // No backend round-trip; the changelog content is shipped in the same + // bundle the browser already loaded. + const version = (typeof _getCurrentVersion === 'function') + ? _getCurrentVersion() + : (btn ? btn.textContent.trim().replace('v', '') : ''); + const sections = (typeof VERSION_MODAL_SECTIONS !== 'undefined') + ? VERSION_MODAL_SECTIONS + : []; + const versionData = { + version, + title: "What's New in SoulSync", + subtitle: version ? `Version ${version} — Latest Changes` : 'Latest Changes', + sections, + }; + try { - console.log('Fetching version info...'); - - // Fetch version data from API - const response = await fetch('/api/version-info'); - if (!response.ok) { - throw new Error('Failed to fetch version info'); - } - - const versionData = await response.json(); - console.log('Version data received:', versionData); - - // Populate modal content populateVersionModal(versionData, hadUpdate ? updateInfo : null); - - // Show modal const modalOverlay = document.getElementById('version-modal-overlay'); - modalOverlay.classList.remove('hidden'); - - console.log('Version modal opened'); - + if (modalOverlay) modalOverlay.classList.remove('hidden'); } catch (error) { console.error('Error showing version info:', error); showToast('Failed to load version information', 'error'); diff --git a/webui/static/helper.js b/webui/static/helper.js index 62d17da5..4b8a260a 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -917,34 +917,31 @@ const HELPER_CONTENT = { description: 'Search for music across your configured metadata sources and download from Soulseek, YouTube, Tidal, Qobuz, HiFi, or Deezer.', docsId: 'search' }, - '.search-source-picker-container': { - title: 'Search From', - description: 'Pick which metadata source to search. "All sources (Auto)" keeps the multi-source fan-out behavior; any specific source hits only that provider. "Soulseek (raw files)" switches to raw P2P file search with quality filters.', + '#enh-source-row': { + title: 'Search Source Icons', + description: 'Each icon is a metadata source. The highlighted one is what your next search will target — defaults to your configured primary source on page load. Click a different icon to search or switch to that source; a small dot on the icon marks sources that already have cached results for the current query.', tips: [ - 'Auto: searches your configured primary source plus library matches', - 'Spotify / Apple Music / Deezer / Discogs / Hydrabase / MusicBrainz: metadata-only results for that provider', - 'Soulseek: raw file results with format, bitrate, size, uploader — same as the old Basic Search' + 'Typing searches only the highlighted source — no more silent fan-out across every provider', + 'Switching to an already-cached source is instant, no re-fetch', + 'The Soulseek icon routes to the raw-file search (same as the old Basic Search)', + 'Music Videos queries YouTube for downloadable music video files', + 'An amber border on a source means the backend fell back to a different provider for you (usually because Spotify is rate-limited)' ], docsId: 'search-enhanced' }, // Enhanced Search '.enhanced-search-input-wrapper': { - title: 'Enhanced Search', - description: 'Type an artist, album, or track name. Results appear in categorized sections: Library Artists, Artists, Albums, Singles & EPs, and Tracks. Results come from your active metadata source.', + title: 'Search Bar', + description: 'Type an artist, album, or track name. Results appear in categorized sections: Library Artists, Artists, Albums, Singles & EPs, and Tracks. Only the source highlighted in the icon row above is queried — click another icon to switch.', tips: [ 'Click an album to open the download modal', 'Click a track to search your download source', 'Play button previews tracks from your download source', - 'Multi-source tabs compare results across Spotify, iTunes, and Deezer' + 'Switch sources via the icon row above — results are cached per query' ], docsId: 'search-enhanced' }, - '.enh-source-tabs': { - title: 'Source Tabs', - description: 'Switch between metadata sources to see results from Spotify, iTunes, or Deezer. Each source has its own catalog — tracks missing on one may be found on another.', - docsId: 'search-enhanced' - }, '#enh-db-artists-section': { title: 'Library Artists', description: 'Artists from your local music library that match the search. Click to view their collection on the Library page.', @@ -1291,11 +1288,8 @@ const HELPER_CONTENT = { title: 'Similar Artist', description: 'An artist similar to the one you\'re viewing. Click to load their discography and browse their releases.', }, - '.search-source-picker-container': { - title: 'Search Source', - description: 'Pick which metadata source the Search page queries. "All sources (Auto)" fans out across configured providers (the legacy default); pick a specific source to constrain the lookup. "Soulseek (raw files)" routes to the file-search pipeline that used to be the Basic mode.', - docsId: 'search' - }, + // (Search source picker annotation lives under `#enh-source-row` above — + // the old `.search-source-picker-container` dropdown is gone.) // ─── AUTOMATIONS PAGE ───────────────────────────────────────────── @@ -2408,7 +2402,7 @@ const HELPER_TOURS = { description: 'Step-by-step guide to downloading your first album.', icon: '⬇️', steps: [ - { page: 'search', selector: '.search-source-picker-container', title: 'Pick a Search Source', description: '"All sources (Auto)" fans out across every provider. Pick a specific one (Spotify, Apple Music, Deezer, etc.) to get results from just that catalog. "Soulseek (raw files)" is the old Basic mode — raw P2P file results with quality filters.' }, + { page: 'search', selector: '#enh-source-row', title: 'Pick a Search Source', description: 'Each icon is a metadata source. The highlighted one is where your next search goes — defaults to your configured primary source. Click a different icon to switch to Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz, Music Videos, or Soulseek (raw P2P files). A small dot marks sources you\'ve already searched for the current query.' }, { page: 'search', selector: '.enhanced-search-input-wrapper', title: 'Search for Music', description: 'Type an artist or album name here. Results appear in categorized sections — Artists, Albums, Singles/EPs, and Tracks. Try searching for your favorite artist now!' }, { page: 'search', selector: '#enh-results-container', title: 'Search Results', description: 'After searching, results appear organized by type: Artists at the top as cards, then Albums, Singles/EPs, and individual Tracks. "In Library" badges mark items you already own.' }, { page: 'search', selector: '.enhanced-search-input-wrapper', title: 'Downloading an Album', description: 'Click any album card to open the download modal. You\'ll see the tracklist, quality options, and a big "Download Album" button. Individual tracks have a play button to preview before downloading.' }, @@ -3443,20 +3437,49 @@ function closeHelperSearch() { // ═══════════════════════════════════════════════════════════════════════════ // Entries tagged with `unreleased: true` are accumulating under a version label -// but won't display until the build version catches up. The Search/Artists -// unification project stays folded here at 2.40 until the whole thing ships. +// but won't display until the build version catches up — used for in-progress +// 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.40': [ - // --- Search & Artists unification (in progress, not yet released) --- - { date: 'Unreleased — Search & Artists unification', unreleased: true }, - { title: 'Search Source Picker', desc: 'The Search page\'s Enhanced/Basic toggle is replaced by a single "Search from" dropdown at the top — pick All sources (Auto), Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz, or Soulseek (raw files). Auto keeps today\'s multi-source fan-out; picking a specific source hits only that provider so there are no more surprise Spotify rate-limit hits from flows that didn\'t need Spotify. "Soulseek" routes to the raw-file search (what "Basic" used to do), so one picker now covers both old modes. Loading text reflects the selected source', page: 'search', unreleased: true }, - { title: 'Explicit Source Selection on /api/enhanced-search', desc: 'The enhanced-search endpoint now accepts an optional `source` body param (spotify, itunes, deezer, discogs, hydrabase, musicbrainz, auto). When a specific source is chosen, only that provider is queried and db_artists (local library matches) still come back. Cache keys isolate per-source so single-source and multi-source results don\'t collide. Omitted or `auto` preserves the old multi-source fan-out behavior unchanged — nothing breaks for existing callers', page: 'search', unreleased: true }, - { title: 'Shared Enhanced-Search Fetch Helper', desc: 'Internal refactor — the Search page dropdown and the global search widget now route through one shared enhancedSearchFetch helper in search.js instead of duplicating the POST boilerplate. Zero UX change, but it means any future source-picker tweak only needs wiring in one place', page: 'search', unreleased: true }, - { title: 'Search Page Renamed to /search', desc: 'The Search page\'s internal id is now "search" instead of the confusing "downloads" (which clashed with the actual Downloads page). Sidebar label unchanged. URL is now /search; /downloads still resolves so old bookmarks keep working. Profile ACL "Page Access" now saves as "search"; existing profiles with "downloads" in allowed_pages still resolve through a legacy-compat check', page: 'search', unreleased: true }, - { title: 'Embedded Download Manager Removed from Search Page', desc: 'The Search page used to carry a second copy of the Download Manager (active + finished queues, clear/cancel-all buttons) that was hidden by default and duplicated the dedicated Downloads page. That duplicate is gone — toggle button, side-panel HTML, and its 1-second polling loop all removed. About 330 lines of dead code cleaned up. The dedicated Downloads sidebar page is now the single downloads UI', page: 'search', unreleased: true }, - { title: 'Artists Sidebar Entry Retired — Use Search Instead', desc: 'Cin flagged that "Artists" in the sidebar read like a library section but was actually a dedicated artist-search page, duplicating what the unified Search already does. The sidebar entry is gone. New flow: Sidebar → Search → type artist name → click their result. "Browse Artists" on the empty Watchlist page and "View artist from Wishlist" now open Search pre-filled with the artist\'s name. Removed "Artists" from profile Home Page + Page Access options. Deep link to /artists still resolves so old bookmarks keep working — the page just isn\'t promoted anywhere', page: 'search', unreleased: true }, - { title: 'Artist Detail Back Button Fallback', desc: 'The back button on the Artists-page inline detail view used to dump users on an empty "Search for an artist..." screen when they arrived from outside the Artists page — a dead end now that Artists isn\'t in the sidebar. If you searched inside the Artists page, back still returns to your results list. Otherwise (arriving from Search, Discover, Watchlist, etc.), back uses the browser history to land you on whichever page you came from. Falls back to the Search page only when there\'s no browser history to go back to (the natural place to find another artist)', page: 'search', unreleased: true }, - { title: 'Interactive Help Updated for Unified Search', desc: 'The click-for-help annotations and the "Your First Download" guided tour were rewritten for the new Search page. Stale annotations pointing at removed elements (Basic/Enhanced toggle button, side-panel queues, download-manager controls) are deleted. The first-download tour now runs on /search and opens with the source picker. PAGE_TOUR_MAP accepts both "search" and the legacy "downloads" id so old bookmarks still match a tour. Retired the standalone "Browse Artists" tour', page: 'help', unreleased: true }, + '2.4.1': [ + // --- post-2.4.0 dev work — entries hidden by _getLatestWhatsNewVersion until the build version bumps --- + { date: 'Unreleased — 2.4.1 dev cycle' }, + { title: 'Lock Down Socket.IO CORS', desc: 'socket.io was accepting websocket connections from any origin (cors=*). now defaults to same-origin only. if your websocket fails after updating, the server logs a clear warning with the rejected origin — add it to settings → security → allowed websocket origins.', page: 'settings' }, + { title: 'Faster Docker Startup — yt-dlp Pinned', desc: 'docker startup used to run `pip install -U yt-dlp` on every container start. removed that — yt-dlp is now pinned in requirements.txt so startup is fast and reproducible. tradeoff: youtube fixes ship via soulsync releases now instead of next container restart.' }, + { title: 'Settings Endpoints: Admin-Only', desc: 'the /api/settings endpoints (read, write, log-level, config-status, verify) had no auth gate — any logged-in profile could read or change service tokens, oauth secrets, api keys. now admin-only. single-admin setups (no multi-profile config) work transparently as before.', page: 'settings' }, + { title: 'Browser Caching for Static Assets + Discover Pages', desc: 'static assets (js/css/icons) now get a 1-year browser cache instead of revalidating on every page load. safe because the existing ?v=static_v cache-bust query changes every server restart, so deploys still ship live. discover pages (hero, similar artists, recent releases, deep cuts, etc.) now cache 5 minutes browser-side so toggling between sections doesn\'t re-fetch everything. faster repeat loads, fewer round-trips.', page: 'discover' }, + { title: 'Service Worker for Cover Art + Installable PWA', desc: 'cover art used to re-fetch from the cdn on every library / discover page visit. now a service worker caches images locally — second visit serves art instantly from disk, no network hit. also added a pwa manifest so soulsync can be installed to home screen / desktop as a standalone app (chrome / edge / safari → install soulsync). cache versioned so future strategy changes invalidate cleanly.' }, + ], + '2.4.0': [ + // --- April 26, 2026 — Search & Artists unification + reorganize queue --- + { date: 'April 26, 2026 — 2.4.0 release' }, + { title: 'Reorganize Queue Polish', desc: 'cleaned up some race conditions in the reorganize queue. cancel + bulk dedupe behavior is solid now. preview button no longer gets stuck disabled on errors.', page: 'library' }, + { title: 'Reorganize Queue with Live Status Panel', desc: 'reorganize is now a queue with a live status panel. spam-click all you want — items run one at a time and you can keep browsing while they go. expand the panel to see queue + cancel buttons.', page: 'library' }, + { title: 'Album Completeness Job Actually Works', desc: 'completeness job was finding zero issues for everyone. now it works — uses real expected track counts from your metadata source instead of comparing your library to itself.', page: 'library' }, + { title: 'Reorganize Routes Through the Download Pipeline', desc: 'reorganize now uses the same pipeline downloads use. fixes 3-disc albums collapsing to single-disc and tracks silently disappearing on you. extracted to core/library_reorganize.py.', page: 'library' }, + { title: 'Spotify: Longer Post-Ban Cooldown', desc: 'bumped the post-ban cooldown from 5 to 30 minutes. first call after a ban was getting re-banned within seconds because spotify\'s memory outlasts the cooldown.', page: 'dashboard' }, + { title: 'Tidal: No More Silent Quality Downgrades', desc: 'tidal was silently serving 320kbps when you asked for hires. now it rejects the downgrade and the fallback chain advances properly — or fails honestly if you have "hires only, no fallback" set.', page: 'downloads' }, + { title: 'Search Source Picker Icon Row', desc: 'search page now has a row of source icons above the bar — one per source. typing only searches the active source instead of fanning out to all of them. click another icon to switch.', page: 'search' }, + { title: 'Per-Query Source Cache', desc: 'switching back to a source you already searched is instant — results are cached for the current query. cache resets when you type a new query. ~6-7x fewer api calls per search.', page: 'search' }, + { title: 'Global Search Widget Source Parity', desc: 'the sidebar global search popover got the same source icon row + cache dots + fallback banner as the full search page.', page: 'search' }, + { title: 'Rate-Limit Fallback Banner', desc: 'if the backend swaps your selected source for a working one (e.g. spotify rate-limited → deezer), you get a small amber banner explaining the swap. icon for the failed source gets an amber border.', page: 'search' }, + { title: 'Explicit Source Selection on /api/enhanced-search', desc: 'enhanced-search endpoint takes a source param now to skip the fan-out backend-side. cache keys isolate per-source so single and multi-source results don\'t collide.', page: 'search' }, + { title: 'Shared Enhanced-Search Fetch Helper', desc: 'internal — search dropdown and global widget share one fetch helper now instead of duplicating the post boilerplate.', page: 'search' }, + { title: 'Search Page Renamed to /search', desc: 'search page is now /search instead of the confusing /downloads (which clashed with the actual downloads page). old urls still work.', page: 'search' }, + { title: 'Embedded Download Manager Removed from Search Page', desc: 'killed the duplicate download manager on the search page (~330 lines of dead code). dedicated downloads page is the only one now.', page: 'search' }, + { title: 'Artists Sidebar Entry Retired', desc: 'removed the artists sidebar entry — unified search already does what it did. old /artists urls still resolve.', page: 'search' }, + { title: 'Artist Detail Back Button Fallback', desc: 'back button on inline artist detail uses browser history when you arrived from outside the artists page, instead of dumping you on an empty artists search.', page: 'search' }, + { title: 'Interactive Help Updated for Unified Search', desc: 'rewrote the click-for-help annotations and the first-download tour for the new search page. retired the standalone browse-artists tour.', page: 'help' }, + { title: 'Unified Source-Picker Controller', desc: 'internal — search page and global widget share one controller now (~380 lines of duplicate state/fetch/render code gone). bug fixes land everywhere at once.', page: 'search' }, + { title: 'Fix Clean Search History Automation Crashing', desc: 'hourly clean-search-history automation was crashing on a stale base_url path. fixed.', page: 'stats' }, + { title: 'Search Results Always Visible', desc: 'killed the show/hide results toggle. visibility is just based on whether you\'ve typed a query.', page: 'search' }, + { title: 'Cached Search Results Restore on Navigate-Back', desc: 'leaving and coming back to /search now re-renders your last query\'s results from cache instead of hiding them.', page: 'search' }, + { title: 'Fix Soulseek Handoff from Global Search', desc: 'clicking soulseek in the global search popover used to run metadata search against your default source instead of basic file search. fixed.', page: 'search' }, + { title: 'Stale Search Requests No Longer Flash Empty', desc: 'fast retypes used to flash an empty state for a moment while the new fetch was still mid-flight. added a request-sequence token so old responses don\'t clobber new ones.', page: 'search' }, + { title: 'Soulseek Icon Dims When slskd Isn\'t Configured', desc: 'soulseek icon dims if you don\'t have slskd set up. clicking it routes to settings → downloads instead of failing silently.', page: 'search' }, + { title: 'Fix Discover Hero View Discography 404', desc: 'view discography on the discover hero was 404ing for non-library artists. fixed by passing the source through to /api/artist-detail.', page: 'discover' }, + { title: 'MusicBrainz Search Actually Works', desc: 'musicbrainz search was returning empty/garbage results and taking 30+ seconds. rewrote it — artist, track, and album searches all work now and complete in ~3 seconds on cold cache.', page: 'search' }, + { title: 'MusicBrainz Search Follow-Ups', desc: 'three more musicbrainz fixes — artist images now resolve via itunes/deezer fallback, total_tracks off-by-one fixed, and "artist title" queries no longer browse the whole discography.', page: 'search' }, ], '2.39': [ // --- April 22, 2026 --- @@ -3655,20 +3678,329 @@ const WHATS_NEW = { ], }; +// ═══════════════════════════════════════════════════════════════════════════ +// VERSION MODAL — curated highlight reel +// ═══════════════════════════════════════════════════════════════════════════ +// +// `WHATS_NEW` above is the per-version detailed log used by the "What's New" +// helper-popover panel — short one-liners, internal page links, every entry +// shown on every browse-back through versions. +// +// `VERSION_MODAL_SECTIONS` (this block) is the curated highlight reel shown +// when the user clicks the version button in the sidebar. It's NOT a +// mechanical view of WHATS_NEW — it's editorial curation: bigger-picture +// sections, bullet-list expansions, optional "usage" hints at the bottom. +// Some sections aggregate across multiple WHATS_NEW entries ("Recent Fixes", +// "Earlier in v2.3"); some don't have a 1:1 WHATS_NEW counterpart at all. +// +// Both consts live here so a release editor only opens one file. At release +// time: +// 1. Add the per-version block to `WHATS_NEW` (one entry per shipped item). +// 2. Promote any items worth a modal-section into `VERSION_MODAL_SECTIONS` +// at the top of the array (latest highlights lead). +// 3. Roll older sections down or merge them into a "Recent Fixes" / +// "Earlier in vX.Y" aggregator section as they age out of the spotlight. +// +// Section shape: { title, description, features: [bullet strings], +// usage_note?: 'optional hint shown at the bottom' } +const VERSION_MODAL_SECTIONS = [ + { + title: "Reorganize Queue Polish", + description: "cleaned up some race conditions in the queue. behavior is solid now.", + features: [ + "• worker pick + status flip is atomic now — cancel can\'t land between them and let a cancelled item still run", + "• swapped lock + wakeup-event for a single threading.Condition — newly-queued items don\'t sleep up to 60s anymore", + "• bulk enqueue dedupes within a single batch (was only deduping against pre-existing items)", + "• reorganize-preview Apply button no longer gets stuck disabled on errors", + "• db helpers let exceptions bubble instead of swallowing them as \"album not found\"", + ], + }, + { + title: "Reorganize Queue with Live Status Panel", + description: "reorganize is now a queue with a live status panel. spam-click all you want — items run one at a time and you can keep browsing.", + features: [ + "• per-album reorganize and reorganize all both enqueue into a single backend queue", + "• buttons stay clickable — clicking the same album twice silently dedupes", + "• status panel shows active progress, queued count, and recent finishes", + "• expand the panel for the full queue + per-item cancel buttons (running items can\'t be cancelled mid-flight)", + "• cross-artist items get tagged so you know what\'s queued from where", + "• continue-on-failure: one bad album never stalls the queue", + "• reorganize all is now one backend call instead of N js-driven calls — way faster", + ], + }, + { + title: "Fix Wrong-Artist Tracks Silently Downloading", + description: "searching for a track could silently download a completely different artist\'s song with the same name. fixed at two layers.", + features: [ + "• example: \"maduk — leave a light on\" on tidal was downloading tom walker\'s song of the same name with maduk\'s metadata embedded", + "• tightened the candidate artist gate (was letting through 0.4 similarity, now blocks at 0.5)", + "• acoustid verification now FAILs (quarantines) clear artist mismatches instead of accepting them", + "• ambiguous matches (covers, collabs) still get the benefit of the doubt — only obvious mismatches get blocked", + ], + }, + { + title: "Tidal Search Falls Back on Long Queries", + description: "tidal\'s search chokes on long remix-credit queries. now retries with shorter variants when the original returns 0 results.", + features: [ + "• example: \"maduk transformations remixed fire away fred v remix\" returned 0 — falls back to shorter queries until tidal finds the track", + "• up to 4 shortened variants tried, capped at 5 total requests", + "• qualifier-safe: live/remix/acoustic searches only accept fallback results that keep the qualifier", + "• returns empty if no variant preserves the qualifiers — same as before", + ], + }, + { + title: "Manual Discovery Fixes Persist Across Restart", + description: "manual discovery fixes are now saved under your active metadata source instead of always \"spotify\" — so deezer / itunes / discogs / hydrabase users\' fixes survive restart.", + features: [ + "• affects tidal, deezer, spotify public, youtube, and discovery pool manual fixes", + "• matches how the auto-discovery worker already saved", + "• spotify-primary users unaffected (hardcoded value matched their source)", + ], + }, + { + title: "Watchlist Content Filters Fixed", + description: "global override and live-version detection now behave the way the ui implies.", + features: [ + "• scheduled auto-watchlist honors watchlist → global override (was bypassing it)", + "• live detection tightened — no more false positives on titles like \"what we live for\"", + "• same fix applies to the library maintenance live/commentary cleaner", + "• still catches (live), - live, live at/from/in/on, unplugged, in concert", + ], + }, + { + title: "Discography Backfill", + description: "new maintenance job that scans each artist\'s full discography and finds what you\'re missing.", + features: [ + "• scans each library artist against your metadata source", + "• creates findings for missing tracks — review and add to wishlist", + "• respects all content filters (live, remix, acoustic, etc.) and release type filters", + "• optional auto-add-to-wishlist setting for hands-off operation", + "• opt-in, runs weekly, processes up to 50 artists per run", + ], + }, + { + title: "Repair 'Run Now' Honored While Paused", + description: "force-running a repair job no longer stalls forever when the master worker is paused.", + features: [ + "• jobs queued via run now complete even if the master worker is paused", + "• fixes silent stalls where the job logged \"scanning 50 artists\" then did nothing", + "• master-pause still blocks scheduled runs — only affects user-triggered runs", + ], + }, + { + title: "Multi-Artist Tagging", + description: "more control over how multiple artists are written to audio file tags.", + features: [ + "• configurable separator: comma, semicolon, or slash", + "• multi-value ARTISTS tag for navidrome / jellyfin multi-artist linking", + "• \"move featured artists to title\" mode — primary in ARTIST tag, others as (feat. ...) in title", + "• opt-in, defaults match current behavior", + ], + }, + { + title: "Enriched Downloads Page", + description: "download cards now show rich metadata instead of just filenames.", + features: [ + "• album artwork thumbnail on each card", + "• artist name, album name, source badge", + "• quality badge appears after post-processing", + "• falls back gracefully for transfers without metadata context", + ], + }, + { + title: "Template Variable Delimiters", + description: "use ${var} syntax to append literal text to template variables.", + features: [ + "• ${albumtype}s produces \"Albums\", \"Singles\", \"EPs\"", + "• both $var and ${var} syntaxes work everywhere", + "• validation updated to accept delimited variables", + ], + }, + { + title: "Reorganize All Albums", + description: "bulk reorganize all albums for an artist from the enhanced library view.", + features: [ + "• new reorganize all button in the artist header", + "• processes sequentially with progress toasts", + "• continues on error — one failed album doesn\'t block the rest", + "• uses the same template + endpoint as per-album reorganize", + ], + }, + { + title: "SoulSync Standalone Library", + description: "use soulsync without plex, jellyfin, or navidrome — manage your library directly.", + features: [ + "• new standalone server option in settings → connections", + "• downloads and imports write to the library db immediately", + "• pre-populated enrichment ids — workers skip re-discovery", + "• deep scan finds untracked files and removes stale db records", + "• sync page hidden automatically in standalone mode", + "• full library / artist detail / discography all work standalone", + ], + usage_note: "settings → connections → standalone. no media server needed.", + }, + { + title: "Auto-Import", + description: "background folder watcher that automatically identifies and imports music into your library.", + features: [ + "• recursive scan — any folder depth (artist/album/tracks, loose files, whatever)", + "• tag-based identification preferred, acoustid fingerprinting as fallback", + "• stats bar, filter pills, scan now, approve all, clear history", + "• expandable per-track match details with confidence scores", + "• race condition fix prevents duplicate processing on multi-track albums", + ], + usage_note: "import page → auto tab. set your import folder in settings.", + }, + { + title: "Wishlist Nebula", + description: "wishlist redesigned as an interactive artist orb visualization.", + features: [ + "• each artist is a glowing orb — albums and singles orbit around it", + "• click orbs to expand and download directly from the nebula", + "• live progress with spinning ring animation while processing", + "• stats strip up top: total artists, albums, singles, tracks", + ], + usage_note: "click wishlist in the sidebar.", + }, + { + title: "Automation Group Management", + description: "organize and manage automation groups properly.", + features: [ + "• rename, delete, and bulk-toggle groups from the group header", + "• drag-and-drop automations between groups", + "• delete confirmation shows group name and automation count", + ], + usage_note: "use the action buttons on group headers in the automations page.", + }, + { + title: "Bidirectional Artist Sync & Server Playlists", + description: "artist sync now goes both ways, and server playlists show full coverage.", + features: [ + "• artist sync pulls new content from your media server AND removes stale library entries", + "• deep scan mode fetches full metadata for newly-discovered tracks", + "• server playlist view shows all playlists with synced vs unsynced visual separation", + ], + }, + { + title: "Provider-Agnostic Discovery", + description: "discovery features work with any configured metadata source instead of requiring spotify.", + features: [ + "• similar artist matching, discovery pool, and incremental updates use source priority", + "• falls back through spotify, itunes, deezer in configured order", + "• musicmap url encoding fixed for artists with special characters", + "• freshness check simplified to age-based", + ], + }, + { + title: "Dashboard & Navigation", + description: "dashboard improvements and sidebar navigation enhancements.", + features: [ + "• library status card on dashboard — server state, track counts, scan buttons", + "• tools page in sidebar — maintenance tools moved out of the dashboard modal", + "• watchlist and wishlist promoted to full sidebar pages with live count badges", + "• acoustid scanner scans full library with retag / redownload / delete fix options", + ], + }, + { + title: "MusicBrainz & Metadata Fixes", + description: "critical tag embedding fix and picard-style album consistency.", + features: [ + "• source id tags (spotify, musicbrainz, deezer, audiodb) were silently skipped on every download — now embed correctly", + "• picard-style release preference scoring prevents navidrome album splits", + "• source tags wiped when metadata enhancement is skipped or fails", + "• spotify api no longer called when deezer/itunes is your primary source", + ], + }, + { + title: "Downloads & Soulseek Improvements", + description: "better download management, search accuracy, and queue control.", + features: [ + "• downloads batch panel — color-coded cards with progress, cancel, expand, 7-day history", + "• soulseek queries include album name now — fewer wrong-artist downloads", + "• reject results from various artists / unknown artist folders", + "• clearing wishlist cancels the active wishlist download batch", + "• album delete with \"delete files too\" option on enhanced library", + "• fix download modal freezing mid-download (m3u auto-save was exhausting server threads)", + "• fix unknown artist when adding playlist tracks to wishlist", + ], + }, + { + title: "Recent Fixes", + description: "smaller bug fixes from recent releases and community reports.", + features: [ + "• fix watchlist scan false failures — empty discography no longer reported as error", + "• fix deezer_artist_id column error on enhanced library sync", + "• fix wishlist button intermittently not navigating", + "• fix worker orb tooltips rendering behind dashboard content", + "• fix oauth callback port hardcoding — custom ports respected now", + "• fix allow duplicates and replace-lower-quality settings not saving", + "• fix wishlist dropping cross-album tracks when duplicates enabled", + "• fix spotify enrichment worker infinite loop on pre-matched artists", + "• reject qobuz 30-second sample/preview downloads", + "• auto wing-it fallback for failed discovery", + "• fix album track lookup hardcoded to spotify — uses configured primary now", + "• fix m3u showing all tracks as missing after post-processing", + "• fix acoustid retag not writing corrected tags to file", + "• fix downloads badge dropping to 300 after opening downloads page", + "• unmatch discovery tracks (red ✕ button)", + "• customizable music video naming with $artist, $title, $year", + "• fix soulseek log spam when not configured as download source", + ], + }, + { + title: "Earlier in v2.3", + description: "major features from earlier in this release cycle.", + features: [ + "• centralized downloads page with live-updating list and filter pills", + "• first-run setup wizard — 7-step guided configuration", + "• music videos — search and download from youtube", + "• inbound music request api for external tools (discord bots, home assistant)", + "• lidarr download source (in development) for usenet / torrent", + "• graceful shutdown — all workers respond to shutdown signals immediately", + "• unknown artist prevention with 3-tier metadata fallback", + "• deezer multi-artist tagging via contributors field", + "• artist map — watchlist constellation, genre map, artist explorer", + "• discogs integration — enrichment worker, fallback source, search tab", + "• wing it mode, global search bar, redesigned notifications", + "• server playlist manager, sync history dashboard, playlist explorer", + "• enhanced library manager with inline tag editing and write-to-file", + "• automation signals, multi-source search tabs, rich artist profiles", + ], + }, +]; + function _getCurrentVersion() { const btn = document.querySelector('.version-button'); - return btn ? btn.textContent.trim().replace('v', '') : '2.39'; + return btn ? btn.textContent.trim().replace('v', '') : '2.4.0'; +} + +// Compare two semver-ish strings ("2.4.0" vs "2.4.1" vs "2.39"). Returns +// negative if a < b, positive if a > b, 0 if equal. Strips any +sha suffix +// before parsing. Missing components are treated as 0 so "2.4" sorts as +// "2.4.0". Replaces the old parseFloat() approach which collapsed any +// 3-part version to its first two components — making 2.4.0 and 2.4.1 +// indistinguishable. +function _compareVersions(a, b) { + const parse = (s) => String(s || '0').split('+')[0].split('.').map(n => parseInt(n, 10) || 0); + const pa = parse(a); + const pb = parse(b); + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i++) { + const diff = (pa[i] || 0) - (pb[i] || 0); + if (diff !== 0) return diff; + } + return 0; } function _getLatestWhatsNewVersion() { // Only surface entries whose version number is <= the current build. Entries // sitting at higher versions are unreleased work-in-progress and shouldn't // flag as "new" in the helper badge until the build catches up. - const buildVer = parseFloat(_getCurrentVersion()) || 2.39; + const buildVer = _getCurrentVersion(); const versions = Object.keys(WHATS_NEW) - .filter(v => (parseFloat(v) || 0) <= buildVer) - .sort((a, b) => parseFloat(b) - parseFloat(a)); - return versions[0] || '2.39'; + .filter(v => _compareVersions(v, buildVer) <= 0) + .sort((a, b) => _compareVersions(b, a)); + return versions[0] || '2.4.0'; } function openWhatsNew() { @@ -3757,10 +4089,10 @@ function _openFullChangelog() { function _showOlderNotes() { // Cycle to next older version in the what's new panel (skip unreleased entries) - const buildVer = parseFloat(_getCurrentVersion()) || 2.39; + const buildVer = _getCurrentVersion(); const versions = Object.keys(WHATS_NEW) - .filter(v => (parseFloat(v) || 0) <= buildVer) - .sort((a, b) => parseFloat(b) - parseFloat(a)); + .filter(v => _compareVersions(v, buildVer) <= 0) + .sort((a, b) => _compareVersions(b, a)); const panel = _helperPopover; if (!panel) return; const currentTitle = panel.querySelector('.helper-popover-title'); diff --git a/webui/static/init.js b/webui/static/init.js index a92a010f..77145543 100644 --- a/webui/static/init.js +++ b/webui/static/init.js @@ -1903,6 +1903,19 @@ async function checkAdminPinRequired() { } } +// Service worker registration. Runs as soon as the JS parses (doesn't +// need to wait for DOMContentLoaded). Cache-first image strategy + +// stale-while-revalidate static shell — see /sw.js for details. Skipped +// when the API isn't available (older browsers, file:// origin) or when +// the page is loaded from a non-secure origin (SW requires HTTPS or +// localhost). +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js', { scope: '/' }) + .catch((err) => console.warn('[SW] registration failed:', err)); + }); +} + document.addEventListener('DOMContentLoaded', async function () { console.log('SoulSync WebUI initializing...'); @@ -2152,7 +2165,13 @@ function navigateToPage(pageId, options = {}) { // Artists page, now replaced by clicking artists from the unified Search. if (pageId === 'downloads' || pageId === 'artists') pageId = 'search'; - if (pageId === currentPage) return; + if (pageId === currentPage) { + // Already on this page — still process pending sync tab actions + if (pageId === 'sync' && window._pendingSyncTabAction && typeof _applySyncTabAction === 'function') { + _applySyncTabAction(); + } + return; + } // Permission guard — redirect to home page if not allowed if (!isPageAllowed(pageId)) { @@ -2237,6 +2256,15 @@ async function loadPageData(pageId) { if (typeof _stopNebulaLivePolling === 'function') _stopNebulaLivePolling(); if (pageId !== 'sync') { cleanupBeatportContent(); + // Clear any discover sync tab pollers when leaving the sync page + if (typeof discoverSyncPollers === 'object') { + for (const key of Object.keys(discoverSyncPollers)) { + clearInterval(discoverSyncPollers[key]); + delete discoverSyncPollers[key]; + } + } + // Reset so discover tab refetches on next visit + discoverSyncPlaylistsLoaded = false; } switch (pageId) { case 'dashboard': @@ -2246,6 +2274,10 @@ async function loadPageData(pageId) { case 'sync': initializeSyncPage(); await loadSyncData(); + // Process any pending deep-link tab switch (e.g. from Discover page) + if (window._pendingSyncTabAction && typeof _applySyncTabAction === 'function') { + _applySyncTabAction(); + } break; case 'search': initializeSearch(); diff --git a/webui/static/library.js b/webui/static/library.js index b74a30ed..55b1892c 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -358,13 +358,60 @@ function showLibraryLoading(show) { function showLibraryEmpty(show) { const emptyElement = document.getElementById("library-empty"); - if (emptyElement) { - if (show) { - emptyElement.classList.remove("hidden"); - } else { - emptyElement.classList.add("hidden"); + if (!emptyElement) return; + if (!show) { + emptyElement.classList.add("hidden"); + return; + } + + // When a search query is active and returned zero library hits, swap the + // generic "no artists" copy for a CTA that hands the query off to /search + // so the user can look the artist up across metadata sources without + // retyping. + const query = (libraryPageState.currentSearch || '').trim(); + const iconEl = document.getElementById('library-empty-icon'); + const titleEl = document.getElementById('library-empty-title'); + const subtitleEl = document.getElementById('library-empty-subtitle'); + const ctaEl = document.getElementById('library-empty-search-cta'); + const ctaQueryEl = document.getElementById('library-empty-search-cta-query'); + + if (query) { + if (iconEl) iconEl.textContent = '🔎'; + if (titleEl) titleEl.textContent = `"${query}" isn't in your library`; + if (subtitleEl) subtitleEl.textContent = 'They might be available on a connected metadata source.'; + if (ctaQueryEl) ctaQueryEl.textContent = `"${query}"`; + if (ctaEl) { + ctaEl.classList.remove('hidden'); + // Rebind cleanly — onclick avoids duplicate listeners across renders. + ctaEl.onclick = () => _handoffLibrarySearchToEnhancedSearch(query); + } + } else { + if (iconEl) iconEl.textContent = '🎵'; + if (titleEl) titleEl.textContent = 'No artists found'; + if (subtitleEl) subtitleEl.textContent = 'Try adjusting your search or filters'; + if (ctaEl) { + ctaEl.classList.add('hidden'); + ctaEl.onclick = null; } } + + emptyElement.classList.remove("hidden"); +} + +// Navigate to /search and pre-fill the enhanced search input with the query +// the user had typed into the library search. Uses the same hand-off pattern +// the global widget uses for Soulseek — navigate, then dispatch an `input` +// event so the Search page's existing debounced search kicks in. +function _handoffLibrarySearchToEnhancedSearch(query) { + if (typeof navigateToPage !== 'function') return; + navigateToPage('search'); + setTimeout(() => { + const input = document.getElementById('enhanced-search-input'); + if (input && query) { + input.value = query; + input.dispatchEvent(new Event('input', { bubbles: true })); + } + }, 300); } async function openWatchAllUnwatchedModal() { @@ -2663,6 +2710,10 @@ function renderArtistMetaPanel(artist) { const headerRight = document.createElement('div'); headerRight.className = 'enhanced-artist-meta-actions'; + // Live reorganize-queue status — sits first so the user sees what's + // happening before any of the action buttons. + mountReorganizeStatusPanel(headerRight, String(artist.id)); + if (isEnhancedAdmin()) { const editToggle = document.createElement('button'); editToggle.className = 'enhanced-meta-edit-toggle'; @@ -2758,7 +2809,7 @@ function renderArtistMetaPanel(artist) { const reorgAllBtn = document.createElement('button'); reorgAllBtn.className = 'enhanced-sync-btn'; reorgAllBtn.innerHTML = '📁 Reorganize All'; - reorgAllBtn.title = 'Reorganize all albums for this artist using path template'; + reorgAllBtn.title = 'Reorganize all albums for this artist using your configured download template'; reorgAllBtn.onclick = () => _showReorganizeAllModal(); headerRight.appendChild(reorgAllBtn); @@ -3224,7 +3275,8 @@ function renderExpandedAlbumHeader(album) { const reorganizeBtn = document.createElement('button'); reorganizeBtn.className = 'enhanced-reorganize-album-btn'; reorganizeBtn.innerHTML = '📁 Reorganize'; - reorganizeBtn.title = 'Reorganize album files using a custom path template'; + reorganizeBtn.title = 'Reorganize album files using your configured download template'; + reorganizeBtn.dataset.albumId = String(album.id); reorganizeBtn.onclick = (e) => { e.stopPropagation(); showReorganizeModal(album.id); }; enrichRow.appendChild(reorganizeBtn); @@ -6057,11 +6109,27 @@ function _pollBatchRgStatus() { } // ── Reorganize Album Files ── +// +// Click → enqueue → close modal. The reorganize queue worker (server- +// side) processes items FIFO. The Reorganize Status panel mounted at +// the top of the artist's enhanced-actions section is what surfaces +// live progress — buttons no longer wait or lock. let _reorganizeAlbumId = null; -let _reorganizePollTimer = null; async function showReorganizeModal(albumId) { + // Short-circuit if this album is already queued or running — opening + // the modal would be misleading (the apply click would just dedupe). + const queuedState = _reorganizeStateForAlbum(albumId); + if (queuedState) { + const label = queuedState === 'running' ? 'Reorganize already running for this album' : 'Album already queued for reorganize'; + showToast(label, 'info'); + if (typeof refreshReorganizeStatusPanel === 'function') { + refreshReorganizeStatusPanel(); + } + return; + } + _reorganizeAlbumId = albumId; const overlay = document.getElementById('reorganize-overlay'); const body = document.getElementById('reorganize-modal-body'); @@ -6085,52 +6153,19 @@ async function showReorganizeModal(albumId) { applyBtn.onclick = () => executeReorganize(); } - // Build modal content - const variables = [ - { var: '$artist', desc: 'Track artist', example: artistName || 'Artist' }, - { var: '$albumartist', desc: 'Album artist', example: artistName || 'Album Artist' }, - { var: '$artistletter', desc: 'First letter of artist', example: (artistName || 'A')[0].toUpperCase() }, - { var: '$album', desc: 'Album title', example: albumData ? albumData.title : 'Album' }, - { var: '$albumtype', desc: 'Album/EP/Single', example: 'Album' }, - { var: '$title', desc: 'Track title', example: 'Track Name' }, - { var: '$track', desc: 'Track number (zero-padded)', example: '01' }, - { var: '$disc', desc: 'Disc number (filename only)', example: '01' }, - { var: '$cdnum', desc: 'CD label — "CD01" on multi-disc, empty otherwise', example: 'CD01' }, - { var: '$year', desc: 'Release year', example: albumData && albumData.year ? String(albumData.year) : '2024' }, - { var: '$quality', desc: 'Audio quality (filename only)', example: 'FLAC 16bit/44kHz' }, - ]; - let html = '
'; - // Template input - html += '
'; - html += ''; - html += '
Use / to separate folders. The last segment becomes the filename.
'; - // Load saved template from settings, fall back to default - let savedTemplate = '$albumartist/$albumartist - $album/$track - $title'; - try { - const settingsResp = await fetch('/api/settings'); - if (settingsResp.ok) { - const settings = await settingsResp.json(); - savedTemplate = settings.file_organization?.templates?.album_path || savedTemplate; - } - } catch (_) { } - html += ''; + html += ''; html += '
'; - // Variables reference - html += '
'; - html += ''; - html += '
'; - variables.forEach(v => { - html += `
`; - html += `${v.var}${v.desc}`; - html += '
'; - }); - html += '
'; - // Preview area html += '
'; html += '
'; @@ -6145,31 +6180,34 @@ async function showReorganizeModal(albumId) { body.innerHTML = html; overlay.classList.remove('hidden'); - // Wire up live preview on enter key - setTimeout(() => { - const input = document.getElementById('reorganize-template-input'); - if (input) { - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - loadReorganizePreview(); - } - }); - input.focus(); - } - }, 50); + // Populate source picker after the modal mounts + setTimeout(() => _populateReorganizeSources(_reorganizeAlbumId), 50); } -function insertReorganizeVar(varName) { - const input = document.getElementById('reorganize-template-input'); - if (!input) return; - const start = input.selectionStart; - const end = input.selectionEnd; - const val = input.value; - input.value = val.substring(0, start) + varName + val.substring(end); - input.focus(); - const newPos = start + varName.length; - input.setSelectionRange(newPos, newPos); +async function _populateReorganizeSources(albumId) { + const select = document.getElementById('reorganize-source-select'); + if (!select || !albumId) return; + try { + const resp = await fetch(`/api/library/album/${albumId}/reorganize/sources`); + if (!resp.ok) return; + const data = await resp.json(); + const sources = data.sources || []; + // Keep the "auto" default option, append concrete sources beneath it. + sources.forEach(s => { + const opt = document.createElement('option'); + opt.value = s.source; + opt.textContent = s.label || s.source; + select.appendChild(opt); + }); + if (sources.length === 0) { + const opt = document.createElement('option'); + opt.disabled = true; + opt.textContent = 'No sources available — run enrichment first'; + select.appendChild(opt); + } + } catch (err) { + console.error('Failed to load reorganize sources:', err); + } } function closeReorganizeModal() { @@ -6179,19 +6217,26 @@ function closeReorganizeModal() { } async function loadReorganizePreview() { - const template = document.getElementById('reorganize-template-input')?.value?.trim(); const previewBody = document.getElementById('reorganize-preview-body'); const applyBtn = document.getElementById('reorganize-apply-btn'); - if (!template || !previewBody || !_reorganizeAlbumId) return; + if (!previewBody || !_reorganizeAlbumId) return; if (applyBtn) applyBtn.disabled = true; previewBody.innerHTML = '
Loading preview...
'; + // Final apply-button state: only enable when the preview actually + // produced movable tracks AND no collisions blocked it. Any error + // path or empty result keeps it disabled. We compute it as we go and + // commit it in finally so an early return / throw can't leave the + // button stuck disabled forever. + let canApply = false; + try { + const chosenSource = document.getElementById('reorganize-source-select')?.value || ''; const response = await fetch(`/api/library/album/${_reorganizeAlbumId}/reorganize/preview`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ template }) + body: JSON.stringify({ source: chosenSource }) }); const result = await response.json(); if (!result.success) { @@ -6215,66 +6260,103 @@ async function loadReorganizePreview() { const unchanged = t.unchanged; const noFile = !t.file_exists; const collision = t.collision; - if (!unchanged && t.file_exists) hasChanges = true; + const unmatched = (t.matched === false); + const missingPath = !unmatched && !noFile && !t.new_path; // matched but path-build failed + if (!unchanged && t.file_exists && !unmatched && !missingPath) hasChanges = true; if (collision) hasCollisions = true; - const rowClass = collision ? 'reorganize-row-collision' : noFile ? 'reorganize-row-missing' : unchanged ? 'reorganize-row-unchanged' : 'reorganize-row-changed'; + let rowClass; + if (collision) rowClass = 'reorganize-row-collision'; + else if (noFile || unmatched || missingPath) rowClass = 'reorganize-row-missing'; + else if (unchanged) rowClass = 'reorganize-row-unchanged'; + else rowClass = 'reorganize-row-changed'; + + const arrow = collision ? '!!' + : unchanged ? '=' + : (noFile || unmatched || missingPath) ? '⊘' + : '→'; + + const newCell = noFile ? '' + : unmatched ? `${escapeHtml(t.reason || 'Not in selected source\'s tracklist')}` + : missingPath ? `${escapeHtml(t.reason || 'Couldn\'t compute destination path')}` + : (escapeHtml(t.new_path) + (collision ? ' (collision)' : '')); + html += ``; html += `${t.track_number || ''}`; html += `${escapeHtml(t.title)}`; html += `${noFile ? 'File not found' : escapeHtml(t.current_path)}`; - html += `${collision ? '!!' : unchanged ? '=' : noFile ? '' : '→'}`; - html += `${noFile ? '' : escapeHtml(t.new_path)}${collision ? ' (collision)' : ''}`; + html += `${arrow}`; + html += `${newCell}`; html += ''; }); html += ''; - const changedCount = tracks.filter(t => !t.unchanged && t.file_exists && !t.collision).length; + const changedCount = tracks.filter(t => !t.unchanged && t.file_exists && !t.collision && t.matched !== false && t.new_path).length; const skippedCount = tracks.filter(t => t.unchanged).length; const missingCount = tracks.filter(t => !t.file_exists).length; const collisionCount = tracks.filter(t => t.collision).length; + const unmatchedCount = tracks.filter(t => t.file_exists && t.matched === false).length; + const noPathCount = tracks.filter(t => t.file_exists && t.matched !== false && !t.new_path && !t.collision).length; let summary = `
`; if (changedCount > 0) summary += `${changedCount} will move`; if (skippedCount > 0) summary += `${skippedCount} unchanged`; - if (missingCount > 0) summary += `${missingCount} missing`; - if (collisionCount > 0) summary += `${collisionCount} collision${collisionCount !== 1 ? 's' : ''} — add $track or $disc to fix`; + if (unmatchedCount > 0) summary += `${unmatchedCount} not in source — try a different source`; + if (noPathCount > 0) summary += `${noPathCount} couldn't compute destination`; + if (missingCount > 0) summary += `${missingCount} missing on disk`; + if (collisionCount > 0) summary += `${collisionCount} collision${collisionCount !== 1 ? 's' : ''} — likely a source data issue`; summary += '
'; previewBody.innerHTML = summary + html; - // Block apply if collisions exist - if (applyBtn) applyBtn.disabled = !hasChanges || hasCollisions; + canApply = hasChanges && !hasCollisions; } catch (error) { previewBody.innerHTML = `
Error: ${escapeHtml(error.message)}
`; + } finally { + if (applyBtn) applyBtn.disabled = !canApply; } } async function executeReorganize() { - const template = document.getElementById('reorganize-template-input')?.value?.trim(); - if (!template || !_reorganizeAlbumId) return; + if (!_reorganizeAlbumId) return; const applyBtn = document.getElementById('reorganize-apply-btn'); if (applyBtn) { applyBtn.disabled = true; - applyBtn.textContent = 'Reorganizing...'; + applyBtn.textContent = 'Queueing...'; } + const albumTitle = document.getElementById('reorganize-modal-title')?.textContent + ?.replace(/^Reorganize:\s*/, '') || 'album'; + try { + const chosenSource = document.getElementById('reorganize-source-select')?.value || ''; const response = await fetch(`/api/library/album/${_reorganizeAlbumId}/reorganize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ template }) + body: JSON.stringify({ source: chosenSource }) }); const result = await response.json(); if (!result.success) throw new Error(result.error); closeReorganizeModal(); - showToast(`Reorganizing ${result.total} tracks...`, 'info'); - _pollReorganizeStatus(); + if (result.queued) { + const posLabel = result.position && result.position > 1 ? ` (#${result.position} in queue)` : ''; + showToast(`Queued: ${albumTitle}${posLabel}`, 'info'); + } else if (result.reason === 'already_queued') { + showToast(`Already queued: ${albumTitle}`, 'info'); + } else { + showToast('Reorganize queued', 'info'); + } + + // Wake the status panel so the user sees the new item land + // immediately rather than waiting for the next poll tick. + if (typeof refreshReorganizeStatusPanel === 'function') { + refreshReorganizeStatusPanel(); + } } catch (error) { showToast(`Reorganize failed: ${error.message}`, 'error'); if (applyBtn) { @@ -6284,46 +6366,44 @@ async function executeReorganize() { } } -function _pollReorganizeStatus() { - if (_reorganizePollTimer) clearTimeout(_reorganizePollTimer); +// kettui PR #377 review: distinguish 'completed' from non-completed +// outcomes so zero-failure skips (no_source_id, no_album, no_tracks, +// setup_failed, error) don't get a green checkmark. +function _classifyReorganizeOutcome(state) { + const status = state.result_status; + if (status && status !== 'completed') return 'warning'; + if (state.failed && state.failed > 0) return 'warning'; + return 'success'; +} - async function poll() { - try { - const response = await fetch('/api/library/album/reorganize/status'); - const state = await response.json(); - - if (state.status === 'running') { - const pct = state.total > 0 ? Math.round(state.processed / state.total * 100) : 0; - showToast(`Reorganizing: ${state.processed}/${state.total} (${pct}%) — ${state.current_track}`, 'info'); - _reorganizePollTimer = setTimeout(poll, 800); - } else if (state.status === 'done') { - let msg = `Reorganized: ${state.moved} moved`; - if (state.skipped > 0) msg += `, ${state.skipped} skipped`; - if (state.failed > 0) msg += `, ${state.failed} failed`; - if (state.failed > 0 && state.errors && state.errors.length > 0) { - msg += ` (${state.errors[0].error})`; - } - showToast(msg, state.failed > 0 ? 'warning' : 'success'); - _reorganizePollTimer = null; - - // Refresh the enhanced view to show updated paths - if (artistDetailPageState.currentArtistId && artistDetailPageState.enhancedView) { - loadEnhancedViewData(artistDetailPageState.currentArtistId); - } - } - } catch (error) { - console.error('Poll reorganize status failed:', error); - _reorganizePollTimer = null; - } +function _formatReorganizeResultMessage(state) { + const status = state.result_status; + if (status === 'no_source_id') { + return 'Reorganize skipped — album has no metadata source ID. Run enrichment first.'; } - - _reorganizePollTimer = setTimeout(poll, 600); + if (status === 'no_album') { + return 'Reorganize skipped — album not found in DB.'; + } + if (status === 'no_tracks') { + return 'Reorganize skipped — album has no tracks.'; + } + if (status === 'setup_failed') { + return 'Reorganize failed — couldn\'t create staging directory.'; + } + if (status === 'error') { + return 'Reorganize failed — see server logs for details.'; + } + let msg = `Reorganized: ${state.moved || 0} moved`; + if (state.skipped > 0) msg += `, ${state.skipped} skipped`; + if (state.failed > 0) msg += `, ${state.failed} failed`; + if (state.failed > 0 && state.errors && state.errors.length > 0) { + msg += ` (${state.errors[0].error})`; + } + return msg; } // ── Reorganize All Albums for Artist ── -let _reorganizeAllRunning = false; - async function _showReorganizeAllModal() { if (!artistDetailPageState.enhancedData) { showToast('No album data loaded', 'error'); @@ -6345,23 +6425,17 @@ async function _showReorganizeAllModal() { title.textContent = `Reorganize All Albums — ${artistName}`; - // Load saved template - let savedTemplate = '$albumartist/$albumartist - $album/$track - $title'; - try { - const settingsResp = await fetch('/api/settings'); - if (settingsResp.ok) { - const settings = await settingsResp.json(); - savedTemplate = settings.file_organization?.templates?.album_path || savedTemplate; - } - } catch (_) { } - let html = '
'; - // Template input - html += '
'; - html += ''; - html += '
This template will be applied to all albums below. Use / to separate folders.
'; - html += ``; + // Source picker — applies to ALL albums in this run. Albums without + // an ID for the chosen source will be skipped at the backend with + // a clear status. Auto = use configured primary with fallback chain. + html += '
'; + html += ''; + html += '
Pick which source to read tracklists from. Albums without an ID for that source will be skipped. Reorganize uses your global download template, same as fresh downloads.
'; + html += ''; html += '
'; // Album list @@ -6387,96 +6461,538 @@ async function _showReorganizeAllModal() { } overlay.classList.remove('hidden'); + + // Populate the source dropdown from the global authed-sources endpoint + setTimeout(async () => { + const select = document.getElementById('reorganize-source-select'); + if (!select) return; + try { + const resp = await fetch('/api/library/reorganize/sources'); + if (!resp.ok) return; + const data = await resp.json(); + (data.sources || []).forEach(s => { + const opt = document.createElement('option'); + opt.value = s.source; + opt.textContent = s.label || s.source; + select.appendChild(opt); + }); + } catch (err) { + console.error('Failed to load reorganize sources:', err); + } + }, 50); } async function _executeReorganizeAll() { - if (_reorganizeAllRunning) return; - - const templateInput = document.getElementById('reorganize-template-input'); - const template = templateInput ? templateInput.value.trim() : ''; - if (!template) { - showToast('Template cannot be empty', 'error'); - return; - } - - const albums = artistDetailPageState.enhancedData.albums || []; + const albums = artistDetailPageState.enhancedData?.albums || []; const total = albums.length; - const artistName = artistDetailPageState.enhancedData.artist?.name || 'this artist'; + const artistName = artistDetailPageState.enhancedData?.artist?.name || 'this artist'; + const artistId = artistDetailPageState.currentArtistId; + if (!artistId) return; const confirmed = await showConfirmDialog({ title: 'Reorganize All Albums', - message: `This will reorganize ${total} album${total !== 1 ? 's' : ''} for ${artistName} using the template:\n\n${template}\n\nFiles will be moved and renamed. This cannot be undone.`, - confirmText: 'Reorganize All', + message: `This will queue ${total} album${total !== 1 ? 's' : ''} for ${artistName} using your configured download template. Files will be moved and renamed. This cannot be undone.`, + confirmText: 'Queue All', destructive: false, }); if (!confirmed) return; - _reorganizeAllRunning = true; const applyBtn = document.getElementById('reorganize-apply-btn'); - if (applyBtn) { applyBtn.disabled = true; applyBtn.textContent = 'Working...'; } + if (applyBtn) { applyBtn.disabled = true; applyBtn.textContent = 'Queueing...'; } - // Close modal const overlay = document.getElementById('reorganize-overlay'); if (overlay) overlay.classList.add('hidden'); - let succeeded = 0, failed = 0; + // One source pick applies to every album in the batch. + const chosenSource = document.getElementById('reorganize-source-select')?.value || ''; - for (let i = 0; i < total; i++) { - const album = albums[i]; - showToast(`Reorganizing album ${i + 1}/${total}: ${album.title}`, 'info'); + try { + const resp = await fetch(`/api/library/artist/${artistId}/reorganize-all`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source: chosenSource }), + }); + const result = await resp.json(); + if (!result.success) throw new Error(result.error || 'Queue request failed'); - try { - const resp = await fetch(`/api/library/album/${album.id}/reorganize`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ template }), + const enqueued = result.enqueued || 0; + const already = result.already_queued || 0; + if (enqueued > 0 && already > 0) { + showToast(`Queued ${enqueued} album${enqueued !== 1 ? 's' : ''}; ${already} already in queue`, 'info'); + } else if (enqueued > 0) { + showToast(`Queued ${enqueued} album${enqueued !== 1 ? 's' : ''} for ${artistName}`, 'info'); + } else if (already > 0) { + showToast(`All ${already} album${already !== 1 ? 's' : ''} already in queue`, 'info'); + } else { + showToast('No albums to queue', 'warning'); + } + + if (typeof refreshReorganizeStatusPanel === 'function') { + refreshReorganizeStatusPanel(); + } + } catch (err) { + showToast(`Reorganize-all failed: ${err.message}`, 'error'); + } finally { + if (applyBtn) { applyBtn.disabled = false; applyBtn.textContent = 'Reorganize All'; } + } +} + + +// ── Reorganize Status Panel ── +// +// Lives at the start of `.enhanced-artist-meta-actions`. Polls the +// queue snapshot endpoint and renders an at-a-glance summary plus an +// expandable card list. Only visible when there's something to show +// (active item, queued items, or recent completions). +// +// Cross-artist hint: items belonging to a different artist than the +// page's current one are flagged so the user understands progress they +// see refers to a separate batch. + +let _reorgPanelEl = null; +let _reorgPanelArtistId = null; +let _reorgPanelExpanded = false; +let _reorgPanelTimer = null; +let _reorgPanelLastSnapshot = null; +let _reorgPanelInflight = false; + +const _REORG_PANEL_FAST_MS = 1500; +const _REORG_PANEL_SLOW_MS = 8000; + +function mountReorganizeStatusPanel(container, artistId) { + if (!container) return; + // Tear down any panel left over from a previous artist view. + _stopReorganizeStatusPolling(); + + const panel = document.createElement('div'); + panel.className = 'reorganize-status-panel hidden'; + panel.id = 'reorganize-status-panel'; + container.insertBefore(panel, container.firstChild); + + _reorgPanelEl = panel; + _reorgPanelArtistId = artistId || null; + _reorgPanelExpanded = false; + _reorgPanelLastSnapshot = null; + + // Defer the initial refresh: the caller (renderArtistMetaPanel) is + // still building the header in memory, so neither this panel nor + // its ancestor headerRight has been attached to document.body yet. + // refreshReorganizeStatusPanel guards on document.body.contains, + // so a synchronous call here would bail and kill polling forever. + // setTimeout 0 lets the call stack unwind so the parent appendChild + // runs before we check connectivity. + setTimeout(() => { + if (!_reorgPanelEl || !document.body.contains(_reorgPanelEl)) return; + refreshReorganizeStatusPanel(); + }, 0); +} + +function _stopReorganizeStatusPolling() { + if (_reorgPanelTimer) { + clearTimeout(_reorgPanelTimer); + _reorgPanelTimer = null; + } + _reorgPanelEl = null; + _reorgPanelLastSnapshot = null; +} + +function _scheduleReorganizeStatusPoll(delayMs) { + if (_reorgPanelTimer) clearTimeout(_reorgPanelTimer); + _reorgPanelTimer = setTimeout(() => { + _reorgPanelTimer = null; + refreshReorganizeStatusPanel(); + }, delayMs); +} + +async function refreshReorganizeStatusPanel() { + // The panel may have been unmounted (user navigated away from + // enhanced view); detect by checking it's still in the document. + if (!_reorgPanelEl || !document.body.contains(_reorgPanelEl)) { + _stopReorganizeStatusPolling(); + return; + } + if (_reorgPanelInflight) return; + _reorgPanelInflight = true; + + let snapshot = null; + try { + const resp = await fetch('/api/library/reorganize/queue'); + if (resp.ok) { + const data = await resp.json(); + if (data.success !== false) snapshot = data; + } else { + console.warn('Reorganize queue snapshot HTTP', resp.status); + } + } catch (err) { + // Network blip — keep showing the last snapshot, retry slowly. + console.warn('Reorganize queue snapshot failed:', err); + } finally { + _reorgPanelInflight = false; + } + + if (snapshot) _reorgPanelLastSnapshot = snapshot; + _renderReorganizeStatusPanel(_reorgPanelLastSnapshot); + + // Reschedule. Fast cadence while there's actually work in flight, + // slow when the queue is empty so we're not hammering the endpoint. + if (_reorgPanelEl && document.body.contains(_reorgPanelEl)) { + const active = _reorgPanelLastSnapshot?.active; + const queued = _reorgPanelLastSnapshot?.queued?.length || 0; + const next = (active || queued > 0) ? _REORG_PANEL_FAST_MS : _REORG_PANEL_SLOW_MS; + _scheduleReorganizeStatusPoll(next); + } +} + +function _renderReorganizeStatusPanel(snapshot) { + const panel = _reorgPanelEl; + if (!panel) return; + if (!snapshot) { + panel.classList.add('hidden'); + return; + } + const active = snapshot.active; + const queued = snapshot.queued || []; + const recent = snapshot.recent || []; + + // Show if anything is active/queued, OR a recent completion landed + // within the last 20 seconds (so the user sees the result). + const cutoffSec = (Date.now() / 1000) - 20; + const recentVisible = recent.filter(r => (r.finished_at || 0) >= cutoffSec); + + if (!active && queued.length === 0 && recentVisible.length === 0) { + panel.classList.add('hidden'); + panel.innerHTML = ''; + _paintQueuedAlbumButtons(snapshot); + return; + } + panel.classList.remove('hidden'); + + // Compact summary (always visible). Click to toggle expand. + let html = '
'; + html += '
'; + + if (active) { + const total = active.progress_total || 0; + const done = active.progress_processed || 0; + const pct = total > 0 ? Math.round((done / total) * 100) : 0; + const trackBit = active.current_track ? ` — ${escapeHtml(active.current_track)}` : ''; + const albumLabel = _reorgPanelDisplayLabel(active); + html += ``; + html += `Reorganizing ${escapeHtml(albumLabel)}`; + if (total > 0) html += ` (${done}/${total} · ${pct}%)`; + html += `${trackBit}`; + } else if (queued.length > 0) { + html += ``; + html += `Reorganize queue starting…`; + } else { + // Only recent items remain — give a quick wrap-up summary. + const failed = recentVisible.filter(r => r.status === 'failed').length; + const done = recentVisible.filter(r => r.status === 'done').length; + const cls = failed > 0 ? 'recent-warn' : 'recent-ok'; + html += ``; + const parts = []; + if (done > 0) parts.push(`${done} reorganized`); + if (failed > 0) parts.push(`${failed} failed`); + html += `${parts.join(', ') || 'Recent activity'}`; + } + html += '
'; + + // Right: queue count badge + expand chevron. + html += '
'; + if (queued.length > 0) { + html += `+${queued.length} queued`; + } + const chev = _reorgPanelExpanded ? '▾' : '▸'; + html += `${chev}`; + html += '
'; + html += '
'; + + if (_reorgPanelExpanded) { + html += '
'; + + // Active card + if (active) { + html += _reorgPanelRenderActiveCard(active); + } + + // Queued list + if (queued.length > 0) { + html += '
'; + html += `Queued (${queued.length})`; + html += ``; + html += '
'; + html += '
'; + queued.forEach((item, idx) => { + html += _reorgPanelRenderQueuedRow(item, idx + 1); }); - const result = await resp.json(); - if (!result.success) { - showToast(`Failed: ${album.title} — ${result.error || 'unknown error'}`, 'error'); - failed++; - continue; - } + html += '
'; + } - // Wait for this album to finish - await _waitForReorganizeComplete(); - succeeded++; - } catch (err) { - showToast(`Error: ${album.title} — ${err.message}`, 'error'); - failed++; + // Recent + if (recentVisible.length > 0) { + html += `
Recent
`; + html += '
'; + recentVisible.slice(0, 6).forEach(item => { + html += _reorgPanelRenderRecentRow(item); + }); + html += '
'; + } + + html += '
'; + } + + panel.innerHTML = html; + + // Mark per-album reorganize buttons so users see at-a-glance which + // albums are already in the queue without opening the modal. + _paintQueuedAlbumButtons(snapshot); + + // If the active item just transitioned to a recent done/failed + // entry, refresh the enhanced view so the new on-disk paths show. + _maybeReloadEnhancedAfterCompletion(snapshot); +} + +function _reorganizeStateForAlbum(albumId) { + const snap = _reorgPanelLastSnapshot; + if (!snap) return null; + const id = String(albumId); + if (snap.active && String(snap.active.album_id) === id) return 'running'; + if ((snap.queued || []).some(q => String(q.album_id) === id)) return 'queued'; + return null; +} + +function _paintQueuedAlbumButtons(snapshot) { + const queuedIds = new Set(); + const runningIds = new Set(); + if (snapshot?.active) runningIds.add(String(snapshot.active.album_id)); + (snapshot?.queued || []).forEach(q => queuedIds.add(String(q.album_id))); + + document.querySelectorAll('.enhanced-reorganize-album-btn[data-album-id]').forEach(btn => { + const id = btn.dataset.albumId; + if (runningIds.has(id)) { + btn.classList.add('reorg-state-running'); + btn.classList.remove('reorg-state-queued'); + btn.title = 'Reorganize already running for this album'; + } else if (queuedIds.has(id)) { + btn.classList.add('reorg-state-queued'); + btn.classList.remove('reorg-state-running'); + btn.title = 'Album already queued for reorganize'; + } else { + btn.classList.remove('reorg-state-queued', 'reorg-state-running'); + btn.title = 'Reorganize album files using your configured download template'; + } + }); +} + +function _reorgPanelDisplayLabel(item) { + if (!item) return ''; + if (_reorgPanelArtistId && item.artist_id && String(item.artist_id) !== _reorgPanelArtistId) { + return `${item.album_title || 'Unknown album'} (${item.artist_name || 'other artist'})`; + } + return item.album_title || 'Unknown album'; +} + +function _reorgPanelRenderActiveCard(active) { + const total = active.progress_total || 0; + const done = active.progress_processed || 0; + const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0; + const crossArtist = _reorgPanelArtistId && active.artist_id && String(active.artist_id) !== _reorgPanelArtistId; + + let h = '
'; + h += `
${escapeHtml(active.album_title || 'Unknown album')}`; + if (crossArtist) { + h += ` ${escapeHtml(active.artist_name || 'other artist')}`; + } + h += '
'; + h += '
'; + h += `
`; + h += '
'; + h += '
'; + if (total > 0) { + h += `${done}/${total}`; + } + if (active.current_track) { + h += `${escapeHtml(active.current_track)}`; + } + h += ''; + h += `${active.moved || 0} moved`; + if ((active.skipped || 0) > 0) h += `${active.skipped} skipped`; + if ((active.failed || 0) > 0) h += `${active.failed} failed`; + h += ''; + h += '
'; + h += '
'; + return h; +} + +function _reorgPanelRenderQueuedRow(item, position) { + const crossArtist = _reorgPanelArtistId && item.artist_id && String(item.artist_id) !== _reorgPanelArtistId; + let h = '
'; + h += `#${position}`; + h += '
'; + h += `
${escapeHtml(item.album_title || 'Unknown album')}
`; + if (crossArtist) { + h += `
${escapeHtml(item.artist_name || 'other artist')}
`; + } else if (item.source) { + h += `
via ${escapeHtml(item.source)}
`; + } + h += '
'; + h += ``; + h += '
'; + return h; +} + +function _reorgPanelRenderRecentRow(item) { + const crossArtist = _reorgPanelArtistId && item.artist_id && String(item.artist_id) !== _reorgPanelArtistId; + const tone = _classifyReorganizeOutcome({ + result_status: item.result_status, + failed: item.failed, + }); + const cls = item.status === 'cancelled' ? 'cancelled' : tone; + let h = `
`; + h += ``; + h += '
'; + h += `
${escapeHtml(item.album_title || 'Unknown album')}
`; + let sub; + if (item.status === 'cancelled') { + sub = 'Cancelled'; + } else { + sub = _formatReorganizeResultMessage({ + result_status: item.result_status, + moved: item.moved, + skipped: item.skipped, + failed: item.failed, + errors: item.error ? [{ error: item.error }] : [], + }); + } + if (crossArtist) sub = `${escapeHtml(item.artist_name || 'other artist')} — ${sub}`; + h += `
${escapeHtml(sub)}
`; + h += '
'; + return h; +} + +function toggleReorganizeStatusPanel() { + _reorgPanelExpanded = !_reorgPanelExpanded; + _renderReorganizeStatusPanel(_reorgPanelLastSnapshot); +} + +async function cancelReorganizeQueueItem(queueId, event) { + if (event) event.stopPropagation(); + if (!queueId) return; + try { + const resp = await fetch(`/api/library/reorganize/queue/${encodeURIComponent(queueId)}/cancel`, { + method: 'POST', + }); + const data = await resp.json(); + if (data.cancelled) { + showToast('Cancelled queued item', 'info'); + } else if (data.reason === 'running_cant_cancel') { + showToast('Already running — too late to cancel', 'warning'); + } else { + showToast('Could not cancel item', 'warning'); + } + } catch (err) { + showToast(`Cancel failed: ${err.message}`, 'error'); + } + refreshReorganizeStatusPanel(); +} + +async function clearReorganizeQueue(event) { + if (event) event.stopPropagation(); + const queued = _reorgPanelLastSnapshot?.queued?.length || 0; + if (queued === 0) return; + const confirmed = await showConfirmDialog({ + title: 'Cancel All Queued', + message: `Cancel ${queued} queued reorganize${queued !== 1 ? 's' : ''}? The currently-running item will continue.`, + confirmText: 'Cancel All', + destructive: true, + }); + if (!confirmed) return; + try { + const resp = await fetch('/api/library/reorganize/queue/clear', { method: 'POST' }); + const data = await resp.json(); + if (data.success) { + showToast(`Cancelled ${data.cancelled} queued item${data.cancelled !== 1 ? 's' : ''}`, 'info'); + } + } catch (err) { + showToast(`Clear failed: ${err.message}`, 'error'); + } + refreshReorganizeStatusPanel(); +} + +let _reorgPanelLastActiveId = null; +let _reorgPanelPendingReload = false; +let _reorgPanelReloadTimer = null; + +function _maybeReloadEnhancedAfterCompletion(snapshot) { + // When an item completes for the artist on screen, the moved file + // paths need to be re-rendered in the enhanced view. Two failure + // modes to avoid: + // 1. Reloading mid-batch — a 20-album "Reorganize All" would + // otherwise fire 20 sequential /api/library/artist/X/enhanced + // calls + 20 full re-renders, hammering the server. + // 2. Never reloading — if we wait for queue idle but more items + // keep arriving, the user never sees the freshly-moved paths. + // + // Strategy: mark a reload as pending whenever a completion lands + // for our artist. Defer the reload until the queue is fully idle + // for that artist (no active item, nothing queued) — that's the + // natural "batch finished" boundary. Use a 1.5s timer reset on + // every snapshot so we don't fire while the worker is still + // between items. + const active = snapshot?.active; + const recent = snapshot?.recent || []; + const queued = snapshot?.queued || []; + + // Detect a fresh completion (recent-top is a new queue_id we + // hadn't seen as 'active' before) for our artist. + if (active) { + _reorgPanelLastActiveId = active.queue_id; + } else if (_reorgPanelLastActiveId && recent.length > 0) { + const recentTop = recent[0]; + if (recentTop.queue_id === _reorgPanelLastActiveId) { + const finishedRecently = (recentTop.finished_at || 0) >= ((Date.now() / 1000) - 10); + const sameArtist = _reorgPanelArtistId && + recentTop.artist_id && String(recentTop.artist_id) === _reorgPanelArtistId; + if (finishedRecently && sameArtist) { + _reorgPanelPendingReload = true; + } + _reorgPanelLastActiveId = null; } } - let msg = `Reorganized ${succeeded} of ${total} album${total !== 1 ? 's' : ''}`; - if (failed > 0) msg += ` (${failed} failed)`; - showToast(msg, failed > 0 ? 'warning' : 'success'); + if (!_reorgPanelPendingReload) return; - _reorganizeAllRunning = false; - if (applyBtn) { applyBtn.disabled = false; applyBtn.textContent = 'Reorganize All'; } + // Hold the reload until the queue is fully idle for our artist. + const stillBusyForOurArtist = active && + _reorgPanelArtistId && + active.artist_id && String(active.artist_id) === _reorgPanelArtistId; + const queuedForOurArtist = queued.some(q => + _reorgPanelArtistId && q.artist_id && String(q.artist_id) === _reorgPanelArtistId + ); - // Refresh enhanced view - if (artistDetailPageState.currentArtistId && artistDetailPageState.enhancedView) { - loadEnhancedViewData(artistDetailPageState.currentArtistId); + if (stillBusyForOurArtist || queuedForOurArtist) { + // More work coming for this artist — keep the pending flag, + // don't reload yet. Cancel any already-armed timer. + if (_reorgPanelReloadTimer) { + clearTimeout(_reorgPanelReloadTimer); + _reorgPanelReloadTimer = null; + } + return; } + + // Queue is idle for our artist. Arm a debounced reload — the + // 1.5s gap absorbs the brief window between worker items so a + // back-to-back batch doesn't trigger mid-flight. + if (_reorgPanelReloadTimer) clearTimeout(_reorgPanelReloadTimer); + _reorgPanelReloadTimer = setTimeout(() => { + _reorgPanelReloadTimer = null; + _reorgPanelPendingReload = false; + if (artistDetailPageState.currentArtistId && artistDetailPageState.enhancedView) { + loadEnhancedViewData(artistDetailPageState.currentArtistId); + } + }, 1500); } -function _waitForReorganizeComplete() { - return new Promise(resolve => { - const poll = setInterval(async () => { - try { - const resp = await fetch('/api/library/album/reorganize/status'); - const state = await resp.json(); - if (state.status === 'done' || state.status === 'idle') { - clearInterval(poll); - resolve(); - } - } catch { - clearInterval(poll); - resolve(); - } - }, 800); - }); -} async function playLibraryTrack(track, albumTitle, artistName) { if (!track.file_path) { diff --git a/webui/static/manifest.json b/webui/static/manifest.json new file mode 100644 index 00000000..ccf8acba --- /dev/null +++ b/webui/static/manifest.json @@ -0,0 +1,37 @@ +{ + "name": "SoulSync", + "short_name": "SoulSync", + "description": "Music download & sync app — playlists, watchlist, library management.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "any", + "theme_color": "#1db954", + "background_color": "#0a0a0a", + "icons": [ + { + "src": "/static/pwa-icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/static/pwa-icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/static/pwa-icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/static/pwa-icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/webui/static/mobile.css b/webui/static/mobile.css index 38dba241..75ff8337 100644 --- a/webui/static/mobile.css +++ b/webui/static/mobile.css @@ -329,11 +329,6 @@ width: 100%; } - .enhanced-search-bar-container button { - width: 100%; - min-height: 44px; - } - .filter-group { flex-wrap: wrap; } @@ -3627,4 +3622,119 @@ width: calc(100vw - 16px); bottom: 54px; } +} + +/* ───────────────────────────────────────────────────────────── + Source picker row — Search page + global widget responsive. + Chips shrink on tablet, labels drop on phone so the full row + of 8 sources remains scannable without hijacking the screen. */ +@media (max-width: 768px) { + .enh-source-row { + padding: 8px 8px; + margin: 8px 0 10px; + gap: 6px; + border-radius: 12px; + } + .enh-source-icon { + min-width: 72px; + padding: 8px 10px; + gap: 4px; + font-size: 10.5px; + } + .enh-source-icon-glyph, + .enh-source-icon-glyph img { + width: 24px; + height: 24px; + } + .enh-source-icon-label { font-size: 10.5px; } + .enh-fallback-banner { + font-size: 11px; + padding: 6px 10px; + } + + /* Global widget source row (inside the popover) */ + .gsearch-source-row { + padding: 10px 10px 6px; + gap: 4px; + } + .gsearch-source-icon { + min-width: 62px; + padding: 6px 8px; + gap: 3px; + } + .gsearch-source-icon-glyph, + .gsearch-source-icon-glyph img { + width: 20px; + height: 20px; + } + .gsearch-fallback-banner { + font-size: 10px; + padding: 5px 10px; + } + + /* Ambient glow under the global search — trim size so it doesn't + dominate a short mobile viewport. */ + .gsearch-aura { + height: 180px; + background: + radial-gradient(ellipse 440px 160px at 50% 100%, + rgba(var(--accent-rgb), 0.20) 0%, + rgba(var(--accent-rgb), 0.08) 35%, + rgba(var(--accent-rgb), 0.02) 65%, + transparent 85%); + } + .gsearch-aura.active { + background: + radial-gradient(ellipse 540px 200px at 50% 100%, + rgba(var(--accent-rgb), 0.34) 0%, + rgba(var(--accent-rgb), 0.14) 28%, + rgba(var(--accent-rgb), 0.04) 58%, + transparent 85%); + } + + /* Library empty-state hand-off CTA — allow wrap on narrow screens + so long artist queries don't overflow. */ + .library-empty-search-cta { + flex-wrap: wrap; + justify-content: center; + padding: 10px 14px; + font-size: 13px; + max-width: 100%; + } + .library-empty-search-cta-text { + white-space: normal; + text-align: center; + } +} + +/* Very narrow phones — drop the icon labels entirely and show just the + glyphs. Tap target stays big enough, row compacts dramatically. */ +@media (max-width: 480px) { + .enh-source-row { + padding: 6px 6px; + gap: 4px; + border-radius: 10px; + } + .enh-source-icon { + min-width: 44px; + padding: 6px 8px; + } + .enh-source-icon-label { display: none; } + + .gsearch-source-row { + padding: 8px 8px 5px; + gap: 4px; + } + .gsearch-source-icon { + min-width: 40px; + padding: 5px 7px; + } + .gsearch-source-icon-label { display: none; } + + .gsearch-aura { height: 140px; } + + .library-empty-search-cta { + font-size: 12px; + padding: 9px 12px; + } } \ No newline at end of file diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index 6b5275f7..c8bfa1c4 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -2522,7 +2522,7 @@ function _adlRenderBatchPanel() { if (batch.active > 0) phaseIcon = ''; } else if (batch.phase === 'complete') { const analysisTotal = batch.analysis_total || 0; - const alreadyOwned = analysisTotal > 0 ? analysisTotal - total : 0; + const alreadyOwned = analysisTotal > 0 ? Math.max(0, analysisTotal - total) : 0; let parts = [`${batch.completed} downloaded`]; if (alreadyOwned > 0) parts.push(`${alreadyOwned} owned`); if (batch.failed > 0) parts.push(`${batch.failed} failed`); @@ -2645,6 +2645,12 @@ function _adlOpenBatchModal(batchId, playlistId, batchName) { return; } + // For discover batches, use the discover-specific modal path + if (playlistId.startsWith('discover_') && typeof openDiscoverDownloadModal === 'function') { + openDiscoverDownloadModal(playlistId); + return; + } + // For other batches, try to show existing modal or rehydrate for (const [pid, process] of Object.entries(activeDownloadProcesses)) { if (process.batchId === batchId && process.modalElement && document.body.contains(process.modalElement)) { diff --git a/webui/static/pwa-icon-192.png b/webui/static/pwa-icon-192.png new file mode 100644 index 00000000..bad488da Binary files /dev/null and b/webui/static/pwa-icon-192.png differ diff --git a/webui/static/pwa-icon-512.png b/webui/static/pwa-icon-512.png new file mode 100644 index 00000000..069f466c Binary files /dev/null and b/webui/static/pwa-icon-512.png differ diff --git a/webui/static/search.js b/webui/static/search.js index 0cae5787..585a5495 100644 --- a/webui/static/search.js +++ b/webui/static/search.js @@ -1,21 +1,8 @@ // SEARCH FUNCTIONALITY // =============================== - -// Shared enhanced-search fetch used by the Search page and the global widget. -// Pass source to restrict results to a single metadata provider; omit or pass -// null/'auto' to let the backend fan out across all configured sources. -async function enhancedSearchFetch(query, { source = null, signal = null } = {}) { - const body = { query }; - if (source && source !== 'auto') body.source = source; - const res = await fetch('/api/enhanced-search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - signal: signal || undefined, - }); - if (!res.ok) throw new Error(`Enhanced search failed: ${res.status}`); - return res.json(); -} +// `enhancedSearchFetch`, `SOURCE_LABELS`, and `renderCompactSection` live in +// shared-helpers.js so the Search page and the global widget share the same +// implementations. function initializeSearch() { // --- FIX: Corrected the element IDs to match the HTML --- @@ -48,19 +35,31 @@ function initializeSearch() { // =============================== let searchModeToggleInitialized = false; +// Set by the closure on first init; called by subsequent invocations to +// re-display the search dropdown from the controller's cached state. +// Solves the "results vanish on navigate-back" UX issue — a sidebar nav +// click is treated as outside-click and dismisses the dropdown, so when +// the user returns to /search we need to re-render whatever was cached. +let _searchPageRestoreOnEnter = null; +// Exposed so the global-search widget's Soulseek handoff can sync the +// controller's state.query to the widget's query before clicking the +// Soulseek icon — otherwise onSoulseekSelected fires with whatever the +// user last typed on /search and overwrites the basic input. +let _searchPageController = null; function initializeSearchModeToggle() { - // Only initialize once to prevent duplicate event listeners + // Subsequent invocations: just re-display cached results so they don't + // vanish on navigate-back. Skip the duplicate-listener setup. if (searchModeToggleInitialized) { - console.log('Search mode toggle already initialized, skipping...'); + if (_searchPageRestoreOnEnter) _searchPageRestoreOnEnter(); return; } - const sourceSelect = document.getElementById('search-source-select'); + const sourceRow = document.getElementById('enh-source-row'); const basicSection = document.getElementById('basic-search-section'); const enhancedSection = document.getElementById('enhanced-search-section'); - if (!sourceSelect || !basicSection || !enhancedSection) { + if (!sourceRow || !basicSection || !enhancedSection) { console.warn('Search source picker elements not found'); return; } @@ -68,31 +67,12 @@ function initializeSearchModeToggle() { searchModeToggleInitialized = true; console.log('✅ Initializing search source picker (first time only)'); - // Current source selection — 'auto' (fan-out) by default. Soulseek routes - // to the raw-file basic search; everything else routes to enhanced. - let currentSearchSource = sourceSelect.value || 'auto'; + // State + fetch dispatch + icon-row rendering live in the shared + // `createSearchController` factory (shared-helpers.js) so this page and + // the global search widget share one implementation. This closure wires + // the controller up with Search-page-specific DOM + callbacks. - const applySourceSelection = (value) => { - currentSearchSource = value; - if (value === 'soulseek') { - basicSection.classList.add('active'); - enhancedSection.classList.remove('active'); - } else { - basicSection.classList.remove('active'); - enhancedSection.classList.add('active'); - } - }; - - applySourceSelection(currentSearchSource); - - sourceSelect.addEventListener('change', (e) => { - applySourceSelection(e.target.value); - console.log('Search source →', currentSearchSource); - }); - - // Initialize enhanced search const enhancedInput = document.getElementById('enhanced-search-input'); - const enhancedSearchBtn = document.getElementById('enhanced-search-btn'); const enhancedCancelBtn = document.getElementById('enhanced-cancel-btn'); const enhancedDropdown = document.getElementById('enhanced-dropdown'); const loadingState = document.getElementById('enhanced-loading'); @@ -100,21 +80,148 @@ function initializeSearchModeToggle() { const resultsContainer = document.getElementById('enhanced-results-container'); let debounceTimer = null; - let abortController = null; - // Multi-source search state - let _enhancedSearchData = null; // Full response with all sources - let _activeSearchSource = null; // Currently displayed source tab - let _altSourceController = null; // AbortController for alternate source fetches + // ── Fallback banner ("Spotify unavailable — showing Deezer") ─────── + function _renderFallbackBanner(state) { + const banner = document.getElementById('enh-fallback-banner'); + if (!banner) return; + const src = state.activeSource; + const actual = state.fallbacks[src]; + if (actual && actual !== src) { + const clicked = (SOURCE_LABELS[src] || {}).text || src; + const served = (SOURCE_LABELS[actual] || {}).text || actual; + banner.textContent = `${clicked} unavailable — showing ${served}.`; + banner.classList.remove('hidden'); + } else { + banner.classList.add('hidden'); + } + } - const SOURCE_LABELS = { - spotify: { text: 'Spotify', tabClass: 'enh-tab-spotify', badgeClass: 'enh-badge-spotify' }, - itunes: { text: 'Apple Music', tabClass: 'enh-tab-itunes', badgeClass: 'enh-badge-itunes' }, - deezer: { text: 'Deezer', tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer' }, - discogs: { text: 'Discogs', tabClass: 'enh-tab-discogs', badgeClass: 'enh-badge-discogs' }, - hydrabase: { text: 'Hydrabase', tabClass: 'enh-tab-hydrabase', badgeClass: 'enh-badge-hydrabase' }, - youtube_videos: { text: 'Music Videos', tabClass: 'enh-tab-youtube', badgeClass: 'enh-badge-youtube' }, - musicbrainz: { text: 'MusicBrainz', tabClass: 'enh-tab-musicbrainz', badgeClass: 'enh-badge-musicbrainz' }, + // Central re-render callback — called by the controller whenever state + // changes (cache hit, fetch settle, query reset). Drives the enhanced + // dropdown UI: loading state, empty state, results render, fallback + // banner. + function _renderFromState(state) { + const src = state.activeSource; + + // Soulseek has its own surface (basic-section) — the controller fires + // onSoulseekSelected for that, so there's nothing to render here. + if (src === 'soulseek') return; + + // Ensure the enhanced section is visible (may have been hidden if the + // user was previously on Soulseek). + basicSection.classList.remove('active'); + enhancedSection.classList.add('active'); + + _renderFallbackBanner(state); + + const cached = state.sources[src]; + const loading = state.loadingSources.has(src); + + // Mid-fetch with no cache yet → loading state. + if (loading && !cached) { + emptyState.classList.add('hidden'); + resultsContainer.classList.add('hidden'); + loadingState.classList.remove('hidden'); + const loadingText = document.getElementById('enhanced-loading-text'); + if (loadingText) { + const info = SOURCE_LABELS[src]; + loadingText.textContent = `Searching ${(info && info.text) || src} and your library...`; + } + showDropdown(); + return; + } + + // No cache + no query → nothing to show; hide the dropdown. + if (!cached) { + if (!state.query) { + hideDropdown(); + return; + } + // Fetch settled with no data — empty state. + loadingState.classList.add('hidden'); + resultsContainer.classList.add('hidden'); + emptyState.classList.remove('hidden'); + showDropdown(); + return; + } + + const total = src === 'youtube_videos' + ? ((cached.videos && cached.videos.length) || 0) + : ((cached.db_artists && cached.db_artists.length) || 0) + + ((cached.artists && cached.artists.length) || 0) + + ((cached.albums && cached.albums.length) || 0) + + ((cached.tracks && cached.tracks.length) || 0); + + loadingState.classList.add('hidden'); + + if (total === 0) { + resultsContainer.classList.add('hidden'); + emptyState.classList.remove('hidden'); + showDropdown(); + return; + } + + emptyState.classList.add('hidden'); + resultsContainer.classList.remove('hidden'); + showDropdown(); + + if (src === 'youtube_videos') { + ['enh-db-artists-section', 'enh-spotify-artists-section', 'enh-albums-section', 'enh-singles-section', 'enh-tracks-section'].forEach(id => { + const el = document.getElementById(id); + if (el) el.classList.add('hidden'); + }); + const artistsWrapper = document.querySelector('.enh-artists-wrapper'); + if (artistsWrapper) artistsWrapper.style.display = 'none'; + _renderVideoResults(cached.videos || []); + return; + } + + const videosSec = document.getElementById('enh-videos-section'); + if (videosSec) videosSec.classList.add('hidden'); + const artistsWrapper = document.querySelector('.enh-artists-wrapper'); + if (artistsWrapper) artistsWrapper.style.display = ''; + + renderDropdownResults({ + db_artists: cached.db_artists || [], + spotify_artists: cached.artists || [], + spotify_albums: cached.albums || [], + spotify_tracks: cached.tracks || [], + metadata_source: src, + }); + } + + const searchController = createSearchController({ + sourceRowElement: sourceRow, + iconClassPrefix: 'enh', + onStateChange: _renderFromState, + onSoulseekSelected: (query) => { + // Soulseek returns raw file results, rendered by the basic-search + // UI — swap sections and re-fire the basic search with the + // current query. + basicSection.classList.add('active'); + enhancedSection.classList.remove('active'); + hideDropdown(); + const basicInput = document.getElementById('downloads-search-input'); + if (basicInput) { + if (query) basicInput.value = query; + if (basicInput.value && typeof performDownloadsSearch === 'function') { + performDownloadsSearch(); + } + } + }, + }); + searchController.init(); + _searchPageController = searchController; + + // Expose a re-render hook so navigate-back to /search restores cached + // results instead of leaving the dropdown hidden. Deferred to the next + // tick so the render happens AFTER the nav-button click finishes + // bubbling to the document outside-click handler — otherwise that + // handler sees the just-shown dropdown and immediately dismisses it. + _searchPageRestoreOnEnter = () => { + if (!searchController.state.query) return; + setTimeout(() => _renderFromState(searchController.state), 0); }; // Live search with debouncing @@ -138,7 +245,7 @@ function initializeSearchModeToggle() { // Debounce search debounceTimer = setTimeout(() => { - performEnhancedSearch(query); + searchController.submitQuery(query); }, 300); }); @@ -147,41 +254,12 @@ function initializeSearchModeToggle() { const query = e.target.value.trim(); if (query.length >= 2) { clearTimeout(debounceTimer); - performEnhancedSearch(query); + searchController.submitQuery(query); } } }); } - if (enhancedSearchBtn) { - enhancedSearchBtn.addEventListener('click', (e) => { - // Prevent click from bubbling to document (which would close the dropdown) - e.stopPropagation(); - - // Get fresh references (in case we navigated away and back) - const dropdown = document.getElementById('enhanced-dropdown'); - const results = document.getElementById('enhanced-results-container'); - - if (!dropdown) return; - - // Toggle the dropdown visibility to show/hide previous search results - if (dropdown.classList.contains('hidden')) { - // Check if there are results to show by looking for actual content - const hasResults = results && - !results.classList.contains('hidden') && - results.children.length > 0; - - if (hasResults) { - showDropdown(); - } else { - showToast('No previous results to show. Type to search!', 'info'); - } - } else { - hideDropdown(); - } - }); - } - if (enhancedCancelBtn) { enhancedCancelBtn.addEventListener('click', () => { enhancedInput.value = ''; @@ -204,105 +282,26 @@ function initializeSearchModeToggle() { const dropdown = document.getElementById('enhanced-dropdown'); if (dropdown && !dropdown.classList.contains('hidden')) { const isClickInside = e.target.closest('.enhanced-search-input-wrapper'); + // Source icons live above the input, outside the dropdown — they + // control which cached source is shown, so don't dismiss when the + // user clicks them. + 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 && !isClickInModal) { + if (!isClickInside && !isClickOnSourceRow && !isClickInModal) { hideDropdown(); } } }); - async function performEnhancedSearch(query) { - console.log('Enhanced search:', query); - const searchId = Date.now() + Math.random(); - - // Show loading state with correct source name - showDropdown(); - const loadingText = document.getElementById('enhanced-loading-text'); - if (loadingText) { - const _sourceLabelMap = { - spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', - discogs: 'Discogs', hydrabase: 'Hydrabase', musicbrainz: 'MusicBrainz', - }; - const _sourceName = currentSearchSource && currentSearchSource !== 'auto' - ? (_sourceLabelMap[currentSearchSource] || currentSearchSource) - : currentMusicSourceName; - loadingText.textContent = `Searching across ${_sourceName} and your library...`; - } - loadingState.classList.remove('hidden'); - emptyState.classList.add('hidden'); - resultsContainer.classList.add('hidden'); - - // Abort previous requests (primary + alternates) - if (abortController) { - abortController.abort(); - } - if (_altSourceController) { - _altSourceController.abort(); - } - abortController = new AbortController(); - _altSourceController = new AbortController(); - - // Initialize multi-source state early so alternate fetches can write to it - _enhancedSearchData = { db_artists: [], primary_source: null, sources: {}, searchId, query }; - - try { - const data = await enhancedSearchFetch(query, { - source: currentSearchSource, - signal: abortController.signal, - }); - console.log('Enhanced results:', data); - - // Store multi-source state - const primarySource = data.primary_source || data.metadata_source || 'deezer'; - _activeSearchSource = primarySource; - _enhancedSearchData = _enhancedSearchData || {}; - _enhancedSearchData.db_artists = data.db_artists; - _enhancedSearchData.primary_source = primarySource; - if (!_enhancedSearchData.sources) _enhancedSearchData.sources = {}; - _enhancedSearchData.sources[primarySource] = { - artists: data.spotify_artists || [], - albums: data.spotify_albums || [], - tracks: data.spotify_tracks || [], - available: true, - }; - - // Calculate total from primary source - const total = (data.db_artists?.length || 0) + - (data.spotify_artists?.length || 0) + - (data.spotify_albums?.length || 0) + - (data.spotify_tracks?.length || 0); - - // Hide loading - loadingState.classList.add('hidden'); - - if (total === 0) { - emptyState.classList.remove('hidden'); - } else { - renderSourceTabs(_enhancedSearchData); - renderDropdownResults(data); - resultsContainer.classList.remove('hidden'); - } - - // Alternate sources now start after the primary response has landed. - // This avoids speculative fan-out for short or aborted searches. - _queueAlternateSourceFetches(data.alternate_sources || [], query, searchId); - - } catch (error) { - if (error.name !== 'AbortError') { - console.error('Enhanced search error:', error); - loadingState.classList.add('hidden'); - emptyState.classList.remove('hidden'); - } - } - } - function renderDropdownResults(data) { + const activeSource = searchController.state.activeSource; + // Music Videos tab — don't render regular sections - if (_activeSearchSource === 'youtube_videos') return; + if (activeSource === 'youtube_videos') return; // Determine source badge from active tab (not just primary) - const displaySource = _activeSearchSource || data.metadata_source || 'spotify'; + const displaySource = activeSource || data.metadata_source || 'spotify'; const sourceInfo = SOURCE_LABELS[displaySource] || SOURCE_LABELS.spotify; const sourceBadge = { text: sourceInfo.text, class: sourceInfo.badgeClass }; @@ -339,7 +338,7 @@ function initializeSearchModeToggle() { meta: 'Artist', badge: sourceBadge, onClick: () => { - const sourceOverride = _activeSearchSource; + const sourceOverride = searchController.state.activeSource; console.log(`🎵 Opening artist detail: ${artist.name} (ID: ${artist.id}, source: ${sourceOverride})`); hideDropdown(); navigateToArtistDetail(artist.id, artist.name, sourceOverride || null); @@ -491,200 +490,6 @@ function initializeSearchModeToggle() { } } - function _queueAlternateSourceFetches(alternateSources, query, searchId) { - if (!Array.isArray(alternateSources) || alternateSources.length === 0) return; - - // Fetch metadata sources first, then YouTube last so it does not compete - // with the primary artist/album/track results for early attention. - const orderedSources = ['spotify', 'itunes', 'deezer', 'discogs', 'musicbrainz', 'hydrabase', 'youtube_videos'] - .filter(src => alternateSources.includes(src) && src !== _activeSearchSource); - - orderedSources.forEach((src, index) => { - setTimeout(() => { - if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return; - _fetchAlternateSource(src, query, searchId); - }, index * 150); - }); - } - - async function _fetchAlternateSource(sourceName, query, searchId) { - try { - if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return; - - const response = await fetch(`/api/enhanced-search/source/${sourceName}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }), - signal: _altSourceController?.signal, - }); - if (!response.ok) return; - if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return; - - // Stream NDJSON — render each search type (artists, albums, tracks) as it arrives - if (!_enhancedSearchData.sources[sourceName]) { - const loadingSet = sourceName === 'youtube_videos' ? new Set(['videos']) : new Set(['artists', 'albums', 'tracks']); - _enhancedSearchData.sources[sourceName] = { artists: [], albums: [], tracks: [], videos: [], available: true, _loading: loadingSet }; - } - const sourceData = _enhancedSearchData.sources[sourceName]; - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - - let newlineIdx; - while ((newlineIdx = buffer.indexOf('\n')) !== -1) { - const line = buffer.slice(0, newlineIdx).trim(); - buffer = buffer.slice(newlineIdx + 1); - if (!line) continue; - if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return; - - try { - const chunk = JSON.parse(line); - if (chunk.type === 'artists') { sourceData.artists = chunk.data; if (sourceData._loading) sourceData._loading.delete('artists'); } - else if (chunk.type === 'albums') { sourceData.albums = chunk.data; if (sourceData._loading) sourceData._loading.delete('albums'); } - else if (chunk.type === 'tracks') { sourceData.tracks = chunk.data; if (sourceData._loading) sourceData._loading.delete('tracks'); } - else if (chunk.type === 'videos') { sourceData.videos = chunk.data; if (sourceData._loading) sourceData._loading.delete('videos'); } - else if (chunk.type === 'done') { delete sourceData._loading; break; } - - // Re-render tabs + content if this is the active source - if (_enhancedSearchData.primary_source) { - renderSourceTabs(_enhancedSearchData); - if (_activeSearchSource === sourceName) { - window._switchEnhSourceTab(sourceName); - } - } - } catch (parseErr) { - console.debug(`NDJSON parse error for ${sourceName}:`, parseErr); - } - } - } - - // Final render - if (_enhancedSearchData && _enhancedSearchData.searchId === searchId && _enhancedSearchData.primary_source) { - renderSourceTabs(_enhancedSearchData); - } - } catch (e) { - if (e.name !== 'AbortError') { - console.debug(`Alternate source ${sourceName} failed:`, e); - } - } - } - - function renderSourceTabs(data) { - const tabBar = document.getElementById('enh-source-tabs'); - if (!tabBar) return; - - const sources = data.sources || {}; - const primary = data.primary_source || 'spotify'; - - // Build tab list: primary first, then alternates sorted alphabetically. - // Hide completed zero-result sources so the bar stays focused. - const sourceNames = Object.keys(sources).filter(s => sources[s].available); - const visibleSources = sourceNames.filter(name => { - const src = sources[name] || {}; - const count = name === 'youtube_videos' - ? (src.videos?.length || 0) - : (src.artists?.length || 0) + (src.albums?.length || 0) + (src.tracks?.length || 0); - const isLoading = !!(src._loading && src._loading.size > 0); - return isLoading || count > 0 || name === _activeSearchSource; - }); - if (visibleSources.length <= 1) { - tabBar.classList.add('hidden'); - tabBar.innerHTML = ''; - return; - } - - // Primary tab first, then others - const ordered = [primary, ...visibleSources.filter(s => s !== primary).sort()]; - - tabBar.innerHTML = ordered.map(name => { - const info = SOURCE_LABELS[name] || { text: name, tabClass: '' }; - const src = sources[name] || {}; - const count = name === 'youtube_videos' - ? (src.videos?.length || 0) - : (src.artists?.length || 0) + (src.albums?.length || 0) + (src.tracks?.length || 0); - const isActive = name === _activeSearchSource; - return ``; - }).join(''); - - tabBar.classList.remove('hidden'); - } - - // Expose tab switch globally (onclick from HTML) - window._switchEnhSourceTab = function (sourceName) { - if (!_enhancedSearchData || !_enhancedSearchData.sources) return; - const src = _enhancedSearchData.sources[sourceName]; - if (!src) return; - - _activeSearchSource = sourceName; - - // Update tab active states - document.querySelectorAll('.enh-source-tab').forEach(tab => { - tab.classList.toggle('active', tab.dataset.source === sourceName); - }); - - // Music Videos tab — render video cards instead of regular sections - if (sourceName === 'youtube_videos') { - // Hide ALL regular sections including wrappers - ['enh-db-artists-section', 'enh-spotify-artists-section', 'enh-albums-section', 'enh-singles-section', 'enh-tracks-section'].forEach(id => { - const el = document.getElementById(id); - if (el) el.classList.add('hidden'); - }); - // Hide the artists wrapper div too - const artistsWrapper = document.querySelector('.enh-artists-wrapper'); - if (artistsWrapper) artistsWrapper.style.display = 'none'; - _renderVideoResults(src.videos || []); - resultsContainer.classList.remove('hidden'); - return; - } - - // Hide videos section and restore regular layout when switching to a metadata tab - const videosSec = document.getElementById('enh-videos-section'); - if (videosSec) videosSec.classList.add('hidden'); - const artistsWrapper = document.querySelector('.enh-artists-wrapper'); - if (artistsWrapper) artistsWrapper.style.display = ''; - - // Build data in the shape renderDropdownResults expects - const viewData = { - db_artists: _enhancedSearchData.db_artists, - spotify_artists: src.artists || [], - spotify_albums: src.albums || [], - spotify_tracks: src.tracks || [], - metadata_source: sourceName, - }; - - renderDropdownResults(viewData); - resultsContainer.classList.remove('hidden'); - - // Show loading spinners for categories still streaming - if (src._loading && src._loading.size > 0) { - const loadingHtml = '
Loading...
'; - if (src._loading.has('artists')) { - const sec = document.getElementById('enh-spotify-artists-section'); - if (sec) { sec.classList.remove('hidden'); document.getElementById('enh-spotify-artists-list').innerHTML = loadingHtml; } - } - if (src._loading.has('albums')) { - const sec = document.getElementById('enh-albums-section'); - if (sec) { sec.classList.remove('hidden'); document.getElementById('enh-albums-list').innerHTML = loadingHtml; } - const sec2 = document.getElementById('enh-singles-section'); - if (sec2) { sec2.classList.remove('hidden'); document.getElementById('enh-singles-list').innerHTML = loadingHtml; } - } - if (src._loading.has('tracks')) { - const sec = document.getElementById('enh-tracks-section'); - if (sec) { sec.classList.remove('hidden'); document.getElementById('enh-tracks-list').innerHTML = loadingHtml; } - } - } - }; - function _renderVideoResults(videos) { let section = document.getElementById('enh-videos-section'); if (!section) { @@ -769,9 +574,17 @@ function initializeSearchModeToggle() { if (!artistId) continue; try { - const imgUrl = _activeSearchSource && _activeSearchSource !== 'spotify' - ? `/api/artist/${artistId}/image?source=${_activeSearchSource}` - : `/api/artist/${artistId}/image`; + const activeSource = searchController.state.activeSource; + // Pass the artist name so the backend can look up images + // for sources that don't store them (e.g. MusicBrainz — + // it only has MBIDs, not artist art, so the resolver + // falls back to iTunes/Deezer keyed by name). + const artistName = card.dataset.artistName || ''; + const params = new URLSearchParams(); + if (activeSource && activeSource !== 'spotify') params.set('source', activeSource); + if (artistName) params.set('name', artistName); + const qs = params.toString(); + const imgUrl = `/api/artist/${artistId}/image${qs ? '?' + qs : ''}`; const response = await fetch(imgUrl); const data = await response.json(); @@ -808,118 +621,7 @@ function initializeSearchModeToggle() { return `${minutes}:${seconds.toString().padStart(2, '0')}`; } - function renderCompactSection(sectionId, listId, countId, items, mapItem) { - const section = document.getElementById(sectionId); - const list = document.getElementById(listId); - const count = document.getElementById(countId); - - if (!list) return; - - list.innerHTML = ''; - - if (!items || items.length === 0) { - section.classList.add('hidden'); - return; - } - - section.classList.remove('hidden'); - count.textContent = items.length; - - // Determine type based on section ID - const isArtist = sectionId.includes('artists'); - const isAlbum = sectionId.includes('albums') || sectionId.includes('singles'); - const isTrack = sectionId.includes('tracks'); - - // Add appropriate grid class to list - if (isArtist) { - list.classList.add('enh-artists-grid'); - } else if (isAlbum) { - list.classList.add('enh-albums-grid'); - } else if (isTrack) { - list.classList.add('enh-tracks-list'); - } - - items.forEach(item => { - const config = mapItem(item); - const elem = document.createElement('div'); - - // Add appropriate card class - if (isArtist) { - elem.className = 'enh-compact-item artist-card'; - // Add data attributes for lazy loading - if (item.id) { - elem.dataset.artistId = item.id; - elem.dataset.needsImage = config.image ? 'false' : 'true'; - } - } else if (isAlbum) { - elem.className = 'enh-compact-item album-card'; - } else if (isTrack) { - elem.className = 'enh-compact-item track-item'; - } - - // Build image HTML with type-specific classes - let imageClass = 'enh-item-image'; - let placeholderClass = 'enh-item-image-placeholder'; - - if (isArtist) { - imageClass += ' artist-image'; - placeholderClass += ' artist-placeholder'; - } else if (isAlbum) { - imageClass += ' album-cover'; - placeholderClass += ' album-placeholder'; - } else if (isTrack) { - imageClass += ' track-cover'; - placeholderClass += ' track-placeholder'; - } - - const imageHtml = config.image - ? `${escapeHtml(config.name)}` - : `
${config.placeholder}
`; - - const badgeHtml = config.badge - ? `
${config.badge.text}
` - : ''; - - const durationHtml = config.duration && isTrack - ? `
- ${escapeHtml(config.duration)} - -
` - : ''; - - elem.innerHTML = ` - ${imageHtml} -
-
${escapeHtml(config.name)}
-
${escapeHtml(config.meta)}
-
- ${durationHtml} - ${badgeHtml} - `; - - elem.addEventListener('click', config.onClick); - - // Add play button handler for tracks - if (isTrack && config.onPlay) { - const playBtn = elem.querySelector('.enh-item-play-btn'); - if (playBtn) { - playBtn.addEventListener('click', (e) => { - e.stopPropagation(); // Don't trigger main onClick - config.onPlay(); - }); - } - } - - list.appendChild(elem); - - // Extract colors from image for dynamic glow effect - if (config.image) { - extractImageColors(config.image, (colors) => { - applyDynamicGlow(elem, colors); - }); - } - }); - } + // renderCompactSection now lives in shared-helpers.js. async function handleEnhancedSearchAlbumClick(album) { console.log(`💿 Enhanced search album clicked: ${album.name} by ${album.artist}`); @@ -929,8 +631,9 @@ function initializeSearchModeToggle() { try { // Fetch full album data with tracks — pass source for correct routing const albumParams = new URLSearchParams({ name: album.name || '', artist: album.artist || '' }); - if (_activeSearchSource && _activeSearchSource !== 'spotify') { - albumParams.set('source', _activeSearchSource); + const activeSource = searchController.state.activeSource; + if (activeSource && activeSource !== 'spotify') { + albumParams.set('source', activeSource); } // Pass Hydrabase plugin origin so server routes to correct client if (album.external_urls?.hydrabase_plugin) { @@ -996,7 +699,7 @@ function initializeSearchModeToggle() { id: firstArtist.id || album.id?.split?.('_')?.[0] || '', name: firstArtist.name || album.artist, image_url: firstArtist.image_url || firstArtist.images?.[0]?.url || '', - source: _activeSearchSource || '', + source: activeSource || '', }; // Prepare full album object for modal @@ -1314,10 +1017,7 @@ function initializeSearchModeToggle() { function showDropdown() { const dropdown = document.getElementById('enhanced-dropdown'); - if (dropdown) { - dropdown.classList.remove('hidden'); - updateToggleButtonState(); - } + if (dropdown) dropdown.classList.remove('hidden'); // Hide the page header + source picker to reclaim space const header = document.querySelector('#search-page .downloads-header'); const modeToggle = document.querySelector('.search-source-picker-container'); @@ -1329,10 +1029,7 @@ function initializeSearchModeToggle() { function hideDropdown() { const dropdown = document.getElementById('enhanced-dropdown'); - if (dropdown) { - dropdown.classList.add('hidden'); - updateToggleButtonState(); - } + if (dropdown) dropdown.classList.add('hidden'); // Restore hidden elements const header = document.querySelector('#search-page .downloads-header'); const modeToggle = document.querySelector('.search-source-picker-container'); @@ -1341,27 +1038,6 @@ function initializeSearchModeToggle() { if (modeToggle) modeToggle.classList.remove('enh-results-active-hide'); if (slskdPlaceholder) slskdPlaceholder.classList.remove('enh-results-active-hide'); } - - function updateToggleButtonState() { - // Get fresh references - const btn = document.getElementById('enhanced-search-btn'); - const dropdown = document.getElementById('enhanced-dropdown'); - - if (!btn || !dropdown) return; - - const btnIcon = btn.querySelector('.btn-icon'); - const btnText = btn.querySelector('.btn-text'); - - if (dropdown.classList.contains('hidden')) { - // Dropdown is hidden - button should say "Show Results" - if (btnIcon) btnIcon.textContent = '👁️'; - if (btnText) btnText.textContent = 'Show Results'; - } else { - // Dropdown is visible - button should say "Hide Results" - if (btnIcon) btnIcon.textContent = '🙈'; - if (btnText) btnText.textContent = 'Hide Results'; - } - } } async function performSearch() { diff --git a/webui/static/settings.js b/webui/static/settings.js index 7d10dabf..fb47a9ec 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -996,6 +996,11 @@ async function loadSettingsData() { const requirePin = settings.security?.require_pin_on_launch || false; document.getElementById('security-require-pin').checked = requirePin; + // CORS origins — stored verbatim as the user typed (string). + const corsOrigins = settings.security?.cors_origins || ''; + const corsField = document.getElementById('security-cors-origins'); + if (corsField) corsField.value = corsOrigins; + // Check if admin has a PIN set const profilesRes = await fetch('/api/profiles'); const profilesData = await profilesRes.json(); @@ -2587,9 +2592,32 @@ async function saveSettings(quiet = false) { }, security: { require_pin_on_launch: document.getElementById('security-require-pin')?.checked || false, + cors_origins: document.getElementById('security-cors-origins')?.value?.trim() || '', } }; + // Validate cors_origins entries — backend silently filters malformed + // values, so warn the user up-front if any line doesn't look like a + // URL (or the special '*' token). One-shot toast; doesn't block save. + const corsRaw = settings.security.cors_origins; + if (corsRaw) { + const entries = corsRaw.replace(/\n/g, ',').split(',') + .map(s => s.trim()) + .filter(s => s); + const invalid = entries.filter(e => { + if (e === '*') return false; + // Accept scheme://host[:port] only — no path, query, or fragment. + // Engineio compares Origin against {scheme}://{host} exactly. + return !/^https?:\/\/[^\s/?#]+$/i.test(e); + }); + if (invalid.length) { + showToast( + `Allowed Origins: ${invalid.length} entr${invalid.length === 1 ? 'y looks' : 'ies look'} malformed (need full URL like https://soulsync.example.com, no trailing slash). Saving anyway — they\'ll be ignored.`, + 'warning' + ); + } + } + try { if (!quiet) showLoadingOverlay('Saving settings...'); diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index 312278fb..348944ea 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -12,6 +12,614 @@ // ============================================================================ +// ---------------------------------------------------------------------------- +// Enhanced search shared utilities (used by Search page + global widget) +// ---------------------------------------------------------------------------- + +// Pass source to restrict results to a single metadata provider; omit or pass +// null/'auto' to let the backend fan out across all configured sources. +async function enhancedSearchFetch(query, { source = null, signal = null } = {}) { + const body = { query }; + if (source && source !== 'auto') body.source = source; + const res = await fetch('/api/enhanced-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: signal || undefined, + }); + if (!res.ok) throw new Error(`Enhanced search failed: ${res.status}`); + return res.json(); +} + +// Per-source labels + tab/badge CSS classes + icon glyph for the source +// picker row. The `logo` URL (when present) renders as an in the +// source-picker chip; `icon` stays as the emoji fallback for sources +// without a canonical logo. Logo URLs mirror the constants in core.js so +// both places stay in sync. +const SOURCE_LABELS = { + spotify: { + text: 'Spotify', icon: '🎵', + logo: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png', + tabClass: 'enh-tab-spotify', badgeClass: 'enh-badge-spotify', + }, + itunes: { + text: 'Apple Music', icon: '🍎', + logo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/ITunes_logo.svg/960px-ITunes_logo.svg.png', + tabClass: 'enh-tab-itunes', badgeClass: 'enh-badge-itunes', + }, + deezer: { + text: 'Deezer', icon: '🎶', + logo: 'https://cdn.brandfetch.io/idEUKgCNtu/theme/dark/symbol.svg?c=1bxid64Mup7aczewSAYMX&t=1758260798610', + tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer', + }, + discogs: { + text: 'Discogs', icon: '📀', + logo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/6b/Discogs_icon.svg/960px-Discogs_icon.svg.png', + tabClass: 'enh-tab-discogs', badgeClass: 'enh-badge-discogs', + }, + hydrabase: { + text: 'Hydrabase', icon: '💎', + logo: '/static/hydrabase.png', + tabClass: 'enh-tab-hydrabase', badgeClass: 'enh-badge-hydrabase', + }, + musicbrainz: { + text: 'MusicBrainz', icon: '🧠', + logo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/MusicBrainz_Logo_%282016%29.svg/500px-MusicBrainz_Logo_%282016%29.svg.png', + tabClass: 'enh-tab-musicbrainz', badgeClass: 'enh-badge-musicbrainz', + }, + youtube_videos: { + text: 'Music Videos', icon: '🎬', + tabClass: 'enh-tab-youtube', badgeClass: 'enh-badge-youtube', + }, + soulseek: { + // No canonical brand logo available — stick with a basic music glyph. + text: 'Soulseek', icon: '🎼', + tabClass: 'enh-tab-soulseek', badgeClass: 'enh-badge-soulseek', + }, +}; + +// Canonical display order for the source picker. Standard metadata sources +// first, then YouTube Music Videos, then Soulseek (basic-file source). +const SOURCE_ORDER = [ + 'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz', + 'youtube_videos', 'soulseek', +]; + +// Sources the config-status endpoint doesn't cover because they don't need +// user-supplied credentials — they always render as "configured" in the picker. +// Soulseek IS configurable (needs slskd URL), so it's intentionally not here: +// /api/settings/config-status reports its real state and the picker dims it +// when no slskd is set up, redirecting clicks to Settings → Downloads. +const _ALWAYS_CONFIGURED_SOURCES = new Set(['musicbrainz', 'youtube_videos']); + +// Fetch /api/settings/config-status and return a map { src -> bool } +// covering every source in SOURCE_ORDER. Sources not present in the backend +// registry (musicbrainz / youtube_videos / soulseek) are reported as +// configured so the picker doesn't dim always-available sources. +async function fetchSourceConfiguredMap() { + const map = {}; + try { + const resp = await fetch('/api/settings/config-status'); + if (resp.ok) { + const data = await resp.json(); + for (const src of SOURCE_ORDER) { + if (_ALWAYS_CONFIGURED_SOURCES.has(src)) { + map[src] = true; + } else { + map[src] = !!(data[src] && data[src].configured); + } + } + return map; + } + } catch (_) { /* fall through to conservative default */ } + // Network / endpoint failure — be permissive rather than dim everything. + for (const src of SOURCE_ORDER) map[src] = true; + return map; +} + +// Shared source-picker controller used by both the unified Search page +// and the global search widget. Owns all the query/active-source/per-query +// cache state, fetch dispatch (enhanced-search for standard sources, NDJSON +// for YouTube Music Videos), configured-source discovery, fallback tracking, +// and icon-row rendering. Each surface passes per-surface wiring — DOM +// elements, a CSS class prefix, and callbacks — and the controller takes +// care of the rest. +// +// Config: +// sourceRowElement — HTMLElement where the icon row is rendered +// iconClassPrefix — 'enh' or 'gsearch' (drives CSS class names) +// onStateChange(state) — called whenever the surface should re-render +// results (cache hit, fetch settle, query reset) +// onSoulseekSelected(q) — surface decides what happens when the user +// clicks the Soulseek icon (basic-section swap +// on the Search page, /search handoff on the +// global widget) +// onUnconfiguredClick(src)— override the default "open Settings" behaviour +// +// Returned methods: +// init() — async; reads /api/settings + /api/settings/ +// config-status, seeds default source, falls +// forward if primary is unconfigured, draws row +// submitQuery(query) — user typed a new query (clears cache on change) +// setActiveSource(src) — user clicked a different source icon +// renderSourceRow() — re-draws the icon row (call after state edits) +function createSearchController({ + sourceRowElement, + iconClassPrefix = 'enh', + onStateChange, + onSoulseekSelected, + onUnconfiguredClick, +} = {}) { + const iconClass = `${iconClassPrefix}-source-icon`; + const glyphClass = `${iconClassPrefix}-source-icon-glyph`; + const labelClass = `${iconClassPrefix}-source-icon-label`; + + // Per-query cache. `sources[src]` holds the result payload the last + // time `src` was fetched for the current query. `fallbacks[src]` + // records the source the backend actually served when it auto-fell- + // back (e.g. user clicked Spotify but got Deezer because Spotify is + // rate-limited). `loadingSources` drives per-icon spinners. The whole + // cache is cleared whenever the query string changes — we never + // serve stale results across queries. + const state = { + query: '', + activeSource: 'spotify', + sources: {}, + fallbacks: {}, + loadingSources: new Set(), + configuredSources: {}, + _initialized: false, + }; + // Optimistic default — replaced by the real config-status lookup on + // init. Prevents a flash of "all unconfigured" icons. + for (const src of SOURCE_ORDER) state.configuredSources[src] = true; + + let abortCtrl = null; + // Per-source request tokens. Each _fetchSource call increments the + // monotonic _requestSeq and stamps it into _sourceRequestIds[src]. + // Settle/error blocks bail before mutating shared state if their + // requestId no longer matches the latest id for THAT source — + // protecting against the fast-retype race (same-source supersession) + // without dropping cleanup for cross-source supersession. + // + // A single global token would mishandle cross-source: switching + // Spotify → Deezer aborts Spotify's fetch, but Spotify's catch needs + // to clear 'spotify' from loadingSources (Deezer's request hasn't + // touched it). Per-source tracking lets each source's catch own its + // own loadingSources entry. + let _requestSeq = 0; + const _sourceRequestIds = Object.create(null); + + function _notify() { if (onStateChange) onStateChange(state); } + + function renderSourceRow() { + if (!sourceRowElement) return; + sourceRowElement.innerHTML = SOURCE_ORDER.map(src => { + const info = SOURCE_LABELS[src]; + if (!info) return ''; + const active = src === state.activeSource; + const cached = !!state.sources[src]; + const loading = state.loadingSources.has(src); + const fallback = state.fallbacks[src]; + const configured = state.configuredSources[src] !== false; + + const classes = [ + iconClass, + active ? 'active' : '', + cached ? 'cached' : '', + loading ? 'loading' : '', + fallback ? 'fallback-warning' : '', + configured ? '' : 'unconfigured', + ].filter(Boolean).join(' '); + + let title; + if (!configured) { + title = `${info.text} — set up in Settings`; + } else if (fallback) { + title = `${info.text} unavailable — served from ${(SOURCE_LABELS[fallback] || {}).text || fallback}`; + } else { + title = info.text; + } + + const glyph = loading + ? '⏳' + : (info.logo + ? `` + : info.icon); + + return ` + `; + }).join(''); + + sourceRowElement.querySelectorAll(`.${iconClass}`).forEach(btn => { + btn.addEventListener('click', (e) => { + // stopPropagation prevents surface-level outside-click handlers + // from dismissing the results while we re-render the icon row + // (which detaches the clicked button from the DOM). + e.stopPropagation(); + setActiveSource(btn.dataset.source); + }); + }); + } + + async function init() { + if (state._initialized) return; + state._initialized = true; + + // Resolve the user's configured primary source. + try { + const resp = await fetch('/api/settings'); + if (resp.ok) { + const settings = await resp.json(); + const cfg = settings.metadata && settings.metadata.fallback_source; + if (cfg && SOURCE_LABELS[cfg]) state.activeSource = cfg; + } + } catch (_) { /* best-effort */ } + if (!SOURCE_LABELS[state.activeSource]) state.activeSource = 'spotify'; + + // Figure out which sources actually have credentials saved. + try { + state.configuredSources = await fetchSourceConfiguredMap(); + } catch (_) { /* keep optimistic default */ } + + // If the configured primary is itself unconfigured (Spotify saved + // as primary but no client_id yet), fall forward to the first + // configured source so the default active icon is usable. + if (state.configuredSources[state.activeSource] === false) { + const firstConfigured = SOURCE_ORDER.find(s => state.configuredSources[s] !== false); + if (firstConfigured) state.activeSource = firstConfigured; + } + + renderSourceRow(); + _notify(); + } + + function setActiveSource(src) { + if (!SOURCE_LABELS[src]) return; + + // Unconfigured — jump to the relevant card in Settings rather than + // firing a search that can't succeed. Don't swap activeSource so the + // user's previous pick stays current when they come back. + if (state.configuredSources[src] === false) { + if (onUnconfiguredClick) onUnconfiguredClick(src); + else openSettingsForSource(src); + return; + } + + // Clicking the already-active source is a no-op for normal sources, + // but for Soulseek we still re-fire the callback so the surface can + // re-issue the handoff (e.g. user typed and wants a fresh search). + if (src === state.activeSource) { + if (src === 'soulseek' && onSoulseekSelected) onSoulseekSelected(state.query); + return; + } + + state.activeSource = src; + renderSourceRow(); + + // Soulseek — let the surface decide what to do (basic-section swap + // on Search page, /search handoff on global widget). We don't cache + // or auto-fetch soulseek results in the controller. + if (src === 'soulseek') { + if (onSoulseekSelected) onSoulseekSelected(state.query); + return; + } + + if (state.sources[src]) { + _notify(); + } else if (state.query) { + _fetchSource(src); + } else { + _notify(); + } + } + + async function _fetchSource(src) { + const query = state.query; + if (!query) return; + + const requestId = ++_requestSeq; + _sourceRequestIds[src] = requestId; + + state.loadingSources.add(src); + renderSourceRow(); + _notify(); + + if (abortCtrl) abortCtrl.abort(); + abortCtrl = new AbortController(); + + try { + if (src === 'youtube_videos') { + await _fetchYouTubeVideos(query, abortCtrl.signal, requestId); + } else { + const data = await enhancedSearchFetch(query, { + source: src, + signal: abortCtrl.signal, + }); + // Bail without writing if a newer request for THIS source + // has superseded us. Cross-source supersession (different + // src entirely) is handled by the loadingSources cleanup + // below — each source's catch owns its own entry. + if (_sourceRequestIds[src] !== requestId) return; + state.sources[src] = { + artists: data.spotify_artists || [], + albums: data.spotify_albums || [], + tracks: data.spotify_tracks || [], + videos: [], + db_artists: data.db_artists || [], + }; + const served = data.primary_source || data.metadata_source; + if (served && served !== src) state.fallbacks[src] = served; + } + + if (_sourceRequestIds[src] !== requestId) return; + state.loadingSources.delete(src); + renderSourceRow(); + _notify(); + } catch (err) { + // Only clear loadingSources if no newer request for THIS source + // is in flight. Cross-source supersession (e.g. user switched + // Spotify → Deezer) still falls through here so Spotify's + // spinner gets cleared on its own AbortError. + if (_sourceRequestIds[src] === requestId) { + state.loadingSources.delete(src); + renderSourceRow(); + _notify(); + } + if (err.name !== 'AbortError') { + console.debug(`Source fetch failed for ${src}:`, err); + } + } + } + + async function _fetchYouTubeVideos(query, signal, requestId) { + const res = await fetch('/api/enhanced-search/source/youtube_videos', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }), + signal, + }); + if (!res.ok) throw new Error(`YouTube search failed: ${res.status}`); + + // Bail before allocating cache entry if a newer YouTube request + // has superseded us. + if (_sourceRequestIds['youtube_videos'] !== requestId) return; + + state.sources['youtube_videos'] = { + artists: [], albums: [], tracks: [], videos: [], db_artists: [], + }; + const cache = state.sources['youtube_videos']; + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (_sourceRequestIds['youtube_videos'] !== requestId) return; + buffer += decoder.decode(value, { stream: true }); + let idx; + while ((idx = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (!line) continue; + try { + const chunk = JSON.parse(line); + if (chunk.type === 'videos') { + cache.videos = chunk.data; + if (state.activeSource === 'youtube_videos') _notify(); + } + } catch (_) { /* best-effort NDJSON parse */ } + } + } + } + + function submitQuery(query) { + if (query !== state.query) { + state.query = query; + state.sources = {}; + state.fallbacks = {}; + state.loadingSources = new Set(); + // Invalidate every in-flight per-source token. Without this, a + // settle that arrives AFTER a query reset (e.g. user typed 'a', + // fetch started, then user cleared the input) would still + // pass the per-source token check and write stale data back + // into the just-cleared state.sources. Setting fresh tokens + // when each new _fetchSource fires re-stamps as needed. + for (const k in _sourceRequestIds) delete _sourceRequestIds[k]; + // Abort the active fetch — its results are useless now. + if (abortCtrl) { abortCtrl.abort(); abortCtrl = null; } + renderSourceRow(); + } + + // Soulseek — surface handles the full query handoff. + if (state.activeSource === 'soulseek') { + if (onSoulseekSelected) onSoulseekSelected(query); + return; + } + + // Cache hit — instant re-render, no fetch. + if (state.sources[state.activeSource]) { + _notify(); + return; + } + + _fetchSource(state.activeSource); + } + + return { + state, + init, + submitQuery, + setActiveSource, + renderSourceRow, + }; +} + + +// Navigate to Settings → relevant tab and scroll to the service card that +// matches the picker's source id. Called when a user clicks an unconfigured +// source icon. Soulseek is special-cased to land on the Downloads tab where +// its slskd URL field lives (gated behind the download-source-mode select); +// every other source has a card on Connections. +function openSettingsForSource(src) { + if (typeof navigateToPage !== 'function') return; + navigateToPage('settings'); + const targetTab = src === 'soulseek' ? 'downloads' : 'connections'; + setTimeout(() => { + try { + if (typeof switchSettingsTab === 'function') switchSettingsTab(targetTab); + } catch (_) { /* best-effort */ } + setTimeout(() => { + // Soulseek doesn't have a .stg-service card — scroll to the + // slskd URL input instead so the user lands on the right field. + const target = src === 'soulseek' + ? document.querySelector('#settings-page #soulseek-url') + : document.querySelector(`#settings-page .stg-service[data-service="${src}"]`); + if (!target) return; + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + if (src === 'soulseek') { + try { target.focus(); } catch (_) { /* best-effort */ } + } else { + target.classList.add('stg-service-flash'); + setTimeout(() => target.classList.remove('stg-service-flash'), 2200); + } + }, 120); + }, 60); +} + +// Render a single enhanced-search result section (artists / albums / tracks). +// Shared between the Search page and the global widget. The mapItem callback +// projects each backend item to the card config consumed here. +function renderCompactSection(sectionId, listId, countId, items, mapItem) { + const section = document.getElementById(sectionId); + const list = document.getElementById(listId); + const count = document.getElementById(countId); + + if (!list) return; + + list.innerHTML = ''; + + if (!items || items.length === 0) { + section.classList.add('hidden'); + return; + } + + section.classList.remove('hidden'); + count.textContent = items.length; + + // Determine type based on section ID + const isArtist = sectionId.includes('artists'); + const isAlbum = sectionId.includes('albums') || sectionId.includes('singles'); + const isTrack = sectionId.includes('tracks'); + + // Add appropriate grid class to list + if (isArtist) { + list.classList.add('enh-artists-grid'); + } else if (isAlbum) { + list.classList.add('enh-albums-grid'); + } else if (isTrack) { + list.classList.add('enh-tracks-list'); + } + + items.forEach(item => { + const config = mapItem(item); + const elem = document.createElement('div'); + + // Add appropriate card class + if (isArtist) { + elem.className = 'enh-compact-item artist-card'; + // Add data attributes for lazy loading + if (item.id) { + elem.dataset.artistId = item.id; + elem.dataset.needsImage = config.image ? 'false' : 'true'; + // Stash the artist name so the lazy-loader can pass it to + // the backend. Needed for sources that don't store artist + // images directly (MusicBrainz) — backend resolves the + // image by looking up the name on a fallback source. + if (config.name) elem.dataset.artistName = config.name; + } + } else if (isAlbum) { + elem.className = 'enh-compact-item album-card'; + } else if (isTrack) { + elem.className = 'enh-compact-item track-item'; + } + + // Build image HTML with type-specific classes + let imageClass = 'enh-item-image'; + let placeholderClass = 'enh-item-image-placeholder'; + + if (isArtist) { + imageClass += ' artist-image'; + placeholderClass += ' artist-placeholder'; + } else if (isAlbum) { + imageClass += ' album-cover'; + placeholderClass += ' album-placeholder'; + } else if (isTrack) { + imageClass += ' track-cover'; + placeholderClass += ' track-placeholder'; + } + + // Fallback placeholder used when the image 404s (common for MB + // Cover Art Archive URLs — we construct them deterministically + // without probing first, so some will miss). Without onerror the + // browser shows its broken-image icon. + const placeholderHtml = `
${config.placeholder}
`; + const escapedFallback = placeholderHtml.replace(/"/g, '"'); + const imageHtml = config.image + ? `${escapeHtml(config.name)}` + : placeholderHtml; + + const badgeHtml = config.badge + ? `
${config.badge.text}
` + : ''; + + const durationHtml = config.duration && isTrack + ? `
+ ${escapeHtml(config.duration)} + +
` + : ''; + + elem.innerHTML = ` + ${imageHtml} +
+
${escapeHtml(config.name)}
+
${escapeHtml(config.meta)}
+
+ ${durationHtml} + ${badgeHtml} + `; + + elem.addEventListener('click', config.onClick); + + // Add play button handler for tracks + if (isTrack && config.onPlay) { + const playBtn = elem.querySelector('.enh-item-play-btn'); + if (playBtn) { + playBtn.addEventListener('click', (e) => { + e.stopPropagation(); // Don't trigger main onClick + config.onPlay(); + }); + } + } + + list.appendChild(elem); + + // Extract colors from image for dynamic glow effect + if (config.image) { + extractImageColors(config.image, (colors) => { + applyDynamicGlow(elem, colors); + }); + } + }); +} + + // ---------------------------------------------------------------------------- // Discography completion checking (for artist-detail pages, library page) // ---------------------------------------------------------------------------- diff --git a/webui/static/style.css b/webui/static/style.css index 13eb0790..e9a556ae 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -5336,6 +5336,38 @@ body.helper-mode-active #dashboard-activity-feed:hover { GLOBAL SEARCH BAR — Spotlight-style search from anywhere ================================================================================== */ +/* Ambient glow under the global search bar — a radial gradient that emanates + from the bar's position and tapers out toward the window corners. Pointer + events disabled so it never intercepts clicks; hidden on /search where the + bar itself is hidden. */ +.gsearch-aura { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 260px; + pointer-events: none; + z-index: 99990; /* below the bar (99998) but above most page content */ + opacity: 0.55; + transition: opacity 0.4s ease, background 0.4s ease; + background: + radial-gradient(ellipse 620px 230px at 50% 100%, + rgba(var(--accent-rgb), 0.22) 0%, + rgba(var(--accent-rgb), 0.10) 32%, + rgba(var(--accent-rgb), 0.03) 62%, + transparent 85%); +} +.gsearch-aura.hidden { display: none; } +.gsearch-aura.active { + opacity: 1; + background: + radial-gradient(ellipse 820px 280px at 50% 100%, + rgba(var(--accent-rgb), 0.40) 0%, + rgba(var(--accent-rgb), 0.18) 28%, + rgba(var(--accent-rgb), 0.05) 58%, + transparent 85%); +} + .gsearch-bar { position: fixed; bottom: 24px; @@ -5434,6 +5466,22 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .gsearch-results.visible { display: flex; animation: gsearchSlideUp 0.2s ease; } +/* Stable results-panel structure built by downloads.js _doInit: the source + row and fallback banner keep their natural height; #gsearch-body grows + to fill what's left of the 60vh cap and scrolls when content overflows. + Without the flex:1 + overflow on #gsearch-body, content past the cap + was clipped with no scrollbar — the source-picker refactor introduced + the structure but not the accompanying CSS. */ +#gsearch-source-row { flex-shrink: 0; } +#gsearch-fallback-banner { flex-shrink: 0; } +#gsearch-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; +} +#gsearch-body::-webkit-scrollbar { width: 4px; } +#gsearch-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 2px; } + @keyframes gsearchSlideUp { from { opacity: 0; transform: translateX(-50%) translateY(10px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } @@ -5479,6 +5527,195 @@ body.helper-mode-active #dashboard-activity-feed:hover { .gsearch-tab.active { background: rgba(var(--accent-rgb), 0.12); color: var(--accent); border-color: rgba(var(--accent-rgb), 0.2); } .gsearch-tab:hover:not(.active) { background: rgba(255,255,255,0.06); } +/* Source icon row in the global widget. The popover itself is already a + glass panel, so this row is just a transparent flex strip — no double + border/background. justify-content: center keeps the chips grouped + instead of drifting to the left when they don't fill the popover width. */ +.gsearch-source-row { + display: flex; + flex-wrap: nowrap; + overflow-x: auto; + overflow-y: visible; + justify-content: center; + gap: 6px; + padding: 12px 14px 8px; + flex-shrink: 0; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.2) transparent; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} +.gsearch-source-row::-webkit-scrollbar { height: 4px; } +.gsearch-source-row::-webkit-scrollbar-track { background: transparent; } +.gsearch-source-row::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 2px; } + +.gsearch-source-icon { + position: relative; + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + min-width: 72px; + padding: 8px 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%); + color: rgba(255, 255, 255, 0.6); + cursor: pointer; + font-family: inherit; + font-size: 10px; + font-weight: 600; + white-space: nowrap; + flex-shrink: 0; + transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease, + color 0.18s ease, box-shadow 0.18s ease; +} +.gsearch-source-icon:hover { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.09) 0%, rgba(255, 255, 255, 0.04) 100%); + color: #fff; + border-color: rgba(255, 255, 255, 0.18); + transform: translateY(-1px); + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.25); +} +.gsearch-source-icon:active { transform: translateY(0) scale(0.97); } + +.gsearch-source-icon-glyph { + font-size: 20px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.25)); +} +.gsearch-source-icon-glyph img { + width: 22px; + height: 22px; + object-fit: contain; + display: block; +} +.gsearch-source-icon-label { font-size: 10px; letter-spacing: 0.02em; font-weight: 600; } +.gsearch-source-icon.active .gsearch-source-icon-label { font-weight: 700; } + +.gsearch-source-icon.active { + transform: scale(1.04); + border-color: currentColor; +} +.gsearch-source-icon[data-source="spotify"].active { + color: #1db954; + background: linear-gradient(180deg, rgba(29, 185, 84, 0.28) 0%, rgba(29, 185, 84, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(29, 185, 84, 0.35), 0 4px 16px rgba(29, 185, 84, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.gsearch-source-icon[data-source="itunes"].active { + color: #fc3c44; + background: linear-gradient(180deg, rgba(252, 60, 68, 0.28) 0%, rgba(252, 60, 68, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(252, 60, 68, 0.35), 0 4px 16px rgba(252, 60, 68, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.gsearch-source-icon[data-source="deezer"].active { + color: #a238ff; + background: linear-gradient(180deg, rgba(162, 56, 255, 0.28) 0%, rgba(162, 56, 255, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(162, 56, 255, 0.35), 0 4px 16px rgba(162, 56, 255, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.gsearch-source-icon[data-source="discogs"].active { + color: #D4A574; + background: linear-gradient(180deg, rgba(212, 165, 116, 0.28) 0%, rgba(212, 165, 116, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(212, 165, 116, 0.35), 0 4px 16px rgba(212, 165, 116, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.gsearch-source-icon[data-source="hydrabase"].active { + color: #00b4d8; + background: linear-gradient(180deg, rgba(0, 180, 216, 0.28) 0%, rgba(0, 180, 216, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(0, 180, 216, 0.35), 0 4px 16px rgba(0, 180, 216, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.gsearch-source-icon[data-source="musicbrainz"].active { + color: #BA3358; + background: linear-gradient(180deg, rgba(186, 51, 88, 0.28) 0%, rgba(186, 51, 88, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(186, 51, 88, 0.35), 0 4px 16px rgba(186, 51, 88, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.gsearch-source-icon[data-source="youtube_videos"].active { + color: #ff4444; + background: linear-gradient(180deg, rgba(255, 0, 0, 0.28) 0%, rgba(255, 0, 0, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(255, 0, 0, 0.35), 0 4px 16px rgba(255, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.gsearch-source-icon[data-source="soulseek"].active { + color: #fff; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0.06) 100%); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.32), 0 4px 16px rgba(255, 255, 255, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.18); +} + +.gsearch-source-icon.cached::after { + content: ''; + position: absolute; + top: 5px; + right: 6px; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 4px currentColor; + animation: gsearch-source-cache-pulse 2.4s ease-in-out infinite; +} +@keyframes gsearch-source-cache-pulse { + 0%, 100% { opacity: 0.6; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.15); } +} +.gsearch-source-icon.loading .gsearch-source-icon-glyph { + animation: gsearch-source-loading-spin 1.2s linear infinite; + opacity: 0.7; +} +@keyframes gsearch-source-loading-spin { + from { transform: rotate(0); } + to { transform: rotate(360deg); } +} +.gsearch-source-icon.fallback-warning { + border-color: rgba(250, 176, 5, 0.5); + box-shadow: 0 0 0 1px rgba(250, 176, 5, 0.25); +} + +/* Same unconfigured treatment as the Search page icons. */ +.gsearch-source-icon.unconfigured { + opacity: 0.42; + filter: grayscale(0.7); + background: rgba(255, 255, 255, 0.02); + border-color: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.5); +} +.gsearch-source-icon.unconfigured:hover { + opacity: 0.75; + filter: grayscale(0.35); + transform: none; + box-shadow: none; + border-color: rgba(255, 255, 255, 0.12); +} +.gsearch-source-icon.unconfigured.active { + background: rgba(255, 255, 255, 0.02); + box-shadow: none; + transform: none; +} + +/* Flash highlight on the Settings service card after scrolling to it via + the picker. Two and a half seconds of gentle accent pulse so the user's + eye catches the card. */ +.stg-service.stg-service-flash { + animation: stg-service-flash-anim 2.2s ease-out; +} +@keyframes stg-service-flash-anim { + 0% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.55); } + 35% { box-shadow: 0 0 0 6px rgba(var(--accent-rgb), 0.25); } + 100% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0); } +} + +.gsearch-fallback-banner { + padding: 6px 14px; + margin: 0 12px 6px; + border-radius: 6px; + background: rgba(250, 176, 5, 0.12); + border: 1px solid rgba(250, 176, 5, 0.3); + color: #fab005; + font-size: 10.5px; + font-weight: 500; +} + /* Section headers */ .gsearch-section-header { font-size: 10px; @@ -22867,6 +23104,39 @@ body.helper-mode-active #dashboard-activity-feed:hover { max-width: 400px; } +/* Hand-off CTA shown in the library empty state when the user's search + returns no library matches — offers to run the same query against the + configured metadata source on the /search page. */ +.library-empty-search-cta { + display: inline-flex; + align-items: center; + gap: 10px; + margin-top: 8px; + padding: 12px 20px; + background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.22), rgba(var(--accent-rgb), 0.08)); + border: 1px solid rgba(var(--accent-rgb), 0.35); + border-radius: 10px; + color: rgb(var(--accent-light-rgb, var(--accent-rgb))); + font-family: inherit; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; +} +.library-empty-search-cta:hover { + transform: translateY(-1px); + border-color: rgba(var(--accent-rgb), 0.55); + box-shadow: 0 6px 20px rgba(var(--accent-rgb), 0.22); +} +.library-empty-search-cta:active { transform: translateY(0); } +.library-empty-search-cta-icon { font-size: 16px; } +.library-empty-search-cta-arrow { + font-weight: 700; + transition: transform 0.15s ease; +} +.library-empty-search-cta:hover .library-empty-search-cta-arrow { transform: translateX(3px); } +#library-empty-search-cta-query { color: #fff; } + /* Pagination */ .library-pagination { display: flex; @@ -33086,38 +33356,6 @@ div.artist-hero-badge { color: #fff; } -.enhanced-search-btn { - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 10px; - padding: 10px 20px; - color: rgba(255, 255, 255, 0.8); - font-family: 'Segoe UI', sans-serif; - font-size: 13px; - font-weight: 600; - cursor: pointer; - display: flex; - align-items: center; - gap: 6px; - transition: all 0.2s ease; - flex-shrink: 0; -} - -.enhanced-search-btn:hover { - background: rgba(255, 255, 255, 0.12); - color: #fff; - transform: none; - box-shadow: none; -} - -.enhanced-search-btn:active { - transform: scale(0.97); -} - -.btn-icon { - font-size: 14px; -} - /* Enhanced Search Status */ .enhanced-search-status { display: flex; @@ -33324,12 +33562,6 @@ div.artist-hero-badge { gap: 8px; } - .enhanced-search-btn { - width: 100%; - justify-content: center; - padding: 10px 16px; - } - #enhanced-search-input { font-size: 14px; } @@ -33350,11 +33582,6 @@ div.artist-hero-badge { margin-right: 6px; } - .enhanced-search-btn { - padding: 9px 14px; - font-size: 12px; - } - /* Better album/track results on mobile */ .album-result-item { margin-bottom: 8px; @@ -33515,6 +33742,212 @@ div.artist-hero-badge { .enh-source-tab.enh-tab-youtube.active { background: rgba(255, 0, 0, 0.2); color: #ff4444; } .enh-source-tab.enh-tab-musicbrainz.active { background: rgba(186, 51, 88, 0.2); color: #BA3358; } +/* ── Source picker icon row (replaces dropdown + post-search tabs) ── */ +.enh-source-row { + display: flex; + flex-wrap: nowrap; + overflow-x: auto; + overflow-y: visible; + gap: 10px; + padding: 12px 14px; + margin: 10px 0 14px; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.2) transparent; + /* Frosted-glass panel that holds the icons together visually. */ + background: linear-gradient(180deg, rgba(255, 255, 255, 0.035) 0%, rgba(255, 255, 255, 0.015) 100%); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 14px; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 2px 10px rgba(0, 0, 0, 0.15); + /* Don't compress when the parent flex container is tight. */ + flex-shrink: 0; +} +.enh-source-row::-webkit-scrollbar { height: 6px; } +.enh-source-row::-webkit-scrollbar-track { background: transparent; } +.enh-source-row::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 3px; } + +.enh-source-icon { + position: relative; + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + min-width: 90px; + padding: 12px 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%); + color: rgba(255, 255, 255, 0.65); + cursor: pointer; + transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease, + color 0.18s ease, box-shadow 0.18s ease; + font-family: inherit; + font-size: 11.5px; + font-weight: 600; + white-space: nowrap; + flex-shrink: 0; +} +.enh-source-icon:hover { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.09) 0%, rgba(255, 255, 255, 0.04) 100%); + color: #fff; + border-color: rgba(255, 255, 255, 0.18); + transform: translateY(-1px); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); +} +.enh-source-icon:active { transform: translateY(0) scale(0.97); } + +.enh-source-icon-glyph { + font-size: 26px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.25)); +} +.enh-source-icon-glyph img { + width: 30px; + height: 30px; + object-fit: contain; + display: block; +} +.enh-source-icon-label { + font-size: 11.5px; + letter-spacing: 0.02em; + font-weight: 600; +} +.enh-source-icon.active .enh-source-icon-label { font-weight: 700; } + +/* Active state — brand-coloured gradient + outer glow. Each source gets its + own palette. `transform: scale(1.03)` lifts the chip slightly above its + siblings. */ +.enh-source-icon.active { + transform: scale(1.03); + border-color: currentColor; +} +.enh-source-icon[data-source="spotify"].active { + color: #1db954; + background: linear-gradient(180deg, rgba(29, 185, 84, 0.28) 0%, rgba(29, 185, 84, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(29, 185, 84, 0.35), 0 6px 22px rgba(29, 185, 84, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.enh-source-icon[data-source="itunes"].active { + color: #fc3c44; + background: linear-gradient(180deg, rgba(252, 60, 68, 0.28) 0%, rgba(252, 60, 68, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(252, 60, 68, 0.35), 0 6px 22px rgba(252, 60, 68, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.enh-source-icon[data-source="deezer"].active { + color: #a238ff; + background: linear-gradient(180deg, rgba(162, 56, 255, 0.28) 0%, rgba(162, 56, 255, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(162, 56, 255, 0.35), 0 6px 22px rgba(162, 56, 255, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.enh-source-icon[data-source="discogs"].active { + color: #D4A574; + background: linear-gradient(180deg, rgba(212, 165, 116, 0.28) 0%, rgba(212, 165, 116, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(212, 165, 116, 0.35), 0 6px 22px rgba(212, 165, 116, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.enh-source-icon[data-source="hydrabase"].active { + color: #00b4d8; + background: linear-gradient(180deg, rgba(0, 180, 216, 0.28) 0%, rgba(0, 180, 216, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(0, 180, 216, 0.35), 0 6px 22px rgba(0, 180, 216, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.enh-source-icon[data-source="musicbrainz"].active { + color: #BA3358; + background: linear-gradient(180deg, rgba(186, 51, 88, 0.28) 0%, rgba(186, 51, 88, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(186, 51, 88, 0.35), 0 6px 22px rgba(186, 51, 88, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.enh-source-icon[data-source="youtube_videos"].active { + color: #ff4444; + background: linear-gradient(180deg, rgba(255, 0, 0, 0.28) 0%, rgba(255, 0, 0, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(255, 0, 0, 0.35), 0 6px 22px rgba(255, 0, 0, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.enh-source-icon[data-source="soulseek"].active { + color: #fff; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0.06) 100%); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.32), 0 6px 22px rgba(255, 255, 255, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.18); +} + +/* Cache dot — brand-coloured pulse on icons that have results cached for + the current query. */ +.enh-source-icon.cached::after { + content: ''; + position: absolute; + top: 7px; + right: 9px; + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 6px currentColor; + animation: enh-source-cache-pulse 2.4s ease-in-out infinite; +} +@keyframes enh-source-cache-pulse { + 0%, 100% { opacity: 0.6; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.15); } +} + +.enh-source-icon.loading .enh-source-icon-glyph { + animation: enh-source-loading-spin 1.2s linear infinite; + opacity: 0.7; +} +@keyframes enh-source-loading-spin { + from { transform: rotate(0); } + to { transform: rotate(360deg); } +} + +.enh-source-icon.fallback-warning { + border-color: rgba(250, 176, 5, 0.5); + box-shadow: 0 0 0 1px rgba(250, 176, 5, 0.25); +} + +/* Unconfigured — no credentials saved for this source. The chip still + clicks (redirects to Settings → Connections), but looks muted so the + user's eye is drawn to the sources that actually work. */ +.enh-source-icon.unconfigured { + opacity: 0.42; + filter: grayscale(0.7); + background: rgba(255, 255, 255, 0.02); + border-color: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.5); +} +.enh-source-icon.unconfigured:hover { + opacity: 0.75; + filter: grayscale(0.35); + transform: none; + box-shadow: none; + border-color: rgba(255, 255, 255, 0.12); +} +/* Kill brand glow / active gradient if an unconfigured source is somehow + marked active (defensive — setActiveSource bails before this normally). */ +.enh-source-icon.unconfigured.active { + background: rgba(255, 255, 255, 0.02); + box-shadow: none; + transform: none; +} + +/* Rate-limit fallback banner above the enhanced results. */ +.enh-fallback-banner { + padding: 8px 12px; + margin-bottom: 10px; + border-radius: 8px; + background: rgba(250, 176, 5, 0.12); + border: 1px solid rgba(250, 176, 5, 0.3); + color: #fab005; + font-size: 12px; + font-weight: 500; +} +.enh-fallback-banner.hidden { display: none; } + /* Music Video Grid */ .enh-video-grid { display: grid; @@ -43399,6 +43832,9 @@ a.enhanced-id-badge:visited { .enhanced-artist-meta-actions { display: flex; gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; } .enhanced-meta-save-btn, .enhanced-meta-cancel-btn { @@ -44237,6 +44673,272 @@ textarea.enhanced-meta-field-input { cursor: not-allowed; } +/* ═══════════════════════════════════════════════════════════════════════════ + REORGANIZE STATUS PANEL — sits at the start of .enhanced-artist-meta-actions + ═══════════════════════════════════════════════════════════════════════════ */ + +.reorganize-status-panel { + flex: 1 1 100%; + order: -1; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + padding: 8px 12px; + font-size: 12px; + color: rgba(255, 255, 255, 0.85); + transition: background 0.2s ease, border-color 0.2s ease; + min-width: 0; +} +.reorganize-status-panel.hidden { + display: none; +} + +.reorg-panel-compact { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + cursor: pointer; + user-select: none; + min-width: 0; +} +.reorg-panel-compact-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1 1 auto; +} +.reorg-panel-compact-right { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} +.reorg-panel-active-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} +.reorg-panel-active-text strong { + color: #ffffff; + font-weight: 600; +} +.reorg-panel-spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid rgba(var(--accent-rgb), 0.25); + border-top-color: rgb(var(--accent-rgb)); + border-radius: 50%; + flex-shrink: 0; + animation: reorgPanelSpin 0.9s linear infinite; +} +@keyframes reorgPanelSpin { + to { transform: rotate(360deg); } +} +.reorg-panel-recent-icon { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} +.reorg-panel-recent-icon.recent-ok { background: #4ade80; box-shadow: 0 0 6px rgba(74, 222, 128, 0.4); } +.reorg-panel-recent-icon.recent-warn { background: #facc15; box-shadow: 0 0 6px rgba(250, 204, 21, 0.4); } + +.reorg-panel-queue-badge { + background: rgba(var(--accent-rgb), 0.15); + color: rgb(var(--accent-light-rgb)); + border: 1px solid rgba(var(--accent-rgb), 0.3); + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.4px; +} +.reorg-panel-chevron { + color: rgba(255, 255, 255, 0.5); + font-size: 11px; + transition: color 0.2s ease; +} +.reorg-panel-compact:hover .reorg-panel-chevron { + color: rgba(255, 255, 255, 0.85); +} + +.reorg-panel-expanded { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + display: flex; + flex-direction: column; + gap: 10px; +} + +.reorg-panel-active-card { + background: rgba(var(--accent-rgb), 0.06); + border: 1px solid rgba(var(--accent-rgb), 0.18); + border-radius: 8px; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 6px; +} +.reorg-panel-active-title { + font-weight: 600; + color: #ffffff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.reorg-panel-cross-artist { + margin-left: 6px; + color: rgba(255, 255, 255, 0.55); + font-weight: 400; + font-size: 11px; + font-style: italic; +} +.reorg-panel-progress-track { + width: 100%; + height: 4px; + background: rgba(255, 255, 255, 0.08); + border-radius: 2px; + overflow: hidden; +} +.reorg-panel-progress-fill { + height: 100%; + background: linear-gradient(90deg, + rgb(var(--accent-rgb)) 0%, + rgb(var(--accent-light-rgb)) 100%); + border-radius: 2px; + transition: width 0.4s ease; +} +.reorg-panel-active-meta { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + color: rgba(255, 255, 255, 0.65); + font-size: 11px; +} +.reorg-panel-current-track { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1 1 auto; + color: rgba(255, 255, 255, 0.5); + font-style: italic; +} +.reorg-panel-counters { + display: inline-flex; + gap: 8px; + flex-shrink: 0; +} +.reorg-panel-counters .ok { color: #4ade80; } +.reorg-panel-counters .warn { color: #facc15; } +.reorg-panel-counters .fail { color: #f87171; } + +.reorg-panel-section-header { + display: flex; + align-items: center; + justify-content: space-between; + color: rgba(255, 255, 255, 0.5); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.6px; +} +.reorg-panel-clear-btn { + background: transparent; + border: 1px solid rgba(248, 113, 113, 0.3); + color: #f87171; + padding: 3px 10px; + border-radius: 5px; + font-size: 10px; + font-weight: 600; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 0.4px; + transition: all 0.15s ease; +} +.reorg-panel-clear-btn:hover { + background: rgba(248, 113, 113, 0.12); + border-color: rgba(248, 113, 113, 0.5); +} + +.reorg-panel-list { + display: flex; + flex-direction: column; + gap: 4px; +} +.reorg-panel-row { + display: flex; + align-items: center; + gap: 10px; + background: rgba(255, 255, 255, 0.025); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 6px; + padding: 6px 10px; + min-width: 0; +} +.reorg-panel-row-pos { + color: rgba(255, 255, 255, 0.35); + font-size: 10px; + font-weight: 700; + flex-shrink: 0; + width: 22px; +} +.reorg-panel-row-body { + flex: 1 1 auto; + min-width: 0; +} +.reorg-panel-row-title { + color: rgba(255, 255, 255, 0.85); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.reorg-panel-row-sub { + color: rgba(255, 255, 255, 0.45); + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.reorg-panel-cancel-btn { + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.4); + width: 22px; + height: 22px; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + line-height: 1; + flex-shrink: 0; + transition: all 0.15s ease; +} +.reorg-panel-cancel-btn:hover { + background: rgba(248, 113, 113, 0.15); + color: #f87171; +} +.reorg-panel-row.recent-row.success { border-color: rgba(74, 222, 128, 0.18); } +.reorg-panel-row.recent-row.warning { border-color: rgba(250, 204, 21, 0.18); } +.reorg-panel-row.recent-row.cancelled { border-color: rgba(255, 255, 255, 0.08); opacity: 0.7; } +.reorg-panel-row-icon { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.reorg-panel-row-icon.success { background: #4ade80; } +.reorg-panel-row-icon.warning { background: #facc15; } +.reorg-panel-row-icon.cancelled { background: rgba(255, 255, 255, 0.3); } + /* ═══════════════════════════════════════════════════════════════════════════ DOWNLOAD DISCOGRAPHY — Button + Modal ═══════════════════════════════════════════════════════════════════════════ */ @@ -45729,6 +46431,32 @@ textarea.enhanced-meta-field-input { color: rgba(100, 149, 237, 0.9); border-color: rgba(100, 149, 237, 0.35); } +.enhanced-reorganize-album-btn.reorg-state-queued { + background: rgba(var(--accent-rgb), 0.10); + border-color: rgba(var(--accent-rgb), 0.35); + color: rgb(var(--accent-light-rgb)); +} +.enhanced-reorganize-album-btn.reorg-state-queued::before { + content: '⏳ '; + margin-right: 2px; +} +.enhanced-reorganize-album-btn.reorg-state-running { + background: rgba(var(--accent-rgb), 0.18); + border-color: rgba(var(--accent-rgb), 0.55); + color: #ffffff; +} +.enhanced-reorganize-album-btn.reorg-state-running::before { + content: ''; + display: inline-block; + width: 9px; + height: 9px; + margin-right: 5px; + border: 2px solid rgba(255,255,255,0.35); + border-top-color: #ffffff; + border-radius: 50%; + animation: reorgPanelSpin 0.9s linear infinite; + vertical-align: -1px; +} .enhanced-redownload-album-btn { padding: 6px 14px; @@ -45799,6 +46527,19 @@ textarea.enhanced-meta-field-input { .reorganize-template-input:focus { border-color: rgba(100, 149, 237, 0.5); } +/* `