Merge branch 'dev' into fix/downloads-page
# Conflicts: # webui/static/style.css
This commit is contained in:
commit
a00d069369
53 changed files with 12829 additions and 3467 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
1
.github/workflows/cleanup-dev-images.yml
vendored
1
.github/workflows/cleanup-dev-images.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
1
.github/workflows/dev-nightly.yml
vendored
1
.github/workflows/dev-nightly.yml
vendored
|
|
@ -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)
|
||||||
|
|
|
||||||
1
.github/workflows/docker-publish.yml
vendored
1
.github/workflows/docker-publish.yml
vendored
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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
1496
core/library_reorganize.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 artist→release-group link
|
||||||
|
directly.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
artist_mbid: Artist's MusicBrainz ID
|
||||||
|
release_types: Filter by primary type — any of 'album', 'single',
|
||||||
|
'ep', 'compilation', 'soundtrack', 'live', etc. Combined with
|
||||||
|
`|` per MB spec, e.g. `['album', 'ep']` → `type=album|ep`.
|
||||||
|
None returns all types.
|
||||||
|
limit: 1-100 (MB hard cap)
|
||||||
|
offset: Pagination offset
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of release-group dicts. Each has `id`, `title`, `primary-type`,
|
||||||
|
`secondary-types`, `first-release-date`, `disambiguation`.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
params = {'artist': artist_mbid, 'fmt': 'json', 'limit': min(limit, 100), 'offset': offset}
|
||||||
|
if release_types:
|
||||||
|
params['type'] = '|'.join(release_types)
|
||||||
|
|
||||||
|
response = self.session.get(
|
||||||
|
f"{self.BASE_URL}/release-group",
|
||||||
|
params=params,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
rgs = data.get('release-groups', [])
|
||||||
|
logger.debug(f"Browsed {len(rgs)} release-groups for artist {artist_mbid}")
|
||||||
|
return rgs
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error browsing release-groups for artist {artist_mbid}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
@rate_limited
|
||||||
|
def search_recordings_by_artist_mbid(self, artist_mbid: str,
|
||||||
|
limit: int = 100) -> List[Dict[str, Any]]:
|
||||||
|
"""Search for recordings linked to an artist via Lucene `arid:` query.
|
||||||
|
|
||||||
|
This is the counterpart to `browse_artist_release_groups` for tracks.
|
||||||
|
The proper "browse" endpoint (`/recording?artist=<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]]:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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
453
core/reorganize_queue.py
Normal 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
123
core/reorganize_runner.py
Normal 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
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
256
core/socketio_cors.py
Normal 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}")
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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))"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
129
tests/test_discogs_track_count.py
Normal file
129
tests/test_discogs_track_count.py
Normal 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
|
||||||
1936
tests/test_library_reorganize_orchestrator.py
Normal file
1936
tests/test_library_reorganize_orchestrator.py
Normal file
File diff suppressed because it is too large
Load diff
767
tests/test_musicbrainz_search.py
Normal file
767
tests/test_musicbrainz_search.py
Normal 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
|
||||||
213
tests/test_reorganize_db_methods.py
Normal file
213
tests/test_reorganize_db_methods.py
Normal 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')
|
||||||
479
tests/test_reorganize_queue.py
Normal file
479
tests/test_reorganize_queue.py
Normal 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()
|
||||||
235
tests/test_reorganize_runner.py
Normal file
235
tests/test_reorganize_runner.py
Normal 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
442
tests/test_socketio_cors.py
Normal 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 == []
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
162
tests/test_tidal_stream_tier_verification.py
Normal file
162
tests/test_tidal_stream_tier_verification.py
Normal 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
|
||||||
116
tests/test_worker_utils_album_track_count.py
Normal file
116
tests/test_worker_utils_album_track_count.py
Normal 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)
|
||||||
2577
web_server.py
2577
web_server.py
File diff suppressed because it is too large
Load diff
118
webui/index.html
118
webui/index.html
|
|
@ -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 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>
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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
37
webui/static/manifest.json
Normal file
37
webui/static/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
BIN
webui/static/pwa-icon-192.png
Normal file
BIN
webui/static/pwa-icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
BIN
webui/static/pwa-icon-512.png
Normal file
BIN
webui/static/pwa-icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 303 KiB |
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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...');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, '"');
|
||||||
|
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
162
webui/static/sw.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue