commit
7948a90f09
26 changed files with 1720 additions and 125 deletions
4
.github/workflows/docker-publish.yml
vendored
4
.github/workflows/docker-publish.yml
vendored
|
|
@ -9,9 +9,9 @@ on:
|
|||
workflow_dispatch:
|
||||
inputs:
|
||||
version_tag:
|
||||
description: 'Version tag (e.g. 2.7.2)'
|
||||
description: 'Version tag (e.g. 2.7.3)'
|
||||
required: true
|
||||
default: '2.7.2'
|
||||
default: '2.7.3'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
|
|
|
|||
|
|
@ -319,7 +319,11 @@ def build_import_album_info(
|
|||
(album_info or {}).get("track_number")
|
||||
or track_info.get("track_number")
|
||||
or original_search.get("track_number")
|
||||
or 1
|
||||
# "Track 01" bug: default to 0 (the codebase's "unknown" sentinel,
|
||||
# same as total_tracks below), NOT 1. A fabricated 1 looks
|
||||
# authoritative and blocks the pipeline's downstream recovery
|
||||
# (embedded file tag / resolve chain); 0 lets it fall through.
|
||||
or 0
|
||||
)
|
||||
disc_number = (
|
||||
(album_info or {}).get("disc_number")
|
||||
|
|
|
|||
|
|
@ -629,9 +629,17 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
|
|||
# See ``core/imports/track_number.py`` for the resolution
|
||||
# chain — pure function, unit-tested in isolation, single
|
||||
# place to fix the rule.
|
||||
from core.imports.track_number import resolve_track_number
|
||||
from core.imports.track_number import resolve_track_number, read_embedded_track_number
|
||||
track_info_for_resolve = context.get('track_info') if isinstance(context, dict) else None
|
||||
track_number = resolve_track_number(album_info, track_info_for_resolve, file_path)
|
||||
# "Track 01" bug: a single Deezer track is matched via an endpoint
|
||||
# that omits track_position, so the context never carried the real
|
||||
# number. The downloaded file itself does (deemix/source wrote it),
|
||||
# so read it as a source between metadata and the filename guess.
|
||||
embedded_track_number = read_embedded_track_number(file_path)
|
||||
track_number = resolve_track_number(
|
||||
album_info, track_info_for_resolve, file_path,
|
||||
embedded_track_number=embedded_track_number,
|
||||
)
|
||||
logger.debug(
|
||||
"Final track_number processing: source=%s album_info=%s resolved=%s",
|
||||
album_info.get('source', 'unknown'),
|
||||
|
|
|
|||
|
|
@ -153,6 +153,83 @@ def get_quarantined_source_keys(quarantine_dir: str) -> set:
|
|||
return keys
|
||||
|
||||
|
||||
def quarantine_group_key(
|
||||
expected_artist: Any, expected_track: Any, context: Any = None
|
||||
) -> Optional[str]:
|
||||
"""Grouping key for "the same intended download target".
|
||||
|
||||
#876: when several sources are downloaded for one wishlist/queue
|
||||
track they each fail verification and land in quarantine as separate
|
||||
entries. They are *alternatives for the same song*, so they should
|
||||
group together — and once the user accepts one, the rest are
|
||||
redundant failed attempts at a song they now own.
|
||||
|
||||
The key identifies the *intended* target — what SoulSync was trying to
|
||||
fetch — NOT the downloaded file's own tags. That matters: the file's
|
||||
metadata is frequently *wrong* (that's why it failed acoustid /
|
||||
integrity), whereas the target is fixed and identical across every
|
||||
alternative for one song (they're all Soulseek uploads of the *same*
|
||||
source track), so grouping by it is an exact relationship, not a fuzzy
|
||||
metadata guess.
|
||||
|
||||
Prefers a stable target-track id from the sidecar `context.track_info`
|
||||
when present — isrc, then source id, then uri — since those are exact
|
||||
and constant across siblings. Falls back to the normalized
|
||||
artist|track name only for legacy/thin sidecars that carry no context.
|
||||
Keys are kind-prefixed so an id-based key never collides with a
|
||||
name-based one.
|
||||
|
||||
Returns ``None`` when nothing identifies the target (no usable id and
|
||||
both name fields empty). Callers treat a ``None`` key as "its own
|
||||
singleton group" — ungroupable entries must never collapse together.
|
||||
"""
|
||||
ti = {}
|
||||
if isinstance(context, dict):
|
||||
maybe_ti = context.get("track_info")
|
||||
if isinstance(maybe_ti, dict):
|
||||
ti = maybe_ti
|
||||
isrc = str(ti.get("isrc") or "").strip().lower()
|
||||
if isrc:
|
||||
return f"isrc:{isrc}"
|
||||
tid = str(ti.get("id") or "").strip()
|
||||
if tid:
|
||||
return f"id:{tid}"
|
||||
uri = str(ti.get("uri") or "").strip()
|
||||
if uri:
|
||||
return f"uri:{uri}"
|
||||
artist = " ".join(str(expected_artist or "").split()).lower()
|
||||
track = " ".join(str(expected_track or "").split()).lower()
|
||||
if not artist and not track:
|
||||
return None
|
||||
return f"nm:{artist}|{track}"
|
||||
|
||||
|
||||
def find_quarantine_siblings(quarantine_dir: str, entry_id: str) -> List[str]:
|
||||
"""Other entry ids that share ``entry_id``'s intended-target group key.
|
||||
|
||||
Returns the ids of every *other* quarantine entry whose
|
||||
`expected_artist`/`expected_track` normalize to the same key as
|
||||
``entry_id`` (see :func:`quarantine_group_key`). Excludes ``entry_id``
|
||||
itself. Returns ``[]`` when the entry is missing, has an ungroupable
|
||||
(``None``) key, or has no siblings. Never raises.
|
||||
"""
|
||||
if not entry_id:
|
||||
return []
|
||||
entries = list_quarantine_entries(quarantine_dir)
|
||||
target_key = None
|
||||
for e in entries:
|
||||
if e.get("id") == entry_id:
|
||||
target_key = e.get("group_key")
|
||||
break
|
||||
if target_key is None:
|
||||
return []
|
||||
return [
|
||||
e["id"]
|
||||
for e in entries
|
||||
if e.get("id") != entry_id and e.get("group_key") == target_key
|
||||
]
|
||||
|
||||
|
||||
def list_quarantine_entries(quarantine_dir: str) -> List[Dict[str, Any]]:
|
||||
"""Enumerate quarantined files paired with their sidecars.
|
||||
|
||||
|
|
@ -213,6 +290,11 @@ def list_quarantine_entries(quarantine_dir: str) -> List[Dict[str, Any]]:
|
|||
"reason": sidecar.get("quarantine_reason", "Unknown reason"),
|
||||
"expected_track": sidecar.get("expected_track", ""),
|
||||
"expected_artist": sidecar.get("expected_artist", ""),
|
||||
"group_key": quarantine_group_key(
|
||||
sidecar.get("expected_artist", ""),
|
||||
sidecar.get("expected_track", ""),
|
||||
ctx,
|
||||
),
|
||||
"timestamp": sidecar.get("timestamp", ""),
|
||||
"size_bytes": size_bytes,
|
||||
"has_full_context": isinstance(sidecar.get("context"), dict),
|
||||
|
|
|
|||
|
|
@ -65,17 +65,64 @@ def _coerce_spotify_data(track_info: Any) -> dict:
|
|||
return {}
|
||||
|
||||
|
||||
def read_embedded_track_number(file_path: str) -> Optional[int]:
|
||||
"""Read the track position from a downloaded audio file's own tags.
|
||||
|
||||
Streaming sources (Deezer/deemix, Qobuz, Tidal) and most Soulseek
|
||||
uploads write the correct album position into the file itself. That
|
||||
tag is authoritative for the *source's* idea of the track's place on
|
||||
its album — more reliable than a filename guess — so the resolver
|
||||
consults it before falling back to the filename / default-1 floor.
|
||||
|
||||
Issue #874-adjacent / "Track 01" bug: a single Deezer track is matched
|
||||
via Deezer's ``/search/track`` endpoint, which omits ``track_position``
|
||||
(core/deezer_client.py), so the metadata context never carried the
|
||||
real number — but the downloaded file *does* (deemix wrote it). This
|
||||
recovers it with no network call.
|
||||
|
||||
Returns a positive int, or None when the file has no usable
|
||||
tracknumber tag / can't be read. Never raises. Handles the common
|
||||
``"2/15"`` (number/total) form by taking the leading number.
|
||||
"""
|
||||
if not file_path:
|
||||
return None
|
||||
try:
|
||||
from mutagen import File as MutagenFile
|
||||
audio = MutagenFile(file_path, easy=True)
|
||||
if audio is None:
|
||||
return None
|
||||
raw = audio.get('tracknumber')
|
||||
if isinstance(raw, list):
|
||||
raw = raw[0] if raw else None
|
||||
if raw is None:
|
||||
return None
|
||||
# "2/15" -> "2"; bare "2" -> "2".
|
||||
text = str(raw).split('/', 1)[0].strip()
|
||||
return _coerce_positive(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_track_number(
|
||||
album_info: Any,
|
||||
track_info: Any,
|
||||
file_path: str,
|
||||
embedded_track_number: Any = None,
|
||||
) -> Optional[int]:
|
||||
"""Walk the resolution chain and return the first valid positive
|
||||
int found, or None when every source is missing / unusable.
|
||||
|
||||
Caller is responsible for the final default-1 floor — leaving
|
||||
that out of this function so tests can pin "everything missing
|
||||
Order: album_info -> track_info -> nested spotify_data -> filename ->
|
||||
``embedded_track_number`` (the source-written file tag, when the caller
|
||||
supplies it). Caller is responsible for the final default-1 floor —
|
||||
leaving that out of this function so tests can pin "everything missing
|
||||
returns None" separate from the floor behaviour.
|
||||
|
||||
``embedded_track_number`` is passed in (not read here) so this stays a
|
||||
pure function — the file I/O lives in :func:`read_embedded_track_number`.
|
||||
It is consulted **last**, only when every other source came up empty, so
|
||||
it can never override a value the pre-fix resolver already produced — it
|
||||
only fills the gap that would otherwise hit the default-1 floor.
|
||||
"""
|
||||
album_info = album_info if isinstance(album_info, dict) else {}
|
||||
track_info = track_info if isinstance(track_info, dict) else {}
|
||||
|
|
@ -96,10 +143,19 @@ def resolve_track_number(
|
|||
# default-1 floor is the single source of that fallback —
|
||||
# otherwise this resolver would silently fill 1 and the
|
||||
# downstream floor logic would have no effect.
|
||||
if not file_path:
|
||||
return None
|
||||
try:
|
||||
from_filename = extract_explicit_track_number(file_path)
|
||||
except Exception:
|
||||
from_filename = None
|
||||
return _coerce_positive(from_filename)
|
||||
if file_path:
|
||||
try:
|
||||
from_filename = extract_explicit_track_number(file_path)
|
||||
except Exception:
|
||||
from_filename = None
|
||||
ff = _coerce_positive(from_filename)
|
||||
if ff is not None:
|
||||
return ff
|
||||
|
||||
# Embedded source-written file tag is consulted LAST — only when every
|
||||
# other source (metadata + the ripped-album "NN - Title" filename) came
|
||||
# up empty. This is deliberate: it can ONLY fill the gap that would
|
||||
# otherwise hit the caller's default-1 floor, so it never overrides a
|
||||
# value the pre-fix resolver would have used. A correctly-named file
|
||||
# with a stale/wrong embedded tag is therefore never regressed.
|
||||
return _coerce_positive(embedded_track_number)
|
||||
|
|
|
|||
|
|
@ -201,9 +201,27 @@ def numeric_tokens_differ(title_a: str, title_b: str) -> bool:
|
|||
return _digit_tokens(title_a) != _digit_tokens(title_b)
|
||||
|
||||
|
||||
def base_title_before_dash(title: str) -> str:
|
||||
"""The base title before Spotify's ' - <qualifier>' version separator.
|
||||
|
||||
Spotify renders versions as 'Calma - Remix' / 'Song - Radio Edit' /
|
||||
'Track - Remastered 2019'. Libraries (and the files people actually have)
|
||||
very often store just the base — 'Calma' — so a literal search for
|
||||
'Calma - Remix' finds nothing and the OR-fuzzy fallback then floods on the
|
||||
common qualifier word ('remix' matches every remix). This returns the base
|
||||
('Calma') for a base-title search fallback. Splits on the FIRST ' - ' (the
|
||||
spaced hyphen is Spotify's separator; a bare hyphen inside a word is left
|
||||
alone). Returns the title unchanged when there's no separator."""
|
||||
if not title:
|
||||
return title
|
||||
idx = title.find(' - ')
|
||||
return title[:idx].strip() if idx > 0 else title
|
||||
|
||||
|
||||
__all__ = [
|
||||
"titles_plausibly_same",
|
||||
"strip_redundant_context_qualifiers",
|
||||
"strip_subtitle_qualifiers",
|
||||
"numeric_tokens_differ",
|
||||
"base_title_before_dash",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1655,6 +1655,8 @@ class TidalClient:
|
|||
|
||||
ids: List[str] = []
|
||||
next_path: Optional[str] = None
|
||||
consecutive_429 = 0
|
||||
MAX_PAGE_RETRIES = 4
|
||||
|
||||
while True:
|
||||
if next_path:
|
||||
|
|
@ -1687,6 +1689,28 @@ class TidalClient:
|
|||
logger.warning(f"Tidal collection page request failed: {e}")
|
||||
break
|
||||
|
||||
# Rate limited mid-walk → retry the SAME cursor page with backoff
|
||||
# rather than silently truncating the collection. Without this a 429
|
||||
# on page ~5 capped a 513-track favorites list at ~98 (issue #880);
|
||||
# the regular-playlist paginator already retries 429 the same way.
|
||||
if resp.status_code == 429:
|
||||
consecutive_429 += 1
|
||||
if consecutive_429 <= MAX_PAGE_RETRIES:
|
||||
backoff = 5.0 * consecutive_429 # 5s, 10s, 15s, 20s
|
||||
logger.warning(
|
||||
f"Tidal collection {expected_type} rate limited (429) — "
|
||||
f"retry {consecutive_429}/{MAX_PAGE_RETRIES} in {backoff}s "
|
||||
f"({len(ids)} fetched so far)"
|
||||
)
|
||||
time.sleep(backoff)
|
||||
continue # next_path/cursor unchanged → re-request same page
|
||||
logger.error(
|
||||
f"Tidal collection {expected_type} still rate limited after "
|
||||
f"{MAX_PAGE_RETRIES} retries — returning {len(ids)} IDs "
|
||||
f"(PARTIAL: the collection may be larger)"
|
||||
)
|
||||
break
|
||||
|
||||
if resp.status_code != 200:
|
||||
# 401/403 = scope/permission issue. Token predates the
|
||||
# `collection.read` scope expansion or the user revoked
|
||||
|
|
@ -1704,6 +1728,8 @@ class TidalClient:
|
|||
)
|
||||
break
|
||||
|
||||
consecutive_429 = 0 # a good page → reset the retry budget
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError as e:
|
||||
|
|
|
|||
162
core/wishlist/ignore.py
Normal file
162
core/wishlist/ignore.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"""Wishlist ignore-list — a TTL'd skip-gate for the wishlist (#874).
|
||||
|
||||
When a user removes a track from the wishlist or cancels an in-flight
|
||||
wishlist download, SoulSync would otherwise re-add it on the next
|
||||
automatic cycle (watchlist scan, failed-track capture, or the cancel
|
||||
handler's own re-add), so the same release downloads → fails/cancels →
|
||||
re-queues forever. The ignore list records the user's "stop
|
||||
auto-grabbing this" intent, and the wishlist *add* path checks it,
|
||||
skipping automatic re-adds until the entry ages out.
|
||||
|
||||
It is deliberately softer than the blocklist:
|
||||
- it **expires** after ``IGNORE_TTL_DAYS`` so the track is re-attempted
|
||||
again later rather than banned forever, and
|
||||
- it **never blocks a manual force-download** — only the automatic
|
||||
re-queue. (Manual downloads don't go through ``add_to_wishlist`` at
|
||||
all, and an explicit manual *add* both bypasses the gate and clears
|
||||
any existing ignore for the track.)
|
||||
|
||||
This module is pure decision logic — no database handle and no clock of
|
||||
its own; the caller passes ``now``. The SQL lives in ``MusicDatabase``
|
||||
as a thin wrapper around these helpers, which keeps the TTL / id-matching
|
||||
rules unit-testable without a database.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
|
||||
|
||||
IGNORE_TTL_DAYS = 30
|
||||
|
||||
# Recognised reasons (free-text tolerated; these are the canonical two).
|
||||
REASON_REMOVED = "removed"
|
||||
REASON_CANCELLED = "cancelled"
|
||||
|
||||
|
||||
def normalize_ignore_id(track_id: Any) -> str:
|
||||
"""Canonical key for a wishlist track id.
|
||||
|
||||
The wishlist stores some ids as a composite ``<track_id>::<album_id>``
|
||||
(when ``wishlist.allow_duplicate_tracks`` is on). The add-path gate
|
||||
keys on the bare track id (``spotify_track_data['id']``), so we strip
|
||||
the ``::album`` suffix here so an ignore recorded from a composite-id
|
||||
wishlist row still matches the bare-id add attempt, and vice-versa.
|
||||
Returns ``''`` for falsy/blank input.
|
||||
"""
|
||||
s = str(track_id or "").strip()
|
||||
if not s:
|
||||
return ""
|
||||
return s.split("::", 1)[0]
|
||||
|
||||
|
||||
def _parse_ts(value: Any) -> Optional[datetime]:
|
||||
"""Best-effort parse of a sqlite TIMESTAMP / ISO string / datetime."""
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
if not value:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
for fmt in (
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
"%Y-%m-%dT%H:%M:%S",
|
||||
"%Y-%m-%d %H:%M:%S.%f",
|
||||
"%Y-%m-%dT%H:%M:%S.%f",
|
||||
):
|
||||
try:
|
||||
return datetime.strptime(s, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
return datetime.fromisoformat(s)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def is_expired(created_at: Any, now: datetime, ttl_days: int = IGNORE_TTL_DAYS) -> bool:
|
||||
"""True when an ignore entry created at ``created_at`` has aged past TTL.
|
||||
|
||||
Fail-SAFE in the gate's favour: an unparseable/blank timestamp is
|
||||
treated as **expired** (returns True) so a corrupt row can never wedge
|
||||
a track out of the wishlist permanently — the worst case is the
|
||||
ignore silently lapses and the track becomes eligible again.
|
||||
"""
|
||||
created = _parse_ts(created_at)
|
||||
if created is None:
|
||||
return True
|
||||
return now >= created + timedelta(days=ttl_days)
|
||||
|
||||
|
||||
def active_ignored_ids(
|
||||
rows: Iterable[Dict[str, Any]], now: datetime, ttl_days: int = IGNORE_TTL_DAYS
|
||||
) -> Set[str]:
|
||||
"""Set of normalized track ids whose ignore entry is still within TTL."""
|
||||
out: Set[str] = set()
|
||||
for row in rows or []:
|
||||
tid = normalize_ignore_id(row.get("track_id"))
|
||||
if tid and not is_expired(row.get("created_at"), now, ttl_days):
|
||||
out.add(tid)
|
||||
return out
|
||||
|
||||
|
||||
def is_ignored(
|
||||
rows: Iterable[Dict[str, Any]], track_id: Any, now: datetime, ttl_days: int = IGNORE_TTL_DAYS
|
||||
) -> bool:
|
||||
"""Whether ``track_id`` matches an in-TTL entry among ``rows``."""
|
||||
key = normalize_ignore_id(track_id)
|
||||
if not key:
|
||||
return False
|
||||
return key in active_ignored_ids(rows, now, ttl_days)
|
||||
|
||||
|
||||
def extract_display(data: Any) -> Tuple[str, str]:
|
||||
"""Pull a (track_name, artist_name) pair from a Spotify-shaped dict.
|
||||
|
||||
Tolerates ``artists`` as a list of dicts or bare strings, and missing
|
||||
fields. Used to give ignore-list rows a human label for the UI.
|
||||
Returns ``('', '')`` when nothing usable is present.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return "", ""
|
||||
name = str(data.get("name") or "").strip()
|
||||
artist = ""
|
||||
artists = data.get("artists") or []
|
||||
if isinstance(artists, list) and artists:
|
||||
first = artists[0]
|
||||
if isinstance(first, dict):
|
||||
artist = str(first.get("name") or "").strip()
|
||||
else:
|
||||
artist = str(first or "").strip()
|
||||
return name, artist
|
||||
|
||||
|
||||
def ignore_wishlist_track(
|
||||
database: Any,
|
||||
profile_id: int,
|
||||
track_id: Any,
|
||||
reason: str,
|
||||
spotify_data: Any = None,
|
||||
) -> bool:
|
||||
"""Record an ignore entry for a wishlist track. Best-effort; never raises.
|
||||
|
||||
Copies the track's display name/artist from ``spotify_data`` when
|
||||
provided (callers should capture it BEFORE removing the wishlist row,
|
||||
since the row may be gone afterwards); otherwise leaves them blank.
|
||||
Returns True when an entry was written.
|
||||
"""
|
||||
key = normalize_ignore_id(track_id)
|
||||
if not key or database is None:
|
||||
return False
|
||||
name, artist = extract_display(spotify_data or {})
|
||||
try:
|
||||
return bool(
|
||||
database.add_to_wishlist_ignore(
|
||||
key,
|
||||
track_name=name,
|
||||
artist_name=artist,
|
||||
reason=reason or REASON_REMOVED,
|
||||
profile_id=profile_id,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
|
@ -310,12 +310,30 @@ def remove_track_from_wishlist(
|
|||
if not spotify_track_id:
|
||||
return {"success": False, "error": "No spotify_track_id provided"}, 400
|
||||
|
||||
success = get_wishlist_service().remove_track_from_wishlist(
|
||||
service = get_wishlist_service()
|
||||
_db = getattr(service, "database", None)
|
||||
# #874: capture the track's display info BEFORE removal (the row is
|
||||
# gone afterwards) so the ignore-list entry carries a human label.
|
||||
_ignore_data = None
|
||||
try:
|
||||
if _db is not None:
|
||||
_ignore_data = _db.get_wishlist_spotify_data(
|
||||
spotify_track_id, profile_id=runtime.profile_id)
|
||||
except Exception:
|
||||
_ignore_data = None
|
||||
|
||||
success = service.remove_track_from_wishlist(
|
||||
spotify_track_id,
|
||||
profile_id=runtime.profile_id,
|
||||
)
|
||||
|
||||
if success:
|
||||
# #874: a user-initiated remove means "stop auto-requeuing this".
|
||||
# Record a TTL'd ignore so the watchlist/auto-processor doesn't
|
||||
# re-add it. Best-effort — never fails the remove.
|
||||
from core.wishlist.ignore import ignore_wishlist_track, REASON_REMOVED
|
||||
ignore_wishlist_track(_db, runtime.profile_id,
|
||||
spotify_track_id, REASON_REMOVED, spotify_data=_ignore_data)
|
||||
runtime.logger.info("Successfully removed track from wishlist: %s", spotify_track_id)
|
||||
return {"success": True, "message": "Track removed from wishlist"}, 200
|
||||
|
||||
|
|
@ -358,13 +376,20 @@ def remove_album_from_wishlist(
|
|||
if matched:
|
||||
spotify_track_id = track.get("track_id") or track.get("spotify_track_id") or track.get("id")
|
||||
if spotify_track_id:
|
||||
tracks_to_remove.append(spotify_track_id)
|
||||
# Keep the loaded spotify_data alongside the id so the #874
|
||||
# ignore entry can be labelled without a second DB read.
|
||||
tracks_to_remove.append((spotify_track_id, spotify_data))
|
||||
|
||||
from core.wishlist.ignore import ignore_wishlist_track, REASON_REMOVED
|
||||
_db = getattr(wishlist_service, "database", None)
|
||||
removed_count = 0
|
||||
album_remove_pid = runtime.profile_id
|
||||
for spotify_track_id in tracks_to_remove:
|
||||
for spotify_track_id, track_spotify_data in tracks_to_remove:
|
||||
if wishlist_service.remove_track_from_wishlist(spotify_track_id, profile_id=album_remove_pid):
|
||||
removed_count += 1
|
||||
# #874: user removed the whole album → ignore each track.
|
||||
ignore_wishlist_track(_db, album_remove_pid,
|
||||
spotify_track_id, REASON_REMOVED, spotify_data=track_spotify_data)
|
||||
|
||||
if removed_count > 0:
|
||||
runtime.logger.info("Successfully removed %s tracks from album %s", removed_count, album_id)
|
||||
|
|
@ -390,11 +415,22 @@ def remove_batch_from_wishlist(
|
|||
if not spotify_track_ids or not isinstance(spotify_track_ids, list):
|
||||
return {"success": False, "error": "Missing or invalid spotify_track_ids"}, 400
|
||||
|
||||
from core.wishlist.ignore import ignore_wishlist_track, REASON_REMOVED
|
||||
service = get_wishlist_service()
|
||||
_db = getattr(service, "database", None)
|
||||
removed = 0
|
||||
pid = runtime.profile_id
|
||||
for track_id in spotify_track_ids:
|
||||
if get_wishlist_service().remove_track_from_wishlist(track_id, profile_id=pid):
|
||||
# Capture label before the row is deleted (#874).
|
||||
_data = None
|
||||
try:
|
||||
if _db is not None:
|
||||
_data = _db.get_wishlist_spotify_data(track_id, profile_id=pid)
|
||||
except Exception:
|
||||
_data = None
|
||||
if service.remove_track_from_wishlist(track_id, profile_id=pid):
|
||||
removed += 1
|
||||
ignore_wishlist_track(_db, pid, track_id, REASON_REMOVED, spotify_data=_data)
|
||||
|
||||
runtime.logger.info("Batch removed %s track(s) from wishlist", removed)
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -214,7 +214,11 @@ class WishlistService:
|
|||
"preview_url": track_data.get("preview_url") if isinstance(track_data, dict) else None,
|
||||
"external_urls": track_data.get("external_urls", {}) if isinstance(track_data, dict) else {},
|
||||
"popularity": track_data.get("popularity", 0) if isinstance(track_data, dict) else 0,
|
||||
"track_number": track_data.get("track_number", 1) if isinstance(track_data, dict) else 1,
|
||||
# "Track 01" bug: 0 = "unknown position", NOT a fabricated 1.
|
||||
# A fake 1 looks authoritative and blocks the import
|
||||
# pipeline's track-number recovery; 0 lets it recover the
|
||||
# real position (file tag / source lookup) before the floor.
|
||||
"track_number": track_data.get("track_number", 0) if isinstance(track_data, dict) else 0,
|
||||
"disc_number": track_data.get("disc_number", 1) if isinstance(track_data, dict) else 1,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -344,7 +344,29 @@ class MusicDatabase:
|
|||
source_info TEXT -- JSON of source context (playlist name, album info, etc.)
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
# Wishlist ignore-list (#874): a TTL'd skip-gate. When a user
|
||||
# removes a track from the wishlist or cancels an in-flight
|
||||
# wishlist download, the track is recorded here so the automatic
|
||||
# re-add paths (watchlist scan, failed-track capture, cancel
|
||||
# re-add) skip it until the entry ages out (see core.wishlist.
|
||||
# ignore.IGNORE_TTL_DAYS). Softer than `blocklist`: it expires
|
||||
# and never blocks a manual force-download. Keyed on the bare
|
||||
# track id; unique per (profile, track) so re-ignoring refreshes.
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS wishlist_ignore (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
profile_id INTEGER NOT NULL DEFAULT 1,
|
||||
track_id TEXT NOT NULL,
|
||||
track_name TEXT DEFAULT '',
|
||||
artist_name TEXT DEFAULT '',
|
||||
reason TEXT DEFAULT 'removed', -- 'removed' | 'cancelled'
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(profile_id, track_id)
|
||||
)
|
||||
""")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_wishlist_ignore_profile ON wishlist_ignore (profile_id, track_id)")
|
||||
|
||||
# Watchlist table for storing artists to monitor for new releases
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS watchlist_artists (
|
||||
|
|
@ -6887,11 +6909,26 @@ class MusicDatabase:
|
|||
|
||||
# STRATEGY 1: Try basic SQL LIKE search first (fastest)
|
||||
basic_results = self._search_tracks_basic(cursor, title, artist, limit, server_source, rank_artist)
|
||||
|
||||
|
||||
if basic_results:
|
||||
logger.debug(f"Basic search found {len(basic_results)} results")
|
||||
return basic_results
|
||||
|
||||
# STRATEGY 1b: Spotify renders versions as "Title - Qualifier"
|
||||
# ("Calma - Remix") but libraries usually store just the base
|
||||
# ("Calma"), so the literal search misses. Retry on the base title
|
||||
# BEFORE the OR-fuzzy fallback (which would flood on the common
|
||||
# qualifier word — every "... remix" matches "remix"). #: Calma - Remix
|
||||
if title:
|
||||
from core.text.title_match import base_title_before_dash
|
||||
base_title = base_title_before_dash(title)
|
||||
if base_title and base_title != title:
|
||||
base_results = self._search_tracks_basic(
|
||||
cursor, base_title, artist, limit, server_source, rank_artist)
|
||||
if base_results:
|
||||
logger.debug("Base-title search matched '%s' via '%s'", title, base_title)
|
||||
return base_results
|
||||
|
||||
# STRATEGY 2: Broader fuzzy search - splits into individual words with OR matching
|
||||
fuzzy_results = self._search_tracks_fuzzy_fallback(cursor, title, artist, limit, server_source)
|
||||
if fuzzy_results:
|
||||
|
|
@ -6920,6 +6957,17 @@ class MusicDatabase:
|
|||
if basic_rows:
|
||||
return [dict(r) for r in basic_rows]
|
||||
|
||||
# Base-title fallback for Spotify "Title - Qualifier" forms (see
|
||||
# search_tracks STRATEGY 1b) before the OR-fuzzy flood.
|
||||
if title:
|
||||
from core.text.title_match import base_title_before_dash
|
||||
base_title = base_title_before_dash(title)
|
||||
if base_title and base_title != title:
|
||||
base_rows = self._search_tracks_basic_rows(
|
||||
cursor, base_title, artist, limit, server_source)
|
||||
if base_rows:
|
||||
return [dict(r) for r in base_rows]
|
||||
|
||||
fuzzy_rows = self._search_tracks_fuzzy_rows(cursor, title, artist, limit, server_source)
|
||||
return [dict(r) for r in fuzzy_rows]
|
||||
except Exception as e:
|
||||
|
|
@ -8825,6 +8873,21 @@ class MusicDatabase:
|
|||
_blocked[0], _blocked[1])
|
||||
return False
|
||||
|
||||
# Ignore-list guard (#874): a user who removed or cancelled this
|
||||
# track asked us to stop AUTO-requeuing it — every automatic
|
||||
# re-add funnels through here, so one check covers them all.
|
||||
# A *manual* add is explicit user intent → bypass the gate AND
|
||||
# clear any stale ignore so it sticks. Fail-open: any error here
|
||||
# must never block a legitimate wishlist add.
|
||||
try:
|
||||
if source_type == 'manual':
|
||||
self.remove_from_wishlist_ignore(track_id, profile_id=profile_id)
|
||||
elif self.is_track_ignored(track_id, profile_id=profile_id):
|
||||
logger.info("Skipping wishlist add — track is on the ignore-list (#874): %s", track_id)
|
||||
return False
|
||||
except Exception as _ignore_exc:
|
||||
logger.debug("Wishlist ignore-list check skipped (fail-open): %s", _ignore_exc)
|
||||
|
||||
from core.library import manual_library_match as _mlm
|
||||
if _mlm.get_match_for_track(self, profile_id, spotify_track_data):
|
||||
logger.info(
|
||||
|
|
@ -8969,7 +9032,147 @@ class MusicDatabase:
|
|||
except Exception as e:
|
||||
logger.error(f"Error removing track from wishlist: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ── Wishlist ignore-list (#874) ──────────────────────────────────────
|
||||
# A TTL'd skip-gate consulted by add_to_wishlist so user-removed /
|
||||
# user-cancelled tracks are not auto-re-queued. All methods fail-open
|
||||
# (an error here must never block a legitimate wishlist add).
|
||||
|
||||
def add_to_wishlist_ignore(self, track_id: str, track_name: str = "",
|
||||
artist_name: str = "", reason: str = "removed",
|
||||
profile_id: int = 1) -> bool:
|
||||
"""Record (or refresh) an ignore entry for a wishlist track id.
|
||||
|
||||
Keyed on the bare track id; UNIQUE(profile_id, track_id) means a
|
||||
repeat ignore replaces the row and so refreshes its TTL clock.
|
||||
"""
|
||||
from core.wishlist.ignore import normalize_ignore_id
|
||||
key = normalize_ignore_id(track_id)
|
||||
if not key:
|
||||
return False
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO wishlist_ignore
|
||||
(profile_id, track_id, track_name, artist_name, reason, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
""", (profile_id, key, track_name or "", artist_name or "", reason or "removed"))
|
||||
conn.commit()
|
||||
logger.info("Added track to wishlist ignore-list (%s): '%s' [%s]",
|
||||
reason or "removed", track_name or key, key)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Error adding to wishlist ignore-list: %s", e)
|
||||
return False
|
||||
|
||||
def is_track_ignored(self, track_id: str, profile_id: int = 1,
|
||||
ttl_days: Optional[int] = None) -> bool:
|
||||
"""Whether ``track_id`` has a non-expired ignore entry. Fail-open False."""
|
||||
from core.wishlist.ignore import normalize_ignore_id, is_expired, IGNORE_TTL_DAYS
|
||||
ttl = IGNORE_TTL_DAYS if ttl_days is None else ttl_days
|
||||
key = normalize_ignore_id(track_id)
|
||||
if not key:
|
||||
return False
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT created_at FROM wishlist_ignore WHERE profile_id = ? AND track_id = ?",
|
||||
(profile_id, key))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return False
|
||||
return not is_expired(row["created_at"], datetime.now(), ttl)
|
||||
except Exception as e:
|
||||
logger.debug("is_track_ignored failed open: %s", e)
|
||||
return False
|
||||
|
||||
def remove_from_wishlist_ignore(self, track_id: str, profile_id: int = 1) -> bool:
|
||||
"""Un-ignore a track (manual override / UI action). Returns True if a row went."""
|
||||
from core.wishlist.ignore import normalize_ignore_id
|
||||
key = normalize_ignore_id(track_id)
|
||||
if not key:
|
||||
return False
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"DELETE FROM wishlist_ignore WHERE profile_id = ? AND track_id = ?",
|
||||
(profile_id, key))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
except Exception as e:
|
||||
logger.error("Error removing from wishlist ignore-list: %s", e)
|
||||
return False
|
||||
|
||||
def get_wishlist_ignore(self, profile_id: int = 1,
|
||||
ttl_days: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
"""Active (non-expired) ignore entries, newest first; purges lapsed rows."""
|
||||
from core.wishlist.ignore import is_expired, IGNORE_TTL_DAYS
|
||||
ttl = IGNORE_TTL_DAYS if ttl_days is None else ttl_days
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT track_id, track_name, artist_name, reason, created_at "
|
||||
"FROM wishlist_ignore WHERE profile_id = ? ORDER BY created_at DESC",
|
||||
(profile_id,))
|
||||
rows = cursor.fetchall()
|
||||
now = datetime.now()
|
||||
active, expired_ids = [], []
|
||||
for r in rows:
|
||||
if is_expired(r["created_at"], now, ttl):
|
||||
expired_ids.append(r["track_id"])
|
||||
else:
|
||||
active.append({
|
||||
"track_id": r["track_id"],
|
||||
"track_name": r["track_name"] or "",
|
||||
"artist_name": r["artist_name"] or "",
|
||||
"reason": r["reason"] or "removed",
|
||||
"created_at": r["created_at"],
|
||||
})
|
||||
# Opportunistic housekeeping so the table can't grow unbounded.
|
||||
if expired_ids:
|
||||
cursor.executemany(
|
||||
"DELETE FROM wishlist_ignore WHERE profile_id = ? AND track_id = ?",
|
||||
[(profile_id, tid) for tid in expired_ids])
|
||||
conn.commit()
|
||||
return active
|
||||
except Exception as e:
|
||||
logger.error("Error reading wishlist ignore-list: %s", e)
|
||||
return []
|
||||
|
||||
def clear_wishlist_ignore(self, profile_id: int = 1) -> int:
|
||||
"""Drop every ignore entry for a profile. Returns rows removed."""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM wishlist_ignore WHERE profile_id = ?", (profile_id,))
|
||||
conn.commit()
|
||||
return cursor.rowcount
|
||||
except Exception as e:
|
||||
logger.error("Error clearing wishlist ignore-list: %s", e)
|
||||
return 0
|
||||
|
||||
def get_wishlist_spotify_data(self, track_id: str, profile_id: int = 1) -> Dict[str, Any]:
|
||||
"""Parsed ``spotify_data`` for a wishlist row, or {}. Used to label an
|
||||
ignore entry with the track's name/artist before the row is removed."""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT spotify_data FROM wishlist_tracks WHERE spotify_track_id = ? AND profile_id = ?",
|
||||
(track_id, profile_id))
|
||||
row = cursor.fetchone()
|
||||
if not row or not row["spotify_data"]:
|
||||
return {}
|
||||
data = json.loads(row["spotify_data"])
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception as e:
|
||||
logger.debug("get_wishlist_spotify_data failed: %s", e)
|
||||
return {}
|
||||
|
||||
def get_wishlist_tracks(self, limit: Optional[int] = None, profile_id: int = 1,
|
||||
offset: int = 0, category: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Get tracks in the wishlist for the given profile, ordered by date added
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ from core.imports.quarantine import (
|
|||
approve_quarantine_entry,
|
||||
delete_quarantine_entry,
|
||||
entry_id_from_quarantined_filename,
|
||||
find_quarantine_siblings,
|
||||
get_quarantine_entry_stream_info,
|
||||
get_quarantined_source_keys,
|
||||
list_quarantine_entries,
|
||||
quarantine_group_key,
|
||||
recover_to_staging,
|
||||
serialize_quarantine_context,
|
||||
)
|
||||
|
|
@ -71,18 +73,20 @@ def test_serialize_round_trips_through_json():
|
|||
# list_quarantine_entries
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _write_entry(quarantine_dir, entry_id, original_name, *, with_context=False, trigger="integrity", reason="boom", file_bytes=b"X" * 100):
|
||||
def _write_entry(quarantine_dir, entry_id, original_name, *, with_context=False, trigger="integrity", reason="boom", file_bytes=b"X" * 100, expected_track="Track", expected_artist="Artist", context=None):
|
||||
qfile = quarantine_dir / f"{entry_id}_{original_name}.quarantined"
|
||||
qfile.write_bytes(file_bytes)
|
||||
sidecar = {
|
||||
"original_filename": original_name,
|
||||
"quarantine_reason": reason,
|
||||
"expected_track": "Track",
|
||||
"expected_artist": "Artist",
|
||||
"expected_track": expected_track,
|
||||
"expected_artist": expected_artist,
|
||||
"timestamp": "2026-05-14T12:00:00",
|
||||
"trigger": trigger,
|
||||
}
|
||||
if with_context:
|
||||
if context is not None:
|
||||
sidecar["context"] = context
|
||||
elif with_context:
|
||||
sidecar["context"] = {"track_info": {"name": "Track"}, "context_key": entry_id}
|
||||
sidecar_path = quarantine_dir / f"{entry_id}_{os.path.splitext(original_name)[0]}.json"
|
||||
sidecar_path.write_text(json.dumps(sidecar))
|
||||
|
|
@ -454,3 +458,95 @@ def test_move_with_retry_returns_false_on_missing_source(tmp_path):
|
|||
# attempts=1 keeps the test fast (no retry sleeps)
|
||||
assert _move_with_retry(str(tmp_path / "nope.flac"), str(tmp_path / "dst.flac"),
|
||||
attempts=1, delay=0) is False
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# #876: grouping alternatives for one song — quarantine_group_key /
|
||||
# find_quarantine_siblings, and the group_key field on list entries.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_group_key_prefers_isrc_over_everything():
|
||||
ctx = {"track_info": {"isrc": "USRC12345678", "id": "spid", "uri": "spotify:track:x"}}
|
||||
assert quarantine_group_key("Artist", "Track", ctx) == "isrc:usrc12345678"
|
||||
|
||||
|
||||
def test_group_key_falls_back_to_source_id_then_uri():
|
||||
assert quarantine_group_key("A", "T", {"track_info": {"id": "abc123"}}) == "id:abc123"
|
||||
assert quarantine_group_key("A", "T", {"track_info": {"uri": "spotify:track:z"}}) == "uri:spotify:track:z"
|
||||
|
||||
|
||||
def test_group_key_falls_back_to_normalized_name_without_context():
|
||||
# Trivial case/whitespace differences still collapse to one key.
|
||||
k1 = quarantine_group_key("Kendrick Lamar", "DNA.")
|
||||
k2 = quarantine_group_key("kendrick lamar", "dna.")
|
||||
assert k1 == k2 == "nm:kendrick lamar|dna."
|
||||
|
||||
|
||||
def test_group_key_none_when_nothing_identifies_target():
|
||||
assert quarantine_group_key("", "", {}) is None
|
||||
assert quarantine_group_key("", "", None) is None
|
||||
|
||||
|
||||
def test_list_entries_carry_group_key(tmp_path):
|
||||
_write_entry(tmp_path, "20260514_120000", "a.flac",
|
||||
context={"track_info": {"isrc": "USABC1234567"}})
|
||||
entries = list_quarantine_entries(str(tmp_path))
|
||||
assert entries[0]["group_key"] == "isrc:usabc1234567"
|
||||
|
||||
|
||||
def test_find_siblings_returns_same_target_attempts(tmp_path):
|
||||
# Two failed source attempts at the SAME target track (same isrc) + an
|
||||
# unrelated entry. Siblings of #2 = {#1}, never the unrelated one.
|
||||
same = {"track_info": {"isrc": "USAAA0000001"}}
|
||||
other = {"track_info": {"isrc": "USZZZ9999999"}}
|
||||
q1, _ = _write_entry(tmp_path, "20260514_120000", "src1.flac", context=same)
|
||||
q2, _ = _write_entry(tmp_path, "20260514_120001", "src2.flac", context=same)
|
||||
_write_entry(tmp_path, "20260514_120002", "diff.flac", context=other)
|
||||
|
||||
id1 = entry_id_from_quarantined_filename(q1.name)
|
||||
id2 = entry_id_from_quarantined_filename(q2.name)
|
||||
assert find_quarantine_siblings(str(tmp_path), id2) == [id1]
|
||||
|
||||
|
||||
def test_find_siblings_groups_by_intended_target_not_file_tags(tmp_path):
|
||||
# Same intended target (isrc) even though the bad files differ — that's
|
||||
# the whole point: the file metadata is wrong, the target is constant.
|
||||
same = {"track_info": {"isrc": "USAAA0000001", "name": "Whatever"}}
|
||||
q1, _ = _write_entry(tmp_path, "20260514_120000", "garbage_wrong_song.flac", context=same)
|
||||
q2, _ = _write_entry(tmp_path, "20260514_120001", "another_bad_rip.flac", context=same)
|
||||
id1 = entry_id_from_quarantined_filename(q1.name)
|
||||
id2 = entry_id_from_quarantined_filename(q2.name)
|
||||
assert find_quarantine_siblings(str(tmp_path), id1) == [id2]
|
||||
|
||||
|
||||
def test_find_siblings_empty_for_ungroupable_entry(tmp_path):
|
||||
# No id and blank expected fields -> None key -> never grouped.
|
||||
q1, _ = _write_entry(tmp_path, "20260514_120000", "orphan.flac",
|
||||
expected_track="", expected_artist="")
|
||||
id1 = entry_id_from_quarantined_filename(q1.name)
|
||||
assert find_quarantine_siblings(str(tmp_path), id1) == []
|
||||
|
||||
|
||||
def test_find_siblings_empty_for_missing_entry(tmp_path):
|
||||
_write_entry(tmp_path, "20260514_120000", "a.flac")
|
||||
assert find_quarantine_siblings(str(tmp_path), "does_not_exist") == []
|
||||
|
||||
|
||||
def test_siblings_must_be_captured_before_accepted_entry_leaves_quarantine(tmp_path):
|
||||
# Regression for the approve-endpoint ordering: approving RESTORES (moves)
|
||||
# the accepted entry out of quarantine, after which an id-based sibling
|
||||
# lookup for that id can't resolve its group_key and returns []. The
|
||||
# endpoint therefore captures siblings BEFORE approving. This pins that
|
||||
# invariant: lookup-before == sibling found, lookup-after == empty.
|
||||
same = {"track_info": {"isrc": "USAAA0000001"}}
|
||||
q1, _ = _write_entry(tmp_path, "20260514_120000", "a.flac", context=same)
|
||||
q2, _ = _write_entry(tmp_path, "20260514_120001", "b.flac", context=same)
|
||||
id1 = entry_id_from_quarantined_filename(q1.name)
|
||||
id2 = entry_id_from_quarantined_filename(q2.name)
|
||||
|
||||
captured = find_quarantine_siblings(str(tmp_path), id1) # while id1 present
|
||||
assert captured == [id2]
|
||||
|
||||
delete_quarantine_entry(str(tmp_path), id1) # simulate approve restoring it
|
||||
|
||||
assert find_quarantine_siblings(str(tmp_path), id1) == [] # too late now
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from __future__ import annotations
|
|||
|
||||
from unittest.mock import patch
|
||||
|
||||
from core.imports.track_number import resolve_track_number
|
||||
from core.imports.track_number import read_embedded_track_number, resolve_track_number
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -226,3 +226,113 @@ def test_non_dict_track_info_treated_as_empty():
|
|||
"""Defensive — non-dict track_info won't crash the resolver."""
|
||||
result = resolve_track_number({}, 'not a dict', '/dir/file.flac')
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# "Track 01" bug (Deezer single tracks): embedded file-tag source.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_embedded_tag_used_when_metadata_missing():
|
||||
"""The Deezer single-track case: context carried no position (search
|
||||
endpoint omits track_position), but the downloaded file did. With the
|
||||
metadata-context de-poisoned to 0/None, the embedded tag is consulted
|
||||
BEFORE the filename guess."""
|
||||
result = resolve_track_number(
|
||||
album_info={'track_number': 0}, # de-poisoned "unknown"
|
||||
track_info={'track_number': 0},
|
||||
file_path='/dl/Lenny Kravitz - Fly Away.flac', # no number prefix
|
||||
embedded_track_number=2,
|
||||
)
|
||||
assert result == 2
|
||||
|
||||
|
||||
def test_real_metadata_still_beats_embedded_tag():
|
||||
"""No regression for album downloads: an authoritative album_info
|
||||
position wins over the file tag (they normally agree, but the
|
||||
context is the source of truth when present)."""
|
||||
result = resolve_track_number(
|
||||
album_info={'track_number': 5},
|
||||
track_info={},
|
||||
file_path='/dl/file.flac',
|
||||
embedded_track_number=2,
|
||||
)
|
||||
assert result == 5
|
||||
|
||||
|
||||
def test_filename_beats_embedded_tag_no_regression():
|
||||
"""SAFETY: embedded tag is consulted LAST, so a correctly-named ripped
|
||||
file ('05 - Song') with a stale/wrong embedded tag is NOT regressed —
|
||||
the filename still wins, exactly as before the fix."""
|
||||
result = resolve_track_number(
|
||||
album_info={},
|
||||
track_info={},
|
||||
file_path='/dl/09 - Mislabelled.flac',
|
||||
embedded_track_number=2, # wrong/stale tag must NOT win
|
||||
)
|
||||
assert result == 9
|
||||
|
||||
|
||||
def test_embedded_only_fills_the_floor_gap():
|
||||
"""When metadata AND filename are both empty (the Deezer case), the
|
||||
embedded tag fills what would otherwise be the blind default-1 floor."""
|
||||
result = resolve_track_number(
|
||||
album_info={}, track_info={},
|
||||
file_path='/dl/Lenny Kravitz - Fly Away.flac', # no number prefix
|
||||
embedded_track_number=2,
|
||||
)
|
||||
assert result == 2
|
||||
|
||||
|
||||
def test_resolver_without_embedded_arg_is_backwards_compatible():
|
||||
"""The new parameter is optional — existing 3-arg callers unaffected."""
|
||||
assert resolve_track_number({}, {'track_number': 4}, '/x.flac') == 4
|
||||
|
||||
|
||||
# read_embedded_track_number — mutagen-backed file tag reader.
|
||||
|
||||
def _fake_mutagen(tag_value):
|
||||
"""Patch target factory: returns a callable standing in for
|
||||
``mutagen.File`` that yields an object whose .get('tracknumber')
|
||||
returns tag_value (mimicking easy=True list-valued tags)."""
|
||||
class _Audio(dict):
|
||||
pass
|
||||
def _factory(path, easy=False):
|
||||
a = _Audio()
|
||||
if tag_value is not None:
|
||||
a['tracknumber'] = tag_value
|
||||
return a
|
||||
return _factory
|
||||
|
||||
|
||||
def test_read_embedded_handles_number_slash_total():
|
||||
with patch('mutagen.File', _fake_mutagen(['2/15'])):
|
||||
assert read_embedded_track_number('/x.flac') == 2
|
||||
|
||||
|
||||
def test_read_embedded_handles_bare_number():
|
||||
with patch('mutagen.File', _fake_mutagen(['7'])):
|
||||
assert read_embedded_track_number('/x.flac') == 7
|
||||
|
||||
|
||||
def test_read_embedded_none_when_tag_absent():
|
||||
with patch('mutagen.File', _fake_mutagen(None)):
|
||||
assert read_embedded_track_number('/x.flac') is None
|
||||
|
||||
|
||||
def test_read_embedded_none_when_file_unreadable():
|
||||
def _factory(path, easy=False):
|
||||
return None
|
||||
with patch('mutagen.File', _factory):
|
||||
assert read_embedded_track_number('/x.flac') is None
|
||||
|
||||
|
||||
def test_read_embedded_never_raises():
|
||||
def _boom(path, easy=False):
|
||||
raise RuntimeError('corrupt')
|
||||
with patch('mutagen.File', _boom):
|
||||
assert read_embedded_track_number('/x.flac') is None
|
||||
|
||||
|
||||
def test_read_embedded_empty_path_returns_none():
|
||||
assert read_embedded_track_number('') is None
|
||||
|
|
|
|||
|
|
@ -731,3 +731,26 @@ def test_get_artist_discography_prefers_source_specific_artist_ids(monkeypatch):
|
|||
)
|
||||
]
|
||||
assert deezer.album_calls == []
|
||||
|
||||
|
||||
def test_get_artist_detail_discography_splits_eps_from_singles(monkeypatch):
|
||||
"""#877: get_artist_detail_discography must put EPs in their OWN bucket (not
|
||||
lumped into singles). The Download Discography modal now reads this split, so
|
||||
its EPs filter has cards to act on and stays in sync with Artist Detail."""
|
||||
spotify = _FakeSourceClient(
|
||||
album_results=[
|
||||
_album("a1", "An Album", "2024-01-01", album_type="album"),
|
||||
_album("e1", "An EP", "2024-02-01", album_type="ep"),
|
||||
_album("s1", "A Single", "2024-03-01", album_type="single"),
|
||||
]
|
||||
)
|
||||
clients = {"deezer": _FakeSourceClient(), "spotify": spotify, "itunes": _FakeSourceClient()}
|
||||
monkeypatch.setattr(metadata_registry, "get_primary_source", lambda spotify_client_factory=None: "deezer")
|
||||
monkeypatch.setattr(metadata_registry, "get_source_priority", lambda primary: [primary, "spotify", "itunes"])
|
||||
monkeypatch.setattr(metadata_registry, "get_client_for_source", lambda source, **kwargs: clients.get(source))
|
||||
|
||||
result = metadata_discography.get_artist_detail_discography("artist-1", "Artist One", MetadataLookupOptions())
|
||||
|
||||
assert [r["id"] for r in result["albums"]] == ["a1"]
|
||||
assert [r["id"] for r in result["eps"]] == ["e1"]
|
||||
assert [r["id"] for r in result["singles"]] == ["s1"]
|
||||
|
|
|
|||
88
tests/test_base_title_search_fallback.py
Normal file
88
tests/test_base_title_search_fallback.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""Find & Add: Spotify "Title - Qualifier" must find the base-titled library track.
|
||||
|
||||
wolf's report: Spotify shows "Calma - Remix", Find & Add searches that literal
|
||||
string, the library stores the track as just "Calma" (only the duration marks it
|
||||
as the remix) → the literal search misses and the OR-fuzzy fallback floods 20
|
||||
unrelated "... remix" hits. Dropping "- Remix" (searching "Calma") finds it.
|
||||
|
||||
Fix: search_tracks retries on the base title (before Spotify's " - " separator)
|
||||
before the OR-fuzzy flood.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from core.text.title_match import base_title_before_dash
|
||||
from database.music_database import MusicDatabase
|
||||
|
||||
|
||||
# --- pure helper -----------------------------------------------------------
|
||||
|
||||
def test_base_title_before_dash_strips_spotify_version_suffix():
|
||||
assert base_title_before_dash('Calma - Remix') == 'Calma'
|
||||
assert base_title_before_dash('Closer - Radio Edit') == 'Closer'
|
||||
assert base_title_before_dash('Crocodile Rock - Remastered 2014') == 'Crocodile Rock'
|
||||
|
||||
|
||||
def test_base_title_before_dash_leaves_plain_titles_alone():
|
||||
assert base_title_before_dash('Tom Sawyer') == 'Tom Sawyer'
|
||||
assert base_title_before_dash('21st Century Schizoid Man') == '21st Century Schizoid Man'
|
||||
assert base_title_before_dash('Up-Tight') == 'Up-Tight' # bare hyphen, not a separator
|
||||
assert base_title_before_dash('') == ''
|
||||
|
||||
|
||||
def test_base_title_before_dash_splits_first_separator_only():
|
||||
assert base_title_before_dash('A - B - C') == 'A'
|
||||
|
||||
|
||||
# --- integration: the real search path -------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
return MusicDatabase(str(tmp_path / "music.db"))
|
||||
|
||||
|
||||
def _insert(db, tid, title, artist_id, artist_name):
|
||||
with db._get_connection() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO artists (id, name) VALUES (?, ?)", (artist_id, artist_name))
|
||||
conn.execute("INSERT OR IGNORE INTO albums (id, title, artist_id) VALUES (?, ?, ?)",
|
||||
(artist_id, "Alb", artist_id))
|
||||
conn.execute(
|
||||
"INSERT INTO tracks (id, album_id, artist_id, title, track_number, duration, file_path) "
|
||||
"VALUES (?, ?, ?, ?, 1, 238, ?)",
|
||||
(tid, artist_id, artist_id, title, f"/m/{tid}.flac"),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def test_spotify_dash_remix_finds_base_titled_track(db):
|
||||
# Library stores the remix as just "Calma" (the wolf case).
|
||||
_insert(db, 1, "Calma", 1, "Pedro Capó")
|
||||
results = db.search_tracks(title="Calma - Remix", rank_artist="Pedro Capó")
|
||||
titles = [t.title for t in results]
|
||||
assert "Calma" in titles, "base-title fallback should find 'Calma' for 'Calma - Remix'"
|
||||
|
||||
|
||||
def test_spotify_dash_remix_finds_parenthesized_remix(db):
|
||||
# …and still matches when the library DID label it "(Remix)".
|
||||
_insert(db, 1, "Calma (Remix)", 1, "Pedro Capó")
|
||||
results = db.search_tracks(title="Calma - Remix", rank_artist="Pedro Capó")
|
||||
assert any("Calma" in t.title for t in results)
|
||||
|
||||
|
||||
def test_plain_title_unaffected_uses_basic_search(db):
|
||||
_insert(db, 1, "Tom Sawyer", 1, "Rush")
|
||||
results = db.search_tracks(title="Tom Sawyer")
|
||||
assert [t.title for t in results] == ["Tom Sawyer"]
|
||||
|
||||
|
||||
def test_dash_query_does_not_flood_when_base_matches(db):
|
||||
# The base-title retry must short-circuit BEFORE the OR-fuzzy flood, so an
|
||||
# unrelated "... Remix" track doesn't drown the real one.
|
||||
_insert(db, 1, "Calma", 1, "Pedro Capó")
|
||||
_insert(db, 2, "Some Other Song (KAIZ Remix)", 2, "Someone Else")
|
||||
results = db.search_tracks(title="Calma - Remix", rank_artist="Pedro Capó")
|
||||
titles = [t.title for t in results]
|
||||
assert "Calma" in titles
|
||||
assert "Some Other Song (KAIZ Remix)" not in titles
|
||||
|
|
@ -24,6 +24,23 @@ def _cm(config_data):
|
|||
return cm
|
||||
|
||||
|
||||
# ── #879: the GET /api/settings handler calls config_manager.redacted_config().
|
||||
# If that method is ever renamed/removed the endpoint 500s, the web UI treats the
|
||||
# error body as settings, blanks the form to defaults, and autosaves over the
|
||||
# user's real config. Pin the method's presence on the class so that can't ship.
|
||||
|
||||
def test_redacted_config_is_a_method_on_the_class():
|
||||
assert callable(getattr(ConfigManager, 'redacted_config', None)), (
|
||||
"GET /api/settings depends on ConfigManager.redacted_config(); removing or "
|
||||
"renaming it 500s the settings endpoint and the UI then wipes the config (#879)")
|
||||
|
||||
|
||||
def test_redacted_config_callable_on_instance_returns_dict():
|
||||
cm = _cm({'spotify': {'client_secret': 'REAL'}})
|
||||
out = cm.redacted_config()
|
||||
assert isinstance(out, dict) and out.get('spotify', {}).get('client_secret') == S
|
||||
|
||||
|
||||
# ── redacted_config: secrets out, everything else intact ────────────────────
|
||||
|
||||
def test_configured_secrets_are_masked():
|
||||
|
|
|
|||
|
|
@ -252,6 +252,55 @@ class TestIterCollectionTrackIds:
|
|||
|
||||
assert ids == []
|
||||
|
||||
def test_429_mid_walk_retries_and_completes(self):
|
||||
"""Regression for #880: a 429 mid-pagination must RETRY the same
|
||||
cursor page (with backoff), not truncate the collection. Before the
|
||||
fix a transient 429 on page ~5 capped a 513-track favorites list at
|
||||
~98 (the log showed `status=429` then `Retrieved 98/100`)."""
|
||||
client = _make_authed_client()
|
||||
# page1 (200) → 429 (transient) → page2 (200, end of chain)
|
||||
responses = iter([
|
||||
_FakeResp(200, _PAGE_ONE),
|
||||
_FakeResp(429, text=""), # rate limited — retry, don't truncate
|
||||
_FakeResp(200, _PAGE_TWO),
|
||||
])
|
||||
with patch.object(client, '_ensure_valid_token', return_value=True), \
|
||||
patch('core.tidal_client.requests.get', side_effect=lambda *a, **kw: next(responses)), \
|
||||
patch('core.tidal_client.time.sleep'):
|
||||
ids = client._iter_collection_track_ids()
|
||||
|
||||
assert ids == ['1001', '1002', '1003', '1004', '1005'] # full chain, nothing dropped
|
||||
|
||||
def test_429_does_not_set_reconnect_flag(self):
|
||||
"""A rate-limit is transient, NOT a scope problem — must not tell the
|
||||
user to reconnect."""
|
||||
client = _make_authed_client()
|
||||
responses = iter([_FakeResp(200, _PAGE_ONE), _FakeResp(429, text=""), _FakeResp(200, _PAGE_TWO)])
|
||||
with patch.object(client, '_ensure_valid_token', return_value=True), \
|
||||
patch('core.tidal_client.requests.get', side_effect=lambda *a, **kw: next(responses)), \
|
||||
patch('core.tidal_client.time.sleep'):
|
||||
client._iter_collection_track_ids()
|
||||
|
||||
assert client.collection_needs_reconnect() is False
|
||||
|
||||
def test_429_exhausts_retries_returns_partial(self):
|
||||
"""If the 429s never clear, give up after the retry budget and return
|
||||
what we have (PARTIAL) rather than looping forever."""
|
||||
client = _make_authed_client()
|
||||
|
||||
def gen():
|
||||
yield _FakeResp(200, _PAGE_ONE)
|
||||
while True:
|
||||
yield _FakeResp(429, text="")
|
||||
|
||||
responses = gen()
|
||||
with patch.object(client, '_ensure_valid_token', return_value=True), \
|
||||
patch('core.tidal_client.requests.get', side_effect=lambda *a, **kw: next(responses)), \
|
||||
patch('core.tidal_client.time.sleep'):
|
||||
ids = client._iter_collection_track_ids()
|
||||
|
||||
assert ids == ['1001', '1002', '1003'] # page 1 survives; no hang
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_collection_tracks_count
|
||||
|
|
|
|||
190
tests/wishlist/test_wishlist_ignore.py
Normal file
190
tests/wishlist/test_wishlist_ignore.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
"""#874 — wishlist ignore-list: TTL skip-gate for user-removed/cancelled tracks.
|
||||
|
||||
Two layers:
|
||||
* pure logic (core.wishlist.ignore) — TTL, id-normalization, display extract
|
||||
* DB seam (MusicDatabase on a temp db) — add/check/remove/list/clear, the
|
||||
add_to_wishlist gate, the manual-add bypass, and the regression that the
|
||||
success-cleanup removal path does NOT ignore.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from core.wishlist.ignore import (
|
||||
IGNORE_TTL_DAYS,
|
||||
REASON_CANCELLED,
|
||||
REASON_REMOVED,
|
||||
active_ignored_ids,
|
||||
extract_display,
|
||||
is_expired,
|
||||
is_ignored,
|
||||
normalize_ignore_id,
|
||||
)
|
||||
from database.music_database import MusicDatabase
|
||||
|
||||
|
||||
# ── pure logic ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_normalize_strips_composite_album_suffix():
|
||||
assert normalize_ignore_id("track123::album456") == "track123"
|
||||
assert normalize_ignore_id(" track123 ") == "track123"
|
||||
assert normalize_ignore_id("") == ""
|
||||
assert normalize_ignore_id(None) == ""
|
||||
|
||||
|
||||
def test_is_expired_true_past_ttl_false_within():
|
||||
now = datetime(2026, 6, 15, 12, 0, 0)
|
||||
fresh = (now - timedelta(days=5)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
stale = (now - timedelta(days=40)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
assert is_expired(fresh, now) is False
|
||||
assert is_expired(stale, now) is True
|
||||
|
||||
|
||||
def test_is_expired_unparseable_is_treated_expired_fail_open():
|
||||
# Corrupt timestamp must lapse (never wedge a track out of the wishlist).
|
||||
assert is_expired("not-a-date", datetime(2026, 6, 15)) is True
|
||||
assert is_expired("", datetime(2026, 6, 15)) is True
|
||||
assert is_expired(None, datetime(2026, 6, 15)) is True
|
||||
|
||||
|
||||
def test_is_ignored_matches_composite_and_bare_ids():
|
||||
now = datetime(2026, 6, 15, 12, 0, 0)
|
||||
created = (now - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
rows = [{"track_id": "abc", "created_at": created}]
|
||||
# Stored bare, queried with composite (and vice-versa) — both match.
|
||||
assert is_ignored(rows, "abc::album9", now) is True
|
||||
rows2 = [{"track_id": "abc", "created_at": created}]
|
||||
assert is_ignored(rows2, "abc", now) is True
|
||||
assert is_ignored(rows2, "different", now) is False
|
||||
|
||||
|
||||
def test_active_ignored_ids_drops_expired():
|
||||
now = datetime(2026, 6, 15, 12, 0, 0)
|
||||
fresh = (now - timedelta(days=2)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
stale = (now - timedelta(days=99)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
rows = [
|
||||
{"track_id": "keep", "created_at": fresh},
|
||||
{"track_id": "drop", "created_at": stale},
|
||||
]
|
||||
assert active_ignored_ids(rows, now) == {"keep"}
|
||||
|
||||
|
||||
def test_extract_display_handles_dict_and_string_artists():
|
||||
assert extract_display({"name": "Song", "artists": [{"name": "A"}]}) == ("Song", "A")
|
||||
assert extract_display({"name": "Song", "artists": ["B"]}) == ("Song", "B")
|
||||
assert extract_display({}) == ("", "")
|
||||
assert extract_display(None) == ("", "")
|
||||
|
||||
|
||||
# ── DB seam (temp database — never the live db) ─────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
return MusicDatabase(str(tmp_path / "m.db"))
|
||||
|
||||
|
||||
def _track(track_id="t1", name="Some Song", artist="Some Artist", album_id="alb1"):
|
||||
return {
|
||||
"id": track_id,
|
||||
"name": name,
|
||||
"artists": [{"name": artist}],
|
||||
"album": {"id": album_id, "name": "Some Album", "images": []},
|
||||
}
|
||||
|
||||
|
||||
def test_add_check_remove_roundtrip(db):
|
||||
assert db.is_track_ignored("t1") is False
|
||||
assert db.add_to_wishlist_ignore("t1", "Song", "Artist", REASON_REMOVED) is True
|
||||
assert db.is_track_ignored("t1") is True
|
||||
# Composite id of the same base track is also considered ignored.
|
||||
assert db.is_track_ignored("t1::albX") is True
|
||||
assert db.remove_from_wishlist_ignore("t1") is True
|
||||
assert db.is_track_ignored("t1") is False
|
||||
|
||||
|
||||
def test_get_and_clear_ignore_list(db):
|
||||
db.add_to_wishlist_ignore("a", "SongA", "ArtA", REASON_REMOVED)
|
||||
db.add_to_wishlist_ignore("b", "SongB", "ArtB", REASON_CANCELLED)
|
||||
entries = db.get_wishlist_ignore()
|
||||
assert {e["track_id"] for e in entries} == {"a", "b"}
|
||||
assert any(e["reason"] == "cancelled" for e in entries)
|
||||
assert db.clear_wishlist_ignore() == 2
|
||||
assert db.get_wishlist_ignore() == []
|
||||
|
||||
|
||||
def test_expired_entry_is_not_active_and_gets_purged(db):
|
||||
db.add_to_wishlist_ignore("old", "Old", "Art", REASON_REMOVED)
|
||||
# Backdate it well past the TTL.
|
||||
with db._get_connection() as conn:
|
||||
conn.execute(
|
||||
"UPDATE wishlist_ignore SET created_at = ? WHERE track_id = ?",
|
||||
((datetime.now() - timedelta(days=IGNORE_TTL_DAYS + 5)).strftime("%Y-%m-%d %H:%M:%S"), "old"),
|
||||
)
|
||||
conn.commit()
|
||||
assert db.is_track_ignored("old") is False # lapsed
|
||||
assert db.get_wishlist_ignore() == [] # and purged on read
|
||||
with db._get_connection() as conn:
|
||||
remaining = conn.execute("SELECT COUNT(*) c FROM wishlist_ignore").fetchone()["c"]
|
||||
assert remaining == 0
|
||||
|
||||
|
||||
# ── the gate: add_to_wishlist honours the ignore-list ───────────────────
|
||||
|
||||
def test_gate_blocks_auto_readd_but_manual_bypasses_and_clears(db):
|
||||
track = _track("t1")
|
||||
# Auto add works first time.
|
||||
assert db.add_to_wishlist(track, source_type="playlist") is True
|
||||
# User removes + ignores it.
|
||||
db.remove_from_wishlist("t1")
|
||||
db.add_to_wishlist_ignore("t1", "Some Song", "Some Artist", REASON_REMOVED)
|
||||
# Auto re-add (watchlist / failed-capture / cancel) is now blocked.
|
||||
assert db.add_to_wishlist(track, source_type="playlist") is False
|
||||
assert db.is_track_ignored("t1") is True
|
||||
# A MANUAL add bypasses the gate AND clears the ignore so it sticks.
|
||||
assert db.add_to_wishlist(track, source_type="manual") is True
|
||||
assert db.is_track_ignored("t1") is False
|
||||
|
||||
|
||||
def test_gate_failopen_when_ignore_table_errors(db, monkeypatch):
|
||||
# If the ignore check raises, the add must still succeed (never block).
|
||||
monkeypatch.setattr(db, "is_track_ignored", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")))
|
||||
assert db.add_to_wishlist(_track("t9"), source_type="playlist") is True
|
||||
|
||||
|
||||
def test_route_remove_track_records_ignore(db, monkeypatch):
|
||||
# Pin the route → ignore wiring (not just the DB layer): a user-initiated
|
||||
# remove via the route function must drop the row AND ignore the track.
|
||||
import types
|
||||
from core.wishlist import routes as routes_module
|
||||
|
||||
db.add_to_wishlist(_track("rt1", name="Route Song", artist="Route Artist"),
|
||||
source_type="playlist")
|
||||
|
||||
class _Svc:
|
||||
database = db
|
||||
|
||||
def remove_track_from_wishlist(self, tid, profile_id=1):
|
||||
return db.remove_from_wishlist(tid, profile_id=profile_id)
|
||||
|
||||
monkeypatch.setattr(routes_module, "get_wishlist_service", lambda: _Svc())
|
||||
runtime = types.SimpleNamespace(profile_id=1, logger=routes_module.module_logger)
|
||||
|
||||
payload, status = routes_module.remove_track_from_wishlist(runtime, "rt1")
|
||||
assert status == 200
|
||||
assert db.is_track_ignored("rt1") is True
|
||||
# The ignore carries the captured label.
|
||||
entry = db.get_wishlist_ignore()[0]
|
||||
assert entry["track_name"] == "Route Song"
|
||||
assert entry["reason"] == REASON_REMOVED
|
||||
|
||||
|
||||
def test_regression_success_cleanup_does_not_ignore(db):
|
||||
# The post-download success path calls remove_from_wishlist directly — it
|
||||
# must NOT add anything to the ignore-list (only user remove/cancel do).
|
||||
track = _track("t5")
|
||||
assert db.add_to_wishlist(track, source_type="playlist") is True
|
||||
db.remove_from_wishlist("t5") # simulate success cleanup
|
||||
assert db.is_track_ignored("t5") is False # NOT ignored
|
||||
# And so a later legitimate auto-add still works.
|
||||
assert db.add_to_wishlist(track, source_type="playlist") is True
|
||||
132
web_server.py
132
web_server.py
|
|
@ -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.7.2"
|
||||
_SOULSYNC_BASE_VERSION = "2.7.3"
|
||||
|
||||
def _build_version_string():
|
||||
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
|
||||
|
|
@ -7556,6 +7556,18 @@ def approve_quarantine_item(entry_id):
|
|||
docker_resolve_path(config_manager.get('soulseek.download_path', './downloads')),
|
||||
'Transfer',
|
||||
)
|
||||
_req = request.get_json(silent=True) or {}
|
||||
# #876: capture the sibling alternatives BEFORE approving — the approve
|
||||
# restores (moves) this entry's file out of quarantine, after which its
|
||||
# own group_key can no longer be looked up by id. Read-only here; the
|
||||
# actual deletion happens only after the re-import is safely kicked off.
|
||||
_sibling_ids = []
|
||||
if _req.get('remove_siblings'):
|
||||
from core.imports.quarantine import find_quarantine_siblings
|
||||
try:
|
||||
_sibling_ids = find_quarantine_siblings(_get_quarantine_dir(), entry_id)
|
||||
except Exception as sib_exc:
|
||||
logger.warning(f"[Quarantine] Sibling lookup for {entry_id} failed: {sib_exc}")
|
||||
result = approve_quarantine_entry(_get_quarantine_dir(), entry_id, restore_dir)
|
||||
if result is None:
|
||||
return jsonify({
|
||||
|
|
@ -7574,7 +7586,6 @@ def approve_quarantine_item(entry_id):
|
|||
# context lost task_id/batch_id (the wrapper pops them before quarantine),
|
||||
# so we re-supply them here. Manager-tab approvals (no task_id) keep the
|
||||
# original inner-pipeline path.
|
||||
_req = request.get_json(silent=True) or {}
|
||||
_task_id = (_req.get('task_id') or '').strip() or None
|
||||
_batch_id = None
|
||||
if _task_id:
|
||||
|
|
@ -7594,7 +7605,28 @@ def approve_quarantine_item(entry_id):
|
|||
_reprocess = lambda: _post_process_matched_download(context_key, context, restored_path)
|
||||
threading.Thread(target=_reprocess, daemon=True).start()
|
||||
logger.info(f"[Quarantine] Approved {entry_id} (original_trigger={trigger}, bypass=all, task={_task_id}) → re-running pipeline")
|
||||
return jsonify({"success": True, "trigger_bypassed": "all", "original_trigger": trigger})
|
||||
# #876: once one alternative for a song is accepted, the other
|
||||
# quarantined attempts at the SAME intended target are redundant
|
||||
# failed downloads of a track the user now owns. Delete the siblings
|
||||
# captured above (scoped to the quarantine manager via `remove_siblings`
|
||||
# — the download-modal chooser passes no flag and is unaffected).
|
||||
removed_siblings = []
|
||||
if _sibling_ids:
|
||||
from core.imports.quarantine import delete_quarantine_entry
|
||||
try:
|
||||
for sib_id in _sibling_ids:
|
||||
if delete_quarantine_entry(_get_quarantine_dir(), sib_id):
|
||||
removed_siblings.append(sib_id)
|
||||
if removed_siblings:
|
||||
logger.info(f"[Quarantine] Auto-removed {len(removed_siblings)} sibling alternative(s) of {entry_id}: {removed_siblings}")
|
||||
except Exception as sib_exc:
|
||||
logger.warning(f"[Quarantine] Sibling cleanup for {entry_id} failed: {sib_exc}")
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"trigger_bypassed": "all",
|
||||
"original_trigger": trigger,
|
||||
"removed_siblings": removed_siblings,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"[Quarantine] Error approving {entry_id}: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
|
@ -9114,7 +9146,11 @@ def get_artist_discography(artist_id):
|
|||
effective_override_source = 'spotify'
|
||||
|
||||
from core.metadata.lookup import MetadataLookupOptions
|
||||
from core.metadata_service import get_artist_discography as _get_artist_discography
|
||||
# #877: use the artist-DETAIL discography so the Download Discography modal
|
||||
# gets the SAME release-type split (albums / eps / singles) the Artist
|
||||
# Detail view shows — EPs were being lumped into singles before, leaving
|
||||
# the modal's EPs toggle dead.
|
||||
from core.metadata.discography import get_artist_detail_discography as _get_artist_discography
|
||||
|
||||
# Server-side per-source ID resolution. Look up the library row
|
||||
# by ANY of the IDs the frontend might send: library DB id,
|
||||
|
|
@ -9192,6 +9228,7 @@ def get_artist_discography(artist_id):
|
|||
)
|
||||
|
||||
album_list = discography['albums']
|
||||
eps_list = discography.get('eps', [])
|
||||
singles_list = discography['singles']
|
||||
active_source = discography['source']
|
||||
source_priority = discography['source_priority']
|
||||
|
|
@ -9303,6 +9340,7 @@ def get_artist_discography(artist_id):
|
|||
|
||||
return jsonify({
|
||||
"albums": album_list,
|
||||
"eps": eps_list,
|
||||
"singles": singles_list,
|
||||
"source": active_source or (source_priority[0] if source_priority else "unknown"),
|
||||
"artist_info": artist_info,
|
||||
|
|
@ -16781,6 +16819,48 @@ def remove_batch_from_wishlist():
|
|||
logger.error(f"Error batch removing from wishlist: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/wishlist/ignore-list', methods=['GET'])
|
||||
def get_wishlist_ignore_list():
|
||||
"""#874: active (non-expired) wishlist ignore entries for this profile."""
|
||||
try:
|
||||
from core.wishlist_service import get_wishlist_service
|
||||
runtime = _build_wishlist_route_runtime()
|
||||
entries = get_wishlist_service().database.get_wishlist_ignore(profile_id=runtime.profile_id)
|
||||
from core.wishlist.ignore import IGNORE_TTL_DAYS
|
||||
return jsonify({"success": True, "entries": entries, "ttl_days": IGNORE_TTL_DAYS})
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading wishlist ignore-list: {e}")
|
||||
return jsonify({"success": False, "error": str(e), "entries": []}), 500
|
||||
|
||||
@app.route('/api/wishlist/ignore-list/remove', methods=['POST'])
|
||||
def remove_from_wishlist_ignore_list():
|
||||
"""#874: un-ignore a track so it can be auto-acquired again."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
track_id = data.get('track_id') or data.get('spotify_track_id')
|
||||
if not track_id:
|
||||
return jsonify({"success": False, "error": "No track_id provided"}), 400
|
||||
from core.wishlist_service import get_wishlist_service
|
||||
runtime = _build_wishlist_route_runtime()
|
||||
ok = get_wishlist_service().database.remove_from_wishlist_ignore(
|
||||
track_id, profile_id=runtime.profile_id)
|
||||
return jsonify({"success": True, "removed": ok})
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing from wishlist ignore-list: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/wishlist/ignore-list/clear', methods=['POST'])
|
||||
def clear_wishlist_ignore_list():
|
||||
"""#874: clear the entire wishlist ignore-list for this profile."""
|
||||
try:
|
||||
from core.wishlist_service import get_wishlist_service
|
||||
runtime = _build_wishlist_route_runtime()
|
||||
count = get_wishlist_service().database.clear_wishlist_ignore(profile_id=runtime.profile_id)
|
||||
return jsonify({"success": True, "cleared": count})
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing wishlist ignore-list: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/add-album-to-wishlist', methods=['POST'])
|
||||
def add_album_track_to_wishlist():
|
||||
"""Endpoint to add a single track from an album to the wishlist."""
|
||||
|
|
@ -18918,26 +18998,44 @@ def _check_batch_completion_v2(batch_id):
|
|||
|
||||
|
||||
def _add_cancelled_task_to_wishlist(task):
|
||||
"""
|
||||
Helper function to add cancelled task to wishlist.
|
||||
Separated for clarity and error isolation.
|
||||
"""Handle a user-cancelled download's wishlist state.
|
||||
|
||||
#874: a manual cancel means "stop auto-retrying this release". The
|
||||
previous behaviour re-added the cancelled track to the wishlist, which
|
||||
the auto-processor then re-downloaded → re-cancelled → re-added,
|
||||
forever. Instead we record a TTL'd ignore entry (so the watchlist /
|
||||
auto-processor skip it) and drop it from the active wishlist. The user
|
||||
can still force-download it manually (manual paths bypass the gate),
|
||||
and the ignore lapses after core.wishlist.ignore.IGNORE_TTL_DAYS so it
|
||||
is reconsidered later. Error-isolated; never raises into the caller.
|
||||
"""
|
||||
if not task:
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
from core.wishlist_service import get_wishlist_service
|
||||
from core.wishlist.ignore import extract_display, REASON_CANCELLED
|
||||
wishlist_service = get_wishlist_service()
|
||||
payload = _build_cancelled_task_wishlist_payload(task, profile_id=get_current_profile_id())
|
||||
success = wishlist_service.add_spotify_track_to_wishlist(**payload)
|
||||
|
||||
if success:
|
||||
logger.info(f"[Atomic Cancel] Added '{task.get('track_info', {}).get('name')}' to wishlist")
|
||||
else:
|
||||
logger.error(f"[Atomic Cancel] Failed to add '{task.get('track_info', {}).get('name')}' to wishlist")
|
||||
|
||||
profile_id = get_current_profile_id()
|
||||
track_info = task.get('track_info', {}) or {}
|
||||
track_id = track_info.get('id')
|
||||
name = track_info.get('name')
|
||||
|
||||
if not track_id:
|
||||
logger.warning("[Atomic Cancel] No track id on cancelled task — cannot ignore '%s'", name)
|
||||
return
|
||||
|
||||
disp_name, disp_artist = extract_display(track_info)
|
||||
wishlist_service.database.add_to_wishlist_ignore(
|
||||
track_id, track_name=disp_name, artist_name=disp_artist,
|
||||
reason=REASON_CANCELLED, profile_id=profile_id)
|
||||
# Drop it from the active wishlist so the auto-processor stops
|
||||
# re-attempting it; the gate then blocks any automatic re-add.
|
||||
wishlist_service.remove_track_from_wishlist(track_id, profile_id=profile_id)
|
||||
logger.info("[Atomic Cancel] Ignored (TTL) + removed from wishlist: '%s'", name or track_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Atomic Cancel] Critical error adding to wishlist: {e}")
|
||||
logger.error(f"[Atomic Cancel] Critical error handling cancelled task: {e}")
|
||||
|
||||
@app.route('/api/playlists/<batch_id>/cancel_batch', methods=['POST'])
|
||||
def cancel_batch(batch_id):
|
||||
|
|
|
|||
|
|
@ -1186,6 +1186,9 @@ async function openWishlistOverviewModal() {
|
|||
<button class="playlist-modal-btn playlist-modal-btn-warning" onclick="cleanupWishlistOverview()">
|
||||
🧹 Cleanup Wishlist
|
||||
</button>
|
||||
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="openWishlistIgnoreModal()" title="Tracks you removed or cancelled — auto-skipped until they expire">
|
||||
🚫 Ignored
|
||||
</button>
|
||||
</div>
|
||||
<div class="playlist-modal-footer-right">
|
||||
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closeWishlistOverviewModal()">Close</button>
|
||||
|
|
@ -1312,6 +1315,117 @@ function closeWishlistOverviewModal() {
|
|||
console.log('✅ Modal closed');
|
||||
}
|
||||
|
||||
// ── #874: Wishlist ignore-list ("Ignored") modal ────────────────────────
|
||||
// Tracks the user removed from the wishlist or cancelled mid-download are
|
||||
// auto-skipped (not re-queued) until they expire. This modal lets the user
|
||||
// see what's currently ignored and lift the skip (un-ignore / clear all).
|
||||
|
||||
async function openWishlistIgnoreModal() {
|
||||
let modal = document.getElementById('wishlist-ignore-modal');
|
||||
if (modal) modal.remove();
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'wishlist-ignore-modal';
|
||||
modal.className = 'modal-overlay';
|
||||
modal.style.cssText = 'display:flex;position:fixed;inset:0;z-index:10050;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);';
|
||||
modal.innerHTML = `
|
||||
<div class="playlist-modal-content" style="max-width:560px;width:90%;max-height:80vh;display:flex;flex-direction:column;">
|
||||
<div class="playlist-modal-header">
|
||||
<h2 style="margin:0;">🚫 Ignored Tracks</h2>
|
||||
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closeWishlistIgnoreModal()">Close</button>
|
||||
</div>
|
||||
<p style="opacity:0.7;font-size:13px;margin:8px 16px 0;">Removed or cancelled tracks are skipped by auto-download until they expire. Un-ignore to allow auto-download again (you can always download manually).</p>
|
||||
<div id="wishlist-ignore-list" class="playlist-tracks-scroll" style="flex:1;overflow-y:auto;padding:12px 16px;">
|
||||
<div class="loading-indicator">Loading...</div>
|
||||
</div>
|
||||
<div class="playlist-modal-footer">
|
||||
<div class="playlist-modal-footer-left">
|
||||
<button id="wishlist-ignore-clear-btn" class="playlist-modal-btn playlist-modal-btn-danger" onclick="clearWishlistIgnoreList()" style="display:none;">Clear All</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
modal.addEventListener('click', (e) => { if (e.target === modal) closeWishlistIgnoreModal(); });
|
||||
await loadWishlistIgnoreList();
|
||||
}
|
||||
|
||||
function closeWishlistIgnoreModal() {
|
||||
const modal = document.getElementById('wishlist-ignore-modal');
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
|
||||
async function loadWishlistIgnoreList() {
|
||||
const list = document.getElementById('wishlist-ignore-list');
|
||||
const clearBtn = document.getElementById('wishlist-ignore-clear-btn');
|
||||
if (!list) return;
|
||||
try {
|
||||
const resp = await fetch('/api/wishlist/ignore-list');
|
||||
const data = await resp.json();
|
||||
const entries = (data && data.entries) || [];
|
||||
if (clearBtn) clearBtn.style.display = entries.length ? '' : 'none';
|
||||
if (!entries.length) {
|
||||
list.innerHTML = '<div class="playlist-empty-state" style="text-align:center;opacity:0.6;padding:30px 0;">🎉<br><br>Nothing ignored.</div>';
|
||||
return;
|
||||
}
|
||||
const ttl = (data && data.ttl_days) || 30;
|
||||
list.innerHTML = entries.map(e => {
|
||||
const title = escapeHtml(e.track_name || e.track_id || 'Unknown');
|
||||
const artist = escapeHtml(e.artist_name || '');
|
||||
const reason = e.reason === 'cancelled' ? 'Cancelled' : 'Removed';
|
||||
const tid = escapeHtml(String(e.track_id || ''));
|
||||
return `<div class="wishlist-ignore-row" style="display:flex;align-items:center;gap:10px;padding:8px 4px;border-bottom:1px solid rgba(255,255,255,0.06);">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${title}</div>
|
||||
<div style="opacity:0.6;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${artist}${artist ? ' · ' : ''}${reason} · skips ${ttl}d</div>
|
||||
</div>
|
||||
<button class="playlist-modal-btn playlist-modal-btn-secondary" style="flex-shrink:0;" data-track-id="${tid}" onclick="unignoreWishlistTrack(this.dataset.trackId)">Un-ignore</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
list.innerHTML = '<div class="playlist-empty-state" style="text-align:center;opacity:0.6;padding:30px 0;">Error loading ignored tracks</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function unignoreWishlistTrack(trackId) {
|
||||
if (!trackId) return;
|
||||
try {
|
||||
const resp = await fetch('/api/wishlist/ignore-list/remove', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ track_id: trackId }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data && data.success) {
|
||||
showToast('Track un-ignored — it can be auto-downloaded again.', 'success');
|
||||
await loadWishlistIgnoreList();
|
||||
} else {
|
||||
showToast(`Un-ignore failed: ${(data && data.error) || 'unknown'}`, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`Un-ignore failed: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function clearWishlistIgnoreList() {
|
||||
if (!await showConfirmDialog({
|
||||
title: 'Clear Ignored List',
|
||||
message: 'Allow all currently-ignored tracks to be auto-downloaded again?',
|
||||
confirmText: 'Clear All',
|
||||
cancelText: 'Cancel',
|
||||
})) return;
|
||||
try {
|
||||
const resp = await fetch('/api/wishlist/ignore-list/clear', { method: 'POST' });
|
||||
const data = await resp.json();
|
||||
if (data && data.success) {
|
||||
showToast(`Cleared ${data.cleared || 0} ignored track(s).`, 'success');
|
||||
await loadWishlistIgnoreList();
|
||||
} else {
|
||||
showToast(`Clear failed: ${(data && data.error) || 'unknown'}`, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`Clear failed: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupWishlistOverview() {
|
||||
console.log('🧹 cleanupWishlistOverview() called');
|
||||
|
||||
|
|
|
|||
|
|
@ -3404,21 +3404,19 @@ function closeHelperSearch() {
|
|||
const WHATS_NEW = {
|
||||
// Convention: keep only the CURRENT release here, plus a single brief
|
||||
// "Earlier versions" summary entry. Don't accumulate old per-version blocks.
|
||||
'2.7.2': [
|
||||
{ date: 'June 2026 — 2.7.2 release' },
|
||||
{ title: 'Organize playlists into folders', desc: 'optionally mirror each playlist into its own folder on disk — symlink (no extra space) or copy — so external players (plex / jellyfin / music assistant) see your soulsync playlists as real folders. rebuilds itself after every sync, prunes removed tracks, and there\'s a manual rebuild button in settings.', page: 'settings' },
|
||||
{ title: 'Export server playlists as M3U', desc: 'one-click "Export M3U" button in the Server Playlists compare/editor toolbar — writes a standard .m3u of the playlist\'s tracks and downloads it to your browser. handy for music assistant and friends.', page: 'sync' },
|
||||
{ title: 'Follow-only watchlist', desc: 'each watchlist artist now has an "auto-download" toggle (on by default). turn it off to just follow an artist — scans still discover and surface new releases, they just don\'t get auto-added to the wishlist.', page: 'artists' },
|
||||
{ title: 'Download from a SoundCloud link (#865)', desc: 'paste a soundcloud track url — including unlisted / private share links — into manual search and it resolves + downloads directly.', page: 'downloads' },
|
||||
{ title: 'YouTube playlists keep the artist (#863)', desc: 'youtube / youtube-music playlists that used to import as "Unknown Artist" now recover the real artist from the track\'s music metadata, the "Artist - Title" pattern, or the uploading channel — and fall back to the matched artist when youtube gives nothing.', page: 'sync' },
|
||||
{ title: 'Rename mirrored playlists', desc: 'a mirrored playlist now has a rename (✏️) button — set a custom name that changes how it shows in soulsync and how it syncs, survives upstream refreshes, and still tracks the same server playlist.', page: 'sync' },
|
||||
{ title: 'New maintenance jobs', desc: 'ReplayGain Filler (#437) computes + writes missing replaygain tags across your library, and Empty Folder Cleaner sweeps out leftover empty directories. both under Tools → library maintenance.', page: 'tools' },
|
||||
{ title: 'Export your watchlist + library', desc: 'one Export button with a scope selector dumps your watchlist roster and/or whole library roster to JSON / CSV / text.', page: 'artists' },
|
||||
{ title: 'Custom completed-download path for Torrent/Usenet (#857)', desc: 'point soulsync at the in-container folder your torrent/usenet client drops finished files into (e.g. a category subfolder), so completed grabs are found instead of going missing.', page: 'settings' },
|
||||
{ title: 'HiFi instances: restore + new working instance', desc: 'a "Restore Defaults" button re-adds any built-in instances you removed (keeps your own), the ✔/✖ controls have bigger tap targets, and a freshly-confirmed working instance is auto-pushed to everyone (thanks Sokhi).', page: 'settings' },
|
||||
{ title: 'Artist DB Record inspector', desc: 'an artist\'s detail page can now show the raw "DB Record" — everything the database knows about that artist — for debugging metadata.', page: 'library' },
|
||||
{ title: 'Fixes', desc: 'a hung database update self-heals now instead of wedging on "Starting..." forever (#859); Library Reorganize finally works on media-server libraries by falling back to tag mode (#862); spotify (no-auth) shows as connected and the dashboard test reports it correctly instead of claiming deezer; navidrome reconnects itself instead of latching disconnected; the orphan detector hard-bails on a mass-orphan flood; plus more #852 lock-screen hardening and login-password management in Manage Profiles.', page: 'settings' },
|
||||
{ title: 'Earlier versions', desc: '2.7.1 added download verification & a review queue (acoustid fingerprint-checks every download), closed the websocket login-bypass (#852), and the acoustid Relocate fix. 2.7.0 brought multi-user for real — per-profile streaming accounts, opt-in login, reverse-proxy support. before that the 2.6.x cycle brought the blocklist, the download-retry overhaul, Download Origins, Spotify-no-auth metadata, and Library Re-tag.' },
|
||||
'2.7.3': [
|
||||
{ date: 'June 2026 — 2.7.3 release' },
|
||||
{ title: 'Quality Upgrade Finder', desc: 'a new findings-based job that scans your library for tracks you own in worse quality than is available and lets you upgrade them. matches by ISRC first, then album→track, then artist+title, with a direct track-ID tier, a dedup-skip, and a duration guard so it never swaps in the wrong song. replaces the old auto-acting Quality Scanner.', page: 'tools' },
|
||||
{ title: 'Tidal playlist discovery shows everything (#867)', desc: 'tidal playlist discovery used to cap at ~21 tracks — it now walks the whole playlist. and the discovery modal opens instantly in its "discovering" state instead of freezing the UI for ~10s on a pre-fetch.', page: 'sync' },
|
||||
{ title: 'Wishlist ignore-list (#874)', desc: 'remove a track from the wishlist or cancel an in-flight wishlist download and soulsync stops auto-re-adding it (it used to re-grab the same thing forever). softer than a blocklist — it expires after 30 days and never blocks a manual download. new "Ignored" view to see/undo it.', page: 'downloads' },
|
||||
{ title: 'Quarantine: group duplicates + auto-clear (#876)', desc: 'multiple failed attempts at the same song now collapse into one collapsible group; approve the good one and the other options auto-clear. plus the quarantine tab shows the correct count the moment you open it.', page: 'downloads' },
|
||||
{ title: 'Download Discography filters match Artist Detail (#877)', desc: 'the bulk-download modal now has the same Albums / EPs / Singles + Live / Compilations / Featured filters Artist Detail does — and the EPs toggle actually works now (it was always empty before).', page: 'artists' },
|
||||
{ title: 'Settings can no longer wipe your config (#879)', desc: 'if the settings page failed to load it used to fall back to blank defaults and then autosave the blanks over your real config. it now bails on a failed load and tells you to reload — your saved config is left untouched.', page: 'settings' },
|
||||
{ title: 'Tidal Favorites mirror grabs everything (#880)', desc: 'a transient tidal rate-limit (429) mid-sync used to truncate your Favorite Tracks mirror to ~98 of 500+. it retries the page with backoff now instead of stopping short.', page: 'sync' },
|
||||
{ title: 'Track numbers fixed (the "Track 01" bug)', desc: 'single tracks — especially deezer-sourced — imported as "01 - Title" regardless of their real album position, littering album folders with duplicate 01s. soulsync now recovers the real position from the downloaded file\'s own tag instead of guessing 1.', page: 'downloads' },
|
||||
{ title: 'More fixes', desc: 'deezer ARL stops appearing to "reset itself" — the saved token is tested directly, not a redacted mask (#870); an artist sharing a name with another no longer gets the wrong discography (disambiguated by the catalog you actually own, #868); a "Title - Remix" search now matches the base-titled track in your library; and a colon in a title (T:T) matches an underscore variant (T_T).', page: 'library' },
|
||||
{ title: 'Sidebar polish', desc: 'frosted-glass header blur, vertically-centered nav count badges, and the My-Accounts / Personal-Settings buttons are hidden for admins (who use the global app account anyway).', page: 'settings' },
|
||||
{ title: 'Earlier versions', desc: '2.7.2 added playlist-folder mirroring, server-playlist M3U export, follow-only watchlist, soundcloud-link + better youtube imports, and ReplayGain / Empty-Folder maintenance jobs. 2.7.1 added download verification (acoustid fingerprint-checks every download) + a review queue and closed the websocket login-bypass (#852). 2.7.0 made multi-user real — per-profile streaming accounts, opt-in login, reverse-proxy support. before that the 2.6.x cycle brought the blocklist, the download-retry overhaul, Download Origins, and Library Re-tag.' },
|
||||
],
|
||||
};
|
||||
|
||||
|
|
@ -3449,58 +3447,57 @@ const WHATS_NEW = {
|
|||
// usage_note?: 'optional hint shown at the bottom' }
|
||||
const VERSION_MODAL_SECTIONS = [
|
||||
{
|
||||
title: "Organize playlists into folders",
|
||||
description: "soulsync can now mirror each of your playlists into its own folder on disk, so external players (plex / jellyfin / music assistant) see them as real folders instead of just living inside soulsync.",
|
||||
title: "Quality Upgrade Finder",
|
||||
description: "a new findings-based job that scans your library for tracks you own in worse quality than is available, and lets you upgrade them. replaces the old auto-acting Quality Scanner.",
|
||||
features: [
|
||||
"symlink (no extra disk space) or copy — your choice",
|
||||
"rebuilds itself after every sync and prunes tracks you removed",
|
||||
"separate output root + a manual rebuild button in settings",
|
||||
"matches by ISRC first, then album→track, then artist+title — using the IDs enrichment already embedded",
|
||||
"a direct track-ID tier for exact source matches",
|
||||
"dedup-skip so it won't re-find the same upgrade, and a duration guard so it never swaps in the wrong track",
|
||||
],
|
||||
usage_note: "Settings → Playlists → organize into folders",
|
||||
usage_note: "Tools → Quality Upgrade Finder",
|
||||
},
|
||||
{
|
||||
title: "Better YouTube & SoundCloud imports",
|
||||
description: "the two weak spots in playlist/link importing got fixed.",
|
||||
title: "Wishlist ignore-list (#874)",
|
||||
description: "remove a track from the wishlist, or cancel an in-flight wishlist download, and soulsync stops auto-re-adding it — it used to re-download the same release forever.",
|
||||
features: [
|
||||
"#863 — youtube / youtube-music playlists that imported as \"Unknown Artist\" now recover the real artist from music metadata, the \"Artist - Title\" pattern, or the uploading channel (and fall back to the matched artist)",
|
||||
"#865 — paste a soundcloud track link, including unlisted / private share urls, into manual search to download it directly",
|
||||
"softer than a blocklist: it expires after 30 days and never blocks a manual download",
|
||||
"a new \"Ignored\" view lets you see what's skipped and un-ignore anything",
|
||||
],
|
||||
usage_note: "Wishlist → Ignored",
|
||||
},
|
||||
{
|
||||
title: "Quarantine: group duplicates + auto-clear (#876)",
|
||||
description: "the quarantine workflow got a cleanup pass.",
|
||||
features: [
|
||||
"multiple failed attempts at the same song collapse into one collapsible group",
|
||||
"approve the good one and the other options auto-clear",
|
||||
"the quarantine tab shows the correct count the moment you open it (no more stale 0)",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Export server playlists as M3U",
|
||||
description: "a one-click \"Export M3U\" button now sits in the Server Playlists compare/editor toolbar — writes a standard .m3u of the playlist and downloads it to your browser. great for music assistant.",
|
||||
features: [],
|
||||
usage_note: "Sync → Server Playlists → Export M3U",
|
||||
},
|
||||
{
|
||||
title: "Follow-only watchlist + more",
|
||||
description: "a grab-bag of requested features.",
|
||||
title: "Tidal discovery & favorites (#867, #880)",
|
||||
description: "two tidal sync fixes.",
|
||||
features: [
|
||||
"per-artist \"auto-download\" toggle: turn it off to just follow an artist — scans still surface new releases, they just don't auto-add to the wishlist",
|
||||
"rename mirrored playlists (✏️): a custom name that changes the display + sync name, survives refreshes, still tracks the server playlist",
|
||||
"export your watchlist and/or whole library roster to JSON / CSV / text",
|
||||
"ReplayGain Filler (#437) and Empty Folder Cleaner library-maintenance jobs",
|
||||
"custom in-container completed-download path for Torrent / Usenet sources (#857)",
|
||||
"HiFi instances: Restore Defaults button, bigger tap targets, and a new confirmed-working instance pushed to everyone",
|
||||
"Artist detail \"DB Record\" inspector for debugging metadata",
|
||||
"#867 — playlist discovery now walks the whole playlist instead of capping at ~21 tracks, and the modal opens instantly instead of freezing the UI for ~10s",
|
||||
"#880 — a transient rate-limit (429) mid-sync no longer truncates your Favorite Tracks mirror to ~98 of 500+; it retries with backoff",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Fixes this release",
|
||||
description: "a stack of issue fixes on top of 2.7.1.",
|
||||
description: "a stack of issue fixes on top of 2.7.2.",
|
||||
features: [
|
||||
"#859 — a hung database update self-heals instead of wedging on \"Starting...\" forever",
|
||||
"#862 — Library Reorganize now works on media-server libraries (falls back to tag mode when there are no source IDs)",
|
||||
"spotify (no-auth) shows as connected and the dashboard test reports it correctly, instead of claiming a deezer fallback",
|
||||
"navidrome reconnects itself instead of latching \"disconnected\"",
|
||||
"the orphan detector hard-bails on a mass-orphan flood instead of plowing ahead",
|
||||
"more #852 lock-screen hardening + login-password management in Manage Profiles",
|
||||
"Aria2 added to the torrent client list",
|
||||
"the \"Track 01\" bug — single tracks (especially deezer) imported as \"01\" regardless of real album position; now recovered from the file's own tag",
|
||||
"#879 — a failed Settings load can no longer overwrite your saved config with blank defaults",
|
||||
"#877 — Download Discography filters now match Artist Detail (working EPs toggle + Live / Compilations / Featured)",
|
||||
"#870 — deezer ARL stops appearing to \"reset itself\" (the saved token is tested, not a redacted mask)",
|
||||
"#868 — an artist sharing a name with another no longer gets the wrong discography",
|
||||
"Find & Add matches a \"Title - Remix\" search to the base-titled library track; a colon (T:T) matches an underscore variant (T_T)",
|
||||
"sidebar polish: frosted-glass header, centered nav badges, admin-only cleanup",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Earlier in 2.7.1 / 2.7.0",
|
||||
description: "2.7.1 added download verification & an unverified review queue (acoustid fingerprint-checks every download against what you asked for), closed the websocket login-bypass (#852), and added the acoustid Relocate fix action. 2.7.0 made multi-user real: per-profile streaming accounts (My Accounts), auto-sync running as its owner, opt-in username/password login with recovery, and reverse-proxy support. before that, the 2.6.x cycle brought the blocklist, the download-retry overhaul, Download Origins, Spotify-no-auth metadata, and Library Re-tag.",
|
||||
title: "Earlier in 2.7.2 / 2.7.1 / 2.7.0",
|
||||
description: "2.7.2 added playlist-folder mirroring, server-playlist M3U export, follow-only watchlist, soundcloud-link + better youtube imports, and ReplayGain / Empty-Folder maintenance jobs. 2.7.1 added download verification (acoustid fingerprint-checks every download against what you asked for) + an unverified review queue, and closed the websocket login-bypass (#852). 2.7.0 made multi-user real: per-profile streaming accounts (My Accounts), opt-in username/password login with recovery, and reverse-proxy support. before that, the 2.6.x cycle brought the blocklist, the download-retry overhaul, Download Origins, and Library Re-tag.",
|
||||
features: [],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1116,6 +1116,15 @@ function updateProfileIndicator() {
|
|||
const statusSection = document.querySelector('.status-section--clickable');
|
||||
if (statusSection) statusSection.classList.toggle('status-section--locked', !currentProfile.is_admin);
|
||||
|
||||
// My Accounts (per-profile streaming OAuth) and My Settings (per-profile
|
||||
// server library) are inert for admin — admin uses the global app account
|
||||
// for every service and the full Settings page. Hide both for admin; keep
|
||||
// them for non-admins, who actually get a connect/library UI.
|
||||
const myAccountsBtn = document.getElementById('my-accounts-btn');
|
||||
const personalSettingsBtn = document.getElementById('personal-settings-btn');
|
||||
if (myAccountsBtn) myAccountsBtn.style.display = currentProfile.is_admin ? 'none' : '';
|
||||
if (personalSettingsBtn) personalSettingsBtn.style.display = currentProfile.is_admin ? 'none' : '';
|
||||
|
||||
indicator.onclick = async () => {
|
||||
const res = await fetch('/api/profiles');
|
||||
const data = await res.json();
|
||||
|
|
|
|||
|
|
@ -1891,16 +1891,12 @@ function createReleaseCard(release) {
|
|||
// Store mutable reference so stream updates propagate to click handler
|
||||
card._releaseData = release;
|
||||
|
||||
// Tag card for content-type filtering
|
||||
const livePattern = /\b(live)\b|\(live[^)]*\)|\[live[^]]*\]/i;
|
||||
const compilationPattern = /\b(greatest hits|best of|collection|anthology|essential)\b/i;
|
||||
const featuredPattern = /\(?\bfeat\.?\s|\bft\.?\s|\bfeaturing\b/i;
|
||||
const isLive = livePattern.test(release.title || '') || (release.album_type === 'compilation' && livePattern.test(release.title || ''));
|
||||
const isCompilation = (release.album_type === 'compilation') || compilationPattern.test(release.title || '');
|
||||
const isFeatured = featuredPattern.test(release.title || '');
|
||||
card.setAttribute("data-is-live", isLive ? "true" : "false");
|
||||
card.setAttribute("data-is-compilation", isCompilation ? "true" : "false");
|
||||
card.setAttribute("data-is-featured", isFeatured ? "true" : "false");
|
||||
// Tag card for content-type filtering (shared classifier — #877, so Artist
|
||||
// Detail and the Download Discography modal never drift apart).
|
||||
const cc = _classifyReleaseContent(release);
|
||||
card.setAttribute("data-is-live", cc.isLive ? "true" : "false");
|
||||
card.setAttribute("data-is-compilation", cc.isCompilation ? "true" : "false");
|
||||
card.setAttribute("data-is-featured", cc.isFeatured ? "true" : "false");
|
||||
|
||||
// Background image — use data-bg-src for IntersectionObserver lazy loading
|
||||
// (observeLazyBackgrounds is called by the caller after appending the grid).
|
||||
|
|
@ -2518,8 +2514,8 @@ async function openDiscographyModal() {
|
|||
const data = await res.json();
|
||||
|
||||
if (!data.error) {
|
||||
discography = { albums: data.albums || [], singles: data.singles || [] };
|
||||
if (discography.albums.length > 0 || discography.singles.length > 0) {
|
||||
discography = { albums: data.albums || [], eps: data.eps || [], singles: data.singles || [] };
|
||||
if (discography.albums.length > 0 || discography.eps.length > 0 || discography.singles.length > 0) {
|
||||
artistsPageState.artistDiscography = discography;
|
||||
artistsPageState.sourceOverride = data.source || artistsPageState.sourceOverride || null;
|
||||
// Use metadata source ID for the modal (needed for download API calls)
|
||||
|
|
@ -2569,6 +2565,9 @@ async function openDiscographyModal() {
|
|||
<button class="discog-filter active" data-type="album" onclick="toggleDiscogFilter(this)">Albums</button>
|
||||
<button class="discog-filter active" data-type="ep" onclick="toggleDiscogFilter(this)">EPs</button>
|
||||
<button class="discog-filter active" data-type="single" onclick="toggleDiscogFilter(this)">Singles</button>
|
||||
<button class="discog-filter active" data-content="live" onclick="toggleDiscogFilter(this)">Live</button>
|
||||
<button class="discog-filter active" data-content="compilations" onclick="toggleDiscogFilter(this)">Compilations</button>
|
||||
<button class="discog-filter active" data-content="featured" onclick="toggleDiscogFilter(this)">Featured</button>
|
||||
</div>
|
||||
<div class="discog-select-actions">
|
||||
<button class="discog-select-btn" onclick="discogSelectAll(true)">Select All</button>
|
||||
|
|
@ -2605,21 +2604,36 @@ async function openDiscographyModal() {
|
|||
|
||||
function _esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
// #877: single source of truth for content-type classification, shared by the
|
||||
// Artist Detail cards and the Download Discography modal so they can't drift.
|
||||
function _classifyReleaseContent(release) {
|
||||
const t = (release && (release.title || release.name)) || '';
|
||||
const livePattern = /\b(live)\b|\(live[^)]*\)|\[live[^\]]*\]/i;
|
||||
const compilationPattern = /\b(greatest hits|best of|collection|anthology|essential)\b/i;
|
||||
const featuredPattern = /\(?\bfeat\.?\s|\bft\.?\s|\bfeaturing\b/i;
|
||||
return {
|
||||
isLive: livePattern.test(t),
|
||||
isCompilation: (release && release.album_type === 'compilation') || compilationPattern.test(t),
|
||||
isFeatured: featuredPattern.test(t),
|
||||
};
|
||||
}
|
||||
|
||||
function _renderDiscogCard(release, index, completionData) {
|
||||
const comp = completionData?.albums?.find(c => c.id === release.id) || completionData?.singles?.find(c => c.id === release.id);
|
||||
const status = comp?.status || 'unknown';
|
||||
const isOwned = status === 'completed';
|
||||
const isPartial = status === 'partial' || status === 'nearly_complete';
|
||||
const year = release.release_date ? release.release_date.substring(0, 4) : '';
|
||||
const tracks = release.total_tracks || 0;
|
||||
const tracks = release.total_tracks || release.track_count || 0;
|
||||
const img = release.image_url || '';
|
||||
const cc = _classifyReleaseContent(release);
|
||||
const checked = !isOwned;
|
||||
const statusClass = isOwned ? 'owned' : isPartial ? 'partial' : '';
|
||||
const statusIcon = isOwned ? '✓' : isPartial ? '◐' : '';
|
||||
|
||||
const albumName = release.name || release.title || '';
|
||||
return `
|
||||
<label class="discog-card ${statusClass}" data-type="${release._type}" style="animation-delay:${index * 0.03}s">
|
||||
<label class="discog-card ${statusClass}" data-type="${release._type}" data-is-live="${cc.isLive}" data-is-compilation="${cc.isCompilation}" data-is-featured="${cc.isFeatured}" style="animation-delay:${index * 0.03}s">
|
||||
<input type="checkbox" class="discog-card-cb" data-album-id="${release.id}" data-album-name="${_esc(albumName)}" data-tracks="${tracks}" ${checked ? 'checked' : ''} onchange="_updateDiscogFooterCount()">
|
||||
<div class="discog-card-art">
|
||||
${img ? `<img src="${img}" alt="" loading="lazy">` : '<div class="discog-card-art-placeholder">🎵</div>'}
|
||||
|
|
@ -2636,9 +2650,29 @@ function _renderDiscogCard(release, index, completionData) {
|
|||
|
||||
function toggleDiscogFilter(btn) {
|
||||
btn.classList.toggle('active');
|
||||
const type = btn.dataset.type;
|
||||
document.querySelectorAll(`.discog-card[data-type="${type}"]`).forEach(card => {
|
||||
card.style.display = btn.classList.contains('active') ? '' : 'none';
|
||||
_applyDiscogFilters();
|
||||
}
|
||||
|
||||
// #877: combined category (Albums/EPs/Singles) + content (Live/Compilations/
|
||||
// Featured) filtering, mirroring the Artist Detail filter logic. A card is
|
||||
// hidden if its category is off OR any active content exclusion applies — and
|
||||
// because the download payload is built from VISIBLE checked cards, every
|
||||
// toggle now actually changes what gets downloaded.
|
||||
function _applyDiscogFilters() {
|
||||
const typeActive = {};
|
||||
document.querySelectorAll('.discog-filter[data-type]').forEach(b => {
|
||||
typeActive[b.dataset.type] = b.classList.contains('active');
|
||||
});
|
||||
const contentActive = {};
|
||||
document.querySelectorAll('.discog-filter[data-content]').forEach(b => {
|
||||
contentActive[b.dataset.content] = b.classList.contains('active');
|
||||
});
|
||||
document.querySelectorAll('.discog-card').forEach(card => {
|
||||
let hidden = typeActive[card.getAttribute('data-type')] === false;
|
||||
if (!hidden && contentActive.live === false && card.getAttribute('data-is-live') === 'true') hidden = true;
|
||||
if (!hidden && contentActive.compilations === false && card.getAttribute('data-is-compilation') === 'true') hidden = true;
|
||||
if (!hidden && contentActive.featured === false && card.getAttribute('data-is-featured') === 'true') hidden = true;
|
||||
card.style.display = hidden ? 'none' : '';
|
||||
});
|
||||
_updateDiscogFooterCount();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ 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;
|
||||
// #879: never auto-save while the last settings load failed — the form is
|
||||
// showing defaults, not the real config, so saving would wipe it.
|
||||
if (window._settingsLoadFailed) 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
|
||||
|
|
@ -976,6 +979,18 @@ async function loadSettingsData() {
|
|||
const response = await fetch(API.settings);
|
||||
const settings = await response.json();
|
||||
|
||||
// #879: a failed GET /api/settings returns an error body (e.g. {"error":
|
||||
// "..."} on a 500), NOT real settings. Populating from it blanks every
|
||||
// field to its default ('settings.spotify?.x || ""'), and the next
|
||||
// (auto)save then overwrites the user's real config. Abort BEFORE
|
||||
// touching any field and flag it so saves stay blocked until a good load.
|
||||
if (!response.ok || !settings || typeof settings !== 'object' || settings.error) {
|
||||
window._settingsLoadFailed = true;
|
||||
throw new Error('settings load failed (HTTP ' + response.status + '): ' +
|
||||
((settings && settings.error) || 'unexpected response'));
|
||||
}
|
||||
window._settingsLoadFailed = false; // good load → saving is safe again
|
||||
|
||||
// Populate Spotify settings
|
||||
document.getElementById('spotify-client-id').value = settings.spotify?.client_id || '';
|
||||
document.getElementById('spotify-client-secret').value = settings.spotify?.client_secret || '';
|
||||
|
|
@ -1461,7 +1476,10 @@ async function loadSettingsData() {
|
|||
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
showToast('Failed to load settings', 'error');
|
||||
// #879: any load failure → block saves so a blank/partial form can't be
|
||||
// written over the real config. Cleared on the next successful load.
|
||||
window._settingsLoadFailed = true;
|
||||
showToast('Failed to load settings — reload the page before saving (your saved config is untouched)', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2861,6 +2879,16 @@ function _getTagConfig(path) {
|
|||
}
|
||||
|
||||
async function saveSettings(quiet = false) {
|
||||
// #879: refuse to save if the settings never loaded successfully — the form
|
||||
// is showing defaults, not the user's real config, so saving would wipe it.
|
||||
// Cleared automatically on the next successful load (reload the page).
|
||||
if (window._settingsLoadFailed) {
|
||||
if (!quiet && typeof showToast === 'function') {
|
||||
showToast("Settings didn't load — reload the page before saving (your config is untouched)", 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file organization templates before saving
|
||||
const validationErrors = validateFileOrganizationTemplates();
|
||||
if (validationErrors.length > 0) {
|
||||
|
|
|
|||
|
|
@ -80,7 +80,6 @@ body {
|
|||
/* Soft translucent borders */
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-top-right-radius: 24px;
|
||||
border-bottom-right-radius: 24px;
|
||||
|
||||
/* Soft floating shadow with inner glow */
|
||||
|
|
@ -291,17 +290,16 @@ body.reduce-effects .sidebar::after {
|
|||
|
||||
.sidebar-header {
|
||||
min-height: 115px;
|
||||
/* Opaque base layered under the accent gradient so nav items scrolling
|
||||
past the sticky header don't bleed through the translucent stops. */
|
||||
/* Translucent so the backdrop-filter blur below is visible — the dark tint
|
||||
keeps the header readable while nav items scrolling behind it read as a
|
||||
soft frosted blur instead of a sharp bleed-through. */
|
||||
background:
|
||||
linear-gradient(180deg,
|
||||
rgba(var(--accent-rgb), 0.14) 0%,
|
||||
rgba(var(--accent-rgb), 0.08) 30%,
|
||||
rgba(var(--accent-rgb), 0.03) 70%,
|
||||
rgba(18, 18, 18, 1) 100%),
|
||||
rgb(18, 18, 18);
|
||||
rgba(var(--accent-rgb), 0.16) 0%,
|
||||
rgba(var(--accent-rgb), 0.10) 30%,
|
||||
rgba(24, 24, 24, 0.62) 70%,
|
||||
rgba(18, 18, 18, 0.72) 100%);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-top-right-radius: 20px;
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -309,9 +307,21 @@ body.reduce-effects .sidebar::after {
|
|||
gap: 8px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
/* Above the nav (the sidebar gives its children z-index:1) so nav items
|
||||
scrolling up sit BEHIND the header — i.e. in its backdrop, where the
|
||||
blur below can actually act on them. Without this they paint in front
|
||||
and backdrop-filter has nothing to blur. */
|
||||
z-index: 2;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
/* Intense frosted-glass blur: nav items scrolling up behind the translucent
|
||||
accent-gradient top now read as a soft blur instead of bleeding through
|
||||
sharply. (Only visible where the background is translucent — the opaque
|
||||
base toward the bottom still stops any bleed.) */
|
||||
backdrop-filter: blur(28px) saturate(1.3);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(1.3);
|
||||
|
||||
/* Subtle inner glow */
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
|
@ -10520,6 +10530,63 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
gap: 4px;
|
||||
}
|
||||
|
||||
/* #876: quarantine "alternatives for one song" group — a collapsible parent
|
||||
row wrapping the standard entry rows for each failed source attempt. */
|
||||
.lh-quarantine-group {
|
||||
border-left: 3px solid rgba(var(--accent-rgb), 0.35);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 4px;
|
||||
background: rgba(var(--accent-rgb), 0.03);
|
||||
}
|
||||
|
||||
.lh-quarantine-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.lh-quarantine-group-header:hover {
|
||||
background: rgba(var(--accent-rgb), 0.06);
|
||||
}
|
||||
|
||||
.lh-quarantine-group-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lh-quarantine-group-count {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: rgba(var(--accent-rgb), 0.95);
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.4);
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lh-quarantine-group-header .lh-expand-btn {
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.lh-quarantine-group:not(.lh-qgroup-collapsed) .lh-quarantine-group-header .lh-expand-btn {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.lh-quarantine-group-members {
|
||||
padding: 0 8px 8px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.lh-quarantine-group.lh-qgroup-collapsed .lh-quarantine-group-members {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.library-history-entry-row1 {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
|
@ -60149,8 +60216,9 @@ body.reduce-effects #page-particles-canvas {
|
|||
|
||||
.dl-nav-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
top: 50%;
|
||||
right: 8px;
|
||||
transform: translateY(-50%);
|
||||
background: rgb(var(--accent-rgb));
|
||||
color: #fff;
|
||||
font-size: 0.6rem;
|
||||
|
|
|
|||
|
|
@ -3253,9 +3253,23 @@ function openLibraryHistoryModal() {
|
|||
overlay.classList.remove('hidden');
|
||||
_libraryHistoryState.page = 1;
|
||||
loadLibraryHistory();
|
||||
_refreshQuarantineTabCount(); // #876: count correct on open, not only after clicking the tab
|
||||
}
|
||||
}
|
||||
|
||||
// #876: keep the Quarantine tab badge accurate the moment the modal opens. The
|
||||
// full count was previously only set by loadQuarantineList() (i.e. after the tab
|
||||
// was clicked), so it showed a stale 0 until then.
|
||||
async function _refreshQuarantineTabCount() {
|
||||
const el = document.getElementById('history-quarantine-count');
|
||||
if (!el) return;
|
||||
try {
|
||||
const resp = await fetch('/api/quarantine/list');
|
||||
const data = await resp.json();
|
||||
el.textContent = (data.entries || []).length;
|
||||
} catch (e) { /* leave the existing value on error */ }
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Quarantine tab — rendered inside the Library History modal as a third
|
||||
// tab next to Downloads + Server Imports. Reuses the existing list +
|
||||
|
|
@ -3286,13 +3300,66 @@ async function loadQuarantineList() {
|
|||
list.innerHTML = '<div class="library-history-empty">🛡️<br><br>No quarantined files. Nice and clean.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = entries.map(renderQuarantineEntry).join('');
|
||||
list.innerHTML = _groupQuarantineEntries(entries).map(renderQuarantineGroupOrEntry).join('');
|
||||
} catch (err) {
|
||||
console.error('Error loading quarantine entries:', err);
|
||||
list.innerHTML = '<div class="library-history-empty">Error loading quarantine</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// #876: bucket entries that are alternatives for the SAME intended target
|
||||
// (same backend group_key — derived from expected_artist/expected_track, the
|
||||
// track SoulSync was trying to fetch, not the bad file's own tags). Entries
|
||||
// with a null group_key (legacy/orphan, ungroupable) each stand alone.
|
||||
// Preserves the newest-first order the backend already sorted by.
|
||||
function _groupQuarantineEntries(entries) {
|
||||
const groups = [];
|
||||
const byKey = new Map();
|
||||
for (const entry of entries) {
|
||||
const key = entry.group_key;
|
||||
if (!key) { groups.push({ key: null, members: [entry] }); continue; }
|
||||
let g = byKey.get(key);
|
||||
if (!g) { g = { key, members: [] }; byKey.set(key, g); groups.push(g); }
|
||||
g.members.push(entry);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function renderQuarantineGroupOrEntry(group) {
|
||||
if (group.members.length === 1) return renderQuarantineEntry(group.members[0]);
|
||||
return renderQuarantineGroup(group);
|
||||
}
|
||||
|
||||
// A collapsible parent row for multiple alternatives of one song. Header shows
|
||||
// the shared track/artist + an alternatives count; members reuse the standard
|
||||
// entry markup so per-row Approve/Recover/Delete keep working unchanged.
|
||||
function renderQuarantineGroup(group) {
|
||||
const first = group.members[0] || {};
|
||||
const title = first.expected_track || first.original_filename || 'Unknown';
|
||||
const artist = first.expected_artist || '';
|
||||
const n = group.members.length;
|
||||
const sub = artist ? escapeHtml(artist) : '';
|
||||
// Reuse the album art the sidecar context carried, when any member has it.
|
||||
const thumb = (group.members.find(m => m.thumb_url) || {}).thumb_url || '';
|
||||
const thumbHtml = thumb
|
||||
? `<img class="library-history-thumb" src="${escapeHtml(thumb)}" alt="" onerror="this.style.display='none'">`
|
||||
: '<div class="library-history-thumb-placeholder">🛡️</div>';
|
||||
return `<div class="lh-quarantine-group lh-qgroup-collapsed">
|
||||
<div class="lh-quarantine-group-header" onclick="this.parentElement.classList.toggle('lh-qgroup-collapsed')">
|
||||
${thumbHtml}
|
||||
<div class="lh-quarantine-group-text">
|
||||
<div class="library-history-entry-title">${escapeHtml(title)}</div>
|
||||
<div class="library-history-entry-meta">${sub}</div>
|
||||
</div>
|
||||
<span class="lh-quarantine-group-count">${n} alternatives</span>
|
||||
<span class="lh-expand-btn">▾</span>
|
||||
</div>
|
||||
<div class="lh-quarantine-group-members">
|
||||
${group.members.map(renderQuarantineEntry).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderQuarantineEntry(entry) {
|
||||
const triggerLabels = { integrity: 'Duration / Integrity', acoustid: 'AcoustID Mismatch', bit_depth: 'Bit Depth Filter', unknown: 'Unknown' };
|
||||
const triggerColors = { integrity: '#facc15', acoustid: '#ef5350', bit_depth: '#fb923c', unknown: '#888' };
|
||||
|
|
@ -3374,12 +3441,20 @@ async function approveQuarantineEntry(entryId) {
|
|||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
const r = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/approve`, { method: 'POST' });
|
||||
const r = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
// #876: clear the other quarantined alternatives for this same song
|
||||
// once one is accepted — they're redundant failed attempts now.
|
||||
body: JSON.stringify({ remove_siblings: true }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!data.success) {
|
||||
showToast(`Approve failed: ${data.error}`, 'error');
|
||||
} else {
|
||||
showToast(`Approved — skipped ${data.trigger_bypassed} check, re-running pipeline.`, 'success');
|
||||
const removed = (data.removed_siblings || []).length;
|
||||
const extra = removed ? ` — removed ${removed} other option${removed === 1 ? '' : 's'}.` : '';
|
||||
showToast(`Approved — skipped ${data.trigger_bypassed} check, re-running pipeline.${extra}`, 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`Approve failed: ${err.message}`, 'error');
|
||||
|
|
|
|||
Loading…
Reference in a new issue