Merge pull request #834 from Nezreka/dev

Dev
This commit is contained in:
BoulderBadgeDad 2026-06-09 23:32:10 -07:00 committed by GitHub
commit 222653036b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 5724 additions and 326 deletions

View file

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

View file

@ -66,6 +66,12 @@ class ConfigManager:
self._load_config()
# Placeholder shipped to the browser in place of a configured secret
# (#832 follow-up). The settings UI shows it as masked dots; if it's
# round-tripped back on save, ``set()`` treats it as "keep existing" so the
# real value is never overwritten by the mask.
REDACTED_SENTINEL = '__redacted_unchanged__'
# Dot-notation paths to sensitive config values that must be encrypted at rest.
# Paths pointing to dicts encrypt the entire dict as a JSON blob.
_SENSITIVE_PATHS = frozenset({
@ -792,7 +798,40 @@ class ConfigManager:
return value
def redacted_config(self) -> Dict[str, Any]:
"""Deep copy of the live config with every sensitive value masked.
Used for ``GET /api/settings`` so decrypted secrets never reach the
browser (#832 follow-up). A *set* secret becomes ``REDACTED_SENTINEL``
(the UI renders it as masked dots); an unset one stays empty so the UI
can show "not configured". Dict-valued secrets (OAuth sessions) collapse
to the sentinel too the UI has no field for them anyway. The matching
guard in ``set()`` turns a round-tripped sentinel back into a no-op.
"""
import copy
data = copy.deepcopy(self.config_data)
for path in self._SENSITIVE_PATHS:
keys = path.split('.')
parent = data
for k in keys[:-1]:
if isinstance(parent, dict) and k in parent:
parent = parent[k]
else:
parent = None
break
if not isinstance(parent, dict):
continue
leaf = keys[-1]
if leaf in parent and parent[leaf] not in (None, '', {}, [], 0, False):
parent[leaf] = self.REDACTED_SENTINEL
return data
def set(self, key: str, value: Any):
# The UI round-trips REDACTED_SENTINEL for any secret the user didn't
# touch — never let the mask overwrite the real value (#832 follow-up).
if value == self.REDACTED_SENTINEL and key in self._SENSITIVE_PATHS:
return
keys = key.split('.')
config = self.config_data

View file

@ -315,7 +315,52 @@ class _BatchStateAccessImpl:
row['album_bundle_state'] = 'failed'
def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: MasterDeps):
# Task states that mean a batch still has work in flight. While ANY of a batch's
# tasks is in one of these, a serialized album-pool worker keeps its slot.
_NON_TERMINAL_TASK_STATUSES = ('pending', 'queued', 'searching', 'downloading', 'post_processing')
def _wait_for_batch_drain(batch_id: str, poll_seconds: float = 1.5,
max_wait_seconds: float = 3600.0) -> None:
"""Block until every task in ``batch_id`` reaches a terminal state (the batch
is fully drained), the batch is removed, shutdown is requested, or a safety
cap elapses.
Used to make the dedicated album-bundle pool actually SERIALIZE albums: the
worker holds its pool slot for the album's whole lifetime instead of
returning the instant downloads are started. That stops every album from
dumping its tracks into the shared download pool at once (Sokhi: "searching
for way too many tracks at once"). It's a PASSIVE wait — the downloads are
driven by the monitor + completion callbacks on other threads, so this never
drives the work and can't deadlock; worst case the cap releases the slot and
the downloads simply finish in the background."""
from core.downloads import monitor as _monitor
start = time.time()
while True:
if getattr(_monitor, 'IS_SHUTTING_DOWN', False):
return
with tasks_lock:
batch = download_batches.get(batch_id)
if not batch:
return
queue = list(batch.get('queue', ()) or ())
still_working = any(
download_tasks.get(t, {}).get('status') in _NON_TERMINAL_TASK_STATUSES
for t in queue
)
if not still_working:
return
if time.time() - start > max_wait_seconds:
logger.warning(
"[Album Serialize] batch %s not drained after %.0fs — releasing the "
"album-pool slot (its downloads continue in the background)",
batch_id, max_wait_seconds)
return
time.sleep(poll_seconds)
def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: MasterDeps,
serialize: bool = False):
"""
A master worker that handles the entire missing tracks process:
1. Runs the analysis.
@ -1150,6 +1195,16 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
deps.download_monitor.start_monitoring(batch_id)
deps.start_next_batch_of_downloads(batch_id)
# Album-bundle batches run on the dedicated album pool and pass
# serialize=True: hold this pool slot until the album finishes so only a
# few albums are ever in flight at once, instead of every album batch
# immediately starting and flooding the shared download pool with
# 'searching' tracks (#740 / Sokhi). The residual + playlist + manual
# paths run on the shared download pool and DON'T serialize (blocking
# there would steal an actual download worker).
if serialize:
_wait_for_batch_drain(batch_id)
except Exception as e:
logger.error(f"Master worker for batch {batch_id} failed: {e}")
import traceback

View file

@ -0,0 +1,97 @@
"""Recognize a pasted streaming-source track link in the manual download
search (#813).
A user pastes e.g. ``https://tidal.com/track/434945950/u`` instead of typing a
query, to grab the exact version. We only recognize sources that download by
track ID (Tidal, Qobuz) the manual search then resolves the link to that
track and runs the source's own search so the result is a normal, downloadable
candidate (no hand-built download encoding).
Pure + import-safe: parsing only, no network.
"""
from __future__ import annotations
import re
from typing import Any, Optional, Tuple
from urllib.parse import urlparse
# host substring → download source id. Only ID-downloadable streaming sources.
_HOSTS = (
('tidal.com', 'tidal'),
('qobuz.com', 'qobuz'),
)
def parse_download_track_link(raw: str) -> Optional[Tuple[str, str]]:
"""Parse a pasted Tidal/Qobuz track URL into ``(source, track_id)``.
Returns None when the input isn't a recognized track link (so the caller
falls back to a normal text search). Handles the common URL shapes:
``tidal.com/track/<id>[/u]``, ``listen.tidal.com/track/<id>``,
``tidal.com/browse/track/<id>``, ``open.qobuz.com/track/<id>``,
``play.qobuz.com/track/<id>`` with or without the scheme.
"""
raw = (raw or '').strip()
if not raw:
return None
lowered = raw.lower()
if '://' not in raw and not any(h in lowered for h, _ in _HOSTS):
return None # not even a URL we care about
url = raw if '://' in raw else f'https://{raw}'
parsed = urlparse(url)
host = (parsed.netloc or '').lower()
source = next((sid for h, sid in _HOSTS if h in host), None)
if not source:
return None
segs = [s for s in (parsed.path or '').split('/') if s]
for i, seg in enumerate(segs):
if seg.lower() == 'track' and i + 1 < len(segs):
m = re.match(r'(\d+)', segs[i + 1]) # id may carry a slug/suffix
if m:
return (source, m.group(1))
return None
def _first_artist_name(value: Any) -> str:
"""First artist name from a list of {'name': ...}/strings, or a single
{'name': ...}/string."""
if isinstance(value, list):
value = value[0] if value else None
if isinstance(value, dict):
return str(value.get('name') or '')
return str(value or '')
def query_from_track_payload(source: str, raw: Any) -> Optional[str]:
"""Build a clean ``"artist title"`` search query from a source ``get_track``
payload pure, so the per-source shape parsing is unit-testable without a
live client.
- Tidal: attributes dict (``title`` + optional ``version`` + maybe
``artists``/``artist``). The version is appended so a remix link searches
for the remix.
- Qobuz: track dict (``title`` + ``performer``/``album.artist``).
"""
if not isinstance(raw, dict):
return None
title = (raw.get('title') or '').strip()
artist = ''
if source == 'tidal':
version = (raw.get('version') or '').strip()
if version and version.lower() not in title.lower():
title = f"{title} ({version})" if title else version
artist = _first_artist_name(raw.get('artists') or raw.get('artist'))
elif source == 'qobuz':
artist = _first_artist_name(raw.get('performer'))
if not artist:
album = raw.get('album') if isinstance(raw.get('album'), dict) else {}
artist = _first_artist_name(album.get('artist'))
query = f"{artist} {title}".strip()
return query or (title or None)

View file

@ -129,30 +129,36 @@ def get_import_search_result(context: Optional[Dict[str, Any]]) -> Dict[str, Any
def get_import_source(context: Optional[Dict[str, Any]]) -> str:
# Several track payloads carry the metadata source under "_source" rather
# than "source" (the discography/wishlist dicts, frontend search results).
# Only the context-level "_source" was honored (normalize_import_context);
# the nested dicts were checked for "source" alone, so a Deezer-sourced
# Download Now resolved to '' and source-specific metadata logic (the
# Deezer contributors upgrade for multi-artist tags) never ran (Netti93).
if not isinstance(context, dict):
return ""
source = context.get("source")
source = context.get("source") or context.get("_source")
if source:
return str(source)
track_info = get_import_track_info(context)
source = _first_value(track_info, "source", default="")
source = _first_value(track_info, "source", "_source", default="")
if source:
return str(source)
original_search = get_import_original_search(context)
source = _first_value(original_search, "source", default="")
source = _first_value(original_search, "source", "_source", default="")
if source:
return str(source)
album = get_import_context_album(context)
source = _first_value(album, "source", default="")
source = _first_value(album, "source", "_source", default="")
if source:
return str(source)
artist = get_import_context_artist(context)
source = _first_value(artist, "source", default="")
source = _first_value(artist, "source", "_source", default="")
return str(source) if source else ""

View file

@ -587,6 +587,45 @@ def build_final_path_for_track(context, artist_context, album_info, file_ext, cr
disc_label = _get_config_manager().get("file_organization.disc_label", "Disc")
folder_path, filename_base = get_file_path_from_template(template_context, "album_path")
# #829: if this album already lives in a single folder on disk, drop the
# new track there instead of a freshly-templated folder — this is what
# keeps an album from splitting when $albumtype/$year drift between
# batches (wishlist, Album Completeness, a missed track later). Strict
# match + transfer-dir-only + single-folder-only inside the resolver;
# any miss falls through to the template path below. Best-effort.
reuse_folder = None
if filename_base:
try:
from core.library.existing_album_folder import resolve_existing_album_folder
from database.music_database import get_database
try:
_active_server = _get_config_manager().get_active_media_server()
except Exception:
_active_server = None
_spotify_album_id = (album_context.get("id")
if album_context and str(source).startswith("spotify") else None)
_expected_tracks = None
if album_context and album_context.get("total_tracks"):
_expected_tracks = _coerce_int(album_context.get("total_tracks"), 0) or None
reuse_folder = resolve_existing_album_folder(
db=get_database(),
transfer_dir=transfer_dir,
album_name=album_info.get("album_name"),
album_artist=template_context.get("albumartist"),
spotify_album_id=_spotify_album_id,
active_server=_active_server,
expected_track_count=_expected_tracks,
config_manager=_get_config_manager(),
)
except Exception as _reuse_err:
logger.debug("[Existing Album Folder] lookup failed: %s", _reuse_err)
reuse_folder = None
if reuse_folder and filename_base:
final_path = os.path.join(reuse_folder, filename_base + file_ext)
_ensure_dir(reuse_folder, exist_ok=True)
return final_path, True
if folder_path and filename_base:
if total_discs > 1 and not user_controls_disc:
disc_folder = f"{disc_label} {disc_number}"

View file

@ -734,6 +734,14 @@ class iTunesWorker:
UPDATE albums SET year = ?
WHERE id = ? AND (year IS NULL OR year = '' OR year = '0')
""", (year, album_id))
# #824: also store the FULL release date when iTunes has one
# (YYYY-MM or YYYY-MM-DD). Only when empty — never clobber a
# manually-set release_date.
if len(album_obj.release_date) > 4:
cursor.execute("""
UPDATE albums SET release_date = ?
WHERE id = ? AND (release_date IS NULL OR release_date = '')
""", (album_obj.release_date, album_id))
# Cache the authoritative expected track count for the Album
# Completeness repair job (see set_album_api_track_count docstring).

View file

@ -65,7 +65,19 @@ class JellyfinAlbum:
self.title = jellyfin_data.get('Name', 'Unknown Album')
self.addedAt = self._parse_date(jellyfin_data.get('DateCreated'))
self._artist_id = jellyfin_data.get('AlbumArtists', [{}])[0].get('Id', '') if jellyfin_data.get('AlbumArtists') else ''
# Album cover image, mirroring JellyfinArtist.thumb — so the library
# scan stores albums.thumb_url instead of leaving it empty. Without
# this, EVERY album reads back with no thumb, the web UI shows blank
# art, and the Cover Art Filler flags the entire library as "missing
# cover art" (it became the only thing populating the column).
self.thumb = self._get_album_image_url()
def _get_album_image_url(self) -> Optional[str]:
"""Jellyfin/Emby album primary image URL (same shape as the artist one)."""
if not self.ratingKey:
return None
return f"/Items/{self.ratingKey}/Images/Primary"
def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
if not date_str:
return None
@ -73,7 +85,7 @@ class JellyfinAlbum:
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
except:
return None
def artist(self) -> Optional[JellyfinArtist]:
"""Get the album artist"""
if self._artist_id:
@ -1554,20 +1566,40 @@ class JellyfinClient(MediaServerClient):
return self.create_playlist(playlist_name, tracks)
playlist_id = existing_playlist.id
existing_tracks = self.get_playlist_tracks(playlist_id)
existing_ids = {
str(t.id) for t in existing_tracks if hasattr(t, 'id') and t.id
}
# #823 round 2: the old dedupe read `t.id` off get_playlist_tracks()
# results — but JellyfinTrack only defines `ratingKey`, so the
# existing-ids set was ALWAYS empty and every sync re-appended the
# whole matched list ("added 22 ... skipped 0" on a playlist that
# already had them, every track N times). Fetch the playlist's items
# from the canonical /Playlists/{id}/Items endpoint (the same one
# reconcile uses — works on Jellyfin GUIDs and Emby numeric ids) and
# dedupe on the raw item Id; fall back to ratingKey if that fails.
existing_ids = set()
items_resp = self._make_request(
f'/Playlists/{playlist_id}/Items', {'UserId': self.user_id})
if items_resp:
for item in items_resp.get('Items', []):
iid = str(item.get('Id') or '')
if iid:
existing_ids.add(iid)
else:
existing_ids = {
str(getattr(t, 'ratingKey', '') or '')
for t in self.get_playlist_tracks(playlist_id)
} - {''}
new_track_ids = []
desired_ids = []
for t in tracks:
tid = None
if hasattr(t, 'id'):
tid = str(t.id) if t.id else None
elif isinstance(t, dict):
tid = str(t.get('Id') or t.get('id') or '')
if tid and tid not in existing_ids and self._is_valid_guid(tid):
new_track_ids.append(tid)
if tid and self._is_valid_guid(tid):
desired_ids.append(tid)
from core.sync.playlist_edit import plan_playlist_append
new_track_ids = plan_playlist_append(existing_ids, desired_ids)
if not new_track_ids:
logger.info(

View file

@ -0,0 +1,129 @@
"""Reuse an album's existing on-disk folder for new downloads (#829).
When tracks are added to an album across multiple batches (a wishlist run, the
Album Completeness job, a missed track re-downloaded later), the destination
folder is normally rebuilt from API metadata each time. If ``$albumtype`` or
``$year`` come back blank/different on a later batch, the folder *name* changes
and the album splits across folders forcing a Reorganize afterwards.
This resolves the folder the album *already* lives in so the new track joins its
existing files instead. Matching is deliberately conservative: the exact stored
Spotify album id first (definitive), then a STRICT (>= 0.85) name+artist match
higher than the 0.7 used elsewhere, because a wrong match here misplaces a file.
Safety rails:
* Only ever returns a folder UNDER the transfer dir (the managed download
tree) never a read-only library/NAS mount the resolver happens to find.
* Only reuses when the album lives in EXACTLY ONE folder on disk. Multiple
folders means disc subfolders (DatabaseTrack carries no disc number, so we
can't safely pick the right one) — those defer to the template path.
* Any failure returns None the caller falls back to the normal template.
"""
from __future__ import annotations
import os
from typing import Any, Optional
from core.library.path_resolver import resolve_library_file_path
from utils.logging_config import get_logger
logger = get_logger("library.existing_album_folder")
# Strict — a wrong album match drops the file in the wrong folder.
_STRICT_ALBUM_CONFIDENCE = 0.85
def _is_under(child: str, parent: str) -> bool:
"""True if ``child`` is the same as or inside ``parent`` (normalized)."""
try:
child_n = os.path.normcase(os.path.normpath(os.path.abspath(child)))
parent_n = os.path.normcase(os.path.normpath(os.path.abspath(parent)))
return child_n == parent_n or child_n.startswith(parent_n + os.sep)
except Exception:
return False
def _find_album(db: Any, spotify_album_id: Optional[str], album_name: Optional[str],
album_artist: Optional[str], active_server: Optional[str],
expected_track_count: Optional[int]):
"""Stored Spotify id first, then a strict name+artist match. None on no match."""
if spotify_album_id:
try:
album = db.get_album_by_spotify_album_id(spotify_album_id)
if album:
return album
except Exception as e:
logger.debug("album-by-spotify-id lookup failed: %s", e)
if album_name and album_artist:
try:
match, confidence = db.check_album_exists_with_editions(
title=album_name, artist=album_artist,
confidence_threshold=_STRICT_ALBUM_CONFIDENCE,
expected_track_count=expected_track_count,
server_source=active_server,
)
if match and confidence >= _STRICT_ALBUM_CONFIDENCE:
return match
except Exception as e:
logger.debug("strict album name+artist match failed: %s", e)
return None
def resolve_existing_album_folder(
*,
db: Any,
transfer_dir: Optional[str],
album_name: Optional[str] = None,
album_artist: Optional[str] = None,
spotify_album_id: Optional[str] = None,
active_server: Optional[str] = None,
expected_track_count: Optional[int] = None,
config_manager: Any = None,
resolver=resolve_library_file_path,
) -> Optional[str]:
"""Return the on-disk folder an existing album lives in (so a new track joins
it) or None to fall back to the templated path. See module docstring."""
if not transfer_dir or not os.path.isdir(transfer_dir):
return None
if not db:
return None
album = _find_album(db, spotify_album_id, album_name, album_artist,
active_server, expected_track_count)
if not album:
return None
try:
tracks = db.get_tracks_by_album(album.id)
except Exception as e:
logger.debug("get_tracks_by_album(%s) failed: %s", getattr(album, 'id', '?'), e)
return None
folders = set()
for t in tracks:
file_path = getattr(t, 'file_path', None)
if not file_path:
continue
try:
resolved = resolver(file_path, transfer_folder=transfer_dir,
config_manager=config_manager)
except Exception:
resolved = None
if not resolved:
continue
folder = os.path.dirname(resolved)
if _is_under(folder, transfer_dir):
folders.add(os.path.normpath(folder))
# Single folder under the transfer dir → reuse it. Zero (nothing on disk yet)
# or many (disc subfolders) → let the template decide.
if len(folders) == 1:
reuse = next(iter(folders))
logger.info("[Existing Album Folder] Reusing '%s' for album '%s'",
reuse, getattr(album, 'title', album_name))
return reuse
return None
__all__ = ["resolve_existing_album_folder"]

View file

@ -0,0 +1,86 @@
"""Confusable-tolerant filesystem path resolution (#833).
the-hang-man: a track titled "I'm Upset" was written to disk with an ASCII
apostrophe (U+0027) but the library DB stored the title with a typographic one
(U+2019, the form Spotify/Apple metadata uses). Deleting rebuilt the unlink
target from the DB path, so ``os.path.exists`` compared U+2019-bytes against a
U+0027 filename always a miss and the file survived ("could not be deleted").
The same byte-exact mismatch hits any on-disk operation that starts from stored
metadata (delete, sidecar cleanup, dead-file checks). The fix is to resolve the
*real* on-disk name: descend the path component by component, taking an exact
match when present and otherwise folding a small set of typographic confusables
(curly vs straight quotes, en/em dash, ellipsis, nbsp) for the comparison ONLY.
We never rename we just find the file that's actually there.
Case is deliberately preserved: on a case-sensitive dataset (ext4/ZFS) two
tracks can differ only by case, so folding case could delete the wrong file.
The reported failure is purely typographic, so that's all we fold.
"""
from __future__ import annotations
import os
import unicodedata
# Typographic characters that routinely differ between DB metadata (Unicode,
# from streaming-service catalogs) and the ASCII filename on disk. Folded to a
# common form for COMPARISON only.
_CONFUSABLES = {
'': "'", '': "'", 'ʼ': "'", '': "'", # ʼ → '
'': '"', '': '"', '': '"', # “ ” ″ → "
'': '-', '': '-', '': '-', '': '-', # ― → -
'': '...', # … → ...
' ': ' ', # nbsp → space
}
def fold_confusables(name: str) -> str:
"""Fold typographic confusables + NFC-normalize so a DB name and the real
on-disk name compare equal despite curly-vs-straight quotes, dashes, etc.
Case and everything else are left untouched."""
if not name:
return ''
name = unicodedata.normalize('NFC', name)
for bad, good in _CONFUSABLES.items():
if bad in name:
name = name.replace(bad, good)
return name
def find_on_disk(base_dir: str, suffix_parts):
"""Descend ``base_dir`` following ``suffix_parts`` (the path components of a
stored file path). Each component is matched exactly when it exists, else by
confusable-folded comparison against the directory's real entries. Returns
the real absolute path, or None if any component can't be resolved.
Exact matches always win the folded scan only runs for a component that
isn't present byte-for-byte, so this never changes behaviour for paths that
already resolve.
"""
if not base_dir or not os.path.isdir(base_dir):
return None
current = base_dir
for part in suffix_parts:
if not part:
continue
exact = os.path.join(current, part)
if os.path.exists(exact):
current = exact
continue
target = fold_confusables(part)
match = None
try:
for entry in os.listdir(current):
if fold_confusables(entry) == target:
match = os.path.join(current, entry)
break
except OSError:
return None
if match is None:
return None
current = match
return current if current != base_dir else None
__all__ = ['fold_confusables', 'find_on_disk']

View file

@ -214,6 +214,22 @@ class MusicMatchingEngine:
# Standard similarity
standard_ratio = SequenceMatcher(None, str1, str2).ratio()
# Version vocabulary, shared by the prefix check and the divergent
# check below.
remaster_keywords = ['remaster', 'remastered']
different_version_keywords = [
'remix', 'mix', 'rmx', # Remixes (different song)
'live', 'live at', 'live from', # Live versions (different recording)
'acoustic', 'unplugged', # Acoustic versions (different arrangement)
'slowed', 'reverb', 'sped up', 'speed up', # TikTok edits (different)
'radio edit', 'radio version', # Radio edits (different cut)
'single edit', # Single edits (different cut)
'album edit', # Album edits (different cut)
'instrumental', 'karaoke', # Instrumental (different)
'extended', 'extended version', # Extended (different length)
'demo', 'rough cut', # Demos (different recording)
]
# STRICT VERSION CHECKING: Different versions should score LOW
# This prevents "Song Title" from matching "Song Title (Remix)" during sync
shorter, longer = (str1, str2) if len(str1) <= len(str2) else (str2, str1)
@ -223,23 +239,6 @@ class MusicMatchingEngine:
# Extract the extra content
extra_content = longer[len(shorter):].strip()
# Check if the extra content looks like version info
# Separate remasters from other versions - they should be treated differently
remaster_keywords = ['remaster', 'remastered']
different_version_keywords = [
'remix', 'mix', 'rmx', # Remixes (different song)
'live', 'live at', 'live from', # Live versions (different recording)
'acoustic', 'unplugged', # Acoustic versions (different arrangement)
'slowed', 'reverb', 'sped up', 'speed up', # TikTok edits (different)
'radio edit', 'radio version', # Radio edits (different cut)
'single edit', # Single edits (different cut)
'album edit', # Album edits (different cut)
'instrumental', 'karaoke', # Instrumental (different)
'extended', 'extended version', # Extended (different length)
'demo', 'rough cut', # Demos (different recording)
]
# Normalize extra content for comparison
extra_normalized = extra_content.lower().strip(' -()[]')
@ -261,6 +260,38 @@ class MusicMatchingEngine:
logger.debug(f"Version mismatch detected: '{str1}' vs '{str2}' (keyword: '{keyword}') - applying heavy penalty")
return 0.30
# STRICT VERSION CHECKING (divergent case): two DIFFERENT versions of
# the same base — e.g. "Song (Shazam Remix)" vs "Song (southstar
# Remix)", or "...live at pukkelpop" vs "...live at wembley". Both
# carry a version descriptor, so neither is a prefix of the other and
# the prefix check above misses them; the raw ratio then stays high off
# the shared base. Without this, when the requested version is absent a
# different cut of the same song can outscore the threshold and get
# downloaded. A correct same-version match is identical after
# normalisation and already returned 1.0 above, so a both-versioned
# pair that survives to here with high base overlap is a genuinely
# different cut. (Remasters are intentionally excluded — the prefix
# branch gives them the lenient 0.75 so re-mastered cuts still match.)
def _versions_in(s: str) -> frozenset:
return frozenset(
kw for kw in different_version_keywords
if re.search(r'\b' + re.escape(kw) + r'\b', s))
v1, v2 = _versions_in(str1), _versions_in(str2)
if v1 and v2 and standard_ratio >= 0.5:
# Strip the version words; what remains is base + distinguishing
# descriptor (remixer / performance / year).
def _strip_versions(s: str) -> str:
for kw in different_version_keywords:
s = re.sub(r'\b' + re.escape(kw) + r'\b', ' ', s)
return re.sub(r'\s+', ' ', s).strip()
if v1 != v2 or _strip_versions(str1) != _strip_versions(str2):
logger.debug(
f"Divergent version detected: '{str1}' vs '{str2}' "
f"- applying heavy penalty")
return 0.30
return standard_ratio
def duration_similarity(self, duration1: int, duration2: int) -> float:

View file

@ -17,7 +17,7 @@ from __future__ import annotations
import contextlib
import errno
import os
from typing import Iterable
from typing import Iterable, Optional
from core.metadata.artwork import download_cover_art, embed_album_art_metadata
from core.metadata.common import get_mutagen_symbols
@ -81,6 +81,38 @@ def _audio_has_art(audio, symbols) -> bool:
return False
def extract_embedded_art(file_path: str) -> Optional[bytes]:
"""Return the first embedded cover-art image bytes from an audio file, or
None. Used to write a cover.jpg sidecar from the album's OWN art — no API
call, and the sidecar matches what's embedded (#813/Sokhi)."""
if not file_path or not os.path.isfile(file_path):
return None
symbols = get_mutagen_symbols()
if not symbols:
return None
try:
audio = symbols.File(file_path)
if audio is None:
return None
pics = getattr(audio, "pictures", None) # FLAC / Ogg
if pics:
return bytes(pics[0].data)
if isinstance(audio, symbols.MP4):
covr = audio.get("covr")
if covr:
return bytes(covr[0])
tags = getattr(audio, "tags", None)
if tags is not None:
with contextlib.suppress(Exception):
if isinstance(tags, symbols.ID3):
apics = tags.getall("APIC")
if apics:
return bytes(apics[0].data)
except Exception as exc:
logger.debug("embedded-art extract failed for %s: %s", file_path, exc)
return None
def album_has_art_on_disk(rep_file_path: str) -> bool:
"""Does this album have art on disk?
@ -166,13 +198,49 @@ def apply_art_to_album_files(
logger.warning("Could not embed art into %s: %s", fp, exc)
result["failed"] += 1
target_dir = folder or (os.path.dirname(paths[0]) if paths else None)
if target_dir and os.path.isdir(target_dir):
try:
download_cover_art(album_info, target_dir, context)
result["cover_written"] = folder_has_cover_sidecar(target_dir)
except Exception as exc:
if getattr(exc, "errno", None) == errno.EROFS:
# Prefer the caller's folder, but if it doesn't actually exist (e.g. a raw
# DB path that isn't mounted in this container), fall back to the real
# directory of the files we just wrote to — never silently skip the sidecar
# because a passed-in folder was wrong (Sokhi: cover.jpg never written).
target_dir = folder if (folder and os.path.isdir(folder)) else None
if not target_dir and paths:
cand = os.path.dirname(paths[0])
target_dir = cand if os.path.isdir(cand) else None
if target_dir and not folder_has_cover_sidecar(target_dir):
# Prefer the album's OWN embedded art for the cover.jpg sidecar: it's
# always present once the files are arted (we may have just embedded it),
# needs no API call, and the sidecar matches the files exactly
# (#813/Sokhi: files have art, just no cover.jpg). Fall back to a fresh
# download only when there's nothing embedded to extract.
cover_path = os.path.join(target_dir, "cover.jpg")
art_bytes = None
for fp in paths:
art_bytes = extract_embedded_art(fp)
if art_bytes:
break
if art_bytes:
try:
with open(cover_path, "wb") as handle:
handle.write(art_bytes)
result["cover_written"] = True
except OSError as exc:
if getattr(exc, "errno", None) == errno.EROFS:
result["read_only_fs"] = True
logger.warning("cover.jpg sidecar write failed for %s: %s", target_dir, exc)
if not result["cover_written"] and not result["read_only_fs"]:
# No embedded art to extract → fetch it. download_cover_art swallows
# its own write errors, so it records read-only on the context dict
# (EROFS detection gap, Sokhi). force=True bypasses the import-time
# "Download cover.jpg" toggle — running the filler is an explicit ask.
cover_ctx = context if isinstance(context, dict) else {}
try:
download_cover_art(album_info, target_dir, cover_ctx, force=True)
result["cover_written"] = folder_has_cover_sidecar(target_dir)
except Exception as exc:
if getattr(exc, "errno", None) == errno.EROFS:
result["read_only_fs"] = True
logger.warning("cover.jpg write failed for %s: %s", target_dir, exc)
if cover_ctx.get("_cover_read_only"):
result["read_only_fs"] = True
logger.warning("cover.jpg write failed for %s: %s", target_dir, exc)
return result

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import errno
import os
import re
import time
@ -469,9 +470,17 @@ def embed_album_art_metadata(audio_file, metadata: dict):
return False
def download_cover_art(album_info: dict, target_dir: str, context: dict = None):
def download_cover_art(album_info: dict, target_dir: str, context: dict = None, force: bool = False):
"""Write cover.jpg into ``target_dir``.
``force`` bypasses the import-time "Download cover.jpg to album folder"
toggle used by the Cover Art Filler, whose whole job is to add cover art
(if you explicitly run the filler you want the sidecar regardless of the
auto-import preference). The import pipeline calls this WITHOUT force, so it
still honors the user's setting.
"""
cfg = get_config_manager()
if cfg.get("metadata_enhancement.cover_art_download", True) is False:
if not force and cfg.get("metadata_enhancement.cover_art_download", True) is False:
return
try:
@ -566,4 +575,14 @@ def download_cover_art(album_info: dict, target_dir: str, context: dict = None):
handle.write(image_data)
logger.info("Cover art downloaded to: %s", cover_path)
except Exception as exc:
logger.error("Error downloading cover.jpg: %s", exc)
# A read-only mount (EROFS) is a "can't write" condition the caller
# needs to surface (cover-art filler #804/Tim/Sokhi) — but we must NOT
# re-raise (import callers aren't wrapped here). Record it on the
# context so callers that care can detect it, instead of just spamming
# the log with a swallowed error.
if getattr(exc, "errno", None) == errno.EROFS:
if isinstance(context, dict):
context["_cover_read_only"] = True
logger.warning("cover.jpg write blocked — read-only filesystem: %s", cover_path)
else:
logger.error("Error downloading cover.jpg: %s", exc)

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import os
import shutil
import threading
import weakref
from types import SimpleNamespace
@ -142,13 +143,63 @@ def is_vorbis_like(audio_file: Any, symbols: Any) -> bool:
return bool(vorbis_classes) and isinstance(audio_file, vorbis_classes) or is_ogg_opus(audio_file)
def save_audio_file(audio_file: Any, symbols: Any) -> None:
def _raw_audio_save(audio_file: Any, symbols: Any, target: Any = None) -> None:
"""The plain mutagen save with the format-specific kwargs. ``target`` None →
save in place (the exact call used before #819, byte-for-byte unchanged); a
path save into that file (the atomic temp copy)."""
if isinstance(audio_file.tags, symbols.ID3):
audio_file.save(v1=0, v2_version=4)
audio_file.save(v1=0, v2_version=4) if target is None else audio_file.save(target, v1=0, v2_version=4)
elif isinstance(audio_file, symbols.FLAC):
audio_file.save(deleteid3=True)
audio_file.save(deleteid3=True) if target is None else audio_file.save(target, deleteid3=True)
else:
audio_file.save()
audio_file.save() if target is None else audio_file.save(target)
def save_audio_file(audio_file: Any, symbols: Any) -> None:
"""Persist mutagen tag changes ATOMICALLY where possible (#819).
mutagen's in-place ``save()`` rewrites the file; if the process is
interrupted or OOM-killed mid-write, the file is left truncated audio AND
tags gone (CubeComming's large hi-res FLACs imported to an empty shell).
Instead: copy the original to a temp in the same directory, write the
modified tags into that copy, verify it's still a valid audio file, then
``os.replace`` it in atomically. The original is never touched until that
final swap, so a crash can only ever orphan the temp never destroy the
user's file.
Falls back to the plain in-place save if the atomic path can't run (no
filename, copy fails, or a format mutagen can't save-to-path) so the file
is never left worse off than it is today.
"""
path = getattr(audio_file, "filename", None)
try:
path = os.fspath(path) if path else None
except TypeError:
path = None
if not path or not os.path.isfile(path):
_raw_audio_save(audio_file, symbols)
return
tmp = f"{path}.sstmp"
try:
shutil.copy2(path, tmp) # snapshot original (audio + tags)
_raw_audio_save(audio_file, symbols, target=tmp) # write new tags into the copy
check = symbols.File(tmp) # verify it's still real audio
if check is None or getattr(getattr(check, "info", None), "length", 0) <= 0:
raise ValueError("atomic save produced a file with no audio")
os.replace(tmp, path) # atomic swap — original safe until here
except Exception as atomic_err:
# Original untouched (only tmp was written). Clean up + fall back to the
# original in-place save so any format/edge the atomic path can't handle
# behaves exactly as before.
try:
if os.path.exists(tmp):
os.remove(tmp)
except OSError:
pass
logger.warning("[Atomic Save] atomic path failed (%s) — in-place fallback for %s",
atomic_err, os.path.basename(path))
_raw_audio_save(audio_file, symbols)
def get_image_dimensions(data: bytes):

View file

@ -28,8 +28,31 @@ single source of truth.
from __future__ import annotations
import re
from typing import Any, Dict, List, Optional
# Split a combined artist credit into its individual artists. Sources like
# iTunes return collabs as ONE string ("TRVNSPORTER, Narvent & SKVLENT"), not a
# list — so an exact full-string compare drops every collaborator's discography
# entry (#830). Split on the common credit separators; " and " / " with " are
# deliberately excluded (too many real band names contain them).
_ARTIST_CREDIT_SPLIT_RE = re.compile(
r"\s*[,&;/]\s*|\s+(?:feat\.?|ft\.?|featuring|vs\.?|x)\s+",
re.IGNORECASE,
)
def _artist_credit_components(name: str) -> List[str]:
"""Return the individual artist names within a (possibly combined) credit,
always including the full string itself (so exact band names with internal
separators still match)."""
name = (name or "").strip()
if not name:
return []
parts = [name]
parts.extend(p.strip() for p in _ARTIST_CREDIT_SPLIT_RE.split(name) if p.strip())
return parts
from core.watchlist_scanner import (
is_acoustic_version,
is_instrumental_version,
@ -47,10 +70,12 @@ def track_artist_matches(track_artists: Any, requested_artist_name: str) -> bool
what the discography fetch returns), or the list-of-dicts shape
that some upstreams pass directly. Both are accepted.
Returns True for primary-artist tracks AND feature appearances
the requested artist need only be one of the listed artists. Only
drops tracks where the requested artist isn't named at all (the
cross-artist compilation case from #559).
Returns True for primary-artist tracks AND feature/collab appearances
the requested artist need only be one of the credited artists, INCLUDING
when a source (iTunes, etc.) packs the collab into one combined string like
"TRVNSPORTER, Narvent & SKVLENT" (#830). Only drops tracks where the
requested artist isn't credited at all (the cross-artist compilation case
from #559).
"""
if not requested_artist_name:
# No artist to compare against — don't filter; let the caller
@ -70,8 +95,13 @@ def track_artist_matches(track_artists: Any, requested_artist_name: str) -> bool
name = entry.get('name', '') or ''
else:
name = str(entry or '')
if name.strip().lower() == target:
return True
# Match the requested artist as a component of the credit, so combined
# collab strings ("A, B & C") keep B's discography entry. Component
# matching is still exact per-name, so true contamination (the artist
# genuinely absent) is dropped exactly as before.
for component in _artist_credit_components(name):
if component.strip().lower() == target:
return True
return False

View file

@ -1102,12 +1102,16 @@ class NavidromeClient(MediaServerClient):
return self.create_playlist(playlist_name, tracks)
primary = existing_playlists[0]
existing_tracks = self.get_playlist_tracks(primary.id)
# #823 round 2: the old dedupe read `t.id` — but NavidromeTrack only
# defines `ratingKey`, so the existing-ids set was ALWAYS empty and
# every sync re-appended the whole matched list (every track N
# times). Same bug as the Jellyfin append; dedupe on ratingKey.
existing_ids = {
str(t.id) for t in existing_tracks if hasattr(t, 'id') and t.id
}
str(getattr(t, 'ratingKey', '') or '')
for t in self.get_playlist_tracks(primary.id)
} - {''}
new_track_ids = []
desired_ids = []
for t in tracks:
tid = None
if hasattr(t, 'ratingKey'):
@ -1116,8 +1120,11 @@ class NavidromeClient(MediaServerClient):
tid = str(t.id) if t.id else None
elif isinstance(t, dict):
tid = str(t.get('id') or '')
if tid and tid not in existing_ids:
new_track_ids.append(tid)
if tid:
desired_ids.append(tid)
from core.sync.playlist_edit import plan_playlist_append
new_track_ids = plan_playlist_append(existing_ids, desired_ids)
if not new_track_ids:
logger.info(

View file

@ -36,7 +36,17 @@ class DeadFileCleanerJob(RepairJob):
icon = 'repair-icon-deadfile'
default_enabled = True
default_interval_hours = 24
default_settings = {}
default_settings = {
# Mass-false-positive guard: if at least this fraction of tracks resolve
# to no file on disk, treat it as a path-mapping/mount problem (SoulSync
# can't SEE the library — e.g. Docker, or library.music_paths unset for
# this environment) rather than thousands of individually-deleted files,
# and abort without creating findings. Mirrors the transfer-folder abort.
'max_unresolved_fraction': 0.5,
# ...but only once the library is at least this big — a small library can
# legitimately have a high dead fraction.
'min_tracks_for_guard': 25,
}
auto_fix = False
def scan(self, context: JobContext) -> JobResult:
@ -87,9 +97,30 @@ class DeadFileCleanerJob(RepairJob):
if context.config_manager:
download_folder = context.config_manager.get('soulseek.download_path', '')
# Mass-false-positive guard thresholds (see default_settings).
max_unresolved_fraction = 0.5
min_tracks_for_guard = 25
if context.config_manager:
try:
max_unresolved_fraction = float(context.config_manager.get(
self.get_config_key('max_unresolved_fraction'), 0.5))
except (TypeError, ValueError):
max_unresolved_fraction = 0.5
try:
min_tracks_for_guard = int(context.config_manager.get(
self.get_config_key('min_tracks_for_guard'), 25))
except (TypeError, ValueError):
min_tracks_for_guard = 25
if context.report_progress:
context.report_progress(phase=f'Checking {total} tracks...', total=total)
# Collect unresolvable tracks first; decide whether they're genuine dead
# files or a systemic path problem AFTER the full pass (below). A "None"
# from the resolver means "couldn't find it at any known base dir" — which
# for a mis-mounted library is EVERY track, not a real deletion.
dead_rows = []
for i, row in enumerate(tracks):
if context.check_stop():
return result
@ -112,44 +143,75 @@ class DeadFileCleanerJob(RepairJob):
config_manager=context.config_manager)
if resolved is None:
# File is truly missing — create finding
if context.report_progress:
context.report_progress(
log_line=f'Missing: {title or "Unknown"}{os.path.basename(file_path)}',
log_type='error'
)
if context.create_finding:
try:
inserted = context.create_finding(
job_id=self.job_id,
finding_type='dead_file',
severity='warning',
entity_type='track',
entity_id=str(track_id),
file_path=file_path,
title=f'Missing file: {title or "Unknown"}',
description=f'Track "{title}" by {artist_name or "Unknown"} points to a file that no longer exists',
details={
'track_id': track_id,
'title': title,
'artist': artist_name,
'album': album_title,
'original_path': file_path,
'album_thumb_url': album_thumb or None,
'artist_thumb_url': artist_thumb or None,
}
)
if inserted:
result.findings_created += 1
else:
result.findings_skipped_dedup += 1
except Exception as e:
logger.debug("Error creating dead file finding for track %s: %s", track_id, e)
result.errors += 1
dead_rows.append(row)
if context.update_progress and (i + 1) % 100 == 0:
context.update_progress(i + 1, total)
# Mass-false-positive guard: a large fraction of unresolvable paths almost
# always means SoulSync can't SEE the library (Docker mount, or
# Settings → Library → Music Paths not set for this environment), NOT that
# thousands of files were individually deleted. Refuse to flag and say so
# — same principle as the transfer-folder abort above. (#828: a Plex-on-
# macOS user in Docker had all 5,250 tracks flagged because their stored
# /Volumes/... paths don't exist inside the container.)
if (dead_rows
and result.scanned >= min_tracks_for_guard
and len(dead_rows) >= result.scanned * max_unresolved_fraction):
logger.error(
"Dead file scan: %d/%d tracks unresolvable (>= %.0f%%) — aborting without "
"creating findings; this is a path-mapping/mount problem, not deleted files.",
len(dead_rows), result.scanned, max_unresolved_fraction * 100)
result.errors += 1
if context.report_progress:
context.report_progress(
phase='Aborted — too many unreachable paths',
log_line=(f"{len(dead_rows)} of {result.scanned} tracks point to paths SoulSync "
f"can't reach — almost always a path-mapping issue (Docker mount, or "
f"Settings → Library → Music Paths), not deleted files. No findings created."),
log_type='error'
)
if context.update_progress:
context.update_progress(total, total)
return result
# A small fraction unresolvable — treat as genuine dead files and report.
for row in dead_rows:
track_id, title, artist_name, album_title, file_path, album_thumb, artist_thumb = row
if context.report_progress:
context.report_progress(
log_line=f'Missing: {title or "Unknown"}{os.path.basename(file_path)}',
log_type='error'
)
if context.create_finding:
try:
inserted = context.create_finding(
job_id=self.job_id,
finding_type='dead_file',
severity='warning',
entity_type='track',
entity_id=str(track_id),
file_path=file_path,
title=f'Missing file: {title or "Unknown"}',
description=f'Track "{title}" by {artist_name or "Unknown"} points to a file that no longer exists',
details={
'track_id': track_id,
'title': title,
'artist': artist_name,
'album': album_title,
'original_path': file_path,
'album_thumb_url': album_thumb or None,
'artist_thumb_url': artist_thumb or None,
}
)
if inserted:
result.findings_created += 1
else:
result.findings_skipped_dedup += 1
except Exception as e:
logger.debug("Error creating dead file finding for track %s: %s", track_id, e)
result.errors += 1
if context.update_progress:
context.update_progress(total, total)

View file

@ -3,7 +3,8 @@
import os
import re
from core.metadata.art_apply import album_has_art_on_disk
from core.metadata.art_apply import file_has_embedded_art, folder_has_cover_sidecar
from core.library.path_resolver import resolve_library_file_path
from core.metadata_service import get_client_for_source, get_primary_source, get_source_priority
from core.repair_jobs import register_job
from core.repair_jobs.base import JobContext, JobResult, RepairJob
@ -137,6 +138,17 @@ class MissingCoverArtJob(RepairJob):
context.update_progress(0, total)
logger.info("Found %d albums missing cover art", total)
download_folder = (context.config_manager.get('soulseek.download_path', '')
if context.config_manager else None)
# Skip-reason breakdown so a "0 findings" scan tells us WHY each album
# was skipped instead of guessing (Sokhi cover.jpg saga). Logged in the
# summary line below.
skip_reasons = {
'have_disk_art': 0, # local file has embedded art + sidecar (or sidecar not wanted)
'no_local_db_has_art': 0, # file path didn't resolve; DB already has a thumb
'no_art_source': 0, # needs fix, but no API art found and nothing embedded to extract
}
_diag_logged = 0
if context.report_progress:
context.report_progress(phase=f'Searching artwork for {total} albums...', total=total)
@ -160,8 +172,73 @@ class MissingCoverArtJob(RepairJob):
# Art can be missing in the DB (no thumb_url) and/or on disk (no
# embedded art and no cover.jpg). Skip albums that already have both.
db_missing = not (str(album_thumb).strip() if album_thumb else '')
disk_missing = bool(rep_path) and not album_has_art_on_disk(rep_path)
if not db_missing and not disk_missing:
# Resolve the representative path the SAME way the apply does
# (_fix_missing_cover_art) before checking disk art. Checking the raw
# DB path would fail on any path-mapped setup (docker mounts, a
# Plex/SoulSync path mismatch) — the file isn't found, album art
# reads as "missing", and EVERY album gets flagged while the apply
# (which resolves) then finds the art already present. Unresolvable →
# treat as no-local-file (don't claim disk-missing).
resolved_rep = resolve_library_file_path(
rep_path,
transfer_folder=getattr(context, 'transfer_folder', None),
download_folder=download_folder,
config_manager=context.config_manager,
) if rep_path else None
# Match the apply (_fix_missing_cover_art line ~1376: `... or p`): if
# the path-mapping layer returns nothing but the raw DB path is
# already a real file (the common Docker case where the container
# path == the stored path), use it as-is. Without this the scan never
# sees the folder and skips the album, while the apply WOULD have
# written the cover.jpg — the exact gap behind Sokhi's 0-findings.
if not resolved_rep and rep_path and os.path.isfile(rep_path):
resolved_rep = rep_path
has_local = bool(resolved_rep)
# Check embedded art and the cover.jpg sidecar SEPARATELY (not the
# combined album_has_art_on_disk, which returns True if EITHER is
# present). An album can have embedded art but no cover.jpg — and if
# the user wants cover.jpg files, that's still a fixable "missing"
# (Sokhi: scans returned 0 because embedded-art albums were treated
# as fully arted and skipped, so their cover.jpg never got written).
has_embedded = file_has_embedded_art(resolved_rep) if has_local else False
has_sidecar = folder_has_cover_sidecar(os.path.dirname(resolved_rep)) if has_local else False
# cover.jpg sidecars are only a "missing" thing when the user has
# cover.jpg writing enabled (Boulder: "only scan for cover.jpgs when
# they're enabled"). Default on.
cover_sidecar_enabled = bool(
context.config_manager.get('metadata_enhancement.cover_art_download', True)
if context.config_manager else True)
if has_local:
embed_missing = not has_embedded
sidecar_missing = cover_sidecar_enabled and not has_sidecar
# has_embedded + no sidecar → the apply writes cover.jpg from the
# existing embedded art, so it's fixable even if the API finds no
# art. embed_missing still requires API art (nothing to embed).
sidecar_from_embedded = sidecar_missing and has_embedded
needs_fix = embed_missing or sidecar_missing
else:
# Media-server-only album: the DB thumb is the only art.
embed_missing = db_missing
sidecar_from_embedded = False
needs_fix = db_missing
# Diagnostic: dump the decision inputs for the first few albums so a
# confusing "0 findings" scan reveals path-resolution + on-disk state
# (Sokhi: scans kept returning 0 with no way to see why).
if _diag_logged < 5:
logger.info(
"[cover-diag] album=%r db_path=%r resolved=%r local=%s embedded=%s "
"sidecar=%s db_missing=%s cover_enabled=%s needs_fix=%s",
title, rep_path, resolved_rep, has_local, has_embedded, has_sidecar,
db_missing, cover_sidecar_enabled, needs_fix)
_diag_logged += 1
if not needs_fix:
if not has_local:
skip_reasons['no_local_db_has_art'] += 1
else:
skip_reasons['have_disk_art'] += 1
result.skipped += 1
continue
@ -197,10 +274,14 @@ class MissingCoverArtJob(RepairJob):
if artwork_url:
break
if artwork_url:
# Fixable if we found API art (to embed/write), OR it's just a
# missing cover.jpg on an album that already has embedded art — the
# apply writes the sidecar from that embedded art, no API art needed.
if artwork_url or sidecar_from_embedded:
if context.report_progress:
context.report_progress(
log_line=f'Found art: {title or "Unknown"}',
log_line=(f'Found art: {title or "Unknown"}' if artwork_url
else f'Will write cover.jpg from embedded art: {title or "Unknown"}'),
log_type='success'
)
# Also search for an artist image so the finding can offer it as
@ -212,6 +293,9 @@ class MissingCoverArtJob(RepairJob):
# Create finding for user to approve
if context.create_finding:
try:
_desc = (f'Album "{title}" by {artist_name or "Unknown"} has no cover art. '
+ ('Found artwork from API.' if artwork_url
else 'Will write cover.jpg from the existing embedded art.'))
inserted = context.create_finding(
job_id=self.job_id,
finding_type='missing_cover_art',
@ -220,7 +304,7 @@ class MissingCoverArtJob(RepairJob):
entity_id=str(album_id),
file_path=None,
title=f'Missing artwork: {title or "Unknown"}',
description=f'Album "{title}" by {artist_name or "Unknown"} has no cover art. Found artwork from API.',
description=_desc,
details={
'album_id': album_id,
'album_title': title,
@ -239,7 +323,8 @@ class MissingCoverArtJob(RepairJob):
# apply can embed into the audio + write cover.jpg.
'album_folder': os.path.dirname(rep_path) if rep_path else None,
'db_missing': db_missing,
'disk_missing': disk_missing,
'embed_missing': embed_missing,
'sidecar_from_embedded': sidecar_from_embedded,
'musicbrainz_release_id': None,
}
)
@ -251,6 +336,7 @@ class MissingCoverArtJob(RepairJob):
logger.debug("Error creating cover art finding for album %s: %s", album_id, e)
result.errors += 1
else:
skip_reasons['no_art_source'] += 1
result.skipped += 1
if context.update_progress and (i + 1) % 5 == 0:
@ -261,6 +347,13 @@ class MissingCoverArtJob(RepairJob):
logger.info("Cover art scan: %d albums checked, %d found art, %d skipped",
result.scanned, result.findings_created, result.skipped)
if result.skipped:
logger.info(
"[cover-diag] skip breakdown — have_disk_art=%d (already have embedded+sidecar), "
"no_local_db_has_art=%d (file path didn't resolve, DB has thumb), "
"no_art_source=%d (needed art but none found/embedded)",
skip_reasons['have_disk_art'], skip_reasons['no_local_db_has_art'],
skip_reasons['no_art_source'])
return result
def _try_source(self, source, source_album_id, title, artist_name):

View file

@ -1325,7 +1325,11 @@ class RepairWorker:
artist_result = self._fix_artist_art(album_id, details)
artwork_url = details.get('found_artwork_url')
if not artwork_url:
# sidecar_from_embedded: the album already has embedded art and just needs
# a cover.jpg sidecar — the apply writes it from the existing embedded art,
# so no API artwork_url is required (Sokhi #813).
sidecar_from_embedded = bool(details.get('sidecar_from_embedded'))
if not artwork_url and not sidecar_from_embedded:
# 'both' but no album art — report the artist outcome if that ran.
if artist_result is not None:
return artist_result
@ -1390,7 +1394,14 @@ class RepairWorker:
'album_name': album_title, 'album_image_url': artwork_url,
'musicbrainz_release_id': mbid,
}
folder = details.get('album_folder') or os.path.dirname(resolved[0])
# Use the RESOLVED file's directory — NOT details['album_folder'], which
# is the raw DB path (e.g. Jellyfin's /data/music) and frequently does
# NOT exist inside the SoulSync container (only the resolved /app/...
# path does). Passing the raw folder made os.path.isdir() fail in
# apply_art_to_album_files, silently skipping the cover.jpg write while
# embedding (which uses the resolved paths) still worked — Sokhi's
# "embeds art but never writes cover.jpgs".
folder = os.path.dirname(resolved[0])
art_result = apply_art_to_album_files(resolved, metadata, album_info, folder=folder)
embedded = art_result.get('embedded', 0)
@ -1407,12 +1418,34 @@ class RepairWorker:
'mount (NFS/SMB/mergerfs) is read-write, then recreate the '
'container. (Database thumbnail was still updated.)'),
'art_result': art_result}
msg = f'Applied cover art: embedded into {embedded}/{len(resolved)} file(s)'
if art_result.get('cover_written'):
msg += ' + wrote cover.jpg'
if embedded == 0 and not art_result.get('cover_written'):
# DB updated but nothing reached disk (e.g. permissions).
msg = 'Updated database thumbnail, but could not write art to files (read-only?)'
skipped = art_result.get('skipped', 0)
failed = art_result.get('failed', 0)
cover_written = art_result.get('cover_written')
wrote_parts = []
if embedded:
wrote_parts.append(f'embedded into {embedded}/{len(resolved)} file(s)')
if cover_written:
wrote_parts.append('wrote cover.jpg')
if wrote_parts:
msg = 'Applied cover art: ' + ' + '.join(wrote_parts)
elif failed:
# Real per-file write failures that were NOT a read-only mount
# (genuine EROFS is handled above) — almost always file/folder
# permissions or a locked file.
msg = (f'Updated database thumbnail, but could not write art to '
f'{failed} file(s) — check file/folder permissions')
elif skipped:
# Every file already had embedded art and no new cover.jpg was
# needed — nothing to do, NOT a failure. This is the case that made
# the old "(read-only?)" message fire on perfectly writable
# libraries (Boulder on Windows, Sokhi): the files were simply
# already arted, so embedded==0 and cover_written==False.
msg = f'Cover art already present on all {skipped} file(s) — database thumbnail updated'
else:
# No file art applied and nothing found to write.
msg = 'Updated database thumbnail (no file artwork was applied)'
if artist_result is not None and artist_result.get('success'):
msg += ' + applied artist image'
return {'success': True, 'action': 'applied_cover_art', 'message': msg, 'art_result': art_result}

View file

@ -33,6 +33,7 @@ an injected ``client_resolver`` (defaulting to the orchestrator's
from __future__ import annotations
import logging
import re
from typing import Any, Callable, NamedTuple, Optional
from urllib.parse import parse_qs, urlparse
@ -42,13 +43,13 @@ logger = logging.getLogger(__name__)
# providers whose public links a user would paste AND whose get-by-id returns
# the common Spotify-shaped dict. Streaming download backends (Tidal/Qobuz)
# return raw API shapes and aren't metadata-link sources, so they're omitted.
SUPPORTED_SOURCES = ('spotify', 'itunes', 'musicbrainz', 'deezer')
SUPPORTED_SOURCES = ('spotify', 'itunes', 'musicbrainz', 'deezer', 'discogs')
# Domains we recognize — used to detect a pasted URL even when the user
# omitted the scheme (e.g. "open.spotify.com/album/…").
_KNOWN_HOSTS = (
'open.spotify.com', 'music.apple.com', 'itunes.apple.com',
'musicbrainz.org', 'deezer.com',
'musicbrainz.org', 'deezer.com', 'discogs.com',
)
@ -72,7 +73,7 @@ class LookupTarget(NamedTuple):
def _kind_from_keyword(keyword: str) -> Optional[str]:
"""Map a URL/URI path keyword to a lookup kind."""
if keyword in ('album', 'release', 'release-group'):
if keyword in ('album', 'release', 'release-group', 'master'):
return 'album'
if keyword in ('track', 'recording', 'song'):
return 'track'
@ -130,6 +131,18 @@ def _parse_url(raw: str) -> list[LookupTarget]:
# redirect; only handle canonical /album/ /track/ paths.
return _by_keyword('deezer')
if 'discogs.com' in host:
# Discogs paths are /artist/<id>-Slug, /release/<id>-Slug,
# /master/<id>-Slug — the id is embedded with a slug, so strip to the
# leading number. (Discogs has no standalone track URLs; tracks live
# inside a release, so only artist/album resolve.)
out = []
for t in _by_keyword('discogs'):
m = re.match(r'(\d+)', t.id)
if m:
out.append(t._replace(id=m.group(1)))
return out
return []
@ -254,7 +267,7 @@ def _fetch_album(client: Any, source: str, identifier: str) -> Optional[dict]:
(the modal re-fetches the full tracklist on open)."""
if source == 'deezer':
return client.get_album_metadata(identifier, include_tracks=False)
if source in ('itunes', 'musicbrainz'):
if source in ('itunes', 'musicbrainz', 'discogs'):
return client.get_album(identifier, include_tracks=False)
return client.get_album(identifier) # spotify
@ -274,8 +287,8 @@ def _fetch_artist(client: Any, source: str, identifier: str) -> Optional[dict]:
# Shown in the dropdown's empty state so the user knows what to do next.
_MSG_NOT_A_LINK = (
'Paste a full link from Spotify, Apple Music, MusicBrainz, or Deezer '
'(a bare ID is ambiguous).'
'Paste a full link from Spotify, Apple Music, Deezer, Discogs, or '
'MusicBrainz (a bare ID is ambiguous).'
)
_MSG_NOT_FOUND = "Couldn't resolve that link — double-check it's correct."

View file

@ -35,6 +35,7 @@ def search_kind(client, query: str, kind: str, source_name: Optional[str] = None
artists.append({
"id": artist.id,
"name": artist.name,
"source": source_name or "",
"image_url": artist.image_url,
"external_urls": artist.external_urls or {},
})
@ -52,6 +53,7 @@ def search_kind(client, query: str, kind: str, source_name: Optional[str] = None
"id": album.id,
"name": album.name,
"artist": artist_name,
"source": source_name or "",
"image_url": album.image_url,
"release_date": album.release_date,
"total_tracks": album.total_tracks,
@ -78,6 +80,21 @@ def search_kind(client, query: str, kind: str, source_name: Optional[str] = None
"id": track.id,
"name": track.name,
"artist": artist_name,
# The REAL artist list, not the joined display string above.
# Spotify/Tidal/iTunes searches return collabs as a list;
# collapsing them to one "A, B" string made the import
# pipeline tag downloads with a single combined artist
# (resolve_track_artists saw one value). The frontend keeps
# using "artist" for display.
"artists": list(track.artists or []),
# Which metadata source this result came from. Travels with
# the payload through Download Now -> download task ->
# import context, where extract_source_metadata needs it to
# run source-specific logic (the Deezer contributors
# upgrade for multi-artist tags — Netti93's report: without
# it get_import_source() resolved '' and collab tracks
# were tagged with only the primary artist until a Retag).
"source": source_name or "",
"album": track.album,
"duration_ms": track.duration_ms,
"image_url": track.image_url,

View file

View file

@ -0,0 +1,89 @@
"""Server-side enforcement of the launch PIN (#832).
Beckid: the admin "launch PIN" was a client-side overlay only the
``launch_pin_required`` flag just told the frontend to draw a fixed-position
div over the app. Removing that div (Safari "Hide Distracting Items", devtools,
or any non-browser client like curl) gave full, unauthenticated access to every
``/api/*`` endpoint, because nothing on the server ever checked it.
``request_is_locked`` is the pure decision the ``before_request`` gate uses:
given the request path/method and the session's verified state, should this
request be blocked? Kept pure (no Flask) so the allow/deny matrix is unit-
testable without standing up the whole app.
Allow-list while locked (everything else 401):
* ``/`` and ``/static/`` and ``/favicon*`` the page shell + lock-screen
assets must load so the user can enter the PIN.
* The unlock flow itself current-profile probe, profile list/select for the
picker, verify-launch-pin, reset-pin-via-credential, logout.
* The public REST API ``/api/v1/`` those routes carry their OWN
``@require_api_key`` auth and are built for headless automation, so a
launch-locked UI shouldn't break a legitimate key holder. EXCEPT
``/api/v1/api-keys-internal*``, which are session-UI key management
("no auth required") and MUST stay locked otherwise an attacker could
mint a key and walk in through the public API.
"""
from __future__ import annotations
# GET endpoints the lock/picker screens need before a PIN is entered.
_ALLOWED_GET = frozenset({
'/api/profiles', # profile picker list (multi-profile launch)
'/api/profiles/current', # how the frontend detects the lock state
})
# POST endpoints that drive selection + unlock. Selecting a profile only sets
# session['profile_id'] (+ any per-profile PIN check); it does NOT set
# launch_pin_verified, so it can't bypass the launch lock.
_ALLOWED_POST = frozenset({
'/api/profiles/select',
'/api/profiles/verify-launch-pin',
'/api/profiles/reset-pin-via-credential',
'/api/profiles/logout',
})
def is_html_navigation(method: str, accept: str, sec_fetch_mode: str) -> bool:
"""True when a BLOCKED request is a top-level browser navigation (address
bar, link, refresh) rather than a programmatic fetch/XHR.
Such a request should be bounced to the root lock screen, not handed a raw
JSON 401 otherwise deep-linking/refreshing on a sub-page (e.g. /dashboard)
while locked dumps JSON in the user's face (#832 follow-up). Programmatic
fetches (Accept: */* or application/json) still get the JSON so the frontend
can react to the lock.
"""
if (method or 'GET').upper() != 'GET':
return False
if (sec_fetch_mode or '').strip().lower() == 'navigate':
return True
return 'text/html' in (accept or '').lower()
def request_is_locked(path: str, method: str, *,
require_pin: bool, pin_verified: bool) -> bool:
"""True when the launch-PIN gate must reject this request with 401."""
if not require_pin or pin_verified:
return False
path = path or ''
method = (method or 'GET').upper()
# Page shell + assets needed to render the lock screen.
if path == '/' or path.startswith('/static/') or path.startswith('/favicon'):
return False
# Key-authed public API — its own auth governs it. The session-UI key
# management under it is the one exception that stays locked.
if path.startswith('/api/v1/') and not path.startswith('/api/v1/api-keys-internal'):
return False
if method == 'GET' and path in _ALLOWED_GET:
return False
if method == 'POST' and path in _ALLOWED_POST:
return False
return True
__all__ = ['request_is_locked', 'is_html_navigation']

View file

@ -633,6 +633,13 @@ class SpotifyClient:
watchlist) use THIS instead of ``is_spotify_authenticated()`` so the
free source is reachable. Does NOT change auth semantics."""
from core.spotify_free_metadata import should_offer_spotify_metadata
# The enrichment worker's prefer-free opt-in (set on its own client)
# makes the no-auth source the active path even without auth or the
# 'no-auth Spotify' source choice — so metadata IS available to it. This
# only fires on a client carrying _prefer_free (the worker's), so
# interactive/watchlist availability is unchanged.
if getattr(self, '_prefer_free', False) and self._free_installed():
return True
try:
authed = self.is_spotify_authenticated()
except Exception:
@ -646,13 +653,21 @@ class SpotifyClient:
term covers the brief window before the auth cache refreshes. When authed
+ healthy the official path returns first, so this never opens.
Three activations fall out of this: a no-auth user who chose Spotify
Activations that fall out of this: a no-auth user who chose Spotify
Free (free is their source), a connected user mid-rate-limit (free
bridges the ban), and a connected user who has spent the enrichment
worker's real-API daily budget (``_budget_exhausted_use_free``, set by
the worker) so a Spotify-Free user is never paused by the budget, it
just switches to the uncapped free source. See _free_wanted()."""
bridges the ban), a connected user who has spent the enrichment worker's
real-API daily budget (``_budget_exhausted_use_free``, set by the worker)
so a Spotify-Free user is never paused by the budget and the worker
opt-in below. See _free_wanted()."""
from core.spotify_free_metadata import should_use_free_fallback
# Worker opt-in (metadata.spotify_free_enrichment): prefer the no-creds
# source for enrichment even while authed + healthy + under budget, to
# spare the official quota for interactive use. The flag IS the explicit
# opt-in, so it only needs the package installed — not the 'Spotify Free'
# metadata-source choice — and it's set only on the enrichment worker's
# own client, so interactive search/resolve stay official-first.
if getattr(self, '_prefer_free', False) and self._free_installed():
return True
if not self._free_available():
return False
try:
@ -1426,7 +1441,11 @@ class SpotifyClient:
if tracks:
return tracks
use_spotify = self.is_spotify_authenticated()
# Skip the official API when the no-creds free source should serve this
# (no-auth / rate-limited — where auth is already False — plus the
# budget-bridge and the worker's prefer-free opt-in, where auth is True
# but we deliberately defer to free). The free branch below then runs.
use_spotify = self.is_spotify_authenticated() and not self._free_active()
if use_spotify:
try:
@ -1492,7 +1511,11 @@ class SpotifyClient:
artists.sort(key=lambda a: (0 if a.name.lower().strip() == query_lower else 1))
return artists
use_spotify = self.is_spotify_authenticated()
# Skip the official API when the no-creds free source should serve this
# (no-auth / rate-limited — where auth is already False — plus the
# budget-bridge and the worker's prefer-free opt-in, where auth is True
# but we deliberately defer to free). The free branch below then runs.
use_spotify = self.is_spotify_authenticated() and not self._free_active()
if use_spotify:
try:
@ -1548,11 +1571,17 @@ class SpotifyClient:
return []
@rate_limited
def search_albums(self, query: str, limit: int = 10, allow_fallback: bool = True) -> List[Album]:
def search_albums(self, query: str, limit: int = 10, allow_fallback: bool = True,
artist: str = None, album: str = None) -> List[Album]:
"""Search for albums.
When allow_fallback is True, falls back to the configured metadata source
if Spotify is unavailable or returns an error.
``artist`` + ``album`` (the names, passed separately) enable the no-creds
Spotify Free path: SpotipyFree has no album-name search, so when Free is
active it resolves the album via the artist's discography. Callers without
that context (a bare query) skip the Free album path.
"""
cache = get_metadata_cache()
# Check Spotify cache first so cached data remains usable even when
@ -1568,7 +1597,11 @@ class SpotifyClient:
if albums:
return albums
use_spotify = self.is_spotify_authenticated()
# Skip the official API when the no-creds free source should serve this
# (no-auth / rate-limited — where auth is already False — plus the
# budget-bridge and the worker's prefer-free opt-in, where auth is True
# but we deliberately defer to free). The free branch below then runs.
use_spotify = self.is_spotify_authenticated() and not self._free_active()
if use_spotify:
try:
@ -1592,7 +1625,21 @@ class SpotifyClient:
except Exception as e:
_detect_and_set_rate_limit(e, 'search_albums')
logger.error(f"Error searching albums via Spotify: {e}")
# Fall through to iTunes fallback
# Fall through to free / iTunes fallback
# No-creds Spotify (SpotipyFree): keep Spotify catalog/matching when
# official Spotify can't serve us (no auth / rate-limited / budget spent),
# before the iTunes/Deezer fallback. Albums have no name-search upstream,
# so resolve via the artist's discography — needs artist + album names.
# Gated by _free_active() so it never runs while auth is healthy.
if allow_fallback and self._free_active() and artist and album:
try:
objs = [Album.from_spotify_album(a)
for a in self._free_meta.search_albums_via_artist(artist, album, min(limit, 10))]
if objs:
return objs
except Exception as e:
logger.debug("SpotipyFree album search failed: %s", e)
# Fallback (iTunes or Deezer)
if allow_fallback:
@ -1623,7 +1670,7 @@ class SpotifyClient:
# Fallback cache hit — delegate to fallback client which reconstructs enhanced format
return self._fallback.get_track_details(track_id)
if self.is_spotify_authenticated():
if self.is_spotify_authenticated() and not self._free_active():
try:
track_data = self.sp.track(track_id)
@ -1725,7 +1772,7 @@ class SpotifyClient:
# Fallback cache hit — delegate to fallback client
return self._fallback.get_album(album_id)
if self.is_spotify_authenticated():
if self.is_spotify_authenticated() and not self._free_active():
try:
album_data = self.sp.album(album_id)
if album_data:
@ -1768,7 +1815,7 @@ class SpotifyClient:
if cached:
return cached
if self.is_spotify_authenticated():
if self.is_spotify_authenticated() and not self._free_active():
try:
# Get first page of tracks
first_page = self.sp.album_tracks(album_id)
@ -1862,7 +1909,7 @@ class SpotifyClient:
except Exception as e:
logger.debug("artist albums cache reuse: %s", e)
if self.is_spotify_authenticated():
if self.is_spotify_authenticated() and not self._free_active():
try:
albums = []
raw_items = []
@ -1980,7 +2027,7 @@ class SpotifyClient:
return self._fallback.get_artist(artist_id)
return None
if self.is_spotify_authenticated():
if self.is_spotify_authenticated() and not self._free_active():
try:
result = self.sp.artist(artist_id)
if result:

View file

@ -27,10 +27,31 @@ from __future__ import annotations
import importlib.util
import logging
from difflib import SequenceMatcher
from typing import Any, Optional
logger = logging.getLogger(__name__)
def _norm_name(s: str) -> str:
"""Lowercase + collapse whitespace for loose name comparison."""
return ' '.join((s or '').lower().split())
def rank_albums_by_name(albums: list[dict], album_name: str, limit: int = 10) -> list[dict]:
"""Rank Spotify-shaped album dicts by name similarity to ``album_name`` (best
first) and return up to ``limit``. Pure (no network) so it's unit-testable.
Used to turn an artist's whole discography into the most-relevant album
candidates, since SpotipyFree has no album-name search of its own."""
target = _norm_name(album_name)
scored = [
(SequenceMatcher(None, _norm_name((alb or {}).get('name', '')), target).ratio(), alb)
for alb in (albums or [])
]
scored.sort(key=lambda t: t[0], reverse=True)
return [alb for _score, alb in scored[:limit]]
_installed_cache: Optional[bool] = None
@ -186,9 +207,46 @@ class SpotifyFreeMetadataClient:
def search_albums(self, query: str, limit: int = 10) -> list[dict]:
# No album-name search exists in SpotipyFree/spotapi. Albums are only
# reachable by id or via an artist's discography.
# reachable by id or via an artist's discography — see
# search_albums_via_artist() for the artist-scoped workaround.
return []
def search_albums_via_artist(self, artist_name: str, album_name: str,
limit: int = 10) -> list[dict]:
"""Find an album by name using ONLY the free source: SpotipyFree has no
album-name search, so resolve the artist (best name match) and scan their
discography for the album. Returns Spotify-shaped album dicts ranked by
similarity to ``album_name`` (best first); the caller applies its own
match threshold. Empty when artist/album is missing or no artist matches.
This is what lets the budget/rate-limit bridge match albums on Spotify
Free instead of being unable to (the gap that previously dropped album
enrichment straight through to the iTunes/Deezer fallback)."""
if not (artist_name and album_name):
return []
try:
artists = self.search_artists(artist_name, limit=5)
except Exception as e:
logger.debug(f"SpotipyFree search_albums_via_artist artist lookup failed: {e}")
return []
if not artists:
return []
target_artist = _norm_name(artist_name)
best = max(
artists,
key=lambda a: SequenceMatcher(
None, _norm_name(a.get('name', '')), target_artist).ratio(),
)
artist_id = best.get('id')
if not artist_id:
return []
try:
albums = self.get_artist_albums_list(artist_id, limit=50)
except Exception as e:
logger.debug(f"SpotipyFree search_albums_via_artist discography failed: {e}")
return []
return rank_albums_by_name(albums, album_name, limit)
# -- entity lookups ---------------------------------------------------
def get_album(self, album_id: str, include_tracks: bool = True) -> Optional[dict]:
try:

View file

