Merge branch 'dev' into fix/downloads-page

# Conflicts:
#	webui/static/style.css
This commit is contained in:
JohnBaumb 2026-04-27 10:20:36 -07:00
commit a00d069369
53 changed files with 12829 additions and 3467 deletions

View file

@ -1,5 +1,8 @@
# Docker ignore file for SoulSync WebUI # Docker ignore file for SoulSync WebUI
# Hidden folders and files
.*
# Git # Git
.git .git
.gitignore .gitignore

View file

@ -8,6 +8,7 @@ on:
jobs: jobs:
cleanup: cleanup:
if: github.repository == 'Nezreka/SoulSync'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
packages: write packages: write

View file

@ -13,6 +13,7 @@ on:
jobs: jobs:
nightly: nightly:
if: github.repository == 'Nezreka/SoulSync'
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Skip scheduled runs if dev branch has no new commits in the last 24h # Skip scheduled runs if dev branch has no new commits in the last 24h
# (pushes and manual triggers always run) # (pushes and manual triggers always run)

View file

@ -15,6 +15,7 @@ on:
jobs: jobs:
build-and-push: build-and-push:
if: github.repository == 'Nezreka/SoulSync'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:

View file

@ -8,7 +8,7 @@ from datetime import datetime, timedelta
from utils.logging_config import get_logger from utils.logging_config import get_logger
from database.music_database import MusicDatabase from database.music_database import MusicDatabase
from core.deezer_client import DeezerClient 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") logger = get_logger("deezer_worker")
@ -579,6 +579,17 @@ class DeezerWorker:
WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]') WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]')
""", (json.dumps(genre_names), album_id)) """, (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() conn.commit()
except Exception as e: except Exception as e:

View file

@ -18,11 +18,34 @@ from datetime import datetime, timedelta
from utils.logging_config import get_logger from utils.logging_config import get_logger
from database.music_database import MusicDatabase from database.music_database import MusicDatabase
from core.discogs_client import DiscogsClient 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") 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: class DiscogsWorker:
"""Background worker for enriching library artists and albums with Discogs metadata.""" """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 = '') WHERE id = ? AND (thumb_url IS NULL OR thumb_url = '')
""", (image_url, album_id)) """, (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() conn.commit()
except Exception as e: except Exception as e:

View file

@ -8,7 +8,7 @@ from datetime import datetime, timedelta
from utils.logging_config import get_logger from utils.logging_config import get_logger
from database.music_database import MusicDatabase from database.music_database import MusicDatabase
from core.itunes_client import iTunesClient 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") logger = get_logger("itunes_worker")
@ -669,6 +669,10 @@ class iTunesWorker:
WHERE id = ? AND (year IS NULL OR year = '' OR year = '0') WHERE id = ? AND (year IS NULL OR year = '' OR year = '0')
""", (year, album_id)) """, (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() conn.commit()
except Exception as e: except Exception as e:
logger.error(f"Error updating album #{album_id} with iTunes data: {e}") logger.error(f"Error updating album #{album_id} with iTunes data: {e}")

View file

@ -1194,7 +1194,49 @@ class JellyfinClient:
stats['bulk_tracks_cached'] = len(self._all_tracks_cache) stats['bulk_tracks_cached'] = len(self._all_tracks_cache)
return stats 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]: def get_all_playlists(self) -> List[JellyfinPlaylistInfo]:
"""Get all playlists from Jellyfin server""" """Get all playlists from Jellyfin server"""
if not self.ensure_connection(): if not self.ensure_connection():

1496
core/library_reorganize.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -1783,8 +1783,14 @@ def get_artist_image_url(
artist_id: str, artist_id: str,
source_override: Optional[str] = None, source_override: Optional[str] = None,
plugin: Optional[str] = None, plugin: Optional[str] = None,
artist_name: Optional[str] = None,
) -> Optional[str]: ) -> 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: if not artist_id:
return None return None
@ -1801,6 +1807,14 @@ def get_artist_image_url(
return _get_artist_image_from_source('itunes', artist_id) return _get_artist_image_from_source('itunes', artist_id)
return None 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: if source_override:
return _get_artist_image_from_source(source_override, artist_id) return _get_artist_image_from_source(source_override, artist_id)
@ -1812,6 +1826,41 @@ def get_artist_image_url(
return None 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(): def get_deezer_client():
"""Get cached Deezer client. """Get cached Deezer client.

View file

@ -44,65 +44,85 @@ def rate_limited(func):
class MusicBrainzClient: class MusicBrainzClient:
"""Client for interacting with MusicBrainz API""" """Client for interacting with MusicBrainz API"""
BASE_URL = "https://musicbrainz.org/ws/2" 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 = ""): def __init__(self, app_name: str = "SoulSync", app_version: str = "1.0", contact_email: str = ""):
""" """
Initialize MusicBrainz client Initialize MusicBrainz client
Args: Args:
app_name: Name of the application app_name: Name of the application
app_version: Version 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}" contact = contact_email or self.DEFAULT_CONTACT
if contact_email: self.user_agent = f"{app_name}/{app_version} ( {contact} )"
self.user_agent += f" ( {contact_email} )"
self.session = requests.Session() self.session = requests.Session()
self.session.headers.update({ self.session.headers.update({
'User-Agent': self.user_agent, 'User-Agent': self.user_agent,
'Accept': 'application/json' 'Accept': 'application/json'
}) })
logger.info(f"MusicBrainz client initialized with user agent: {self.user_agent}") logger.info(f"MusicBrainz client initialized with user agent: {self.user_agent}")
@rate_limited @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: Args:
artist_name: Name of the artist to search for artist_name: Name of the artist to search for
limit: Maximum number of results to return 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: 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: try:
# Escape quotes and backslashes for Lucene query # Escape quotes and backslashes for Lucene query
safe_name = artist_name.replace('\\', '\\\\').replace('"', '\\"') 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 = { params = {
'query': f'artist:"{safe_name}"', 'query': query,
'fmt': 'json', 'fmt': 'json',
'limit': limit 'limit': limit
} }
response = self.session.get( response = self.session.get(
f"{self.BASE_URL}/artist", f"{self.BASE_URL}/artist",
params=params, params=params,
timeout=10 timeout=10
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
artists = data.get('artists', []) artists = data.get('artists', [])
logger.debug(f"Found {len(artists)} artists for query: {artist_name}") logger.debug(f"Found {len(artists)} artists for query: {artist_name}")
return artists return artists
except Exception as e: except Exception as e:
logger.error(f"Error searching for artist '{artist_name}': {e}") logger.error(f"Error searching for artist '{artist_name}': {e}")
return [] return []
@ -197,6 +217,98 @@ class MusicBrainzClient:
logger.error(f"Error searching for recording '{track_name}': {e}") logger.error(f"Error searching for recording '{track_name}': {e}")
return [] 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 artistrelease-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=<mbid>`) 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:<mbid>` 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 @rate_limited
def get_artist(self, mbid: str, includes: Optional[List[str]] = None) -> Optional[Dict[str, Any]]: 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}") logger.error(f"Error fetching release {mbid}: {e}")
return None 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 @rate_limited
def get_recording(self, mbid: str, includes: Optional[List[str]] = None) -> Optional[Dict[str, Any]]: def get_recording(self, mbid: str, includes: Optional[List[str]] = None) -> Optional[Dict[str, Any]]:
""" """

View file

@ -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). Album art is fetched from Cover Art Archive (free, linked by release MBID).
""" """
import requests import threading
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@ -59,29 +59,24 @@ class Album:
external_urls: Optional[Dict[str, str]] = None external_urls: Optional[Dict[str, str]] = None
def _get_cover_art_url(release_mbid: str) -> Optional[str]: def _cover_art_url(mbid: str, scope: str = 'release') -> Optional[str]:
"""Fetch album art URL from Cover Art Archive. Returns None if not available.""" """Build a Cover Art Archive URL without hitting the network.
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
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 <img> 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]: `scope` is 'release' (most specific) or 'release-group' (covers all
"""Fetch album art from release group (covers all editions).""" editions better hit rate).
try: """
url = f"{COVER_ART_ARCHIVE_URL}/release-group/{release_group_mbid}/front-250" if not mbid:
resp = requests.head(url, timeout=3, allow_redirects=True)
if resp.status_code == 200:
return resp.url
return None
except Exception:
return None 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]: 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] 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: def _map_release_type(primary_type: str, secondary_types: List[str] = None) -> str:
"""Map MusicBrainz release group type to standard album_type.""" """Map MusicBrainz release group type to standard album_type."""
pt = (primary_type or '').lower() pt = (primary_type or '').lower()
@ -116,49 +133,304 @@ class MusicBrainzSearchClient:
def __init__(self): def __init__(self):
from core.musicbrainz_client import MusicBrainzClient from core.musicbrainz_client import MusicBrainzClient
self._client = MusicBrainzClient("SoulSync", "2.3") # Client defaults to the project URL as its User-Agent contact,
self._art_cache: Dict[str, Optional[str]] = {} # mbid -> url # 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]: def _cached_art(self, release_mbid: str, release_group_mbid: str = '') -> Optional[str]:
"""Get cover art with caching. Tries release first, then release group.""" """Build a Cover Art Archive URL for a release / release-group MBID.
if release_mbid in self._art_cache:
return self._art_cache[release_mbid]
url = _get_cover_art_url(release_mbid) Prefers release-group scope when provided better hit rate because
if not url and release_group_mbid: it covers all editions of the same album. No network call; the
url = _get_release_group_art(release_group_mbid) frontend's <img onerror> fallback handles 404s.
self._art_cache[release_mbid] = url """
return url 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]: def search_artists(self, query: str, limit: int = 10) -> List[Artist]:
"""MusicBrainz search tab doesn't show artists — only albums and tracks.""" """Search MusicBrainz for artists by name.
return []
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]: 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:
# Try to split "Artist Album" for better matching artist_name, title = self._split_structured_query(query)
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
# 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) results = self._client.search_release(album_name, artist_name=artist_name, limit=limit)
# Score filter — same threshold as artists. Drops garbage
# If no separator, try word-boundary splitting # title-match hits from unrelated releases.
if not results and not artist_name: results = [r for r in results if (r.get('score', 0) or 0) >= self._MIN_SCORE]
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
albums = [] albums = []
for r in results: for r in results:
@ -223,165 +495,300 @@ class MusicBrainzSearchClient:
logger.warning(f"MusicBrainz album search failed: {e}") logger.warning(f"MusicBrainz album search failed: {e}")
return [] 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]: 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:
# Try to split "Artist - Title" for better matching artist_name, title = self._split_structured_query(query)
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
# 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=<mbid> (browse) rejects inc=releases,
# so we use the fielded Lucene search arid:<mbid> 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) 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 = [] tracks = []
for r in results: for r in results:
mbid = r.get('id', '') t = self._recording_to_track(r, artist_name or '')
title = r.get('title', '') if t:
if not title: tracks.append(t)
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,
))
return tracks return tracks
except Exception as e: except Exception as e:
logger.warning(f"MusicBrainz track search failed: {e}") logger.warning(f"MusicBrainz track search failed: {e}")
return [] return []
def get_album(self, release_mbid: str) -> Optional[Dict[str, Any]]: def _pick_representative_release(self, releases: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Get full album details with track listing for download modal.""" """Pick the best release out of a release-group's editions.
try:
release = self._client.get_release(release_mbid, includes=['recordings', 'artist-credits', 'release-groups'])
if not release:
return None
title = release.get('title', '') Release-groups often contain 5-20+ releases (original, reissues,
artists_raw = _extract_artist_credit(release.get('artist-credit', [])) remasters, regional editions, bonus-track editions). We want a
release_date = release.get('date', '') or '' single canonical version to show the user as 'the album.' Prefer:
1. Official releases (not promo/bootleg)
rg = release.get('release-group', {}) 2. Earliest date (the original)
primary_type = rg.get('primary-type', '') or '' 3. Any release with media (skip entries that are just stubs)
secondary_types = rg.get('secondary-types', []) or [] """
album_type = _map_release_type(primary_type, secondary_types) if not releases:
# 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}")
return None 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: def get_artist_albums(self, artist_mbid: str, album_type: str = 'album,single') -> List:
"""Get artist's releases for discography view.""" """Get artist's releases for discography view."""
try: try:

453
core/reorganize_queue.py Normal file
View file

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

123
core/reorganize_runner.py Normal file
View file

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

View file

@ -7,6 +7,7 @@ from core.metadata_service import (
) )
from core.repair_jobs import register_job from core.repair_jobs import register_job
from core.repair_jobs.base import JobContext, JobResult, RepairJob 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 from utils.logging_config import get_logger
logger = get_logger("repair_job.album_complete") logger = get_logger("repair_job.album_complete")
@ -19,9 +20,10 @@ class AlbumCompletenessJob(RepairJob):
description = 'Checks if all tracks from albums are present' description = 'Checks if all tracks from albums are present'
help_text = ( help_text = (
'Compares the number of tracks you have for each album against the expected total ' '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. ' 'from your configured metadata sources. Counts cached during normal enrichment are '
'Albums where tracks are missing get flagged as findings with details about which ' 'used when available; otherwise the job queries a metadata source directly. Albums '
'tracks are absent.\n\n' '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. ' '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' 'You can use the Download Missing feature from the album page to fill gaps.\n\n'
'Settings:\n' 'Settings:\n'
@ -53,6 +55,7 @@ class AlbumCompletenessJob(RepairJob):
conn = None conn = None
has_itunes = False has_itunes = False
has_deezer = False has_deezer = False
has_api_track_count = False
try: try:
conn = context.db._get_connection() conn = context.db._get_connection()
cursor = conn.cursor() cursor = conn.cursor()
@ -65,17 +68,31 @@ class AlbumCompletenessJob(RepairJob):
has_discogs = 'discogs_id' in columns has_discogs = 'discogs_id' in columns
has_hydrabase = 'soul_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 = [ select_cols = [
('al.id', 'album_id'), ('al.id', 'album_id'),
('al.title', 'album_title'), ('al.title', 'album_title'),
('ar.name', 'artist_name'), ('ar.name', 'artist_name'),
('al.spotify_album_id', 'spotify_album_id'), ('al.spotify_album_id', 'spotify_album_id'),
('al.track_count', 'track_count'),
('COUNT(t.id)', 'actual_count'), ('COUNT(t.id)', 'actual_count'),
('al.thumb_url', 'album_thumb_url'), ('al.thumb_url', 'album_thumb_url'),
('ar.thumb_url', 'artist_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: if has_itunes:
select_cols.append(('al.itunes_album_id', 'itunes_album_id')) select_cols.append(('al.itunes_album_id', 'itunes_album_id'))
if has_deezer: if has_deezer:
@ -135,7 +152,6 @@ class AlbumCompletenessJob(RepairJob):
title = row[column_index['album_title']] title = row[column_index['album_title']]
artist_name = row[column_index['artist_name']] artist_name = row[column_index['artist_name']]
spotify_album_id = row[column_index['spotify_album_id']] spotify_album_id = row[column_index['spotify_album_id']]
db_track_count = row[column_index['track_count']]
actual_count = row[column_index['actual_count']] actual_count = row[column_index['actual_count']]
album_thumb = row[column_index['album_thumb_url']] album_thumb = row[column_index['album_thumb_url']]
artist_thumb = row[column_index['artist_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 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 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 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 result.scanned += 1
@ -154,9 +173,6 @@ class AlbumCompletenessJob(RepairJob):
log_type='info' 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 = { album_ids = {
'spotify': spotify_album_id or '', 'spotify': spotify_album_id or '',
'itunes': itunes_album_id or '', 'itunes': itunes_album_id or '',
@ -165,8 +181,20 @@ class AlbumCompletenessJob(RepairJob):
'hydrabase': hydrabase_album_id or '', '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: if not expected_total:
expected_total = self._get_expected_total(context, primary_source, album_ids) 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) # Skip singles/EPs based on expected track count (not local count)
if expected_total and expected_total < min_tracks: if expected_total and expected_total < min_tracks:
@ -251,6 +279,27 @@ class AlbumCompletenessJob(RepairJob):
result.scanned, result.findings_created) result.scanned, result.findings_created)
return result 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): def _get_expected_total(self, context, primary_source, album_ids):
"""Try to get the expected track count from the active metadata provider first.""" """Try to get the expected track count from the active metadata provider first."""
for source in get_source_priority(primary_source): for source in get_source_priority(primary_source):

View file

@ -371,8 +371,13 @@ class SeasonalDiscoveryService:
config = SEASONAL_CONFIG[season_key] config = SEASONAL_CONFIG[season_key]
keywords = config['keywords'] keywords = config['keywords']
# Use the right track ID column based on source # Each source stores IDs in its own column
track_id_col = 'spotify_track_id' if source == 'spotify' else 'itunes_track_id' 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 = [] seasonal_tracks = []

256
core/socketio_cors.py Normal file
View file

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

View file

@ -63,9 +63,15 @@ _rate_limit_first_hit = 0 # Timestamp of the first hit in the current escalat
_LONG_RATE_LIMIT_THRESHOLD = 60 # seconds _LONG_RATE_LIMIT_THRESHOLD = 60 # seconds
# After a ban expires, wait this long before making any auth probe calls. # 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 # This prevents the "immediate re-probe → re-ban" cycle where Spotify's
# cooldown outlasts the Retry-After value they sent us. # server-side cooldown outlasts the Retry-After (or our default ban
_POST_BAN_COOLDOWN = 300 # 5 minutes # 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: if we get rate limited again within this window, increase ban duration
_ESCALATION_WINDOW = 3600 # 1 hour — if re-limited within this, escalate _ESCALATION_WINDOW = 3600 # 1 hour — if re-limited within this, escalate

View file

@ -8,7 +8,7 @@ from datetime import datetime, date, timedelta
from utils.logging_config import get_logger from utils.logging_config import get_logger
from database.music_database import MusicDatabase from database.music_database import MusicDatabase
from core.spotify_client import SpotifyClient, SpotifyRateLimitError 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") logger = get_logger("spotify_worker")
@ -782,6 +782,10 @@ class SpotifyWorker:
WHERE id = ? AND (year IS NULL OR year = '' OR year = '0') WHERE id = ? AND (year IS NULL OR year = '' OR year = '0')
""", (year, album_id)) """, (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() conn.commit()
except Exception as e: except Exception as e:
logger.error(f"Error updating album #{album_id} with Spotify data: {e}") logger.error(f"Error updating album #{album_id} with Spotify data: {e}")

View file

@ -76,6 +76,86 @@ if tidalapi is not None:
QUALITY_MAP['hires']['tidal_quality'] = tidalapi.Quality.hi_res_lossless 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: class TidalDownloadClient:
""" """
Tidal download client using tidalapi. Tidal download client using tidalapi.
@ -185,8 +265,16 @@ class TidalDownloadClient:
login, future = self.session.login_oauth() login, future = self.session.login_oauth()
self._device_auth_future = future self._device_auth_future = future
# tidalapi returns `verification_uri_complete` as a schemeless
# string like `link.tidal.com/ABCDE`. Passing that straight to
# an <a href> 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 = { 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, 'user_code': login.user_code,
} }
logger.info(f"Tidal device auth started — 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") logger.warning(f"Quality {q_key} returned no stream, trying next")
quality_error_reasons.append(reason) quality_error_reasons.append(reason)
continue 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}") logger.info(f"Got Tidal stream at quality: {q_key}")
except Exception as e: except Exception as e:
reason = f"{q_key}: {type(e).__name__}: {e}" reason = f"{q_key}: {type(e).__name__}: {e}"
@ -672,7 +767,8 @@ class TidalDownloadClient:
download_url = urls[0] 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() codec = manifest.get_codecs()
if codec and 'flac' in codec.lower(): if codec and 'flac' in codec.lower():
extension = 'flac' extension = 'flac'
@ -683,20 +779,6 @@ class TidalDownloadClient:
else: else:
extension = q_info.get('extension', 'flac') 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 # Build output filename
safe_name = re.sub(r'[<>:"/\\|?*]', '_', display_name) safe_name = re.sub(r'[<>:"/\\|?*]', '_', display_name)
out_filename = f"{safe_name}.{extension}" out_filename = f"{safe_name}.{extension}"

View file

@ -1,7 +1,10 @@
"""Shared helpers for background workers.""" """Shared helpers for background workers."""
import logging
import threading import threading
logger = logging.getLogger(__name__)
def interruptible_sleep(stop_event: threading.Event, seconds: float, step: float = 0.5) -> bool: def interruptible_sleep(stop_event: threading.Event, seconds: float, step: float = 0.5) -> bool:
"""Sleep in chunks so shutdown can interrupt long waits.""" """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 break
remaining -= wait_for remaining -= wait_for
return stop_event.is_set() 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
)

View file

@ -636,7 +636,8 @@ class MusicDatabase:
playlist_folder_mode INTEGER DEFAULT 0, playlist_folder_mode INTEGER DEFAULT 0,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at 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)") 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: except Exception:
pass 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 # Migration: add track_artist column for per-track artist on compilations/DJ mixes
try: try:
cursor.execute("SELECT track_artist FROM tracks LIMIT 1") 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") cursor.execute("ALTER TABLE albums ADD COLUMN soul_id TEXT DEFAULT NULL")
logger.info("Added soul_id column to albums table") 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) # Tracks: soul_id (song-level) + album_soul_id (release-specific)
cursor.execute("PRAGMA table_info(tracks)") cursor.execute("PRAGMA table_info(tracks)")
track_cols = [c[1] for c in cursor.fetchall()] track_cols = [c[1] for c in cursor.fetchall()]
@ -4715,6 +4739,10 @@ class MusicDatabase:
'audiodb_id', 'audiodb_match_status', 'audiodb_last_attempted', 'audiodb_id', 'audiodb_match_status', 'audiodb_last_attempted',
'style', 'mood', 'label', 'explicit', 'record_type', 'style', 'mood', 'label', 'explicit', 'record_type',
'deezer_id', 'deezer_match_status', 'deezer_last_attempted', '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 # 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}") logger.error(f"Error inserting/updating {server_source} album {getattr(album_obj, 'title', 'Unknown')}: {e}")
return False 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]: def get_albums_by_artist(self, artist_id: int) -> List[DatabaseAlbum]:
"""Get all albums by artist ID""" """Get all albums by artist ID"""
try: try:
@ -10497,6 +10582,20 @@ class MusicDatabase:
logger.debug(f"Error updating sync history track results: {e}") logger.debug(f"Error updating sync history track results: {e}")
return False 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): 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.""" """Update an existing sync_history entry with new stats and reset timestamps to move it to the top."""
try: try:
@ -10611,7 +10710,8 @@ class MusicDatabase:
cursor.execute(""" cursor.execute("""
SELECT id, batch_id, playlist_name, source, sync_type, source_page, SELECT id, batch_id, playlist_name, source, sync_type, source_page,
total_tracks, tracks_found, tracks_downloaded, tracks_failed, 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 FROM sync_history
WHERE completed_at IS NOT NULL WHERE completed_at IS NOT NULL
AND started_at >= datetime('now', ? || ' days') AND started_at >= datetime('now', ? || ' days')

View file

@ -69,10 +69,6 @@ chown -R soulsync:soulsync /app/config /app/data /app/logs /app/downloads /app/T
echo "✅ Configuration initialized successfully" 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 # Display final user info
echo "👤 Running as:" echo "👤 Running as:"
echo " User: $(id -u soulsync):$(id -g soulsync) ($(id -un soulsync):$(id -gn soulsync))" echo " User: $(id -u soulsync):$(id -g soulsync) ($(id -un soulsync):$(id -gn soulsync))"

View file

@ -27,8 +27,8 @@ beautifulsoup4>=4.12.0
# System monitoring # System monitoring
psutil>=6.0.0 psutil>=6.0.0
# YouTube support # YouTube support — pinned for reproducible builds; bump per release. See #367.
yt-dlp>=2024.12.13 yt-dlp==2026.3.17
# Lyrics support # Lyrics support
lrclibapi>=0.3.1 lrclibapi>=0.3.1

View file

@ -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 [track["track_number"] for track in missing_tracks] == [2, 3, 4]
assert missing_tracks[0]["source"] == "hydrabase" assert missing_tracks[0]["source"] == "hydrabase"
assert missing_tracks[0]["source_track_id"] == "hy-2" 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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -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/<rg-mbid>
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

View file

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

View file

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

View file

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

442
tests/test_socketio_cors.py Normal file
View file

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

View file

@ -16,11 +16,15 @@ if 'tidalapi' not in sys.modules:
_fake = types.ModuleType('tidalapi') _fake = types.ModuleType('tidalapi')
class _FakeQuality: class _FakeQuality:
low_96k = 'low_96k' # Values mirror the real tidalapi Quality enum (the strings the
low_320k = 'low_320k' # Tidal API returns in `audioQuality`). Keeping these honest
high_lossless = 'high_lossless' # lets sibling tests that actually compare quality values rely
hi_res = 'hi_res' # on the same stub regardless of pytest collection order.
hi_res_lossless = 'hi_res_lossless' low_96k = 'LOW'
low_320k = 'HIGH'
high_lossless = 'LOSSLESS'
hi_res = 'HI_RES'
hi_res_lossless = 'HI_RES_LOSSLESS'
_fake.Quality = _FakeQuality _fake.Quality = _FakeQuality
_fake.media = types.SimpleNamespace(Track=object) _fake.media = types.SimpleNamespace(Track=object)

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -5,10 +5,13 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
<title>SoulSync - Music Sync & Manager</title> <title>SoulSync - Music Sync & Manager</title>
<link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon.png') }}"> <link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon.png', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="manifest" href="{{ url_for('static', filename='manifest.json', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='mobile.css') }}"> <meta name="theme-color" content="#1db954">
<link rel="stylesheet" href="{{ url_for('static', filename='setup-wizard.css') }}"> <link rel="apple-touch-icon" href="{{ url_for('static', filename='pwa-icon-192.png', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='mobile.css', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='setup-wizard.css', v=static_v) }}">
</head> </head>
<body> <body>
@ -270,7 +273,7 @@
<!-- Version Section --> <!-- Version Section -->
<div class="version-section"> <div class="version-section">
<button class="version-button" onclick="showVersionInfo()">v2.3</button> <button class="version-button" onclick="showVersionInfo()">v2.4.0</button>
</div> </div>
<!-- Status Section --> <!-- Status Section -->
@ -1872,23 +1875,10 @@
</div> </div>
</div> </div>
<!-- Search Source Picker (replaces Enhanced/Basic toggle) --> <!-- Search source picker — icon row populated by search.js.
<div class="search-source-picker-container"> Each icon triggers a single-source fetch (no fan-out);
<label for="search-source-select" class="search-source-picker-label">Search from</label> results are cached per (query, source) pair. -->
<div class="search-source-picker-wrapper"> <div id="enh-source-row" class="enh-source-row" role="tablist" aria-label="Search source"></div>
<select id="search-source-select" class="search-source-picker-select">
<option value="auto" selected>All sources (Auto)</option>
<option value="spotify">Spotify</option>
<option value="itunes">Apple Music</option>
<option value="deezer">Deezer</option>
<option value="discogs">Discogs</option>
<option value="hydrabase">Hydrabase</option>
<option value="musicbrainz">MusicBrainz</option>
<option value="soulseek">Soulseek (raw files)</option>
</select>
<span class="search-source-picker-caret"></span>
</div>
</div>
<!-- Basic Search Section (Current) --> <!-- Basic Search Section (Current) -->
<div id="basic-search-section" class="search-section"> <div id="basic-search-section" class="search-section">
@ -2001,10 +1991,6 @@
placeholder="Search for artists, albums, or tracks..."> placeholder="Search for artists, albums, or tracks...">
<button id="enhanced-cancel-btn" class="enhanced-cancel-btn hidden"></button> <button id="enhanced-cancel-btn" class="enhanced-cancel-btn hidden"></button>
</div> </div>
<button id="enhanced-search-btn" class="enhanced-search-btn">
<span class="btn-icon">👁️</span>
<span class="btn-text">Show Results</span>
</button>
</div> </div>
<!-- Enhanced Search Dropdown (Overlay Panel) --> <!-- Enhanced Search Dropdown (Overlay Panel) -->
@ -2030,8 +2016,9 @@
<!-- Results Container --> <!-- Results Container -->
<div id="enhanced-results-container" class="enhanced-results-container hidden"> <div id="enhanced-results-container" class="enhanced-results-container hidden">
<!-- Source Tabs --> <!-- Fallback banner — shown when user clicked Spotify but backend
<div id="enh-source-tabs" class="enh-source-tabs hidden"></div> served Deezer due to rate-limit, etc. Populated by search.js. -->
<div id="enh-fallback-banner" class="enh-fallback-banner hidden"></div>
<!-- Artists Container (Side by Side) --> <!-- Artists Container (Side by Side) -->
<div class="enh-artists-wrapper"> <div class="enh-artists-wrapper">
@ -2332,11 +2319,19 @@
<!-- Artist cards will be populated here --> <!-- Artist cards will be populated here -->
</div> </div>
<!-- Empty State --> <!-- Empty State — populated by showLibraryEmpty() in library.js.
Shows a generic "no artists" message by default; when the
user's search has no library matches, it switches to a
search-online CTA that hands off to the /search page. -->
<div class="library-empty hidden" id="library-empty"> <div class="library-empty hidden" id="library-empty">
<div class="empty-icon">🎵</div> <div class="empty-icon" id="library-empty-icon">🎵</div>
<div class="empty-title">No artists found</div> <div class="empty-title" id="library-empty-title">No artists found</div>
<div class="empty-subtitle">Try adjusting your search or filters</div> <div class="empty-subtitle" id="library-empty-subtitle">Try adjusting your search or filters</div>
<button class="library-empty-search-cta hidden" id="library-empty-search-cta">
<span class="library-empty-search-cta-icon">🔍</span>
<span class="library-empty-search-cta-text">Search online for <span id="library-empty-search-cta-query"></span></span>
<span class="library-empty-search-cta-arrow"></span>
</button>
</div> </div>
<!-- Pagination --> <!-- Pagination -->
@ -5430,6 +5425,14 @@
<div class="form-group" id="security-change-pin-section" style="display: none;"> <div class="form-group" id="security-change-pin-section" style="display: none;">
<button class="auth-button" onclick="showChangeSecurityPin()">Change PIN</button> <button class="auth-button" onclick="showChangeSecurityPin()">Change PIN</button>
</div> </div>
<div class="form-group">
<label for="security-cors-origins">Allowed WebSocket Origins:</label>
<textarea id="security-cors-origins" rows="3" placeholder="https://soulsync.example.com&#10;http://192.168.1.5:8888" style="width: 100%; font-family: monospace; font-size: 12px;"></textarea>
<div class="setting-help-text">
Origins (full URL, no trailing slash) allowed to open WebSocket connections to this instance — one per line, or comma-separated. Leave empty for same-origin only (the secure default; works for direct access and most reverse-proxy setups). Add your public domain here if you reach SoulSync via a reverse proxy or custom domain and the WebSocket fails to connect. Use <code>*</code> on its own line to allow any origin (insecure — only do this if you understand why you need it).
</div>
</div>
</div> </div>
<!-- Discovery Settings --> <!-- Discovery Settings -->
@ -7881,28 +7884,33 @@
</div> </div>
</div> </div>
<script src="{{ url_for('static', filename='vendor/socket.io.min.js') }}"></script> <script src="{{ url_for('static', filename='vendor/socket.io.min.js', v=static_v) }}"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script src="{{ url_for('static', filename='setup-wizard.js') }}"></script> <script src="{{ url_for('static', filename='setup-wizard.js', v=static_v) }}"></script>
<!-- Split modules (was: script.js) — core.js must load first, init.js last --> <!-- Split modules (was: script.js) — core.js must load first, init.js last -->
<script src="{{ url_for('static', filename='core.js') }}"></script> <script src="{{ url_for('static', filename='core.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='shared-helpers.js') }}"></script> <script src="{{ url_for('static', filename='shared-helpers.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='media-player.js') }}"></script> <script src="{{ url_for('static', filename='media-player.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='settings.js') }}"></script> <script src="{{ url_for('static', filename='settings.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='search.js') }}"></script> <script src="{{ url_for('static', filename='search.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-spotify.js') }}"></script> <script src="{{ url_for('static', filename='sync-spotify.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='downloads.js') }}"></script> <script src="{{ url_for('static', filename='downloads.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='wishlist-tools.js') }}"></script> <script src="{{ url_for('static', filename='wishlist-tools.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-services.js') }}"></script> <script src="{{ url_for('static', filename='sync-services.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='api-monitor.js') }}"></script> <script src="{{ url_for('static', filename='api-monitor.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='library.js') }}"></script> <script src="{{ url_for('static', filename='library.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='beatport-ui.js') }}"></script> <script src="{{ url_for('static', filename='beatport-ui.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='discover.js') }}"></script> <script src="{{ url_for('static', filename='discover.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='enrichment.js') }}"></script> <script src="{{ url_for('static', filename='enrichment.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='stats-automations.js') }}"></script> <script src="{{ url_for('static', filename='stats-automations.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='pages-extra.js') }}"></script> <script src="{{ url_for('static', filename='pages-extra.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='init.js') }}"></script> <script src="{{ url_for('static', filename='init.js', v=static_v) }}"></script>
<!-- Notification bell + floating helper toggle — always accessible above modals --> <!-- Notification bell + floating helper toggle — always accessible above modals -->
<!-- Ambient glow under the global search bar. Radial gradient, brightest
directly under the bar, tapering out toward the window corners.
Purely decorative (pointer-events: none). Visibility follows the bar
via _gsUpdateVisibility(). -->
<div class="gsearch-aura" id="gsearch-aura"></div>
<!-- Global Search Bar — Spotlight-style search from anywhere --> <!-- Global Search Bar — Spotlight-style search from anywhere -->
<div class="gsearch-bar" id="gsearch-bar"> <div class="gsearch-bar" id="gsearch-bar">
<div class="gsearch-icon"> <div class="gsearch-icon">
@ -7979,10 +7987,10 @@
<span>?</span> <span>?</span>
</button> </button>
<script src="{{ url_for('static', filename='docs.js') }}"></script> <script src="{{ url_for('static', filename='docs.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='helper.js') }}"></script> <script src="{{ url_for('static', filename='helper.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='particles.js') }}"></script> <script src="{{ url_for('static', filename='particles.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='worker-orbs.js') }}"></script> <script src="{{ url_for('static', filename='worker-orbs.js', v=static_v) }}"></script>
</body> </body>
</html> </html>

View file

@ -278,6 +278,12 @@ function displayDiscoverHeroArtist(artist) {
if (discographyBtn && artistId) { if (discographyBtn && artistId) {
discographyBtn.setAttribute('data-artist-id', artistId); discographyBtn.setAttribute('data-artist-id', artistId);
discographyBtn.setAttribute('data-artist-name', artist.artist_name); 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 // Also store both IDs for cross-source operations
if (artist.spotify_artist_id) discographyBtn.setAttribute('data-spotify-id', artist.spotify_artist_id); 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); 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 artistId = button.getAttribute('data-artist-id');
const artistName = button.getAttribute('data-artist-name'); 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) { if (!artistId || !artistName) {
console.error('No artist data found for discography view'); console.error('No artist data found for discography view');
return; return;
} }
console.log(`🎵 Navigating to artist detail for: ${artistName}`); console.log(`🎵 Navigating to artist detail for: ${artistName} (source: ${source || 'library'})`);
navigateToArtistDetail(artistId, artistName); navigateToArtistDetail(artistId, artistName, source);
} }
function showDiscoverHeroEmpty() { function showDiscoverHeroEmpty() {
@ -7849,88 +7859,9 @@ function checkForActiveDiscoverDownloads() {
} }
async function startDiscoverPlaylistSync(playlistType, playlistName) { 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 // Disable the sync button on the Discover page
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)
const buttonId = playlistType.replace(/_/g, '-') + '-sync-btn'; const buttonId = playlistType.replace(/_/g, '-') + '-sync-btn';
const syncButton = document.getElementById(buttonId); const syncButton = document.getElementById(buttonId);
if (syncButton) { if (syncButton) {
@ -7939,23 +7870,104 @@ async function startDiscoverPlaylistSync(playlistType, playlistName) {
syncButton.style.cursor = 'not-allowed'; syncButton.style.cursor = 'not-allowed';
} }
// Start sync using existing function try {
await startPlaylistSync(virtualPlaylistId); // Fetch tracks from API
const apiUrl = _discoverPlaylistApiUrl(playlistType);
// Extract image URL from first track for download bar bubble if (!apiUrl) {
let imageUrl = null; showToast(`Unknown playlist type: ${playlistType}`, 'error');
if (spotifyTracks && spotifyTracks.length > 0) { if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; }
const firstTrack = spotifyTracks[0]; return;
if (firstTrack.album && firstTrack.album.images && firstTrack.album.images.length > 0) {
imageUrl = firstTrack.album.images[0].url;
} }
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 const icon = { success: '\u2705', error: '\u274c', warning: '\u26a0\ufe0f', info: '\u2139\ufe0f' }[type] || '\u2139\ufe0f';
startDiscoverSyncPolling(playlistType, virtualPlaylistId); const toast = document.createElement('div');
toast.className = `toast-compact toast-${type}`;
toast.innerHTML = `<span class="toast-compact-icon">${icon}</span><span class="toast-compact-msg">${_escToast(message)}</span>`;
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 // Track active discover sync pollers
@ -9098,17 +9110,17 @@ function renderDiscoverSyncCard(playlist, container, sourceLabel) {
const trackLabel = isEmpty ? 'No tracks yet' : `${playlist.track_count} tracks`; const trackLabel = isEmpty ? 'No tracks yet' : `${playlist.track_count} tracks`;
card.innerHTML = ` card.innerHTML = `
<div class="discover-sync-card-icon">${playlist.icon}</div> <div class="discover-sync-card-icon">${_esc(playlist.icon)}</div>
<div class="discover-sync-card-info"> <div class="discover-sync-card-info">
<div class="discover-sync-card-name">${playlist.name} <div class="discover-sync-card-name">${_esc(playlist.name)}
<span class="discover-sync-card-meta-inline"> <span class="discover-sync-card-meta-inline">
<span class="discover-sync-source-badge">${sourceLabel || 'unknown'}</span> <span class="discover-sync-source-badge">${_esc(sourceLabel || 'unknown')}</span>
<span class="discover-sync-separator">\u00b7</span> <span class="discover-sync-separator">\u00b7</span>
<span class="discover-sync-track-count">${trackLabel}</span> <span class="discover-sync-track-count">${_esc(trackLabel)}</span>
<span class="discover-sync-separator">\u00b7</span> <span class="discover-sync-separator">\u00b7</span>
<span class="discover-sync-status ${statusClass}">${statusText}</span> <span class="discover-sync-status ${statusClass}">${_esc(statusText)}</span>
<span class="discover-sync-separator">\u00b7</span> <span class="discover-sync-separator">\u00b7</span>
<span class="discover-sync-last-synced">${lastSyncedText}</span> <span class="discover-sync-last-synced">${_esc(lastSyncedText)}</span>
</span> </span>
</div> </div>
</div> </div>
@ -9116,19 +9128,43 @@ function renderDiscoverSyncCard(playlist, container, sourceLabel) {
<div class="discover-sync-toggle-wrapper" title="${isEmpty ? 'No tracks available — visit Discover first' : 'Keep this playlist updated automatically'}"> <div class="discover-sync-toggle-wrapper" title="${isEmpty ? 'No tracks available — visit Discover first' : 'Keep this playlist updated automatically'}">
<label class="discover-sync-toggle-label">Keep updated</label> <label class="discover-sync-toggle-label">Keep updated</label>
<label class="discover-sync-toggle"> <label class="discover-sync-toggle">
<input type="checkbox" ${playlist.auto_update ? 'checked' : ''} ${isEmpty ? 'disabled' : ''} <input type="checkbox" class="discover-auto-update-toggle" ${playlist.auto_update ? 'checked' : ''} ${isEmpty ? 'disabled' : ''}>
onchange="toggleDiscoverAutoUpdate('${playlist.type}', this.checked)">
<span class="discover-sync-toggle-slider"></span> <span class="discover-sync-toggle-slider"></span>
</label> </label>
</div> </div>
<button class="discover-sync-btn" id="discover-sync-btn-${playlist.type}" <div class="discover-sync-toggle-wrapper" title="Coming soon: download any available quality for this batch, even if it's below your global quality profile. Useful for rotating discover playlists where quantity matters more than quality.">
onclick="syncDiscoverPlaylistFromTab('${playlist.type}', '${playlist.name}')" <label class="discover-sync-toggle-label" style="opacity:0.5">Any Quality</label>
<label class="discover-sync-toggle" style="opacity:0.5;cursor:not-allowed">
<input type="checkbox" class="discover-any-quality-toggle" disabled>
<span class="discover-sync-toggle-slider"></span>
</label>
</div>
<button class="discover-sync-btn"
${playlist.sync_status === 'syncing' || isEmpty ? 'disabled' : ''}> ${playlist.sync_status === 'syncing' || isEmpty ? 'disabled' : ''}>
\u27f3 Sync Now \u27f3 Sync Now
</button> </button>
</div> </div>
`; `;
// 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 // Make the icon + info area clickable to view tracks
if (!isEmpty) { if (!isEmpty) {
const clickArea = card.querySelector('.discover-sync-card-info'); const clickArea = card.querySelector('.discover-sync-card-info');
@ -9193,18 +9229,21 @@ async function _doSyncDiscoverPlaylist(playlistType, playlistName) {
} }
try { try {
let tracksResponse;
// Use unified URL helper (handles ListenBrainz + standard discover types)
const apiUrl = _discoverPlaylistApiUrl(playlistType);
if (apiUrl) {
tracksResponse = await fetch(apiUrl);
}
let tracks = []; let tracks = [];
if (tracksResponse && tracksResponse.ok) {
const data = await tracksResponse.json(); if (playlistType === 'build_playlist') {
tracks = data.tracks || []; // 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) { if (!tracks.length) {
@ -9235,14 +9274,15 @@ async function _doSyncDiscoverPlaylist(playlistType, playlistName) {
// Use the download batch endpoint directly so the batch is labeled // Use the download batch endpoint directly so the batch is labeled
// as "Discover" instead of going through sync → wishlist → "Wishlist" batch. // 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`, { const batchResponse = await fetch(`/api/playlists/${virtualPlaylistId}/start-missing-process`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify(bodyPayload)
tracks: syncTracks,
playlist_name: playlistName
})
}); });
const result = await batchResponse.json(); const result = await batchResponse.json();
@ -9315,7 +9355,23 @@ function pollDiscoverSyncFromTab(playlistType, virtualPlaylistId, playlistName)
} }
function pollDiscoverBatchFromTab(playlistType, batchId, 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 () => { 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 { try {
const resp = await fetch(`/api/playlists/${batchId}/download_status`); const resp = await fetch(`/api/playlists/${batchId}/download_status`);
if (!resp.ok) { clearInterval(pollInterval); return; } if (!resp.ok) { clearInterval(pollInterval); return; }
@ -9324,6 +9380,7 @@ function pollDiscoverBatchFromTab(playlistType, batchId, playlistName) {
if (phase === 'complete' || phase === 'error' || phase === 'cancelled') { if (phase === 'complete' || phase === 'error' || phase === 'cancelled') {
clearInterval(pollInterval); clearInterval(pollInterval);
delete discoverSyncPollers[playlistType];
const btn = document.getElementById(`discover-sync-btn-${playlistType}`); const btn = document.getElementById(`discover-sync-btn-${playlistType}`);
if (btn) { btn.disabled = false; btn.textContent = '\u27f3 Sync Now'; } if (btn) { btn.disabled = false; btn.textContent = '\u27f3 Sync Now'; }
@ -9370,8 +9427,12 @@ function pollDiscoverBatchFromTab(playlistType, batchId, playlistName) {
} }
} catch (error) { } catch (error) {
clearInterval(pollInterval); clearInterval(pollInterval);
delete discoverSyncPollers[playlistType];
} }
}, 3000); }, 3000);
// Register so page-leave cleanup can clear it
discoverSyncPollers[playlistType] = pollInterval;
} }
/** /**

View file

@ -5013,33 +5013,57 @@ function _gsClickVideo(cardEl) {
// GLOBAL SEARCH BAR — Spotlight-style search from anywhere // 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 = { const _gsState = {
active: false, active: false,
query: '', _lastInteraction: 0,
data: null,
sources: {},
activeSource: null,
abortCtrl: null,
altAbortCtrl: null,
debounceTimer: null, debounceTimer: null,
}; };
// Shared source-picker controller — built on DOM-ready in `_doInit`.
let _gsController = null;
(function initGlobalSearch() { (function initGlobalSearch() {
// Defer init until DOM is ready // Defer init until DOM is ready
const _doInit = () => { const _doInit = () => {
const bar = document.getElementById('gsearch-bar'); const bar = document.getElementById('gsearch-bar');
const input = document.getElementById('gsearch-input'); const input = document.getElementById('gsearch-input');
const results = document.getElementById('gsearch-results'); 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 = `
<div class="gsearch-source-row" id="gsearch-source-row"></div>
<div class="gsearch-fallback-banner hidden" id="gsearch-fallback-banner"></div>
<div id="gsearch-body"></div>
`;
_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()); bar.addEventListener('click', () => input.focus());
input.addEventListener('focus', () => { input.addEventListener('focus', () => {
bar.classList.add('active'); bar.classList.add('active');
const aura = document.getElementById('gsearch-aura');
if (aura) aura.classList.add('active');
_gsState.active = true; _gsState.active = true;
const shortcut = document.getElementById('gsearch-shortcut'); const shortcut = document.getElementById('gsearch-shortcut');
if (shortcut) shortcut.style.display = 'none'; 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 // No blur handler — closing is handled by click-outside and Escape only
@ -5049,20 +5073,26 @@ const _gsState = {
input.addEventListener('input', () => { input.addEventListener('input', () => {
const q = input.value.trim(); const q = input.value.trim();
_gsState.query = q;
if (clearBtn) clearBtn.style.display = q.length > 0 ? '' : 'none'; if (clearBtn) clearBtn.style.display = q.length > 0 ? '' : 'none';
if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer); if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer);
if (q.length < 2) { _gsHideResults(); return; } if (q.length < 2) { _gsHideResults(); return; }
_gsState.debounceTimer = setTimeout(() => _gsPerformSearch(q), 300); _gsState.debounceTimer = setTimeout(() => _gsController.submitQuery(q), 300);
}); });
if (clearBtn) { if (clearBtn) {
clearBtn.addEventListener('click', e => { clearBtn.addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();
input.value = ''; input.value = '';
_gsState.query = '';
_gsState.data = null;
clearBtn.style.display = 'none'; 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(); _gsHideResults();
input.focus(); input.focus();
}); });
@ -5073,7 +5103,7 @@ const _gsState = {
e.preventDefault(); e.preventDefault();
if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer); if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer);
const q = input.value.trim(); const q = input.value.trim();
if (q.length >= 2) _gsPerformSearch(q); if (q.length >= 2) _gsController.submitQuery(q);
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
_gsDeactivate(); _gsDeactivate();
input.blur(); input.blur();
@ -5116,18 +5146,22 @@ const _gsState = {
function _gsUpdateVisibility() { function _gsUpdateVisibility() {
const bar = document.getElementById('gsearch-bar'); const bar = document.getElementById('gsearch-bar');
const aura = document.getElementById('gsearch-aura');
if (!bar) return; if (!bar) return;
// Hide on the Search page where the unified search already exists. Accept the // Hide on the Search page where the unified search already exists. Accept the
// legacy 'downloads' id for callers that predate the page rename. // legacy 'downloads' id for callers that predate the page rename.
const onSearchPage = typeof currentPage !== 'undefined' && (currentPage === 'search' || currentPage === 'downloads'); const onSearchPage = typeof currentPage !== 'undefined' && (currentPage === 'search' || currentPage === 'downloads');
bar.style.display = onSearchPage ? 'none' : ''; bar.style.display = onSearchPage ? 'none' : '';
if (aura) aura.classList.toggle('hidden', onSearchPage);
if (onSearchPage && _gsState.active) _gsDeactivate(); if (onSearchPage && _gsState.active) _gsDeactivate();
} }
function _gsDeactivate() { function _gsDeactivate() {
const bar = document.getElementById('gsearch-bar'); const bar = document.getElementById('gsearch-bar');
const aura = document.getElementById('gsearch-aura');
const shortcut = document.getElementById('gsearch-shortcut'); const shortcut = document.getElementById('gsearch-shortcut');
if (bar) bar.classList.remove('active'); if (bar) bar.classList.remove('active');
if (aura) aura.classList.remove('active');
if (shortcut) shortcut.style.display = ''; if (shortcut) shortcut.style.display = '';
_gsState.active = false; _gsState.active = false;
_gsHideResults(); _gsHideResults();
@ -5143,113 +5177,116 @@ function _gsShowResults() {
if (r && r.innerHTML.trim()) r.classList.add('visible'); if (r && r.innerHTML.trim()) r.classList.add('visible');
} }
async function _gsPerformSearch(query) { function _gsNavigateToSearchPage(query, src) {
if (_gsState.abortCtrl) _gsState.abortCtrl.abort(); _gsDeactivate();
if (_gsState.altAbortCtrl) _gsState.altAbortCtrl.abort(); if (typeof navigateToPage !== 'function') return;
_gsState.abortCtrl = new AbortController(); navigateToPage('search');
_gsState.altAbortCtrl = new AbortController(); // 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'); // Sync the Search page controller's state.query to the widget's
if (!results) return; // query BEFORE clicking the Soulseek icon. Otherwise the icon
// click fires onSoulseekSelected(state.query) where state.query
results.innerHTML = '<div class="gsearch-loading"><div class="server-search-spinner"></div>Searching...</div>'; // is whatever the user last typed on /search (often stale), and
results.classList.add('visible'); // the callback would overwrite basicInput.value with that stale
// value before running performDownloadsSearch.
try { if (typeof _searchPageController !== 'undefined' && _searchPageController) {
const data = await enhancedSearchFetch(query, { signal: _gsState.abortCtrl.signal }); _searchPageController.state.query = query || '';
_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 = '<div class="gsearch-empty">Search failed</div>';
}
}
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) { }
} }
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(); const input = document.getElementById('enhanced-search-input');
} catch (e) { if (input && query) {
if (e.name !== 'AbortError') console.debug(`GS alt source ${src} failed:`, e); 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'); 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 // Fallback banner — independent of body content.
if (_gsState.activeSource === 'youtube_videos') { const banner = document.getElementById('gsearch-fallback-banner');
const src = _gsState.sources['youtube_videos'] || {}; const activeSrc = state.activeSource;
const videos = src.videos || []; const actual = state.fallbacks[activeSrc];
const isLoading = src._loading && src._loading.size > 0; if (banner) {
let h = ''; if (actual && actual !== activeSrc) {
h += `<div class="gsearch-results-header"><span class="gsearch-results-title">Results</span><span class="gsearch-results-count">${videos.length} videos</span></div>`; const clicked = (SOURCE_LABELS[activeSrc] || {}).text || activeSrc;
h += '<div class="gsearch-tabs" id="gsearch-tabs"></div>'; 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 = '<div class="gsearch-empty">Type to search…</div>';
results.classList.add('visible');
return;
}
// In-flight, nothing cached yet — loading state.
if (isLoading && !cached) {
const info = SOURCE_LABELS[activeSrc];
body.innerHTML = `<div class="gsearch-loading"><div class="server-search-spinner"></div>Searching ${_escToast((info && info.text) || activeSrc)}...</div>`;
results.classList.add('visible');
return;
}
// No cache, not loading — source switch before fetch fired (e.g. empty query).
if (!cached) {
body.innerHTML = '<div class="gsearch-empty">Click the source above to search.</div>';
results.classList.add('visible');
return;
}
// Music Videos — video grid instead of regular sections.
if (activeSrc === 'youtube_videos') {
const videos = cached.videos || [];
let h = `<div class="gsearch-results-header"><span class="gsearch-results-title">Results</span><span class="gsearch-results-count">${videos.length} videos</span></div>`;
h += '<div class="gsearch-results-body">'; h += '<div class="gsearch-results-body">';
if (isLoading) { if (videos.length === 0) {
h += '<div class="gsearch-section-loading"><div class="server-search-spinner" style="width:14px;height:14px"></div> Searching YouTube...</div>'; h += `<div class="gsearch-empty">No music videos found for "${_escToast(query)}"</div>`;
} else if (videos.length === 0) {
h += `<div class="gsearch-empty">No music videos found for "${_escToast(_gsState.query)}"</div>`;
} else { } else {
h += '<div class="gsearch-section-header">🎬 Music Videos</div>'; h += '<div class="gsearch-section-header">🎬 Music Videos</div>';
h += '<div class="enh-video-grid">'; h += '<div class="enh-video-grid">';
@ -5268,74 +5305,61 @@ function _gsRender(data) {
h += '</div>'; h += '</div>';
} }
h += '</div>'; h += '</div>';
results.innerHTML = h; body.innerHTML = h;
results.classList.add('visible'); results.classList.add('visible');
_gsRenderTabs();
return; return;
} }
const src = _gsState.sources[_gsState.activeSource] || {}; // Standard metadata source — library + artists + albums + singles + tracks.
const loading = src._loading || new Set(); const dbArtists = cached.db_artists || [];
const dbArtists = data?.db_artists || []; const artists = cached.artists || [];
const artists = src.artists || []; const allAlbums = cached.albums || [];
const allAlbums = src.albums || [];
const albums = allAlbums.filter(a => !a.album_type || a.album_type === 'album' || a.album_type === 'compilation'); 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 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 total = dbArtists.length + artists.length + albums.length + singles.length + tracks.length;
const isLoading = loading.size > 0;
if (total === 0 && !isLoading) { if (total === 0) {
results.innerHTML = `<div class="gsearch-empty">No results for "${_escToast(_gsState.query)}"<br><span style="font-size:10px;opacity:0.5">Try different keywords or check spelling</span></div>`; body.innerHTML = `<div class="gsearch-empty">No results for "${_escToast(query)}"<br><span style="font-size:10px;opacity:0.5">Try different keywords or check spelling</span></div>`;
results.classList.add('visible'); results.classList.add('visible');
return; return;
} }
const sourceLabels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase', youtube_videos: 'Music Videos', musicbrainz: 'MusicBrainz' }; const srcLabel = (SOURCE_LABELS[activeSrc] || {}).text || activeSrc || '';
const srcLabel = sourceLabels[_gsState.activeSource] || _gsState.activeSource || '';
let h = ''; let h = '';
h += `<div class="gsearch-results-header"><span class="gsearch-results-title">Results</span><span class="gsearch-results-count">${total} items</span></div>`; h += `<div class="gsearch-results-header"><span class="gsearch-results-title">Results</span><span class="gsearch-results-count">${total} items</span></div>`;
h += '<div class="gsearch-tabs" id="gsearch-tabs"></div>';
h += '<div class="gsearch-results-body">'; h += '<div class="gsearch-results-body">';
if (dbArtists.length) { if (dbArtists.length) {
h += '<div class="gsearch-section-header">📚 In Your Library</div><div class="gsearch-grid">'; h += '<div class="gsearch-section-header">📚 In Your Library</div><div class="gsearch-grid">';
h += dbArtists.map(a => `<div class="gsearch-item" onclick="_gsClickArtist('${a.id}', '${_escAttr(a.name)}', true)"><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy">` : '🎤'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">Library</div></div></div>`).join(''); h += dbArtists.map(a => `<div class="gsearch-item" onclick="_gsClickArtist('${a.id}', '${_escAttr(a.name)}', true)"><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy" onerror="this.parentElement.textContent='🎤'">` : '🎤'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">Library</div></div></div>`).join('');
h += '</div>'; h += '</div>';
} }
if (artists.length) { if (artists.length) {
h += `<div class="gsearch-section-header">🎤 Artists <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid" id="gsearch-artists-grid">`; h += `<div class="gsearch-section-header">🎤 Artists <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid" id="gsearch-artists-grid">`;
h += artists.map(a => `<div class="gsearch-item" onclick="_gsClickArtist('${a.id}', '${_escAttr(a.name)}', false)" ${!a.image_url ? `data-artist-id="${a.id}" data-needs-image="true"` : ''}><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy">` : '🎤'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div></div></div>`).join(''); h += artists.map(a => `<div class="gsearch-item" onclick="_gsClickArtist('${a.id}', '${_escAttr(a.name)}', false)" ${!a.image_url ? `data-artist-id="${a.id}" data-needs-image="true" data-artist-name="${_escAttr(a.name)}"` : ''}><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy" onerror="this.parentElement.textContent='🎤'">` : '🎤'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div></div></div>`).join('');
h += '</div>'; h += '</div>';
} else if (loading.has('artists')) {
h += `<div class="gsearch-section-header">🎤 Artists <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-section-loading"><div class="server-search-spinner" style="width:14px;height:14px"></div> Loading artists...</div>`;
} }
const activeSrc = _gsState.activeSource || 'spotify';
if (albums.length) { if (albums.length) {
h += `<div class="gsearch-section-header">💿 Albums <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid">`; h += `<div class="gsearch-section-header">💿 Albums <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid">`;
h += albums.map(a => { h += albums.map(a => {
const ar = a.artist || (a.artists ? a.artists.join(', ') : ''); const ar = a.artist || (a.artists ? a.artists.join(', ') : '');
const yr = a.release_date ? a.release_date.substring(0, 4) : ''; const yr = a.release_date ? a.release_date.substring(0, 4) : '';
const img = (a.image_url || '').replace(/'/g, "\\'"); const img = (a.image_url || '').replace(/'/g, "\\'");
return `<div class="gsearch-item" onclick="_gsClickAlbum('${a.id}', '${_escAttr(a.name)}', '${_escAttr(ar)}', '${img}', '${activeSrc}')"><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy">` : '💿'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}${yr ? ` · ${yr}` : ''}</div></div></div>`; return `<div class="gsearch-item" onclick="_gsClickAlbum('${a.id}', '${_escAttr(a.name)}', '${_escAttr(ar)}', '${img}', '${activeSrc}')"><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy" onerror="this.parentElement.textContent='💿'">` : '💿'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}${yr ? ` · ${yr}` : ''}</div></div></div>`;
}).join(''); }).join('');
h += '</div>'; h += '</div>';
} }
if (!albums.length && !singles.length && loading.has('albums')) {
h += `<div class="gsearch-section-header">💿 Albums <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-section-loading"><div class="server-search-spinner" style="width:14px;height:14px"></div> Loading albums...</div>`;
}
if (singles.length) { if (singles.length) {
h += `<div class="gsearch-section-header">🎶 Singles & EPs <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid">`; h += `<div class="gsearch-section-header">🎶 Singles & EPs <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid">`;
h += singles.map(a => { h += singles.map(a => {
const ar = a.artist || (a.artists ? a.artists.join(', ') : ''); const ar = a.artist || (a.artists ? a.artists.join(', ') : '');
const img = (a.image_url || '').replace(/'/g, "\\'"); const img = (a.image_url || '').replace(/'/g, "\\'");
return `<div class="gsearch-item" onclick="_gsClickAlbum('${a.id}', '${_escAttr(a.name)}', '${_escAttr(ar)}', '${img}', '${activeSrc}')"><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy">` : '🎶'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}</div></div></div>`; return `<div class="gsearch-item" onclick="_gsClickAlbum('${a.id}', '${_escAttr(a.name)}', '${_escAttr(ar)}', '${img}', '${activeSrc}')"><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy" onerror="this.parentElement.textContent='🎶'">` : '🎶'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}</div></div></div>`;
}).join(''); }).join('');
h += '</div>'; h += '</div>';
} }
@ -5345,20 +5369,22 @@ function _gsRender(data) {
h += tracks.map(t => { h += tracks.map(t => {
const ar = t.artist || (t.artists ? t.artists.join(', ') : ''); 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')}` : ''; const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : '';
return `<div class="gsearch-track" onclick="_gsClickTrack('${_escAttr(ar)}', '${_escAttr(t.name)}', '${_escAttr(t.album || '')}', '${_escAttr(t.id || '')}', '${_escAttr(t.image_url || '')}', ${t.duration_ms || 0})"><div class="gsearch-item-art" style="width:32px;height:32px;border-radius:6px">${t.image_url ? `<img src="${t.image_url}" loading="lazy">` : '🎵'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(t.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}${t.album ? ` · ${_escToast(t.album)}` : ''}</div></div><div class="gsearch-track-dur">${dur}</div><button class="gsearch-play-btn" onclick="event.stopPropagation(); _gsPlayTrack('${_escAttr(t.name)}', '${_escAttr(ar)}', '${_escAttr(t.album || '')}')" title="Stream">▶</button></div>`; return `<div class="gsearch-track" onclick="_gsClickTrack('${_escAttr(ar)}', '${_escAttr(t.name)}', '${_escAttr(t.album || '')}', '${_escAttr(t.id || '')}', '${_escAttr(t.image_url || '')}', ${t.duration_ms || 0})"><div class="gsearch-item-art" style="width:32px;height:32px;border-radius:6px">${t.image_url ? `<img src="${t.image_url}" loading="lazy" onerror="this.parentElement.textContent='🎵'">` : '🎵'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(t.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}${t.album ? ` · ${_escToast(t.album)}` : ''}</div></div><div class="gsearch-track-dur">${dur}</div><button class="gsearch-play-btn" onclick="event.stopPropagation(); _gsPlayTrack('${_escAttr(t.name)}', '${_escAttr(ar)}', '${_escAttr(t.album || '')}')" title="Stream">▶</button></div>`;
}).join(''); }).join('');
h += '</div>'; h += '</div>';
} else if (loading.has('tracks')) {
h += `<div class="gsearch-section-header">🎵 Tracks <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-section-loading"><div class="server-search-spinner" style="width:14px;height:14px"></div> Loading tracks...</div>`;
} }
h += '</div>'; h += '</div>';
results.innerHTML = h; body.innerHTML = h;
results.classList.add('visible'); 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(); _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() { async function _gsLazyLoadArtistImages() {
@ -5366,66 +5392,31 @@ async function _gsLazyLoadArtistImages() {
if (!grid) return; if (!grid) return;
const cards = grid.querySelectorAll('[data-needs-image="true"]'); const cards = grid.querySelectorAll('[data-needs-image="true"]');
if (cards.length === 0) return; if (cards.length === 0) return;
const activeSrc = _gsState.activeSource || 'spotify'; const activeSrc = (_gsController && _gsController.state.activeSource) || 'spotify';
for (const card of cards) { for (const card of cards) {
const artistId = card.dataset.artistId; const artistId = card.dataset.artistId;
if (!artistId) continue; if (!artistId) continue;
try { 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(); const data = await res.json();
if (data.success && data.image_url) { if (data.success && data.image_url) {
const artDiv = card.querySelector('.gsearch-item-art'); const artDiv = card.querySelector('.gsearch-item-art');
if (artDiv) artDiv.innerHTML = `<img src="${data.image_url}" loading="lazy">`; if (artDiv) artDiv.innerHTML = `<img src="${data.image_url}" loading="lazy" onerror="this.parentElement.textContent='🎤'">`;
card.removeAttribute('data-needs-image'); card.removeAttribute('data-needs-image');
} }
} catch (e) { /* ignore */ } } 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 `<button class="gsearch-tab${s === _gsState.activeSource ? ' active' : ''}" onclick="_gsSwitchSource('${s}')">${labels[s] || s} (${c})</button>`;
}).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) { function _gsClickArtist(id, name, isLibrary) {
_gsDeactivate(); _gsDeactivate();
const source = isLibrary ? null : (_gsState.activeSource || null); const activeSource = _gsController && _gsController.state.activeSource;
const source = isLibrary ? null : (activeSource || null);
navigateToArtistDetail(id, name, source); 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 library check for global search results — adds badges + swaps play buttons
async function _gsLibraryCheck() { async function _gsLibraryCheck() {
try { try {
const src = _gsState.sources[_gsState.activeSource] || {}; if (!_gsController) return;
const src = _gsController.state.sources[_gsController.state.activeSource] || {};
const allAlbums = src.albums || []; const allAlbums = src.albums || [];
const albums = allAlbums.filter(a => !a.album_type || a.album_type === 'album' || a.album_type === 'compilation'); 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 singles = allAlbums.filter(a => a.album_type === 'single' || a.album_type === 'ep');
@ -5760,27 +5752,26 @@ async function showVersionInfo() {
} catch (e) { /* ignore */ } } 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 { 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); populateVersionModal(versionData, hadUpdate ? updateInfo : null);
// Show modal
const modalOverlay = document.getElementById('version-modal-overlay'); const modalOverlay = document.getElementById('version-modal-overlay');
modalOverlay.classList.remove('hidden'); if (modalOverlay) modalOverlay.classList.remove('hidden');
console.log('Version modal opened');
} catch (error) { } catch (error) {
console.error('Error showing version info:', error); console.error('Error showing version info:', error);
showToast('Failed to load version information', 'error'); showToast('Failed to load version information', 'error');

View file

@ -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.', description: 'Search for music across your configured metadata sources and download from Soulseek, YouTube, Tidal, Qobuz, HiFi, or Deezer.',
docsId: 'search' docsId: 'search'
}, },
'.search-source-picker-container': { '#enh-source-row': {
title: 'Search From', title: 'Search Source Icons',
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.', 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: [ tips: [
'Auto: searches your configured primary source plus library matches', 'Typing searches only the highlighted source — no more silent fan-out across every provider',
'Spotify / Apple Music / Deezer / Discogs / Hydrabase / MusicBrainz: metadata-only results for that provider', 'Switching to an already-cached source is instant, no re-fetch',
'Soulseek: raw file results with format, bitrate, size, uploader — same as the old Basic Search' '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' docsId: 'search-enhanced'
}, },
// Enhanced Search // Enhanced Search
'.enhanced-search-input-wrapper': { '.enhanced-search-input-wrapper': {
title: 'Enhanced Search', title: 'Search Bar',
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.', 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: [ tips: [
'Click an album to open the download modal', 'Click an album to open the download modal',
'Click a track to search your download source', 'Click a track to search your download source',
'Play button previews tracks from 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' 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': { '#enh-db-artists-section': {
title: 'Library Artists', title: 'Library Artists',
description: 'Artists from your local music library that match the search. Click to view their collection on the Library page.', 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', title: 'Similar Artist',
description: 'An artist similar to the one you\'re viewing. Click to load their discography and browse their releases.', description: 'An artist similar to the one you\'re viewing. Click to load their discography and browse their releases.',
}, },
'.search-source-picker-container': { // (Search source picker annotation lives under `#enh-source-row` above —
title: 'Search Source', // the old `.search-source-picker-container` dropdown is gone.)
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'
},
// ─── AUTOMATIONS PAGE ───────────────────────────────────────────── // ─── AUTOMATIONS PAGE ─────────────────────────────────────────────
@ -2408,7 +2402,7 @@ const HELPER_TOURS = {
description: 'Step-by-step guide to downloading your first album.', description: 'Step-by-step guide to downloading your first album.',
icon: '⬇️', icon: '⬇️',
steps: [ 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: '.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: '#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.' }, { 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 // Entries tagged with `unreleased: true` are accumulating under a version label
// but won't display until the build version catches up. The Search/Artists // but won't display until the build version catches up — used for in-progress
// unification project stays folded here at 2.40 until the whole thing ships. // 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 = { const WHATS_NEW = {
'2.40': [ '2.4.1': [
// --- Search & Artists unification (in progress, not yet released) --- // --- post-2.4.0 dev work — entries hidden by _getLatestWhatsNewVersion until the build version bumps ---
{ date: 'Unreleased — Search & Artists unification', unreleased: true }, { date: 'Unreleased — 2.4.1 dev cycle' },
{ 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: '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: '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: '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: '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: '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: '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: '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: '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: '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.' },
{ 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 }, '2.4.0': [
{ 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 }, // --- 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': [ '2.39': [
// --- April 22, 2026 --- // --- 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() { function _getCurrentVersion() {
const btn = document.querySelector('.version-button'); 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() { function _getLatestWhatsNewVersion() {
// Only surface entries whose version number is <= the current build. Entries // Only surface entries whose version number is <= the current build. Entries
// sitting at higher versions are unreleased work-in-progress and shouldn't // sitting at higher versions are unreleased work-in-progress and shouldn't
// flag as "new" in the helper badge until the build catches up. // 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) const versions = Object.keys(WHATS_NEW)
.filter(v => (parseFloat(v) || 0) <= buildVer) .filter(v => _compareVersions(v, buildVer) <= 0)
.sort((a, b) => parseFloat(b) - parseFloat(a)); .sort((a, b) => _compareVersions(b, a));
return versions[0] || '2.39'; return versions[0] || '2.4.0';
} }
function openWhatsNew() { function openWhatsNew() {
@ -3757,10 +4089,10 @@ function _openFullChangelog() {
function _showOlderNotes() { function _showOlderNotes() {
// Cycle to next older version in the what's new panel (skip unreleased entries) // 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) const versions = Object.keys(WHATS_NEW)
.filter(v => (parseFloat(v) || 0) <= buildVer) .filter(v => _compareVersions(v, buildVer) <= 0)
.sort((a, b) => parseFloat(b) - parseFloat(a)); .sort((a, b) => _compareVersions(b, a));
const panel = _helperPopover; const panel = _helperPopover;
if (!panel) return; if (!panel) return;
const currentTitle = panel.querySelector('.helper-popover-title'); const currentTitle = panel.querySelector('.helper-popover-title');

View file

@ -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 () { document.addEventListener('DOMContentLoaded', async function () {
console.log('SoulSync WebUI initializing...'); console.log('SoulSync WebUI initializing...');
@ -2152,7 +2165,13 @@ function navigateToPage(pageId, options = {}) {
// Artists page, now replaced by clicking artists from the unified Search. // Artists page, now replaced by clicking artists from the unified Search.
if (pageId === 'downloads' || pageId === 'artists') pageId = '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 // Permission guard — redirect to home page if not allowed
if (!isPageAllowed(pageId)) { if (!isPageAllowed(pageId)) {
@ -2237,6 +2256,15 @@ async function loadPageData(pageId) {
if (typeof _stopNebulaLivePolling === 'function') _stopNebulaLivePolling(); if (typeof _stopNebulaLivePolling === 'function') _stopNebulaLivePolling();
if (pageId !== 'sync') { if (pageId !== 'sync') {
cleanupBeatportContent(); 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) { switch (pageId) {
case 'dashboard': case 'dashboard':
@ -2246,6 +2274,10 @@ async function loadPageData(pageId) {
case 'sync': case 'sync':
initializeSyncPage(); initializeSyncPage();
await loadSyncData(); await loadSyncData();
// Process any pending deep-link tab switch (e.g. from Discover page)
if (window._pendingSyncTabAction && typeof _applySyncTabAction === 'function') {
_applySyncTabAction();
}
break; break;
case 'search': case 'search':
initializeSearch(); initializeSearch();

File diff suppressed because it is too large Load diff

View file

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

View file

@ -329,11 +329,6 @@
width: 100%; width: 100%;
} }
.enhanced-search-bar-container button {
width: 100%;
min-height: 44px;
}
.filter-group { .filter-group {
flex-wrap: wrap; flex-wrap: wrap;
} }
@ -3627,4 +3622,119 @@
width: calc(100vw - 16px); width: calc(100vw - 16px);
bottom: 54px; 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;
}
} }

View file

@ -2522,7 +2522,7 @@ function _adlRenderBatchPanel() {
if (batch.active > 0) phaseIcon = '<span class="adl-spinner" style="margin-right:4px"></span>'; if (batch.active > 0) phaseIcon = '<span class="adl-spinner" style="margin-right:4px"></span>';
} else if (batch.phase === 'complete') { } else if (batch.phase === 'complete') {
const analysisTotal = batch.analysis_total || 0; 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`]; let parts = [`${batch.completed} downloaded`];
if (alreadyOwned > 0) parts.push(`${alreadyOwned} owned`); if (alreadyOwned > 0) parts.push(`${alreadyOwned} owned`);
if (batch.failed > 0) parts.push(`${batch.failed} failed`); if (batch.failed > 0) parts.push(`${batch.failed} failed`);
@ -2645,6 +2645,12 @@ function _adlOpenBatchModal(batchId, playlistId, batchName) {
return; 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 other batches, try to show existing modal or rehydrate
for (const [pid, process] of Object.entries(activeDownloadProcesses)) { for (const [pid, process] of Object.entries(activeDownloadProcesses)) {
if (process.batchId === batchId && process.modalElement && document.body.contains(process.modalElement)) { if (process.batchId === batchId && process.modalElement && document.body.contains(process.modalElement)) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

View file

@ -1,21 +1,8 @@
// SEARCH FUNCTIONALITY // SEARCH FUNCTIONALITY
// =============================== // ===============================
// `enhancedSearchFetch`, `SOURCE_LABELS`, and `renderCompactSection` live in
// Shared enhanced-search fetch used by the Search page and the global widget. // shared-helpers.js so the Search page and the global widget share the same
// Pass source to restrict results to a single metadata provider; omit or pass // implementations.
// 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();
}
function initializeSearch() { function initializeSearch() {
// --- FIX: Corrected the element IDs to match the HTML --- // --- FIX: Corrected the element IDs to match the HTML ---
@ -48,19 +35,31 @@ function initializeSearch() {
// =============================== // ===============================
let searchModeToggleInitialized = false; 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() { 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) { if (searchModeToggleInitialized) {
console.log('Search mode toggle already initialized, skipping...'); if (_searchPageRestoreOnEnter) _searchPageRestoreOnEnter();
return; return;
} }
const sourceSelect = document.getElementById('search-source-select'); const sourceRow = document.getElementById('enh-source-row');
const basicSection = document.getElementById('basic-search-section'); const basicSection = document.getElementById('basic-search-section');
const enhancedSection = document.getElementById('enhanced-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'); console.warn('Search source picker elements not found');
return; return;
} }
@ -68,31 +67,12 @@ function initializeSearchModeToggle() {
searchModeToggleInitialized = true; searchModeToggleInitialized = true;
console.log('✅ Initializing search source picker (first time only)'); console.log('✅ Initializing search source picker (first time only)');
// Current source selection — 'auto' (fan-out) by default. Soulseek routes // State + fetch dispatch + icon-row rendering live in the shared
// to the raw-file basic search; everything else routes to enhanced. // `createSearchController` factory (shared-helpers.js) so this page and
let currentSearchSource = sourceSelect.value || 'auto'; // 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 enhancedInput = document.getElementById('enhanced-search-input');
const enhancedSearchBtn = document.getElementById('enhanced-search-btn');
const enhancedCancelBtn = document.getElementById('enhanced-cancel-btn'); const enhancedCancelBtn = document.getElementById('enhanced-cancel-btn');
const enhancedDropdown = document.getElementById('enhanced-dropdown'); const enhancedDropdown = document.getElementById('enhanced-dropdown');
const loadingState = document.getElementById('enhanced-loading'); const loadingState = document.getElementById('enhanced-loading');
@ -100,21 +80,148 @@ function initializeSearchModeToggle() {
const resultsContainer = document.getElementById('enhanced-results-container'); const resultsContainer = document.getElementById('enhanced-results-container');
let debounceTimer = null; let debounceTimer = null;
let abortController = null;
// Multi-source search state // ── Fallback banner ("Spotify unavailable — showing Deezer") ───────
let _enhancedSearchData = null; // Full response with all sources function _renderFallbackBanner(state) {
let _activeSearchSource = null; // Currently displayed source tab const banner = document.getElementById('enh-fallback-banner');
let _altSourceController = null; // AbortController for alternate source fetches 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 = { // Central re-render callback — called by the controller whenever state
spotify: { text: 'Spotify', tabClass: 'enh-tab-spotify', badgeClass: 'enh-badge-spotify' }, // changes (cache hit, fetch settle, query reset). Drives the enhanced
itunes: { text: 'Apple Music', tabClass: 'enh-tab-itunes', badgeClass: 'enh-badge-itunes' }, // dropdown UI: loading state, empty state, results render, fallback
deezer: { text: 'Deezer', tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer' }, // banner.
discogs: { text: 'Discogs', tabClass: 'enh-tab-discogs', badgeClass: 'enh-badge-discogs' }, function _renderFromState(state) {
hydrabase: { text: 'Hydrabase', tabClass: 'enh-tab-hydrabase', badgeClass: 'enh-badge-hydrabase' }, const src = state.activeSource;
youtube_videos: { text: 'Music Videos', tabClass: 'enh-tab-youtube', badgeClass: 'enh-badge-youtube' },
musicbrainz: { text: 'MusicBrainz', tabClass: 'enh-tab-musicbrainz', badgeClass: 'enh-badge-musicbrainz' }, // 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 // Live search with debouncing
@ -138,7 +245,7 @@ function initializeSearchModeToggle() {
// Debounce search // Debounce search
debounceTimer = setTimeout(() => { debounceTimer = setTimeout(() => {
performEnhancedSearch(query); searchController.submitQuery(query);
}, 300); }, 300);
}); });
@ -147,41 +254,12 @@ function initializeSearchModeToggle() {
const query = e.target.value.trim(); const query = e.target.value.trim();
if (query.length >= 2) { if (query.length >= 2) {
clearTimeout(debounceTimer); 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) { if (enhancedCancelBtn) {
enhancedCancelBtn.addEventListener('click', () => { enhancedCancelBtn.addEventListener('click', () => {
enhancedInput.value = ''; enhancedInput.value = '';
@ -204,105 +282,26 @@ function initializeSearchModeToggle() {
const dropdown = document.getElementById('enhanced-dropdown'); const dropdown = document.getElementById('enhanced-dropdown');
if (dropdown && !dropdown.classList.contains('hidden')) { if (dropdown && !dropdown.classList.contains('hidden')) {
const isClickInside = e.target.closest('.enhanced-search-input-wrapper'); 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. // Modal sits above the dropdown; closing it shouldn't dismiss results.
const isClickInModal = e.target.closest('.download-missing-modal'); const isClickInModal = e.target.closest('.download-missing-modal');
if (!isClickInside && !isClickInModal) { if (!isClickInside && !isClickOnSourceRow && !isClickInModal) {
hideDropdown(); 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) { function renderDropdownResults(data) {
const activeSource = searchController.state.activeSource;
// Music Videos tab — don't render regular sections // 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) // 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 sourceInfo = SOURCE_LABELS[displaySource] || SOURCE_LABELS.spotify;
const sourceBadge = { text: sourceInfo.text, class: sourceInfo.badgeClass }; const sourceBadge = { text: sourceInfo.text, class: sourceInfo.badgeClass };
@ -339,7 +338,7 @@ function initializeSearchModeToggle() {
meta: 'Artist', meta: 'Artist',
badge: sourceBadge, badge: sourceBadge,
onClick: () => { onClick: () => {
const sourceOverride = _activeSearchSource; const sourceOverride = searchController.state.activeSource;
console.log(`🎵 Opening artist detail: ${artist.name} (ID: ${artist.id}, source: ${sourceOverride})`); console.log(`🎵 Opening artist detail: ${artist.name} (ID: ${artist.id}, source: ${sourceOverride})`);
hideDropdown(); hideDropdown();
navigateToArtistDetail(artist.id, artist.name, sourceOverride || null); 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 `<button class="enh-source-tab ${info.tabClass} ${isActive ? 'active' : ''}"
onclick="window._switchEnhSourceTab('${name}')"
data-source="${name}">
${info.text}<span class="enh-tab-count">(${count})</span>
</button>`;
}).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 = '<div class="enh-section-loading"><div class="server-search-spinner" style="width:16px;height:16px"></div><span>Loading...</span></div>';
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) { function _renderVideoResults(videos) {
let section = document.getElementById('enh-videos-section'); let section = document.getElementById('enh-videos-section');
if (!section) { if (!section) {
@ -769,9 +574,17 @@ function initializeSearchModeToggle() {
if (!artistId) continue; if (!artistId) continue;
try { try {
const imgUrl = _activeSearchSource && _activeSearchSource !== 'spotify' const activeSource = searchController.state.activeSource;
? `/api/artist/${artistId}/image?source=${_activeSearchSource}` // Pass the artist name so the backend can look up images
: `/api/artist/${artistId}/image`; // 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 response = await fetch(imgUrl);
const data = await response.json(); const data = await response.json();
@ -808,118 +621,7 @@ function initializeSearchModeToggle() {
return `${minutes}:${seconds.toString().padStart(2, '0')}`; return `${minutes}:${seconds.toString().padStart(2, '0')}`;
} }
function renderCompactSection(sectionId, listId, countId, items, mapItem) { // renderCompactSection now lives in shared-helpers.js.
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
? `<img src="${escapeHtml(config.image)}" class="${imageClass}" alt="${escapeHtml(config.name)}">`
: `<div class="${placeholderClass}" data-lazy-image="true">${config.placeholder}</div>`;
const badgeHtml = config.badge
? `<div class="enh-item-badge ${config.badge.class}">${config.badge.text}</div>`
: '';
const durationHtml = config.duration && isTrack
? `<div class="enh-item-duration">
${escapeHtml(config.duration)}
<button class="enh-item-play-btn" title="Stream this track"></button>
</div>`
: '';
elem.innerHTML = `
${imageHtml}
<div class="enh-item-info">
<div class="enh-item-name">${escapeHtml(config.name)}</div>
<div class="enh-item-meta">${escapeHtml(config.meta)}</div>
</div>
${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);
});
}
});
}
async function handleEnhancedSearchAlbumClick(album) { async function handleEnhancedSearchAlbumClick(album) {
console.log(`💿 Enhanced search album clicked: ${album.name} by ${album.artist}`); console.log(`💿 Enhanced search album clicked: ${album.name} by ${album.artist}`);
@ -929,8 +631,9 @@ function initializeSearchModeToggle() {
try { try {
// Fetch full album data with tracks — pass source for correct routing // Fetch full album data with tracks — pass source for correct routing
const albumParams = new URLSearchParams({ name: album.name || '', artist: album.artist || '' }); const albumParams = new URLSearchParams({ name: album.name || '', artist: album.artist || '' });
if (_activeSearchSource && _activeSearchSource !== 'spotify') { const activeSource = searchController.state.activeSource;
albumParams.set('source', _activeSearchSource); if (activeSource && activeSource !== 'spotify') {
albumParams.set('source', activeSource);
} }
// Pass Hydrabase plugin origin so server routes to correct client // Pass Hydrabase plugin origin so server routes to correct client
if (album.external_urls?.hydrabase_plugin) { if (album.external_urls?.hydrabase_plugin) {
@ -996,7 +699,7 @@ function initializeSearchModeToggle() {
id: firstArtist.id || album.id?.split?.('_')?.[0] || '', id: firstArtist.id || album.id?.split?.('_')?.[0] || '',
name: firstArtist.name || album.artist, name: firstArtist.name || album.artist,
image_url: firstArtist.image_url || firstArtist.images?.[0]?.url || '', image_url: firstArtist.image_url || firstArtist.images?.[0]?.url || '',
source: _activeSearchSource || '', source: activeSource || '',
}; };
// Prepare full album object for modal // Prepare full album object for modal
@ -1314,10 +1017,7 @@ function initializeSearchModeToggle() {
function showDropdown() { function showDropdown() {
const dropdown = document.getElementById('enhanced-dropdown'); const dropdown = document.getElementById('enhanced-dropdown');
if (dropdown) { if (dropdown) dropdown.classList.remove('hidden');
dropdown.classList.remove('hidden');
updateToggleButtonState();
}
// Hide the page header + source picker to reclaim space // Hide the page header + source picker to reclaim space
const header = document.querySelector('#search-page .downloads-header'); const header = document.querySelector('#search-page .downloads-header');
const modeToggle = document.querySelector('.search-source-picker-container'); const modeToggle = document.querySelector('.search-source-picker-container');
@ -1329,10 +1029,7 @@ function initializeSearchModeToggle() {
function hideDropdown() { function hideDropdown() {
const dropdown = document.getElementById('enhanced-dropdown'); const dropdown = document.getElementById('enhanced-dropdown');
if (dropdown) { if (dropdown) dropdown.classList.add('hidden');
dropdown.classList.add('hidden');
updateToggleButtonState();
}
// Restore hidden elements // Restore hidden elements
const header = document.querySelector('#search-page .downloads-header'); const header = document.querySelector('#search-page .downloads-header');
const modeToggle = document.querySelector('.search-source-picker-container'); const modeToggle = document.querySelector('.search-source-picker-container');
@ -1341,27 +1038,6 @@ function initializeSearchModeToggle() {
if (modeToggle) modeToggle.classList.remove('enh-results-active-hide'); if (modeToggle) modeToggle.classList.remove('enh-results-active-hide');
if (slskdPlaceholder) slskdPlaceholder.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() { async function performSearch() {

View file

@ -996,6 +996,11 @@ async function loadSettingsData() {
const requirePin = settings.security?.require_pin_on_launch || false; const requirePin = settings.security?.require_pin_on_launch || false;
document.getElementById('security-require-pin').checked = requirePin; 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 // Check if admin has a PIN set
const profilesRes = await fetch('/api/profiles'); const profilesRes = await fetch('/api/profiles');
const profilesData = await profilesRes.json(); const profilesData = await profilesRes.json();
@ -2587,9 +2592,32 @@ async function saveSettings(quiet = false) {
}, },
security: { security: {
require_pin_on_launch: document.getElementById('security-require-pin')?.checked || false, 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 { try {
if (!quiet) showLoadingOverlay('Saving settings...'); if (!quiet) showLoadingOverlay('Saving settings...');

View file

@ -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 <img> 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
? `<img src="${escapeHtml(info.logo)}" alt="" loading="lazy">`
: info.icon);
return `
<button class="${classes}" data-source="${src}" role="tab"
aria-selected="${active}" title="${escapeHtml(title)}">
<span class="${glyphClass}">${glyph}</span>
<span class="${labelClass}">${escapeHtml(info.text)}</span>
</button>`;
}).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 = `<div class="${placeholderClass}" data-lazy-image="true">${config.placeholder}</div>`;
const escapedFallback = placeholderHtml.replace(/"/g, '&quot;');
const imageHtml = config.image
? `<img src="${escapeHtml(config.image)}" class="${imageClass}" alt="${escapeHtml(config.name)}" onerror="this.outerHTML='${escapedFallback}'">`
: placeholderHtml;
const badgeHtml = config.badge
? `<div class="enh-item-badge ${config.badge.class}">${config.badge.text}</div>`
: '';
const durationHtml = config.duration && isTrack
? `<div class="enh-item-duration">
${escapeHtml(config.duration)}
<button class="enh-item-play-btn" title="Stream this track"></button>
</div>`
: '';
elem.innerHTML = `
${imageHtml}
<div class="enh-item-info">
<div class="enh-item-name">${escapeHtml(config.name)}</div>
<div class="enh-item-meta">${escapeHtml(config.meta)}</div>
</div>
${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) // Discography completion checking (for artist-detail pages, library page)
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------

File diff suppressed because it is too large Load diff

162
webui/static/sw.js Normal file
View file

@ -0,0 +1,162 @@
/* SoulSync Service Worker image cache + lightweight shell cache.
*
* Strategy:
*
* - **Images** (cover art / artist photos from CDNs + the local
* /api/image-proxy endpoint): cache-first. Once an album cover is
* fetched, every future page load serves it instantly from
* CacheStorage with no network round-trip. Cover art is the
* heaviest asset on Library and Discover; this is the single
* biggest perceived-performance win.
*
* - **Static assets** (/static/*.js, /static/*.css, /static/*.png):
* stale-while-revalidate. Serve from cache instantly, refresh in
* the background. Combined with the existing ?v=static_v cache
* bust, deploys still ship live a new query string means a
* different cache entry, the old one ages out naturally.
*
* - **Everything else** (HTML, /api/*, etc.): no caching. Pass
* through to the network. We deliberately do NOT cache HTML or
* API responses both are user-specific or change frequently
* enough that staleness would hurt more than it helps.
*
* Cache versioning: bump CACHE_VERSION when changing strategies or
* cache shapes. The activate handler clears any cache whose name
* doesn't match the current version, so old entries don't accumulate.
*/
const CACHE_VERSION = 'v1';
const IMAGE_CACHE = `soulsync-images-${CACHE_VERSION}`;
const STATIC_CACHE = `soulsync-static-${CACHE_VERSION}`;
const VALID_CACHES = new Set([IMAGE_CACHE, STATIC_CACHE]);
// Image hosts we cache. Local /api/image-proxy is treated as an image
// (see _isImageRequest below) so the proxy endpoint piggybacks on the
// same strategy without needing to be listed here.
const IMAGE_HOSTS = [
'i.scdn.co', // Spotify
'lastfm.freetls.fastly.net', 'lastfm-img2.akamaized.net',
'mosaic.scdn.co',
'is1-ssl.mzstatic.com', 'is2-ssl.mzstatic.com',
'is3-ssl.mzstatic.com', 'is4-ssl.mzstatic.com',
'is5-ssl.mzstatic.com', // Apple
'cdns-images.dzcdn.net', 'e-cdns-images.dzcdn.net', // Deezer
'i.discogs.com', 'st.discogs.com', // Discogs
'coverartarchive.org', // MusicBrainz Cover Art Archive
'i.ytimg.com', // YouTube thumbnails
];
function _isImageRequest(request) {
if (request.method !== 'GET') return false;
const url = new URL(request.url);
// Local image proxy
if (url.pathname.startsWith('/api/image-proxy')) return true;
// Known CDN hosts
if (IMAGE_HOSTS.includes(url.hostname)) return true;
// Last-resort: file extension hint (covers misc CDNs we missed)
if (/\.(png|jpe?g|webp|gif|svg)(\?|$)/i.test(url.pathname)) {
// Only if same-origin or known image host; refuse arbitrary
// third-party domains so we don't accidentally cache trackers.
if (url.origin === self.location.origin) return true;
}
return false;
}
function _isStaticAsset(request) {
if (request.method !== 'GET') return false;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return false;
return url.pathname.startsWith('/static/');
}
self.addEventListener('install', (event) => {
// Skip waiting so a freshly-installed SW takes control on the next
// navigation instead of needing all tabs to close first. Combined
// with clients.claim() in activate, deploys propagate quickly.
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
// Wipe any caches whose name doesn't match the current version, then
// claim all open clients so this SW starts handling their fetches
// immediately (otherwise they'd keep using the previous SW until
// navigation).
event.waitUntil(
caches.keys().then((names) => Promise.all(
names.map((name) => VALID_CACHES.has(name) ? null : caches.delete(name))
)).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
const request = event.request;
if (_isImageRequest(request)) {
event.respondWith(_cacheFirst(request, IMAGE_CACHE));
return;
}
if (_isStaticAsset(request)) {
event.respondWith(_staleWhileRevalidate(request, STATIC_CACHE));
return;
}
// HTML / API / everything else: pass through, no caching.
// Do NOT call event.respondWith() — let the browser handle it
// normally. This is intentional: HTML and API responses are
// user-specific or change too often for SW caching to help.
});
// ── strategies ───────────────────────────────────────────────────────
async function _cacheFirst(request, cacheName) {
try {
const cache = await caches.open(cacheName);
const hit = await cache.match(request);
if (hit) return hit;
const response = await fetch(request);
// Only cache successful, opaque-OK responses. Don't cache 404s
// / 500s — would pin a bad placeholder for the lifetime of the
// cache version.
if (response && (response.ok || response.type === 'opaque')) {
// Clone before .put — body is consumed otherwise.
cache.put(request, response.clone()).catch(() => { /* quota / disk full */ });
}
return response;
} catch (err) {
// Network failure with no cache hit — let the browser surface
// its standard offline / error UI (returning Response.error()
// is equivalent to letting the fetch reject naturally).
return Response.error();
}
}
async function _staleWhileRevalidate(request, cacheName) {
try {
const cache = await caches.open(cacheName);
const hit = await cache.match(request);
// Kick off a background refresh regardless of cache hit so the
// next load picks up any deploy. Failure here is silent — we
// already have a cached copy to serve (or are about to fetch).
const networkPromise = fetch(request).then((response) => {
if (response && response.ok) {
cache.put(request, response.clone()).catch(() => {});
}
return response;
}).catch(() => null);
// Serve cached immediately if we have it; otherwise wait on the
// network and fall back to Response.error() if THAT also failed.
// Important: must await networkPromise here — returning the
// Promise directly would let respondWith resolve to null when
// the fetch rejects, which throws TypeError in the browser.
if (hit) return hit;
const networkResponse = await networkPromise;
return networkResponse || Response.error();
} catch (err) {
return Response.error();
}
}

View file

@ -2736,6 +2736,65 @@ async function startDeezerDownloadMissing(urlHash) {
// SYNC PAGE FUNCTIONALITY (REDESIGNED) // SYNC PAGE FUNCTIONALITY (REDESIGNED)
// =============================== // ===============================
/**
* Navigate to the Sync page and activate a specific tab.
* Works from any page. If already on the sync page, just switches the tab.
* @param {string} tabId - Tab data-tab value (e.g. 'discover', 'spotify', 'mirrored')
* @param {object} [opts] - Options
* @param {string} [opts.highlight] - Element ID to scroll to and briefly highlight
* @param {string} [opts.autoSync] - Discover playlist type to auto-trigger sync on
* @param {boolean} [opts.forceDownload] - Pass force_download_all when auto-syncing
*/
function navigateToSyncTab(tabId, opts) {
window._pendingSyncTabAction = { tabId, ...(opts || {}) };
if (typeof currentPage !== 'undefined' && currentPage === 'sync') {
_applySyncTabAction();
} else {
navigateToPage('sync');
}
}
function _applySyncTabAction() {
const action = window._pendingSyncTabAction;
if (!action) return;
window._pendingSyncTabAction = null;
const tabId = action.tabId;
// Click the target tab button to trigger normal tab-switch logic
const btn = document.querySelector(`.sync-tab-button[data-tab="${tabId}"]`);
if (btn && !btn.classList.contains('active')) {
btn.click();
}
// Wait for lazy-loaded content, then highlight / auto-sync
const apply = () => {
if (action.highlight) {
const el = document.getElementById(action.highlight);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('discover-sync-card-highlight');
setTimeout(() => el.classList.remove('discover-sync-card-highlight'), 2500);
}
}
if (action.autoSync) {
syncDiscoverPlaylistFromTab(action.autoSync, action.autoSyncName || action.autoSync);
}
};
// Wait for lazy-loaded content to appear before applying
let attempts = 0;
const maxAttempts = 20; // 20 * 200ms = 4s max
const waitAndApply = () => {
const ready = !action.highlight || document.getElementById(action.highlight);
if (ready || attempts >= maxAttempts) {
apply();
} else {
attempts++;
setTimeout(waitAndApply, 200);
}
};
setTimeout(waitAndApply, 200);
}
function initializeSyncPage() { function initializeSyncPage() {
// Logic for tab switching // Logic for tab switching
const tabButtons = document.querySelectorAll('.sync-tab-button'); const tabButtons = document.querySelectorAll('.sync-tab-button');