@ -213,11 +213,29 @@ class SpotifyWorker:
interruptible_sleep(self._stop_event, min(remaining, 60)) # Check again every 60s max
continue
# Is the worker serving via the no-creds Spotify Free source this
# Enrichment runs on the no-auth Spotify source by DEFAULT
# (metadata.spotify_free_enrichment, ON unless turned off): bulk
# enrichment is the workload that bans the real API, so we keep it
# off your connected account's quota and reserve official Spotify
# for interactive search + playlist sync. The flag overrides auth
# for the worker (authed users still enrich via the no-auth path)
# and also lets the worker run with no auth at all. _free_active()
# and is_spotify_metadata_available() both honor it; set only on
# the worker's OWN client, so interactive paths stay official-first.
# Harmless when the no-auth package isn't installed (the methods
# fall back to official, then iTunes/Deezer).
try:
from config.settings import config_manager as _cfg
self.client._prefer_free = bool(
_cfg.get('metadata.spotify_free_enrichment', True))
except Exception: # noqa: S110 — prefer-free toggle is best-effort
self.client._prefer_free = True
# Is the worker serving via the no-auth Spotify source this
# iteration? The daily budget and post-ban cooldown both exist to
# protect the REAL authenticated API from bans — they don't apply
# to free (a different, anonymous path). Computed once and reused
# below; the loop already probes auth, so no extra quota cost.
# to the no-auth path. Computed once and reused below; the loop
# already probes auth, so no extra quota cost.
budget_exhausted = self._is_daily_budget_exhausted()
# Daily budget is a REAL-API ban protection. When it's spent, if
@ -768,7 +786,10 @@ class SpotifyWorker:
return
query = f"{artist_name} {album_name}" if artist_name else album_name
results = self.client.search_albums(query, limit=5)
# Pass artist + album names separately too, so the no-creds Spotify Free
# path can resolve the album via the artist's discography (SpotipyFree has
# no album-name search) when bridging a budget/rate-limit ban.
results = self.client.search_albums(query, limit=5, artist=artist_name, album=album_name)
if not results:
self._mark_status('album', album_id, 'not_found')
@ -929,6 +950,14 @@ class SpotifyWorker:
UPDATE albums SET year = ?
WHERE id = ? AND (year IS NULL OR year = '' OR year = '0')
""", (year, album_id))
# #824: also store the FULL release date when Spotify has one
# (YYYY-MM or YYYY-MM-DD, not just a bare year). Only when empty —
# never clobber a manually-set release_date.
if len(album_obj.release_date) > 4:
cursor.execute("""
UPDATE albums SET release_date = ?
WHERE id = ? AND (release_date IS NULL OR release_date = '')
""", (album_obj.release_date, album_id))
# Cache the authoritative expected track count for the Album
# Completeness repair job (see set_album_api_track_count docstring).

View file

@ -109,6 +109,34 @@ def plan_playlist_reconcile(
return {"add": add, "remove": remove}
def plan_playlist_append(
current_ids: List[str],
desired_ids: List[str],
) -> List[str]:
"""Plan an append: which desired ids are NOT already in the playlist.
Used by ``sync_mode='append'`` (#823 round 2): the Jellyfin/Emby and
Navidrome appends deduped with ``{t.id for t in existing}`` but their
track wrappers only define ``ratingKey``, never ``id``, so the existing-ids
set was ALWAYS empty and every sync re-appended the full matched list
(every track N times, "skipped 0 already present"). Pure planner so the
dedupe logic is testable; the caller fetches current ids however its
server API works and applies the returned adds.
Order-preserving and duplicate-safe: desired order is kept, ids already
present are dropped, and duplicates WITHIN desired are emitted once.
"""
current_set = {str(t) for t in current_ids}
out: List[str] = []
seen = set()
for d in desired_ids:
tid = str(d)
if tid and tid not in current_set and tid not in seen:
seen.add(tid)
out.append(tid)
return out
VALID_SYNC_MODES = ("replace", "append", "reconcile")
@ -129,6 +157,7 @@ __all__ = [
"plan_playlist_add",
"remove_one_occurrence",
"plan_playlist_reconcile",
"plan_playlist_append",
"normalize_sync_mode",
"VALID_SYNC_MODES",
]

View file

@ -210,10 +210,20 @@ def build_tag_diff(file_tags: Dict[str, Any], db_data: Dict[str, Any]) -> List[D
db_str = ', '.join(db_val) if db_val else ''
db_val = db_str if db_str else None
# Special: year — DB stores int, file stores string
if db_key == 'year' and db_val is not None:
db_str = str(db_val)
db_val = str(db_val)
# Special: year / release date (#824). Prefer the full release_date when
# the DB has one — it's authoritative, compare it directly. Otherwise use
# the year int, for which a MORE-specific file date with the same year is
# preserved (not flagged as a change). DB year is int, file is string.
if db_key == 'year':
release_date = db_data.get('release_date')
if release_date:
db_val = str(release_date)
db_str = str(release_date).strip()
elif db_val is not None:
db_str = str(db_val)
db_val = str(db_val)
if file_str and file_str[:4] == db_str:
file_str = db_str
# Only mark as changed if DB has a value AND it differs from file
# (writer skips fields where DB value is empty, so don't show them as diffs)
@ -319,7 +329,10 @@ def write_tags_to_file(file_path: str, db_data: Dict[str, Any],
album = guard_placeholder_overwrite(album, _current.get('album'))
album_artist = guard_placeholder_overwrite(album_artist, _current.get('album_artist'))
year = db_data.get('year')
# Prefer the full release_date (e.g. 2023-09-01) when the DB has one;
# fall back to the year-only int. _date_to_write() then writes the full
# date and still preserves an equally-specific existing file date (#824).
year = db_data.get('release_date') or db_data.get('year')
genres = db_data.get('genres')
track_num = db_data.get('track_number')
total_tracks = db_data.get('track_count')
@ -390,13 +403,12 @@ def write_tags_to_file(file_path: str, db_data: Dict[str, Any],
if art_ok:
written.append('cover_art')
# Save
if isinstance(audio.tags, ID3):
audio.save(v1=0, v2_version=4)
elif isinstance(audio, FLAC):
audio.save(deleteid3=True)
else:
audio.save()
# Save — atomically (#819): write into a temp copy + atomic replace so an
# interrupted/OOM-killed save can never truncate the user's file. Same
# format kwargs as before, just routed through the shared atomic helper.
from types import SimpleNamespace
from core.metadata.common import save_audio_file
save_audio_file(audio, SimpleNamespace(ID3=ID3, FLAC=FLAC, File=MutagenFile))
return {'success': True, 'written_fields': written}
@ -443,6 +455,20 @@ def _multi_artist_write_enabled() -> bool:
return False
def _date_to_write(existing: Optional[str], year) -> str:
"""Value to write for the date/year tag. Writes the DB year, BUT keeps an
existing, MORE-specific file date (e.g. ``2023-11-03``) when its year already
matches so enrichment/retag never downgrades a real full release date to
just the year (#824). When the years differ (a genuine correction) or the
file has no date, the year is written as before."""
year_str = str(year)
if existing:
existing = str(existing).strip()
if len(existing) > 4 and existing[:4] == year_str:
return existing
return year_str
def _write_id3(audio, title, artist, album_artist, album, year, genre,
track_num, total_tracks, disc_num, bpm,
artists_list: Optional[List[str]] = None) -> List[str]:
@ -474,8 +500,9 @@ def _write_id3(audio, title, artist, album_artist, album, year, genre,
audio.tags.add(TALB(encoding=3, text=[album]))
written.append('album')
if year is not None:
existing_date = _id3_text(audio.tags, 'TDRC')
audio.tags.delall('TDRC')
audio.tags.add(TDRC(encoding=3, text=[str(year)]))
audio.tags.add(TDRC(encoding=3, text=[_date_to_write(existing_date, year)]))
written.append('year')
if genre:
audio.tags.delall('TCON')
@ -521,7 +548,7 @@ def _write_vorbis(audio, title, artist, album_artist, album, year, genre,
audio['album'] = [album]
written.append('album')
if year is not None:
audio['date'] = [str(year)]
audio['date'] = [_date_to_write(_vorbis_first(audio, 'date'), year)]
written.append('year')
if genre:
audio['genre'] = [genre]
@ -565,7 +592,7 @@ def _write_mp4(audio, title, artist, album_artist, album, year, genre,
audio['\xa9alb'] = [album]
written.append('album')
if year is not None:
audio['\xa9day'] = [str(year)]
audio['\xa9day'] = [_date_to_write(_mp4_first(audio, '\xa9day'), year)]
written.append('year')
if genre:
audio['\xa9gen'] = [genre]

View file

@ -115,6 +115,77 @@ def strip_redundant_context_qualifiers(title: str, *context_texts: str) -> str:
return re.sub(r"\s+", " ", out).strip()
# Qualifier tokens that mark a genuinely DIFFERENT recording/cut — these must
# keep blocking a match. Union of the matching-engine keyword lists plus the
# Spanish markers seen in real libraries (#825: 'En Directo…', 'Versión 1988',
# 'Dueto 2007'). Titles reaching the matcher are unidecode-normalized, so the
# ASCII forms ('version') cover the accented ones ('versión').
_VERSION_MARKER_TOKENS = frozenset({
# English
"remix", "mix", "rmx", "live", "acoustic", "unplugged", "instrumental",
"karaoke", "demo", "demos", "edit", "version", "versions", "remaster",
"remastered", "slowed", "reverb", "sped", "spedup", "speedup", "extended",
"club", "mashup", "bootleg", "cover", "covers", "reprise", "session",
"sessions", "mono", "stereo", "duet", "rework", "dub", "vip", "single",
"radio", "alt", "alternate", "alternative", "take", "edition", "orchestral",
"symphonic", "piano", "acapella", "cappella", "nightcore",
# Distinct-track qualifiers — '(Interlude)' etc. are SEPARATE short tracks
# that share the base name with the full song; never treat as subtitles.
"interlude", "intro", "outro", "skit", "freestyle", "medley", "snippet",
# Part/volume markers whose number can be non-numeric ('Pt. II') — the
# digit guard below only catches actual digits.
"pt", "part", "vol", "ii", "iii", "iv", "vi", "vii", "viii",
# Spanish (unidecode-normalized; 'versión' → 'version' is covered above)
"directo", "vivo", "dueto",
})
def strip_subtitle_qualifiers(title: str, other_title: str) -> str:
"""Remove bracketed qualifiers that are SUBTITLES, not version markers.
#825 (carlosjfcasero): the wishlist held 'Llamando a la tierra (Serenade
From the Stars)' — the song's official subtitle while the library track
was the bare 'Llamando a la tierra'. The qualifier appears in no album or
counterpart title, so :func:`strip_redundant_context_qualifiers` keeps it,
and the length-ratio penalty then crushes an obviously-same song to ~0.14.
The sync matcher reported it missing on every run (re-adding it to the
wishlist) and the cleanup same matcher could never remove it.
A qualifier is stripped only when ALL of:
* its text does not appear in ``other_title`` (if it does, the direct
comparison already handles it);
* it contains no version-marker token ('(Live)', '(Versión 1988)',
'(Dueto 2007)' keep blocking they are different recordings);
* it introduces no digit token absent from ``other_title`` ('(Pt. 2)',
'(2007)' are different releases, never subtitles).
Inputs should be normalized the same way the caller compares them
(lowercased / unidecode'd), like strip_redundant_context_qualifiers.
"""
if not title:
return title
other = (other_title or "").casefold()
other_tokens = set(_TOKEN_RE.findall(other))
def _drop(match: re.Match) -> str:
inner = match.group(1).strip().casefold()
if not inner:
return " "
# Restated in the counterpart title — leave for the direct comparison.
if re.search(r"\b" + re.escape(inner) + r"\b", other):
return match.group(0)
tokens = _TOKEN_RE.findall(inner)
if any(t in _VERSION_MARKER_TOKENS for t in tokens):
return match.group(0)
if any(any(c.isdigit() for c in t) and t not in other_tokens for t in tokens):
return match.group(0)
return " "
out = _QUALIFIER_RE.sub(_drop, title)
return re.sub(r"\s+", " ", out).strip()
def numeric_tokens_differ(title_a: str, title_b: str) -> bool:
"""True when the digit-bearing tokens of two titles differ — 'Vol.4' vs
'Vol.4.5', 'Album' vs 'Album 2'. A numeric difference is a different
@ -133,5 +204,6 @@ def numeric_tokens_differ(title_a: str, title_b: str) -> bool:
__all__ = [
"titles_plausibly_same",
"strip_redundant_context_qualifiers",
"strip_subtitle_qualifiers",
"numeric_tokens_differ",
]

View file

@ -302,18 +302,21 @@ class TidalDownloadClient(DownloadSourcePlugin):
@classmethod
def _track_matches_qualifiers(cls, track, qualifiers: List[str]) -> bool:
"""Issue #589 — qualifier check must inspect both track.name AND
track.album.name. For MTV Unplugged-style releases the live /
unplugged signal lives in the album title, not the track title.
A track passes if every required qualifier appears as a whole
word in either the track name OR its album name.
"""Issue #589 — qualifier check must inspect track.name, track.version
AND track.album.name. Tidal stores remix/live/edit qualifiers in a
dedicated `version` attribute (e.g. name="Emerge", version="Junkie XL
Remix"), and for MTV Unplugged-style releases the live / unplugged
signal lives in the album title. A track passes if every required
qualifier appears as a whole word in the track name, its version, OR
its album name.
"""
if not qualifiers:
return True
track_name = (getattr(track, 'name', '') or '').lower()
version = (getattr(track, 'version', '') or '').lower()
album = getattr(track, 'album', None)
album_name = (getattr(album, 'name', '') or '').lower() if album else ''
haystack = f"{track_name} {album_name}".strip()
haystack = f"{track_name} {version} {album_name}".strip()
if not haystack:
return False
for kw in qualifiers:
@ -475,6 +478,17 @@ class TidalDownloadClient(DownloadSourcePlugin):
def _tidal_to_track_result(self, track, quality_info: dict) -> TrackResult:
artist_name = track.artist.name if track.artist else 'Unknown Artist'
title = track.name or 'Unknown Title'
# Tidal keeps remix/live/edition qualifiers in a separate `version`
# attribute (e.g. name="Emerge", version="Junkie XL Remix"). The
# matcher only scores `title`, so without folding the version in,
# every remix/live/edit candidate presents as the bare base track
# and MusicMatchingEngine's strict version check rejects it against
# a "... (Junkie XL Remix)" request. Append the version (unless it's
# already in the name) so the candidate title is the full
# "Title (Version)".
version = getattr(track, 'version', None)
if version and version.strip() and version.strip().lower() not in title.lower():
title = f"{title} ({version.strip()})"
album_name = track.album.name if track.album else None
duration_ms = int(track.duration * 1000) if track.duration else None
@ -548,6 +562,62 @@ class TidalDownloadClient(DownloadSourcePlugin):
return init_uri, segment_uris
# Tidal aggressively rate-limits the trackManifests endpoint. When a
# batch fans out, a bare request fails 429 instantly, the quality tier is
# burned, the track is re-queued, and the retry hammers again — a
# self-amplifying storm (thousands of 429s, downloads stalled, only the
# lowest tier squeaking through). Honour the server's Retry-After (or back
# off exponentially) so downloads pace themselves to whatever rate Tidal
# allows instead of slamming the wall and instant-failing.
_MANIFEST_MAX_RETRIES = 5
_MANIFEST_BACKOFF_BASE = 2.0 # seconds → 2, 4, 8, 16, 32 (capped)
_MANIFEST_BACKOFF_CAP = 30.0
@classmethod
def _retry_after_seconds(cls, response, attempt: int) -> float:
"""Backoff delay for a rate-limited response: honour a numeric
Retry-After header when present, else exponential backoff (capped)."""
retry_after = response.headers.get('Retry-After') if response is not None else None
if retry_after:
try:
return min(max(float(retry_after), 0.0), cls._MANIFEST_BACKOFF_CAP)
except (TypeError, ValueError):
pass
return min(cls._MANIFEST_BACKOFF_BASE * (2 ** attempt), cls._MANIFEST_BACKOFF_CAP)
def _sleep_with_shutdown(self, delay: float) -> bool:
"""Sleep up to `delay` seconds in small slices. Returns True if a
shutdown was requested mid-sleep so the caller can bail early."""
slept = 0.0
while slept < delay:
if self.shutdown_check and self.shutdown_check():
return True
chunk = min(0.5, delay - slept)
time.sleep(chunk)
slept += chunk
return False
def _get_with_rate_limit_retry(self, url: str, *, params=None, headers=None,
timeout: int = 20):
"""HTTP GET that backs off and retries on 429 (and transient 5xx),
honouring Retry-After. Returns the final Response (the caller still
calls raise_for_status); a persistent rate-limit after all retries
returns the last 429 response so existing error handling applies."""
response = None
for attempt in range(self._MANIFEST_MAX_RETRIES + 1):
response = http_requests.get(url, params=params, headers=headers, timeout=timeout)
if response.status_code != 429 and response.status_code < 500:
return response
if attempt >= self._MANIFEST_MAX_RETRIES:
return response
delay = self._retry_after_seconds(response, attempt)
logger.warning(
f"Tidal returned {response.status_code} on manifest fetch — backing off "
f"{delay:.1f}s (attempt {attempt + 1}/{self._MANIFEST_MAX_RETRIES})")
if self._sleep_with_shutdown(delay):
return response
return response
def _get_hls_manifest(self, track_id: int, quality: str = 'lossless') -> Optional[Dict]:
q_info = HLS_QUALITY_MAP.get(quality, HLS_QUALITY_MAP['lossless'])
formats = q_info['formats']
@ -573,7 +643,7 @@ class TidalDownloadClient(DownloadSourcePlugin):
}
try:
response = http_requests.get(url, params=params, headers=headers, timeout=20)
response = self._get_with_rate_limit_retry(url, params=params, headers=headers, timeout=20)
response.raise_for_status()
data = response.json()
except http_requests.HTTPError as e:

View file

@ -354,8 +354,15 @@ def _normalize_album_for_match(name: str) -> str:
return cleaned
# Capture the FULL trailing number, including multi-part / decimal volumes.
# Normalization strips the dot in "Vol.5.5" to "vol 5 5", so without the
# `(?:\s+\d+)*` the extractor grabbed only the last "5" — making "Vol.5",
# "Vol.5.5" and "Vol.4.5" all look like volume "5" and collapse together. That
# made the watchlist treat tracks from different character-song CD volumes as
# duplicates and skip them (Sokhi: partially-filled discography never completed).
_VOLUME_MARKER_RE = re.compile(
r'\b(?:vol(?:ume)?|pt|part|disc|book|chapter|episode)\.?\s*(\d+)\b|\b(\d+)\s*$',
r'\b(?:vol(?:ume)?|pt|part|disc|book|chapter|episode)\.?\s*(\d+(?:\s+\d+)*)\b'
r'|\b(\d+(?:\s+\d+)*)\s*$',
re.IGNORECASE,
)
@ -1313,6 +1320,19 @@ class WatchlistScanner:
logger.info("Skipping album with placeholder tracks: %s", album_name)
continue
if not self._should_include_release(len(tracks), artist):
# Make the type-filter skip visible — otherwise a user
# with "Albums" toggled off just sees missing tracks
# with no explanation (Sokhi #815-adjacent: 14 singles,
# 0 albums because include_albums was off).
_n = len(tracks)
_kind = 'album' if _n >= 7 else ('EP' if _n >= 4 else 'single')
logger.info(
"Skipping %s '%s' (%d tracks) — release type filter "
"(albums=%s, eps=%s, singles=%s) excludes it",
_kind, album_name, _n,
getattr(artist, 'include_albums', True),
getattr(artist, 'include_eps', True),
getattr(artist, 'include_singles', True))
continue
album_image_url = ''
@ -1356,14 +1376,34 @@ class WatchlistScanner:
if scan_state is not None:
scan_state['tracks_found_this_scan'] += 1
if self.add_track_to_wishlist(track, album_data, artist):
added = self.add_track_to_wishlist(
track, album_data, artist,
scan_run_id=(scan_state or {}).get('scan_run_id', ''),
)
track_artists = track.get('artists', [])
track_artist_name = track_artists[0].get('name', 'Unknown Artist') if track_artists else 'Unknown Artist'
# #831: per-run ledger so the completed-scan
# summary can list WHICH tracks the counts mean.
# 'skipped' = found-new but add_to_wishlist
# declined (already queued in the wishlist, or
# the artist is blocklisted). Capped for sanity.
if scan_state is not None:
events = scan_state.setdefault('scan_track_events', [])
if len(events) < 500:
events.append({
'track_name': track_name,
'artist_name': track_artist_name,
'album_name': album_name,
'album_image_url': album_image_url,
'status': 'added' if added else 'skipped',
})
if added:
artist_added_tracks += 1
if scan_state is not None:
scan_state['tracks_added_this_scan'] += 1
track_artists = track.get('artists', [])
track_artist_name = track_artists[0].get('name', 'Unknown Artist') if track_artists else 'Unknown Artist'
if scan_state is not None:
scan_state['recent_wishlist_additions'].insert(0, {
'track_name': track_name,
'artist_name': track_artist_name,
@ -2204,7 +2244,8 @@ class WatchlistScanner:
logger.warning(f"Error checking if track exists: {track_name}: {e}")
return True # Assume missing if we can't check
def add_track_to_wishlist(self, track, album, watchlist_artist: WatchlistArtist) -> bool:
def add_track_to_wishlist(self, track, album, watchlist_artist: WatchlistArtist,
scan_run_id: str = '') -> bool:
"""Add a missing track to the wishlist"""
try:
# Handle both dict and object track/album formats
@ -2286,7 +2327,9 @@ class WatchlistScanner:
'watchlist_artist_name': watchlist_artist.artist_name,
'watchlist_artist_id': watchlist_artist.spotify_artist_id,
'album_name': album_name,
'scan_timestamp': datetime.now().isoformat()
'scan_timestamp': datetime.now().isoformat(),
# #831: groups wishlist rows by the scan run that added them.
'scan_run_id': scan_run_id or '',
},
profile_id=getattr(watchlist_artist, 'profile_id', 1)
)

View file

@ -61,7 +61,7 @@ class WishlistAutoProcessingRuntime:
update_automation_progress: Callable[..., Any]
automation_engine: Any
missing_download_executor: Any
run_full_missing_tracks_process: Callable[[str, str, list[dict[str, Any]]], Any]
run_full_missing_tracks_process: Callable[..., Any] # (batch_id, playlist_id, tracks, serialize=False)
get_batch_max_concurrent: Callable[[], int]
get_active_server: Callable[[], str]
current_time_fn: Callable[[], float]
@ -246,11 +246,14 @@ def _run_wishlist_cycle(
f"'{album_name}' ({len(group.tracks)} tracks) → {album_batch_id}"
)
submitted.append(album_batch_id)
# Album bundles block their worker for the whole search+download → dedicated
# pool (falls back to the shared pool when unset). See #740.
# Album bundles block their worker for the whole search+download →
# dedicated pool (falls back to the shared pool when unset). serialize=True
# makes the worker actually HOLD its pool slot until the album drains, so
# only a few albums are in flight at once instead of every album flooding
# the shared download pool with 'searching' tracks (#740 / Sokhi).
album_executor.submit(
runtime.run_full_missing_tracks_process,
album_batch_id, playlist_id, group.tracks,
album_batch_id, playlist_id, group.tracks, True,
)
residual_tracks = grouping.residual_tracks if grouping is not None else tracks
@ -626,7 +629,7 @@ class WishlistManualDownloadRuntime:
download_batches: Dict[str, Dict[str, Any]]
tasks_lock: Any
missing_download_executor: Any
run_full_missing_tracks_process: Callable[[str, str, list[dict[str, Any]]], Any]
run_full_missing_tracks_process: Callable[..., Any] # (batch_id, playlist_id, tracks, serialize=False)
get_batch_max_concurrent: Callable[[], int]
add_activity_item: Callable[[Any, Any, Any, Any], Any]
active_server: str

View file

@ -423,6 +423,33 @@ def add_album_track_to_wishlist(
track_data = _build_track_data(track, album)
# #825: don't add a track that's already in the library, unless the user
# has opted into duplicates. The manual album "add to wishlist" modal
# otherwise dumped owned tracks straight into the wishlist with no check
# (carlosjfcasero) — and the auto-cleanup may not reliably remove them.
# Respects the same wishlist.allow_duplicate_tracks toggle the watchlist
# scan + cleanup use: OFF → skip owned, ON → add anyway. (The quality
# re-download flow uses a different endpoint, so it's unaffected.)
try:
from config.settings import config_manager as _cfg
if not _cfg.get('wishlist.allow_duplicate_tracks', True):
_db = runtime.get_music_database()
_existing, _conf = _db.check_track_exists(
track.get('name', ''), artist.get('name', ''),
confidence_threshold=0.7,
server_source=runtime.active_server,
album=album.get('name', ''),
)
if _existing and _conf >= 0.7:
runtime.logger.info(
"[Wishlist Add] skipping '%s' by '%s' — already in library "
"(allow_duplicate_tracks is off)",
track.get('name'), artist.get('name'))
return {"success": True, "skipped": True,
"message": f"'{track.get('name')}' is already in your library"}, 200
except Exception as _own_err:
runtime.logger.debug("Wishlist add ownership check failed (adding anyway): %s", _own_err)
enhanced_source_context = {
**(source_context or {}),
"artist_id": artist.get("id"),

View file

@ -649,6 +649,27 @@ class MusicDatabase:
logger.info(f"Added {_col} column to library_history")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_lh_origin ON library_history (origin, created_at DESC)")
# Watchlist scan history (#831 round 2) — one row per scan run with
# its full track ledger (added/skipped), so the Watchlist page can
# show what every past run did. Wishlist rows erode as tracks
# download, so this is the durable record.
cursor.execute("""
CREATE TABLE IF NOT EXISTS watchlist_scan_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id TEXT NOT NULL UNIQUE,
profile_id INTEGER DEFAULT 1,
status TEXT NOT NULL,
started_at TIMESTAMP,
completed_at TIMESTAMP,
total_artists INTEGER DEFAULT 0,
artists_scanned INTEGER DEFAULT 0,
tracks_found INTEGER DEFAULT 0,
tracks_added INTEGER DEFAULT 0,
track_events TEXT
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_wsr_completed ON watchlist_scan_runs (completed_at DESC)")
# Auto-import history — tracks auto-import scan results and processing status
cursor.execute("""
CREATE TABLE IF NOT EXISTS auto_import_history (
@ -1020,6 +1041,15 @@ class MusicDatabase:
cursor.execute("ALTER TABLE albums ADD COLUMN api_track_count INTEGER DEFAULT NULL")
logger.info("Repaired missing api_track_count column on albums table")
# Full release date (#824). Additive + nullable: NULL means "only the
# year is known", and every reader falls back to albums.year, so this
# is safe to ship dormant. Populated by enrichment + manual edit;
# consumed by the tag writer to write the full date (e.g. 2023-09-01)
# instead of truncating it to the year.
if album_cols and 'release_date' not in album_cols:
cursor.execute("ALTER TABLE albums ADD COLUMN release_date TEXT DEFAULT NULL")
logger.info("Added release_date column to albums table (#824)")
# Canonical album version (#765 / #767-Bug2). Additive + nullable:
# a NULL canonical means "unresolved" and every tool falls back to
# today's behavior, so this is safe to ship dormant. Columns are
@ -1352,6 +1382,7 @@ class MusicDatabase:
artist_id TEXT NOT NULL,
title TEXT NOT NULL,
year INTEGER,
release_date TEXT,
thumb_url TEXT,
genres TEXT,
track_count INTEGER,
@ -6250,11 +6281,47 @@ class MusicDatabase:
))
return tracks
except Exception as e:
logger.error(f"Error getting tracks for album {album_id}: {e}")
return []
def get_album_by_spotify_album_id(self, spotify_album_id: str) -> Optional[DatabaseAlbum]:
"""Fetch a single album by its (enriched) Spotify album id, or None.
Used by the download path builder (#829) to reuse an album's existing
on-disk folder when re-downloading into the same album matching the
exact stored Spotify id before falling back to fuzzy name+artist.
"""
if not spotify_album_id:
return None
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT albums.*, artists.name as artist_name
FROM albums
JOIN artists ON albums.artist_id = artists.id
WHERE albums.spotify_album_id = ?
LIMIT 1
""", (spotify_album_id,))
row = cursor.fetchone()
if not row:
return None
genres = json.loads(row['genres']) if row['genres'] else None
album = DatabaseAlbum(
id=row['id'], artist_id=row['artist_id'], title=row['title'],
year=row['year'], thumb_url=row['thumb_url'], genres=genres,
track_count=row['track_count'], duration=row['duration'],
created_at=datetime.fromisoformat(row['created_at']) if row['created_at'] else None,
updated_at=datetime.fromisoformat(row['updated_at']) if row['updated_at'] else None,
)
album.artist_name = row['artist_name']
return album
except Exception as e:
logger.error(f"Error getting album by spotify_album_id {spotify_album_id}: {e}")
return None
def search_artists(self, query: str, limit: int = 50, server_source: str = None) -> List[DatabaseArtist]:
"""Search artists by name, optionally filtered by server source.
Uses diacritic-insensitive matching so 'Tiesto' finds 'Tiësto'."""
@ -7622,6 +7689,24 @@ class MusicDatabase:
ctx_sim *= ctx_ratio # 'Believe' vs 'Believe In Me' still penalised
best_title_similarity = max(best_title_similarity, ctx_sim)
# #825: a bracketed qualifier that is a SUBTITLE — not a version
# marker and not numeric — is the same song. 'Llamando a la tierra
# (Serenade From the Stars)' vs the library's bare 'Llamando a la
# tierra': the subtitle restates nothing (so #808 keeps it) and the
# length penalty crushed the pair to ~0.14 — sync re-added it to
# the wishlist forever and cleanup (same matcher) never removed it.
# Version qualifiers ('(Live)', '(Versión 1988)', '(Dueto 2007)')
# are kept by the helper, so their mismatch penalty still stands.
from core.text.title_match import strip_subtitle_qualifiers
sub_search = strip_subtitle_qualifiers(search_title_norm, db_title_norm)
sub_db = strip_subtitle_qualifiers(db_title_norm, search_title_norm)
if (sub_search, sub_db) != (search_title_norm, db_title_norm) and sub_search and sub_db:
sub_sim = self._string_similarity(sub_search, sub_db)
sub_ratio = min(len(sub_search), len(sub_db)) / max(len(sub_search), len(sub_db))
if sub_ratio < 0.7:
sub_sim *= sub_ratio # stripped forms still length-guarded
best_title_similarity = max(best_title_similarity, sub_sim)
# Word-level guard: SequenceMatcher's char ratio over-credits
# different songs that share a long substring or only a stopword
# ("Dani California" vs "Californication" = 0.67; "Under The Bridge"
@ -10507,6 +10592,7 @@ class MusicDatabase:
MIN(a.id) as id,
a.title,
a.year,
MAX(a.release_date) as release_date,
SUM(a.track_count) as track_count,
MAX(a.thumb_url) as thumb_url,
MAX(a.musicbrainz_release_id) as musicbrainz_release_id,
@ -10566,6 +10652,7 @@ class MusicDatabase:
'id': album_row['id'],
'title': album_row['title'],
'year': album_row['year'],
'release_date': album_row['release_date'],
'image_url': album_row['thumb_url'],
'owned': True, # All albums in our DB are owned
'track_count': album_row['track_count'],
@ -10648,7 +10735,7 @@ class MusicDatabase:
# Field whitelists for safe updates
ARTIST_EDITABLE_FIELDS = {'name', 'genres', 'summary', 'style', 'mood', 'label'}
ALBUM_EDITABLE_FIELDS = {'title', 'year', 'genres', 'style', 'mood', 'label', 'explicit', 'record_type', 'track_count'}
ALBUM_EDITABLE_FIELDS = {'title', 'year', 'release_date', 'genres', 'style', 'mood', 'label', 'explicit', 'record_type', 'track_count'}
TRACK_EDITABLE_FIELDS = {'title', 'track_number', 'bpm', 'explicit', 'style', 'mood'}
def get_artist_full_detail(self, artist_id) -> Dict[str, Any]:
@ -12478,6 +12565,75 @@ class MusicDatabase:
logger.debug(f"Error adding library history entry: {e}")
return False
def save_watchlist_scan_run(self, run_id, profile_id=1, status='completed',
started_at=None, completed_at=None,
total_artists=0, artists_scanned=0,
tracks_found=0, tracks_added=0,
track_events=None, keep_last=100) -> bool:
"""Persist one watchlist scan run + its track ledger (#831 round 2).
Idempotent on run_id (re-saving a run replaces it). Prunes the table to
the most recent ``keep_last`` runs so history can't grow unbounded."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("""
INSERT OR REPLACE INTO watchlist_scan_runs
(run_id, profile_id, status, started_at, completed_at,
total_artists, artists_scanned, tracks_found, tracks_added,
track_events)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (run_id, profile_id, status, started_at, completed_at,
total_artists, artists_scanned, tracks_found, tracks_added,
json.dumps(track_events or [])))
cursor.execute("""
DELETE FROM watchlist_scan_runs WHERE id NOT IN (
SELECT id FROM watchlist_scan_runs
ORDER BY completed_at DESC, id DESC LIMIT ?
)
""", (keep_last,))
conn.commit()
return True
except Exception as e:
logger.error(f"Error saving watchlist scan run {run_id}: {e}")
return False
def get_watchlist_scan_runs(self, limit=30, profile_id=None):
"""Recent watchlist scan runs, newest first — WITHOUT track ledgers
(fetch those per-run via get_watchlist_scan_run_events)."""
try:
conn = self._get_connection()
cursor = conn.cursor()
where = "WHERE profile_id = ?" if profile_id is not None else ""
params = ([profile_id] if profile_id is not None else []) + [limit]
cursor.execute(f"""
SELECT run_id, profile_id, status, started_at, completed_at,
total_artists, artists_scanned, tracks_found, tracks_added
FROM watchlist_scan_runs {where}
ORDER BY completed_at DESC, id DESC LIMIT ?
""", params)
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Error getting watchlist scan runs: {e}")
return []
def get_watchlist_scan_run_events(self, run_id):
"""The track ledger (added/skipped events) for one scan run."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT track_events FROM watchlist_scan_runs WHERE run_id = ?",
(run_id,))
row = cursor.fetchone()
if not row or not row['track_events']:
return []
events = json.loads(row['track_events'])
return events if isinstance(events, list) else []
except Exception as e:
logger.error(f"Error getting watchlist scan run events for {run_id}: {e}")
return []
def get_origin_cleanup_candidates(self):
"""Origin-tracked downloads (watchlist/playlist) annotated with the
matching library track's play_count, for the Expired Download Cleaner.

View file

@ -665,6 +665,10 @@ class PlaylistSyncService:
_try_title, _try_artist,
confidence_threshold=0.7, server_source=active_server,
candidate_tracks=artist_candidates,
# #825: album context enables the album-aware fallback
# (multi-artist albums filed under another artist) —
# the cleanup path already passes it; sync didn't.
album=getattr(spotify_track, 'album', None) or None,
)
if _cand_conf > confidence:
db_track, confidence = _cand_track, _cand_conf

View file

@ -738,6 +738,12 @@ class TestSyncPlaylist:
import time
time.sleep(0.01)
assert len(sync_calls) == 1
# #823 — the handler must NOT force a sync mode; it leaves it unset so
# _run_sync_task resolves the user's configured global mode (else the
# automated sync always 'replace'd and wiped the playlist image/desc).
args, kwargs = sync_calls[0]
assert kwargs.get('sync_mode') is None # not forced via kwarg
assert len(args) == 6 # no 7th positional sync_mode either
def test_organize_by_playlist_passes_skip_wishlist_add(self):
discovered_track = {

View file

@ -0,0 +1,73 @@
"""Album-bundle serialization wait (#740 / Sokhi "too many searching").
_wait_for_batch_drain holds the album-pool worker until the batch's tasks all
reach a terminal state so only a few albums are in flight at once instead of
every album flooding the shared download pool. It's a passive wait that must
also bail on shutdown / a removed batch / a safety cap.
"""
import threading
import time
import pytest
from core.runtime_state import download_batches, download_tasks, tasks_lock
from core.downloads import master, monitor
def _set_batch(bid, task_statuses):
with tasks_lock:
download_batches[bid] = {'queue': list(task_statuses.keys())}
for tid, st in task_statuses.items():
download_tasks[tid] = {'status': st}
@pytest.fixture(autouse=True)
def _cleanup():
yield
with tasks_lock:
for bid in ('b1', 'b2', 'b3', 'b4'):
download_batches.pop(bid, None)
for tid in ('t1', 't2', 't3'):
download_tasks.pop(tid, None)
def test_returns_immediately_when_all_terminal():
_set_batch('b1', {'t1': 'completed', 't2': 'failed', 't3': 'not_found'})
start = time.time()
master._wait_for_batch_drain('b1', poll_seconds=0.05, max_wait_seconds=5)
assert time.time() - start < 1.0 # nothing in flight → no block
def test_returns_when_batch_missing():
master._wait_for_batch_drain('nope', poll_seconds=0.05, max_wait_seconds=5) # no hang
def test_waits_until_tasks_go_terminal():
_set_batch('b2', {'t1': 'searching', 't2': 'downloading'})
def finish():
time.sleep(0.25)
with tasks_lock:
download_tasks['t1']['status'] = 'completed'
download_tasks['t2']['status'] = 'failed'
threading.Thread(target=finish, daemon=True).start()
start = time.time()
master._wait_for_batch_drain('b2', poll_seconds=0.05, max_wait_seconds=5)
assert 0.2 < time.time() - start < 3.0 # held the slot until they finished
def test_bails_on_shutdown(monkeypatch):
_set_batch('b3', {'t1': 'searching'}) # never terminal
monkeypatch.setattr(monitor, 'IS_SHUTTING_DOWN', True)
start = time.time()
master._wait_for_batch_drain('b3', poll_seconds=0.05, max_wait_seconds=10)
assert time.time() - start < 1.0 # didn't block app shutdown
def test_respects_safety_cap():
_set_batch('b4', {'t1': 'searching'}) # never terminal
start = time.time()
master._wait_for_batch_drain('b4', poll_seconds=0.05, max_wait_seconds=0.3)
assert 0.3 <= time.time() - start < 2.0 # released the slot after the cap

View file

@ -0,0 +1,80 @@
"""Parse pasted Tidal/Qobuz track links for the manual download search (#813)."""
from core.downloads.track_link import parse_download_track_link as p
def test_tidal_track_url_with_region_suffix():
assert p('https://tidal.com/track/434945950/u') == ('tidal', '434945950')
def test_tidal_browse_and_listen_hosts():
assert p('https://tidal.com/browse/track/434945950') == ('tidal', '434945950')
assert p('https://listen.tidal.com/track/434945950') == ('tidal', '434945950')
def test_qobuz_track_urls():
assert p('https://open.qobuz.com/track/12345678') == ('qobuz', '12345678')
assert p('https://play.qobuz.com/track/12345678') == ('qobuz', '12345678')
def test_scheme_less():
assert p('tidal.com/track/999') == ('tidal', '999')
def test_id_with_slug_suffix():
assert p('https://www.qobuz.com/track/555-some-slug') == ('qobuz', '555')
def test_non_track_links_rejected():
assert p('https://tidal.com/album/123') is None # album, not track
assert p('https://tidal.com/artist/123') is None
assert p('https://open.spotify.com/track/abc') is None # unsupported source
assert p('https://example.com/track/123') is None
def test_garbage_rejected():
assert p('') is None
assert p('just some text') is None
assert p('Habbit (T-Mass Remix)') is None
# ── query_from_track_payload (pure per-source parsing) ──
from core.downloads.track_link import query_from_track_payload as q
def test_tidal_payload_appends_version():
# Tidal attributes: title + version → remix link searches for the remix.
raw = {'title': 'Habbit', 'version': 'T-Mass Remix',
'artists': [{'name': 'Rain Man'}, {'name': 'Krysta Youngs'}]}
assert q('tidal', raw) == 'Rain Man Habbit (T-Mass Remix)'
def test_tidal_payload_no_version_no_artist():
assert q('tidal', {'title': 'Bloom'}) == 'Bloom'
def test_tidal_payload_singular_artist():
assert q('tidal', {'title': 'X', 'artist': {'name': 'Jinco'}}) == 'Jinco X'
def test_tidal_version_already_in_title_not_doubled():
raw = {'title': 'Bloom (Nurko Remix)', 'version': 'Nurko Remix',
'artists': [{'name': 'Dabin'}]}
assert q('tidal', raw) == 'Dabin Bloom (Nurko Remix)'
def test_qobuz_payload_performer():
raw = {'title': "What's Good For Me", 'performer': {'name': 'Jinco'}}
assert q('qobuz', raw) == "Jinco What's Good For Me"
def test_qobuz_payload_falls_back_to_album_artist():
raw = {'title': 'Song', 'album': {'artist': {'name': 'Some Artist'}}}
assert q('qobuz', raw) == 'Some Artist Song'
def test_payload_non_dict_or_empty():
assert q('tidal', None) is None
assert q('tidal', {}) is None
assert q('qobuz', 'garbage') is None

View file

@ -338,3 +338,20 @@ def test_detect_album_info_web_forces_album_when_track_and_artist_differ():
assert album_info["album_name"] == "Album One"
assert album_info["track_number"] == 4
assert album_info["disc_number"] == 2
def test_get_import_source_reads_underscore_source_from_nested_dicts():
"""Netti93 multi-artist fix: many track payloads carry '_source' (the
discography/wishlist dicts) or 'source' only inside track_info (search
results). get_import_source must resolve all of them previously only
the context-level keys worked, so direct downloads resolved '' and
source-specific metadata logic never ran."""
from core.imports.context import get_import_source
assert get_import_source({"track_info": {"source": "deezer"}}) == "deezer"
assert get_import_source({"track_info": {"_source": "deezer"}}) == "deezer"
assert get_import_source({"original_search_result": {"_source": "itunes"}}) == "itunes"
assert get_import_source({"_source": "tidal"}) == "tidal"
# context-level 'source' still wins over nested
assert get_import_source({"source": "spotify", "track_info": {"_source": "deezer"}}) == "spotify"
assert get_import_source({}) == ""

View file

@ -0,0 +1,100 @@
"""Divergent-version matching: two DIFFERENT versions of the same base
title must NOT match.
Context: Tidal stores remix/live/edit qualifiers in a dedicated `version`
field which `_tidal_to_track_result` now folds into the candidate title so
the matcher can see it. That fix made the *correct* version win but it
also makes OTHER versions of the same song visible ("We Are The People
(Shazam Remix)" vs "(southstar Remix)"). Neither title is a prefix of the
other, so the original prefix-based version check missed them and the raw
ratio stayed high (~0.8) off the shared base. Without discrimination, when
the requested version is absent a different remix could outscore the
threshold and the wrong cut would be downloaded.
These pin: different descriptors reject, the correct one still wins, and
the existing original-vs-version / remaster behaviour is preserved.
"""
from __future__ import annotations
import pytest
from core.matching_engine import MusicMatchingEngine
me = MusicMatchingEngine()
# ── similarity_score: divergent version tails (already-normalised input) ──
def test_different_remix_descriptors_rejected():
assert me.similarity_score(
'we are the people shazam remix',
'we are the people southstar remix',
) == 0.30
def test_different_live_performances_rejected():
assert me.similarity_score(
'all night live at pukkelpop',
'all night live at wembley',
) == 0.30
def test_different_version_types_same_base_rejected():
# Same song, different version TYPE (remix vs live) — different cut.
assert me.similarity_score('song title remix', 'song title live') == 0.30
def test_same_base_non_version_tails_not_penalised():
# "one" / "two" are not version words — leave the raw ratio alone.
assert me.similarity_score('song one', 'song two') != 0.30
# ── regression: original-vs-version + remaster behaviour preserved ──
def test_original_vs_remix_still_rejected():
assert me.similarity_score('we are the people', 'we are the people remix') == 0.30
def test_remaster_still_light_penalty():
assert me.similarity_score('song title', 'song title remastered') == 0.75
def test_identical_titles_still_perfect():
assert me.similarity_score('emerge junkie xl remix', 'emerge junkie xl remix') == 1.0
# ── end-to-end via score_track_match (raw titles, real weighting) ──
_ARTIST = 'Empire Of The Sun'
def test_wrong_remix_scored_below_threshold():
# Requested Shazam Remix, only a different remix available → must land
# well under the 0.55/0.60 acceptance gate so it is never downloaded.
conf, _ = me.score_track_match(
'We Are The People (Shazam Remix)', [_ARTIST], 344_000,
'We Are The People (southstar Remix)', [_ARTIST], 236_000,
)
assert conf < 0.55, f'wrong remix scored {conf:.2f}, should be < 0.55'
def test_correct_remix_still_wins():
conf, _ = me.score_track_match(
'We Are The People (Shazam Remix)', [_ARTIST], 344_000,
'We Are The People (Shazam Remix)', [_ARTIST], 344_000,
)
assert conf >= 0.90, f'correct remix scored {conf:.2f}, should be >= 0.90'
@pytest.mark.parametrize('wanted,candidate', [
('We Are The People (Shazam Remix)', 'We Are The People (ARTBAT Remix)'),
('All Night (Live @ Pukkelpop)', 'All Night (Umek Remix)'),
('Emerge (Junkie XL Remix)', 'Emerge (DFA Version)'),
])
def test_wrong_version_below_correct(wanted, candidate):
artist = 'X'
wrong, _ = me.score_track_match(wanted, [artist], 0, candidate, [artist], 0)
right, _ = me.score_track_match(wanted, [artist], 0, wanted, [artist], 0)
assert right > wrong
assert wrong < 0.60, f'{candidate!r} scored {wrong:.2f} vs {wanted!r}'

View file

@ -61,6 +61,35 @@ class TestTrackArtistMatches:
assert track_artist_matches(['DRAKE'], 'Drake') is True
assert track_artist_matches(['Drake'], 'drake') is True
def test_combined_collab_credit_string_matches_component(self):
"""#830 (Vicky-2418): iTunes packs a collab into ONE string. The
requested artist is one of several credited must match. This is the
exact real case from the report (Narvent's 'Miss You (Ambient Remix)')."""
credit = ['TRVNSPORTER, Narvent & SKVLENT']
assert track_artist_matches(credit, 'Narvent') is True
assert track_artist_matches(credit, 'TRVNSPORTER') is True
assert track_artist_matches(credit, 'SKVLENT') is True
def test_combined_credit_still_drops_absent_artist(self):
"""The #559 contamination guard survives: an artist genuinely absent
from a combined credit is still dropped."""
assert track_artist_matches(['TRVNSPORTER, Narvent & SKVLENT'], 'Drake') is False
def test_feat_forms_match_component(self):
for credit in (['Drake feat. Narvent'], ['Drake ft. Narvent'],
['Drake featuring Narvent'], ['Drake x Narvent']):
assert track_artist_matches(credit, 'Narvent') is True
def test_no_substring_false_positive(self):
"""Component matching is exact per-name — a longer name that merely
contains the target must NOT match."""
assert track_artist_matches(['Narventos'], 'Narvent') is False
def test_band_name_with_internal_separator_still_matches_exactly(self):
"""A real band name containing a separator still matches as the full
string (we keep the whole credit as a candidate alongside the split)."""
assert track_artist_matches(['Florence + the Machine'], 'Florence + the Machine') is True
def test_match_handles_whitespace_padding(self):
"""Trailing whitespace in either side mustn't break the match."""
assert track_artist_matches([' Drake '], 'Drake') is True
@ -87,12 +116,14 @@ class TestTrackArtistMatches:
assert track_artist_matches([{'name': 'Drake'}], 'Drake') is True
assert track_artist_matches([{'name': 'Random'}], 'Drake') is False
def test_substring_does_not_match(self):
"""A song by "Drake & Future" should not match "Drake" via
substring that's exactly the false-positive case the bug
report describes. Exact full-name match only."""
assert track_artist_matches(['Drake & Future'], 'Drake') is False
def test_substring_does_not_match_but_component_does(self):
"""No SUBSTRING matching — "Drakeo the Ruler" must not match "Drake".
But a combined collab credit IS component-matched (#830): "Drake &
Future" matches "Drake" because Drake is genuinely one of the credited
artists. The #559 guard drops artists who aren't credited AT ALL, not
legit collaborators packed into one string by sources like iTunes."""
assert track_artist_matches(['Drakeo the Ruler'], 'Drake') is False
assert track_artist_matches(['Drake & Future'], 'Drake') is True
# ---------------------------------------------------------------------------

View file

@ -561,3 +561,93 @@ class TestDeezerDirectDownloadFlow:
assert meta["_artists_list"] == ["FAYAN", "Dalton"]
assert meta["artist"] == "FAYAN;Dalton"
assert meta["title"] == "VERLIEBT IN MICH"
class TestSourceResolutionFromRealDownloadPayload:
"""Netti93 round 3: the prior tests set context['source'] — a field the
REAL Search Download Now flow never had. The serialized search results
carried no source at all, so get_import_source() resolved '' and the
Deezer contributors upgrade never ran on direct downloads (single-artist
tags until a Retag). These pin the actual payload shapes end to end."""
def _staging_context(self, track_info):
# Shape built by core/downloads/staging.py:457 for a stream download.
return {
"track_info": track_info,
"spotify_artist": {"name": "August Burns Red", "id": None},
"spotify_album": {"name": "Death Below"},
"original_search_result": {
"username": "tidal", "filename": "/x.flac",
"title": "Sonic Salvation", "artist": "August Burns Red",
"spotify_clean_title": "Sonic Salvation",
"spotify_clean_album": "Death Below",
"spotify_clean_artist": "August Burns Red",
"track_number": 1, "disc_number": 1,
},
"is_album_download": False,
"staging_source": True,
}
def _fake_deezer(self):
return SimpleNamespace(get_track_details=MagicMock(return_value={
"id": "3966840171", "name": "Sonic Salvation",
"artists": ["August Burns Red", "Polaris"],
}))
def test_source_in_track_info_triggers_upgrade(self):
"""The fixed flow: serialized search result carries source='deezer'
inside track_info (no context-level source) upgrade fires."""
from core.metadata import source as src_module
track_info = {
"id": "3966840171", "name": "Sonic Salvation",
"source": "deezer",
"artists": ["August Burns Red"],
"album": {"name": "Death Below", "id": None},
}
fake_deezer = self._fake_deezer()
with patch.object(src_module, "get_config_manager",
return_value=_make_cfg({"metadata_enhancement.tags.write_multi_artist": True})), \
patch("core.metadata.get_deezer_client", return_value=fake_deezer):
meta = src_module.extract_source_metadata(
self._staging_context(track_info), {"name": "August Burns Red", "id": ""}, {})
assert meta["_artists_list"] == ["August Burns Red", "Polaris"]
fake_deezer.get_track_details.assert_called_once_with("3966840171")
def test_underscore_source_shape_also_triggers_upgrade(self):
"""Discography/wishlist payloads carry '_source' instead of 'source'
get_import_source must honor that shape too."""
from core.metadata import source as src_module
track_info = {
"id": "3966840171", "name": "Sonic Salvation",
"_source": "deezer",
"artists": ["August Burns Red"],
}
fake_deezer = self._fake_deezer()
with patch.object(src_module, "get_config_manager", return_value=_make_cfg()), \
patch("core.metadata.get_deezer_client", return_value=fake_deezer):
meta = src_module.extract_source_metadata(
self._staging_context(track_info), {"name": "August Burns Red", "id": ""}, {})
assert meta["_artists_list"] == ["August Burns Red", "Polaris"]
fake_deezer.get_track_details.assert_called_once_with("3966840171")
def test_sourceless_payload_still_no_upgrade(self):
"""A payload with no source anywhere (pre-fix shape) must not crash —
and must not call the Deezer API blindly."""
from core.metadata import source as src_module
track_info = {
"id": "3966840171", "name": "Sonic Salvation",
"artists": ["August Burns Red"],
}
fake_deezer = self._fake_deezer()
with patch.object(src_module, "get_config_manager", return_value=_make_cfg()), \
patch("core.metadata.get_deezer_client", return_value=fake_deezer):
meta = src_module.extract_source_metadata(
self._staging_context(track_info), {"name": "August Burns Red", "id": ""}, {})
assert meta["_artists_list"] == ["August Burns Red"]
fake_deezer.get_track_details.assert_not_called()

View file

@ -449,3 +449,53 @@ def test_resolve_get_album_returning_none_yields_not_found():
)
assert res['available'] is False
assert res['albums'] == []
# ── Discogs (#813 — extend paste-link to Discogs) ──────────────────────────
def test_parse_discogs_release_url_strips_slug():
out = by_id.parse_metadata_identifier(
'https://www.discogs.com/release/678910-Some-Album-Title')
assert out == [by_id.LookupTarget('discogs', 'album', '678910')]
def test_parse_discogs_master_url_is_album():
out = by_id.parse_metadata_identifier(
'https://www.discogs.com/master/555-A-Master')
assert out == [by_id.LookupTarget('discogs', 'album', '555')]
def test_parse_discogs_artist_url():
out = by_id.parse_metadata_identifier(
'https://www.discogs.com/artist/12345-Some-Artist')
assert out == [by_id.LookupTarget('discogs', 'artist', '12345')]
def test_parse_discogs_url_no_scheme():
out = by_id.parse_metadata_identifier('discogs.com/release/999-X')
assert out == [by_id.LookupTarget('discogs', 'album', '999')]
def test_resolve_discogs_release_uses_get_album_with_numeric_id():
client = _FakeClient(album={'id': '678910', 'name': 'Some Album',
'artists': [{'name': 'Some Artist'}]})
res = by_id.resolve_identifier(
'https://www.discogs.com/release/678910-Some-Album-Title', deps=None,
client_resolver=_resolver_from({'discogs': client}))
assert res['available'] is True and res['source'] == 'discogs'
assert res['albums'] and res['albums'][0]['name'] == 'Some Album'
assert client.album_calls == ['678910'] # numeric id, slug stripped
def test_resolve_discogs_artist():
client = _FakeClient(artist={'id': '12345', 'name': 'Some Artist'})
res = by_id.resolve_identifier(
'https://www.discogs.com/artist/12345-Some-Artist', deps=None,
client_resolver=_resolver_from({'discogs': client}))
assert res['available'] is True and res['source'] == 'discogs'
assert res['artists'] and res['artists'][0]['name'] == 'Some Artist'
assert client.artist_calls == ['12345']
def test_discogs_in_supported_sources():
assert 'discogs' in by_id.SUPPORTED_SOURCES

View file

@ -84,6 +84,7 @@ def test_search_kind_artists_returns_normalized_dicts():
assert result == [{
'id': 'id1',
'name': 'Pink Floyd',
'source': 'spotify',
'image_url': 'thumb.jpg',
'external_urls': {'spotify': 'url'},
}]
@ -137,6 +138,8 @@ def test_search_kind_tracks_returns_full_shape():
'id': 't1',
'name': 'Money',
'artist': 'Pink Floyd',
'artists': ['Pink Floyd'],
'source': '',
'album': 'DSOTM',
'duration_ms': 383000,
'image_url': 'm.jpg',
@ -201,3 +204,45 @@ def test_search_source_all_fail_returns_empty_lists():
client = _Client(fail={'artists', 'albums', 'tracks'})
result = sources.search_source('q', client, 'spotify')
assert result == {'artists': [], 'albums': [], 'tracks': [], 'available': True}
# ── source field on serialized results (Netti93 multi-artist fix) ───────────
# Search results must carry which metadata source they came from: the payload
# travels Download Now → download task → import context, where the Deezer
# contributors upgrade (multi-artist tags) is gated on source == 'deezer'.
# Without it get_import_source() resolved '' and collab tracks were tagged
# with only the primary artist until a Retag.
def _full_client():
return _Client(
artists=[_Artist('id1', 'A')],
albums=[_Album('a1', 'Album', artists=['A'])],
tracks=[_Track('t1', 'T', artists=['A'], album='Album')],
)
def test_serialized_tracks_carry_source():
out = sources.search_kind(_full_client(), "q", "tracks", source_name="deezer")
assert out and all(t["source"] == "deezer" for t in out)
def test_serialized_albums_and_artists_carry_source():
albums = sources.search_kind(_full_client(), "q", "albums", source_name="deezer")
artists = sources.search_kind(_full_client(), "q", "artists", source_name="deezer")
assert albums and all(a["source"] == "deezer" for a in albums)
assert artists and all(a["source"] == "deezer" for a in artists)
def test_serialized_source_empty_when_unnamed():
# hydrabase path calls without a source_name — emit '' not the class name.
out = sources.search_kind(_full_client(), "q", "tracks")
assert out and all(t["source"] == "" for t in out)
def test_serialized_tracks_carry_real_artists_list():
"""Collabs must survive as a LIST — the joined "A, B" display string made
downloads tag a single combined artist (Marcus's report)."""
client = _Client(tracks=[_Track('t1', 'Collab', artists=['Artist A', 'Artist B'], album='X')])
out = sources.search_kind(client, 'q', 'tracks', source_name='spotify')
assert out[0]['artists'] == ['Artist A', 'Artist B']
assert out[0]['artist'] == 'Artist A, Artist B' # display string unchanged

View file

@ -109,7 +109,7 @@ def test_apply_embeds_into_each_file_and_writes_cover(tmp_path, monkeypatch):
monkeypatch.setattr(aa, 'embed_album_art_metadata', lambda a, m: embed_calls.append(m) or True)
# download_cover_art is the standard cover.jpg writer — stub it to drop one.
monkeypatch.setattr(aa, 'download_cover_art',
lambda album_info, folder, ctx=None: open(f"{folder}/cover.jpg", 'wb').close())
lambda album_info, folder, ctx=None, **k: open(f"{folder}/cover.jpg", 'wb').close())
meta = {'artist': 'A', 'album': 'B', 'album_art_url': 'http://x/y.jpg'}
res = aa.apply_art_to_album_files([str(f1), str(f2)], meta, {'album_name': 'B'}, folder=str(tmp_path))
@ -194,6 +194,27 @@ def test_apply_flags_erofs_from_actual_write(tmp_path, monkeypatch):
assert res['failed'] == 2 # first EROFS fails it + bails the rest
def test_cover_only_read_only_is_detected(tmp_path, monkeypatch):
"""The gap that left Sokhi stuck (#804): tracks already have art so the
embed loop is skipped, but the cover.jpg write hits a read-only mount.
download_cover_art swallows that EROFS onto the context apply must still
surface read_only_fs (not report a silent success)."""
f = tmp_path / 'a.flac'; f.write_bytes(b'')
audio = SimpleNamespace(pictures=['pic'], tags={'ok': 1}) # already has art → embed skipped
monkeypatch.setattr(aa, 'get_mutagen_symbols', lambda: _fake_symbols(audio))
def _fake_download(album_info, target_dir, ctx, **k):
# mimic download_cover_art's EROFS handling: record on context, swallow
if isinstance(ctx, dict):
ctx['_cover_read_only'] = True
monkeypatch.setattr(aa, 'download_cover_art', _fake_download)
res = aa.apply_art_to_album_files([str(f)], {}, {}, folder=str(tmp_path))
assert res['embedded'] == 0 and res['skipped'] == 1 # nothing embedded
assert res['read_only_fs'] is True # but read-only surfaced
def test_apply_normal_failure_not_flagged_read_only(tmp_path, monkeypatch):
def _boom():
raise PermissionError(13, 'Permission denied')
@ -210,3 +231,106 @@ def test_apply_normal_failure_not_flagged_read_only(tmp_path, monkeypatch):
assert res['failed'] == 1
def test_filler_forces_cover_sidecar_write(tmp_path, monkeypatch):
# The Cover Art Filler must write cover.jpg regardless of the import-time
# "Download cover.jpg" toggle — so apply passes force=True (Sokhi).
f = tmp_path / 'a.mp3'; f.write_bytes(b'')
audio = SimpleNamespace(pictures=[], tags={'ok': 1}, save=lambda: None)
monkeypatch.setattr(aa, 'get_mutagen_symbols', lambda: _fake_symbols(audio))
monkeypatch.setattr(aa, 'embed_album_art_metadata', lambda *a, **k: True)
captured = {}
monkeypatch.setattr(aa, 'download_cover_art',
lambda album_info, target_dir, ctx, **k: captured.update(k))
aa.apply_art_to_album_files([str(f)], {}, {}, folder=str(tmp_path))
assert captured.get('force') is True
# ── cover.jpg from embedded art (#813/Sokhi: files have art, no sidecar) ──
def test_extract_embedded_art_flac(tmp_path, monkeypatch):
f = tmp_path / 'a.flac'; f.write_bytes(b'')
audio = SimpleNamespace(pictures=[SimpleNamespace(data=b'IMGBYTES')], tags=None)
monkeypatch.setattr(aa, 'get_mutagen_symbols', lambda: _fake_symbols(audio))
assert aa.extract_embedded_art(str(f)) == b'IMGBYTES'
def test_extract_embedded_art_none_when_no_pictures(tmp_path, monkeypatch):
f = tmp_path / 'a.flac'; f.write_bytes(b'')
audio = SimpleNamespace(pictures=[], tags=None)
monkeypatch.setattr(aa, 'get_mutagen_symbols', lambda: _fake_symbols(audio))
assert aa.extract_embedded_art(str(f)) is None
def test_apply_writes_cover_jpg_from_embedded_art(tmp_path, monkeypatch):
# Files already have embedded art, no cover.jpg → apply extracts it and
# writes the sidecar WITHOUT an API fetch (consistent + offline).
f = tmp_path / '01.flac'; f.write_bytes(b'')
audio = SimpleNamespace(pictures=[SimpleNamespace(data=b'EMBEDDED')], tags=None, save=lambda: None)
monkeypatch.setattr(aa, 'get_mutagen_symbols', lambda: _fake_symbols(audio))
monkeypatch.setattr(aa, 'embed_album_art_metadata', lambda *a, **k: True)
dl_called = []
monkeypatch.setattr(aa, 'download_cover_art', lambda *a, **k: dl_called.append(1))
res = aa.apply_art_to_album_files([str(f)], {}, {}, folder=str(tmp_path))
assert (tmp_path / 'cover.jpg').read_bytes() == b'EMBEDDED'
assert res['cover_written'] is True
assert dl_called == [] # used embedded art — no API call
def test_apply_falls_back_to_download_when_no_embedded(tmp_path, monkeypatch):
# No embedded art to extract → fetch the sidecar via download_cover_art.
f = tmp_path / '01.flac'; f.write_bytes(b'')
audio = SimpleNamespace(pictures=[], tags=None, add_tags=lambda: None, save=lambda: None)
monkeypatch.setattr(aa, 'get_mutagen_symbols', lambda: _fake_symbols(audio))
monkeypatch.setattr(aa, 'embed_album_art_metadata', lambda *a, **k: True)
dl_called = []
def fake_dl(album_info, target, ctx=None, **k):
dl_called.append(1)
open(f"{target}/cover.jpg", 'wb').close()
monkeypatch.setattr(aa, 'download_cover_art', fake_dl)
res = aa.apply_art_to_album_files([str(f)], {'album_art_url': 'http://x/y.jpg'},
{'album_name': 'B'}, folder=str(tmp_path))
assert dl_called == [1] # no embedded art → API fetch
assert res['cover_written'] is True
def test_apply_skips_cover_when_sidecar_exists(tmp_path, monkeypatch):
# cover.jpg already present → don't extract or download.
(tmp_path / 'cover.jpg').write_bytes(b'EXISTING')
f = tmp_path / '01.flac'; f.write_bytes(b'')
audio = SimpleNamespace(pictures=[SimpleNamespace(data=b'X')], tags=None, save=lambda: None)
monkeypatch.setattr(aa, 'get_mutagen_symbols', lambda: _fake_symbols(audio))
monkeypatch.setattr(aa, 'embed_album_art_metadata', lambda *a, **k: True)
dl_called = []
monkeypatch.setattr(aa, 'download_cover_art', lambda *a, **k: dl_called.append(1))
aa.apply_art_to_album_files([str(f)], {}, {}, folder=str(tmp_path))
assert (tmp_path / 'cover.jpg').read_bytes() == b'EXISTING' # untouched
assert dl_called == []
def test_apply_cover_falls_back_to_file_dir_when_folder_doesnt_exist(tmp_path, monkeypatch):
# The Sokhi bug: the caller passed the raw DB folder (e.g. Jellyfin's
# /data/music/...) which doesn't exist in the container, so the cover.jpg
# write was silently skipped. Now it falls back to the real directory of
# the files and writes there anyway.
real_dir = tmp_path / 'real'
real_dir.mkdir()
f = real_dir / '01.flac'; f.write_bytes(b'')
audio = SimpleNamespace(pictures=[SimpleNamespace(data=b'EMB')], tags=None, save=lambda: None)
monkeypatch.setattr(aa, 'get_mutagen_symbols', lambda: _fake_symbols(audio))
monkeypatch.setattr(aa, 'embed_album_art_metadata', lambda *a, **k: True)
monkeypatch.setattr(aa, 'download_cover_art', lambda *a, **k: None)
res = aa.apply_art_to_album_files([str(f)], {}, {},
folder='/data/music/does/not/exist/in/container')
assert (real_dir / 'cover.jpg').read_bytes() == b'EMB' # written to the REAL dir
assert res['cover_written'] is True

View file

@ -0,0 +1,137 @@
"""Atomic tag saves: an interrupted/OOM-killed save must never destroy the
user's file (#819 — CubeComming's hi-res FLACs imported to an empty shell).
save_audio_file writes the modified tags into a temp COPY, verifies it's still
valid audio, then os.replace()s it in the original is untouched until that
atomic swap.
"""
from __future__ import annotations
import os
import shutil
import subprocess
from types import SimpleNamespace
import pytest
from core.metadata.common import save_audio_file
def _symbols(file_length):
"""Symbols whose File() reports the given decoded length (None → invalid)."""
return SimpleNamespace(
ID3=type("ID3", (), {}), FLAC=type("FLAC", (), {}),
File=lambda p: (None if file_length is None
else SimpleNamespace(info=SimpleNamespace(length=file_length))),
)
def test_atomic_replace_on_success(tmp_path):
f = tmp_path / "song.flac"
f.write_bytes(b"ORIGINAL-AUDIO")
saved_to = []
class Audio:
filename = str(f)
tags = None
def save(self, target=None, **k):
saved_to.append(target)
# mimic mutagen writing modified tags into the temp copy
with open(target, "ab") as h:
h.write(b"+TAGS")
save_audio_file(Audio(), _symbols(180.0))
assert f.read_bytes() == b"ORIGINAL-AUDIO+TAGS" # replaced with the tagged copy
assert saved_to == [str(f) + ".sstmp"] # wrote the temp, NOT in place
assert not (tmp_path / "song.flac.sstmp").exists() # temp cleaned up
def test_original_survives_save_failure(tmp_path):
# The #819 scenario: the save blows up mid-write. The original must be intact.
f = tmp_path / "song.flac"
f.write_bytes(b"ORIGINAL")
inplace = []
class Audio:
filename = str(f)
tags = None
def save(self, target=None, **k):
if target is not None:
raise OSError("simulated interrupted/OOM save")
inplace.append(True) # fallback in-place save (writes nothing here)
save_audio_file(Audio(), _symbols(180.0))
assert f.read_bytes() == b"ORIGINAL" # never destroyed
assert not (tmp_path / "song.flac.sstmp").exists() # temp removed
assert inplace == [True] # fell back to in-place
def test_corrupt_temp_rejected(tmp_path):
# save-to-temp "succeeds" but produces a file with no audio → must NOT
# replace the original; fall back instead.
f = tmp_path / "song.flac"
f.write_bytes(b"ORIGINAL")
inplace = []
class Audio:
filename = str(f)
tags = None
def save(self, target=None, **k):
if target is None:
inplace.append(True)
save_audio_file(Audio(), _symbols(0)) # File().info.length == 0 → invalid
assert f.read_bytes() == b"ORIGINAL"
assert not (tmp_path / "song.flac.sstmp").exists()
assert inplace == [True]
def test_no_filename_plain_save():
saves = []
class Audio:
filename = None
tags = None
def save(self, target=None, **k):
saves.append(target)
save_audio_file(Audio(), _symbols(180.0))
assert saves == [None] # nothing to be atomic about → plain in-place
# ── real mutagen round-trip (only if ffmpeg can make a FLAC) ──
def _make_flac(path):
ff = shutil.which("ffmpeg")
if not ff:
return False
r = subprocess.run(
[ff, "-f", "lavfi", "-i", "sine=frequency=440:duration=1", "-y", str(path)],
capture_output=True)
return r.returncode == 0 and os.path.getsize(path) > 0
def test_real_flac_atomic_save_preserves_audio(tmp_path):
from mutagen.flac import FLAC
f = tmp_path / "real.flac"
if not _make_flac(f):
pytest.skip("ffmpeg unavailable — cannot build a real FLAC")
orig_len = FLAC(str(f)).info.length
audio = FLAC(str(f))
audio["title"] = "Atomic Test"
save_audio_file(audio, SimpleNamespace(ID3=type("ID3", (), {}), FLAC=FLAC,
File=__import__("mutagen").File))
reread = FLAC(str(f))
assert reread.info.length == pytest.approx(orig_len, abs=0.05) # audio intact
assert reread["title"] == ["Atomic Test"] # tag written
assert not (tmp_path / "real.flac.sstmp").exists()

View file

@ -128,3 +128,63 @@ def test_artist_action_without_found_artist_url_fails_cleanly(tmp_path):
assert res['success'] is False
album_thumb, artist_thumb = _thumbs(w)
assert artist_thumb == 'http://old/artist.jpg' # nothing changed
# ── apply-result message accuracy (Sokhi/Boulder: "read-only?" on writable fs) ──
def _add_track(w, path):
conn = w.db._get_connection()
c = conn.cursor()
c.execute("INSERT INTO tracks VALUES ('t1', 'al1', ?)", (str(path),))
conn.commit()
conn.close()
def _apply_returns(monkeypatch, **art_result):
import core.metadata.art_apply as aa
base = {'embedded': 0, 'failed': 0, 'skipped': 0, 'cover_written': False, 'read_only_fs': False}
base.update(art_result)
monkeypatch.setattr(aa, 'apply_art_to_album_files', lambda *a, **k: base)
def test_already_arted_reports_present_not_readonly(tmp_path, monkeypatch):
# The bug: all files already had art (skipped) → embedded 0, cover 0 → the
# old message cried "(read-only?)" on a perfectly writable library.
w = _worker(tmp_path)
f = tmp_path / 'song.mp3'; f.write_bytes(b'x')
_add_track(w, f)
_apply_returns(monkeypatch, skipped=1)
res = w._fix_missing_cover_art('album', 'al1', None, {**DETAILS, '_fix_action': 'album'})
assert res['success'] is True
assert 'already present' in res['message'].lower()
assert 'read-only' not in res['message'].lower()
def test_failed_writes_blame_permissions_not_readonly(tmp_path, monkeypatch):
w = _worker(tmp_path)
f = tmp_path / 'song.mp3'; f.write_bytes(b'x')
_add_track(w, f)
_apply_returns(monkeypatch, failed=1)
res = w._fix_missing_cover_art('album', 'al1', None, {**DETAILS, '_fix_action': 'album'})
assert res['success'] is True
assert 'permission' in res['message'].lower()
assert 'read-only' not in res['message'].lower()
def test_genuine_read_only_still_hard_fails(tmp_path, monkeypatch):
w = _worker(tmp_path)
f = tmp_path / 'song.mp3'; f.write_bytes(b'x')
_add_track(w, f)
_apply_returns(monkeypatch, read_only_fs=True)
res = w._fix_missing_cover_art('album', 'al1', None, {**DETAILS, '_fix_action': 'album'})
assert res['success'] is False
assert 'read-only' in res['error'].lower()
def test_embedded_success_message(tmp_path, monkeypatch):
w = _worker(tmp_path)
f = tmp_path / 'song.mp3'; f.write_bytes(b'x')
_add_track(w, f)
_apply_returns(monkeypatch, embedded=1)
res = w._fix_missing_cover_art('album', 'al1', None, {**DETAILS, '_fix_action': 'album'})
assert res['success'] is True and 'embedded into 1' in res['message']

View file

@ -0,0 +1,135 @@
"""Dead File Cleaner — mass-false-positive guard (#828).
macstainless: a Plex-on-macOS user running SoulSync in Docker had all 5,250
tracks flagged "dead" because their stored /Volumes/... paths don't exist inside
the container. The resolver returning None means "couldn't find it at any known
base dir" — for a mis-mounted library that's EVERY track, not a real deletion.
The job now refuses to flag when a large fraction is unresolvable (a path-mapping
problem) and reports it as such, mirroring the existing transfer-folder abort.
"""
from __future__ import annotations
from core.repair_jobs.base import JobContext
from core.repair_jobs.dead_file_cleaner import DeadFileCleanerJob
class _Cur:
def __init__(self, rows):
self._rows = rows
def execute(self, *a, **k):
pass
def fetchall(self):
return self._rows
def fetchone(self):
return [len(self._rows)]
def close(self):
pass
class _Conn:
def __init__(self, rows):
self._rows = rows
def cursor(self):
return _Cur(self._rows)
def close(self):
pass
class _Db:
def __init__(self, rows):
self._rows = rows
def _get_connection(self):
return _Conn(self._rows)
class _Cfg:
def __init__(self, overrides=None):
self._o = overrides or {}
def get(self, key, default=None):
return self._o.get(key, default)
def _row(i, path):
# (track_id, title, artist, album, file_path, album_thumb, artist_thumb)
return (i, f"Track {i}", "Yellowcard", "Ocean Avenue", path, None, None)
def _run(rows, transfer_folder, cfg_overrides=None):
findings = []
cfg = _Cfg({'soulseek.download_path': '', **(cfg_overrides or {})})
ctx = JobContext(
db=_Db(rows),
transfer_folder=str(transfer_folder),
config_manager=cfg,
create_finding=lambda **kw: (findings.append(kw) or True),
)
res = DeadFileCleanerJob().scan(ctx)
return res, findings
def test_mass_unresolvable_aborts_without_findings(tmp_path):
# 30 tracks all pointing to a /Volumes path that doesn't exist in this env
# -> systemic path problem -> abort, zero findings, one error.
rows = [_row(i, f"/Volumes/Core/Music/Plex/Yellowcard/{i}.mp3") for i in range(30)]
res, findings = _run(rows, tmp_path)
assert findings == []
assert res.findings_created == 0
assert res.errors >= 1
assert res.scanned == 30
def test_few_unresolvable_creates_findings(tmp_path):
# 4 real (resolvable) files + 1 genuinely missing -> fraction 0.2 < 0.5 ->
# the one dead file IS reported.
rows = []
for i in range(4):
f = tmp_path / f"real_{i}.mp3"
f.write_text("x")
rows.append(_row(i, str(f)))
rows.append(_row(99, "/no/such/path/dead.mp3"))
res, findings = _run(rows, tmp_path,
{'repair.jobs.dead_file_cleaner.min_tracks_for_guard': 4})
assert res.findings_created == 1
assert len(findings) == 1
assert findings[0]['entity_id'] == '99'
assert res.errors == 0
def test_small_library_all_dead_still_reports(tmp_path):
# 3 dead tracks, below the default min_tracks_for_guard (25) -> guard doesn't
# apply -> all 3 reported (a tiny library can legitimately be all-dead).
rows = [_row(i, f"/no/such/{i}.mp3") for i in range(3)]
res, findings = _run(rows, tmp_path)
assert res.findings_created == 3
def test_guard_thresholds_configurable(tmp_path):
# Lower min to 4; all 4 dead -> fraction 1.0 >= 0.5 -> abort.
rows = [_row(i, f"/no/such/{i}.mp3") for i in range(4)]
res, findings = _run(rows, tmp_path,
{'repair.jobs.dead_file_cleaner.min_tracks_for_guard': 4})
assert res.findings_created == 0
assert res.errors >= 1
def test_healthy_library_no_abort_no_findings(tmp_path):
# 30 fully-resolvable tracks -> 0 dead -> neither aborts nor flags anything.
rows = []
for i in range(30):
f = tmp_path / f"ok_{i}.mp3"
f.write_text("x")
rows.append(_row(i, str(f)))
res, findings = _run(rows, tmp_path)
assert res.findings_created == 0
assert res.errors == 0
assert res.scanned == 30
assert findings == []

View file

@ -0,0 +1,114 @@
"""Reuse an album's existing on-disk folder for new downloads (#829).
Tacobell444: tracks added to an album across batches split into different folders
when $albumtype/$year drift. The resolver finds the album's existing single
folder (under the transfer dir) so the new track joins it. These pin the safety
rails: strict match, transfer-dir-only, single-folder-only, id-first.
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from core.library.existing_album_folder import resolve_existing_album_folder
class _FakeDb:
def __init__(self, album=None, album_conf=0.0, tracks=None, by_spotify=None):
self._album = album
self._album_conf = album_conf
self._tracks = tracks or []
self._by_spotify = by_spotify
def get_album_by_spotify_album_id(self, sid):
return self._by_spotify
def check_album_exists_with_editions(self, title, artist, confidence_threshold=0.8,
expected_track_count=None, server_source=None, **kw):
return (self._album, self._album_conf)
def get_tracks_by_album(self, album_id):
return self._tracks
def _track(path):
return SimpleNamespace(file_path=path)
def _album(id=1, title="Ocean Avenue"):
return SimpleNamespace(id=id, title=title)
def _mkfile(folder, name):
folder.mkdir(parents=True, exist_ok=True)
f = folder / name
f.write_text("x")
return str(f)
def test_reuses_single_folder_under_transfer(tmp_path):
album_dir = tmp_path / "Yellowcard - Ocean Avenue"
f1 = _mkfile(album_dir, "01 - Way Away.mp3")
f2 = _mkfile(album_dir, "02 - Breathing.mp3")
db = _FakeDb(album=_album(), album_conf=0.95, tracks=[_track(f1), _track(f2)])
out = resolve_existing_album_folder(
db=db, transfer_dir=str(tmp_path),
album_name="Ocean Avenue", album_artist="Yellowcard")
assert out == os.path.normpath(str(album_dir))
def test_no_match_returns_none(tmp_path):
db = _FakeDb(album=None, album_conf=0.0)
assert resolve_existing_album_folder(
db=db, transfer_dir=str(tmp_path), album_name="X", album_artist="Y") is None
def test_below_strict_threshold_returns_none(tmp_path):
f = _mkfile(tmp_path / "A", "1.mp3")
# 0.80 < the resolver's 0.85 strict gate -> not reused.
db = _FakeDb(album=_album(), album_conf=0.80, tracks=[_track(f)])
assert resolve_existing_album_folder(
db=db, transfer_dir=str(tmp_path), album_name="A", album_artist="B") is None
def test_multi_folder_defers_to_template(tmp_path):
f1 = _mkfile(tmp_path / "Album" / "Disc 01", "1.mp3")
f2 = _mkfile(tmp_path / "Album" / "Disc 02", "1.mp3")
db = _FakeDb(album=_album(), album_conf=0.95, tracks=[_track(f1), _track(f2)])
assert resolve_existing_album_folder(
db=db, transfer_dir=str(tmp_path), album_name="A", album_artist="B") is None
def test_folder_outside_transfer_returns_none(tmp_path):
f = _mkfile(tmp_path / "outside", "1.mp3")
transfer = tmp_path / "transfer"
transfer.mkdir()
db = _FakeDb(album=_album(), album_conf=0.95, tracks=[_track(f)])
assert resolve_existing_album_folder(
db=db, transfer_dir=str(transfer), album_name="A", album_artist="B") is None
def test_id_first_match_skips_name_lookup(tmp_path):
album_dir = tmp_path / "Album"
f = _mkfile(album_dir, "1.mp3")
# name match would FAIL (album=None); the stored spotify id hits.
db = _FakeDb(album=None, album_conf=0.0, tracks=[_track(f)], by_spotify=_album())
out = resolve_existing_album_folder(
db=db, transfer_dir=str(tmp_path), spotify_album_id="sp123",
album_name="X", album_artist="Y")
assert out == os.path.normpath(str(album_dir))
def test_missing_transfer_dir_returns_none(tmp_path):
db = _FakeDb(album=_album(), album_conf=0.95, tracks=[])
assert resolve_existing_album_folder(
db=db, transfer_dir=str(tmp_path / "nope"), album_name="A", album_artist="B") is None
def test_album_with_no_files_on_disk_returns_none(tmp_path):
# Album matched but its tracks have no resolvable file -> nothing to reuse.
db = _FakeDb(album=_album(), album_conf=0.95,
tracks=[_track("/gone/1.mp3"), _track(None)])
assert resolve_existing_album_folder(
db=db, transfer_dir=str(tmp_path), album_name="A", album_artist="B") is None

View file

@ -0,0 +1,31 @@
"""JellyfinAlbum must expose a cover-image thumb so the library scan stores
albums.thumb_url (mirroring JellyfinArtist). Without it the whole library reads
back with empty album thumbs blank art in the UI + the Cover Art Filler
flagging every album as "missing cover art".
"""
from __future__ import annotations
from core.jellyfin_client import JellyfinAlbum, JellyfinArtist
class _Client:
pass
def test_album_has_primary_image_thumb():
alb = JellyfinAlbum({'Id': 'abc123', 'Name': 'For You'}, _Client())
assert alb.thumb == '/Items/abc123/Images/Primary'
def test_album_thumb_none_without_id():
alb = JellyfinAlbum({'Name': 'No Id Album'}, _Client())
assert alb.thumb is None
def test_album_thumb_matches_artist_shape():
# Same URL shape the artist uses (already proven to display) — just the
# album's own item id.
art = JellyfinArtist({'Id': 'xyz', 'Name': 'A'}, _Client())
alb = JellyfinAlbum({'Id': 'xyz', 'Name': 'B'}, _Client())
assert alb.thumb == art.thumb

View file

@ -0,0 +1,115 @@
"""Server-side launch-PIN gate (#832, Beckid).
The admin PIN was a client-side overlay only; removing the div gave full API
access. request_is_locked() is the pure allow/deny decision the before_request
gate uses. These pin the exact matrix so the lock can't silently regress to
'advisory'.
"""
from __future__ import annotations
import pytest
from core.security.launch_lock import request_is_locked
def L(path, method='GET', require_pin=True, pin_verified=False):
return request_is_locked(path, method, require_pin=require_pin, pin_verified=pin_verified)
# ── gate disabled / already verified → never locks ──────────────────────────
def test_no_lock_when_require_pin_off():
# The default config: nothing is gated.
assert L('/api/downloads/start', 'POST', require_pin=False) is False
assert L('/api/settings', 'POST', require_pin=False) is False
def test_no_lock_when_session_verified():
assert L('/api/downloads/start', 'POST', pin_verified=True) is False
assert L('/api/v1/api-keys-internal/generate', 'POST', pin_verified=True) is False
# ── locked session blocks the app ───────────────────────────────────────────
@pytest.mark.parametrize('path,method', [
('/api/watchlist/artists', 'GET'),
('/api/downloads/start', 'POST'),
('/api/settings', 'POST'),
('/api/wishlist/tracks', 'GET'),
('/api/library/artists', 'GET'),
('/socket.io/', 'GET'),
])
def test_locked_blocks_data_and_action_endpoints(path, method):
assert L(path, method) is True
def test_locked_blocks_profile_mutation():
# Creating/editing/deleting profiles or setting PINs must NOT be reachable
# pre-auth — else an attacker mints an admin or rewrites the PIN.
assert L('/api/profiles', 'POST') is True # create
assert L('/api/profiles/2', 'PUT') is True # edit
assert L('/api/profiles/2', 'DELETE') is True # delete
assert L('/api/profiles/1/set-pin', 'POST') is True
def test_locked_blocks_internal_key_minting():
# The documented "no auth required" key-management endpoints are the
# secondary bypass: mint a key, walk in via the public API. Must be locked.
assert L('/api/v1/api-keys-internal', 'GET') is True
assert L('/api/v1/api-keys-internal/generate', 'POST') is True
assert L('/api/v1/api-keys-internal/revoke/abc', 'DELETE') is True
# ── locked session still allows the unlock flow + shell ─────────────────────
@pytest.mark.parametrize('path', ['/', '/static/init.js', '/static/dist/app.js', '/favicon.ico'])
def test_locked_allows_page_shell_and_assets(path):
assert L(path) is False
def test_locked_allows_unlock_flow():
assert L('/api/profiles/current', 'GET') is False
assert L('/api/profiles', 'GET') is False # picker list
assert L('/api/profiles/select', 'POST') is False
assert L('/api/profiles/verify-launch-pin', 'POST') is False
assert L('/api/profiles/reset-pin-via-credential', 'POST') is False
assert L('/api/profiles/logout', 'POST') is False
def test_locked_allows_keyauthed_public_api():
# The public REST API carries its own @require_api_key, so a launch-locked
# UI must not break a legitimate headless key holder.
assert L('/api/v1/search', 'GET') is False
assert L('/api/v1/playlists', 'GET') is False
def test_method_matters_for_shared_paths():
# GET /api/profiles is the picker (allowed); POST /api/profiles is create
# (blocked). Same path, opposite verdicts.
assert L('/api/profiles', 'GET') is False
assert L('/api/profiles', 'POST') is True
# ── blocked navigations bounce to the lock screen, not JSON (#832 follow-up) ──
from core.security.launch_lock import is_html_navigation # noqa: E402
def test_browser_navigation_is_detected():
# Address-bar / link / refresh — gets redirected to the root lock screen.
assert is_html_navigation('GET', 'text/html,application/xhtml+xml,*/*', '') is True
assert is_html_navigation('GET', '*/*', 'navigate') is True
def test_programmatic_fetch_is_not_navigation():
# fetch()/XHR want JSON so the frontend can react to the 401.
assert is_html_navigation('GET', '*/*', 'cors') is False
assert is_html_navigation('GET', 'application/json', 'same-origin') is False
assert is_html_navigation('GET', '', '') is False
def test_non_get_is_never_navigation():
# A programmatic POST/DELETE always gets JSON, never a redirect.
assert is_html_navigation('POST', 'text/html', 'navigate') is False
assert is_html_navigation('DELETE', 'text/html', '') is False

View file

@ -281,3 +281,151 @@ def test_result_matches_unit():
# no artist on result → require exact title
assert m({'title': 'Album'}, 'Album', 'Artist')
assert not m({'title': 'Album Deluxe'}, 'Album', 'Artist')
# ── disk-art check must run on the RESOLVED path (flags-every-album bug) ──
def _add_track(conn, path):
conn.execute(
"INSERT INTO tracks (id, album_id, file_path, disc_number, track_number) "
"VALUES (1, 1, ?, 1, 1)", (path,))
conn.commit()
def test_scan_checks_disk_art_on_resolved_path(monkeypatch):
# Album already has a DB thumb (db not missing) and a track whose DB path
# only resolves via mapping. The disk-art check must run on the RESOLVED
# path — checking the raw path would fail on path-mapped setups and flag
# the whole library while the apply (which resolves) finds art present.
conn = _make_db((1, 'Album', 1, 'https://has/thumb', None, None, None, None, None))
_add_track(conn, '/plex/raw/song.flac')
context = _make_context(conn)
checked = {}
monkeypatch.setattr(mca, 'resolve_library_file_path',
lambda raw, **k: '/resolved/song.flac' if raw == '/plex/raw/song.flac' else None)
monkeypatch.setattr(mca, 'file_has_embedded_art',
lambda p: checked.update(path=p) or True)
monkeypatch.setattr(mca, 'folder_has_cover_sidecar', lambda d: True) # has cover.jpg too
result = mca.MissingCoverArtJob().scan(context)
assert checked.get('path') == '/resolved/song.flac' # resolved, not raw
assert result.findings_created == 0 # embedded + cover.jpg → not flagged
def test_scan_unresolvable_path_not_flagged_disk_missing(monkeypatch):
# An unreachable file (resolve → None) must NOT be claimed as "missing disk
# art" — we can't know, so don't false-flag. (Album has a thumb already.)
conn = _make_db((1, 'Album', 1, 'https://has/thumb', None, None, None, None, None))
_add_track(conn, '/gone/song.flac')
context = _make_context(conn)
monkeypatch.setattr(mca, 'resolve_library_file_path', lambda raw, **k: None)
called = []
monkeypatch.setattr(mca, 'file_has_embedded_art', lambda p: called.append(p) or False)
monkeypatch.setattr(mca, 'folder_has_cover_sidecar', lambda d: called.append(d) or False)
result = mca.MissingCoverArtJob().scan(context)
assert result.findings_created == 0 # thumb present, disk unknown → not flagged
assert called == [] # never checked art on a None path
def test_local_album_with_embedded_and_sidecar_not_flagged(monkeypatch):
# Has BOTH embedded art AND a cover.jpg — nothing missing, even with an
# empty DB thumb cache. (Boulder: don't flag albums that already have art.)
conn = _make_db((1, 'Album', 1, '', None, None, None, None, None)) # empty thumb
_add_track(conn, '/music/Album/01.flac')
context = _make_context(conn)
monkeypatch.setattr(mca, 'resolve_library_file_path', lambda raw, **k: raw)
monkeypatch.setattr(mca, 'file_has_embedded_art', lambda p: True)
monkeypatch.setattr(mca, 'folder_has_cover_sidecar', lambda d: True)
result = mca.MissingCoverArtJob().scan(context)
assert result.findings_created == 0 # has both → not "missing"
assert result.skipped == 1
def test_embedded_art_but_no_cover_jpg_is_flagged(monkeypatch):
# Sokhi: files HAVE embedded art but no cover.jpg sidecar. With cover.jpg
# enabled (default), it's flagged so the filler writes the sidecar — even
# when the API finds NO art (the apply extracts the embedded art).
conn = _make_db((1, 'Album', 1, 'https://has/thumb', None, None, None, None, None))
_add_track(conn, '/music/Album/01.flac')
context = _make_context(conn)
monkeypatch.setattr(mca, 'resolve_library_file_path', lambda raw, **k: raw)
monkeypatch.setattr(mca, 'file_has_embedded_art', lambda p: True) # embedded present
monkeypatch.setattr(mca, 'folder_has_cover_sidecar', lambda d: False) # but no cover.jpg
monkeypatch.setattr(mca, 'get_primary_source', lambda: 'spotify')
monkeypatch.setattr(mca, 'get_client_for_source', lambda s: _FakeClient()) # API finds nothing
result = mca.MissingCoverArtJob().scan(context)
assert result.findings_created == 1 # flagged for the missing sidecar
assert context.findings[0]['details']['sidecar_from_embedded'] is True
def test_local_album_without_file_art_still_flagged(monkeypatch):
# Local album whose files genuinely lack art → still flagged (real case).
# Give it a source id + findable art so a finding is created when flagged.
conn = _make_db((1, 'Album', 1, '', 'sp-album', None, None, None, None))
_add_track(conn, '/music/Album/01.flac')
context = _make_context(conn)
monkeypatch.setattr(mca, 'resolve_library_file_path', lambda raw, **k: raw)
monkeypatch.setattr(mca, 'file_has_embedded_art', lambda p: False) # no embedded art
monkeypatch.setattr(mca, 'folder_has_cover_sidecar', lambda d: False)
monkeypatch.setattr(mca, 'get_primary_source', lambda: 'spotify')
monkeypatch.setattr(mca, 'get_client_for_source', lambda s: _FakeClient(album_image='https://img/x'))
result = mca.MissingCoverArtJob().scan(context)
assert result.findings_created == 1 # files lack art → flagged
def test_media_server_only_album_empty_thumb_still_flagged(monkeypatch):
# No local files (media-server-only) + empty thumb → DB thumb is the only
# art, so still flag it.
conn = _make_db((1, 'Album', 1, '', 'sp-album', None, None, None, None)) # no track added
context = _make_context(conn)
monkeypatch.setattr(mca, 'get_primary_source', lambda: 'spotify')
monkeypatch.setattr(mca, 'get_client_for_source', lambda s: _FakeClient(album_image='https://img/x'))
result = mca.MissingCoverArtJob().scan(context)
assert result.findings_created == 1 # media-server-only + empty thumb → flagged
def test_unresolved_path_falls_back_to_raw_when_file_exists(tmp_path, monkeypatch):
# Docker case (Sokhi): the path-mapping layer returns None, but the raw DB
# path is already a real file in the container. The scan must use it as-is —
# like the apply does (`_resolve_file_path(...) or p`) — so the album's
# folder is actually checked instead of the album being skipped.
track = tmp_path / 'Album' / '01.flac'
track.parent.mkdir()
track.write_bytes(b'')
conn = _make_db((1, 'Album', 1, 'https://has/thumb', None, None, None, None, None))
_add_track(conn, str(track))
context = _make_context(conn)
monkeypatch.setattr(mca, 'resolve_library_file_path', lambda raw, **k: None) # mapping fails
monkeypatch.setattr(mca, 'file_has_embedded_art', lambda p: True) # files have art
# real folder has no cover.jpg → should flag for the sidecar
monkeypatch.setattr(mca, 'get_primary_source', lambda: 'spotify')
monkeypatch.setattr(mca, 'get_client_for_source', lambda s: _FakeClient()) # API finds nothing
result = mca.MissingCoverArtJob().scan(context)
assert result.findings_created == 1 # raw path used → folder checked → flagged
assert context.findings[0]['details']['sidecar_from_embedded'] is True
def test_unresolved_path_with_no_real_file_still_skips(tmp_path, monkeypatch):
# Guard: the raw-path fallback must NOT fire for a path that isn't a real
# file (no false "local" on a genuinely media-server-only album).
conn = _make_db((1, 'Album', 1, 'https://has/thumb', None, None, None, None, None))
_add_track(conn, '/does/not/exist/01.flac')
context = _make_context(conn)
monkeypatch.setattr(mca, 'resolve_library_file_path', lambda raw, **k: None)
called = []
monkeypatch.setattr(mca, 'file_has_embedded_art', lambda p: called.append(p) or True)
result = mca.MissingCoverArtJob().scan(context)
assert result.findings_created == 0 # no real file, db has thumb → skipped
assert called == [] # never treated as local

View file

@ -0,0 +1,96 @@
"""Confusable-tolerant path resolution (#833, the-hang-man).
The library DB stored a track title with a curly apostrophe (U+2019); the file
was written to disk with an ASCII one (U+0027). Delete rebuilt the unlink path
from the DB value, so os.path.exists missed and the file survived. find_on_disk
resolves the real on-disk name despite typographic confusables with REAL temp
files, not mocks, so it exercises the actual byte-level mismatch.
"""
from __future__ import annotations
import os
from core.library.path_resolve import fold_confusables, find_on_disk
CURLY = chr(0x2019) # right single quotation mark
ASCII = chr(0x27) # ' ascii apostrophe
# ── fold_confusables ────────────────────────────────────────────────────────
def test_curly_and_straight_apostrophe_fold_equal():
assert fold_confusables(f"I{CURLY}m Upset") == fold_confusables(f"I{ASCII}m Upset")
assert fold_confusables(f"I{CURLY}m Upset") == "I'm Upset"
def test_other_confusables_fold():
assert fold_confusables('Rock Roll') == 'Rock - Roll' # en dash
assert fold_confusables('Rock — Roll') == 'Rock - Roll' # em dash
assert fold_confusables('A “B” C') == 'A "B" C' # smart quotes
def test_fold_preserves_case_and_plain_text():
# Case must NOT be folded — case-sensitive datasets can hold names that
# differ only by case, and folding could pick the wrong file.
assert fold_confusables('Track NAME.mp3') == 'Track NAME.mp3'
assert fold_confusables('') == ''
# ── find_on_disk against real files ─────────────────────────────────────────
def test_finds_ascii_file_from_curly_db_path(tmp_path):
# On disk: ASCII apostrophe. DB/query: curly. This is the exact #833 case.
album = tmp_path / 'Drake' / 'Scorpion'
album.mkdir(parents=True)
real = album / f"01 - I{ASCII}m Upset.mp3"
real.write_text('audio')
suffix = ['Drake', 'Scorpion', f"01 - I{CURLY}m Upset.mp3"]
found = find_on_disk(str(tmp_path), suffix)
assert found is not None
assert os.path.samefile(found, real)
def test_exact_match_still_works(tmp_path):
real = tmp_path / 'Artist' / 'Album' / 'Track.mp3'
real.parent.mkdir(parents=True)
real.write_text('audio')
found = find_on_disk(str(tmp_path), ['Artist', 'Album', 'Track.mp3'])
assert found is not None and os.path.samefile(found, real)
def test_confusable_in_folder_component(tmp_path):
# The apostrophe can be in a folder name (album/artist), not just the file.
folder = tmp_path / f"Guns N{ASCII} Roses"
folder.mkdir()
real = folder / 'track.mp3'
real.write_text('audio')
found = find_on_disk(str(tmp_path), [f"Guns N{CURLY} Roses", 'track.mp3'])
assert found is not None and os.path.samefile(found, real)
def test_returns_none_for_genuinely_missing_file(tmp_path):
(tmp_path / 'Artist').mkdir()
assert find_on_disk(str(tmp_path), ['Artist', 'Nope.mp3']) is None
def test_does_not_match_a_different_track(tmp_path):
# Folding apostrophes must not collapse two genuinely different names.
(tmp_path / 'Some Other Song.mp3').write_text('x')
assert find_on_disk(str(tmp_path), [f"I{CURLY}m Upset.mp3"]) is None
def test_exact_wins_over_folded_when_both_present(tmp_path):
# If the byte-exact file exists, it's chosen even when a folded sibling also
# exists — no accidental cross-match.
exact = tmp_path / f"I{CURLY}m Upset.mp3"
other = tmp_path / f"I{ASCII}m Upset.mp3"
exact.write_text('curly')
other.write_text('ascii')
found = find_on_disk(str(tmp_path), [f"I{CURLY}m Upset.mp3"])
assert found is not None and os.path.samefile(found, exact)
def test_bad_base_dir_returns_none(tmp_path):
assert find_on_disk(str(tmp_path / 'does-not-exist'), ['x.mp3']) is None

View file

@ -138,6 +138,15 @@ def test_normalize_keeps_reconcile_from_config():
assert normalize_sync_mode('', 'reconcile') == 'reconcile'
def test_normalize_keeps_append_from_config():
# #823 — an AUTOMATED sync (mirrored auto-sync / Playlist Pipeline) passes no
# per-request mode, so it must resolve to the user's configured global mode
# instead of hardcoding 'replace' (which recreated the playlist + wiped its
# image/description). Default 'replace' users are unaffected.
assert normalize_sync_mode(None, 'append') == 'append'
assert normalize_sync_mode(None, 'replace') == 'replace'
def test_normalize_request_overrides_config():
assert normalize_sync_mode('append', 'reconcile') == 'append'
assert normalize_sync_mode('reconcile', 'replace') == 'reconcile'
@ -152,3 +161,38 @@ def test_normalize_falls_back_for_unknown():
def test_normalize_all_real_modes_pass_through():
for m in ('replace', 'append', 'reconcile'):
assert normalize_sync_mode(m, 'replace') == m
# ── plan_playlist_append (#823 round 2) ──────────────────────────────────────
# The Jellyfin/Emby + Navidrome appends deduped on `t.id`, but their track
# wrappers only define `ratingKey` — the existing-ids set was always empty, so
# every sync re-appended the full matched list (every track N times,
# "skipped 0 already present"). The planner is the now-testable dedupe.
from core.sync.playlist_edit import plan_playlist_append # noqa: E402
def test_append_skips_already_present():
# carlosjfcasero's case: second sync of an unchanged playlist adds NOTHING.
assert plan_playlist_append(['a', 'b', 'c'], ['a', 'b', 'c']) == []
def test_append_adds_only_new_in_desired_order():
assert plan_playlist_append(['a', 'c'], ['a', 'b', 'c', 'd']) == ['b', 'd']
def test_append_to_empty_playlist_adds_all():
assert plan_playlist_append([], ['a', 'b']) == ['a', 'b']
def test_append_dedupes_within_desired():
assert plan_playlist_append(['x'], ['a', 'a', 'x', 'b', 'a']) == ['a', 'b']
def test_append_stringifies_ids():
# Emby numeric ids may arrive as ints on one side and strings on the other.
assert plan_playlist_append([16607838], ['16607838', '999']) == ['999']
def test_append_ignores_empty_ids():
assert plan_playlist_append(['a'], ['', 'b']) == ['b']

View file

@ -52,7 +52,8 @@ SPLIT_MODULES = [
# Other JS files that exist in static/ but are NOT part of the split
NON_SPLIT_JS = {"setup-wizard.js", "docs.js", "helper.js", "particles.js", "worker-orbs.js",
"enrichment-manager.js", "origin-history.js", "blocklist.js"}
"enrichment-manager.js", "origin-history.js", "blocklist.js",
"watchlist-history.js"}
# Pre-existing duplicate helper functions that lived in the original monolith.
# In a plain <script> context the last-loaded declaration wins. These are NOT

View file

@ -173,13 +173,19 @@ class TestJellyfinAppendToPlaylist:
mock_create.assert_called_once_with("Test", new_tracks)
def test_filters_out_already_present_tracks(self):
"""Reporter's exact case for Jellyfin — only new GUIDs go in."""
"""Reporter's exact case for Jellyfin — only new GUIDs go in.
#823 round 2: existing ids now come from the canonical
/Playlists/{id}/Items request ({'Items':[{'Id': ...}]}) the old
get_playlist_tracks path returned JellyfinTrack objects that only
define `ratingKey`, so the previous `t.id` dedupe was always empty and
every sync re-appended everything. These mocks pin the REAL shape."""
client = _make_jellyfin_client()
existing_playlist = SimpleNamespace(id='pl-1')
existing_tracks = [
SimpleNamespace(id='aaaaaaaa-bbbb-cccc-dddd-000000000001'),
SimpleNamespace(id='aaaaaaaa-bbbb-cccc-dddd-000000000002'),
]
items_resp = {'Items': [
{'Id': 'aaaaaaaa-bbbb-cccc-dddd-000000000001'},
{'Id': 'aaaaaaaa-bbbb-cccc-dddd-000000000002'},
]}
incoming = [
SimpleNamespace(id='aaaaaaaa-bbbb-cccc-dddd-000000000001'), # present
SimpleNamespace(id='aaaaaaaa-bbbb-cccc-dddd-000000000003'), # NEW
@ -194,7 +200,7 @@ class TestJellyfinAppendToPlaylist:
with patch.object(client, 'ensure_connection', return_value=True), \
patch.object(client, 'get_playlist_by_name', return_value=existing_playlist), \
patch.object(client, 'get_playlist_tracks', return_value=existing_tracks), \
patch.object(client, '_make_request', return_value=items_resp), \
patch.object(client, '_is_valid_guid', return_value=True), \
patch('core.jellyfin_client.requests.post', side_effect=fake_post):
result = client.append_to_playlist("Test", incoming)
@ -206,9 +212,26 @@ class TestJellyfinAppendToPlaylist:
def test_short_circuits_when_no_new_tracks(self):
client = _make_jellyfin_client()
existing_playlist = SimpleNamespace(id='pl-1')
existing_tracks = [SimpleNamespace(id='guid-1')]
items_resp = {'Items': [{'Id': 'guid-1'}]}
with patch.object(client, 'ensure_connection', return_value=True), \
patch.object(client, 'get_playlist_by_name', return_value=existing_playlist), \
patch.object(client, '_make_request', return_value=items_resp), \
patch.object(client, '_is_valid_guid', return_value=True), \
patch('core.jellyfin_client.requests.post') as mock_post:
result = client.append_to_playlist("Test", [SimpleNamespace(id='guid-1')])
assert result is True
mock_post.assert_not_called()
def test_falls_back_to_ratingkey_tracks_when_items_request_fails(self):
"""When /Playlists/{id}/Items fails, dedupe falls back to
get_playlist_tracks reading ratingKey (the attr that EXISTS on
JellyfinTrack), not the never-present `.id` of the original bug."""
client = _make_jellyfin_client()
existing_playlist = SimpleNamespace(id='pl-1')
existing_tracks = [SimpleNamespace(ratingKey='guid-1')]
with patch.object(client, 'ensure_connection', return_value=True), \
patch.object(client, 'get_playlist_by_name', return_value=existing_playlist), \
patch.object(client, '_make_request', return_value=None), \
patch.object(client, 'get_playlist_tracks', return_value=existing_tracks), \
patch.object(client, '_is_valid_guid', return_value=True), \
patch('core.jellyfin_client.requests.post') as mock_post:
@ -221,7 +244,7 @@ class TestJellyfinAppendToPlaylist:
existing_playlist = SimpleNamespace(id='pl-1')
with patch.object(client, 'ensure_connection', return_value=True), \
patch.object(client, 'get_playlist_by_name', return_value=existing_playlist), \
patch.object(client, 'get_playlist_tracks', return_value=[]), \
patch.object(client, '_make_request', return_value={'Items': []}), \
patch.object(client, '_is_valid_guid', return_value=True), \
patch('core.jellyfin_client.requests.post',
return_value=SimpleNamespace(status_code=500, text='server error')):
@ -256,9 +279,12 @@ class TestNavidromeAppendToPlaylist:
mock_create.assert_called_once()
def test_filters_out_already_present_tracks_and_calls_subsonic(self):
# #823 round 2: existing tracks are NavidromeTrack objects, which only
# define `ratingKey` — the old `t.id` dedupe was always empty. These
# mocks pin the REAL attribute so the dedupe is actually exercised.
client = _make_navidrome_client()
existing_playlists = [SimpleNamespace(id='pl-1', title='Test')]
existing_tracks = [SimpleNamespace(id='100'), SimpleNamespace(id='101')]
existing_tracks = [SimpleNamespace(ratingKey='100'), SimpleNamespace(ratingKey='101')]
incoming = [
SimpleNamespace(id='100'), # present
SimpleNamespace(id='102'), # NEW
@ -287,7 +313,7 @@ class TestNavidromeAppendToPlaylist:
def test_short_circuits_when_no_new_tracks(self):
client = _make_navidrome_client()
existing_playlists = [SimpleNamespace(id='pl-1', title='Test')]
existing_tracks = [SimpleNamespace(id='100')]
existing_tracks = [SimpleNamespace(ratingKey='100')]
with patch.object(client, 'ensure_connection', return_value=True), \
patch.object(client, 'get_playlists_by_name', return_value=existing_playlists), \
patch.object(client, 'get_playlist_tracks', return_value=existing_tracks), \

View file

@ -0,0 +1,111 @@
"""Settings-secret redaction for GET /api/settings (#832 follow-up).
GET /api/settings used to ship the DECRYPTED config to the browser every API
key, token, and password in cleartext. redacted_config() masks configured
secrets with a sentinel; set() refuses to let that round-tripped sentinel
overwrite the real value. Together: secrets never reach the client, and saving
an unchanged form never clobbers them.
ConfigManager.__init__ touches the DB, so these build instances via __new__ and
set config_data directly the methods under test only read/write that dict.
"""
from __future__ import annotations
from config.settings import ConfigManager
S = ConfigManager.REDACTED_SENTINEL
def _cm(config_data):
cm = ConfigManager.__new__(ConfigManager)
cm.config_data = config_data
cm._save_config = lambda: None
return cm
# ── redacted_config: secrets out, everything else intact ────────────────────
def test_configured_secrets_are_masked():
cm = _cm({'spotify': {'client_secret': 'REAL', 'redirect_uri': 'http://x'},
'plex': {'token': 'PLEXTOK', 'url': 'http://plex'}})
r = cm.redacted_config()
assert r['spotify']['client_secret'] == S
assert r['plex']['token'] == S
# Non-secret siblings pass through untouched.
assert r['spotify']['redirect_uri'] == 'http://x'
assert r['plex']['url'] == 'http://plex'
def test_unset_secret_stays_empty_not_masked():
# An empty secret must NOT become the sentinel — the UI shows "not set".
cm = _cm({'jellyfin': {'api_key': ''}, 'navidrome': {'password': None}})
r = cm.redacted_config()
assert r['jellyfin']['api_key'] == ''
assert r['navidrome']['password'] is None
def test_dict_valued_secret_is_masked():
# OAuth session blobs (tidal/qobuz) collapse to the sentinel.
cm = _cm({'tidal_download': {'session': {'access': 'A', 'refresh': 'R'}}})
assert cm.redacted_config()['tidal_download']['session'] == S
def test_redaction_does_not_mutate_live_config():
cm = _cm({'spotify': {'client_secret': 'REAL'}})
cm.redacted_config()
assert cm.config_data['spotify']['client_secret'] == 'REAL'
def _put(cfg, path, value):
parent = cfg
keys = path.split('.')
for k in keys[:-1]:
parent = parent.setdefault(k, {})
parent[keys[-1]] = value
def _at(cfg, path):
cur = cfg
for k in path.split('.'):
cur = cur[k]
return cur
def test_every_sensitive_path_is_masked():
# Put a value at every sensitive path (any depth) — none may survive in clear.
cfg = {}
for path in ConfigManager._SENSITIVE_PATHS:
_put(cfg, path, 'VALUE')
r = _cm(cfg).redacted_config()
leaked = [p for p in ConfigManager._SENSITIVE_PATHS if _at(r, p) != S]
assert leaked == [], f"secrets shipped in cleartext: {leaked}"
# ── set() guard: the sentinel can never overwrite a real secret ─────────────
def test_sentinel_roundtrip_keeps_existing_secret():
cm = _cm({'spotify': {'client_secret': 'REAL'}})
cm.set('spotify.client_secret', S) # untouched masked field saved
assert cm.config_data['spotify']['client_secret'] == 'REAL'
def test_real_value_overwrites():
cm = _cm({'spotify': {'client_secret': 'REAL'}})
cm.set('spotify.client_secret', 'NEW')
assert cm.config_data['spotify']['client_secret'] == 'NEW'
def test_empty_value_clears_secret():
# Deliberately clearing a secret must still work (empty != sentinel).
cm = _cm({'spotify': {'client_secret': 'REAL'}})
cm.set('spotify.client_secret', '')
assert cm.config_data['spotify']['client_secret'] == ''
def test_sentinel_on_non_secret_path_writes_normally():
# The guard is scoped to sensitive paths — a literal sentinel elsewhere is
# a normal write (absurd in practice, but proves the guard isn't global).
cm = _cm({'ui_appearance': {'theme': 'dark'}})
cm.set('ui_appearance.theme', S)
assert cm.config_data['ui_appearance']['theme'] == S

View file

@ -234,3 +234,193 @@ def test_selected_but_package_missing_is_graceful():
def test_connected_ratelimited_but_no_package_no_bridge():
avail, free = _gate(authed=False, has_creds=True, selected=False, installed=False, rate_limited=True)
assert avail is False and free is False
# ── #(free album search): rank + artist-discography workaround ──────────────
from core.spotify_free_metadata import ( # noqa: E402
rank_albums_by_name, SpotifyFreeMetadataClient,
)
def test_rank_albums_by_name_orders_best_first_and_limits():
albums = [{'name': 'Random Access Memories'}, {'name': 'GNX'},
{'name': 'GNX (Deluxe)'}, {'name': 'untitled unmastered.'}]
out = rank_albums_by_name(albums, 'GNX', limit=2)
assert [a['name'] for a in out] == ['GNX', 'GNX (Deluxe)']
def test_rank_albums_by_name_handles_empty():
assert rank_albums_by_name([], 'GNX') == []
assert rank_albums_by_name(None, 'GNX') == []
def test_search_albums_via_artist_resolves_through_discography():
c = SpotifyFreeMetadataClient()
c.search_artists = lambda q, limit=5: [
{'id': 'art_other', 'name': 'Some Other Band'},
{'id': 'art_k', 'name': 'Kendrick Lamar'},
]
c.get_artist_albums_list = lambda aid, limit=50: (
[{'id': 'al1', 'name': 'DAMN.'}, {'id': 'al2', 'name': 'GNX'}]
if aid == 'art_k' else [])
out = c.search_albums_via_artist('Kendrick Lamar', 'GNX', limit=3)
assert out and out[0]['name'] == 'GNX' # picked the right artist + ranked
def test_search_albums_via_artist_empty_without_artist_or_album():
c = SpotifyFreeMetadataClient()
c.search_artists = lambda q, limit=5: [{'id': 'a', 'name': 'X'}]
assert c.search_albums_via_artist('', 'GNX') == []
assert c.search_albums_via_artist('X', '') == []
def test_search_albums_via_artist_empty_when_no_artist_match():
c = SpotifyFreeMetadataClient()
c.search_artists = lambda q, limit=5: [] # nothing found
assert c.search_albums_via_artist('Nobody', 'GNX') == []
def test_client_search_albums_uses_free_via_artist_when_active():
"""SpotifyClient.search_albums bridges album matching through Spotify Free
(artist discography) when free is active, instead of dropping to iTunes/Deezer."""
c = SpotifyClient.__new__(SpotifyClient)
fake_free = SpotifyFreeMetadataClient()
fake_free.search_albums_via_artist = lambda artist, album, limit: [
{'id': 'al2', 'name': 'GNX',
'artists': [{'name': 'Kendrick Lamar', 'id': 'art_k'}]}
]
c._free_meta_client = fake_free
fake_cache = type('C', (), {'get_search_results': lambda *a, **k: None})()
with patch.object(SpotifyClient, 'is_spotify_authenticated', return_value=False), \
patch('core.spotify_client.config_manager') as cm, \
patch('core.spotify_client._is_globally_rate_limited', return_value=False), \
patch('core.spotify_client.get_metadata_cache', return_value=fake_cache), \
patch.object(_sfm, 'spotify_free_installed', return_value=True):
cm.get_spotify_config.return_value = {}
cm.get.side_effect = lambda k, d=None: True if k == 'metadata.spotify_free' else d
results = c.search_albums('Kendrick Lamar GNX', limit=5,
artist='Kendrick Lamar', album='GNX')
assert len(results) == 1 and results[0].name == 'GNX'
assert results[0].id == 'al2'
# ── prefer-free (enrichment opt-in) + the use_spotify root-cause fix ─────────
def _free_active_with(prefer_free, installed, authed=True, rate_limited=False,
selected=False, budget=False):
c = SpotifyClient.__new__(SpotifyClient)
if prefer_free:
c._prefer_free = True
if budget:
c._budget_exhausted_use_free = True
with patch.object(SpotifyClient, 'is_spotify_authenticated', return_value=authed), \
patch('core.spotify_client.config_manager') as cm, \
patch('core.spotify_client._is_globally_rate_limited', return_value=rate_limited), \
patch.object(_sfm, 'spotify_free_installed', return_value=installed):
cm.get.side_effect = lambda k, d=None: selected if k == 'metadata.spotify_free' else d
return c._free_active()
def test_prefer_free_activates_even_when_authed_healthy_under_budget():
# The enrichment opt-in: free serves even though official is perfectly usable.
assert _free_active_with(prefer_free=True, installed=True) is True
def test_prefer_free_inert_without_package():
# Graceful: opt-in set but SpotipyFree missing -> stays on official.
assert _free_active_with(prefer_free=True, installed=False) is False
def test_no_prefer_free_authed_healthy_stays_official():
assert _free_active_with(prefer_free=False, installed=True, selected=True) is False
def _client_that_forbids_official(prefer_free=False, budget=False):
"""A SpotifyClient whose official .sp blows up if touched + a Free client that
serves albums so a passing search proves official was skipped."""
c = SpotifyClient.__new__(SpotifyClient)
if prefer_free:
c._prefer_free = True
if budget:
c._budget_exhausted_use_free = True
class _Sp:
def search(self, *a, **k):
raise AssertionError("official Spotify API must not be called when deferring to Free")
c.sp = _Sp()
fake_free = SpotifyFreeMetadataClient()
fake_free.search_albums_via_artist = lambda artist, album, limit: [
{'id': 'al2', 'name': 'GNX', 'artists': [{'name': 'Kendrick Lamar', 'id': 'art_k'}]}]
c._free_meta_client = fake_free
return c
def test_search_albums_prefers_free_when_authed_and_prefer_free_set():
"""Root-cause regression: prefer_free makes an AUTHED, healthy client defer to
Spotify Free instead of the official API. Previously the use_spotify gate
(= is_spotify_authenticated()) ignored _free_active() and hit official."""
c = _client_that_forbids_official(prefer_free=True)
fake_cache = type('C', (), {'get_search_results': lambda *a, **k: None})()
with patch.object(SpotifyClient, 'is_spotify_authenticated', return_value=True), \
patch('core.spotify_client.config_manager') as cm, \
patch('core.spotify_client._is_globally_rate_limited', return_value=False), \
patch('core.spotify_client.get_metadata_cache', return_value=fake_cache), \
patch.object(_sfm, 'spotify_free_installed', return_value=True):
cm.get_spotify_config.return_value = {'client_id': 'x', 'client_secret': 'y'}
cm.get.side_effect = lambda k, d=None: d
results = c.search_albums('Kendrick Lamar GNX', limit=5,
artist='Kendrick Lamar', album='GNX')
assert len(results) == 1 and results[0].id == 'al2' # Free served; official untouched
def test_search_albums_diverts_to_free_when_budget_exhausted_and_authed():
"""Budget→Free bridge regression: an AUTHED client that has spent the daily
budget defers to Free instead of hammering the official API (which the budget
exists to protect). This is the divert that previously never happened."""
c = _client_that_forbids_official(budget=True)
fake_cache = type('C', (), {'get_search_results': lambda *a, **k: None})()
with patch.object(SpotifyClient, 'is_spotify_authenticated', return_value=True), \
patch('core.spotify_client.config_manager') as cm, \
patch('core.spotify_client._is_globally_rate_limited', return_value=False), \
patch('core.spotify_client.get_metadata_cache', return_value=fake_cache), \
patch.object(_sfm, 'spotify_free_installed', return_value=True):
cm.get_spotify_config.return_value = {'client_id': 'x', 'client_secret': 'y'}
# metadata.spotify_free=True so the budget path's _free_available() holds
cm.get.side_effect = lambda k, d=None: True if k == 'metadata.spotify_free' else d
results = c.search_albums('Kendrick Lamar GNX', limit=5,
artist='Kendrick Lamar', album='GNX')
assert len(results) == 1 and results[0].id == 'al2'
# ── default-ON enrichment: prefer_free makes metadata available to the worker ──
def _metadata_available(prefer_free, installed, authed=False, selected=False):
c = SpotifyClient.__new__(SpotifyClient)
if prefer_free:
c._prefer_free = True
with patch.object(SpotifyClient, 'is_spotify_authenticated', return_value=authed), \
patch('core.spotify_client.config_manager') as cm, \
patch('core.spotify_client._is_globally_rate_limited', return_value=False), \
patch.object(_sfm, 'spotify_free_installed', return_value=installed):
cm.get.side_effect = lambda k, d=None: selected if k == 'metadata.spotify_free' else d
return c.is_spotify_metadata_available()
def test_prefer_free_makes_metadata_available_without_auth_or_source():
# Default-ON enrichment: the worker runs via the no-auth path on the toggle
# alone — no account connected, no 'no-auth Spotify' source selected.
assert _metadata_available(prefer_free=True, installed=True) is True
def test_prefer_free_metadata_unavailable_without_package():
assert _metadata_available(prefer_free=True, installed=False) is False
def test_interactive_metadata_availability_unaffected_by_prefer_free():
# A client WITHOUT _prefer_free (interactive/global): no auth + no source -> unavailable.
assert _metadata_available(prefer_free=False, installed=True) is False
# ...and authed is available as before.
assert _metadata_available(prefer_free=False, installed=True, authed=True) is True

View file

@ -0,0 +1,158 @@
"""#825: bracketed SUBTITLES must not block library-presence matching.
carlosjfcasero's case (round 2): the mirrored-playlist sync auto-added
'Llamando a la tierra (Serenade From the Stars)' by M-Clan to the wishlist on
every run, even though his library has the song (stored bare). The subtitle is
the song's official parenthetical — it restates no album context, so the #808
strip kept it, and the length-ratio penalty crushed the pair to ~0.14. The
sync matcher reported it missing forever AND the wishlist cleanup (the same
matcher) could never remove it.
Fix: a bracketed qualifier with no version-marker token and no new numeric
token is a subtitle compare with it stripped. Version qualifiers ('(Live)',
'(Versión 1988)', '(Dueto 2007)') still block, both EN and ES.
"""
from __future__ import annotations
import pytest
from core.text.title_match import strip_subtitle_qualifiers
from database.music_database import MusicDatabase
# ── the pure helper ──────────────────────────────────────────────────────────
def test_subtitle_is_stripped():
out = strip_subtitle_qualifiers(
'llamando a la tierra (serenade from the stars)', 'llamando a la tierra')
assert out == 'llamando a la tierra'
def test_version_markers_kept_english():
for q in ('live', 'remix', 'acoustic', 'instrumental', 'demo', 'radio edit'):
assert strip_subtitle_qualifiers(f'song ({q})', 'song') == f'song ({q})'
def test_version_markers_kept_spanish():
assert strip_subtitle_qualifiers('song (version 1988)', 'song') == 'song (version 1988)'
assert strip_subtitle_qualifiers('song (dueto 2007)', 'song') == 'song (dueto 2007)'
assert strip_subtitle_qualifiers('song (en directo en el liceu / 2008)', 'song') \
== 'song (en directo en el liceu / 2008)'
def test_new_numeric_token_kept():
# '(Pt. 2)' / '(2007)' are different releases, never subtitles.
assert strip_subtitle_qualifiers('song (pt. 2)', 'song') == 'song (pt. 2)'
assert strip_subtitle_qualifiers('song (2007)', 'song') == 'song (2007)'
def test_distinct_track_qualifiers_kept():
# '(Interlude)' etc. are SEPARATE short tracks sharing the base name —
# treating them as subtitles would wrongly count the full song as owned.
for q in ('interlude', 'intro', 'outro', 'skit', 'freestyle'):
assert strip_subtitle_qualifiers(f'song ({q})', 'song') == f'song ({q})'
def test_roman_numeral_parts_kept():
# No digits, so the numeric guard alone can't catch these.
assert strip_subtitle_qualifiers('song (pt. ii)', 'song') == 'song (pt. ii)'
assert strip_subtitle_qualifiers('song (part two)', 'song') == 'song (part two)'
assert strip_subtitle_qualifiers('song (vol. iii)', 'song') == 'song (vol. iii)'
def test_numeric_token_shared_with_other_title_is_fine():
# The digit appears on the other side too — not a new release marker.
assert strip_subtitle_qualifiers('song 2007 (the ballad)', 'song 2007') == 'song 2007'
def test_qualifier_restated_in_other_title_left_for_direct_compare():
full = 'song (the ballad)'
assert strip_subtitle_qualifiers(full, 'song (the ballad)') == full
def test_empty_and_plain_untouched():
assert strip_subtitle_qualifiers('', 'x') == ''
assert strip_subtitle_qualifiers('plain title', 'other') == 'plain title'
# ── end to end through check_track_exists (sync + cleanup contract) ──────────
@pytest.fixture()
def lib_db(tmp_path):
db = MusicDatabase(str(tmp_path / 'm.db'))
conn = db._get_connection()
c = conn.cursor()
c.execute("INSERT INTO artists (id, name, server_source) VALUES ('a1', 'M-Clan', 'jellyfin')")
c.execute("""INSERT INTO albums (id, title, artist_id, server_source)
VALUES ('al1', 'Usar y tirar', 'a1', 'jellyfin')""")
c.execute("""INSERT INTO tracks (id, album_id, artist_id, title, file_path, server_source)
VALUES ('t1', 'al1', 'a1', 'Llamando a la tierra', '/m/llamando.mp3', 'jellyfin')""")
c.execute("""INSERT INTO tracks (id, album_id, artist_id, title, file_path, server_source)
VALUES ('t2', 'al1', 'a1', 'Carolina', '/m/carolina.mp3', 'jellyfin')""")
conn.commit()
conn.close()
return db
def test_825_subtitled_search_matches_bare_library_track(lib_db):
"""The reported case verbatim: playlist title carries the subtitle, the
library stores the bare title must match (sync) and clean (cleanup)."""
match, conf = lib_db.check_track_exists(
'Llamando a la tierra (Serenade From the Stars)', 'M-Clan',
confidence_threshold=0.7, server_source='jellyfin',
)
assert match is not None and conf >= 0.7
assert match.title == 'Llamando a la tierra'
def test_825_reverse_direction_matches(lib_db):
"""Library could equally store the FULL title while the playlist has the
bare one both directions must match."""
conn = lib_db._get_connection()
c = conn.cursor()
c.execute("""INSERT INTO tracks (id, album_id, artist_id, title, file_path, server_source)
VALUES ('t3', 'al1', 'a1', 'Maggie (despierta)', '/m/maggie.mp3', 'jellyfin')""")
conn.commit()
conn.close()
match, conf = lib_db.check_track_exists(
'Maggie', 'M-Clan', confidence_threshold=0.7, server_source='jellyfin')
assert match is not None and conf >= 0.7
def test_live_version_still_blocked(lib_db):
match, conf = lib_db.check_track_exists(
'Llamando a la tierra (Live)', 'M-Clan',
confidence_threshold=0.7, server_source='jellyfin',
)
assert conf < 0.7
def test_spanish_version_qualifiers_still_blocked(lib_db):
for title in ('Carolina (Versión 1988)', 'Carolina (Dueto 2007)',
'Carolina (En Directo / 2005)'):
match, conf = lib_db.check_track_exists(
title, 'M-Clan', confidence_threshold=0.7, server_source='jellyfin')
assert conf < 0.7, title
def test_different_song_prefix_still_blocked(lib_db):
"""Non-bracketed extensions are untouched — the length penalty stands."""
match, conf = lib_db.check_track_exists(
'Carolina en mi mente y otras cosas', 'M-Clan',
confidence_threshold=0.7, server_source='jellyfin',
)
assert conf < 0.7
def test_batched_candidate_path_also_fixed(lib_db):
"""The sync matcher uses the candidate_tracks (batched) path — the fix must
apply there too, not just the SQL-variation path."""
candidates = lib_db.search_tracks(artist='M-Clan', limit=50, server_source='jellyfin')
assert candidates
match, conf = lib_db.check_track_exists(
'Llamando a la tierra (Serenade From the Stars)', 'M-Clan',
confidence_threshold=0.7, server_source='jellyfin',
candidate_tracks=candidates,
)
assert match is not None and conf >= 0.7

View file

@ -141,3 +141,78 @@ def test_write_real_value_still_overwrites(flac_path):
result = write_tags_to_file(flac_path, {'artist_name': 'Coldplay'}, embed_cover=False)
assert result['success'] is True
assert FLAC(flac_path).get('artist') == ['Coldplay']
# ---------------------------------------------------------------------------
# #824 — full release dates must not be downgraded to just the year
# ---------------------------------------------------------------------------
def test_diff_full_date_same_year_not_flagged():
# File has the full date 2023-11-03; DB has year 2023. Same year → the writer
# keeps the full date, so it must NOT show as a change.
diff = {d['field']: d for d in build_tag_diff({'year': '2023-11-03'}, {'year': 2023})}
assert diff['Year']['changed'] is False
def test_diff_different_year_still_flagged():
# A genuinely different year is still a real change.
diff = {d['field']: d for d in build_tag_diff({'year': '2022-11-03'}, {'year': 2023})}
assert diff['Year']['changed'] is True
def test_write_preserves_full_date_when_year_matches(flac_path):
audio = FLAC(flac_path)
audio['date'] = ['2023-11-03'] # file already has the full release date
audio.save()
write_tags_to_file(flac_path, {'year': 2023}, embed_cover=False) # DB knows only the year
assert FLAC(flac_path).get('date') == ['2023-11-03'] # full date preserved, NOT downgraded
def test_write_corrects_year_when_it_actually_differs(flac_path):
audio = FLAC(flac_path)
audio['date'] = ['2022']
audio.save()
write_tags_to_file(flac_path, {'year': 2023}, embed_cover=False)
assert FLAC(flac_path).get('date') == ['2023'] # wrong year still corrected
# ---------------------------------------------------------------------------
# #824 Part 2 — DB release_date (full date) is written, and wins over year
# ---------------------------------------------------------------------------
def test_write_uses_db_release_date_over_year(flac_path):
write_tags_to_file(flac_path, {'year': 2023, 'release_date': '2023-09-01'},
embed_cover=False)
assert FLAC(flac_path).get('date') == ['2023-09-01'] # full DB date written, not the year
def test_write_db_release_date_overrides_existing_file_date(flac_path):
audio = FLAC(flac_path)
audio['date'] = ['2023-11-03'] # file has a different (but same-year) date
audio.save()
write_tags_to_file(flac_path, {'year': 2023, 'release_date': '2023-09-01'},
embed_cover=False)
# The DB's explicit full date is authoritative — it replaces the file's date.
assert FLAC(flac_path).get('date') == ['2023-09-01']
def test_write_falls_back_to_year_when_no_release_date(flac_path):
write_tags_to_file(flac_path, {'year': 2023, 'release_date': None}, embed_cover=False)
assert FLAC(flac_path).get('date') == ['2023']
def test_diff_uses_release_date_when_present():
# File has a full date that differs from the DB's release_date → real change.
diff = {d['field']: d for d in build_tag_diff(
{'year': '2023-11-03'}, {'year': 2023, 'release_date': '2023-09-01'})}
assert diff['Year']['changed'] is True
assert diff['Year']['db_value'] == '2023-09-01'
# File already equals the DB release_date → no change.
diff = {d['field']: d for d in build_tag_diff(
{'year': '2023-09-01'}, {'year': 2023, 'release_date': '2023-09-01'})}
assert diff['Year']['changed'] is False

View file

@ -19,12 +19,14 @@ from unittest.mock import MagicMock
from core.tidal_download_client import TidalDownloadClient
def _make_track(name: str, album_name: str = ''):
def _make_track(name: str, album_name: str = '', version: str = ''):
"""Build a minimal duck-typed track object matching what the Tidal
SDK returns: a `name` attribute and an `album` attribute with its
own `name`."""
SDK returns: a `name` attribute, a `version` attribute (remix/live/
edit qualifier separate from the title), and an `album` attribute
with its own `name`."""
track = MagicMock()
track.name = name
track.version = version
track.album = MagicMock()
track.album.name = album_name
return track
@ -121,3 +123,121 @@ def test_extract_qualifiers_picks_up_live_unplugged():
quals = TidalDownloadClient._extract_qualifiers('Shy Away (MTV Unplugged Live)')
assert 'live' in quals
assert 'unplugged' in quals
# ──────────────────────────────────────────────────────────────────────
# track.version — Tidal stores the remix/live/edit qualifier in a
# dedicated `version` attribute, NOT in track.name. Real-world: the
# exact recording is present in the search results but was discarded
# because neither the qualifier filter nor the matcher looked at
# `version`. Cases below are real Tidal tracks (id in comments).
# ──────────────────────────────────────────────────────────────────────
def test_qualifier_in_version_field_passes():
# Tidal 124341 — name="Emerge", version="Junkie XL Remix"
track = _make_track('Emerge', album_name='#1', version='Junkie XL Remix')
assert TidalDownloadClient._track_matches_qualifiers(track, ['remix']) is True
def test_qualifier_in_version_field_radio_version():
# Tidal 122127 — name="Black Horse And The Cherry Tree",
# version="Radio Version"
track = _make_track('Black Horse And The Cherry Tree', version='Radio Version')
assert TidalDownloadClient._track_matches_qualifiers(track, ['version']) is True
def test_qualifier_missing_from_name_version_and_album_fails():
# The studio cut: nothing carries "remix" anywhere — must still reject.
track = _make_track('Emerge', album_name='#1', version='')
assert TidalDownloadClient._track_matches_qualifiers(track, ['remix']) is False
def test_empty_version_does_not_pollute_haystack():
# Regression guard: a falsy version must not let unrelated qualifiers
# match (e.g. via a stringified mock).
track = _make_track('Emerge', album_name='#1', version='')
assert TidalDownloadClient._track_matches_qualifiers(track, ['live']) is False
# ──────────────────────────────────────────────────────────────────────
# _tidal_to_track_result — folds `version` into the candidate title so
# the matcher (which scores title only) can see the qualifier.
# ──────────────────────────────────────────────────────────────────────
def _make_full_track(name, artist, version='', album='', duration=240,
isrc='X', tid=1):
track = MagicMock()
track.id = tid
track.name = name
track.version = version
track.artist = MagicMock()
track.artist.name = artist
track.artist.id = 99
track.album = MagicMock()
track.album.name = album
track.duration = duration
track.track_num = 1
track.isrc = isrc
track.bpm = 0
track.copyright = ''
return track
_QINFO = {'codec': 'flac', 'bitrate': 1411}
def test_version_folded_into_title():
track = _make_full_track('Emerge', 'Fischerspooner', version='Junkie XL Remix')
result = TidalDownloadClient._tidal_to_track_result(None, track, _QINFO)
assert result.title == 'Emerge (Junkie XL Remix)'
def test_no_version_leaves_title_unchanged():
track = _make_full_track('Emerge', 'Fischerspooner', version='')
result = TidalDownloadClient._tidal_to_track_result(None, track, _QINFO)
assert result.title == 'Emerge'
def test_version_already_in_name_not_duplicated():
# Some Tidal tracks redundantly carry the version in the name too;
# don't produce "Song (Remix) (Remix)".
track = _make_full_track('Song (Remix)', 'Artist', version='Remix')
result = TidalDownloadClient._tidal_to_track_result(None, track, _QINFO)
assert result.title == 'Song (Remix)'
# ──────────────────────────────────────────────────────────────────────
# End-to-end: folding version in is what lets MusicMatchingEngine accept
# the exact recording. Before the fix the bare name scores below the
# 0.60 gate; after it, the full title matches. Real repro cases.
# ──────────────────────────────────────────────────────────────────────
import pytest
from core.matching_engine import MusicMatchingEngine
# (wanted title, artist, tidal name, tidal version)
_REPROS = [
('Emerge (Junkie XL Remix)', 'Fischerspooner', 'Emerge', 'Junkie XL Remix'),
('We Are The People (Shazam Remix)', 'Empire Of The Sun', 'We Are The People', 'Shazam Remix'),
('Black Horse And The Cherry Tree (Radio Version)', 'KT Tunstall', 'Black Horse And The Cherry Tree', 'Radio Version'),
('All Night (Live @ Pukkelpop)', 'Parov Stelar', 'All Night', 'Live @ Pukkelpop'),
('Fleur de Lille (Extended)', 'Parov Stelar', 'Fleur de Lille', 'Extended'),
]
@pytest.mark.parametrize('wanted,artist,tidal_name,tidal_version', _REPROS)
def test_version_fold_lets_matcher_accept(wanted, artist, tidal_name, tidal_version):
me = MusicMatchingEngine()
track = _make_full_track(tidal_name, artist, version=tidal_version)
folded = TidalDownloadClient._tidal_to_track_result(None, track, _QINFO).title
# Before the fix the candidate title was the bare Tidal name → rejected.
bare_conf, _ = me.score_track_match(wanted, [artist], 0, tidal_name, [artist], 0)
# After the fix it's "Name (Version)" → accepted.
folded_conf, _ = me.score_track_match(wanted, [artist], 0, folded, [artist], 0)
assert folded_conf >= 0.60, f'{wanted!r}: folded {folded_conf:.2f} should clear 0.60'
assert folded_conf > bare_conf, (
f'{wanted!r}: folding version in ({folded_conf:.2f}) must beat bare name '
f'({bare_conf:.2f})')

View file

@ -0,0 +1,114 @@
"""Tidal manifest fetch backs off on HTTP 429 instead of instant-failing.
Tidal aggressively rate-limits the trackManifests endpoint. The bare
request previously failed 429 immediately, burning the quality tier and
re-queueing the track, which hammered Tidal again a self-amplifying
storm (thousands of 429s, downloads stalled). These pin the backoff:
retry on 429, honour Retry-After, give up after a bounded number of
attempts, bail on shutdown, and never retry a normal 4xx.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from core.tidal_download_client import TidalDownloadClient
def _client():
# Bypass __init__ (which builds a tidalapi session + mkdirs); we only
# exercise the pure HTTP-retry helpers.
c = TidalDownloadClient.__new__(TidalDownloadClient)
c.shutdown_check = None
return c
def _resp(status, retry_after=None):
r = MagicMock()
r.status_code = status
r.headers = {'Retry-After': str(retry_after)} if retry_after is not None else {}
return r
# ── _retry_after_seconds ──────────────────────────────────────────────
def test_exponential_backoff_without_header():
assert TidalDownloadClient._retry_after_seconds(_resp(429), 0) == 2.0
assert TidalDownloadClient._retry_after_seconds(_resp(429), 1) == 4.0
assert TidalDownloadClient._retry_after_seconds(_resp(429), 2) == 8.0
def test_backoff_capped():
# 2 * 2**10 = 2048 → capped at 30.
assert TidalDownloadClient._retry_after_seconds(_resp(429), 10) == 30.0
def test_retry_after_header_honoured():
assert TidalDownloadClient._retry_after_seconds(_resp(429, retry_after=7), 0) == 7.0
def test_retry_after_header_capped():
assert TidalDownloadClient._retry_after_seconds(_resp(429, retry_after=999), 0) == 30.0
def test_garbage_retry_after_falls_back_to_exponential():
assert TidalDownloadClient._retry_after_seconds(_resp(429, retry_after='soon'), 1) == 4.0
# ── _get_with_rate_limit_retry ────────────────────────────────────────
def test_retries_then_succeeds():
c = _client()
responses = [_resp(429), _resp(429), _resp(200)]
with patch('core.tidal_download_client.http_requests.get',
side_effect=responses) as g, \
patch('core.tidal_download_client.time.sleep') as sleep:
out = c._get_with_rate_limit_retry('http://x')
assert out.status_code == 200
assert g.call_count == 3
assert sleep.call_count >= 2 # backed off before each retry
def test_non_429_4xx_returns_immediately_no_retry():
c = _client()
with patch('core.tidal_download_client.http_requests.get',
side_effect=[_resp(403)]) as g, \
patch('core.tidal_download_client.time.sleep') as sleep:
out = c._get_with_rate_limit_retry('http://x')
assert out.status_code == 403
assert g.call_count == 1
sleep.assert_not_called()
def test_gives_up_after_max_retries():
c = _client()
with patch('core.tidal_download_client.http_requests.get',
side_effect=[_resp(429)] * 20) as g, \
patch('core.tidal_download_client.time.sleep'):
out = c._get_with_rate_limit_retry('http://x')
assert out.status_code == 429
# initial try + MAX_RETRIES retries
assert g.call_count == TidalDownloadClient._MANIFEST_MAX_RETRIES + 1
def test_transient_5xx_is_retried():
c = _client()
with patch('core.tidal_download_client.http_requests.get',
side_effect=[_resp(503), _resp(200)]) as g, \
patch('core.tidal_download_client.time.sleep'):
out = c._get_with_rate_limit_retry('http://x')
assert out.status_code == 200
assert g.call_count == 2
def test_shutdown_aborts_backoff():
c = _client()
c.shutdown_check = lambda: True # shutdown requested
with patch('core.tidal_download_client.http_requests.get',
side_effect=[_resp(429), _resp(200)]) as g, \
patch('core.tidal_download_client.time.sleep'):
out = c._get_with_rate_limit_retry('http://x')
# First call 429 → enters backoff → shutdown → returns the 429 without
# making the second request.
assert out.status_code == 429
assert g.call_count == 1

View file

@ -239,3 +239,32 @@ def test_agreeing_volume_markers_still_match(spotify_name, lib_name) -> None:
"""Same volume marker should NOT block a match that other rules accept."""
assert _albums_likely_match(spotify_name, lib_name)
@pytest.mark.parametrize(
"spotify_name,lib_name",
[
# Decimal / multi-part volume numbers must be distinguished — the dot is
# stripped to a space in normalization, and grabbing only the last digit
# made these collapse to the same marker (Sokhi: character-song CDs).
("Character CD Vol.5", "Character CD Vol.5.5"),
("Character CD Vol.5.5", "Character CD Vol.4.5"),
("Anime OST Vol.1.5", "Anime OST Vol.2.5"),
# The real CJK album names from the report.
("TVアニメ「【推しの子】」キャラクターソングCD Vol.5",
"TVアニメ「【推しの子】」キャラクターソングCD Vol.5.5"),
],
)
def test_decimal_volume_markers_block_match(spotify_name, lib_name) -> None:
assert not _albums_likely_match(spotify_name, lib_name)
@pytest.mark.parametrize(
"spotify_name,lib_name",
[
("Character CD Vol.5.5", "Character CD Vol.5.5"), # identical decimal vol
("Character CD Vol.5.5", "Character CD Vol.5.5 (Deluxe)"),
],
)
def test_same_decimal_volume_still_matches(spotify_name, lib_name) -> None:
assert _albums_likely_match(spotify_name, lib_name)

View file

@ -0,0 +1,68 @@
"""Watchlist scan history persistence (#831 round 2).
Every scan run is saved to watchlist_scan_runs with its track ledger so the
Watchlist History modal can show what each past run added wishlist rows
erode as tracks download, so this table is the durable record.
"""
from __future__ import annotations
import pytest
from database.music_database import MusicDatabase
@pytest.fixture()
def db(tmp_path):
return MusicDatabase(str(tmp_path / 'm.db'))
def _events(n_added=2, n_skipped=1):
evs = [{'track_name': f'Added {i}', 'artist_name': 'A', 'album_name': 'Al',
'album_image_url': '', 'status': 'added'} for i in range(n_added)]
evs += [{'track_name': f'Skipped {i}', 'artist_name': 'A', 'album_name': 'Al',
'album_image_url': '', 'status': 'skipped'} for i in range(n_skipped)]
return evs
def test_save_and_fetch_run_with_ledger(db):
assert db.save_watchlist_scan_run(
'run-1', status='completed',
started_at='2026-06-09T20:00:00', completed_at='2026-06-09T20:05:00',
total_artists=63, artists_scanned=63, tracks_found=19, tracks_added=10,
track_events=_events())
runs = db.get_watchlist_scan_runs()
assert len(runs) == 1
r = runs[0]
assert (r['run_id'], r['status'], r['tracks_found'], r['tracks_added']) == \
('run-1', 'completed', 19, 10)
events = db.get_watchlist_scan_run_events('run-1')
assert [e['status'] for e in events] == ['added', 'added', 'skipped']
def test_resave_is_idempotent_on_run_id(db):
db.save_watchlist_scan_run('run-1', tracks_added=10)
db.save_watchlist_scan_run('run-1', tracks_added=11)
runs = db.get_watchlist_scan_runs()
assert len(runs) == 1 and runs[0]['tracks_added'] == 11
def test_prune_keeps_most_recent(db):
for i in range(1, 8):
db.save_watchlist_scan_run(
f'run-{i}', completed_at=f'2026-06-09T20:0{i}:00', keep_last=5)
runs = db.get_watchlist_scan_runs()
assert len(runs) == 5
assert runs[0]['run_id'] == 'run-7' # newest first
assert all(r['run_id'] != 'run-1' for r in runs) # oldest pruned
def test_events_for_unknown_run_empty(db):
assert db.get_watchlist_scan_run_events('nope') == []
def test_cancelled_run_recorded(db):
db.save_watchlist_scan_run('run-c', status='cancelled', tracks_added=3,
track_events=_events(1, 0))
r = db.get_watchlist_scan_runs()[0]
assert r['status'] == 'cancelled'

View file

@ -1177,3 +1177,57 @@ def test_match_to_spotify_uses_strict_lookup():
assert result is None
assert spotify_client.search_calls == [("Artist One", 5, False)]
def test_scan_records_per_run_track_ledger(monkeypatch):
"""#831: the scan keeps a full per-run ledger (scan_track_events) of found
tracks 'added' vs 'skipped' (wishlist dup / blocklisted) so the UI can
list WHICH tracks the 'New tracks / Added to wishlist' counts refer to."""
monkeypatch.setattr(watchlist_scanner_module, "DELAY_BETWEEN_ARTISTS", 0)
monkeypatch.setattr(watchlist_scanner_module, "DELAY_BETWEEN_ALBUMS", 0)
artist = _build_artist()
album = types.SimpleNamespace(id="album-1", name="Album One")
album_data = {
"name": "Album One",
"images": [{"url": "https://example.com/album.jpg"}],
"tracks": {
"items": [
{"id": "t1", "name": "Added Track", "track_number": 1,
"disc_number": 1, "artists": [{"name": "Artist One"}]},
{"id": "t2", "name": "Skipped Track", "track_number": 2,
"disc_number": 1, "artists": [{"name": "Artist One"}]},
]
},
}
scanner = _build_scanner(album_data, [artist])
scanner._database.has_fresh_similar_artists = lambda *args, **kwargs: False
monkeypatch.setattr(scanner, "_backfill_missing_ids", lambda *args, **kwargs: None)
monkeypatch.setattr(scanner, "get_artist_image_url", lambda *_a, **_k: "")
monkeypatch.setattr(scanner, "get_artist_discography_for_watchlist", lambda *_a, **_k: [album])
monkeypatch.setattr(scanner, "_get_lookback_period_setting", lambda: "30")
monkeypatch.setattr(scanner, "_get_rescan_cutoff", lambda: None)
monkeypatch.setattr(scanner, "_should_include_release", lambda *_a, **_k: True)
monkeypatch.setattr(scanner, "_should_include_track", lambda *_a, **_k: True)
monkeypatch.setattr(scanner, "is_track_missing_from_library", lambda *_a, **_k: True)
# First add succeeds, second is declined (already queued / blocklisted).
_add_results = iter([True, False])
monkeypatch.setattr(scanner, "add_track_to_wishlist",
lambda *_a, **_k: next(_add_results))
monkeypatch.setattr(scanner, "update_artist_scan_timestamp", lambda *_a, **_k: True)
monkeypatch.setattr(scanner, "update_similar_artists", lambda *_a, **_k: True)
monkeypatch.setattr(scanner, "_backfill_similar_artists_fallback_ids", lambda *_a, **_k: 0)
scan_state = {"scan_run_id": "20260609-1"}
scanner.scan_watchlist_artists([artist], scan_state=scan_state)
events = scan_state["scan_track_events"]
assert [(e["track_name"], e["status"]) for e in events] == [
("Added Track", "added"),
("Skipped Track", "skipped"),
]
assert events[0]["album_name"] == "Album One"
assert events[0]["artist_name"] == "Artist One"
# The 10-item live FIFO only carries the ADDED one, as before.
assert [a["track_name"] for a in scan_state["recent_wishlist_additions"]] == ["Added Track"]

View file

@ -561,3 +561,54 @@ def test_add_album_track_to_wishlist_requires_required_fields():
"success": False,
"error": "Missing required fields: track, artist, album",
}
# ── #825: don't add already-owned tracks to the wishlist (respects the toggle) ──
def _own_track_args():
return dict(track={"id": "t", "name": "Song", "artists": [{"name": "A"}]},
artist={"id": "a1", "name": "A"},
album={"id": "al1", "name": "Album"}, source_type="album")
def test_add_album_track_skips_owned_when_duplicates_off(monkeypatch):
from config.settings import config_manager
monkeypatch.setattr(config_manager, 'get',
lambda key, default=None: False if key == 'wishlist.allow_duplicate_tracks' else default)
runtime, service, db, _logger, _ = _build_runtime()
db.check_track_exists = lambda *a, **k: (object(), 0.95) # already owned
payload, status = add_album_track_to_wishlist(runtime, **_own_track_args())
assert status == 200
assert payload.get("skipped") is True
assert service.add_calls == [] # nothing added
def test_add_album_track_adds_missing_when_duplicates_off(monkeypatch):
from config.settings import config_manager
monkeypatch.setattr(config_manager, 'get',
lambda key, default=None: False if key == 'wishlist.allow_duplicate_tracks' else default)
runtime, service, db, _logger, _ = _build_runtime()
db.check_track_exists = lambda *a, **k: (None, 0.0) # not in library
payload, status = add_album_track_to_wishlist(runtime, **_own_track_args())
assert status == 200
assert not payload.get("skipped")
assert len(service.add_calls) == 1 # added
def test_add_album_track_adds_owned_when_duplicates_on(monkeypatch):
from config.settings import config_manager
monkeypatch.setattr(config_manager, 'get',
lambda key, default=None: True if key == 'wishlist.allow_duplicate_tracks' else default)
runtime, service, db, _logger, _ = _build_runtime()
called = []
db.check_track_exists = lambda *a, **k: called.append(1) or (object(), 0.99)
payload, status = add_album_track_to_wishlist(runtime, **_own_track_args())
assert status == 200
assert len(service.add_calls) == 1 # added anyway (user wants dupes)
assert called == [] # ownership check skipped entirely

View file

@ -40,7 +40,7 @@ logger = setup_logging(_log_level, _log_path)
# App version — single source of truth for backup metadata, system-info, update check, etc.
# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release.
_SOULSYNC_BASE_VERSION = "2.6.8"
_SOULSYNC_BASE_VERSION = "2.6.9"
def _build_version_string():
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
@ -390,6 +390,41 @@ def inject_webui_assets():
'vite_assets': build_webui_vite_assets,
}
# --- Launch PIN gate (before_request hook) ---
@app.before_request
def _enforce_launch_pin():
"""Server-side enforcement of the launch PIN (#832).
The PIN was previously a client-only overlay removing the div (Safari
"Hide Distracting Items", devtools, curl) gave full API access. This rejects
every request from an unverified session with 401 while the launch lock is
on, except the page shell + the unlock flow + the key-authed public API.
No-ops entirely when ``security.require_pin_on_launch`` is off (the default).
"""
try:
require_pin = bool(config_manager.get('security.require_pin_on_launch', False)) if config_manager else False
except Exception:
require_pin = False
if not require_pin:
return
from core.security.launch_lock import request_is_locked, is_html_navigation
if request_is_locked(
request.path, request.method,
require_pin=require_pin,
pin_verified=bool(session.get('launch_pin_verified', False)),
):
# A browser navigating to a sub-page (deep link / refresh) should land
# on the lock screen, not raw JSON — bounce it to the root, which serves
# the lock UI. Programmatic fetch/XHR get the JSON so the frontend reacts.
if is_html_navigation(
request.method,
request.headers.get('Accept', ''),
request.headers.get('Sec-Fetch-Mode', ''),
):
return redirect('/')
return jsonify({"error": "locked", "launch_pin_required": True}), 401
# --- Profile Context (before_request hook) ---
@app.before_request
def _set_profile_context():
@ -3010,11 +3045,12 @@ def handle_settings():
return jsonify({"success": False, "error": str(e)}), 500
else: # GET request
try:
data = dict(config_manager.config_data)
# Never ship the OAuth token payload to the browser — the settings
# UI has no field for it and it doesn't belong in devtools/HAR
# captures. NOTE: dict() above is a SHALLOW copy of live config
# state, so rebuild the section instead of popping in place.
# Masks every configured secret (API keys, tokens, passwords) as a
# sentinel so decrypted credentials never reach the browser/devtools/
# HAR captures (#832 follow-up). Deep copy — safe to mutate below.
data = config_manager.redacted_config()
# Also drop the Spotify OAuth token payload — not a settings field
# and not in the sensitive-paths list.
if isinstance(data.get('spotify'), dict) and 'token_info' in data['spotify']:
data['spotify'] = {k: v for k, v in data['spotify'].items() if k != 'token_info'}
# Include which download sources are configured so the UI can auto-disable unconfigured ones
@ -7083,6 +7119,28 @@ def download_selected_candidate(task_id):
return jsonify({"error": str(e)}), 500
def _resolve_link_track_query(source: str, track_id: str):
"""Resolve a pasted (source, track_id) to a clean "artist title" search
query via the source client's get_track (#813). Returns (query, None) or
(None, error). Used so a pasted Tidal/Qobuz link runs the source's normal
search (proven-downloadable candidates) instead of a hand-built one."""
from core.downloads.track_link import query_from_track_payload
client = download_orchestrator.client(source) if download_orchestrator else None
if not client or not hasattr(client, 'get_track'):
return None, f"{source.title()} is not connected"
try:
raw = client.get_track(track_id)
except Exception as e:
return None, f"Could not resolve {source.title()} track: {e}"
if not raw:
return None, f"{source.title()} track {track_id} not found"
query = query_from_track_payload(source, raw)
if not query:
return None, f"Could not read the track title from {source.title()}"
return query, None
@app.route('/api/downloads/task/<task_id>/manual-search', methods=['POST'])
def manual_search_for_task(task_id):
"""Run a user-driven search against one (or all) configured download
@ -7122,6 +7180,34 @@ def manual_search_for_task(task_id):
download_mode, available_sources = _list_available_download_sources()
valid_source_ids = {s['id'] for s in available_sources}
# Pasted streaming-source track link (#813): resolve it to a clean
# "artist title" query and search ONLY that source, then bubble the
# exact track to the top. Falls back to a normal text search if the
# source isn't connected or the link can't be resolved — so the user is
# never worse off than typing the query themselves.
from core.downloads.track_link import parse_download_track_link
link = parse_download_track_link(query)
link_source = None
link_track_id = None
if link:
_src, _tid = link
# A parsed link is unambiguously a Tidal/Qobuz track URL, never a
# name a user would type — so if we can't use it, say why clearly
# instead of running a useless search of the raw URL text.
if _src not in valid_source_ids:
return jsonify({
"error": f"{_src.title()} isn't connected — can't resolve a "
f"{_src.title()} link. Connect it in Settings, or search by name."
}), 400
clean_q, link_err = _resolve_link_track_query(_src, _tid)
if not clean_q:
return jsonify({
"error": link_err or f"Couldn't resolve that {_src.title()} link."
}), 400
query = clean_q
source = _src
link_source, link_track_id = _src, _tid
if source != 'all':
if source not in valid_source_ids:
return jsonify({
@ -7181,6 +7267,13 @@ def manual_search_for_task(task_id):
"error": error,
}) + "\n"
continue
# Pasted-link exact match: bubble the track whose id matches
# the link to the top so the user sees the exact version
# first (graceful no-op if ids don't line up).
if src_name == link_source and link_track_id and tracks:
tracks = sorted(
tracks,
key=lambda t: str(getattr(t, 'id', '')) != str(link_track_id))
serialized = []
for t in tracks:
s = _serialize_candidate(t, source_override=src_name)
@ -9520,6 +9613,7 @@ def _build_library_tag_db_data(track_data, album_genres=None):
'track_artist': track_data.get('track_artist'),
'album_title': track_data.get('album_title'),
'year': track_data.get('year'),
'release_date': track_data.get('release_date'), # #824: full date wins over year when present
'genres': album_genres,
'track_number': track_data.get('track_number'),
'disc_number': track_data.get('disc_number'),
@ -9556,7 +9650,7 @@ def get_track_tag_preview(track_id):
cursor = conn.cursor()
cursor.execute("""
SELECT t.*, a.name as artist_name, al.title as album_title,
al.year, al.genres as album_genres, al.track_count,
al.year, al.release_date, al.genres as album_genres, al.track_count,
al.thumb_url as album_thumb_url, a.thumb_url as artist_thumb_url
FROM tracks t
JOIN artists a ON t.artist_id = a.id
@ -9632,7 +9726,7 @@ def get_batch_tag_preview():
placeholders = ','.join('?' for _ in track_ids)
cursor.execute(f"""
SELECT t.*, a.name as artist_name, al.title as album_title,
al.year, al.genres as album_genres, al.track_count,
al.year, al.release_date, al.genres as album_genres, al.track_count,
al.thumb_url as album_thumb_url, a.thumb_url as artist_thumb_url
FROM tracks t
JOIN artists a ON t.artist_id = a.id
@ -9717,7 +9811,7 @@ def write_track_tags(track_id):
cursor = conn.cursor()
cursor.execute("""
SELECT t.*, a.name as artist_name, al.title as album_title,
al.year, al.genres as album_genres, al.track_count,
al.year, al.release_date, al.genres as album_genres, al.track_count,
al.thumb_url as album_thumb_url, a.thumb_url as artist_thumb_url
FROM tracks t
JOIN artists a ON t.artist_id = a.id
@ -9811,7 +9905,7 @@ def write_tracks_tags_batch():
placeholders = ','.join('?' * len(track_ids))
cursor.execute(f"""
SELECT t.*, a.name as artist_name, al.title as album_title,
al.year, al.genres as album_genres, al.track_count,
al.year, al.release_date, al.genres as album_genres, al.track_count,
al.thumb_url as album_thumb_url, a.thumb_url as artist_thumb_url
FROM tracks t
JOIN artists a ON t.artist_id = a.id
@ -11070,14 +11164,18 @@ def _resolve_library_file_path(file_path):
path_parts = file_path.replace('\\', '/').split('/')
# Try progressively shorter path suffixes against each candidate directory
# (skip index 0 to avoid drive letter issues)
# (skip index 0 to avoid drive letter issues). find_on_disk matches each
# component exactly when present, else folds typographic confusables (#833:
# curly U+2019 apostrophe in DB metadata vs ASCII U+0027 on disk) — exact
# matches always win, so paths that already resolved are unaffected.
from core.library.path_resolve import find_on_disk
for base_dir in [transfer_dir, download_dir] + list(library_dirs):
if not base_dir or not os.path.isdir(base_dir):
continue
for i in range(1, len(path_parts)):
candidate = os.path.join(base_dir, *path_parts[i:])
if os.path.exists(candidate):
return candidate
found = find_on_disk(base_dir, path_parts[i:])
if found:
return found
return None
@ -17092,9 +17190,9 @@ def _build_master_deps():
def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json):
def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, serialize=False):
return _downloads_master.run_full_missing_tracks_process(
batch_id, playlist_id, tracks_json, _build_master_deps()
batch_id, playlist_id, tracks_json, _build_master_deps(), serialize=serialize
)
@ -24078,9 +24176,20 @@ def _run_sync_task(
automation_id=None,
profile_id=1,
playlist_image_url='',
sync_mode='replace',
sync_mode=None,
skip_wishlist_add=False,
):
# When a caller doesn't specify a mode — the mirrored auto-sync + Playlist
# Pipeline (auto_sync_playlist), iTunes-link sync, Wing It — honor the user's
# configured global "Playlist sync mode" instead of hardcoding 'replace'.
# Hardcoding replace meant every AUTOMATED sync recreated the server
# playlist, wiping its custom image + description even when the user chose
# Append/Reconcile (#823 carlosjfcasero). The global default is still
# 'replace', so default users are unaffected; only users who set
# Append/Reconcile get the change. (Mirrors _submit_sync_task.)
if sync_mode is None:
from core.sync.playlist_edit import normalize_sync_mode
sync_mode = normalize_sync_mode(None, config_manager.get('playlist_sync.mode', 'replace'))
return _discovery_sync.run_sync_task(
playlist_id, playlist_name, tracks_json, automation_id, profile_id, playlist_image_url,
_build_sync_deps(),
@ -24990,8 +25099,12 @@ def get_current_profile():
# Check if launch PIN is required
require_pin = config_manager.get('security.require_pin_on_launch', False) if config_manager else False
# Check if PIN was verified this page load, then consume the flag
pin_verified = session.pop('launch_pin_verified', False)
# #832: READ (don't pop) the verified flag — the server-side gate
# (_enforce_launch_pin) relies on it persisting for the whole session,
# so verified requests keep passing. Verification now lasts the session
# (until logout / cookie expiry) instead of one page load — which is
# both what an enforced gate requires and the correct security model.
pin_verified = session.get('launch_pin_verified', False)
return jsonify({
'success': True,
@ -25883,9 +25996,14 @@ def start_watchlist_scan():
'current_track_name': '',
'tracks_found_this_scan': 0,
'tracks_added_this_scan': 0,
'recent_wishlist_additions': []
'recent_wishlist_additions': [],
# #831: full per-run ledger of found tracks (added vs
# skipped) so the completed-scan summary can list WHICH
# tracks the "New tracks / Added to wishlist" counts mean.
'scan_track_events': [],
'scan_run_id': datetime.now().strftime('%Y%m%d-%H%M%S'),
})
scan_results = []
# Pause enrichment workers during scan to reduce API contention
@ -25922,6 +26040,29 @@ def start_watchlist_scan():
else:
logger.warning("Watchlist scan cancelled — skipping post-scan steps")
# #831 round 2: persist this run + its track ledger so the
# Watchlist History modal can show what every past scan did.
try:
_state = watchlist_scan_state
get_database().save_watchlist_scan_run(
run_id=_state.get('scan_run_id') or datetime.now().strftime('%Y%m%d-%H%M%S'),
profile_id=scan_profile_id,
status='cancelled' if was_cancelled else 'completed',
started_at=(_state.get('started_at').isoformat()
if _state.get('started_at') else None),
completed_at=(_state.get('completed_at') or datetime.now()).isoformat()
if not isinstance(_state.get('completed_at'), str)
else _state.get('completed_at'),
total_artists=(_state.get('summary') or {}).get('total_artists',
_state.get('total_artists', 0)),
artists_scanned=(_state.get('summary') or {}).get('successful_scans', 0),
tracks_found=_state.get('tracks_found_this_scan', 0),
tracks_added=_state.get('tracks_added_this_scan', 0),
track_events=_state.get('scan_track_events') or [],
)
except Exception as _hist_err:
logger.error(f"Failed to persist watchlist scan run: {_hist_err}")
# Post-scan steps — skip if cancelled
if not was_cancelled:
# Populate discovery pool from similar artists
@ -26078,6 +26219,29 @@ def get_watchlist_scan_status():
logger.error(f"Error getting watchlist scan status: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/watchlist/scan/history', methods=['GET'])
def get_watchlist_scan_history():
"""Recent watchlist scan runs (counts only — ledgers fetched per run)."""
try:
limit = min(int(request.args.get('limit', 30) or 30), 100)
runs = get_database().get_watchlist_scan_runs(limit=limit)
return jsonify({"success": True, "runs": runs})
except Exception as e:
logger.error(f"Error getting watchlist scan history: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/watchlist/scan/history/<run_id>/tracks', methods=['GET'])
def get_watchlist_scan_history_tracks(run_id):
"""The track ledger (added/skipped) for one past scan run."""
try:
events = get_database().get_watchlist_scan_run_events(run_id)
return jsonify({"success": True, "events": events})
except Exception as e:
logger.error(f"Error getting watchlist scan history tracks: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/watchlist/scan/cancel', methods=['POST'])
def cancel_watchlist_scan():
"""Cancel a running watchlist scan"""

View file

@ -3839,7 +3839,7 @@
<label>Primary metadata source:</label>
<select id="metadata-fallback-source">
<option value="spotify">Spotify</option>
<option value="spotify_free">Spotify Free (no credentials)</option>
<option value="spotify_free">Spotify (no auth)</option>
<option value="itunes">iTunes / Apple Music</option>
<option value="deezer">Deezer</option>
<option value="discogs">Discogs</option>
@ -3849,11 +3849,11 @@
<div class="callback-info">
<div class="callback-help">Where artist, album, and track metadata comes from:<br>
<strong>Spotify</strong> — official Spotify; connect your account in the Spotify section below.<br>
<strong>Spotify Free</strong> — the same Spotify catalog with no account needed. It's unofficial and best-effort (may break if Spotify changes its site), can't search albums by name (album results come from iTunes/Deezer instead), and can't read your personal library or playlists.<br>
<strong>Spotify (no auth)</strong> — the same Spotify catalog with no account needed. It's unofficial and best-effort (may break if Spotify changes its site) and can't read your personal library or playlists.<br>
<strong>Deezer</strong> / <strong>iTunes</strong> — free, no account.<br>
<strong>MusicBrainz</strong> — free, but limited to 1 request/sec.<br>
<strong>Discogs</strong> — needs a personal access token.<br><br>
Tip: if you pick <strong>Spotify Free</strong> and later connect a real Spotify account, it uses the official account normally and only falls back to free while Spotify is rate-limited — then switches back automatically.</div>
Tip: if you pick <strong>Spotify (no auth)</strong> and later connect a real Spotify account, it uses the official account normally and only falls back to no-auth while Spotify is rate-limited — then switches back automatically.</div>
</div>
</div>
@ -3893,6 +3893,15 @@
onclick="disconnectSpotify()" style="display: none;">🔌
Disconnect</button>
</div>
<div class="form-group" style="margin-top: 14px; border-top: 1px solid rgba(255,255,255,0.08); padding-top: 14px;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: normal;">
<input type="checkbox" id="metadata-spotify-free-enrichment" style="width: auto; margin: 0;">
<span>Use Spotify (no auth) for background enrichment</span>
</label>
<div class="callback-info">
<div class="callback-help">Runs the background metadata enrichment worker on the no-auth Spotify source instead of this connected account — keeping bulk enrichment off your official API quota (and dodging rate-limit bans), so your account is reserved for interactive search and playlist sync. <strong>On by default.</strong> Turn off to enrich through your connected account instead. Works even with no account connected. Note: the no-auth source can't supply artist genres.</div>
</div>
</div>
</div>
</div>
@ -6958,28 +6967,53 @@
Watchlist
</h2>
<div class="watchlist-page-meta">
<span class="watchlist-page-count" id="watchlist-page-count">0 artists</span>
<span class="watchlist-page-timer" id="watchlist-next-auto-timer">Next Auto: --</span>
<span class="wl-meta-chip" id="watchlist-page-count">0 artists</span>
<span class="wl-meta-chip wl-meta-chip--accent" id="watchlist-next-auto-timer">Next Auto: --</span>
</div>
</div>
</div>
<!-- Scan status / live activity (IDs match old modal for compatibility) -->
<div id="watchlist-scan-status" class="watchlist-page-scan-status" style="display: none;">
<div id="watchlist-live-activity" class="watchlist-live-activity" style="display: none;">
<div class="watchlist-live-activity-col">
<img id="watchlist-artist-img" class="watchlist-live-activity-artist-img" src="" alt="Artist" onerror="this.style.display='none';" />
<div id="watchlist-artist-name" class="watchlist-live-activity-label">Waiting...</div>
<!-- #831 round 2: full-width live "scan deck" -->
<div id="watchlist-live-activity" class="wl-scan-deck" style="display: none;">
<div class="wl-scan-deck-head">
<span class="wl-scan-live-dot"></span>
<span class="wl-scan-live-label">Scanning</span>
<span id="wl-scan-progress-text" class="wl-scan-progress-text"></span>
<div class="wl-scan-counters">
<div class="wl-scan-counter">
<span id="wl-scan-found" class="wl-scan-counter-num">0</span>
<span class="wl-scan-counter-label">found</span>
</div>
<div class="wl-scan-counter added">
<span id="wl-scan-added" class="wl-scan-counter-num">0</span>
<span class="wl-scan-counter-label">added</span>
</div>
</div>
</div>
<div class="watchlist-live-activity-col">
<img id="watchlist-album-img" class="watchlist-live-activity-album-img" src="" alt="Album" onerror="this.style.display='none';" />
<div id="watchlist-album-name" class="watchlist-live-activity-label">Waiting...</div>
</div>
<div class="watchlist-live-activity-feed">
<div class="watchlist-live-activity-feed-label">Current Track:</div>
<div id="watchlist-track-name" class="watchlist-live-activity-track">Waiting...</div>
<div class="watchlist-live-activity-feed-label-orange">Recently Added:</div>
<div id="watchlist-additions-feed" style="max-height: 80px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; font-size: 10px;"></div>
<div class="wl-scan-progress"><div id="wl-scan-progress-bar" class="wl-scan-progress-bar" style="width: 0%;"></div></div>
<div class="wl-scan-deck-body">
<div class="wl-scan-hero">
<div class="wl-scan-portrait">
<img id="watchlist-artist-img" class="wl-scan-portrait-img" src="" alt="" onerror="this.style.display='none';" />
<div class="wl-scan-album-thumb">
<img id="watchlist-album-img" src="" alt="" onerror="this.style.display='none';" />
</div>
</div>
<div class="wl-scan-hero-text">
<div id="watchlist-artist-name" class="wl-scan-artist-name">Waiting…</div>
<div id="wl-scan-phase" class="wl-scan-phase">starting…</div>
<div class="wl-scan-now">
<div id="watchlist-album-name" class="wl-scan-album-name"></div>
<div id="watchlist-track-name" class="wl-scan-track-name"></div>
</div>
</div>
</div>
<div class="wl-scan-feed">
<div class="wl-scan-feed-label">Added to wishlist this run</div>
<div id="watchlist-additions-feed" class="wl-scan-feed-list"></div>
</div>
</div>
</div>
<div id="watchlist-page-scan-summary" class="scan-status-summary" style="display: none;"></div>
@ -6987,27 +7021,32 @@
<!-- Action buttons -->
<div class="watchlist-page-actions">
<button class="btn btn--primary" id="scan-watchlist-btn" onclick="startWatchlistScan()">
<button class="wl-chip wl-chip--cta" id="scan-watchlist-btn" onclick="startWatchlistScan()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Scan for New Releases
<span class="wl-chip-shimmer"></span>
</button>
<button class="btn btn--secondary" id="cancel-watchlist-scan-btn" onclick="cancelWatchlistScan()" style="display: none;">
<button class="wl-chip wl-chip--red" id="cancel-watchlist-scan-btn" onclick="cancelWatchlistScan()" style="display: none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
Cancel Scan
</button>
<button class="btn btn--secondary" id="update-similar-artists-btn" onclick="updateSimilarArtists()">
<button class="wl-chip wl-chip--blue" id="update-similar-artists-btn" onclick="updateSimilarArtists()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Update Similar Artists
</button>
<button class="btn btn--secondary" id="watchlist-page-settings-btn" onclick="openWatchlistGlobalSettingsModal()">
<button class="wl-chip wl-chip--slate" id="watchlist-page-settings-btn" onclick="openWatchlistGlobalSettingsModal()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Global Settings
</button>
<button class="btn btn--secondary" id="watchlist-page-origins-btn" onclick="openDownloadOriginsModal('watchlist')" title="See every track your watchlist downloaded">
<button class="wl-chip wl-chip--green" id="watchlist-page-origins-btn" onclick="openDownloadOriginsModal('watchlist')" title="See every track your watchlist downloaded">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download Origins
</button>
<button class="btn btn--secondary" id="watchlist-page-blocklist-btn" onclick="openBlocklistModal('artist')" title="Block artists, albums or tracks from ever being downloaded">
<button class="wl-chip wl-chip--amber" id="watchlist-page-history-btn" onclick="openWatchlistHistoryModal()" title="Every past scan and the tracks it added to the wishlist">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v5h5"/><path d="M3.05 13A9 9 0 1 0 6 5.3L3 8"/><polyline points="12 7 12 12 15 15"/></svg>
History
</button>
<button class="wl-chip wl-chip--red" id="watchlist-page-blocklist-btn" onclick="openBlocklistModal('artist')" title="Block artists, albums or tracks from ever being downloaded">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="4.9" y1="4.9" x2="19.1" y2="19.1"/></svg>
Blocklist
</button>
@ -7689,18 +7728,13 @@
<!-- Watchlist Global Config Modal -->
<div class="modal-overlay hidden" id="watchlist-global-config-modal-overlay">
<div class="watchlist-artist-config-modal" id="watchlist-global-config-modal">
<div class="watchlist-artist-config-header" style="padding: 24px 28px;">
<div style="display: flex; align-items: center; gap: 12px;">
<span style="font-size: 28px;">⚙️</span>
<div>
<h2 style="color: #fff; margin: 0; font-size: 22px;">Global Watchlist Settings</h2>
<p style="color: #b3b3b3; margin: 4px 0 0; font-size: 13px;">
Override per-artist settings for all watchlist scans
</p>
</div>
<div class="watchlist-artist-config-modal wl-global-modal" id="watchlist-global-config-modal">
<div class="wl-global-modal-head">
<div>
<h2 class="wl-global-modal-title">Global Watchlist Settings</h2>
<p class="wl-global-modal-sub">Override per-artist settings for all watchlist scans.</p>
</div>
<span class="watchlist-artist-config-close" onclick="closeWatchlistGlobalSettingsModal()">&times;</span>
<button class="wl-global-modal-close" onclick="closeWatchlistGlobalSettingsModal()" aria-label="Close"></button>
</div>
<div class="watchlist-artist-config-content">
@ -8154,6 +8188,7 @@
<script src="{{ url_for('static', filename='track-detail.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='wishlist-tools.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='origin-history.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='watchlist-history.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='blocklist.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-services.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-listenbrainz.js', v=static_v) }}"></script>

View file

@ -2830,12 +2830,10 @@ async function openWatchlistGlobalSettingsModal() {
// Update options visibility based on toggle state
toggleGlobalOverrideOptions();
// Update toggle label border
// Reflect enabled state on the toggle card (styled in CSS)
const toggleLabel = document.getElementById('global-override-toggle-label');
if (toggleLabel) {
toggleLabel.style.border = config.global_override_enabled
? '2px solid rgba(29, 185, 84, 0.5)'
: '2px solid rgba(255, 255, 255, 0.1)';
toggleLabel.classList.toggle('enabled', !!config.global_override_enabled);
}
// Show modal
@ -2867,12 +2865,10 @@ function toggleGlobalOverrideOptions() {
options.style.pointerEvents = enabled ? 'auto' : 'none';
}
// Update toggle label border
// Reflect enabled state on the toggle card (styled in CSS)
const toggleLabel = document.getElementById('global-override-toggle-label');
if (toggleLabel) {
toggleLabel.style.border = enabled
? '2px solid rgba(29, 185, 84, 0.5)'
: '2px solid rgba(255, 255, 255, 0.1)';
toggleLabel.classList.toggle('enabled', enabled);
}
}
@ -3144,7 +3140,7 @@ async function startWatchlistScan() {
try {
const button = document.getElementById('scan-watchlist-btn');
button.disabled = true;
button.textContent = 'Starting scan...';
_wlSetChipLabel(button, 'Starting scan...');
button.classList.add('btn-processing');
const response = await fetch('/api/watchlist/scan', {
@ -3157,7 +3153,7 @@ async function startWatchlistScan() {
throw new Error(data.error || 'Failed to start scan');
}
button.textContent = 'Scanning...';
_wlSetChipLabel(button, 'Scanning...');
// Show cancel button
const cancelBtn = document.getElementById('cancel-watchlist-scan-btn');
@ -3180,7 +3176,7 @@ async function startWatchlistScan() {
console.error('Error starting watchlist scan:', error);
const button = document.getElementById('scan-watchlist-btn');
button.disabled = false;
button.textContent = 'Scan for New Releases';
_wlSetChipLabel(button, 'Scan for New Releases');
button.classList.remove('btn-processing');
alert(`Error starting scan: ${error.message}`);
}
@ -3189,6 +3185,91 @@ async function startWatchlistScan() {
/**
* Poll watchlist scan status
*/
// #831 (Tacobell444): the scan summary said "New tracks: 19 • Added to
// wishlist: 10" with no way to see WHICH tracks. The scan now ships a per-run
// ledger (scan_track_events: track/artist/album/thumb + added|skipped) and
// this renders it as an expandable list under the completion summary.
function renderWatchlistScanTrackLedger(events) {
if (!Array.isArray(events) || events.length === 0) return '';
const added = events.filter(e => e.status === 'added');
const skipped = events.filter(e => e.status !== 'added');
const row = (e) => `
<div class="watchlist-live-addition-item">
<img src="${escapeHtml(e.album_image_url || '')}" alt="" onerror="this.style.display='none';" />
<div class="watchlist-live-addition-item-info">
<div class="watchlist-live-addition-item-track">${escapeHtml(e.track_name || '')}</div>
<div class="watchlist-live-addition-item-artist">${escapeHtml(e.artist_name || '')}${e.album_name ? ' — ' + escapeHtml(e.album_name) : ''}</div>
</div>
${e.status === 'added'
? '<span class="watchlist-scan-track-badge added">added</span>'
: '<span class="watchlist-scan-track-badge skipped">skipped</span>'}
</div>`;
const section = (label, list) => list.length
? `<div class="watchlist-scan-tracks-section">${label} (${list.length})</div>${list.map(row).join('')}`
: '';
return `
<button type="button" class="watchlist-scan-tracks-toggle" onclick="toggleWatchlistScanTracks(this)">
Show tracks <span class="watchlist-scan-tracks-caret"></span>
</button>
<div class="watchlist-scan-tracks" style="display: none;">
${section('Added to wishlist', added)}
${section('Found but skipped — already queued or blocklisted', skipped)}
</div>`;
}
// Human-readable phase line for the scan deck ("checking_album_2_of_5" →
// "Checking album 2 of 5").
// Set a wl-chip button's label without destroying its icon/shimmer children
// (textContent wipes them; these buttons carry an svg + shimmer span).
function _wlSetChipLabel(btn, text) {
if (!btn) return;
const svg = btn.querySelector('svg');
const shimmer = btn.querySelector('.wl-chip-shimmer');
btn.textContent = '';
if (svg) btn.appendChild(svg);
btn.appendChild(document.createTextNode(' ' + text));
if (shimmer) btn.appendChild(shimmer);
}
function _wlPrettyPhase(data) {
const phase = data.current_phase || '';
if (!phase) return 'Working…';
const m = phase.match(/^checking_album_(\d+)_of_(\d+)$/);
if (m) return `Checking album ${m[1]} of ${m[2]}`;
const map = {
starting: 'Starting…',
fetching_discography: 'Fetching releases…',
populating_discovery_pool: 'Populating discovery…',
updating_listenbrainz: 'Updating ListenBrainz…',
};
return map[phase] || phase.replace(/_/g, ' ');
}
// Update a live counter and replay its pop animation when the value changes.
function _wlBumpCounter(id, value) {
const el = document.getElementById(id);
if (!el) return;
const text = String(value);
if (el.textContent !== text) {
el.textContent = text;
el.classList.remove('pop');
void el.offsetWidth; // restart the CSS animation
el.classList.add('pop');
}
}
function toggleWatchlistScanTracks(btn) {
const list = btn.parentElement.querySelector('.watchlist-scan-tracks');
if (!list) return;
const open = list.style.display !== 'none';
list.style.display = open ? 'none' : 'block';
btn.innerHTML = (open ? 'Show tracks ' : 'Hide tracks ')
+ `<span class="watchlist-scan-tracks-caret">${open ? '▾' : '▴'}</span>`;
}
function handleWatchlistScanData(data) {
const button = document.getElementById('scan-watchlist-btn');
const liveActivity = document.getElementById('watchlist-live-activity');
@ -3201,17 +3282,23 @@ function handleWatchlistScanData(data) {
// Update live visual activity display
if (liveActivity && data.status === 'scanning') {
liveActivity.style.display = 'flex';
liveActivity.style.display = liveActivity.classList.contains('wl-scan-deck') ? 'block' : 'flex';
// Update artist image and name
// Update artist image and name (hide the img when THIS artist has no
// photo, so the previous artist's portrait doesn't linger and the
// glyph placeholder shows instead)
const artistImg = document.getElementById('watchlist-artist-img');
const artistName = document.getElementById('watchlist-artist-name');
if (artistImg && data.current_artist_image_url) {
artistImg.src = data.current_artist_image_url;
artistImg.style.display = 'block';
if (artistImg) {
if (data.current_artist_image_url) {
artistImg.src = data.current_artist_image_url;
artistImg.style.display = 'block';
} else {
artistImg.style.display = 'none';
}
}
if (artistName) {
artistName.textContent = data.current_artist_name || 'Processing...';
artistName.textContent = data.current_artist_name || 'Starting…';
}
// Update album image and name
@ -3224,30 +3311,54 @@ function handleWatchlistScanData(data) {
albumImg.style.display = 'none';
}
if (albumName) {
albumName.textContent = data.current_album || (data.current_phase === 'fetching_discography' ? 'Fetching releases...' : 'Processing...');
albumName.textContent = data.current_album
|| (data.current_phase === 'fetching_discography' ? 'Fetching releases…' : 'Looking for new releases…');
}
// Update current track
// Update current track ('' keeps the slot without echoing the album line)
const trackName = document.getElementById('watchlist-track-name');
if (trackName) {
trackName.textContent = data.current_track_name || (data.current_phase === 'fetching_discography' ? 'Fetching releases...' : 'Processing...');
trackName.textContent = data.current_track_name || '—';
}
// Update wishlist additions feed
// #831 round 2: scan-deck extras — artist progress bar, live counters,
// readable phase. All optional elements, so the legacy modal markup
// (which lacks them) keeps working untouched.
const total = data.total_artists || 0;
const idx = total ? Math.min((data.current_artist_index || 0) + 1, total) : 0;
const progText = document.getElementById('wl-scan-progress-text');
if (progText) progText.textContent = total ? `${idx} / ${total} artists` : '';
const progBar = document.getElementById('wl-scan-progress-bar');
if (progBar && total) progBar.style.width = `${Math.round((100 * idx) / total)}%`;
const phaseEl = document.getElementById('wl-scan-phase');
if (phaseEl) phaseEl.textContent = _wlPrettyPhase(data);
_wlBumpCounter('wl-scan-found', data.tracks_found_this_scan || 0);
_wlBumpCounter('wl-scan-added', data.tracks_added_this_scan || 0);
// Update wishlist additions feed — only re-render when it actually
// changed, so the slide-in animation plays once per new track instead
// of replaying on every poll.
const additionsFeed = document.getElementById('watchlist-additions-feed');
if (additionsFeed) {
if (data.recent_wishlist_additions && data.recent_wishlist_additions.length > 0) {
additionsFeed.innerHTML = data.recent_wishlist_additions.map(item => `
<div class="watchlist-live-addition-item">
<img src="${item.album_image_url || ''}" alt="" onerror="this.style.display='none';" />
<div class="watchlist-live-addition-item-info">
<div class="watchlist-live-addition-item-track">${item.track_name}</div>
<div class="watchlist-live-addition-item-artist">${item.artist_name}</div>
const additions = data.recent_wishlist_additions || [];
const feedKey = additions.map(a => a.track_name).join('');
if (additionsFeed.dataset.feedKey !== feedKey) {
const grew = additions.length > 0 && additionsFeed.dataset.feedKey !== undefined
&& feedKey.length > (additionsFeed.dataset.feedKey || '').length;
additionsFeed.dataset.feedKey = feedKey;
if (additions.length > 0) {
additionsFeed.innerHTML = additions.map((item, i) => `
<div class="watchlist-live-addition-item${grew && i === 0 ? ' is-new' : ''}">
<img src="${escapeHtml(item.album_image_url || '')}" alt="" onerror="this.style.display='none';" />
<div class="watchlist-live-addition-item-info">
<div class="watchlist-live-addition-item-track">${escapeHtml(item.track_name || '')}</div>
<div class="watchlist-live-addition-item-artist">${escapeHtml(item.artist_name || '')}</div>
</div>
</div>
</div>
`).join('');
} else {
additionsFeed.innerHTML = '<div class="watchlist-live-addition-empty">No tracks added yet...</div>';
`).join('');
} else {
additionsFeed.innerHTML = '<div class="watchlist-live-addition-empty">No tracks added yet…</div>';
}
}
}
} else if (liveActivity && data.status !== 'scanning') {
@ -3257,7 +3368,7 @@ function handleWatchlistScanData(data) {
if (data.status === 'completed') {
if (button) {
button.disabled = false;
button.textContent = 'Scan for New Releases';
_wlSetChipLabel(button, 'Scan for New Releases');
button.classList.remove('btn-processing');
}
@ -3295,6 +3406,7 @@ function handleWatchlistScanData(data) {
<span class="sync-separator"> </span>
<span class="sync-stat">Added to wishlist: ${addedTracks}</span>
</div>
${renderWatchlistScanTrackLedger(data.scan_track_events)}
</div>
`;
}
@ -3307,7 +3419,7 @@ function handleWatchlistScanData(data) {
} else if (data.status === 'cancelled') {
if (button) {
button.disabled = false;
button.textContent = 'Scan for New Releases';
_wlSetChipLabel(button, 'Scan for New Releases');
button.classList.remove('btn-processing');
}
@ -3341,6 +3453,7 @@ function handleWatchlistScanData(data) {
<span class="sync-separator"> &bull; </span>
<span class="sync-stat">Added to wishlist: ${addedTracks}</span>
</div>
${renderWatchlistScanTrackLedger(data.scan_track_events)}
</div>
`;
}
@ -3354,7 +3467,7 @@ function handleWatchlistScanData(data) {
} else if (data.status === 'error') {
if (button) {
button.disabled = false;
button.textContent = 'Scan for New Releases';
_wlSetChipLabel(button, 'Scan for New Releases');
button.classList.remove('btn-processing');
}
@ -3405,7 +3518,7 @@ async function updateSimilarArtists() {
const scanButton = document.getElementById('scan-watchlist-btn');
button.disabled = true;
button.textContent = 'Updating...';
_wlSetChipLabel(button, 'Updating...');
button.classList.add('btn-processing');
if (scanButton) scanButton.disabled = true;
@ -3430,7 +3543,7 @@ async function updateSimilarArtists() {
const scanButton = document.getElementById('scan-watchlist-btn');
button.disabled = false;
button.textContent = 'Update Similar Artists';
_wlSetChipLabel(button, 'Update Similar Artists');
button.classList.remove('btn-processing');
if (scanButton) scanButton.disabled = false;
@ -3453,7 +3566,7 @@ async function pollSimilarArtistsUpdate() {
if (data.status === 'completed') {
if (button) {
button.disabled = false;
button.textContent = 'Update Similar Artists';
_wlSetChipLabel(button, 'Update Similar Artists');
button.classList.remove('btn-processing');
}
if (scanButton) scanButton.disabled = false;
@ -3464,7 +3577,7 @@ async function pollSimilarArtistsUpdate() {
} else if (data.status === 'error') {
if (button) {
button.disabled = false;
button.textContent = 'Update Similar Artists';
_wlSetChipLabel(button, 'Update Similar Artists');
button.classList.remove('btn-processing');
}
if (scanButton) scanButton.disabled = false;
@ -3491,7 +3604,7 @@ async function pollSimilarArtistsUpdate() {
if (button) {
button.disabled = false;
button.textContent = 'Update Similar Artists';
_wlSetChipLabel(button, 'Update Similar Artists');
button.classList.remove('btn-processing');
}
if (scanButton) scanButton.disabled = false;

View file

@ -3127,8 +3127,8 @@ function _renderCandidatesModal(data) {
<input type="text"
class="candidates-manual-search-input"
id="candidates-manual-search-input"
placeholder="Search for a different track..."
maxlength="200" />
placeholder="Search, or paste a Tidal / Qobuz track link..."
maxlength="300" />
${sourceControl}
<button class="candidates-manual-search-btn"
id="candidates-manual-search-btn"

View file

@ -3413,6 +3413,24 @@ function closeHelperSearch() {
// projects that span multiple commits before shipping. Strip the flag at
// release time and add a real `date:` line at the top of the version block.
const WHATS_NEW = {
'2.6.9': [
{ date: 'June 9, 2026 — 2.6.9 release' },
{ title: 'Security: the admin launch PIN is now actually enforced (#832)', desc: 'the "require PIN on launch" lock used to be a client-side overlay only — the server never checked it, so removing the overlay (Safari\'s "Hide Distracting Items", browser devtools, or any non-browser client like curl) gave full access to every API. If you expose SoulSync publicly through a reverse proxy, that was wide open. There is now a real server-side gate: while the launch PIN is on, every request from an unverified session is rejected until the PIN is entered — data, settings, profile management, even websockets. A side door was closed too (the internal API-key endpoints were "no auth required", so a key could be minted to bypass the lock). The public REST API still works for valid API-key holders, and a deep link / refresh while locked now lands on the lock screen instead of raw JSON. Worth saying plainly: this makes the launch PIN do its job — for public exposure, keep your reverse proxy\'s own auth in front of it too.', page: 'settings' },
{ title: 'Security: your saved secrets stop being sent to the browser', desc: 'the settings API returned the full decrypted config to the browser — every API key, OAuth secret, Plex/Jellyfin token, and service password in cleartext. They are encrypted at rest, but that does nothing once the API hands the plaintext to the client (devtools, HAR captures, an XSS, a screen share). Configured secrets are now masked before they leave the server (shown as dots in the settings fields); saving an untouched form keeps the real value, editing replaces it, and clearing a field still removes it. No secret reaches the browser anymore.', page: 'settings' },
{ title: 'Delete now works on tracks with curly apostrophes (#833, the-hang-man)', desc: 'a track like "I\'m Upset" could delete its database row but leave the file behind. The library stored the title with a curly apostrophe (what Spotify/Apple metadata uses) while the file was written to disk with a straight one, so the delete compared the wrong characters and missed. File resolution now folds typographic look-alikes (curly vs straight quotes, en/em dashes, ellipsis) when matching the on-disk file — it never renames, just finds the file that\'s actually there. Fixes existing mismatched files with no re-import, and covers sidecar cleanup and dead-file checks too, not just delete.', page: 'library' },
{ title: 'Watchlist live scan — a full revamp with a per-run History (#831)', desc: 'the watchlist scan display was rebuilt. A bespoke live deck shows the artist being scanned with a large portrait, a real progress bar, found/added counters, and a live feed of exactly which tracks a scan found and added to your wishlist — with zero layout shift as data arrives. A new History button opens a modal of every past scan and the tracks each one added (not downloaded — added to the wishlist by the watchlist), persisted per run. The Download Origins view also groups its entries by action, and the whole page got the house-style chip buttons and a reskinned Global Settings modal.', page: 'watchlist' },
{ title: 'Spotify (no auth): renamed, default for enrichment, and fuller search', desc: '"Spotify Free" is now "Spotify (no auth)" — a clearer name for the credential-free metadata source. A new always-visible toggle in the Spotify settings lets the background enrichment worker use it (on by default); if you have Spotify auth, enrichment works either way, and the toggle overrides. The no-auth source also gained album search (via the artist\'s discography, closing a gap where it could only do tracks), and the daily-budget → free bridge now actually diverts to the free source when your real-API budget is spent instead of stalling.', page: 'settings' },
{ title: 'Owned tracks stop coming back to the wishlist (#825, carlosjfcasero)', desc: 'two fixes so tracks you already own stop reappearing as wishlist items. Manually adding an album to the wishlist now checks ownership and skips tracks you have. And the track matcher no longer reads a bracketed subtitle (e.g. a "(Bonus Track)" or remix qualifier) as a different song, so a track you own isn\'t treated as missing — version markers (live / remix / acoustic) are still respected and not collapsed.', page: 'watchlist' },
{ title: 'Playlist sync append: no more duplicates, and it honors your sync mode (#823)', desc: 'append mode no longer re-adds every track on each run (which turned playlists into N copies of themselves) — it now dedupes against what\'s already there. Automated syncs also honor the per-playlist sync mode you configured instead of always running "replace".', page: 'sync' },
{ title: 'Download Discography: explains skips + credits collaborations correctly (#830, Vicky)', desc: 'downloading an artist\'s discography no longer silently skips tracks with a flat "No new tracks". It now shows why each was skipped — already owned, by a different artist, or filtered out. And collaboration tracks credited as one combined string (e.g. "A, B & C") are matched correctly instead of being dropped as an artist mismatch.', page: 'library' },
{ title: 'Album downloads reuse an existing folder (#829)', desc: 'when an album already has a folder on disk, a new batch download drops into it instead of creating a split second folder — so an album stays in one place.', page: 'downloads' },
{ title: 'Dead File Cleaner stops flagging your whole library (#828)', desc: 'the Dead File detector no longer marks an entire library as dead when the files are simply unreachable (an unmounted or temporarily missing path). When an implausibly large share of tracks come back unresolvable, it now treats that as an environment problem and aborts with zero findings instead of proposing to delete everything.', page: 'dashboard' },
{ title: 'Settings stop flooding the log (#827)', desc: 'sitting on the Logs tab no longer triggers settings auto-saves that spam app.log with save lines — auto-save is suppressed while you\'re viewing logs.', page: 'settings' },
{ title: 'Wishlist album bundles no longer jam the download pool (#740, Sokhi)', desc: 'per-album wishlist bundles are serialized so they stop flooding the shared search/download workers and blocking everything behind them.', page: 'downloads' },
{ title: 'Multi-artist tags apply on Search → Download Now (Netti93)', desc: 'a track downloaded straight from Search now carries its real metadata source through, so your multi-artist tagging settings actually take effect instead of being lost on the way to the downloader.', page: 'search' },
{ title: 'Full release dates written to your tags (#824)', desc: 'the tag writer stores and writes full yyyy-mm-dd release dates end to end instead of downgrading them to just the year.', page: 'library' },
{ title: 'Watchlist stops re-flagging decimal-volume albums as duplicates (Sokhi)', desc: 'different decimal-volume editions of an album (e.g. "Vol. 4" vs "Vol. 4.5") are no longer treated as the same album, so the watchlist stops collapsing or skipping one of them.', page: 'watchlist' },
],
'2.6.8': [
{ date: 'June 7, 2026 — 2.6.8 release' },
{ title: 'Blocklist — never download an artist, album, or track again', desc: 'a proper artist / album / track blocklist, reachable from the Blocklist button on the Watchlist page. Add an entry by pasting a Spotify / iTunes / Deezer / MusicBrainz link or ID; SoulSync resolves it and, in the background, matches it across your other sources so a ban added by Spotify ID also blocks the same thing arriving via Deezer or Tidal. Bans are enforced everywhere it matters: the wishlist never re-adds a blocked item, the download queue (playlist sync / album / discography) skips them, and starting a manual download of a blocked artist asks for a "download anyway?" confirm first. Profile-scoped, and your old Discovery blacklist is migrated in automatically.', page: 'watchlist' },

View file

@ -2794,10 +2794,7 @@ async function startDiscographyDownload() {
item.classList.remove('active');
if (data.status === 'done') {
const parts = [];
if (data.tracks_added > 0) parts.push(`${data.tracks_added} added`);
if (data.tracks_skipped > 0) parts.push(`${data.tracks_skipped} skipped`);
statusEl.textContent = parts.join(', ') || 'No new tracks';
statusEl.textContent = _discogItemStatus(data);
iconEl.innerHTML = data.tracks_added > 0 ? '<span class="discog-check">✓</span>' : '<span class="discog-skip">—</span>';
item.classList.add(data.tracks_added > 0 ? 'done' : 'skipped');
} else if (data.status === 'error') {
@ -2814,6 +2811,22 @@ async function startDiscographyDownload() {
}
}
// Build a clear per-album status from the discography stream payload. The
// backend already reports WHY tracks weren't added — other-artist credit,
// already owned/queued, or content-filtered — so surface that instead of a
// misleading "No new tracks" (#830: collab tracks dropped for "artist mismatch"
// looked identical to "you already have it").
function _discogItemStatus(data) {
const parts = [];
const added = data.tracks_added || 0;
if (added > 0) parts.push(`${added} added`);
if ((data.tracks_skipped_owned || 0) > 0) parts.push(`${data.tracks_skipped_owned} already owned`);
if ((data.tracks_skipped || 0) > 0) parts.push(`${data.tracks_skipped} already queued`);
if ((data.tracks_skipped_artist || 0) > 0) parts.push(`${data.tracks_skipped_artist} by other artists`);
if ((data.tracks_skipped_filter || 0) > 0) parts.push(`${data.tracks_skipped_filter} filtered out`);
return parts.join(', ') || 'No tracks';
}
function _handleDiscogProgress(data) {
if (data.type === 'album') {
const item = document.getElementById(`discog-prog-${data.album_id}`);
@ -2826,10 +2839,7 @@ function _handleDiscogProgress(data) {
statusEl.textContent = `Processing ${data.tracks_total} tracks...`;
item.classList.add('active');
} else if (data.status === 'done') {
const parts = [];
if (data.tracks_added > 0) parts.push(`${data.tracks_added} added`);
if (data.tracks_skipped > 0) parts.push(`${data.tracks_skipped} skipped`);
statusEl.textContent = parts.join(', ') || 'No new tracks';
statusEl.textContent = _discogItemStatus(data);
iconEl.innerHTML = data.tracks_added > 0 ? '<span class="discog-check">✓</span>' : '<span class="discog-skip">—</span>';
item.classList.remove('active');
item.classList.add(data.tracks_added > 0 ? 'done' : 'skipped');
@ -3754,6 +3764,7 @@ function renderAlbumMetaRow(album) {
const fields = [
{ key: 'title', label: 'Title', value: album.title || '' },
{ key: 'year', label: 'Year', value: album.year || '', type: 'number' },
{ key: 'release_date', label: 'Release Date', value: album.release_date || '', placeholder: 'YYYY-MM-DD' },
{ key: 'genres', label: 'Genres', value: Array.isArray(album.genres) ? album.genres.join(', ') : (album.genres || '') },
{ key: 'label', label: 'Label', value: album.label || '' },
{ key: 'style', label: 'Style', value: album.style || '' },
@ -3774,6 +3785,7 @@ function renderAlbumMetaRow(album) {
const input = document.createElement('input');
input.className = 'enhanced-album-meta-input';
input.type = f.type || 'text';
if (f.placeholder) input.placeholder = f.placeholder;
input.dataset.albumId = album.id;
input.dataset.field = f.key;
input.value = String(f.value);
@ -5952,6 +5964,7 @@ async function saveAlbumMetadata(albumId) {
const inputs = metaRow.querySelectorAll('.enhanced-album-meta-input');
const updates = {};
let invalidDate = false;
inputs.forEach(input => {
const field = input.dataset.field;
@ -5965,11 +5978,20 @@ async function saveAlbumMetadata(albumId) {
} else if (field === 'year' || field === 'explicit' || field === 'track_count') {
const numVal = value !== '' ? parseInt(value) : null;
if (numVal !== (album[field] || null)) updates[field] = numVal;
} else if (field === 'release_date') {
// Accept empty, YYYY, YYYY-MM or YYYY-MM-DD (#824 full release dates).
if (value && !/^\d{4}(-\d{2}(-\d{2})?)?$/.test(value)) { invalidDate = true; return; }
if ((value || '') !== (album.release_date || '')) updates[field] = value || null;
} else {
if ((value || '') !== (album[field] || '')) updates[field] = value || null;
}
});
if (invalidDate) {
showToast('Release Date must be YYYY-MM-DD (or just YYYY)', 'error');
return;
}
if (Object.keys(updates).length === 0) {
showToast('No album changes to save', 'error');
return;

View file

@ -99,7 +99,8 @@ function _renderOriginEntries() {
return;
}
const ctxLabel = _originActiveTab === 'watchlist' ? 'Watchlist artist' : 'Playlist';
body.innerHTML = _originEntries.map(e => {
const entryRow = (e) => {
const checked = _originSelected.has(e.id) ? 'checked' : '';
const thumb = e.thumb_url
? `<img class="library-history-thumb" src="${escapeHtml(e.thumb_url)}" alt="" loading="lazy"
@ -116,7 +117,6 @@ function _renderOriginEntries() {
<div class="library-history-entry-title">${escapeHtml(e.title || 'Unknown')}</div>
<div class="library-history-entry-meta">${escapeHtml(e.artist_name || '')}${e.album_name ? ' — ' + escapeHtml(e.album_name) : ''}</div>
</div>
<span class="origin-context-badge" title="${ctxLabel}">${escapeHtml(e.origin_context || '—')}</span>
${e.quality ? `<span class="library-history-badge">${escapeHtml(e.quality)}</span>` : ''}
<div class="library-history-entry-time">${escapeHtml(_originFormatTime(e.created_at))}</div>
<button class="lh-audit-btn origin-row-delete" title="Delete this file + entry"
@ -125,10 +125,39 @@ function _renderOriginEntries() {
${fname ? `<div class="library-history-entry-source"><span class="lh-prov-label">File:</span> ${escapeHtml(fname)}</div>` : ''}
</div>
</div>`;
}).join('');
};
// #831: group entries by what triggered them (watchlist artist / playlist
// name) instead of a flat list with a per-row badge. Entries arrive
// newest-first, so groups order themselves by their newest download.
const groups = new Map();
for (const e of _originEntries) {
const key = e.origin_context || '—';
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(e);
}
body.innerHTML = Array.from(groups.entries()).map(([ctx, entries]) => `
<div class="origin-group">
<button type="button" class="origin-group-header" onclick="toggleOriginGroup(this)" title="${ctxLabel}">
<span class="origin-group-caret"></span>
<span class="origin-group-name">${escapeHtml(ctx)}</span>
<span class="origin-group-count">${entries.length} track${entries.length !== 1 ? 's' : ''}</span>
</button>
<div class="origin-group-body">${entries.map(entryRow).join('')}</div>
</div>`).join('');
_updateOriginDeleteButton();
}
function toggleOriginGroup(btn) {
const bodyEl = btn.parentElement.querySelector('.origin-group-body');
const caret = btn.querySelector('.origin-group-caret');
if (!bodyEl) return;
const open = bodyEl.style.display !== 'none';
bodyEl.style.display = open ? 'none' : '';
if (caret) caret.textContent = open ? '▸' : '▾';
}
function toggleOriginEntry(id, on) {
if (on) _originSelected.add(id); else _originSelected.delete(id);
_updateOriginDeleteButton();

View file

@ -750,6 +750,9 @@ function initializeSearchModeToggle() {
// Enrich each track with full album object (needed for wishlist functionality)
const enrichedTracks = albumData.tracks.map(track => ({
...track,
// Carry the metadata source for source-specific import logic
// (Deezer contributors upgrade for multi-artist tags).
source: track.source || album.source || null,
album: {
name: albumData.name,
id: albumData.id,
@ -902,7 +905,16 @@ function initializeSearchModeToggle() {
const enrichedTrack = {
id: track.id,
name: track.name,
artists: [track.artist], // Convert string to array for modal compatibility
// Carry the metadata source so the import pipeline can run
// source-specific logic (Deezer contributors upgrade for
// multi-artist tags) on the downloaded file.
source: track.source || null,
// Prefer the real artist list (Spotify/Tidal collabs) over the
// joined "A, B" display string, so downloads get proper
// multi-artist tags instead of one combined artist.
artists: (Array.isArray(track.artists) && track.artists.length)
? track.artists
: [track.artist],
album: {
name: track.album,
id: null,

View file

@ -45,6 +45,11 @@ function debouncedAutoSaveSettings() {
// fields on load — those aren't user edits and must not trigger a full
// save (which re-initializes every backend service client).
if (window._suppressSettingsAutoSave) return;
// #827: the Logs tab has no savable settings — its live-viewer controls
// (source picker, filters, auto-scroll) were tripping the auto-save and
// flooding app.log with "Settings saved" lines, drowning out the logs the
// user is trying to read. Never auto-save while the Logs tab is active.
if (document.querySelector('.stg-tab.active')?.dataset.tab === 'logs') return;
if (settingsAutoSaveTimer) clearTimeout(settingsAutoSaveTimer);
settingsAutoSaveTimer = setTimeout(() => saveSettings(true), 2000);
}
@ -1056,6 +1061,10 @@ async function loadSettingsData() {
const _metaSel = (_fbSrc === 'spotify' && settings.metadata?.spotify_free === true)
? 'spotify_free' : _fbSrc;
document.getElementById('metadata-fallback-source').value = _metaSel;
const _efEl = document.getElementById('metadata-spotify-free-enrichment');
// Default ON: unset (undefined) reads as enabled, matching the worker's
// config default (metadata.spotify_free_enrichment defaults True).
if (_efEl) _efEl.checked = settings.metadata?.spotify_free_enrichment !== false;
// Populate Hydrabase settings
const hbConfig = settings.hydrabase || {};
@ -1391,12 +1400,49 @@ async function loadSettingsData() {
console.error('Error checking dev mode:', error);
}
// Secret fields now arrive masked as REDACTED_SECRET_SENTINEL (#832
// follow-up) — wire them so editing replaces the mask instead of typing
// on top of it, and an untouched field re-masks on blur (round-trips the
// sentinel, which the server treats as "keep existing").
_wireRedactedSecrets();
} catch (error) {
console.error('Error loading settings:', error);
showToast('Failed to load settings', 'error');
}
}
// Mirrors ConfigManager.REDACTED_SENTINEL — secrets are never sent to the
// browser; configured ones come back as this placeholder (rendered as dots in
// the password inputs).
const REDACTED_SECRET_SENTINEL = '__redacted_unchanged__';
function _wireRedactedSecrets() {
document.querySelectorAll('input[type="password"]').forEach(el => {
if (el.dataset.redactWired === '1') return;
el.dataset.redactWired = '1';
// Clear the mask on focus so the user types a fresh value, not on top
// of the sentinel.
el.addEventListener('focus', () => {
if (el.value === REDACTED_SECRET_SENTINEL) {
el.value = '';
el.dataset.wasRedacted = '1';
}
});
// Untouched (focused but not edited, left empty) → restore the mask so
// save round-trips the sentinel and the real secret is kept.
el.addEventListener('blur', () => {
if (el.dataset.wasRedacted === '1' && el.value === '') {
el.value = REDACTED_SECRET_SENTINEL;
el.dataset.wasRedacted = '';
}
});
// Real typing means a genuine change/clear — drop the redacted mark so
// blur won't re-mask (an emptied field then saves as a real clear).
el.addEventListener('input', () => { el.dataset.wasRedacted = ''; });
});
}
async function changeLogLevel() {
const selector = document.getElementById('log-level-select');
const level = selector.value;
@ -2881,7 +2927,10 @@ async function saveSettings(quiet = false) {
// 'Spotify Free' is stored as the spotify source + a flag, so all
// downstream 'spotify' routing is unchanged.
fallback_source: metadataSource === 'spotify_free' ? 'spotify' : metadataSource,
spotify_free: metadataSource === 'spotify_free'
spotify_free: metadataSource === 'spotify_free',
// Independent opt-in: run the enrichment worker on Spotify Free even
// when an official account is connected (spares the official quota).
spotify_free_enrichment: document.getElementById('metadata-spotify-free-enrichment')?.checked || false
},
hydrabase: {
url: document.getElementById('hydrabase-url').value,

View file

@ -1678,6 +1678,12 @@ async function retryFailedMirroredDiscovery(urlHash) {
state.phase = 'discovering';
state.status = 'discovering';
state.discovery_progress = 0;
// #815: stamp a baseline so the completion toast can report how many
// of these retried tracks were newly found (not just overall matched).
state._retryDiscovery = {
matchesBefore: state.spotify_matches || 0,
retryCount: data.retry_count,
};
}
// Update modal buttons to show discovering state

View file

@ -2673,7 +2673,10 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.helper-first-launch-tip {
position: fixed;
bottom: 34px;
right: 84px;
/* Clear the ? float button (right:24 + 48 wide left edge ~72px from the
right) AND its 8px pulse ring, with margin, so the tip never covers the
button (#817). Was 84px only ~4px off the pulse ring, so they touched. */
right: 96px;
padding: 8px 16px;
background: rgba(16, 16, 16, 0.95);
border: 1px solid rgba(var(--accent-rgb), 0.3);
@ -19888,6 +19891,651 @@ body.helper-mode-active #dashboard-activity-feed:hover {
margin-bottom: 10px;
}
/* #831: expandable per-run track ledger under the scan summary */
.watchlist-scan-tracks-toggle {
margin-top: 10px;
padding: 5px 14px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 14px;
color: #ccc;
font-size: 12px;
cursor: pointer;
transition: background 0.15s ease;
}
.watchlist-scan-tracks-toggle:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.watchlist-scan-tracks {
margin-top: 10px;
max-height: 280px;
overflow-y: auto;
text-align: left;
}
.watchlist-scan-tracks-section {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #888;
margin: 10px 0 4px;
}
.watchlist-scan-tracks .watchlist-live-addition-item {
position: relative;
}
.watchlist-scan-track-badge {
margin-left: auto;
flex-shrink: 0;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.watchlist-scan-track-badge.added {
background: rgba(80, 200, 120, 0.15);
color: #6fd99a;
}
.watchlist-scan-track-badge.skipped {
background: rgba(255, 255, 255, 0.08);
color: #999;
}
/* ── #831 round 2: full-width live scan deck ───────────────────────────── */
.wl-scan-deck {
width: 100%;
margin-top: 15px;
padding: 18px 20px 16px;
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.07), rgba(255, 255, 255, 0.02) 55%);
border: 1px solid rgba(var(--accent-rgb), 0.22);
border-radius: 14px;
backdrop-filter: blur(8px);
text-align: left;
}
.wl-scan-deck-head {
display: flex;
align-items: center;
gap: 10px;
}
.wl-scan-live-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: rgb(var(--accent-rgb));
box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.55);
animation: wl-live-pulse 1.6s ease-out infinite;
}
@keyframes wl-live-pulse {
0% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.55); }
70% { box-shadow: 0 0 0 9px rgba(var(--accent-rgb), 0); }
100% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0); }
}
.wl-scan-live-label {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1.2px;
color: rgb(var(--accent-light-rgb));
}
.wl-scan-progress-text {
font-size: 12px;
color: rgba(255, 255, 255, 0.55);
}
.wl-scan-counters {
margin-left: auto;
display: flex;
gap: 8px;
}
.wl-scan-counter {
display: flex;
align-items: baseline;
gap: 5px;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.wl-scan-counter.added {
background: rgba(80, 200, 120, 0.1);
border-color: rgba(80, 200, 120, 0.25);
}
.wl-scan-counter-num {
font-size: 16px;
font-weight: 700;
color: #fff;
display: inline-block;
}
.wl-scan-counter.added .wl-scan-counter-num {
color: #6fd99a;
}
.wl-scan-counter-num.pop {
animation: wl-counter-pop 0.35s ease;
}
@keyframes wl-counter-pop {
0% { transform: scale(1); }
40% { transform: scale(1.35); }
100% { transform: scale(1); }
}
.wl-scan-counter-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(255, 255, 255, 0.45);
}
.wl-scan-progress {
margin: 12px 0 14px;
height: 5px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.07);
overflow: hidden;
}
.wl-scan-progress-bar {
height: 100%;
border-radius: 3px;
background: linear-gradient(90deg, rgb(var(--accent-rgb)), rgb(var(--accent-light-rgb)));
position: relative;
transition: width 0.6s ease;
}
.wl-scan-progress-bar::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.35), transparent);
animation: wl-progress-shimmer 1.8s linear infinite;
}
@keyframes wl-progress-shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.wl-scan-deck-body {
display: grid;
grid-template-columns: minmax(320px, 1.1fr) minmax(300px, 1fr);
gap: 24px;
align-items: stretch;
}
@media (max-width: 760px) {
.wl-scan-deck-body { grid-template-columns: 1fr; }
}
/* Hero big square portrait (artist-page language) with the current album
stamped as an overlay badge, so the layout NEVER shifts when album art is
missing: the badge keeps its slot and shows a glyph placeholder instead. */
.wl-scan-hero {
display: flex;
align-items: center;
gap: 20px;
min-width: 0;
}
.wl-scan-portrait {
position: relative;
width: 148px;
height: 148px;
border-radius: 16px;
background: #181818;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(var(--accent-rgb), 0.25);
flex-shrink: 0;
}
/* glyph placeholder behind the (possibly hidden) artist image */
.wl-scan-portrait::before {
content: '♪';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 42px;
color: rgba(255, 255, 255, 0.12);
}
.wl-scan-portrait-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border-radius: 16px;
object-fit: cover;
}
.wl-scan-album-thumb {
position: absolute;
right: -12px;
bottom: -12px;
width: 62px;
height: 62px;
border-radius: 10px;
background: #181818;
border: 3px solid #121212;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.55);
overflow: hidden;
}
.wl-scan-album-thumb::before {
content: '♪';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: rgba(255, 255, 255, 0.15);
}
.wl-scan-album-thumb img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.wl-scan-hero-text {
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
}
.wl-scan-artist-name {
font-size: 24px;
font-weight: 800;
letter-spacing: -0.3px;
color: #fff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wl-scan-phase {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: rgba(var(--accent-light-rgb), 0.9);
}
/* Fixed-height "now checking" block placeholders keep the slot when the
scan hasn't reached an album/track yet, so nothing jumps. */
.wl-scan-now {
margin-top: 8px;
padding: 8px 12px;
border-left: 2px solid rgba(var(--accent-rgb), 0.5);
background: rgba(255, 255, 255, 0.03);
border-radius: 0 8px 8px 0;
min-height: 44px;
}
.wl-scan-album-name {
font-size: 14px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wl-scan-track-name {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Feed inset panel (artist-page sidebar language) with a FIXED height so
the deck is the same size whether 0 or 10 tracks have been added. */
.wl-scan-feed {
min-width: 0;
display: flex;
flex-direction: column;
height: 172px;
padding: 12px 14px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 12px;
}
.wl-scan-feed-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: #6fd99a;
margin-bottom: 8px;
flex-shrink: 0;
}
.wl-scan-feed-list {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 5px;
}
.wl-scan-feed-list .watchlist-live-addition-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.3);
font-size: 12px;
}
.wl-scan-feed-list .watchlist-live-addition-item {
padding: 5px 8px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
}
.wl-scan-feed-list .watchlist-live-addition-item img {
width: 34px;
height: 34px;
border-radius: 5px;
}
.wl-scan-feed-list .watchlist-live-addition-item.is-new {
animation: wl-feed-slide-in 0.45s cubic-bezier(0.2, 0.9, 0.3, 1.2);
}
@keyframes wl-feed-slide-in {
0% { opacity: 0; transform: translateY(-10px) scale(0.96); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
/* ── #831 round 2: scan history modal run cards ────────────────────────── */
.wlh-run {
margin-bottom: 6px;
}
.wlh-run-header {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 14px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
cursor: pointer;
text-align: left;
transition: background 0.15s ease;
}
.wlh-run-header:hover {
background: rgba(255, 255, 255, 0.08);
}
.wlh-run-when {
font-size: 13px;
font-weight: 600;
color: #fff;
}
.wlh-run-status.cancelled {
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
background: rgba(248, 113, 113, 0.12);
color: #f87171;
}
.wlh-run-stats {
margin-left: auto;
display: flex;
gap: 14px;
}
.wlh-run-stat {
display: flex;
align-items: baseline;
gap: 4px;
font-size: 14px;
font-weight: 700;
color: rgba(255, 255, 255, 0.85);
}
.wlh-run-stat.added {
color: #6fd99a;
}
.wlh-run-stat i {
font-style: normal;
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.4px;
color: rgba(255, 255, 255, 0.4);
}
.wlh-run-body {
padding: 8px 6px 4px 14px;
}
.wlh-run-body .origin-modal-loading,
.wlh-empty {
padding: 16px;
}
.wlh-run-body .watchlist-live-addition-item {
margin-bottom: 4px;
}
/* ── #831 round 3: watchlist action chips (artist-page button language) ── */
.wl-chip {
--chip-rgb: 148, 163, 184;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 16px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
font-family: inherit;
color: rgba(var(--chip-rgb), 1);
background: linear-gradient(135deg, rgba(var(--chip-rgb), 0.15) 0%, rgba(var(--chip-rgb), 0.05) 100%);
border: 1px solid rgba(var(--chip-rgb), 0.3);
border-radius: 8px;
cursor: pointer;
transition: all 0.25s ease;
outline: none;
overflow: hidden;
}
.wl-chip:hover {
background: linear-gradient(135deg, rgba(var(--chip-rgb), 0.25) 0%, rgba(var(--chip-rgb), 0.12) 100%);
border-color: rgba(var(--chip-rgb), 0.5);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(var(--chip-rgb), 0.25);
}
.wl-chip:active {
transform: translateY(0);
box-shadow: none;
}
.wl-chip svg {
transition: transform 0.25s ease;
flex-shrink: 0;
}
.wl-chip:hover svg {
transform: scale(1.12);
}
.wl-chip--blue { --chip-rgb: 96, 165, 250; }
.wl-chip--green { --chip-rgb: 111, 217, 154; }
.wl-chip--amber { --chip-rgb: 251, 191, 36; }
.wl-chip--red { --chip-rgb: 248, 113, 113; }
.wl-chip--slate { --chip-rgb: 165, 180, 203; }
/* Primary CTA — solid accent gradient + shimmer sweep */
.wl-chip--cta {
color: #fff;
background: linear-gradient(135deg, rgb(var(--accent-rgb)), rgb(var(--accent-light-rgb)));
border: 1px solid rgba(var(--accent-light-rgb), 0.6);
box-shadow: 0 4px 18px rgba(var(--accent-rgb), 0.35);
}
.wl-chip--cta:hover {
background: linear-gradient(135deg, rgb(var(--accent-rgb)), rgb(var(--accent-light-rgb)));
border-color: rgba(var(--accent-light-rgb), 0.9);
transform: translateY(-1px);
box-shadow: 0 6px 24px rgba(var(--accent-rgb), 0.5);
}
.wl-chip-shimmer {
position: absolute;
inset: 0;
background: linear-gradient(105deg, transparent 40%, rgba(255, 255, 255, 0.25) 50%, transparent 60%);
transform: translateX(-100%);
animation: wl-chip-shimmer 3.2s ease-in-out infinite;
pointer-events: none;
}
@keyframes wl-chip-shimmer {
0% { transform: translateX(-100%); }
55% { transform: translateX(100%); }
100% { transform: translateX(100%); }
}
/* Header meta chips */
.wl-meta-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.wl-meta-chip--accent {
color: rgb(var(--accent-light-rgb));
background: rgba(var(--accent-rgb), 0.1);
border-color: rgba(var(--accent-rgb), 0.3);
}
/* ── #831 round 3: Global Settings modal reskin ───────────────────────── */
.wl-global-modal-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 22px 28px 18px;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
}
.wl-global-modal-title {
margin: 0;
font-size: 20px;
font-weight: 800;
letter-spacing: -0.2px;
color: #fff;
}
.wl-global-modal-sub {
margin: 4px 0 0;
font-size: 13px;
color: rgba(255, 255, 255, 0.5);
}
.wl-global-modal-close {
flex-shrink: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
cursor: pointer;
transition: all 0.15s ease;
}
.wl-global-modal-close:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
/* Option cards: live checked-state feedback (applies to the per-artist config
modal too same components, same standard). */
.config-option:has(input[type="checkbox"]:checked) {
border-color: rgba(var(--accent-rgb), 0.45);
background: rgba(var(--accent-rgb), 0.07);
}
.config-option:has(input[type="checkbox"]:checked) .config-option-icon {
filter: none;
opacity: 1;
}
.config-option:not(:has(input[type="checkbox"]:checked)) .config-option-icon {
filter: grayscale(0.7);
opacity: 0.55;
}
/* The global-override master toggle gets a stronger enabled treatment */
.global-override-toggle.enabled {
border-color: rgba(var(--accent-rgb), 0.6) !important;
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.12), rgba(var(--accent-rgb), 0.04)) !important;
box-shadow: 0 0 22px rgba(var(--accent-rgb), 0.12);
}
.wl-global-modal .config-section-title {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.8px;
color: rgb(var(--accent-light-rgb));
}
/* Watchlist Search */
.watchlist-search-input {
@ -59389,7 +60037,11 @@ body.reduce-effects #page-particles-canvas {
}
.adl-batch-panel.collapsed .adl-batch-active,
.adl-batch-panel.collapsed .adl-batch-history-section {
.adl-batch-panel.collapsed .adl-batch-history-section,
/* The active-batches summary line ("N batches · M downloading · …") is shown
via JS and was NOT hidden on collapse, so it overflowed the 44px rail as
clipped text (#814). Hide it with the rest of the panel body. */
.adl-batch-panel.collapsed .adl-batch-summary {
display: none;
}
@ -61931,7 +62583,10 @@ body.reduce-effects .dash-card::after {
}
.dash-card:hover {
border-color: rgba(var(--accent-rgb), 0.35);
transform: translateY(-3px);
/* No translateY lift: moving the card up on hover pulls its bottom edge off
the cursor when you hover that edge, which un-hovers drops re-hovers
in a rapid flicker loop (#816). The stronger shadow + glow below already
reads as "raised" without moving the hit box. */
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.40),
0 4px 14px rgba(0, 0, 0, 0.22),
@ -62133,7 +62788,9 @@ body.reduce-effects .dash-card::after {
.qa-tile:hover,
.qa-tile:focus-visible {
transform: translateY(-2px);
/* No translateY lift see .dash-card:hover (#816 hover-flicker). overflow
is hidden here so a pseudo-element gap-buffer can't help; the glow/shadow
below carries the hover state. */
border-color: rgba(var(--accent-rgb), 0.32);
box-shadow:
0 16px 40px rgba(0, 0, 0, 0.5),
@ -62306,7 +62963,11 @@ body.reduce-effects .dash-card::after {
align-items: center;
justify-content: space-between;
gap: clamp(4px, 0.5cqw, 8px);
opacity: 0.45;
/* The flow sits in the bottom row, directly behind the green "Open →" CTA
at 0.45 the accent nodes/line competed with the CTA and read as clutter
(#816 "automations looks a bit strange"). Toned to a faint background
texture; it still brightens on hover. */
opacity: 0.22;
transition: opacity 0.35s ease;
}
@ -66692,6 +67353,55 @@ body.em-scroll-lock { overflow: hidden; }
color: #f87171 !important;
}
/* #831: origins grouped by what triggered them (artist / playlist) */
.origin-group {
margin-bottom: 6px;
}
.origin-group-header {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
cursor: pointer;
text-align: left;
transition: background 0.15s ease;
}
.origin-group-header:hover {
background: rgba(255, 255, 255, 0.08);
}
.origin-group-caret {
color: rgba(255, 255, 255, 0.45);
font-size: 11px;
width: 12px;
}
.origin-group-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 600;
color: rgb(var(--accent-light-rgb));
}
.origin-group-count {
flex: 0 0 auto;
font-size: 11px;
color: rgba(255, 255, 255, 0.45);
}
.origin-group-body {
padding: 4px 0 2px 6px;
}
/* ── Blocklist modal (artist/album/track bans) ── */
.blocklist-modal {
position: relative;

View file

@ -9248,6 +9248,24 @@ async function startYouTubeDiscovery(urlHash) {
}
}
// Discovery-complete toast. For a "Retry Failed" run (#815) it reports how many
// of the retried tracks were newly found this attempt, instead of the generic
// message — the baseline is stamped on the state by retryFailedMirroredDiscovery.
function _discoveryCompleteToast(urlHash) {
const st = youtubePlaylistStates[urlHash];
const retry = st && st._retryDiscovery;
if (retry) {
const found = Math.max(0, (st.spotify_matches || 0) - (retry.matchesBefore || 0));
const stillFailed = Math.max(0, (retry.retryCount || 0) - found);
delete st._retryDiscovery;
let msg = `Retry complete: ${found} of ${retry.retryCount} newly found`;
if (stillFailed > 0) msg += `, ${stillFailed} still not found`;
showToast(msg, found > 0 ? 'success' : 'info');
return;
}
showToast('Discovery complete!', 'success');
}
function startYouTubeDiscoveryPolling(urlHash) {
// Stop any existing polling
if (activeYouTubePollers[urlHash]) {
@ -9274,7 +9292,7 @@ function startYouTubeDiscoveryPolling(urlHash) {
if (st) st.phase = 'discovered';
updateYouTubeCardPhase(urlHash, 'discovered');
updateYouTubeModalButtons(urlHash, 'discovered');
showToast('Discovery complete!', 'success');
_discoveryCompleteToast(urlHash);
}
};
}
@ -9322,7 +9340,7 @@ function startYouTubeDiscoveryPolling(urlHash) {
updateYouTubeModalButtons(urlHash, 'discovered');
console.log('✅ Discovery complete:', urlHash);
showToast('Discovery complete!', 'success');
_discoveryCompleteToast(urlHash);
}
} catch (error) {

View file

@ -0,0 +1,149 @@
// Watchlist Scan History modal (#831 round 2).
//
// Every scan run is persisted server-side (watchlist_scan_runs) with its full
// track ledger — the tracks the run ADDED to the wishlist plus the found-but-
// skipped ones. This modal lists past runs (newest first) and expands each
// into its ledger. Note: this is what the watchlist PUT IN THE WISHLIST — the
// watchlist never downloads; downloaded tracks live in Download Origins.
let _wlhModalEl = null;
let _wlhRuns = [];
const _wlhEventsCache = new Map();
function openWatchlistHistoryModal() {
if (!_wlhModalEl) {
_wlhModalEl = document.createElement('div');
_wlhModalEl.className = 'modal-overlay origin-modal-overlay';
_wlhModalEl.innerHTML = `
<div class="origin-modal">
<div class="origin-modal-head">
<div>
<h2 class="origin-modal-title">Scan History</h2>
<p class="origin-modal-sub">Every watchlist scan and the tracks it added to your wishlist.</p>
</div>
<button class="origin-modal-close" onclick="closeWatchlistHistoryModal()" aria-label="Close"></button>
</div>
<div class="origin-modal-body" id="wlh-modal-body"></div>
</div>`;
_wlhModalEl.addEventListener('click', (e) => {
if (e.target === _wlhModalEl) closeWatchlistHistoryModal();
});
document.body.appendChild(_wlhModalEl);
}
_wlhModalEl.classList.remove('hidden');
_wlhLoadRuns();
}
function closeWatchlistHistoryModal() {
if (_wlhModalEl) _wlhModalEl.classList.add('hidden');
}
async function _wlhLoadRuns() {
const body = document.getElementById('wlh-modal-body');
body.innerHTML = '<div class="origin-modal-loading">Loading…</div>';
try {
const resp = await fetch('/api/watchlist/scan/history?limit=50');
const data = await resp.json();
if (!data.success) throw new Error(data.error || 'Failed to load');
_wlhRuns = data.runs || [];
_wlhRenderRuns();
} catch (err) {
body.innerHTML = `<div class="origin-modal-empty">Couldn't load: ${escapeHtml(err.message)}</div>`;
}
}
function _wlhRenderRuns() {
const body = document.getElementById('wlh-modal-body');
if (!_wlhRuns.length) {
body.innerHTML = '<div class="origin-modal-empty">No scans recorded yet. Run a watchlist scan and it will appear here.</div>';
return;
}
body.innerHTML = _wlhRuns.map(r => {
const when = _wlhFormatDate(r.completed_at || r.started_at);
const cancelled = r.status === 'cancelled';
return `<div class="wlh-run" data-run="${escapeHtml(r.run_id)}">
<button type="button" class="wlh-run-header" onclick="toggleWatchlistHistoryRun('${escapeHtml(r.run_id)}', this)">
<span class="origin-group-caret"></span>
<span class="wlh-run-when">${escapeHtml(when)}</span>
${cancelled ? '<span class="wlh-run-status cancelled">cancelled</span>' : ''}
<span class="wlh-run-stats">
<span class="wlh-run-stat">${r.artists_scanned || 0}<i>artists</i></span>
<span class="wlh-run-stat">${r.tracks_found || 0}<i>found</i></span>
<span class="wlh-run-stat added">${r.tracks_added || 0}<i>added</i></span>
</span>
</button>
<div class="wlh-run-body" style="display: none;"></div>
</div>`;
}).join('');
}
async function toggleWatchlistHistoryRun(runId, btn) {
const runEl = btn.closest('.wlh-run');
const bodyEl = runEl.querySelector('.wlh-run-body');
const caret = btn.querySelector('.origin-group-caret');
const open = bodyEl.style.display !== 'none';
if (open) {
bodyEl.style.display = 'none';
if (caret) caret.textContent = '▸';
return;
}
bodyEl.style.display = '';
if (caret) caret.textContent = '▾';
if (!_wlhEventsCache.has(runId)) {
bodyEl.innerHTML = '<div class="origin-modal-loading">Loading…</div>';
try {
const resp = await fetch(`/api/watchlist/scan/history/${encodeURIComponent(runId)}/tracks`);
const data = await resp.json();
_wlhEventsCache.set(runId, data.success ? (data.events || []) : []);
} catch (e) {
_wlhEventsCache.set(runId, []);
}
}
bodyEl.innerHTML = _wlhRenderEvents(_wlhEventsCache.get(runId));
}
function _wlhRenderEvents(events) {
if (!events.length) {
return '<div class="origin-modal-empty wlh-empty">No new tracks were found by this scan.</div>';
}
const added = events.filter(e => e.status === 'added');
const skipped = events.filter(e => e.status !== 'added');
const row = (e) => `
<div class="watchlist-live-addition-item wlh-track">
<img src="${escapeHtml(e.album_image_url || '')}" alt="" onerror="this.style.display='none';" />
<div class="watchlist-live-addition-item-info">
<div class="watchlist-live-addition-item-track">${escapeHtml(e.track_name || '')}</div>
<div class="watchlist-live-addition-item-artist">${escapeHtml(e.artist_name || '')}${e.album_name ? ' — ' + escapeHtml(e.album_name) : ''}</div>
</div>
${e.status === 'added'
? '<span class="watchlist-scan-track-badge added">added</span>'
: '<span class="watchlist-scan-track-badge skipped">skipped</span>'}
</div>`;
const section = (label, list) => list.length
? `<div class="watchlist-scan-tracks-section">${label} (${list.length})</div>${list.map(row).join('')}`
: '';
return section('Added to wishlist', added)
+ section('Found but skipped — already queued or blocklisted', skipped);
}
function _wlhFormatDate(ts) {
if (!ts) return 'Unknown time';
try {
const d = new Date(ts);
if (isNaN(d.getTime())) return ts;
return d.toLocaleString(undefined, {
month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit',
});
} catch (e) {
return ts;
}
}
window.openWatchlistHistoryModal = openWatchlistHistoryModal;
window.closeWatchlistHistoryModal = closeWatchlistHistoryModal;
window.toggleWatchlistHistoryRun = toggleWatchlistHistoryRun;

View file

@ -953,6 +953,7 @@ async function handleAddToWishlist() {
let successCount = 0;
let errorCount = 0;
let skippedCount = 0; // already-in-library tracks the backend declined to add (#825)
// Add each track to wishlist individually
for (const track of tracks) {
@ -1026,7 +1027,10 @@ async function handleAddToWishlist() {
const result = await response.json();
if (result.success) {
if (result.success && result.skipped) {
skippedCount++; // already in library — not added (#825)
console.log(`⏭️ "${track.name}" already in library — skipped`);
} else if (result.success) {
successCount++;
console.log(`✅ Added "${track.name}" to wishlist`);
} else {
@ -1040,12 +1044,15 @@ async function handleAddToWishlist() {
}
}
// Show completion message
if (successCount > 0) {
const message = errorCount > 0
? `Added ${successCount}/${tracks.length} tracks to wishlist (${errorCount} failed)`
: `Added ${successCount} tracks to wishlist`;
showToast(message, successCount === tracks.length ? 'success' : 'warning');
// Show completion message (#825: report already-owned tracks honestly
// instead of counting them as "added").
if (successCount === 0 && skippedCount > 0 && errorCount === 0) {
showToast(`All ${skippedCount} track${skippedCount !== 1 ? 's' : ''} already in your library`, 'success');
} else if (successCount > 0) {
let message = `Added ${successCount} track${successCount !== 1 ? 's' : ''} to wishlist`;
if (skippedCount > 0) message += ` (${skippedCount} already owned)`;
if (errorCount > 0) message += `${errorCount} failed`;
showToast(message, errorCount > 0 ? 'warning' : 'success');
} else {
showToast('Failed to add any tracks to wishlist', 'error');
}
@ -1365,6 +1372,7 @@ async function addModalTracksToWishlist(playlistId) {
try {
let successCount = 0;
let errorCount = 0;
let skippedCount = 0; // already-in-library tracks the backend declined to add (#825)
// Add each track to wishlist individually
let wingItSkipped = 0;
@ -1479,11 +1487,14 @@ async function addModalTracksToWishlist(playlistId) {
}
}
// Show result toast
if (successCount > 0) {
let message = errorCount > 0
? `Added ${successCount}/${tracks.length} tracks to wishlist (${errorCount} failed)`
: `Added ${successCount} tracks to wishlist`;
// Show result toast (#825: report already-owned skips honestly).
if (successCount === 0 && skippedCount > 0 && errorCount === 0 && wingItSkipped === 0) {
showToast(`All ${skippedCount} track${skippedCount !== 1 ? 's' : ''} already in your library`, 'success');
await closeDownloadMissingModal(playlistId);
} else if (successCount > 0) {
let message = `Added ${successCount} track${successCount !== 1 ? 's' : ''} to wishlist`;
if (skippedCount > 0) message += ` (${skippedCount} already owned)`;
if (errorCount > 0) message += `${errorCount} failed`;
if (wingItSkipped > 0) message += ` (${wingItSkipped} wing-it skipped)`;
showToast(message, 'success');