Merge pull request #854 from Nezreka/dev

Dev
This commit is contained in:
BoulderBadgeDad 2026-06-11 16:16:01 -07:00 committed by GitHub
commit e5f92032b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 4033 additions and 690 deletions

View file

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

View file

@ -56,6 +56,11 @@
"playlist_path": "$playlist/$artist - $title"
}
},
"import": {
"staging_path": "./Staging",
"replace_lower_quality": false,
"folder_artist_override": true
},
"lossy_copy": {
"enabled": false,
"bitrate": "320",
@ -67,4 +72,4 @@
"listenbrainz": {
"token": "LISTENBRAINZ_TOKEN"
}
}
}

View file

@ -684,7 +684,13 @@ class ConfigManager:
},
"import": {
"staging_path": "./Staging",
"replace_lower_quality": False
"replace_lower_quality": False,
# Use the top Staging folder as the artist (Artist/Album layouts,
# mixtapes). On by default to preserve the long-standing import
# behaviour for existing users. Turn OFF if you stage a mixed pile
# of songs under one container folder, otherwise that folder's name
# overrides every metadata-identified artist (the "soulsync" case).
"folder_artist_override": True
},
"m3u_export": {
"enabled": False,

View file

@ -22,10 +22,11 @@ from core.musicbrainz_client import MusicBrainzClient
logger = get_logger("acoustid.verification")
# Thresholds
MIN_ACOUSTID_SCORE = 0.80 # Minimum AcoustID fingerprint score to trust
TITLE_MATCH_THRESHOLD = 0.70 # Title similarity needed to consider a match
ARTIST_MATCH_THRESHOLD = 0.60 # Artist similarity needed to consider a match
# Thresholds — single definition lives in the shared core; re-exported here so
# existing importers keep working and the values can't drift between paths.
from core.matching.audio_verification import ( # noqa: E402
MIN_ACOUSTID_SCORE, TITLE_MATCH_THRESHOLD, ARTIST_MATCH_THRESHOLD,
)
# Single matching-engine instance so version detection reuses the same patterns
# used by the pre-download Soulseek matcher (remix / live / acoustic /
@ -56,166 +57,29 @@ class VerificationResult(Enum):
ERROR = "error" # Lookup errored (invalid key / rate limit / no backend) - continue, but flag it
def _normalize(text: str) -> str:
"""Normalize a string for comparison: lowercase, strip parentheticals, punctuation."""
if not text:
return ""
s = text.lower().strip()
# Remove ALL parenthetical suffixes — these are metadata annotations, not core title
# Covers: (Live), (Remastered), (Parody of ...), (from "..." Soundtrack), (feat. ...), etc.
s = re.sub(r'\s*\([^)]*\)', '', s)
# Remove ALL square bracket suffixes: [Live], [Remastered], [Deluxe], etc.
s = re.sub(r'\s*\[[^\]]*\]', '', s)
# Remove trailing featuring info not in parentheses: "feat. ...", "ft. ...", "featuring ..."
s = re.sub(r'\s+(?:feat\.?|ft\.?|featuring)\s+.*$', '', s, flags=re.IGNORECASE)
# Remove dash-separated version tags: "- Vocal", "- Instrumental", "- Acoustic", etc.
s = re.sub(r'\s*-\s*(?:vocal|instrumental|acoustic|live|remix|cover|clean|explicit|radio\s*edit|original\s*mix|extended\s*mix|club\s*mix)\s*$', '', s, flags=re.IGNORECASE)
# Remove soundtrack/source subtitles: ' - From "..." Soundtrack', ' - from the film ...'
s = re.sub(r'\s*-\s*from\s+.+$', '', s, flags=re.IGNORECASE)
# Remove non-alphanumeric except spaces
s = re.sub(r'[^\w\s]', '', s)
# Collapse whitespace
s = re.sub(r'\s+', ' ', s).strip()
return s
# normalize() + similarity() + the alias-aware comparison now live in the shared
# decision core (core/matching/audio_verification.py) so import-time verification
# and the library scan share ONE definition — the <>-strip fix, CJK handling and
# thresholds can't drift apart again. Names kept (`_normalize` etc.) for existing
# importers/tests.
from core.matching.audio_verification import ( # noqa: E402
normalize as _normalize,
similarity as _similarity,
_alias_aware_artist_sim,
_find_best_title_artist_match as _core_find_best_title_artist_match,
evaluate as _core_evaluate,
Decision as _CoreDecision,
)
def _similarity(a: str, b: str) -> float:
"""Calculate similarity between two strings (0.0-1.0) after normalization."""
na = _normalize(a)
nb = _normalize(b)
if not na or not nb:
return 0.0
if na == nb:
return 1.0
return SequenceMatcher(None, na, nb).ratio()
def _alias_aware_artist_sim(
expected_artist: str,
actual_artist: str,
aliases: Optional[Any] = None,
) -> float:
"""Best artist-similarity across (expected, *aliases) vs actual.
Issue #442 — when expected and actual are in different scripts
(e.g. `Hiroyuki Sawano` vs `澤野弘之`), raw `_similarity` scores
near 0% even though MusicBrainz aliases bridge them. Routes
through the pure helper so the verifier inherits one shared
contract.
Returns the highest score across all candidates so existing
threshold checks (>= ARTIST_MATCH_THRESHOLD) keep their
semantics. When `aliases` is None or empty, behaves identically
to the prior raw `_similarity(expected, actual)` call.
`aliases` accepts two shapes:
- **Iterable** (list/tuple/set of strings): used directly. Used
by tests that already know the aliases.
- **Callable**: invoked LAZILY only when direct similarity
falls below the threshold. Lets the verifier pass a memoizing
thunk that resolves aliases (DB / cache / live MB) only when
needed. Verifications where the direct match already passes
never trigger the lookup chain no wasted DB query for the
happy path.
Diagnostic logging: emits an INFO line whenever an alias rescues
a comparison that direct similarity would have failed. Lets
future bug reports trace which alias triggered which PASS
decision (e.g. "this file passed because alias `澤野弘之` matched
the file's artist tag").
"""
from core.matching.artist_aliases import artist_names_match
direct = _similarity(expected_artist, actual_artist)
# Fast path — direct match already passes the threshold OR caller
# supplied no aliases handle. Avoids any lookup work.
if aliases is None:
return direct
if direct >= ARTIST_MATCH_THRESHOLD:
return direct
# Resolve the iterable. Callable provider invoked NOW (lazily —
# the caller can memoize the result across multiple invocations
# within one verify_audio_file call).
resolved = aliases() if callable(aliases) else aliases
if not resolved:
return direct
_matched, score = artist_names_match(
expected_artist,
actual_artist,
aliases=resolved,
threshold=ARTIST_MATCH_THRESHOLD,
similarity=_similarity,
def _find_best_title_artist_match(recordings, expected_title, expected_artist,
expected_artist_aliases=None):
"""Back-compat wrapper around the shared core matcher (keeps the
``expected_artist_aliases`` kwarg name for existing callers/tests)."""
return _core_find_best_title_artist_match(
recordings, expected_title, expected_artist, expected_artist_aliases,
)
# Diagnostic — alias rescued a comparison that direct would
# have failed. Worth logging at INFO since it's a user-visible
# decision (file PASS instead of FAIL). One line per rescue
# within a single verify call.
if score >= ARTIST_MATCH_THRESHOLD and direct < ARTIST_MATCH_THRESHOLD:
from core.matching.artist_aliases import best_alias_match
winner, _ = best_alias_match(
expected_artist, actual_artist, resolved, similarity=_similarity,
)
logger.info(
"Artist alias rescued comparison: expected=%r vs actual=%r "
"(direct sim=%.2f, alias %r → score=%.2f)",
expected_artist, actual_artist, direct, winner, score,
)
return score
def _find_best_title_artist_match(
recordings: List[Dict[str, Any]],
expected_title: str,
expected_artist: str,
expected_artist_aliases: Optional[Any] = None,
) -> Tuple[Optional[Dict], float, float]:
"""
Find the AcoustID recording that best matches expected title/artist.
Issue #442 — `expected_artist_aliases` (when supplied) is the
list of alternate spellings for `expected_artist` (Japanese
kanji, Cyrillic, etc.). Accepts either:
- An iterable of alias strings (used eagerly), or
- A callable returning the list (resolved lazily only fires
when at least one recording fails direct artist similarity).
Each recording's artist is scored against (expected, *aliases)
and the best score wins. When the list is empty/omitted/None,
behavior is identical to the prior raw similarity comparison.
Returns:
(best_recording, title_similarity, artist_similarity)
"""
best_rec = None
best_title_sim = 0.0
best_artist_sim = 0.0
best_combined = 0.0
for rec in recordings:
title = rec.get('title') or ''
artist = rec.get('artist') or ''
title_sim = _similarity(expected_title, title)
artist_sim = _alias_aware_artist_sim(
expected_artist, artist, expected_artist_aliases,
)
# Weight title higher since that's the primary identifier
combined = (title_sim * 0.6) + (artist_sim * 0.4)
if combined > best_combined:
best_combined = combined
best_rec = rec
best_title_sim = title_sim
best_artist_sim = artist_sim
return best_rec, best_title_sim, best_artist_sim
# Shared MusicBrainz client for enrichment lookups
_mb_client = None
@ -466,241 +330,32 @@ class AcoustIDVerification:
)
return _alias_cache['value']
# Step 4: Find best title/artist match among AcoustID results
best_rec, title_sim, artist_sim = _find_best_title_artist_match(
recordings, expected_track_name, expected_artist_name,
expected_artist_aliases=_aliases_provider,
# Steps 4-5: delegate the PASS/SKIP/FAIL decision to the shared core
# (core/matching/audio_verification.evaluate) so import verification
# and the library scan apply identical logic.
outcome = _core_evaluate(
expected_track_name, expected_artist_name, recordings,
fingerprint_score=best_score,
aliases_provider=_aliases_provider,
)
if not best_rec:
return VerificationResult.SKIP, "No recordings with title/artist info"
matched_title = best_rec.get('title', '?')
matched_artist = best_rec.get('artist', '?')
logger.info(
f"Best match: '{matched_title}' by '{matched_artist}' "
f"(title_sim={title_sim:.2f}, artist_sim={artist_sim:.2f})"
"Best match: '%s' by '%s' (title_sim=%.2f, artist_sim=%.2f) -> %s",
outcome.matched_title, outcome.matched_artist,
outcome.title_sim, outcome.artist_sim, outcome.decision.value,
)
# Step 4b: Version-mismatch gate.
#
# The ``_normalize`` step deliberately strips parentheticals and
# version tags ("(Instrumental)", "- Live", etc) so that legit
# name variations don't fail the title-similarity comparison.
# That same stripping made it impossible to tell a vocal track
# apart from its instrumental: "In My Feelings" and "In My
# Feelings (Instrumental)" both normalize to "in my feelings",
# the title sim ends up 1.0, and the file passes verification
# even though it's the wrong cut.
#
# Detect the version on each side BEFORE normalization runs.
# If the expected track and the AcoustID-matched recording
# disagree on version (one is original, the other is
# instrumental / live / remix / acoustic / etc), reject — the
# fingerprint identified a real song but it's not the one the
# caller asked for.
expected_version = _detect_title_version(expected_track_name)
matched_version = _detect_title_version(matched_title)
if expected_version != matched_version:
# Issue #607 (AfonsoG6): MusicBrainz often stores live
# recordings with bare titles ("Clarity") while the
# release entry carries the venue annotation ("Clarity
# (Live at Blossom Music Center, ...)"). The fingerprint
# correctly identifies the LIVE recording; only the
# title text is bare. Helper accepts the one-sided bare
# case when fingerprint + bare-title + artist all agree.
# Two-sided version mismatches (live vs remix etc) stay
# strict — those are genuinely different recordings.
if is_acceptable_version_mismatch(
expected_version, matched_version,
fingerprint_score=best_score,
title_similarity=title_sim,
artist_similarity=artist_sim,
):
logger.info(
f"AcoustID version annotation differs (expected={expected_version}, "
f"matched={matched_version}) but fingerprint+title+artist all match — "
f"accepting (likely MB metadata gap on a live/version-annotated recording)"
)
else:
msg = (
f"Version mismatch: expected '{expected_track_name}' ({expected_version}) "
f"but file is '{matched_title}' ({matched_version})"
)
logger.warning(f"AcoustID verification FAILED (version mismatch) - {msg}")
return VerificationResult.FAIL, msg
# Step 5: Decide pass/fail based on similarity
if title_sim >= TITLE_MATCH_THRESHOLD and artist_sim >= ARTIST_MATCH_THRESHOLD:
msg = (
f"Audio verified: '{matched_title}' by '{matched_artist}' "
f"matches expected '{expected_track_name}' by '{expected_artist_name}' "
f"(title={title_sim:.0%}, artist={artist_sim:.0%})"
)
logger.info(f"AcoustID verification PASSED - {msg}")
return VerificationResult.PASS, msg
# Title matches but artist doesn't — could be a cover/collab OR a
# genuinely different track with the same name. Distinguish the
# two by checking whether the expected artist appears anywhere in
# AcoustID's returned recordings.
if title_sim >= TITLE_MATCH_THRESHOLD and artist_sim < ARTIST_MATCH_THRESHOLD:
# First: if the expected artist is present in ANY recording's
# metadata for this fingerprint, it's likely the right track
# (AcoustID's "best" match just picked the wrong variant).
for rec in recordings:
rec_artist = rec.get('artist', '')
if _alias_aware_artist_sim(
expected_artist_name, rec_artist, _aliases_provider,
) >= ARTIST_MATCH_THRESHOLD:
msg = (
f"Audio verified: found '{expected_track_name}' by '{expected_artist_name}' "
f"in AcoustID results"
)
logger.info(f"AcoustID verification PASSED (secondary match) - {msg}")
return VerificationResult.PASS, msg
# Expected artist wasn't found anywhere. Decide between:
# - FAIL: clear mismatch, e.g. "Tom Walker" (sim ~0.2) when
# expecting "Maduk" — different song with same name
# - SKIP: ambiguous, e.g. collab / alt credit / formatting
# difference (sim 0.3-0.6)
#
# The 0.3 cutoff catches hard mismatches while preserving the
# benefit of the doubt for borderline artist formatting.
CLEAR_MISMATCH_THRESHOLD = 0.3
if artist_sim < CLEAR_MISMATCH_THRESHOLD:
msg = (
f"Audio mismatch: file identified as '{matched_title}' by '{matched_artist}', "
f"expected '{expected_track_name}' by '{expected_artist_name}' "
f"(title={title_sim:.0%}, artist={artist_sim:.0%}) — "
f"expected artist not found in any AcoustID recording"
)
logger.warning(f"AcoustID verification FAILED (clear artist mismatch) - {msg}")
return VerificationResult.FAIL, msg
msg = (
f"Title matches but artist unclear: "
f"AcoustID='{matched_title}' by '{matched_artist}', "
f"expected '{expected_track_name}' by '{expected_artist_name}' "
f"(artist_sim={artist_sim:.0%} — ambiguous, could be cover/collab)"
)
logger.info(f"AcoustID verification SKIPPED - {msg}")
return VerificationResult.SKIP, msg
# Title doesn't match — check ALL recordings for any title/artist match
# (the best combined match might not be the right one if there are many results)
# Skip recordings whose version (instrumental/live/etc) disagrees with
# what the caller asked for — the version mismatch above checked
# only the best recording, but a wrong-version variant could still
# win this fallback scan if its bare title matched.
for rec in recordings:
t = rec.get('title') or ''
a = rec.get('artist') or ''
if _detect_title_version(t) != expected_version:
continue
if (_similarity(expected_track_name, t) >= TITLE_MATCH_THRESHOLD and
_alias_aware_artist_sim(
expected_artist_name, a, _aliases_provider,
) >= ARTIST_MATCH_THRESHOLD):
msg = (
f"Audio verified: found '{t}' by '{a}' in AcoustID results "
f"matching expected '{expected_track_name}' by '{expected_artist_name}'"
)
logger.info(f"AcoustID verification PASSED (scan match) - {msg}")
return VerificationResult.PASS, msg
# No match found — but if fingerprint score is very high (≥0.95)
# AND we have evidence the mismatch is a language/script case
# (rather than two genuinely different songs by the same artist),
# skip rather than quarantine a correct file. Two routes:
#
# (a) Either side of the comparison contains non-ASCII characters
# — strong signal of transliteration / kanji↔roman cases.
# Artist must still be a strong match to use this path.
# (b) Both title AND artist similarity are very high (the song
# is recognizably the same with minor punctuation / casing
# differences that fell below the strict match thresholds).
#
# The OLD logic was ``title_sim >= 0.55 OR artist_sim >= match``.
# That fired for English-vs-English songs by the same artist that
# share NO actual content — e.g. "R.O.T.C (Interlude)" by
# Kendrick Lamar getting accepted as "Rich (Interlude)" by
# Kendrick Lamar because the artist matched perfectly and
# "interlude" was shared in both titles. Reported by user when
# downloading Mr. Morale: three tracks (Rich Interlude, Savior
# Interlude, Savior) all received the wrong R.O.T.C audio file
# because of this leak.
# Use the BEST matching recording's strings here (not
# `recordings[0]`) so the failure message reports the same
# candidate the title/artist similarity scores came from.
# Issue #607 (AfonsoG6) example 1: the prior code mixed
# `recordings[0]`'s strings (which can be empty) with
# `best_rec`'s scores, producing nonsense reasons like
# "file identified as '' by '' (artist=100%)" when a later
# recording in the list scored well on artist.
display_title = matched_title or '?'
display_artist = matched_artist or '?'
has_non_ascii = (
any(ord(c) > 127 for c in (expected_track_name or ''))
or any(ord(c) > 127 for c in display_title)
)
language_script_skip = (
best_score >= 0.95
and has_non_ascii
and artist_sim >= ARTIST_MATCH_THRESHOLD
)
high_confidence_strong_match_skip = (
best_score >= 0.95
and title_sim >= 0.80
and artist_sim >= ARTIST_MATCH_THRESHOLD
)
# Issue #797 — the EXPECTED artist and the AcoustID-matched
# artist are written in different scripts (e.g. "Joe Hisaishi"
# vs "久石譲") yet the alias-aware comparison still confirmed
# them as the same artist (artist_sim >= threshold, bridged via
# MusicBrainz aliases). When the artist itself spans scripts the
# title almost always does too — and a romanized-vs-native title
# comparison is meaningless, so it can't be evidence the file is
# wrong. Trust the confirmed artist + the fingerprint (already
# >= MIN_ACOUSTID_SCORE to reach here) and SKIP rather than
# quarantine a correct download of a non-English artist.
#
# Deliberately narrow (the "tight" scope): keyed on the ARTIST
# spanning scripts AND being confirmed. A same-script artist
# with only a cross-script TITLE (romaji artist + kanji title)
# is NOT covered — that case keeps the stricter 0.95 floor
# above, preserving the #607 wrong-file protection.
cross_script_artist_skip = (
best_score >= MIN_ACOUSTID_SCORE
and artist_sim >= ARTIST_MATCH_THRESHOLD
and is_cross_script_mismatch(expected_artist_name, display_artist)
)
if (language_script_skip or high_confidence_strong_match_skip
or cross_script_artist_skip):
reason = (
"likely same song in different language/script"
if (language_script_skip or cross_script_artist_skip)
else "title/artist match within tolerance"
)
msg = (
f"Title/artist mismatch but fingerprint confidence very high ({best_score:.2f}): "
f"AcoustID='{display_title}' by '{display_artist}', "
f"expected '{expected_track_name}' by '{expected_artist_name}'"
f"{reason}"
)
logger.info(f"AcoustID verification SKIPPED (high confidence) - {msg}")
return VerificationResult.SKIP, msg
# Low fingerprint score + no metadata match — file is likely wrong.
msg = (
f"Audio mismatch: file identified as '{display_title}' by '{display_artist}', "
f"expected '{expected_track_name}' by '{expected_artist_name}' "
f"(title={title_sim:.0%}, artist={artist_sim:.0%})"
)
logger.warning(f"AcoustID verification FAILED - {msg}")
return VerificationResult.FAIL, msg
_decision_map = {
_CoreDecision.PASS: VerificationResult.PASS,
_CoreDecision.SKIP: VerificationResult.SKIP,
_CoreDecision.FAIL: VerificationResult.FAIL,
}
result = _decision_map[outcome.decision]
if result == VerificationResult.PASS:
logger.info("AcoustID verification PASSED - %s", outcome.reason)
elif result == VerificationResult.FAIL:
logger.warning("AcoustID verification FAILED - %s", outcome.reason)
else:
logger.info("AcoustID verification SKIPPED - %s", outcome.reason)
return result, outcome.reason
except Exception as e:
# Any unexpected error -> SKIP (fail open)

View file

@ -21,6 +21,7 @@ from datetime import datetime
from difflib import SequenceMatcher
from typing import Any, Callable, Dict, List, Optional
from core.imports.folder_artist import resolve_folder_artist
from utils.logging_config import get_logger
logger = get_logger("auto_import")
@ -1576,31 +1577,18 @@ class AutoImportWorker:
album_name = identification.get('album_name', 'Unknown')
image_url = identification.get('image_url', '')
# Parent folder artist override: if the staging folder structure is
# Artist/Albums/AlbumName or Artist/AlbumName, use the parent folder
# as the artist name when the tag-extracted artist looks wrong.
# This handles mixtapes/compilations where embedded tags have DJ names.
# Parent folder artist override via import.folder_artist_override.
# Default on to preserve the legacy Artist/Album staging behavior.
# Users who stage mixed piles under one container folder can turn it off
# to keep the metadata-identified artist.
try:
staging_root = self._resolve_staging_path() or self.staging_path
rel_path = os.path.relpath(candidate.path, staging_root)
parts = [p for p in rel_path.replace('\\', '/').split('/') if p]
# parts[0] = artist folder, parts[1] = album or category subfolder, etc.
# Only attempt override if there's at least 2 levels (artist/album)
folder_artist = None
if len(parts) >= 2:
_category_names = {'albums', 'singles', 'eps', 'compilations', 'mixtapes',
'discography', 'music', 'downloads'}
if len(parts) >= 3 and parts[1].lower() in _category_names:
# Artist/Albums/AlbumFolder → parts[0] is artist
folder_artist = parts[0]
elif parts[0].lower() not in _category_names:
# Artist/AlbumFolder → parts[0] is artist
folder_artist = parts[0]
if folder_artist and folder_artist.lower() != artist_name.lower():
logger.info(f"[Auto-Import] Parent folder artist '{folder_artist}' differs from tag artist '{artist_name}' — using folder artist")
artist_name = folder_artist
if self._config_manager.get('import.folder_artist_override', True):
staging_root = self._resolve_staging_path() or self.staging_path
rel_path = os.path.relpath(candidate.path, staging_root)
folder_artist = resolve_folder_artist(rel_path, artist_name, enabled=True)
if folder_artist:
logger.info(f"[Auto-Import] Parent folder artist '{folder_artist}' differs from tag artist '{artist_name}' — using folder artist")
artist_name = folder_artist
except Exception as e:
logger.debug("folder artist override failed: %s", e)
release_date = identification.get('release_date', '') or album_data.get('release_date', '')

View file

@ -6,6 +6,7 @@ from typing import Dict, List, Optional, Any
from functools import wraps
from dataclasses import dataclass
from utils.logging_config import get_logger
from core.metadata.artist_album_cache import get_cached_artist_album_items, store_artist_album_items
from core.metadata.cache import get_metadata_cache
logger = get_logger("deezer_client")
@ -873,17 +874,41 @@ class DeezerClient:
Matches iTunesClient.get_artist_albums() interface.
Paginates through all results up to the requested limit."""
cache = get_metadata_cache()
cached_items = get_cached_artist_album_items(cache, 'deezer', artist_id, album_type=album_type, limit=limit)
if cached_items:
try:
requested_types = [t.strip() for t in album_type.split(',')]
cached_albums = []
for album_data in cached_items:
album = Album.from_deezer_album(album_data)
if album_type != 'album,single':
if album.album_type not in requested_types:
if not (album.album_type == 'ep' and 'single' in requested_types):
continue
cached_albums.append(album)
return cached_albums[:limit]
except Exception as e:
logger.debug("Deezer artist albums cache reuse failed: %s", e)
albums = []
all_raw = []
requested_types = [t.strip() for t in album_type.split(',')]
offset = 0
page_size = 100 # Deezer API max per request
complete = True # cleared if pagination breaks on a transient/malformed error
while offset < limit:
fetch_limit = min(page_size, limit - offset)
data = self._api_get(f'artist/{artist_id}/albums', {'limit': fetch_limit, 'index': offset})
if not data or 'data' not in data or len(data['data']) == 0:
if not data or 'data' not in data:
# Malformed/transient response mid-pagination — what we have is a
# PARTIAL discography. Don't cache it as the full list (mirrors the
# Spotify truncated-fetch guard). #853 follow-up.
complete = False
break
if len(data['data']) == 0:
break # No more albums — a clean end of pagination.
for album_data in data['data']:
all_raw.append(album_data)
@ -900,7 +925,6 @@ class DeezerClient:
break # Last page
offset += len(data['data'])
cache = get_metadata_cache()
# Deezer's /artist/{id}/albums endpoint doesn't include artist info on each album.
# Inject it so cached album entities have artist_name for discover page display.
artist_stub = None
@ -914,6 +938,11 @@ class DeezerClient:
entries.append((str(ad['id']), ad))
if entries:
cache.store_entities_bulk('deezer', 'album', entries, skip_if_exists=True)
# Only cache the artist→album-LIST when pagination finished cleanly; a
# partial list would otherwise serve an incomplete discography until TTL.
# (Individual album entities above are complete, so they cache regardless.)
if complete:
store_artist_album_items(cache, 'deezer', artist_id, all_raw, album_type=album_type, limit=limit)
logger.info(f"Retrieved {len(albums)} albums for artist {artist_id}")
return albums[:limit]

View file

@ -12,6 +12,7 @@ import re
import time
import threading
import requests
from core.metadata.artist_album_cache import get_cached_artist_album_payload, store_artist_album_items
from core.metadata.cache import get_metadata_cache
from typing import List, Dict, Any, Optional
from dataclasses import dataclass
@ -79,6 +80,51 @@ def _clean_discogs_artist_name(name: Optional[str]) -> str:
return _DISCOGS_DISAMBIG_RE.sub('', name).strip()
# --- Discogs album ID typing -------------------------------------------------
# Discogs has two album object types — masters (/masters/{id}) and releases
# (/releases/{id}) — whose numeric IDs share one space, so release N and master
# N are DIFFERENT albums. A bare numeric ID is therefore ambiguous. We tag the
# type into the ID string ('m12345' / 'r12345') at the point we parse it, so the
# correct endpoint can be chosen later without guessing. (Artist IDs are a single
# namespace and stay untagged.)
def _discogs_album_kind(data: Dict[str, Any]) -> str:
"""Classify a Discogs album payload as 'master' or 'release'.
Search results and artist-discography items carry an explicit ``type``;
full detail responses don't, but only master detail has ``main_release``."""
t = (data.get('type') or '').lower()
if t in ('master', 'release'):
return t
return 'master' if 'main_release' in data else 'release'
def _tag_discogs_album_id(raw_id: Any, kind: str) -> str:
"""``'12345'`` + ``'master'`` -> ``'m12345'``; empty input -> ``''``."""
s = str(raw_id or '').strip()
if not s:
return ''
return f"{'m' if kind == 'master' else 'r'}{s}"
def _discogs_album_endpoints(album_id: Any) -> List[str]:
"""Map a (possibly tagged) album ID to the API path(s) to try, in order.
``'m12345'`` -> ``['/masters/12345']``
``'r12345'`` -> ``['/releases/12345']``
``'12345'`` (legacy untagged) -> ``['/releases/12345', '/masters/12345']``
Legacy bare IDs are tried release-first because stored IDs originate
overwhelmingly from search / manual-match / collection sync (all releases);
this also self-heals pre-fix bad matches. Returns ``[]`` for unusable input."""
s = str(album_id or '').strip()
if len(s) > 1 and s[0] in ('m', 'r') and s[1:].isdigit():
return [f"/{'masters' if s[0] == 'm' else 'releases'}/{s[1:]}"]
if s.isdigit():
return [f'/releases/{s}', f'/masters/{s}']
return []
# --- Shared dataclasses (same shape as iTunes/Deezer/Spotify) ---
@dataclass
@ -304,7 +350,7 @@ class Album:
external_urls['discogs_api'] = release_data['resource_url']
return cls(
id=str(release_data.get('id', '')),
id=_tag_discogs_album_id(release_data.get('id', ''), _discogs_album_kind(release_data)),
name=title,
artists=artists,
release_date=release_date,
@ -643,10 +689,13 @@ class DiscogsClient:
if cached and cached.get('title'):
data = cached
else:
# Try as master first (artist discography returns master IDs)
data = self._api_get(f'/masters/{release_id}')
if not data or not data.get('title'):
data = self._api_get(f'/releases/{release_id}')
# Hit the endpoint that matches the ID's type (tag-driven, no guessing).
data = None
for path in _discogs_album_endpoints(release_id):
data = self._api_get(path)
if data and data.get('title'):
break
data = None
if not data:
return None
cache.store_entity('discogs', 'album', release_id, data)
@ -680,26 +729,45 @@ class DiscogsClient:
def get_artist_albums(self, artist_id: str, album_type: str = 'album,single', limit: int = 50) -> List[Album]:
"""Get releases by an artist. Prefers master releases, filters features."""
# First get the artist name for feature filtering. Strip Discogs
# disambiguation suffix so feature-vs-primary matching below
# compares against the canonical name, not "Beyoncé*".
artist_data = self._api_get(f'/artists/{artist_id}')
artist_name = _clean_discogs_artist_name(
artist_data.get('name', '') if artist_data else ''
).lower()
cache = get_metadata_cache()
cached_payload = get_cached_artist_album_payload(cache, 'discogs', artist_id, album_type=album_type, limit=limit)
releases = cached_payload.get('_releases') if cached_payload else None
artist_name = ''
if cached_payload:
artist_name = str(cached_payload.get('artist_name') or '').lower()
data = self._api_get(f'/artists/{artist_id}/releases', {
'sort': 'year', 'sort_order': 'desc', 'per_page': min(limit * 3, 200),
})
if not data or not data.get('releases'):
return []
if not isinstance(releases, list) or not releases:
# First get the artist name for feature filtering. Strip Discogs
# disambiguation suffix so feature-vs-primary matching below
# compares against the canonical name, not "Beyoncé*".
artist_data = self._api_get(f'/artists/{artist_id}')
artist_name = _clean_discogs_artist_name(
artist_data.get('name', '') if artist_data else ''
).lower()
data = self._api_get(f'/artists/{artist_id}/releases', {
'sort': 'year', 'sort_order': 'desc', 'per_page': min(limit * 3, 200),
})
if not data or not data.get('releases'):
return []
releases = data.get('releases') or []
store_artist_album_items(
cache,
'discogs',
artist_id,
releases,
album_type=album_type,
limit=limit,
items_field='_releases',
extra_fields={'artist_name': artist_name},
)
# Separate masters from individual releases — prefer masters (canonical versions)
masters = []
releases_no_master = []
master_titles = set()
for item in data['releases']:
for item in releases:
# Skip non-main roles
role = item.get('role', 'Main').lower()
if role not in ('main', ''):
@ -767,10 +835,13 @@ class DiscogsClient:
if cached:
return cached
# Try as master first (master IDs are used in artist discography)
data = self._api_get(f'/masters/{release_id}')
if not data or not data.get('tracklist'):
data = self._api_get(f'/releases/{release_id}')
# Hit the endpoint that matches the ID's type (tag-driven, no guessing).
data = None
for path in _discogs_album_endpoints(release_id):
data = self._api_get(path)
if data and data.get('tracklist'):
break
data = None
if not data or not data.get('tracklist'):
return None
@ -782,7 +853,7 @@ class DiscogsClient:
image_url = (primary or images[0]).get('uri')
album_info = {
'id': str(data.get('id', release_id)),
'id': str(release_id),
'name': data.get('title', ''),
'images': [{'url': image_url, 'height': 600, 'width': 600}] if image_url else [],
'release_date': str(data.get('year', '')) if data.get('year') else '',
@ -871,9 +942,13 @@ class DiscogsClient:
cached = cache.get_entity('discogs', 'album', str(release_id))
if cached and cached.get('title'):
return cached
data = self._api_get(f'/masters/{release_id}')
if not data or not data.get('title'):
data = self._api_get(f'/releases/{release_id}')
# Hit the endpoint that matches the ID's type (tag-driven, no guessing).
data = None
for path in _discogs_album_endpoints(release_id):
data = self._api_get(path)
if data and data.get('title'):
break
data = None
if data:
cache.store_entity('discogs', 'album', str(release_id), data)
return data

View file

@ -17,7 +17,7 @@ from typing import Optional, Dict, Any
from datetime import datetime, timedelta
from utils.logging_config import get_logger
from database.music_database import MusicDatabase
from core.discogs_client import DiscogsClient
from core.discogs_client import DiscogsClient, _discogs_album_kind, _tag_discogs_album_id
from core.worker_utils import accept_artist_match, interruptible_sleep, set_album_api_track_count
logger = get_logger("discogs_worker")
@ -436,7 +436,9 @@ class DiscogsWorker:
conn = self.db._get_connection()
cursor = conn.cursor()
discogs_id = str(data.get('id', ''))
# Tag the ID with its Discogs type so later re-fetches hit the right
# endpoint (master vs release share one numeric space).
discogs_id = _tag_discogs_album_id(data.get('id', ''), _discogs_album_kind(data))
genres = json.dumps(data.get('genres', []))
styles = json.dumps(data.get('styles', []))
labels = data.get('labels', [])

View file

@ -104,7 +104,13 @@ def cancel_sync(
"""
try:
if key not in states:
return {"error": not_found_message}, 404
# Idempotent: the live discovery state is gone (a restart wiped the
# in-memory state, or it was already cancelled). Cancelling a sync
# that isn't running is a no-op SUCCESS, not a 404 — otherwise a
# mirrored playlist (e.g. a ListenBrainz weekly) whose state vanished
# is permanently wedged with "playlist not found" and can never be
# re-synced or dismissed (#702).
return {"success": True, "message": f"No active {label} sync to cancel"}, 200
state = states[key]
state['last_accessed'] = time.time()

View file

@ -352,34 +352,60 @@ class TorrentDownloadPlugin(DownloadSourcePlugin):
self._finalize_download(download_id, last_save_path)
return
if status.state == 'error':
# Clean the dead torrent out of the client, or it's left orphaned
# (active in qbit, untracked here) and re-grabbed as a duplicate.
self._cleanup_torrent(torrent_hash, get_stall_action())
self._mark_error(download_id, status.error or "Torrent client reported error")
return
if stall.is_stalled(status.downloaded, status.state, time.monotonic()):
if stall.is_stalled(status.downloaded, status.state, time.monotonic(),
size=status.size):
self._handle_stalled(download_id, torrent_hash, get_stall_action())
return
time.sleep(_POLL_INTERVAL_SECONDS)
# Deadline reached. One last status check closes the race where the
# torrent completed during the final poll interval — finalize it instead
# of deleting a just-finished download's files. Otherwise clean it out of
# the client, or it sits orphaned in qbit (e.g. a metadata-stuck magnet
# that escaped the stall timer) and gets re-grabbed as a duplicate.
try:
final = run_async(adapter.get_status(torrent_hash))
except Exception:
final = None
if final is not None and final.state in _COMPLETE_STATES:
self._finalize_download(download_id, final.save_path or last_save_path)
return
self._cleanup_torrent(torrent_hash, get_stall_action())
self._mark_error(download_id, "Torrent download timed out")
def _cleanup_torrent(self, torrent_hash: str, action: str) -> None:
"""Remove (abandon) or pause a dead/stalled/timed-out torrent in the
client so it isn't left ORPHANED — active in qbit but no longer tracked
here, which makes SoulSync re-grab the same dead torrent as a duplicate
on the next attempt (noldevin). Best-effort: a client error is logged,
not raised, so the download still fails cleanly."""
adapter = get_active_torrent_adapter()
if adapter is None or not torrent_hash:
return
try:
if action == "pause":
run_async(adapter.pause(torrent_hash))
else:
# delete_files: a stalled/failed torrent's partial data is junk
# (often just a metadata stub) — don't leave it on disk.
run_async(adapter.remove(torrent_hash, delete_files=True))
except Exception as e:
logger.warning("Torrent cleanup (%s) on %s failed: %s",
action, torrent_hash[:8] if torrent_hash else "?", e)
def _handle_stalled(self, download_id: str, torrent_hash: str, action: str) -> None:
"""A torrent made no progress past the stall timeout. Abandon it
(remove from client + delete its partial data) or pause it for the
user, then fail the download so the worker frees up."""
adapter = get_active_torrent_adapter()
timeout_min = round(get_stall_timeout() / 60, 1)
if adapter is not None:
try:
if action == "pause":
run_async(adapter.pause(torrent_hash))
else:
# delete_files: a stalled torrent's partial data is junk
# (often just a metadata stub) — don't leave it on disk.
run_async(adapter.remove(torrent_hash, delete_files=True))
except Exception as e:
logger.warning("Stalled-torrent %s on %s failed: %s",
action, torrent_hash[:8] if torrent_hash else "?", e)
self._cleanup_torrent(torrent_hash, action)
verb = "paused" if action == "pause" else "removed"
self._mark_error(
download_id,

View file

@ -74,26 +74,50 @@ class StallTracker:
def __init__(self, timeout_seconds: float):
self.timeout = float(timeout_seconds or 0)
self._last_downloaded = -1 # -1 = first observation
self._had_metadata = None # None = first observation; else size>0?
self._progress_since = None # monotonic time of last forward movement
def is_stalled(self, downloaded: int, state: str, now: float) -> bool:
"""Record this poll's observation; return True iff the torrent has
gone ``timeout`` seconds with no byte progress while in a state
that's supposed to be downloading.
def is_stalled(self, downloaded: int, state: str, now: float,
size: int = None) -> bool:
"""Record this poll's observation; return True iff the torrent has gone
``timeout`` seconds with no real forward progress while in a working state.
``downloaded`` is cumulative bytes; ``state`` is the adapter-uniform
state; ``now`` is a monotonic timestamp (seconds)."""
``downloaded`` is cumulative payload bytes; ``state`` is the adapter-uniform
state; ``now`` is a monotonic timestamp; ``size`` is the torrent's total
size in bytes (0/None while still fetching metadata).
Metadata-phase fix (#852-adjacent torrent report): a magnet stuck
"downloading metadata" reports ``size==0`` and a ``downloaded`` byte
counter that still ticks up from DHT/peer-protocol overhead even though it
makes no actual progress. Treating those bumps as progress reset the stall
clock forever, so a dead magnet never timed out. Now the byte counter only
counts once metadata is in (``size>0``); during the metadata phase the only
thing that counts as progress is *obtaining* the metadata, so a torrent
that can't even do that within the timeout is correctly flagged stalled.
"""
if self.timeout <= 0:
return False
downloaded = int(downloaded or 0)
# size is None when the caller doesn't track it (assume metadata present —
# the old byte-progress behavior); an explicit size==0 is the metadata
# phase (metaDL), where the byte counter is unreliable noise.
has_metadata = size is None or int(size) > 0
# Forward progress (or first sighting) resets the stall clock.
if self._last_downloaded < 0 or downloaded > self._last_downloaded:
self._last_downloaded = downloaded
# Real forward progress: first sighting, metadata just arrived, or (only
# once we have metadata) more payload bytes. Byte bumps during the
# metadata phase are protocol noise and do NOT count.
progressed = (
self._had_metadata is None # first poll
or (has_metadata and not self._had_metadata) # got metadata
or (has_metadata and downloaded > self._last_downloaded) # more payload
)
self._had_metadata = has_metadata
self._last_downloaded = downloaded
if progressed:
self._progress_since = now
return False
self._last_downloaded = downloaded
# Not in a working state → not a stall (seeding/paused/completed).
if state not in STALLABLE_STATES:

View file

@ -250,6 +250,11 @@ def requeue_quarantined_task_for_retry(task_id, batch_id, trigger):
task.pop('quarantine_entry_id', None)
task['status'] = 'searching'
task['status_change_time'] = time.time()
# Surface the retry progress to the UI ("attempt 2/5" next to the
# status while the task goes around again). Cleared implicitly on
# completion (UI only renders it for active/queued states).
task['retry_info'] = attempt_desc
task['retry_trigger'] = trigger
logger.info(
f"[Retry:{trigger}] Re-queuing task {task_id} for next-best candidate "

View file

@ -349,6 +349,12 @@ def build_batch_status_data(batch_id: str, batch: dict, live_transfers_lookup: d
'error_message': task.get('error_message'), # Surface failure reasons to UI
'quarantine_entry_id': task.get('quarantine_entry_id'),
'has_candidates': bool(task.get('cached_candidates')), # Whether search found results (for clickable review)
# 'verified' / 'unverified' / 'force_imported' — set by the
# import pipeline once post-processing finishes.
'verification_status': task.get('verification_status'),
# "2/5" while the quarantine-retry engine walks candidates.
'retry_info': task.get('retry_info'),
'retry_trigger': task.get('retry_trigger'),
}
_ti = task.get('track_info') if isinstance(task.get('track_info'), dict) else {}
task_filename = task.get('filename') or _ti.get('filename')
@ -705,6 +711,7 @@ def _build_history_download_item(entry: dict) -> dict:
'priority': _STATUS_PRIORITY['completed'],
'quality': entry.get('quality') or '',
'file_path': entry.get('file_path') or '',
'verification_status': entry.get('verification_status'),
'is_persistent_history': True,
}
@ -788,6 +795,9 @@ def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict:
'status': status,
'progress': progress,
'error': task.get('error_message'),
'verification_status': task.get('verification_status'),
'retry_info': task.get('retry_info'),
'retry_trigger': task.get('retry_trigger'),
'batch_id': batch_id,
'batch_name': batch.get('playlist_name') or batch.get('album_name') or '',
'batch_source': batch.get('source_page') or batch.get('initiated_from') or '',

View file

@ -0,0 +1,52 @@
"""Opt-in "parent folder artist" resolution for imports.
Historically the auto-import worker derived the artist from the top Staging
folder whenever the path had >=2 levels and that folder wasn't a category word
(albums/singles/eps/...). It did so *unconditionally*, overriding even a
confidently metadata-identified artist which mass-mislabelled files when a
user staged everything under one container folder (see the "soulsync" incident).
This module isolates that decision as a pure function so it can be:
- gated behind an opt-in setting (``import.folder_artist_override``,
default on for legacy compatibility), and
- unit-tested without standing up the whole import worker.
"""
import os
# Top-level folder names that denote a *category*, not an artist.
DEFAULT_CATEGORY_NAMES = frozenset({
'albums', 'singles', 'eps', 'compilations', 'mixtapes',
'discography', 'music', 'downloads',
})
def resolve_folder_artist(rel_path, identified_artist, enabled,
category_names=DEFAULT_CATEGORY_NAMES):
"""Return the folder-derived artist to use, or ``None`` to keep the
already-identified artist.
When ``enabled`` is False this always returns ``None`` the import keeps
whatever artist the metadata match produced. Only when explicitly enabled
does it fall back to the staging folder name, and even then never when the
folder already equals the identified artist.
``rel_path`` is the candidate's path relative to the staging root.
"""
if not enabled:
return None
parts = [p for p in rel_path.replace('\\', '/').split('/') if p]
folder_artist = None
if len(parts) >= 2:
if len(parts) >= 3 and parts[1].lower() in category_names:
# Artist/Albums/AlbumFolder -> parts[0] is the artist
folder_artist = parts[0]
elif parts[0].lower() not in category_names:
# Artist/AlbumFolder -> parts[0] is the artist
folder_artist = parts[0]
if folder_artist and folder_artist.lower() != (identified_artist or '').lower():
return folder_artist
return None

View file

@ -182,6 +182,26 @@ def build_import_pipeline_runtime(
)
def _persist_verification_status(context, final_path):
"""Compute + persist the verification status (verified / unverified /
force_imported) for a finished import: embedded tag on the file, plus
context['_verification_status'] for the history row and the Downloads UI.
MUST be called on EVERY success exit of post-processing (main, playlist
folder mode, simple download) a missed exit means no badge and no tag.
Never raises."""
try:
from core.matching.verification_status import status_for_import
from core.tag_writer import write_verification_status
status = status_for_import(context)
if status:
context['_verification_status'] = status
if final_path:
write_verification_status(str(final_path), status)
except Exception as _vs_err:
logger.debug(f"verification-status persist skipped: {_vs_err}")
def post_process_matched_download(context_key, context, file_path, runtime, metadata_runtime=None):
on_download_completed = getattr(runtime, "on_download_completed", None)
automation_engine = getattr(runtime, "automation_engine", None)
@ -450,6 +470,7 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
logger.info(f"Simple download post-processing complete: {activity_target}")
context['_simple_download_completed'] = True
context['_final_path'] = str(destination)
_persist_verification_status(context, destination)
emit_track_downloaded(context, automation_engine)
record_library_history_download(context)
record_download_provenance(context)
@ -627,6 +648,8 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
final_path = downsampled_path
context['_final_processed_path'] = final_path
_persist_verification_status(context, final_path)
blasphemy_path = create_lossy_copy(final_path)
if blasphemy_path:
context['_final_processed_path'] = blasphemy_path
@ -944,6 +967,8 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
final_path = downsampled_path
context['_final_processed_path'] = final_path
_persist_verification_status(context, final_path)
blasphemy_path = create_lossy_copy(final_path)
if blasphemy_path:
context['_final_processed_path'] = blasphemy_path
@ -1219,6 +1244,8 @@ def post_process_matched_download_with_verification(context_key, context, file_p
with tasks_lock:
if task_id in download_tasks:
_mark_task_completed(task_id, context.get('track_info'))
if context.get('_verification_status'):
download_tasks[task_id]['verification_status'] = context['_verification_status']
with matched_context_lock:
if context_key in matched_downloads_context:
del matched_downloads_context[context_key]
@ -1231,6 +1258,8 @@ def post_process_matched_download_with_verification(context_key, context, file_p
if task_id in download_tasks:
_mark_task_completed(task_id, context.get('track_info'))
download_tasks[task_id]['metadata_enhanced'] = True
if context.get('_verification_status'):
download_tasks[task_id]['verification_status'] = context['_verification_status']
redownload_ctx = download_tasks[task_id].get('_redownload_context')
with matched_context_lock:

View file

@ -219,6 +219,7 @@ def list_quarantine_entries(quarantine_dir: str) -> List[Dict[str, Any]]:
"trigger": sidecar.get("trigger", "unknown"),
"source_username": source_username,
"source_filename": source_filename,
"thumb_url": _extract_context_thumb(ctx),
}
)
@ -226,6 +227,51 @@ def list_quarantine_entries(quarantine_dir: str) -> List[Dict[str, Any]]:
return entries
def get_quarantine_entry_context(quarantine_dir: str, entry_id: str) -> Dict[str, Any]:
"""The sidecar's embedded pipeline ``context`` dict for one entry.
Returns {} for thin/legacy sidecars, missing entries or read errors."""
_, sidecar_path = _resolve_entry_paths(quarantine_dir, entry_id)
if not sidecar_path or not os.path.isfile(sidecar_path):
return {}
try:
with open(sidecar_path, encoding="utf-8") as f:
loaded = json.load(f)
ctx = loaded.get("context") if isinstance(loaded, dict) else None
return ctx if isinstance(ctx, dict) else {}
except Exception as exc:
logger.debug("quarantine context read failed for %s: %s", entry_id, exc)
return {}
def _extract_context_thumb(ctx: Dict[str, Any]) -> str:
"""Album-art URL from a sidecar's pipeline context — same lookup chain the
library-history recorder uses (album/spotify_album image, then album_info,
then the track_info's embedded album images). Empty string when absent."""
def _first_image(album: Any) -> str:
if not isinstance(album, dict):
return ""
url = album.get("image_url") or ""
if url:
return url
images = album.get("images") or []
if images and isinstance(images[0], dict):
return images[0].get("url", "") or ""
return ""
thumb = _first_image(ctx.get("album")) or _first_image(ctx.get("spotify_album"))
if not thumb:
album_info = ctx.get("album_info")
if isinstance(album_info, dict):
thumb = album_info.get("album_image_url", "") or ""
if not thumb:
ti = ctx.get("track_info")
if isinstance(ti, dict):
thumb = _first_image(ti.get("album"))
if not thumb:
thumb = ti.get("image_url", "") or ""
return thumb
def _resolve_entry_paths(quarantine_dir: str, entry_id: str) -> Tuple[Optional[str], Optional[str]]:
"""Locate the `.quarantined` file + JSON sidecar for an entry id.

View file

@ -268,6 +268,7 @@ def record_library_history_download(context: Dict[str, Any]) -> None:
source_artist=source_artist,
origin=origin,
origin_context=origin_context,
verification_status=context.get("_verification_status"),
)
except Exception as e:
logger.debug("library history record failed: %s", e)

View file

@ -5,6 +5,7 @@ import threading
from functools import wraps
from dataclasses import dataclass
from utils.logging_config import get_logger
from core.metadata.artist_album_cache import get_cached_artist_album_items, store_artist_album_items
from core.metadata.cache import get_metadata_cache
logger = get_logger("itunes_client")
@ -1036,7 +1037,15 @@ class iTunesClient:
"""
import re
results = self._lookup(id=artist_id, entity='album', limit=min(limit, 200))
cache = get_metadata_cache()
cache_limit = min(limit, 200)
cached_items = get_cached_artist_album_items(cache, 'itunes', artist_id, album_type=album_type, limit=cache_limit)
cache_hit = False
if cached_items:
results = cached_items
cache_hit = True
else:
results = self._lookup(id=artist_id, entity='album', limit=cache_limit)
seen_albums = {} # Track albums by normalized name, prefer explicit versions
def normalize_album_name(name: str) -> str:
@ -1098,7 +1107,7 @@ class iTunesClient:
# If this is an explicit album, validate it has tracks before keeping it
# (Some iTunes explicit albums are broken and return 0 tracks)
if is_explicit:
if is_explicit and not cache_hit:
try:
test_tracks = self._lookup(id=album.id, entity='song')
track_count = len([t for t in test_tracks if t.get('wrapperType') == 'track'])
@ -1124,8 +1133,9 @@ class iTunesClient:
if album_data.get('wrapperType') == 'collection' and album_data.get('collectionId'):
album_entries.append((str(album_data['collectionId']), album_data))
if album_entries:
cache = get_metadata_cache()
cache.store_entities_bulk('itunes', 'album', album_entries, skip_if_exists=True)
if not cache_hit:
store_artist_album_items(cache, 'itunes', artist_id, results, album_type=album_type, limit=cache_limit)
logger.info(f"Retrieved {len(albums)} unique albums for artist {artist_id} (filtered from {len(results)} results)")
return albums[:limit]

View file

@ -0,0 +1,267 @@
"""Shared audio-verification decision core (pure; no file/DB I/O).
Single source of truth for normalization + the PASS/SKIP/FAIL decision used by
BOTH import-time verification (``core/acoustid_verification.py``) and the library
scan (``core/repair_jobs/acoustid_scanner.py``). Historically each path had its
own ``_normalize`` and decision branches that drifted apart and produced
inconsistent results (a correct cross-script anime-OST track passed at import but
was false-flagged by the scan). Centralising the decision here means the
thresholds, normalization, alias-aware comparison, cross-script handling, version
gate and duration guard are defined exactly once.
"""
import re
from dataclasses import dataclass
from difflib import SequenceMatcher
from enum import Enum
from typing import Any, List, Optional
from utils.logging_config import get_logger
logger = get_logger("audio_verification")
# Thresholds — the single definition both paths share.
MIN_ACOUSTID_SCORE = 0.80 # Minimum fingerprint score to trust a match.
TITLE_MATCH_THRESHOLD = 0.70 # Title similarity to consider a match.
ARTIST_MATCH_THRESHOLD = 0.60 # Artist similarity to consider a match.
CLEAR_MISMATCH_THRESHOLD = 0.30 # Below this artist sim = clear wrong song.
class Decision(Enum):
PASS = "pass"
SKIP = "skip"
FAIL = "fail"
@dataclass
class Outcome:
decision: Decision
title_sim: float = 0.0
artist_sim: float = 0.0
matched_title: str = ""
matched_artist: str = ""
reason: str = ""
def normalize(text: str) -> str:
"""Normalize a title/artist for comparison.
lowercase; strip ``()`` / ``[]`` / ``<>`` annotations (version tags,
performer credits like ``<Vocal: MIKA KOBAYASHI>``); strip trailing
version / featuring tags; KEEP CJK characters (``\\w`` is unicode-aware) so
Japanese/Chinese/Korean titles produce a comparable form instead of an empty
string; collapse whitespace.
"""
if not text:
return ""
s = text.lower().strip()
# Annotations that are metadata, not core identity.
s = re.sub(r'\s*\([^)]*\)', '', s)
s = re.sub(r'\s*\[[^\]]*\]', '', s)
s = re.sub(r'\s*<[^>]*>', '', s)
# Trailing featuring / version tags.
s = re.sub(r'\s+(?:feat\.?|ft\.?|featuring)\s+.*$', '', s, flags=re.IGNORECASE)
s = re.sub(
r'\s*-\s*(?:vocal|instrumental|acoustic|live|remix|cover|clean|explicit|'
r'radio\s*edit|original\s*mix|extended\s*mix|club\s*mix)\s*$',
'', s, flags=re.IGNORECASE,
)
s = re.sub(r'\s*-\s*from\s+.+$', '', s, flags=re.IGNORECASE)
# Path/separator punctuation -> space so a title keeps matching a source
# filename that substituted '_' for an illegal '/' or ':' (#851): the on-disk
# "You See Big Girl _ T_T" must normalize the same as "You See Big Girl / T:T".
# Done before the strip below so they become word boundaries, not joins.
s = re.sub(r'[\\/:_]+', ' ', s)
# Drop remaining punctuation but keep word chars (incl. CJK) + spaces.
s = re.sub(r'[^\w\s]', '', s)
s = re.sub(r'\s+', ' ', s).strip()
return s
def similarity(a: str, b: str) -> float:
"""Similarity (0.01.0) between two strings after normalization."""
na, nb = normalize(a), normalize(b)
if not na or not nb:
return 0.0
if na == nb:
return 1.0
return SequenceMatcher(None, na, nb).ratio()
_match_engine = None
def _detect_title_version(title: str) -> str:
"""Version label ('original'/'instrumental'/'live'/'remix'/...) for a title."""
global _match_engine
if not title:
return 'original'
if _match_engine is None:
from core.matching_engine import MusicMatchingEngine
_match_engine = MusicMatchingEngine()
version_type, _ = _match_engine.detect_version_type(title)
return version_type
def _alias_aware_artist_sim(expected_artist: str, actual_artist: str,
aliases: Optional[Any] = None) -> float:
"""Best artist similarity across (expected, *aliases) vs actual.
Bridges cross-script artist comparisons (kanjiromaji etc) when MusicBrainz
aliases are available. ``aliases`` is an iterable of alias strings, or a
callable resolving them lazily (only invoked when direct similarity falls
below threshold keeps the happy path lookup-free).
"""
from core.matching.artist_aliases import artist_names_match
direct = similarity(expected_artist, actual_artist)
if aliases is None:
return direct
if direct >= ARTIST_MATCH_THRESHOLD:
return direct
resolved = aliases() if callable(aliases) else aliases
if not resolved:
return direct
_matched, score = artist_names_match(
expected_artist, actual_artist, aliases=resolved,
threshold=ARTIST_MATCH_THRESHOLD, similarity=similarity,
)
# Diagnostic: an alias rescued a comparison direct similarity would have
# failed. INFO since it's a user-visible decision (PASS instead of FAIL).
if score >= ARTIST_MATCH_THRESHOLD and direct < ARTIST_MATCH_THRESHOLD:
from core.matching.artist_aliases import best_alias_match
winner, _ = best_alias_match(
expected_artist, actual_artist, resolved, similarity=similarity,
)
logger.info(
"Artist alias rescued comparison: expected=%r vs actual=%r "
"(direct sim=%.2f, alias %r → score=%.2f)",
expected_artist, actual_artist, direct, winner, score,
)
return score
def _find_best_title_artist_match(recordings, expected_title, expected_artist,
aliases=None):
"""Return (best_recording, title_sim, artist_sim) — title weighted higher."""
best_rec = None
best_title_sim = 0.0
best_artist_sim = 0.0
best_combined = 0.0
for rec in recordings:
title = rec.get('title') or ''
artist = rec.get('artist') or ''
title_sim = similarity(expected_title, title)
artist_sim = _alias_aware_artist_sim(expected_artist, artist, aliases)
combined = (title_sim * 0.6) + (artist_sim * 0.4)
if combined > best_combined:
best_combined = combined
best_rec = rec
best_title_sim = title_sim
best_artist_sim = artist_sim
return best_rec, best_title_sim, best_artist_sim
def evaluate(expected_title: str, expected_artist: str,
recordings: List[dict], *, fingerprint_score: float,
aliases_provider: Optional[Any] = None) -> Outcome:
"""Decide PASS / SKIP / FAIL for a fingerprinted file against expected
title/artist. Pure: no I/O. Shared by import verification and library scan.
``aliases_provider``: iterable or callable of expected-artist aliases
(kanji/cyrillic/etc) used to bridge cross-script comparisons.
Note: fingerprint-collision duration checks are the caller's responsibility
(the library scan pre-checks the top recording's length before calling this)
so the decision here stays purely about title/artist/version identity.
"""
from core.matching.script_compat import is_cross_script_mismatch
from core.matching.version_mismatch import is_acceptable_version_mismatch
# No expected artist on record (legacy/compilation rows): compare on title
# only — the old scanner treated this as artist-match=1.0 and a missing DB
# value is no evidence the file is wrong.
no_expected_artist = not normalize(expected_artist or '')
best_rec, title_sim, artist_sim = _find_best_title_artist_match(
recordings, expected_title, expected_artist, aliases_provider,
)
if no_expected_artist:
artist_sim = 1.0
if not best_rec:
return Outcome(Decision.SKIP, reason="No recordings with title/artist info")
matched_title = best_rec.get('title', '?') or '?'
matched_artist = best_rec.get('artist', '?') or '?'
def out(dec, reason):
return Outcome(dec, title_sim, artist_sim, matched_title, matched_artist, reason)
# Version gate: original vs instrumental/live/remix is a real difference.
expected_version = _detect_title_version(expected_title)
matched_version = _detect_title_version(matched_title)
if expected_version != matched_version:
if not is_acceptable_version_mismatch(
expected_version, matched_version,
fingerprint_score=fingerprint_score,
title_similarity=title_sim, artist_similarity=artist_sim,
):
return out(Decision.FAIL,
f"Version mismatch: expected ({expected_version}) "
f"but file is ({matched_version})")
# Clean match.
if title_sim >= TITLE_MATCH_THRESHOLD and artist_sim >= ARTIST_MATCH_THRESHOLD:
return out(Decision.PASS, "Audio verified")
# Title matches, artist doesn't — cover/collab vs genuinely wrong.
if title_sim >= TITLE_MATCH_THRESHOLD and artist_sim < ARTIST_MATCH_THRESHOLD:
for rec in recordings:
if _alias_aware_artist_sim(
expected_artist, rec.get('artist', ''), aliases_provider,
) >= ARTIST_MATCH_THRESHOLD:
return out(Decision.PASS, "Expected artist found in AcoustID results")
if artist_sim < CLEAR_MISMATCH_THRESHOLD:
return out(Decision.FAIL,
f"Audio mismatch: '{matched_title}' by '{matched_artist}' "
f"— expected artist not found")
return out(Decision.SKIP, "Title matches but artist ambiguous (cover/collab?)")
# Title doesn't match — scan all recordings for a version-matched hit.
def _title_sim(a, b):
return similarity(a, b)
def _artist_sim(ea, aa):
return _alias_aware_artist_sim(ea, aa, aliases_provider)
candidate = None
for rec in recordings:
if _detect_title_version(rec.get('title') or '') != expected_version:
continue
if (similarity(expected_title, rec.get('title') or '') >= TITLE_MATCH_THRESHOLD
and _alias_aware_artist_sim(
expected_artist, rec.get('artist', ''), aliases_provider,
) >= ARTIST_MATCH_THRESHOLD):
candidate = rec
break
if candidate is not None:
return out(Decision.PASS, "Scan match found in AcoustID results")
# High-confidence / cross-script skips (don't quarantine a correct file).
has_non_ascii = (any(ord(c) > 127 for c in (expected_title or ''))
or any(ord(c) > 127 for c in matched_title))
language_script_skip = (fingerprint_score >= 0.95 and has_non_ascii
and artist_sim >= ARTIST_MATCH_THRESHOLD)
high_confidence_strong_match_skip = (fingerprint_score >= 0.95
and title_sim >= 0.80
and artist_sim >= ARTIST_MATCH_THRESHOLD)
cross_script_artist_skip = (fingerprint_score >= MIN_ACOUSTID_SCORE
and artist_sim >= ARTIST_MATCH_THRESHOLD
and is_cross_script_mismatch(expected_artist, matched_artist))
if (language_script_skip or high_confidence_strong_match_skip
or cross_script_artist_skip):
return out(Decision.SKIP, "Likely same song in different language/script")
return out(Decision.FAIL,
f"Audio mismatch: file identified as '{matched_title}' by "
f"'{matched_artist}', expected '{expected_title}' by '{expected_artist}'")

View file

@ -0,0 +1,65 @@
"""Resolve a ``library_history`` row to a playable on-disk file.
Lifted out of ``web_server`` so the fallback chain and its collision-safety
is an importable, unit-tested seam. This matters because a *destructive* delete
(``/api/verification/<id>/delete`` ``os.remove``) trusts the path this returns:
if the tracks-table fallback guessed the wrong same-title file, delete would
remove the wrong track. The rules below are exactly that guard, and the tests
lock them.
Side effects are injected so the decision logic is pure:
- ``exists(path) -> bool`` (os.path.exists)
- ``resolve_library_path(raw) -> str | None`` (transfer/download/library prefix swap)
- ``lookup_titled_paths(title) -> list[str]`` (tracks.file_path WHERE LOWER(title)=LOWER(?))
"""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional
def resolve_history_audio_path(
row: Dict[str, Any],
*,
exists: Callable[[str], bool],
resolve_library_path: Callable[[str], Optional[str]],
lookup_titled_paths: Callable[[str], List[str]],
) -> Optional[str]:
"""Return the on-disk path for a history row, or None. Fallback chain:
1. the recorded path as-is,
2. the prefix-swap resolver (Dockerhost / transferlibrary),
3. the tracks-table mirror by title (knows the CURRENT path after a rename),
resolved the same way but only when it can be picked UNAMBIGUOUSLY.
"""
raw_path = (row.get("file_path") or "").strip()
if raw_path and exists(raw_path):
return raw_path
resolved = resolve_library_path(raw_path) if raw_path else None
if resolved and exists(resolved):
return resolved
title = (row.get("title") or "").strip()
if not title:
return None
artist = (row.get("artist_name") or "").strip()
candidates = [p for p in (lookup_titled_paths(title) or []) if p]
# Same-title collisions across artists exist, and delete() trusts this path,
# so be strict: when the row names an artist, only accept candidates whose
# path mentions it; with no artist, only an unambiguous single candidate.
artist_l = artist.lower()
if artist_l:
candidates = [p for p in candidates if artist_l in p.lower()]
elif len(candidates) != 1:
return None
for cand in candidates:
cand_resolved = resolve_library_path(cand)
if cand_resolved and exists(cand_resolved):
return cand_resolved
return None
__all__ = ["resolve_history_audio_path"]

View file

@ -0,0 +1,58 @@
"""Verification-status vocabulary for imported tracks.
Three states, persisted in the DB (``tracks.verification_status``) AND as an
embedded file tag (``SOULSYNC_VERIFICATION``) so the information survives DB
resets and travels with the file:
- ``verified`` clean AcoustID PASS at import time.
- ``unverified`` AcoustID SKIP (cross-script / ambiguous / no match in
the AcoustID DB). Imported, but not hard-confirmed.
- ``force_imported`` accepted via the version-mismatch fallback after the
retry budget was exhausted (user opted in via
``post_processing.accept_version_mismatch_fallback``).
A later library scan will still re-check these but
reports them as informational, clearly marked.
Quarantined files are never imported, so they carry no status.
"""
VERIFIED = 'verified'
UNVERIFIED = 'unverified'
FORCE_IMPORTED = 'force_imported'
# Set by the user via the review queue ("yes, this file IS the right track").
# Outranks machine states: the scanner skips these entirely.
HUMAN_VERIFIED = 'human_verified'
ALL_STATUSES = (VERIFIED, UNVERIFIED, FORCE_IMPORTED, HUMAN_VERIFIED)
# The file tag name (Vorbis comment key / ID3 TXXX desc / MP4 freeform).
TAG_NAME = 'SOULSYNC_VERIFICATION'
def status_from_acoustid_result(result_value):
"""Map an AcoustID verification result string ('pass'/'skip'/...) to a
status. 'disabled'/'error'/unknown return None no claim either way."""
if result_value == 'pass':
return VERIFIED
if result_value == 'skip':
return UNVERIFIED
return None
def status_for_import(context: dict):
"""Status for a just-imported file from its pipeline context.
Priority order:
1. A quarantine entry the user explicitly approved ("yes, import this
exact file") is a human decision — ``human_verified``, outranking
whatever the machine said about the candidate earlier.
2. The version-mismatch fallback flag: a force-accepted file is
``force_imported`` regardless of what the (earlier, failed)
verification said.
3. The AcoustID result of this pipeline run.
"""
if context.get('_approved_quarantine_trigger'):
return HUMAN_VERIFIED
if context.get('_version_mismatch_fallback'):
return FORCE_IMPORTED
return status_from_acoustid_result(context.get('_acoustid_result'))

View file

@ -0,0 +1,90 @@
"""Shared artist album-list cache helpers for metadata clients."""
from __future__ import annotations
from typing import Any, Optional
def make_artist_album_cache_key(
artist_id: str,
album_type: str = 'album,single',
limit: int = 200,
*,
include_limit: bool = True,
) -> str:
"""Return the metadata-cache key for an artist album-list query."""
safe_album_type = str(album_type or 'album,single').replace(',', '_')
base_key = f"{artist_id}_albums_{safe_album_type}"
return f"{base_key}_{limit}" if include_limit else base_key
def get_cached_artist_album_items(
cache: Any,
source: str,
artist_id: str,
*,
album_type: str = 'album,single',
limit: int = 200,
include_limit: bool = True,
items_field: str = '_albums',
) -> Optional[list[dict[str, Any]]]:
"""Return cached raw artist album-list items, or None on miss/invalid shape."""
cached = cache.get_entity(
source,
'artist',
make_artist_album_cache_key(artist_id, album_type, limit, include_limit=include_limit),
)
if not isinstance(cached, dict):
return None
items = cached.get(items_field)
return items if isinstance(items, list) and items else None
def get_cached_artist_album_payload(
cache: Any,
source: str,
artist_id: str,
*,
album_type: str = 'album,single',
limit: int = 200,
include_limit: bool = True,
) -> Optional[dict[str, Any]]:
"""Return the raw cached artist album-list payload, or None on miss."""
cached = cache.get_entity(
source,
'artist',
make_artist_album_cache_key(artist_id, album_type, limit, include_limit=include_limit),
)
return cached if isinstance(cached, dict) else None
def store_artist_album_items(
cache: Any,
source: str,
artist_id: str,
items: list[dict[str, Any]],
*,
album_type: str = 'album,single',
limit: int = 200,
include_limit: bool = True,
items_field: str = '_albums',
extra_fields: Optional[dict[str, Any]] = None,
) -> None:
"""Store raw artist album-list items in the metadata cache."""
if not items:
return
payload: dict[str, Any] = {
'name': f'albums_{artist_id}',
items_field: items,
}
if extra_fields:
payload.update(extra_fields)
cache.store_entity(
source,
'artist',
make_artist_album_cache_key(artist_id, album_type, limit, include_limit=include_limit),
payload,
)

View file

@ -15,6 +15,9 @@ from typing import Optional
from core.repair_jobs import register_job
from core.repair_jobs.base import JobContext, JobResult, RepairJob
from utils.logging_config import get_logger
from core.matching.audio_verification import evaluate, Decision
from core.matching.acoustid_candidates import duration_mismatches_strongly
from core.acoustid_verification import _resolve_expected_artist_aliases
logger = get_logger("repair_job.acoustid")
@ -220,116 +223,111 @@ class AcoustIDScannerJob(RepairJob):
or expected['artist']
)
# Normalize and compare
norm_expected_title = _normalize(expected['title'])
norm_aid_title = _normalize(aid_title)
norm_expected_artist = _normalize(expected_artist)
norm_aid_artist = _normalize(aid_artist)
title_sim = SequenceMatcher(None, norm_expected_title, norm_aid_title).ratio()
# Issue (Foxxify Discord report): AcoustID returns the FULL artist
# credit (e.g. `Okayracer, aldrch & poptropicaslutz!`) while the
# library DB carries only the primary artist (`Okayracer`). Raw
# similarity scores ~43% — well below threshold — so multi-artist
# tracks get flagged as Wrong Song even though the primary IS in
# the credit. Route through the shared `artist_names_match` helper
# which splits the credit on common separators (comma, ampersand,
# feat./ft./with/vs., etc.) and checks each token. Primary-in-
# credit cases now resolve at 100% match instead of 43%.
#
# Pass RAW artist strings (not pre-normalised) so the splitter
# can recognise the separators. The helper applies its own
# case + whitespace normalisation internally per token.
if norm_expected_artist:
from core.matching.artist_aliases import artist_names_match
_, artist_sim = artist_names_match(
expected_artist,
aid_artist,
threshold=artist_threshold,
)
else:
artist_sim = 1.0
if title_sim >= title_threshold and artist_sim >= artist_threshold:
return
# Issue #587 (Foxxify) — top recording's metadata mismatched, but
# AcoustID often returns multiple recordings per fingerprint
# (sample collisions, multi-MB-record cases). Check ALL of them
# before flagging — if any candidate's metadata matches expected
# title + artist, the file IS the right song and AcoustID's top
# match was just a wrong-credited recording.
from core.matching.acoustid_candidates import (
duration_mismatches_strongly,
find_matching_recording,
)
from core.matching.artist_aliases import artist_names_match
def _scanner_title_sim(a, b):
return SequenceMatcher(None, _normalize(a), _normalize(b)).ratio()
def _scanner_artist_sim(expected_a, actual_a):
_, score = artist_names_match(expected_a, actual_a, threshold=artist_threshold)
return score
candidate_match, _, _ = find_matching_recording(
fp_result.get('recordings') or [],
expected['title'],
expected_artist,
title_threshold=title_threshold,
artist_threshold=artist_threshold,
similarity=_scanner_title_sim,
artist_similarity=_scanner_artist_sim,
)
if candidate_match is not None:
# A lower-ranked candidate matched — file IS the right song.
# No finding.
# Verification status from the embedded SOULSYNC_VERIFICATION tag.
# Only a human decision short-circuits the scan: the user explicitly
# confirmed the file via the review queue. Everything else (verified /
# unverified / force_imported / untagged) is re-checked; force_imported
# mismatches are reported as informational below since a mismatch
# there is EXPECTED (the user accepted the best candidate).
file_verif_status = None
try:
from core.tag_writer import read_file_tags as _rft
file_verif_status = (_rft(fpath) or {}).get('verification_status')
except Exception: # noqa: S110 — verification tag is optional context; None is fine
pass
if file_verif_status == 'human_verified':
# The user explicitly confirmed this file via the review queue —
# never second-guess a human decision.
if context.report_progress:
context.report_progress(
log_line=(
f'Resolved (lower-ranked candidate match): {fname}'
f'expected "{expected["title"]}" matched candidate '
f'"{candidate_match.get("title")}" by '
f'"{candidate_match.get("artist")}"'
),
log_line=f'Skipped (human-verified): {fname}', log_type='skip')
return
# Fingerprint-collision guard: when the TOP recording's length is wildly
# different from the file, the fingerprint hit is a hash collision (the
# 17-min mashup → 5-min track case), not a real match — skip BEFORE any
# title/artist/version analysis so it can't surface as a false finding.
try:
file_duration_s = (expected.get('duration_ms') or 0) / 1000.0
except Exception:
file_duration_s = 0.0
cand_duration_s = best_recording.get('duration') or best_recording.get('length')
if file_duration_s and duration_mismatches_strongly(file_duration_s, cand_duration_s):
if context.report_progress:
context.report_progress(
log_line=(f'Skipped (duration mismatch suggests fingerprint '
f'collision): {fname}'),
log_type='skip')
return
# Decision via the shared verification core — identical logic to import-
# time verification (alias-aware artist match + cross-script SKIP), so the
# scan no longer false-flags correct cross-script tracks. Only a FAIL
# produces a "Wrong download" finding.
_alias_cache = {}
def _aliases():
if 'v' not in _alias_cache:
try:
_alias_cache['v'] = _resolve_expected_artist_aliases(expected_artist)
except Exception:
_alias_cache['v'] = []
return _alias_cache['v']
outcome = evaluate(
expected['title'], expected_artist, fp_result['recordings'],
fingerprint_score=best_score,
aliases_provider=_aliases,
)
# Persist the scan outcome so it feeds the same review pipeline as
# import-time verification: PASS backfills 'verified' on untagged or
# previously-unverified files; SKIP (ambiguous / cross-script / no
# hard confirmation) marks untagged files 'unverified' so they surface
# in the Downloads-page review queue. force_imported is never blessed
# here (normalize() strips version words, so an instrumental can PASS
# the title check) and 'verified' is never downgraded by a SKIP (the
# import-time check ran with richer candidate metadata). FAIL keeps
# the finding flow below.
new_status = file_verif_status
if outcome.decision == Decision.PASS and file_verif_status in (None, '', 'unverified'):
new_status = 'verified'
elif outcome.decision == Decision.SKIP and not file_verif_status:
new_status = 'unverified'
if new_status:
self._persist_status(
context, track_id, fpath,
(expected.get('file_path') or '').strip() or None,
new_status, write_tag=(new_status != file_verif_status),
expected=expected)
if outcome.decision != Decision.FAIL:
if context.report_progress:
context.report_progress(
log_line=f'OK ({outcome.decision.value}): {fname}{outcome.reason}',
log_type='ok',
)
return
# Issue #587 (Foxxify "17min mashup → 5min track") — duration
# guard against fingerprint hash collisions. When the file's
# actual duration differs from AcoustID's matched recording by
# more than max(60s, 35%), the fingerprint is almost certainly
# a sample/intro collision, not a real recording match. Don't
# produce a confident "Wrong Song" finding.
try:
file_duration_s = (expected.get('duration_ms') or 0) / 1000.0
except Exception:
file_duration_s = 0
candidate_duration_s = best_recording.get('duration')
if candidate_duration_s is None and best_recording.get('length'):
candidate_duration_s = best_recording.get('length')
if duration_mismatches_strongly(file_duration_s, candidate_duration_s):
if context.report_progress:
context.report_progress(
log_line=(
f'Skipped (duration mismatch suggests fingerprint collision): '
f'{fname} — expected {file_duration_s:.0f}s, AcoustID '
f'candidate {candidate_duration_s:.0f}s'
),
log_type='skip',
)
return
title_sim = outcome.title_sim
artist_sim = outcome.artist_sim
matched_title = outcome.matched_title or aid_title
matched_artist = outcome.matched_artist or aid_artist
# Mismatch detected
# Mismatch (FAIL) — create finding.
if context.report_progress:
context.report_progress(
log_line=f'Mismatch: {fname} — expected "{expected["title"]}", got "{aid_title}"',
log_line=f'Mismatch: {fname} — expected "{expected["title"]}", got "{matched_title}"',
log_type='error'
)
if context.create_finding:
severity = 'warning' if best_score >= 0.90 else 'info'
_is_force = file_verif_status == 'force_imported'
severity = 'info' if _is_force else ('warning' if best_score >= 0.90 else 'info')
_title = (
f'Force-imported (fallback): "{expected["title"]}" is actually "{matched_title}"'
if _is_force else
f'Wrong download: "{expected["title"]}" is actually "{matched_title}"'
)
inserted = context.create_finding(
job_id=self.job_id,
finding_type='acoustid_mismatch',
@ -337,18 +335,18 @@ class AcoustIDScannerJob(RepairJob):
entity_type='track',
entity_id=str(track_id),
file_path=fpath,
title=f'Wrong download: "{expected["title"]}" is actually "{aid_title}"',
title=_title,
description=(
f'Expected "{expected["title"]}" by {expected_artist}, '
f'but audio fingerprint matches "{aid_title}" by {aid_artist} '
f'but audio fingerprint matches "{matched_title}" by {matched_artist} '
f'(fingerprint: {best_score:.0%}, title match: {title_sim:.0%}, '
f'artist match: {artist_sim:.0%})'
),
details={
'expected_title': expected['title'],
'expected_artist': expected_artist,
'acoustid_title': aid_title,
'acoustid_artist': aid_artist,
'acoustid_title': matched_title,
'acoustid_artist': matched_artist,
'fingerprint_score': round(best_score, 3),
'title_similarity': round(title_sim, 3),
'artist_similarity': round(artist_sim, 3),
@ -356,6 +354,7 @@ class AcoustIDScannerJob(RepairJob):
'artist_thumb_url': expected.get('artist_thumb_url'),
'album_title': expected.get('album_title', ''),
'track_number': expected.get('track_number'),
'force_imported': file_verif_status == 'force_imported',
}
)
if inserted:
@ -363,6 +362,56 @@ class AcoustIDScannerJob(RepairJob):
else:
result.findings_skipped_dedup += 1
def _persist_status(self, context, track_id, fpath, db_path, status, write_tag,
expected=None):
"""Persist a verification status to the file tag (durable, travels with
the file), the tracks row (UI cache) and any library_history rows for
this file (feeds the Unverified review queue on the Downloads page).
``db_path`` is the unresolved DB-side path history rows may store
either form, so both are matched.
Files SoulSync never downloaded have no history row at all for an
'unverified' outcome one is inserted (download_source 'acoustid_scan')
so EVERY scan-flagged file lands in the review queue, not just past
downloads. Re-scans then match this row via file_path (no duplicates).
"""
if not status:
return
if write_tag:
try:
from core.tag_writer import write_verification_status
write_verification_status(fpath, status)
except Exception as e:
logger.debug("verification tag write failed for %s: %s", fpath, e)
try:
conn = context.db._get_connection()
cur = conn.cursor()
cur.execute(
"UPDATE tracks SET verification_status = ? WHERE id = ?",
(status, track_id))
matched = 0
for p in {p for p in (fpath, db_path) if p}:
cur.execute(
"UPDATE library_history SET verification_status = ? WHERE file_path = ?",
(status, p))
matched += max(getattr(cur, 'rowcount', 0) or 0, 0)
if status == 'unverified' and matched == 0:
exp = expected or {}
cur.execute(
"""INSERT INTO library_history
(event_type, title, artist_name, album_name, file_path,
thumb_url, download_source, verification_status)
VALUES ('download', ?, ?, ?, ?, ?, 'acoustid_scan', ?)""",
(exp.get('title') or os.path.basename(fpath),
exp.get('artist') or None,
exp.get('album_title') or None,
db_path or fpath,
exp.get('album_thumb_url') or None,
status))
getattr(conn, 'commit', lambda: None)()
except Exception as e:
logger.debug("verification_status persist failed for %s: %s", track_id, e)
def _load_db_tracks(self, context: JobContext) -> dict:
"""Load all tracks from DB keyed by track ID."""
tracks = {}
@ -480,11 +529,3 @@ class AcoustIDScannerJob(RepairJob):
finally:
if conn:
conn.close()
def _normalize(text: str) -> str:
t = text.lower()
t = re.sub(r'\(.*?\)', '', t)
t = re.sub(r'\[.*?\]', '', t)
t = re.sub(r'[^a-z0-9 ]', '', t)
return t.strip()

View file

@ -0,0 +1,68 @@
"""Relocate an AcoustID-mismatched file into Staging for clean re-import (#704).
The existing 'retag' fix corrects a mismatched file's tags + DB record but leaves
the file in the WRONG artist/album folder on disk so the library shows the right
title while the file sits under the previous track's artist/album. AcoustID only
yields a title + artist (not a reliable album), so an *in-place* move has no
trustworthy target.
Instead: retag the file, move it into the staging folder, and drop the stale
``tracks`` row. The auto-import worker (which watches staging) then re-identifies
the file with full metadata and files it in the correct artist/album/track path
reusing the battle-tested import pipeline rather than guessing a destination here.
Side effects are injected so the orchestration is a pure, unit-testable seam.
"""
from __future__ import annotations
import os
from typing import Any, Callable, Dict, Optional
def staging_destination(staging_dir: str, filename: str,
exists: Callable[[str], bool]) -> str:
"""A non-colliding path for ``filename`` inside ``staging_dir``.
If the name is already taken, suffix it ``' (1)'``, ``' (2)'``, before the
extension never overwrite an unrelated file already waiting in staging.
"""
base, ext = os.path.splitext(filename)
dest = os.path.join(staging_dir, filename)
n = 1
while exists(dest):
dest = os.path.join(staging_dir, f"{base} ({n}){ext}")
n += 1
return dest
def relocate_mismatch_to_staging(
resolved_path: str,
staging_dir: str,
tag_updates: Optional[Dict[str, Any]],
*,
write_tags: Callable[[str, Dict[str, Any]], Any],
move_file: Callable[[str, str], Any],
drop_db_row: Callable[[], Any],
exists: Callable[[str], bool],
) -> str:
"""Retag (best-effort) → move into staging → drop the stale DB row.
Returns the staging destination path. Order matters: the DB row is dropped
only AFTER a successful move, so a failed move (which raises) leaves the
library entry intact rather than orphaning it.
"""
if tag_updates:
try:
write_tags(resolved_path, tag_updates)
except Exception: # noqa: S110 — tags are best-effort; re-import re-derives them
# The relocation itself is the point, so don't abort over a tag write.
pass
dest = staging_destination(staging_dir, os.path.basename(resolved_path), exists)
move_file(resolved_path, dest) # may raise → row NOT dropped (intentional)
drop_db_row()
return dest
__all__ = ["staging_destination", "relocate_mismatch_to_staging"]

View file

@ -1983,6 +1983,57 @@ class RepairWorker:
return {'success': True, 'action': 'redownload',
'message': f'Added "{expected_title}" to wishlist, removed wrong file'}
if fix_action == 'relocate':
# #704: retag fixes the file's tags but leaves it in the WRONG
# artist/album folder. AcoustID gives only title+artist (no reliable
# album), so move the retagged file into staging and let auto-import
# re-file it correctly with full metadata. Drop the stale tracks row.
resolved = _resolve_file_path(file_path, self.transfer_folder,
config_manager=self._config_manager)
if not resolved or not os.path.exists(resolved):
return {'success': False, 'error': f'File not found: {file_path}'}
staging_path = './Staging'
if self._config_manager:
staging_path = self._config_manager.get('import.staging_path', './Staging')
staging_path = self._resolve_path(staging_path)
try:
os.makedirs(staging_path, exist_ok=True)
except OSError as e:
return {'success': False, 'error': f'Staging folder unavailable: {e}'}
aid_title = details.get('acoustid_title', '')
aid_artist = details.get('acoustid_artist', '')
tag_updates = {}
if aid_title:
tag_updates['title'] = aid_title
if aid_artist:
tag_updates['artist_name'] = aid_artist
tag_updates['artists_list'] = _split_acoustid_credit(aid_artist)
def _drop_row():
if not track_id:
return
conn = self.db._get_connection()
try:
conn.cursor().execute("DELETE FROM tracks WHERE id = ?", (track_id,))
conn.commit()
finally:
conn.close()
from core.repair_jobs.relocate import relocate_mismatch_to_staging
from core.tag_writer import write_tags_to_file
from core.imports.file_ops import safe_move_file
try:
dest = relocate_mismatch_to_staging(
resolved, staging_path, tag_updates,
write_tags=write_tags_to_file, move_file=safe_move_file,
drop_db_row=_drop_row, exists=os.path.exists)
except Exception as e:
return {'success': False, 'error': f'Relocate failed: {e}'}
self._cleanup_empty_parents(resolved) # remove the now-empty wrong folder
return {'success': True, 'action': 'relocated',
'message': f'Moved to staging for re-import: {os.path.basename(dest)}'}
# Default: retag — update DB record to match the actual audio content
aid_title = details.get('acoustid_title', '')
aid_artist = details.get('acoustid_artist', '')

39
core/security/ws_gate.py Normal file
View file

@ -0,0 +1,39 @@
"""WebSocket access gate (#852).
Flask's ``before_request`` — where the launch-PIN / login gate lives — does NOT
run for the socketio handshake, so an unauthenticated client that removes the
login/PIN overlay (Safari "Hide Distracting Items", devtools, curl) can still open
a socket and receive the live data SoulSync streams over it (downloads, logs,
dashboard, notifications). The connect handler must therefore enforce the same
check the HTTP gate does.
Pure decision so it's unit-testable; the socketio handler injects the live
session/config/header values. Mirrors the HTTP gate precedence exactly: login
mode (when on) replaces the launch PIN.
"""
from __future__ import annotations
def is_ws_connection_blocked(
*,
require_login: bool,
login_authenticated: bool,
require_pin: bool,
pin_verified: bool,
proxy_authed: bool,
) -> bool:
"""True ⇒ reject this WebSocket connection.
- Login mode on must be login-authenticated.
- Else PIN on must have verified the PIN (or be trusted by an auth proxy).
- Neither on open (matches the HTTP gate's no-op default).
"""
if require_login:
return not login_authenticated
if require_pin:
return not (pin_verified or proxy_authed)
return False
__all__ = ["is_ws_connection_blocked"]

View file

@ -8,6 +8,7 @@ from functools import wraps
from dataclasses import dataclass
from utils.logging_config import get_logger
from config.settings import config_manager
from core.metadata.artist_album_cache import get_cached_artist_album_items, store_artist_album_items
from core.metadata.cache import get_metadata_cache
logger = get_logger("spotify_client")
@ -1897,15 +1898,20 @@ class SpotifyClient:
cache = get_metadata_cache()
fallback_src = self._fallback_source
source = fallback_src if self._is_itunes_id(artist_id) else 'spotify'
cache_key = f"{artist_id}_albums_{album_type.replace(',', '_')}"
# Check cache first (unless caller needs fresh data)
if not skip_cache:
cached = cache.get_entity(source, 'artist', cache_key)
if cached:
cached_items = get_cached_artist_album_items(
cache,
source,
artist_id,
album_type=album_type,
limit=limit,
include_limit=False,
)
if cached_items:
try:
albums_list = cached.get('_albums', cached) if isinstance(cached, dict) else cached
return [Album.from_spotify_album(ad) for ad in albums_list]
return [Album.from_spotify_album(ad) for ad in cached_items]
except Exception as e:
logger.debug("artist albums cache reuse: %s", e)
@ -1959,7 +1965,15 @@ class SpotifyClient:
# complete entities regardless of how many pages we walked.
if raw_items:
if not truncated:
cache.store_entity('spotify', 'artist', cache_key, {'name': f'albums_{artist_id}', '_albums': raw_items})
store_artist_album_items(
cache,
'spotify',
artist_id,
raw_items,
album_type=album_type,
limit=limit,
include_limit=False,
)
# Also cache individual albums opportunistically
entries = [(ad.get('id'), ad) for ad in raw_items if ad.get('id')]
if entries:

View file

@ -44,6 +44,8 @@ def read_file_tags(file_path: str) -> Dict[str, Any]:
'replaygain_track_peak': None,
'replaygain_album_gain': None,
'replaygain_album_peak': None,
# SoulSync verification status ('verified'/'unverified'/'force_imported')
'verification_status': None,
}
if not file_path or not os.path.exists(file_path):
@ -80,6 +82,10 @@ def read_file_tags(file_path: str) -> Dict[str, Any]:
except (ValueError, TypeError):
pass
result['has_cover_art'] = bool(audio.tags.getall('APIC'))
for fr in audio.tags.getall('TXXX'):
if getattr(fr, 'desc', '') == 'SOULSYNC_VERIFICATION' and fr.text:
result['verification_status'] = str(fr.text[0])
break
elif isinstance(audio, (FLAC, OggVorbis)) or type(audio).__name__ == 'OggOpus':
# FLAC / OGG
@ -102,6 +108,7 @@ def read_file_tags(file_path: str) -> Dict[str, Any]:
else:
# OGG doesn't have a standard picture field we can easily check
result['has_cover_art'] = False
result['verification_status'] = _vorbis_first(audio, 'soulsync_verification')
elif isinstance(audio, MP4):
# MP4 / M4A
@ -118,6 +125,12 @@ def read_file_tags(file_path: str) -> Dict[str, Any]:
if disk:
result['disc_number'] = disk[0][0] if isinstance(disk[0], tuple) else None
result['has_cover_art'] = bool(audio.tags.get('covr', [])) if audio.tags else False
vs = (audio.tags or {}).get('----:com.soulsync:VERIFICATION')
if vs:
raw = vs[0]
result['verification_status'] = (
raw.decode('utf-8', 'ignore') if isinstance(raw, bytes) else str(raw)
)
except Exception as e:
result['error'] = str(e)
@ -156,6 +169,39 @@ def is_placeholder_meta(value: Any) -> bool:
return s == '' or s in _PLACEHOLDER_META_VALUES
def write_verification_status(file_path: str, status: str) -> bool:
"""Embed the SoulSync verification status into the file's tags.
Vorbis comment ``SOULSYNC_VERIFICATION`` (FLAC/OGG/Opus), ID3
``TXXX:SOULSYNC_VERIFICATION`` (MP3), MP4 freeform
``----:com.soulsync:VERIFICATION``. The tag travels with the file so the
status survives DB resets; the AcoustID scan reads it back via
``read_file_tags`` to refresh the DB column and to mark force-imported
fallbacks in its findings. Never raises; returns success.
"""
if not status or not file_path or not os.path.exists(file_path):
return False
try:
audio = MutagenFile(file_path)
if audio is None:
return False
if getattr(audio, 'tags', None) is None and hasattr(audio, 'add_tags'):
audio.add_tags()
if isinstance(audio.tags, ID3):
audio.tags.delall('TXXX:SOULSYNC_VERIFICATION')
audio.tags.add(TXXX(encoding=3, desc='SOULSYNC_VERIFICATION', text=[status]))
elif isinstance(audio, MP4):
audio.tags['----:com.soulsync:VERIFICATION'] = [status.encode('utf-8')]
else:
# Vorbis-comment family (FLAC / OggVorbis / OggOpus)
audio['SOULSYNC_VERIFICATION'] = [status]
audio.save()
return True
except Exception as e:
logger.debug("write_verification_status failed for %s: %s", file_path, e)
return False
def guard_placeholder_overwrite(db_val: Any, file_val: Any) -> Any:
"""#800 guard: never replace a real file value with a placeholder.

View file

@ -638,11 +638,29 @@ class MusicDatabase:
if 'download_source' not in lh_cols:
cursor.execute("ALTER TABLE library_history ADD COLUMN download_source TEXT")
logger.info("Added download_source column to library_history")
for _col in ['source_track_id', 'source_track_title', 'source_filename', 'acoustid_result', 'source_artist']:
for _col in ['source_track_id', 'source_track_title', 'source_filename', 'acoustid_result', 'source_artist', 'verification_status']:
if _col not in lh_cols:
cursor.execute(f"ALTER TABLE library_history ADD COLUMN {_col} TEXT")
logger.info(f"Added {_col} column to library_history")
# One-time backfill: derive verification_status for history rows
# written before the column existed (or by pipeline exits that
# missed it) from the acoustid_result those imports already
# recorded (pass->verified, skip->unverified). force_imported
# can't be derived retroactively. Idempotent: only fills NULLs.
cursor.execute("""
UPDATE library_history SET verification_status =
CASE acoustid_result
WHEN 'pass' THEN 'verified'
WHEN 'skip' THEN 'unverified'
WHEN 'fail' THEN 'force_imported'
END
WHERE verification_status IS NULL
AND acoustid_result IN ('pass', 'skip', 'fail')
""")
if cursor.rowcount:
logger.info("Backfilled verification_status from acoustid_result (%d rows)", cursor.rowcount)
# Migration: download-origin provenance — what TRIGGERED a download
# ('watchlist' + artist / 'playlist' + playlist name). Read by the
# origin-history modal on the watchlist + sync pages.
@ -2411,6 +2429,11 @@ class MusicDatabase:
if 'musicbrainz_match_status' not in tracks_columns:
cursor.execute("ALTER TABLE tracks ADD COLUMN musicbrainz_match_status TEXT")
added_tracks = True
if 'verification_status' not in tracks_columns:
# 'verified' / 'unverified' / 'force_imported' — set at import,
# refreshed by the AcoustID scan (which reads the file tag).
cursor.execute("ALTER TABLE tracks ADD COLUMN verification_status TEXT")
added_tracks = True
if added_tracks:
columns_added = True
logger.info("Added MusicBrainz columns to tracks table")
@ -13016,7 +13039,7 @@ class MusicDatabase:
quality=None, server_source=None, file_path=None, thumb_url=None,
download_source=None, source_track_id=None, source_track_title=None,
source_filename=None, acoustid_result=None, source_artist=None,
origin=None, origin_context=None):
origin=None, origin_context=None, verification_status=None):
"""Record a download or import event to the library history table.
``origin``/``origin_context`` record what TRIGGERED the download
@ -13029,11 +13052,12 @@ class MusicDatabase:
INSERT INTO library_history (event_type, title, artist_name, album_name,
quality, server_source, file_path, thumb_url, download_source,
source_track_id, source_track_title, source_filename,
acoustid_result, source_artist, origin, origin_context)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
acoustid_result, source_artist, origin, origin_context,
verification_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (event_type, title, artist_name, album_name, quality, server_source, file_path, thumb_url,
download_source, source_track_id, source_track_title, source_filename,
acoustid_result, source_artist, origin, origin_context))
acoustid_result, source_artist, origin, origin_context, verification_status))
conn.commit()
return True
except Exception as e:

View file

@ -0,0 +1,45 @@
"""#702: a mirrored playlist (e.g. a ListenBrainz weekly) whose in-memory
discovery state was wiped by a restart must still cancel/reset cleanly instead of
404-ing into a permanent wedge. cancel_sync is the shared core for YouTube +
ListenBrainz cancel, so its idempotency is the fix."""
from __future__ import annotations
import threading
from core.discovery.endpoints import cancel_sync
def _lock():
return threading.Lock()
def test_cancel_missing_key_is_idempotent_success_not_404():
body, code = cancel_sync(
{}, 'state_was_wiped', label='YouTube',
not_found_message='YouTube playlist not found',
sync_lock=_lock(), sync_states={}, active_sync_workers={})
assert code == 200
assert body.get('success') is True
assert 'not found' not in str(body).lower() # the wedge message must be gone
def test_cancel_present_key_cancels_and_clears_worker():
states = {'h': {'phase': 'syncing', 'sync_playlist_id': 'sp1'}}
sync_states, workers = {}, {'sp1': 'worker'}
body, code = cancel_sync(
states, 'h', label='YouTube', not_found_message='x',
sync_lock=_lock(), sync_states=sync_states, active_sync_workers=workers)
assert code == 200 and body['success'] is True
assert sync_states['sp1'] == {'status': 'cancelled'}
assert 'sp1' not in workers
assert states['h']['phase'] == 'discovered'
assert states['h']['sync_playlist_id'] is None
def test_cancel_present_with_no_active_sync_still_succeeds():
states = {'h': {'phase': 'discovered', 'sync_playlist_id': None}}
body, code = cancel_sync(
states, 'h', label='YouTube', not_found_message='x',
sync_lock=_lock(), sync_states={}, active_sync_workers={})
assert code == 200 and body['success'] is True

View file

@ -157,11 +157,15 @@ def test_spotify_data_takes_precedence_over_auto_fields():
# cancel_sync
# ---------------------------------------------------------------------------
def test_cancel_sync_not_found_returns_404():
def test_cancel_sync_missing_state_is_idempotent_success():
# #702: a sync whose in-memory state was wiped (e.g. a restart) must cancel
# cleanly — cancelling a sync that isn't running is a no-op SUCCESS, not a
# 404 that permanently wedges the playlist.
body, code = cancel_sync({}, 'missing', label='Tidal',
not_found_message='Tidal playlist not found', **_cancel_infra())
assert code == 404
assert body == {"error": "Tidal playlist not found"}
assert code == 200
assert body.get('success') is True
assert 'not found' not in str(body).lower()
def test_cancel_sync_cancels_active_worker_and_reverts_state():

View file

@ -0,0 +1,84 @@
"""Folder-artist override can be disabled to keep an identified artist.
Background
----------
The auto-import "parent folder artist override" used to fire unconditionally:
whenever the Staging path had >=2 levels and the top folder wasn't a category
word, it replaced the (already metadata-identified) artist with the top folder
name. A user who staged a mixed pile of singles under one container folder
named ``soulsync`` therefore got the album-artist of *every* file forced to
"soulsync" even when the track was confidently resolved to a real artist
with a Spotify/MusicBrainz id. Navidrome groups by album-artist, so 65 singles
collapsed under a bogus "soulsync" artist.
``resolve_folder_artist`` is the extracted, pure decision. It must keep the
identified artist when the feature is OFF, and reproduce the original
folder-derived artist when enabled.
"""
from core.imports.folder_artist import resolve_folder_artist
# --- Disabled: never override -----------------------------------------------
def test_disabled_keeps_identified_artist_even_with_artist_album_structure():
# The 'soulsync' mass mis-file: identified artist is real, folder is a
# generic container. With the feature off it must NOT be overridden.
assert resolve_folder_artist(
"soulsync/Bunny Girl/01 - Bunny Girl.flac",
identified_artist="1nonly, Ciscaux",
enabled=False,
) is None
def test_disabled_returns_none_for_clean_artist_album_path():
assert resolve_folder_artist(
"AC+DC/Back In Black/01 - Hells Bells.flac",
identified_artist="AC/DC",
enabled=False,
) is None
# --- Enabled: original behaviour --------------------------------------------
def test_enabled_uses_top_folder_as_artist_when_it_differs():
assert resolve_folder_artist(
"soulsync/Bunny Girl/01 - Bunny Girl.flac",
identified_artist="1nonly, Ciscaux",
enabled=True,
) == "soulsync"
def test_enabled_skips_category_folder_and_uses_artist_above_it():
assert resolve_folder_artist(
"Pink Floyd/Albums/The Wall/01 - In the Flesh.flac",
identified_artist="Some DJ",
enabled=True,
) == "Pink Floyd"
def test_enabled_flat_file_has_no_folder_artist():
assert resolve_folder_artist(
"01 - Bitch Lasagna.flac",
identified_artist="PewDiePie",
enabled=True,
) is None
def test_enabled_no_override_when_folder_matches_identified():
# Folder already equals the identified artist (case-insensitive) -> no-op.
assert resolve_folder_artist(
"AC_DC/Back In Black/01 - Hells Bells.flac",
identified_artist="ac_dc",
enabled=True,
) is None
def test_enabled_top_level_category_word_is_not_an_artist():
# 'singles/Track/file' — top folder is a category, not an artist.
assert resolve_folder_artist(
"singles/Headache/01 - Headache.flac",
identified_artist="Asal",
enabled=True,
) is None

View file

@ -431,10 +431,10 @@ class TestAliasRescueLogging:
records.append(record)
handler = _ListHandler(level=_logging.INFO)
# Logger name is `soulsync.acoustid.verification` per
# `core.acoustid_verification`'s `get_logger("acoustid_verification")`
# — dot-separated, NOT underscored.
verifier_logger = _logging.getLogger('soulsync.acoustid.verification')
# The alias-aware comparison now lives in the shared core
# (`core.matching.audio_verification`, logger `audio_verification`),
# which is where the rescue diagnostic is emitted.
verifier_logger = _logging.getLogger('soulsync.audio_verification')
verifier_logger.addHandler(handler)
prior_level = verifier_logger.level
verifier_logger.setLevel(_logging.INFO)

View file

@ -0,0 +1,83 @@
"""Shared audio-verification decision core: normalize() + evaluate().
One place for normalization + the PASS/SKIP/FAIL decision used by BOTH import-time
verification and the library AcoustID scan, so the two paths can't drift apart.
"""
from core.matching.audio_verification import normalize, evaluate, Decision
def _rec(title, artist, duration=None):
return {"title": title, "artist": artist, "duration": duration}
def test_cross_script_vocal_credit_clean_match_passes():
# Sawano / 澤野弘之 <Vocal: ...> — <> stripped, alias bridges the artist,
# title matches too -> clean PASS (must never FAIL/quarantine).
out = evaluate(
"Call Your Name", "Sawano Hiroyuki",
[_rec("call your name", "澤野弘之 <Vocal: mpi & CASG>")],
fingerprint_score=0.95,
aliases_provider=lambda: ["澤野弘之"],
)
assert out.decision == Decision.PASS
def test_cross_script_ipa_title_skips_not_fails():
# AcoustID returns an IPA-transcribed title that can't string-match, but the
# artist bridges cross-script -> SKIP (import anyway), never FAIL.
out = evaluate(
"Attack on Titan", "Sawano Hiroyuki",
[_rec("ətˈæk 0N tάɪtn", "澤野弘之")],
fingerprint_score=0.95,
aliases_provider=lambda: ["澤野弘之"],
)
assert out.decision == Decision.SKIP
def test_clean_cross_script_match_passes():
out = evaluate(
"Xl-Tt", "Sawano Hiroyuki",
[_rec("xl-tt", "澤野弘之")],
fingerprint_score=0.95,
aliases_provider=lambda: ["澤野弘之"],
)
assert out.decision == Decision.PASS
def test_genuine_wrong_song_fails():
out = evaluate(
"Yellow", "Coldplay",
[_rec("Rich Interlude", "Kendrick Lamar")],
fingerprint_score=0.85,
)
assert out.decision == Decision.FAIL
def test_no_recordings_skips():
out = evaluate("Whatever", "Someone", [], fingerprint_score=0.9)
assert out.decision == Decision.SKIP
def test_normalize_strips_paren_bracket_angle_and_keeps_cjk():
assert normalize("澤野弘之 <Vocal: MIKA KOBAYASHI>") == "澤野弘之"
assert normalize("Clarity (Live at X) [Remastered]") == "clarity"
assert normalize("Attack on Titan <TV Size>") == "attack on titan"
def test_normalize_strips_version_and_featuring():
assert normalize("In My Feelings - Instrumental") == "in my feelings"
assert normalize("Song feat. Someone") == "song"
def test_normalize_keeps_plain_text():
assert normalize("Sawano Hiroyuki") == "sawano hiroyuki"
def test_empty_expected_artist_does_not_fail():
# Old scanner treated a missing expected artist as artist-match=1.0
# (compare title only). The unified core must not FAIL a track just
# because the DB has no artist value.
out = evaluate("Some Track", "", [_rec("Some Track", "Whoever")],
fingerprint_score=0.95)
assert out.decision == Decision.PASS

View file

@ -0,0 +1,87 @@
"""Seam tests for resolve_history_audio_path — the fallback chain a DESTRUCTIVE
delete trusts. The collision-safety rules (artist filter / single-candidate) are
what stop delete() from removing the wrong same-title file, so they're locked here."""
from __future__ import annotations
from core.matching.history_paths import resolve_history_audio_path
def _resolver(existing=(), resolve_map=None, titled=None):
existing = set(existing)
resolve_map = resolve_map or {}
titled = titled or {}
return dict(
exists=lambda p: p in existing,
resolve_library_path=lambda raw: resolve_map.get(raw),
lookup_titled_paths=lambda title: list(titled.get(title.lower(), [])),
)
def test_recorded_path_used_when_it_exists():
r = resolve_history_audio_path({'file_path': '/m/a.mp3'}, **_resolver(existing={'/m/a.mp3'}))
assert r == '/m/a.mp3'
def test_falls_back_to_prefix_resolved_path():
r = resolve_history_audio_path(
{'file_path': '/transfer/a.mp3'},
**_resolver(existing={'/library/a.mp3'}, resolve_map={'/transfer/a.mp3': '/library/a.mp3'}))
assert r == '/library/a.mp3'
def test_tracks_table_single_candidate_no_artist():
r = resolve_history_audio_path(
{'file_path': '/gone.mp3', 'title': 'Song'},
**_resolver(existing={'/library/song.mp3'},
resolve_map={'/lib/song.mp3': '/library/song.mp3'},
titled={'song': ['/lib/song.mp3']}))
assert r == '/library/song.mp3'
def test_collision_no_artist_multiple_candidates_returns_none():
# THE safety rule: same title, no artist to disambiguate -> refuse to guess
# (delete() must not remove an arbitrary one of two same-title files).
r = resolve_history_audio_path(
{'file_path': '', 'title': 'Intro'},
**_resolver(existing={'/library/a/intro.mp3', '/library/b/intro.mp3'},
resolve_map={'/a/intro.mp3': '/library/a/intro.mp3', '/b/intro.mp3': '/library/b/intro.mp3'},
titled={'intro': ['/a/intro.mp3', '/b/intro.mp3']}))
assert r is None
def test_artist_filter_picks_only_the_matching_path():
# Two same-title files by different artists -> only the one whose path
# mentions the row's artist is eligible (won't delete the other artist's file).
r = resolve_history_audio_path(
{'file_path': '', 'title': 'Intro', 'artist_name': 'Alpha'},
**_resolver(existing={'/music/Alpha/intro.mp3', '/music/Beta/intro.mp3'},
resolve_map={'/Alpha/intro.mp3': '/music/Alpha/intro.mp3',
'/Beta/intro.mp3': '/music/Beta/intro.mp3'},
titled={'intro': ['/Alpha/intro.mp3', '/Beta/intro.mp3']}))
assert r == '/music/Alpha/intro.mp3'
def test_artist_named_but_no_path_mentions_it_returns_none():
r = resolve_history_audio_path(
{'file_path': '', 'title': 'Intro', 'artist_name': 'Gamma'},
**_resolver(existing={'/music/Alpha/intro.mp3'},
resolve_map={'/Alpha/intro.mp3': '/music/Alpha/intro.mp3'},
titled={'intro': ['/Alpha/intro.mp3']}))
assert r is None
def test_no_title_returns_none():
assert resolve_history_audio_path({'file_path': '/gone.mp3'}, **_resolver()) is None
def test_nothing_resolves_returns_none():
assert resolve_history_audio_path(
{'file_path': '/gone.mp3', 'title': 'X'},
**_resolver(titled={'x': ['/also/gone.mp3']})) is None
def test_empty_lookup_returns_none():
# DB-error proxy: lookup yields [] -> None (never a stray delete target).
assert resolve_history_audio_path(
{'file_path': '', 'title': 'X'}, **_resolver(titled={})) is None

View file

@ -0,0 +1,24 @@
"""#851: a '/' or ':' in a title must normalize the same as a source filename
that substituted '_' for them, so the candidate matcher stops rejecting valid
downloads (e.g. Sawano's "You See Big Girl / T:T" vs on-disk "..._ T_T")."""
from __future__ import annotations
from core.matching.audio_verification import normalize, similarity
def test_slash_colon_title_matches_underscore_source():
assert normalize("You See Big Girl / T:T") == normalize("You See Big Girl _ T_T")
assert similarity("You See Big Girl / T:T", "You See Big Girl _ T_T") == 1.0
def test_separators_become_word_boundaries():
assert normalize("Re:Zero") == "re zero"
assert normalize("AC/DC") == "ac dc"
assert normalize("T_T") == "t t"
def test_joined_variant_stays_above_title_threshold():
# Spacing a separator must not drop a joined-variant match below 0.70.
assert similarity("AC/DC", "ACDC") >= 0.70
assert similarity("12:05", "1205") >= 0.70

View file

@ -0,0 +1,36 @@
"""Verification-status vocabulary + mapping (DB column / file tag / UI badge)."""
from core.matching.verification_status import (
VERIFIED, UNVERIFIED, FORCE_IMPORTED, HUMAN_VERIFIED,
status_from_acoustid_result, status_for_import,
)
def test_acoustid_result_maps_to_status():
assert status_from_acoustid_result('pass') == VERIFIED
assert status_from_acoustid_result('skip') == UNVERIFIED
# disabled / error / unknown -> no claim either way
assert status_from_acoustid_result('disabled') is None
assert status_from_acoustid_result('error') is None
assert status_from_acoustid_result(None) is None
def test_force_import_context_wins_over_acoustid():
ctx = {'_version_mismatch_fallback': 'instrumental', '_acoustid_result': 'pass'}
assert status_for_import(ctx) == FORCE_IMPORTED
def test_status_for_import_falls_back_to_acoustid_result():
assert status_for_import({'_acoustid_result': 'pass'}) == VERIFIED
assert status_for_import({'_acoustid_result': 'skip'}) == UNVERIFIED
assert status_for_import({}) is None
def test_approved_quarantine_import_is_human_verified():
# The user clicked Approve on a quarantine entry — an explicit human
# decision about THIS file. Outranks both the fallback flag and whatever
# the (earlier, failed) verification said.
ctx = {'_approved_quarantine_trigger': 'acoustid',
'_version_mismatch_fallback': 'instrumental',
'_acoustid_result': 'fail'}
assert status_for_import(ctx) == HUMAN_VERIFIED

View file

@ -0,0 +1,117 @@
"""#704: relocate an AcoustID-mismatched file to staging for re-import — pure
orchestration (retag -> move -> drop row) with the side effects injected, plus a
real-file move through safe_move_file."""
from __future__ import annotations
import os
import pytest
from core.repair_jobs.relocate import staging_destination, relocate_mismatch_to_staging
# ── staging_destination: never overwrite an unrelated staged file ──────────
def test_staging_destination_no_collision():
assert staging_destination('/stg', 'Song.mp3', exists=lambda p: False) == os.path.join('/stg', 'Song.mp3')
def test_staging_destination_suffixes_on_collision():
taken = {os.path.join('/stg', 'Song.mp3'), os.path.join('/stg', 'Song (1).mp3')}
assert staging_destination('/stg', 'Song.mp3', exists=lambda p: p in taken) == os.path.join('/stg', 'Song (2).mp3')
# ── relocate orchestration ─────────────────────────────────────────────────
class _Spy:
def __init__(self): self.tagged = None; self.moved = None; self.dropped = False
def write_tags(self, path, updates): self.tagged = (path, updates)
def move(self, src, dst): self.moved = (src, dst)
def drop(self): self.dropped = True
def test_relocate_happy_path():
s = _Spy()
dest = relocate_mismatch_to_staging(
'/lib/Artist X/Album Y/03 - x.mp3', '/stg', {'title': 'Real Song'},
write_tags=s.write_tags, move_file=s.move, drop_db_row=s.drop, exists=lambda p: False)
assert s.tagged == ('/lib/Artist X/Album Y/03 - x.mp3', {'title': 'Real Song'})
assert s.moved == ('/lib/Artist X/Album Y/03 - x.mp3', os.path.join('/stg', '03 - x.mp3'))
assert s.dropped is True
assert dest == os.path.join('/stg', '03 - x.mp3')
def test_relocate_tag_failure_still_relocates():
s = _Spy()
def boom(*a): raise RuntimeError('tag write failed')
dest = relocate_mismatch_to_staging(
'/lib/x.mp3', '/stg', {'title': 'T'},
write_tags=boom, move_file=s.move, drop_db_row=s.drop, exists=lambda p: False)
assert s.moved and s.dropped and dest == os.path.join('/stg', 'x.mp3')
def test_relocate_failed_move_does_not_drop_row():
# The library row must survive a failed move (no orphaning).
s = _Spy()
def bad_move(src, dst): raise OSError('cross-device move failed')
with pytest.raises(OSError):
relocate_mismatch_to_staging('/lib/x.mp3', '/stg', None,
write_tags=s.write_tags, move_file=bad_move, drop_db_row=s.drop, exists=lambda p: False)
assert s.dropped is False
def test_relocate_no_tag_updates_skips_write():
s = _Spy()
relocate_mismatch_to_staging('/lib/x.mp3', '/stg', None,
write_tags=s.write_tags, move_file=s.move, drop_db_row=s.drop, exists=lambda p: False)
assert s.tagged is None and s.moved is not None
# ── real-file move through the actual safe_move_file ───────────────────────
def test_real_file_moves_into_staging(tmp_path):
from core.imports.file_ops import safe_move_file
lib = tmp_path / 'lib' / 'Artist X' / 'Album Y'; lib.mkdir(parents=True)
src = lib / '03 - wrong.mp3'; src.write_bytes(b'\x00' * 64)
stg = tmp_path / 'Staging'; stg.mkdir()
dropped = []
dest = relocate_mismatch_to_staging(
str(src), str(stg), None,
write_tags=lambda *a: None, move_file=safe_move_file,
drop_db_row=lambda: dropped.append(True), exists=os.path.exists)
assert not src.exists() # moved out of the wrong folder
assert os.path.exists(dest) # present in staging
assert os.path.dirname(dest) == str(stg)
assert dropped == [True]
# ── handler integration: _fix_acoustid_mismatch relocate end-to-end ─────────
def test_relocate_handler_moves_file_and_drops_row(tmp_path):
from database.music_database import MusicDatabase
from core.repair_worker import RepairWorker
db = MusicDatabase(str(tmp_path / 'm.db'))
lib = tmp_path / 'music' / 'Wrong Artist' / 'Wrong Album'
lib.mkdir(parents=True)
wrong = lib / '03 - wrong.mp3'
wrong.write_bytes(b'\x00' * 64)
staging = tmp_path / 'Staging'; staging.mkdir()
with db._get_connection() as conn:
conn.execute("INSERT OR REPLACE INTO artists (id, name, server_source) VALUES ('a1','Wrong Artist','plex')")
conn.execute("INSERT OR REPLACE INTO albums (id, title, artist_id) VALUES (10,'Wrong Album','a1')")
conn.execute("INSERT INTO tracks (id, album_id, artist_id, title, track_number, duration, file_path, server_source) "
"VALUES ('t1',10,'a1','Wrong Title',3,100,?, 'plex')", (str(wrong),))
conn.commit()
worker = RepairWorker(db)
worker._config_manager = type('C', (), {
'get': staticmethod(lambda k, d=None: str(staging) if k == 'import.staging_path' else d)})()
res = worker._fix_acoustid_mismatch(
'track', 't1', str(wrong),
{'_fix_action': 'relocate', 'acoustid_title': 'Real Song', 'acoustid_artist': 'Real Artist'})
assert res['success'] is True and res['action'] == 'relocated'
assert not wrong.exists() # gone from the wrong album folder
assert (staging / '03 - wrong.mp3').exists() # now staged for re-import
with db._get_connection() as conn:
assert conn.execute("SELECT COUNT(*) FROM tracks WHERE id='t1'").fetchone()[0] == 0 # stale row dropped

View file

@ -0,0 +1,36 @@
"""`_normalize` must strip ``<...>`` annotations like the AcoustID/MusicBrainz
vocalist credit ``澤野弘之 <Vocal: MIKA KOBAYASHI>``.
User report: a correct anime-OST track ("Attack on Titan" by "Sawano Hiroyuki")
was false-quarantined. AcoustID returned the artist as
``澤野弘之 <Vocal: MIKA KOBAYASHI>``. The kanji ``澤野弘之`` IS the artist and the
MusicBrainz alias bridge matches it but `_normalize` stripped ``()`` and
``[]`` annotations, NOT ``<...>``, so the trailing "vocal mika kobayashi" words
diluted the alias comparison down to ~0.28 (below ARTIST_MATCH_THRESHOLD). That
in turn blocked the existing cross-script SKIP safety net (issue #797), which is
gated on ``artist_sim >= threshold``, so the file FAILED and was quarantined.
Stripping ``<...>`` restores the artist to ``澤野弘之`` so the alias match (and
thus the cross-script SKIP) works.
"""
from core.acoustid_verification import _normalize, _similarity
def test_normalize_strips_angle_bracket_vocalist_annotation():
assert _normalize("澤野弘之 <Vocal: MIKA KOBAYASHI>") == "澤野弘之"
def test_normalize_strips_angle_brackets_latin():
assert _normalize("Attack on Titan <TV Size>") == "attack on titan"
def test_vocalist_annotation_no_longer_dilutes_artist_similarity():
# The kanji artist with a vocalist credit must compare as identical to the
# bare kanji artist — this is what lets the alias bridge clear the threshold.
assert _similarity("澤野弘之", "澤野弘之 <Vocal: MIKA KOBAYASHI>") == 1.0
def test_normalize_keeps_plain_text_untouched():
# Guard: no angle brackets -> unchanged behaviour.
assert _normalize("Sawano Hiroyuki") == "sawano hiroyuki"

View file

@ -199,7 +199,11 @@ def test_scanner_still_flags_genuine_artist_mismatch():
'best_score': 0.99,
'recordings': [{
'title': 'Some Track',
'artist': 'Different Band, Other Person & Random Featuring',
# Clearly-different multi-value credit (artist sim < 0.30). The
# unified core gives 0.30-0.60 ("ambiguous") the benefit of the
# doubt, so a genuine-mismatch assertion needs an artist that's
# unambiguously different.
'artist': 'Metallica, Slayer & Anthrax',
}],
},
)
@ -468,7 +472,9 @@ def test_scanner_falls_back_to_db_when_file_tag_missing(monkeypatch):
'best_score': 0.99,
'recordings': [{
'title': 'Some Track',
'artist': 'Different Band',
# Unambiguously different artist (sim < 0.30) so the unified
# core flags it (0.30-0.60 would be treated as ambiguous).
'artist': 'Metallica',
}],
},
)
@ -779,3 +785,173 @@ def test_scanner_still_flags_when_duration_matches():
)
assert len(captured_findings) == 1
def test_scanner_does_not_flag_cross_script_when_alias_bridges(monkeypatch):
"""Anime-OST track: AcoustID returns the kanji artist with a <Vocal: ...>
credit. With the MusicBrainz alias bridging 澤野弘之 Sawano Hiroyuki, the
unified verification core recognises the match, so the library scan must NOT
create a false 'Wrong download' finding (it did before, stripping all
non-ASCII and never consulting aliases)."""
import core.repair_jobs.acoustid_scanner as scanner_mod
monkeypatch.setattr(scanner_mod, "_resolve_expected_artist_aliases",
lambda name: ["澤野弘之"], raising=False)
job = AcoustIDScannerJob()
captured = []
context = _make_finding_capturing_context(
track_row=("7", "Call Your Name", "Sawano Hiroyuki",
"/music/cyn.flac", 15, "Attack on Titan OST", None, None),
captured=captured,
)
fake_acoustid = SimpleNamespace(
fingerprint_and_lookup=lambda fpath: {
'best_score': 0.97,
'recordings': [{'title': 'call your name',
'artist': '澤野弘之 <Vocal: mpi & CASG>'}],
},
)
result = JobResultStub()
job._scan_file('/music/cyn.flac', '7',
{'title': 'Call Your Name', 'artist': 'Sawano Hiroyuki'},
fake_acoustid, context, result,
fp_threshold=0.85, title_threshold=0.85, artist_threshold=0.6)
assert captured == [], f"cross-script track false-flagged: {captured}"
def _force_imported_scan(monkeypatch):
"""Drive a scan over a force-imported file whose fingerprint clearly
mismatches. Returns the captured findings."""
import core.repair_jobs.acoustid_scanner as scanner_mod
monkeypatch.setattr(scanner_mod, "_resolve_expected_artist_aliases",
lambda name: [], raising=False)
monkeypatch.setattr(
'core.tag_writer.read_file_tags',
lambda fpath: {'artist': None, 'verification_status': 'force_imported'},
)
job = AcoustIDScannerJob()
captured = []
context = _make_finding_capturing_context(
track_row=("42", "Wanted Song", "Real Artist",
"/music/ws.flac", 1, "Album", None, None),
captured=captured,
)
fake_acoustid = SimpleNamespace(
fingerprint_and_lookup=lambda fpath: {
'best_score': 0.99,
'recordings': [{'title': 'Wanted Song - Instrumental',
'artist': 'Real Artist'}],
},
)
result = JobResultStub()
job._scan_file('/music/ws.flac', '42',
{'title': 'Wanted Song', 'artist': 'Real Artist'},
fake_acoustid, context, result,
fp_threshold=0.85, title_threshold=0.85, artist_threshold=0.6)
return captured
def test_force_imported_mismatch_is_reported_as_informational(monkeypatch):
# The user opted into the fallback, so the scan must still TELL them the
# file is e.g. an instrumental — but as 'info', clearly marked, not as a
# red Wrong-download warning. Only human_verified short-circuits the scan.
captured = _force_imported_scan(monkeypatch)
assert len(captured) == 1
assert captured[0]['severity'] == 'info'
assert captured[0]['details'].get('force_imported') is True
assert 'Force-imported' in captured[0]['title']
def test_human_verified_files_are_never_scanned(monkeypatch):
import core.repair_jobs.acoustid_scanner as scanner_mod
monkeypatch.setattr(scanner_mod, "_resolve_expected_artist_aliases",
lambda name: [], raising=False)
monkeypatch.setattr('core.tag_writer.read_file_tags',
lambda fpath: {'artist': None, 'verification_status': 'human_verified'})
job = AcoustIDScannerJob()
captured = []
context = _make_finding_capturing_context(
track_row=("7", "T", "A", "/music/t.flac", 1, "Al", None, None),
captured=captured)
fake = SimpleNamespace(fingerprint_and_lookup=lambda f: {
'best_score': 0.99,
'recordings': [{'title': 'Totally Different', 'artist': 'Metallica'}]})
job._scan_file('/music/t.flac', '7', {'title': 'T', 'artist': 'A'},
fake, context, JobResultStub(),
fp_threshold=0.85, title_threshold=0.85, artist_threshold=0.6)
assert captured == []
# ---------------------------------------------------------------------------
# Scan-outcome persistence — the scan feeds the same review pipeline as
# import-time verification (tag + tracks row + library_history rows).
# ---------------------------------------------------------------------------
def _run_persistence_scan(monkeypatch, *, file_status, aid_artist, expected_artist):
"""Drive one _scan_file call and return (status_updates, tag_writes) where
status_updates is the list of (query, params) UPDATEs the scanner ran."""
import core.repair_jobs.acoustid_scanner as scanner_mod
monkeypatch.setattr(scanner_mod, "_resolve_expected_artist_aliases",
lambda name: [], raising=False)
monkeypatch.setattr(
'core.tag_writer.read_file_tags',
lambda fpath: {'artist': None, 'verification_status': file_status})
tag_writes = []
monkeypatch.setattr(
'core.tag_writer.write_verification_status',
lambda fpath, status: tag_writes.append((fpath, status)) or True)
job = AcoustIDScannerJob()
captured = []
context = _make_finding_capturing_context(
track_row=("9", "Call Your Name", expected_artist,
"/music/cyn.flac", 1, "Album", None, None),
captured=captured)
fake = SimpleNamespace(fingerprint_and_lookup=lambda f: {
'best_score': 0.97,
'recordings': [{'title': 'Call Your Name', 'artist': aid_artist}]})
job._scan_file('/music/cyn.flac', '9',
{'title': 'Call Your Name', 'artist': expected_artist},
fake, context, JobResultStub(),
fp_threshold=0.85, title_threshold=0.85, artist_threshold=0.6)
conn = context.db._get_connection()
updates = [(q, p) for q, p in conn.cursor().executed
if 'verification_status' in q]
return updates, tag_writes, captured
def test_scan_pass_backfills_verified_status(monkeypatch):
# Untagged file + clean fingerprint PASS → the scan backfills 'verified'
# into the tag, the tracks row AND library_history (review-queue feed).
updates, tag_writes, captured = _run_persistence_scan(
monkeypatch, file_status=None,
aid_artist='Sawano Hiroyuki', expected_artist='Sawano Hiroyuki')
assert captured == []
assert tag_writes == [('/music/cyn.flac', 'verified')]
assert any('tracks' in q and p == ('verified', '9') for q, p in updates)
assert any('library_history' in q and p == ('verified', '/music/cyn.flac')
for q, p in updates)
def test_scan_skip_marks_untagged_file_unverified(monkeypatch):
# Title matches but the artist is ambiguous (cover/collab band?) → SKIP.
# An untagged file gets 'unverified' so it surfaces in the Downloads-page
# review queue instead of silently passing or being deleted.
updates, tag_writes, captured = _run_persistence_scan(
monkeypatch, file_status=None,
aid_artist='Mantilla', expected_artist='Metallica')
assert captured == []
assert tag_writes == [('/music/cyn.flac', 'unverified')]
assert any('tracks' in q and p == ('unverified', '9') for q, p in updates)
assert any('library_history' in q and p == ('unverified', '/music/cyn.flac')
for q, p in updates)
def test_scan_skip_does_not_downgrade_verified(monkeypatch):
# A SKIP must not downgrade an import-time 'verified' (that check ran with
# richer candidate metadata). Status is refreshed, tag untouched.
updates, tag_writes, captured = _run_persistence_scan(
monkeypatch, file_status='verified',
aid_artist='Mantilla', expected_artist='Metallica')
assert captured == []
assert tag_writes == []
assert any('tracks' in q and p == ('verified', '9') for q, p in updates)

View file

@ -0,0 +1,165 @@
from __future__ import annotations
from unittest.mock import MagicMock
import core.deezer_client as deezer_mod
import core.discogs_client as discogs_mod
import core.itunes_client as itunes_mod
from core.metadata.artist_album_cache import (
get_cached_artist_album_items,
get_cached_artist_album_payload,
make_artist_album_cache_key,
store_artist_album_items,
)
class MemoryCache:
def __init__(self):
self.entities = {}
def get_entity(self, source, entity_type, entity_id):
return self.entities.get((source, entity_type, entity_id))
def store_entity(self, source, entity_type, entity_id, raw_data):
self.entities[(source, entity_type, entity_id)] = raw_data
def store_entities_bulk(self, *args, **kwargs):
return None
def test_artist_album_cache_helper_round_trips_items_and_payload():
cache = MemoryCache()
store_artist_album_items(
cache,
'deezer',
'artist-1',
[{'id': 'album-1'}],
album_type='album,single',
limit=200,
extra_fields={'artist_name': 'Artist'},
)
assert make_artist_album_cache_key('artist-1', 'album,single', 200) == 'artist-1_albums_album_single_200'
assert make_artist_album_cache_key('artist-1', 'album,single', 200, include_limit=False) == 'artist-1_albums_album_single'
assert get_cached_artist_album_items(cache, 'deezer', 'artist-1', limit=200) == [{'id': 'album-1'}]
assert get_cached_artist_album_payload(cache, 'deezer', 'artist-1', limit=200)['artist_name'] == 'Artist'
def test_deezer_artist_albums_reuses_list_cache(monkeypatch):
cache = MemoryCache()
monkeypatch.setattr(deezer_mod, 'get_metadata_cache', lambda: cache)
client = deezer_mod.DeezerClient.__new__(deezer_mod.DeezerClient)
client._api_get = MagicMock(return_value={
'data': [{
'id': 123,
'title': 'Cached Deezer Album',
'record_type': 'album',
'release_date': '2024-01-01',
'nb_tracks': 10,
'artist': {'id': 7, 'name': 'Artist'},
}]
})
first = client.get_artist_albums('7', limit=200)
second = client.get_artist_albums('7', limit=200)
assert [album.name for album in first] == ['Cached Deezer Album']
assert [album.name for album in second] == ['Cached Deezer Album']
client._api_get.assert_called_once()
def test_itunes_artist_albums_reuses_list_cache_and_skips_validation(monkeypatch):
cache = MemoryCache()
monkeypatch.setattr(itunes_mod, 'get_metadata_cache', lambda: cache)
client = itunes_mod.iTunesClient.__new__(itunes_mod.iTunesClient)
client._lookup = MagicMock(side_effect=[
[{
'wrapperType': 'collection',
'collectionId': 456,
'collectionName': 'Cached iTunes Album',
'artistId': 8,
'artistName': 'Artist',
'trackCount': 10,
'collectionExplicitness': 'notExplicit',
'releaseDate': '2024-01-01T00:00:00Z',
}]
])
first = client.get_artist_albums('8', limit=200)
second = client.get_artist_albums('8', limit=200)
assert [album.name for album in first] == ['Cached iTunes Album']
assert [album.name for album in second] == ['Cached iTunes Album']
client._lookup.assert_called_once_with(id='8', entity='album', limit=200)
def test_discogs_artist_albums_reuses_list_cache(monkeypatch):
cache = MemoryCache()
monkeypatch.setattr(discogs_mod, 'get_metadata_cache', lambda: cache)
client = discogs_mod.DiscogsClient.__new__(discogs_mod.DiscogsClient)
client._api_get = MagicMock(side_effect=[
{'name': 'Artist'},
{'releases': [{
'id': 789,
'type': 'master',
'role': 'Main',
'title': 'Cached Discogs Album',
'artist': 'Artist',
'year': 2024,
}]},
])
first = client.get_artist_albums('9', limit=50)
second = client.get_artist_albums('9', limit=50)
assert [album.name for album in first] == ['Cached Discogs Album']
assert [album.name for album in second] == ['Cached Discogs Album']
assert client._api_get.call_count == 2
def _deezer_album(i):
return {
'id': i, 'title': f'Album {i}', 'record_type': 'album',
'release_date': '2024-01-01', 'nb_tracks': 10,
'artist': {'id': 7, 'name': 'Artist'},
}
def test_deezer_partial_pagination_not_cached(monkeypatch):
"""#853 follow-up: a transient/malformed error mid-pagination must NOT cache a
partial discography (mirrors Spotify's truncated-fetch guard) — otherwise an
incomplete album list serves from cache until TTL."""
cache = MemoryCache()
monkeypatch.setattr(deezer_mod, 'get_metadata_cache', lambda: cache)
client = deezer_mod.DeezerClient.__new__(deezer_mod.DeezerClient)
full_page = {'data': [_deezer_album(i) for i in range(100)]} # full → forces page 2
# page 1 ok, page 2 errors (None) → incomplete, on BOTH attempts
client._api_get = MagicMock(side_effect=[full_page, None, full_page, None])
first = client.get_artist_albums('7', limit=200)
assert len(first) == 100 # page-1 albums still returned
second = client.get_artist_albums('7', limit=200)
assert len(second) == 100
# No partial cache → the second call refetched (4 api calls total), instead of
# serving a permanently-incomplete discography from cache (which would be 2).
assert client._api_get.call_count == 4
def test_deezer_complete_multipage_is_cached(monkeypatch):
"""A clean multi-page pagination still caches (guard didn't break the happy path)."""
cache = MemoryCache()
monkeypatch.setattr(deezer_mod, 'get_metadata_cache', lambda: cache)
client = deezer_mod.DeezerClient.__new__(deezer_mod.DeezerClient)
page1 = {'data': [_deezer_album(i) for i in range(100)]} # full
page2 = {'data': [_deezer_album(i) for i in range(100, 150)]} # short → clean end
client._api_get = MagicMock(side_effect=[page1, page2])
first = client.get_artist_albums('7', limit=200)
assert len(first) == 150
second = client.get_artist_albums('7', limit=200) # served from cache
assert len(second) == 150
assert client._api_get.call_count == 2 # no refetch → cached

View file

@ -0,0 +1,40 @@
"""#848 follow-up: the Your Albums Discogs collection sync stores album IDs
TAGGED ('r<id>') like search/discography, so every stored Discogs album ID is
uniform and re-fetches route to the correct endpoint (no master/release collision).
The divergence didn't cause a live bug (the pool dedups by normalized name, and
discogs_release_id is only ever re-fetched which handles bare too); this locks
in the consistency so a future ID comparison can't be tripped by mixed forms.
"""
from __future__ import annotations
import pytest
from core.discogs_client import _tag_discogs_album_id, _discogs_album_endpoints
from database.music_database import MusicDatabase
def test_collection_release_id_is_tagged_and_routes_to_releases_only(tmp_path):
db = MusicDatabase(str(tmp_path / "m.db"))
# Exactly what the Your Albums Discogs sync now passes (collection = releases).
ok = db.upsert_liked_album(
album_name="Some Album", artist_name="Some Artist",
source_service="discogs",
source_id=_tag_discogs_album_id(7361634, "release"), source_id_type="discogs",
profile_id=1,
)
assert ok
with db._get_connection() as conn:
row = conn.execute(
"SELECT discogs_release_id FROM liked_albums_pool WHERE profile_id = 1"
).fetchone()
assert row is not None
assert row["discogs_release_id"] == "r7361634" # stored tagged, not bare
# A tagged collection ID routes ONLY to /releases — never /masters — so it
# can't hit the master/release collision #848 fixed.
assert _discogs_album_endpoints("r7361634") == ["/releases/7361634"]
def test_tagged_collection_id_never_hits_masters():
assert "/masters/" not in " ".join(_discogs_album_endpoints(_tag_discogs_album_id(123, "release")))

View file

@ -0,0 +1,189 @@
"""Tests for Discogs master-vs-release ID disambiguation.
Discogs has two album object types masters (``/masters/{id}``) and releases
(``/releases/{id}``) whose numeric IDs share ONE namespace, so release N and
master N are different albums. The old fetch code tried ``/masters/{id}`` first
and fell back to ``/releases/{id}``; because ``search_albums`` only ever yields
RELEASE ids, any release id that happened to collide with a real master id
returned a valid-but-WRONG album (the fallback never fired). See
``core.discogs_client`` ID-typing helpers.
The fix tags the type into the id string ('r12345' / 'm12345') at parse time and
routes each fetch to the matching endpoint, with legacy bare ids tried
release-first (and master only as a fallback).
"""
import pytest
from core.discogs_client import (
Album,
DiscogsClient,
_discogs_album_endpoints,
_discogs_album_kind,
_tag_discogs_album_id,
)
# ---------------------------------------------------------------------------
# _discogs_album_endpoints — the routing table
# ---------------------------------------------------------------------------
def test_release_tagged_id_hits_only_releases():
assert _discogs_album_endpoints('r12345') == ['/releases/12345']
def test_master_tagged_id_hits_only_masters():
assert _discogs_album_endpoints('m12345') == ['/masters/12345']
def test_legacy_bare_id_tries_release_first_then_master():
# The crux of the fix: bare ids are release-first, NOT master-first.
assert _discogs_album_endpoints('12345') == ['/releases/12345', '/masters/12345']
def test_unusable_ids_return_empty():
assert _discogs_album_endpoints('') == []
assert _discogs_album_endpoints(None) == []
assert _discogs_album_endpoints('not-an-id') == []
# A lone letter prefix with no digits is not a valid tagged id.
assert _discogs_album_endpoints('r') == []
# ---------------------------------------------------------------------------
# _discogs_album_kind / _tag_discogs_album_id — classification + tagging
# ---------------------------------------------------------------------------
def test_kind_from_explicit_type():
assert _discogs_album_kind({'type': 'master'}) == 'master'
assert _discogs_album_kind({'type': 'release'}) == 'release'
def test_kind_full_master_detail_has_main_release():
# Full /masters/{id} responses carry no `type`, but do carry `main_release`.
assert _discogs_album_kind({'id': 1, 'main_release': 42, 'title': 'X'}) == 'master'
def test_kind_full_release_detail_defaults_release():
# Full /releases/{id} responses carry no `type` and no `main_release`.
assert _discogs_album_kind({'id': 1, 'title': 'X', 'master_id': 42}) == 'release'
def test_tagging():
assert _tag_discogs_album_id('123', 'master') == 'm123'
assert _tag_discogs_album_id('123', 'release') == 'r123'
assert _tag_discogs_album_id('', 'release') == ''
assert _tag_discogs_album_id(None, 'master') == ''
# ---------------------------------------------------------------------------
# Album.from_discogs_release — the single tagging point
# ---------------------------------------------------------------------------
def test_search_result_tagged_as_release():
# search_albums uses type=release; results carry type='release'.
album = Album.from_discogs_release({'id': 999, 'title': 'Radiohead - OK Computer',
'type': 'release'})
assert album.id == 'r999'
def test_discography_master_tagged_as_master():
album = Album.from_discogs_release({'id': 777, 'title': 'OK Computer', 'type': 'master'})
assert album.id == 'm777'
def test_full_master_detail_tagged_as_master():
album = Album.from_discogs_release({'id': 777, 'title': 'OK Computer', 'main_release': 12})
assert album.id == 'm777'
# ---------------------------------------------------------------------------
# Fetch routing — the regression lock on the original bug
# ---------------------------------------------------------------------------
class _FakeCache:
"""Always-miss metadata cache."""
def get_entity(self, *a, **k):
return None
def store_entity(self, *a, **k):
pass
def get_search_results(self, *a, **k):
return None
def store_search_results(self, *a, **k):
pass
def store_entities_bulk(self, *a, **k):
pass
@pytest.fixture
def client(monkeypatch):
monkeypatch.setattr('core.discogs_client.get_metadata_cache', lambda: _FakeCache())
return DiscogsClient(token='test-token')
def _record(client, monkeypatch, responses):
"""Replace _api_get with a recorder that returns `responses[path]`."""
calls = []
def fake_api_get(endpoint, params=None):
calls.append(endpoint)
return responses.get(endpoint)
monkeypatch.setattr(client, '_api_get', fake_api_get)
return calls
def test_get_album_release_id_never_hits_masters(client, monkeypatch):
"""A release-tagged id must NOT touch /masters — that was the bug."""
calls = _record(client, monkeypatch, {
'/releases/249504': {'id': 249504, 'title': 'The Real Album', 'artists': [{'name': 'A'}]},
'/masters/249504': {'id': 249504, 'title': 'A DIFFERENT Album', 'artists': [{'name': 'B'}]},
})
result = client.get_album('r249504', include_tracks=False)
assert calls == ['/releases/249504'] # master endpoint never consulted
assert result['name'] == 'The Real Album'
def test_get_album_master_id_hits_masters_only(client, monkeypatch):
calls = _record(client, monkeypatch, {
'/masters/777': {'id': 777, 'title': 'Master Album', 'main_release': 1},
})
result = client.get_album('m777', include_tracks=False)
assert calls == ['/masters/777']
assert result['name'] == 'Master Album'
def test_legacy_bare_id_release_first(client, monkeypatch):
"""Legacy untagged id resolves as a release without ever hitting /masters
when the release lookup succeeds."""
calls = _record(client, monkeypatch, {
'/releases/249504': {'id': 249504, 'title': 'The Real Album'},
'/masters/249504': {'id': 249504, 'title': 'A DIFFERENT Album'},
})
result = client.get_album('249504', include_tracks=False)
assert calls == ['/releases/249504']
assert result['name'] == 'The Real Album'
def test_legacy_bare_id_falls_back_to_master(client, monkeypatch):
"""If the release lookup yields nothing, the bare id still tries master."""
calls = _record(client, monkeypatch, {
'/releases/777': None,
'/masters/777': {'id': 777, 'title': 'Master Only', 'main_release': 1},
})
result = client.get_album('777', include_tracks=False)
assert calls == ['/releases/777', '/masters/777']
assert result['name'] == 'Master Only'
def test_fetch_and_cache_release_id_never_hits_masters(client, monkeypatch):
calls = _record(client, monkeypatch, {
'/releases/249504': {'id': 249504, 'title': 'The Real Album'},
'/masters/249504': {'id': 249504, 'title': 'A DIFFERENT Album'},
})
data = client._fetch_and_cache_album('r249504')
assert calls == ['/releases/249504']
assert data['title'] == 'The Real Album'

View file

@ -0,0 +1,67 @@
"""noldevin: a dead torrent (metaDL stuck / errored / timed out) was left ORPHANED
in qbit cleared from SoulSync but still active in the client, then re-grabbed as
a duplicate. The monitor's terminal exits now call _cleanup_torrent, which removes
(abandon) or pauses it in the client."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from core.download_plugins.torrent import TorrentDownloadPlugin
class _FakeAdapter:
def __init__(self):
self.removed = []
self.paused = []
async def remove(self, h, delete_files=False):
self.removed.append((h, delete_files))
async def pause(self, h):
self.paused.append(h)
@pytest.fixture
def plugin():
with patch('core.download_plugins.torrent.ProwlarrClient'):
yield TorrentDownloadPlugin()
def test_abandon_removes_torrent_and_deletes_files(plugin):
fake = _FakeAdapter()
with patch('core.download_plugins.torrent.get_active_torrent_adapter', return_value=fake):
plugin._cleanup_torrent('abc123', 'abandon')
assert fake.removed == [('abc123', True)] # removed + partial data deleted
assert fake.paused == []
def test_pause_action_pauses_not_removes(plugin):
fake = _FakeAdapter()
with patch('core.download_plugins.torrent.get_active_torrent_adapter', return_value=fake):
plugin._cleanup_torrent('abc123', 'pause')
assert fake.paused == ['abc123']
assert fake.removed == []
def test_no_hash_is_a_noop(plugin):
fake = _FakeAdapter()
with patch('core.download_plugins.torrent.get_active_torrent_adapter', return_value=fake):
plugin._cleanup_torrent('', 'abandon')
plugin._cleanup_torrent(None, 'abandon')
assert fake.removed == [] and fake.paused == []
def test_no_adapter_is_a_noop(plugin):
with patch('core.download_plugins.torrent.get_active_torrent_adapter', return_value=None):
plugin._cleanup_torrent('abc123', 'abandon') # must not raise
def test_client_error_is_swallowed(plugin):
class _Boom:
async def remove(self, h, delete_files=False):
raise RuntimeError("qbit down")
with patch('core.download_plugins.torrent.get_active_torrent_adapter', return_value=_Boom()):
plugin._cleanup_torrent('abc123', 'abandon') # best-effort: logged, not raised

View file

@ -102,3 +102,38 @@ def test_get_stall_action(raw, expected):
with patch.object(ts, "config_manager",
_cfg({"download_source.torrent_stall_action": raw})):
assert get_stall_action() == expected
# ── metadata-phase noise (noldevin #2: metaDL stuck 11h, stall never fired) ──
def test_metadata_phase_byte_noise_does_not_reset_clock():
"""A magnet stuck 'downloading metadata' reports size==0 and a downloaded
counter that still ticks up from DHT/peer overhead. Those bumps must NOT
reset the stall clock, or the dead magnet never times out (the bug)."""
t = StallTracker(timeout_seconds=600)
assert t.is_stalled(0, "downloading", now=0, size=0) is False # first
assert t.is_stalled(16384, "downloading", now=120, size=0) is False # noise bump
assert t.is_stalled(32768, "downloading", now=300, size=0) is False # more noise
assert t.is_stalled(40000, "downloading", now=480, size=0) is False # still under
# Despite the byte counter climbing the whole time, no metadata was obtained
# → stalled at the timeout.
assert t.is_stalled(50000, "downloading", now=600, size=0) is True
def test_obtaining_metadata_resets_the_clock():
"""size 0 -> >0 means metadata arrived — real progress, reset the clock."""
t = StallTracker(timeout_seconds=600)
assert t.is_stalled(0, "downloading", now=0, size=0) is False
assert t.is_stalled(0, "downloading", now=500, size=0) is False # accruing
# metadata arrives at t=550 (size now known) → progress → clock resets
assert t.is_stalled(0, "downloading", now=550, size=10_000_000) is False
assert t.is_stalled(0, "downloading", now=900, size=10_000_000) is False # <600 since reset
assert t.is_stalled(0, "downloading", now=1150, size=10_000_000) is True # 600 later, no bytes
def test_real_download_progress_tracked_after_metadata():
"""Once metadata is in, byte progress resets the clock as normal."""
t = StallTracker(timeout_seconds=600)
assert t.is_stalled(0, "downloading", now=0, size=10_000_000) is False
assert t.is_stalled(500000, "downloading", now=400, size=10_000_000) is False # progress
assert t.is_stalled(500000, "downloading", now=900, size=10_000_000) is False # <600 since
assert t.is_stalled(500000, "downloading", now=1001, size=10_000_000) is True # stalled

View file

@ -0,0 +1,45 @@
"""#845 follow-up: the mutating verification-review endpoints (delete removes a
file from disk; approve flips verification state) must be admin-only, matching the
Phase 3 destructive-endpoint gating. The read/playback ones stay open."""
from __future__ import annotations
import os
import tempfile
import pytest
_TMP = tempfile.mkdtemp(prefix='soulsync-testdb-vgate-')
os.environ['DATABASE_PATH'] = os.path.join(_TMP, 'v.db')
os.environ['SOULSYNC_TEST_DB_READY'] = '1'
web_server = pytest.importorskip('web_server')
@pytest.fixture
def client():
return web_server.app.test_client()
def _as_nonadmin(client):
pid = web_server.get_database().create_profile(name=f'u_{os.urandom(4).hex()}')
with client.session_transaction() as s:
s['profile_id'] = pid
return pid
def test_delete_is_admin_only(client):
_as_nonadmin(client)
assert client.post('/api/verification/1/delete').status_code == 403
def test_approve_is_admin_only(client):
_as_nonadmin(client)
assert client.post('/api/verification/1/approve').status_code == 403
def test_admin_not_blocked(client):
# default session resolves to admin (profile 1) — must pass the gate (a
# missing history id yields 404, NOT 403).
assert client.post('/api/verification/999999/delete').status_code != 403
assert client.post('/api/verification/999999/approve').status_code != 403

View file

@ -0,0 +1,53 @@
"""#845: the migration backfills verification_status for library_history rows
written before the column existed, from the acoustid_result they recorded. Must
map correctly, never overwrite an existing status, leave no-acoustid rows alone,
and be idempotent (it runs on every fresh DB init).
The backfill runs inside _initialize_database, which is guarded to run once per
path per process so the tests clear that guard to re-trigger it on the seeded
rows (exercising the real production code path, not a copy of the SQL)."""
from __future__ import annotations
import database.music_database as mdb
from database.music_database import MusicDatabase
def _reinit(path):
"""Force _initialize_database (incl. the backfill) to run again on `path`."""
mdb._database_initialized_paths.clear()
return MusicDatabase(path)
def _statuses(db):
with db._get_connection() as conn:
return {r['title']: r['verification_status']
for r in conn.execute("SELECT title, verification_status FROM library_history")}
def test_backfill_maps_and_preserves(tmp_path):
p = str(tmp_path / "m.db")
db = MusicDatabase(p)
db.add_library_history_entry('import', 'A', acoustid_result='pass')
db.add_library_history_entry('import', 'B', acoustid_result='skip')
db.add_library_history_entry('import', 'C', acoustid_result='fail')
db.add_library_history_entry('import', 'D', acoustid_result='pass',
verification_status='human_verified') # pre-set, must NOT change
db.add_library_history_entry('import', 'E') # no acoustid → stays NULL
_reinit(p) # run the backfill migration over the seeded rows
s = _statuses(db)
assert s['A'] == 'verified'
assert s['B'] == 'unverified'
assert s['C'] == 'force_imported'
assert s['D'] == 'human_verified' # existing status preserved (NULL-only)
assert s['E'] is None # no acoustid_result → untouched
def test_backfill_is_idempotent(tmp_path):
p = str(tmp_path / "m2.db")
db = MusicDatabase(p)
db.add_library_history_entry('import', 'A', acoustid_result='pass')
_reinit(p); _reinit(p) # run the migration two more times
assert _statuses(db)['A'] == 'verified'

View file

@ -0,0 +1,50 @@
"""SOULSYNC_VERIFICATION file tag: write + read back (travels with the file,
survives DB resets; the AcoustID scan reads it to refresh the DB column)."""
import shutil
import subprocess
import pytest
from core.tag_writer import read_file_tags, write_verification_status
pytestmark = pytest.mark.skipif(
shutil.which('ffmpeg') is None, reason='ffmpeg required to build test audio'
)
def _make_flac(path):
subprocess.run(
['ffmpeg', '-loglevel', 'error', '-y', '-f', 'lavfi',
'-i', 'anullsrc=r=44100:cl=mono', '-t', '0.1', str(path)],
check=True,
)
def test_flac_verification_tag_roundtrip(tmp_path):
f = tmp_path / 'x.flac'
_make_flac(f)
assert write_verification_status(str(f), 'force_imported') is True
tags = read_file_tags(str(f))
assert tags.get('verification_status') == 'force_imported'
def test_overwrite_existing_status(tmp_path):
f = tmp_path / 'y.flac'
_make_flac(f)
write_verification_status(str(f), 'unverified')
write_verification_status(str(f), 'verified')
assert read_file_tags(str(f)).get('verification_status') == 'verified'
def test_missing_file_returns_false_not_raises(tmp_path):
assert write_verification_status(str(tmp_path / 'nope.flac'), 'verified') is False
def test_db_migration_adds_verification_status_column(tmp_path):
from database.music_database import MusicDatabase
db = MusicDatabase(str(tmp_path / 't.db'))
with db._get_connection() as conn:
cols = [r[1] for r in conn.execute('PRAGMA table_info(tracks)').fetchall()]
assert 'verification_status' in cols

View file

@ -0,0 +1,84 @@
"""#852: the socketio handshake bypasses the HTTP launch-PIN/login gate
(before_request doesn't run for it). The connect handler must enforce the same
check, or removing the overlay + opening a socket streams live data unauthenticated."""
from __future__ import annotations
import os
import tempfile
import pytest
from core.security.ws_gate import is_ws_connection_blocked as blocked
# ── pure gate logic ────────────────────────────────────────────────────────
def _b(**kw):
base = dict(require_login=False, login_authenticated=False,
require_pin=False, pin_verified=False, proxy_authed=False)
base.update(kw)
return blocked(**base)
def test_nothing_on_allows():
assert _b() is False
def test_login_on_unauth_blocks():
assert _b(require_login=True) is True
def test_login_on_authed_allows():
assert _b(require_login=True, login_authenticated=True) is False
def test_pin_on_unverified_blocks():
assert _b(require_pin=True) is True
def test_pin_on_verified_allows():
assert _b(require_pin=True, pin_verified=True) is False
def test_pin_on_proxy_authed_allows():
assert _b(require_pin=True, proxy_authed=True) is False
def test_login_takes_precedence_over_pin():
# login on + unauth -> blocked even if the PIN was verified
assert _b(require_login=True, require_pin=True, pin_verified=True, proxy_authed=True) is True
# ── integration: real socketio connect via the gate ────────────────────────
_TMP = tempfile.mkdtemp(prefix='soulsync-testdb-wsgate-')
os.environ['DATABASE_PATH'] = os.path.join(_TMP, 'w.db')
os.environ['SOULSYNC_TEST_DB_READY'] = '1'
web_server = pytest.importorskip('web_server')
def _pin_on(monkeypatch):
real = web_server.config_manager.get
monkeypatch.setattr(web_server.config_manager, 'get',
lambda k, d=None: True if k == 'security.require_pin_on_launch' else real(k, d))
def test_socket_rejected_when_gate_on_and_unauthenticated(monkeypatch):
_pin_on(monkeypatch)
flask_client = web_server.app.test_client() # no launch_pin_verified
sio = web_server.socketio.test_client(web_server.app, flask_test_client=flask_client)
assert sio.is_connected() is False # the #852 hole, now closed
def test_socket_allowed_when_gate_off():
flask_client = web_server.app.test_client()
sio = web_server.socketio.test_client(web_server.app, flask_test_client=flask_client)
assert sio.is_connected() is True
def test_socket_allowed_when_pin_verified(monkeypatch):
_pin_on(monkeypatch)
flask_client = web_server.app.test_client()
with flask_client.session_transaction() as s:
s['launch_pin_verified'] = True
sio = web_server.socketio.test_client(web_server.app, flask_test_client=flask_client)
assert sio.is_connected() is True

View file

@ -0,0 +1,39 @@
"""#702: cancel/reset/delete of a mirrored-playlist sync whose in-memory state is
gone (restart/eviction) must return success, not 404 'YouTube playlist not found'
otherwise the playlist is permanently wedged."""
from __future__ import annotations
import os
import tempfile
import pytest
_TMP = tempfile.mkdtemp(prefix='soulsync-testdb-ytsync-')
os.environ['DATABASE_PATH'] = os.path.join(_TMP, 'y.db')
os.environ['SOULSYNC_TEST_DB_READY'] = '1'
web_server = pytest.importorskip('web_server')
@pytest.fixture
def client():
return web_server.app.test_client()
_GONE = 'state_wiped_by_restart_hash'
def test_cancel_missing_state_is_success(client):
r = client.post(f'/api/youtube/sync/cancel/{_GONE}')
assert r.status_code == 200 and r.get_json().get('success') is True
def test_reset_missing_state_is_success(client):
r = client.post(f'/api/youtube/reset/{_GONE}')
assert r.status_code == 200 and r.get_json().get('success') is True
def test_delete_missing_state_is_success(client):
r = client.delete(f'/api/youtube/delete/{_GONE}')
assert r.status_code == 200 and r.get_json().get('success') is True

View file

@ -40,7 +40,7 @@ logger = setup_logging(_log_level, _log_path)
# App version — single source of truth for backup metadata, system-info, update check, etc.
# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release.
_SOULSYNC_BASE_VERSION = "2.7.0"
_SOULSYNC_BASE_VERSION = "2.7.1"
def _build_version_string():
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
@ -7578,6 +7578,385 @@ def stream_quarantine_item(entry_id):
return jsonify({"error": str(e)}), 500
def _get_library_history_row(history_id):
"""Fetch one full library_history row as a dict (or None)."""
conn = get_database()._get_connection()
cursor = conn.cursor()
cursor.execute("SELECT * FROM library_history WHERE id = ?", (history_id,))
row = cursor.fetchone()
return dict(row) if row else None
def _resolve_history_audio_path(row):
"""Resolve a library_history row to a playable on-disk file.
The recorded path can go stale: Dockerhost prefix differences, or the
media server / organizer renaming files with exotic titles (e.g.
Titan) after import. Fallback chain:
1. the recorded path as-is,
2. `_resolve_library_file_path` (transfer/download/library prefix swap),
3. the tracks table the media-server mirror knows the CURRENT path for
this title+artist even after a rename resolved the same way.
"""
def _lookup_titled_paths(title):
# tracks-table mirror: paths for this title (knows the CURRENT path
# after a media-server rename). [] on any DB error → resolver returns None.
try:
conn = get_database()._get_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT file_path FROM tracks WHERE file_path IS NOT NULL AND LOWER(title) = LOWER(?)",
(title,))
return [r[0] for r in cursor.fetchall() if r[0]]
except Exception as e:
logger.debug(f"[Verification] tracks-table path fallback failed: {e}")
return []
from core.matching.history_paths import resolve_history_audio_path
return resolve_history_audio_path(
row,
exists=os.path.exists,
resolve_library_path=_resolve_library_file_path,
lookup_titled_paths=_lookup_titled_paths,
)
@app.route('/api/verification/<int:history_id>/stream', methods=['GET'])
def stream_verification_item(history_id):
"""Stream a completed download for the verification review queue (listen
before approving). Path comes ONLY from the history row no client paths."""
try:
row = _get_library_history_row(history_id)
if not row:
return jsonify({"error": "History entry not found"}), 404
file_path = _resolve_history_audio_path(row)
if not file_path:
return jsonify({"error": "File not found on disk"}), 404
# _AUDIO_MIME_TYPES keys keep the dot ('.flac') — don't strip it, or
# everything falls back to audio/mpeg and FLAC playback breaks.
ext = os.path.splitext(file_path)[1].lower()
mimetype = _AUDIO_MIME_TYPES.get(ext, 'audio/mpeg')
return _serve_audio_file_with_range(file_path, mimetype_override=mimetype)
except Exception as e:
logger.error(f"[Verification] Error streaming history {history_id}: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/verification/<int:history_id>/entry', methods=['GET'])
def get_verification_entry(history_id):
"""Full library_history row for one review-queue item — feeds the Audit
Trail modal when opened from the Downloads page (where the history-page
entry cache is not populated)."""
try:
row = _get_library_history_row(history_id)
if not row:
return jsonify({"success": False, "error": "History entry not found"}), 404
return jsonify({"success": True, "entry": row})
except Exception as e:
logger.error(f"[Verification] Entry fetch failed for {history_id}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/verification/config', methods=['GET'])
def get_verification_config():
"""Whether AcoustID/download-verification is enabled — if not, the review
queue collapses to quarantine-only in the UI."""
try:
enabled = bool(config_manager.get('acoustid.enabled', False))
return jsonify({"success": True, "acoustid_enabled": enabled})
except Exception as e:
return jsonify({"success": True, "acoustid_enabled": True, "error": str(e)})
def _audio_file_duration_ms(path):
"""Best-effort duration of an on-disk audio file (0 when unreadable).
mutagen detects the format from content, so this also works for
quarantined files whose extension was swapped to `.quarantined`."""
try:
import mutagen
mf = mutagen.File(path)
if mf and mf.info and getattr(mf.info, 'length', 0):
return int(mf.info.length * 1000)
except Exception: # noqa: S110 — duration probe is best-effort; fall through to 0
pass
return 0
def _set_review_play_session(file_path, title, artist, album, mimetype=None):
"""Point THIS listener's media-player session at a local file — same
mechanism as /api/library/play, so the bottom player UI drives playback
(seek/stop/volume) instead of an invisible Audio element."""
sess = _current_stream_state()
with sess.lock:
sess.update({
"status": "ready",
"progress": 100,
"track_info": {
"title": title or os.path.basename(file_path),
"artist": artist or 'Unknown Artist',
"album": album or '',
},
"file_path": file_path,
"stream_url": None,
"error_message": None,
"is_library": True,
# Content-Type hint for /stream/audio — needed for quarantined
# files whose on-disk extension is `.quarantined`. Keyed to the
# exact path so a stale hint can never leak onto another file.
"mimetype_override": mimetype,
"mimetype_override_path": file_path if mimetype else None,
})
@app.route('/api/verification/<int:history_id>/play', methods=['POST'])
def play_verification_item(history_id):
"""Load the downloaded file into the media player (review queue ▶)."""
try:
row = _get_library_history_row(history_id)
if not row:
return jsonify({"success": False, "error": "History entry not found"}), 404
file_path = _resolve_history_audio_path(row)
if not file_path:
return jsonify({"success": False, "error": "File not found on disk"}), 404
_set_review_play_session(
file_path, row.get('title'), row.get('artist_name'), row.get('album_name'))
return jsonify({"success": True, "track_info": {
"title": row.get('title') or os.path.basename(file_path),
"artist": row.get('artist_name') or '',
"album": row.get('album_name') or '',
"image_url": row.get('thumb_url') or None,
}})
except Exception as e:
logger.error(f"[Verification] Play failed for {history_id}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/verification/<int:history_id>/compare-stream', methods=['POST'])
def compare_stream_verification_item(history_id):
"""Find the expected track on Soulseek/streaming sources for an A/B
comparison the SAME pipeline as the /search page play button, but fed
server-side so the local file's duration guides candidate ranking (a
missing duration lets e.g. 10-hour YouTube loops win and time out)."""
try:
row = _get_library_history_row(history_id)
if not row:
return jsonify({"success": False, "error": "History entry not found"}), 404
local = _resolve_history_audio_path(row)
duration_ms = _audio_file_duration_ms(local) if local else 0
result = _search_stream.stream_search_track(
track_name=row.get('title') or '',
artist_name=row.get('artist_name') or '',
album_name=row.get('album_name') or '',
duration_ms=duration_ms,
config_manager=config_manager,
download_orchestrator=download_orchestrator,
matching_engine=matching_engine,
run_async=run_async,
)
if result is None:
return jsonify({"success": False,
"error": "No suitable stream candidate found"}), 404
result['title'] = row.get('title') or ''
result['artist'] = row.get('artist_name') or ''
result['album'] = row.get('album_name') or ''
result['image_url'] = row.get('thumb_url') or None
return jsonify({"success": True, "result": result})
except Exception as e:
logger.error(f"[Verification] Compare-stream failed for {history_id}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
def _get_quarantine_entry(entry_id):
from core.imports.quarantine import list_quarantine_entries
for entry in list_quarantine_entries(_get_quarantine_dir()):
if entry.get('id') == entry_id:
return entry
return None
@app.route('/api/quarantine/<entry_id>/play', methods=['POST'])
def play_quarantine_item(entry_id):
"""Load a quarantined file into the media player (review queue ▶)."""
try:
from core.imports.quarantine import get_quarantine_entry_stream_info
info = get_quarantine_entry_stream_info(_get_quarantine_dir(), entry_id)
if info is None:
return jsonify({"success": False, "error": "Quarantined file not found"}), 404
file_path, extension = info
entry = _get_quarantine_entry(entry_id) or {}
title = entry.get('expected_track') or entry.get('original_filename') or os.path.basename(file_path)
_set_review_play_session(
file_path, f"{title} (quarantined)", entry.get('expected_artist'), '',
mimetype=_AUDIO_MIME_TYPES.get(extension, 'audio/mpeg'))
return jsonify({"success": True, "track_info": {
"title": f"{title} (quarantined)",
"artist": entry.get('expected_artist') or '',
"album": '',
}})
except Exception as e:
logger.error(f"[Quarantine] Play failed for {entry_id}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/quarantine/<entry_id>/compare-stream', methods=['POST'])
def compare_stream_quarantine_item(entry_id):
"""Stream-search the EXPECTED track for a quarantined file (A/B compare),
using the quarantined file's duration to guide candidate ranking."""
try:
from core.imports.quarantine import get_quarantine_entry_stream_info
entry = _get_quarantine_entry(entry_id)
if not entry:
return jsonify({"success": False, "error": "Quarantine entry not found"}), 404
track_name = entry.get('expected_track') or ''
artist_name = entry.get('expected_artist') or ''
if not track_name:
return jsonify({"success": False,
"error": "Entry has no expected-track metadata"}), 400
info = get_quarantine_entry_stream_info(_get_quarantine_dir(), entry_id)
duration_ms = _audio_file_duration_ms(info[0]) if info else 0
result = _search_stream.stream_search_track(
track_name=track_name,
artist_name=artist_name,
album_name='',
duration_ms=duration_ms,
config_manager=config_manager,
download_orchestrator=download_orchestrator,
matching_engine=matching_engine,
run_async=run_async,
)
if result is None:
return jsonify({"success": False,
"error": "No suitable stream candidate found"}), 404
result['title'] = track_name
result['artist'] = artist_name
return jsonify({"success": True, "result": result})
except Exception as e:
logger.error(f"[Quarantine] Compare-stream failed for {entry_id}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/quarantine/<entry_id>/entry', methods=['GET'])
def get_quarantine_audit_entry(entry_id):
"""Synthesize a library_history-shaped entry from a quarantine sidecar so
the review queue opens the SAME Audit Trail modal for quarantined files
(they were never imported, so no history row exists). ``id`` is None on
purpose the modal fetches tags/lyrics through ``_file_tags_url``."""
try:
from core.imports.quarantine import (
get_quarantine_entry_context, get_quarantine_entry_stream_info)
entry = _get_quarantine_entry(entry_id)
if not entry:
return jsonify({"success": False, "error": "Quarantine entry not found"}), 404
ctx = get_quarantine_entry_context(_get_quarantine_dir(), entry_id)
info = get_quarantine_entry_stream_info(_get_quarantine_dir(), entry_id)
osr = ctx.get('original_search_result') if isinstance(ctx.get('original_search_result'), dict) else {}
username = (osr.get('username') or '') if isinstance(osr, dict) else ''
streaming = ('tidal', 'youtube', 'qobuz', 'hifi', 'deezer_dl',
'lidarr', 'soundcloud', 'amazon')
ti = ctx.get('track_info') if isinstance(ctx.get('track_info'), dict) else {}
album_raw = ti.get('album', '')
album_name = album_raw.get('name', '') if isinstance(album_raw, dict) else str(album_raw or '')
synthetic = {
'id': None,
'event_type': 'download',
'title': entry.get('expected_track') or entry.get('original_filename') or '',
'artist_name': entry.get('expected_artist') or '',
'album_name': album_name,
'created_at': entry.get('timestamp') or '',
'thumb_url': entry.get('thumb_url') or '',
'file_path': info[0] if info else '',
'quality': ctx.get('_audio_quality') or '',
'download_source': username if username in streaming else ('soulseek' if username else ''),
'source_filename': entry.get('source_filename') or '',
'source_artist': (osr.get('artist') or '') if isinstance(osr, dict) else '',
'source_track_title': (osr.get('title') or osr.get('name') or '') if isinstance(osr, dict) else '',
'acoustid_result': 'fail' if entry.get('trigger') == 'acoustid' else None,
'verification_status': None,
'_quarantined': True,
'_quarantine_reason': entry.get('reason') or '',
'_file_tags_url': f"/api/quarantine/{entry_id}/file-tags",
}
return jsonify({"success": True, "entry": synthetic})
except Exception as e:
logger.error(f"[Quarantine] Audit entry failed for {entry_id}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/quarantine/<entry_id>/file-tags', methods=['GET'])
def get_quarantine_file_tags(entry_id):
"""Embedded tags of a quarantined file — feeds the Audit modal's Tags /
Lyrics tabs. mutagen detects the format from content, so the swapped
`.quarantined` extension is no obstacle."""
try:
from core.imports.quarantine import get_quarantine_entry_stream_info
from core.library.file_tags import read_embedded_tags
info = get_quarantine_entry_stream_info(_get_quarantine_dir(), entry_id)
if info is None:
return jsonify({'success': False, 'error': 'Quarantined file not found'}), 404
result = read_embedded_tags(info[0])
return jsonify({'success': True, **result})
except Exception as e:
logger.error(f"[Quarantine] File-tags failed for {entry_id}: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/verification/<int:history_id>/approve', methods=['POST'])
@admin_only
def approve_verification_item(history_id):
"""User confirmed the file IS the right track: set human_verified on the
history row, the file tag, and (best-effort) the tracks row. The AcoustID
scanner skips human-verified files entirely. Admin-only: mutates shared
library/verification state."""
try:
from core.matching.verification_status import HUMAN_VERIFIED
from core.tag_writer import write_verification_status
db = get_database()
row = _get_library_history_row(history_id)
if not row:
return jsonify({"success": False, "error": "History entry not found"}), 404
file_path = row.get('file_path') or ''
on_disk = _resolve_history_audio_path(row)
with db._get_connection() as conn:
conn.execute(
"UPDATE library_history SET verification_status = ? WHERE id = ?",
(HUMAN_VERIFIED, history_id))
# The tracks row may carry either the recorded or the resolved path.
for p in {p for p in (file_path, on_disk) if p}:
conn.execute(
"UPDATE tracks SET verification_status = ? WHERE file_path = ?",
(HUMAN_VERIFIED, p))
conn.commit()
tag_written = bool(on_disk) and write_verification_status(on_disk, HUMAN_VERIFIED)
return jsonify({"success": True, "tag_written": tag_written})
except Exception as e:
logger.error(f"[Verification] Approve failed for {history_id}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/verification/<int:history_id>/delete', methods=['POST'])
@admin_only
def delete_verification_item(history_id):
"""User decided the file is wrong: delete it from disk and drop the
history row (the media-server mirror cleans the tracks row on next scan).
Admin-only: it removes a file from disk + the library."""
try:
db = get_database()
row = _get_library_history_row(history_id)
if not row:
return jsonify({"success": False, "error": "History entry not found"}), 404
on_disk = _resolve_history_audio_path(row)
file_deleted = False
if on_disk and os.path.exists(on_disk):
os.remove(on_disk)
file_deleted = True
logger.info(f"[Verification] Deleted rejected file: {on_disk}")
db.delete_library_history_rows([history_id])
return jsonify({"success": True, "file_deleted": file_deleted})
except Exception as e:
logger.error(f"[Verification] Delete failed for {history_id}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/quarantine/<entry_id>/recover', methods=['POST'])
def recover_quarantine_item(entry_id):
"""Fallback for legacy thin sidecars: move file into Staging so the user
@ -12801,6 +13180,11 @@ def stream_audio():
stream_url = sess.get("stream_url")
if not file_path and not stream_url:
return jsonify({"error": "No audio file ready for streaming"}), 404
# Content-Type hint set by the quarantine player (on-disk extension
# is `.quarantined`). Path-keyed so it can't leak onto other files.
mimetype_override = (sess.get("mimetype_override")
if sess.get("mimetype_override_path") == file_path
else None)
# Library track played via the media server's stream API (#809).
if stream_url:
@ -12808,7 +13192,7 @@ def stream_audio():
return _proxy_stream_url_with_range(stream_url)
logger.info(f"Serving audio file: {os.path.basename(file_path)}")
return _serve_audio_file_with_range(file_path)
return _serve_audio_file_with_range(file_path, mimetype_override=mimetype_override)
except Exception as e:
logger.error(f"Error serving audio file: {e}")
return jsonify({"error": str(e)}), 500
@ -24278,7 +24662,10 @@ def reset_youtube_playlist(url_hash):
"""Reset YouTube playlist to fresh phase (clear discovery/sync data)"""
try:
if url_hash not in youtube_playlist_states:
return jsonify({"error": "YouTube playlist not found"}), 404
# Idempotent: live state gone (restart/eviction) — already "fresh".
# 404 here permanently wedges a mirrored playlist whose state vanished
# (#702); treat a reset of nothing as a success so the UI recovers.
return jsonify({"success": True, "message": "Playlist already reset"})
state = youtube_playlist_states[url_hash]
@ -24310,7 +24697,9 @@ def delete_youtube_playlist(url_hash):
"""Remove YouTube playlist from backend storage entirely"""
try:
if url_hash not in youtube_playlist_states:
return jsonify({"error": "YouTube playlist not found"}), 404
# Idempotent: already gone (restart/eviction) — deleting nothing is a
# success, not a 404 that wedges the UI (#702).
return jsonify({"success": True, "message": "Playlist already removed"})
state = youtube_playlist_states[url_hash]
@ -29638,11 +30027,15 @@ def _fetch_liked_albums(profile_id: int):
if discogs_cl.is_authenticated():
logger.info("[Your Albums] Fetching collection from Discogs...")
releases = discogs_cl.get_user_collection()
from core.discogs_client import _tag_discogs_album_id
for r in releases:
database.upsert_liked_album(
album_name=r['album_name'], artist_name=r['artist_name'],
source_service='discogs',
source_id=str(r['release_id']), source_id_type='discogs',
# Collection items are always releases — store the ID tagged
# ('r<id>') to match search/discography (#848), so every stored
# Discogs album ID is uniform and re-fetches route correctly.
source_id=_tag_discogs_album_id(r['release_id'], 'release'), source_id_type='discogs',
image_url=r.get('image_url'), release_date=r.get('release_date', ''),
total_tracks=r.get('total_tracks', 0), profile_id=profile_id
)
@ -36107,8 +36500,39 @@ def _emit_download_status_loop():
# --- Socket.IO event handlers ---
def _ws_connection_blocked():
"""#852: mirror the HTTP launch-PIN / login gate for the socketio handshake
(before_request doesn't run for it). Fails OPEN on a config-read error, same
as the HTTP gate, so a broken config never wedges every client."""
try:
require_login = _require_login_enabled()
require_pin = bool(config_manager.get('security.require_pin_on_launch', False)) if config_manager else False
if not require_login and not require_pin:
return False
proxy_header = (config_manager.get('security.auth_proxy_header', '') or '') if config_manager else ''
from core.security.auth_proxy import trusted_proxy_user
proxy_authed = bool(trusted_proxy_user(request.headers.get, proxy_header))
from core.security.ws_gate import is_ws_connection_blocked
return is_ws_connection_blocked(
require_login=require_login,
login_authenticated=bool(session.get('login_authenticated', False)),
require_pin=require_pin,
pin_verified=bool(session.get('launch_pin_verified', False)),
proxy_authed=proxy_authed,
)
except Exception as e:
logger.error(f"[WS gate] check failed, allowing (matches HTTP gate fail-open): {e}")
return False
@socketio.on('connect')
def handle_connect():
# #852: the launch-PIN / login gate is a before_request hook, which does NOT
# run for the socketio handshake. Without this, removing the client overlay +
# opening a socket streams live data (downloads/logs/dashboard) unauthenticated.
if _ws_connection_blocked():
logger.warning("Rejected WebSocket connection — access gate active, session not verified (#852)")
return False
logger.info("WebSocket client connected")
@socketio.on('disconnect')

View file

@ -2427,6 +2427,7 @@
<button class="adl-pill" data-filter="queued" onclick="adlSetFilter('queued')">Queued</button>
<button class="adl-pill" data-filter="completed" onclick="adlSetFilter('completed')">Completed</button>
<button class="adl-pill" data-filter="failed" onclick="adlSetFilter('failed')">Failed</button>
<button class="adl-pill" data-filter="unverified" onclick="adlSetFilter('unverified')" title="Review queue: imported-but-unconfirmed downloads (unverified / force-imported) and quarantined files that were never imported.">⚠ Unverified/Quarantine</button>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<span class="adl-count" id="adl-count"></span>
@ -5565,8 +5566,9 @@
<option value=", ">, (comma)</option>
<option value="; ">; (semicolon)</option>
<option value=" / ">/ (slash)</option>
<option value=" & ">&amp; (ampersand)</option>
</select>
<small class="settings-hint">Separator between multiple artists in the ARTIST tag</small>
<small class="settings-hint">Separator between multiple artists in the ARTIST tag (e.g. <code>&amp;</code> matches MusicBrainz/Picard style)</small>
</div>
<div class="form-group">
<label class="checkbox-label">
@ -6320,6 +6322,22 @@
(e.g. MP3), it will be replaced with the higher quality version (e.g. FLAC).
If disabled, existing tracks are always kept and the import file is skipped.
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="import-folder-artist-override">
Use the top Staging folder as the artist
</label>
</div>
<div class="help-text">
On by default (legacy behaviour). When enabled, files staged as
<code>Artist/Album/…</code> (or <code>Artist/Albums/Album/…</code>) take the top
folder as the album artist — handy for mixtapes/compilations whose embedded tags
carry DJ names. <strong>Turn this off</strong> if you drop a mixed pile of songs
under one container folder: otherwise every file's artist gets overwritten with
that folder's name (the cause of the "soulsync" mass-mislabel). With it off, the
metadata-identified artist is always kept.
</div>
</div>

View file

@ -3676,10 +3676,36 @@ function processModalStatusUpdate(playlistId, data) {
} else {
switch (task.status) {
case 'pending': statusText = '⏸️ Pending'; break;
case 'searching': statusText = '🔍 Searching...'; break;
case 'downloading': statusText = `⏬ Downloading... ${Math.round(task.progress || 0)}%`; break;
case 'searching':
statusText = '🔍 Searching...';
// Quarantine-retry engine: show which attempt we're on
// ("retry 2/5") while it walks the next-best candidates.
if (task.retry_info) statusText += ` 🔁 retry ${task.retry_info}`;
break;
case 'downloading':
statusText = `⏬ Downloading... ${Math.round(task.progress || 0)}%`;
if (task.retry_info) statusText += ` 🔁 retry ${task.retry_info}`;
break;
case 'post_processing': statusText = '⌛ Processing...'; break;
case 'completed': statusText = '✅ Completed'; completedCount++; break;
case 'completed': {
statusText = '✅ Completed';
// Verification badge — how this file passed verification:
// verified = clean AcoustID pass; unverified = couldn't be
// hard-confirmed (cross-script/ambiguous/no fingerprint match);
// force_imported = accepted as best candidate after the retry
// budget was exhausted (version-mismatch fallback).
if (task.verification_status === 'force_imported') {
statusText += ' <span class="verif-badge verif-force" title="Force-imported: accepted as best available candidate after repeated mismatches (version-mismatch fallback). A library AcoustID scan reports these as informational.">⚑</span>';
} else if (task.verification_status === 'unverified') {
statusText += ' <span class="verif-badge verif-unverified" title="Imported but not hard-verified (AcoustID could not confirm — e.g. cross-script metadata or no fingerprint match).">⚠</span>';
} else if (task.verification_status === 'verified') {
statusText += ' <span class="verif-badge verif-ok" title="AcoustID verified: audio fingerprint matches the expected track.">✔</span>';
} else if (task.verification_status === 'human_verified') {
statusText += ' <span class="verif-badge verif-human" title="Human verified: you confirmed this file is the right track.">🛡✔</span>';
}
completedCount++;
break;
}
case 'not_found': statusText = '🔇 Not Found'; notFoundCount++; break;
case 'failed': {
// Distinguish quarantine outcomes from generic
@ -3712,7 +3738,14 @@ function processModalStatusUpdate(playlistId, data) {
delete statusEl.dataset.quarantineReason;
delete statusEl.dataset.quarantineTrack;
delete statusEl.dataset.detailOpen;
statusEl.textContent = statusText;
// statusText is static markup only; the verif-badge span is the
// one case that needs HTML. Everything else stays textContent
// (XSS-safe default).
if (statusText.includes('class="verif-badge')) {
statusEl.innerHTML = statusText;
} else {
statusEl.textContent = statusText;
}
// Visual-only hooks: the cell carries its state for the badge
// styling, the row glows while a track is actively working.
statusEl.dataset.state = isQuarantinedTask ? 'quarantined'

View file

@ -3583,6 +3583,9 @@ function _promptAcoustidAction() {
<button id="_acid-retag" style="padding:10px 20px;border-radius:10px;border:1px solid rgba(102,126,234,0.4);background:rgba(102,126,234,0.15);color:#667eea;font-weight:600;cursor:pointer;font-family:inherit;">
Retag
</button>
<button id="_acid-relocate" style="padding:10px 20px;border-radius:10px;border:1px solid rgba(245,158,11,0.4);background:rgba(245,158,11,0.12);color:#f59e0b;font-weight:600;cursor:pointer;font-family:inherit;">
Relocate
</button>
<button id="_acid-redownload" style="padding:10px 20px;border-radius:10px;border:1px solid rgba(29,185,84,0.4);background:rgba(29,185,84,0.15);color:#1db954;font-weight:600;cursor:pointer;font-family:inherit;">
Re-download
</button>
@ -3591,7 +3594,7 @@ function _promptAcoustidAction() {
</button>
</div>
<div style="margin-top:12px;font-size:0.78em;color:rgba(255,255,255,0.35);line-height:1.4;">
Retag = update metadata to match actual audio &bull; Re-download = add correct track to wishlist &amp; delete wrong file &bull; Delete = remove file and DB entry
Retag = update metadata in place &bull; Relocate = retag + move to Staging so it's re-imported into the correct artist/album &bull; Re-download = add correct track to wishlist &amp; delete wrong file &bull; Delete = remove file and DB entry
</div>
<button id="_acid-cancel" style="margin-top:12px;padding:6px 16px;border:none;background:none;color:rgba(255,255,255,0.4);cursor:pointer;font-size:0.82em;font-family:inherit;">
Cancel
@ -3601,6 +3604,7 @@ function _promptAcoustidAction() {
document.body.appendChild(overlay);
overlay.querySelector('#_acid-retag').onclick = () => { overlay.remove(); resolve('retag'); };
overlay.querySelector('#_acid-relocate').onclick = () => { overlay.remove(); resolve('relocate'); };
overlay.querySelector('#_acid-redownload').onclick = () => { overlay.remove(); resolve('redownload'); };
overlay.querySelector('#_acid-delete').onclick = () => { overlay.remove(); resolve('delete'); };
overlay.querySelector('#_acid-cancel').onclick = () => { overlay.remove(); resolve(null); };

View file

@ -3415,18 +3415,16 @@ 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.0': [
{ date: 'June 2026 — 2.7.0 release' },
{ title: 'Connect your own streaming accounts — My Accounts', desc: 'multi-user gets real. the new My Accounts panel (the music button by your profile) lets anyone connect their own spotify / tidal / listenbrainz, and from then on your playlist browsing and pulling uses your account, not the admin\'s. metadata + downloads stay on the admin\'s global accounts so background work is unchanged — only playlist reads are personal, and two people can browse their own playlists at the same time. admin and anyone who hasn\'t connected fall back to the global accounts exactly as before.', page: 'settings' },
{ title: 'Auto-sync runs as you', desc: 'background automations now run as their owner profile — a non-admin\'s scheduled auto-sync pulls their playlist from their account instead of falling back to the admin\'s. admin pipelines are untouched.', page: 'sync' },
{ title: 'Quick-switch active sources (admin)', desc: 'the sidebar Service Status panel is now clickable (admin only) — switch the active metadata source / media server / download source from one modal, with brand logos and real hybrid drag. the Manage Profiles modal got a visual revamp too.', page: 'dashboard' },
{ title: 'User accounts & login (opt-in)', desc: 'turn on "require login" in Settings → Security and each profile becomes a real account: a username + password sign-in screen replaces the picker + PIN. passwords are hashed and never sent to the browser, brute-force limited, with anti-lockout (you can\'t enable it until the admin has a password). off by default — your existing PIN / LAN setup is untouched.', page: 'settings' },
{ title: 'Forgot-password recovery', desc: 'set a security question + answer; if you forget your login password you answer it on the sign-in screen to reset. the answer is hashed and matched forgivingly.', page: 'settings' },
{ title: 'Run securely behind a reverse proxy', desc: 'opt-in reverse-proxy mode trusts X-Forwarded-* (correct client IP + https), marks the session cookie secure, and adds security headers — for running behind nginx / caddy / traefik. off by default so direct http:// LAN access is untouched. a full setup guide ships in Support/REVERSE-PROXY.md.', page: 'settings' },
{ title: 'Auth-proxy + PIN hardening', desc: 'optionally let authelia / authentik / oauth2-proxy be the gatekeeper (trust a Remote-User header), and the launch PIN now backs off after repeated wrong tries. the whole Security settings page was reorganized into clear PIN / user-accounts / reverse-proxy groups with step-by-step setup.', page: 'settings' },
{ title: 'Fixes', desc: 'a "/" in a song title no longer truncates the search and quarantines youtube/tidal downloads (#835); a rejected slskd download no longer hangs forever (#836); manual find & add appends to a jellyfin/emby playlist instead of recreating it (#837); auto-sync no longer caps public spotify playlists at 100 tracks (#838); "discovery state not found" after a restart is fixed (#843); and find & add search now puts exact title matches first instead of burying them.', page: 'downloads' },
{ title: 'Artist Sync is now a mini deep scan', desc: 'the sync button on an artist page reuses the same server-diff stale-removal as the whole-library deep scan, scoped to one artist — picking up new/changed tracks and removing ones the server no longer has, with the same safety guards (no more disk-check mass-deletes on an unreachable mount).', page: 'library' },
{ title: 'Earlier versions', desc: 'before 2.7.0, the 2.6.x cycle brought the artist/track/album blocklist, the download-retry overhaul, Download Origins + retention cleanup, server-side launch-PIN enforcement + secret masking, Spotify-no-auth metadata, Import IDs from File Tags, Library Re-tag, and a large pile of import / library / watchlist fixes.' },
'2.7.1': [
{ date: 'June 2026 — 2.7.1 release' },
{ title: 'Download verification & review', desc: 'every download is fingerprint-checked (acoustid) against what you actually asked for, and the result sticks — a verified / unverified badge on the Downloads completed list, persisted to the db, a SOULSYNC_VERIFICATION file tag, and history. import + scan now share one verification core so they stop disagreeing about the same file.', page: 'active-downloads' },
{ title: 'Unverified review queue', desc: 'questionable downloads land in a review queue where you can listen, compare, approve, or delete them — with visible retry progress. approve/delete are admin-only.', page: 'active-downloads' },
{ title: 'Security: login bypass closed (#852)', desc: 'hiding the login/PIN overlay (safari "hide distracting items", devtools) could still stream live data over the websocket — the socket connection now enforces the same login/PIN gate the rest of the app does. every other surface was audited too; the socket was the one hole.', page: 'settings' },
{ title: 'AcoustID "Relocate" fix action (#704)', desc: 'when an acoustid scan flags a wrong song, retag alone left the file in the wrong artist/album folder. the new Relocate option retags it and moves it to staging so auto-import re-files it under the correct artist/album.', page: 'tools' },
{ title: 'Faster artist pages (#853)', desc: 'deezer / itunes / discogs now cache the artist→album list like spotify already did, so reopening an artist stops refetching the entire discography. thanks ramonskie!', page: 'library' },
{ title: 'Fixes', desc: 'listenbrainz weekly playlists can no longer wedge in an unrecoverable sync state — cancel now clears it so you can re-sync (#702); magnets stuck "downloading metadata" actually hit the stall timeout now, and dead torrents are cleaned out of qbittorrent instead of orphaned + re-grabbed as duplicates; a "/" or ":" in a title matches sources that use "_" (#851); "&" is available as an artist tag separator (#840); search auto-selects spotify when "spotify (no auth)" is the active source.', page: 'active-downloads' },
{ title: 'Contributor fixes', desc: 'opt-in import-folder artist override (#845), discogs master/release ID collision fetching the wrong album (#848), and the cover-art cache no longer hard-fails on first load (#850). thanks nick2000713 + RollingBase!' },
{ title: 'Earlier versions', desc: '2.7.0 brought multi-user for real — per-profile streaming accounts (My Accounts), auto-sync running as its owner, opt-in username/password login with recovery, reverse-proxy support, and admin quick-switch for active sources. before that, the 2.6.x cycle brought the blocklist, the download-retry overhaul, Download Origins, server-side launch-PIN enforcement, Spotify-no-auth metadata, Library Re-tag, and a large pile of import / library / watchlist fixes.' },
],
};
@ -3457,55 +3455,38 @@ const WHATS_NEW = {
// usage_note?: 'optional hint shown at the bottom' }
const VERSION_MODAL_SECTIONS = [
{
title: "Per-profile streaming accounts",
description: "multi-user gets real. each profile can connect its own spotify / tidal / listenbrainz from the new My Accounts panel (the music button by your profile), and from then on your playlist browsing and pulling uses your account instead of the admin\'s.",
title: "Download verification & review",
description: "every download is fingerprint-checked (acoustid) against what you actually asked for, and the result sticks. import + scan now share one verification core, so the two stop disagreeing about the same file.",
features: [
"connect your own spotify / tidal / listenbrainz — playlist reads then use your account",
"metadata + downloads stay on the admin\'s global accounts, so background work is unchanged",
"two people browse their own playlists at the same time without stepping on each other",
"background auto-sync runs as its owner profile, pulling their playlist from their account",
"admin + anyone who hasn\'t connected fall back to the global accounts exactly as before",
"verified / unverified badge on the Downloads completed list",
"status persisted to the db, a SOULSYNC_VERIFICATION file tag, and history",
"Unverified review queue: listen, compare, approve, or delete questionable downloads (approve/delete are admin-only)",
"visible retry progress while a questionable download re-fetches",
],
usage_note: "the music (My Accounts) button by your profile",
usage_note: "Downloads page → the Unverified filter",
},
{
title: "Secure access — login, recovery & reverse proxy (opt-in)",
description: "SoulSync can now stand on its own as a secured app, or sit cleanly behind a reverse proxy. all of it is off by default, so a normal LAN setup behaves exactly as before and the launch PIN keeps working untouched.",
features: [
"native login: each profile becomes a real username + password account; the sign-in screen replaces the picker + PIN",
"passwords hashed + never sent to the browser, brute-force limited, anti-lockout (can\'t enable until the admin has a password)",
"forgot-password recovery via a security question you set",
"reverse-proxy mode: trusts X-Forwarded-*, secure cookies, security headers — for nginx / caddy / traefik (see Support/REVERSE-PROXY.md)",
"auth-proxy trust (authelia / authentik / oauth2-proxy) + launch-PIN brute-force backoff",
],
usage_note: "Settings → Security",
},
{
title: "Quick-switch active sources (admin)",
description: "the sidebar Service Status panel is now clickable — switch the active metadata source, media server, and download source from one modal, with brand logos, a hero header, and real hybrid drag. it surfaces the configured-vs-effective source so a no-auth source is never confused with the real one.",
features: [
"switch metadata / media-server / download source in one place",
"real drag-to-reorder for hybrid source priority",
"Manage Profiles modal got a visual revamp too",
],
usage_note: "click the sidebar Service Status panel",
title: "Security fix — login bypass closed (#852)",
description: "hiding the login/PIN overlay (safari \"hide distracting items\", devtools, curl) could still stream live data over the websocket, because the gate was http-only. the socket connection now enforces the same login/PIN check the rest of the app does — every other surface was audited too; the socket was the one hole. covers both the launch PIN and the native login.",
features: [],
},
{
title: "Fixes this release",
description: "a round of bug fixes alongside the big features.",
description: "a stack of issue fixes on top of 2.7.0.",
features: [
"#835 — a \"/\" in a song title (e.g. Sawano\'s YouSeeBIGGIRL/T:T) no longer truncates the search + quarantines youtube/tidal downloads",
"#836 — a rejected slskd download no longer hangs at downloading forever",
"#837 — manual find & add appends to a jellyfin/emby playlist instead of recreating it",
"#838 — auto-sync no longer caps public spotify playlists at 100 tracks",
"#843 — discovery-state-not-found after a restart/import is fixed",
"find & add search puts exact title matches first instead of burying them under a case-sensitive sort",
"artist Sync is now a true single-artist deep scan (server-diff stale removal, no disk-check mass-deletes)",
"#704 — new acoustid Relocate action: retags a wrong song AND moves it to staging so auto-import re-files it under the correct artist/album",
"#702 — listenbrainz weekly playlists can no longer wedge in an unrecoverable sync state; cancel clears it so you can re-sync",
"torrents stuck \"downloading metadata\" actually hit the stall timeout now, and dead torrents are cleaned out of qbittorrent instead of orphaned + re-grabbed as duplicates",
"#853 — artist pages load way faster on reopen: deezer / itunes / discogs now cache the artist→album list instead of refetching the whole discography (thanks ramonskie!)",
"#851 — a \"/\" or \":\" in a title now matches sources that use \"_\"",
"#840 — \"&\" is available as an artist tag separator (musicbrainz/picard style)",
"search auto-selects spotify when \"spotify (no auth)\" is the active metadata source",
"#845 / #848 / #850 — opt-in import-folder artist override, discogs wrong-album collision, cover-art cache first-load failure (thanks nick2000713 + RollingBase!)",
],
},
{
title: "Earlier in 2.6.x",
description: "highlights from the cycle just before 2.7.0: the artist/track/album blocklist, the download-retry overhaul, Download Origins + retention cleanup, server-side launch-PIN enforcement + secret masking, Spotify-no-auth metadata, Import IDs from File Tags, Library Re-tag, and a large pile of import / library / watchlist fixes.",
title: "Earlier in 2.7.0",
description: "the release just before this one made multi-user real: per-profile streaming accounts (My Accounts — connect your own spotify / tidal / listenbrainz), auto-sync running as its owner profile, an opt-in username/password login with forgot-password recovery, reverse-proxy + auth-proxy support, admin quick-switch for active sources, and a round of fixes (#835#843). before that, the 2.6.x cycle brought the blocklist, the download-retry overhaul, Download Origins, server-side launch-PIN enforcement, Spotify-no-auth metadata, and Library Re-tag.",
features: [],
},
];

View file

@ -2288,6 +2288,7 @@ function _adlRenderBatchSummary(activeBatches) {
}
function loadActiveDownloadsPage() {
_verifLoadConfig();
_adlFetch();
_adlFetchBatchHistory();
// Poll downloads every 2 seconds, history every 60 seconds
@ -2348,6 +2349,506 @@ function _updateDlNavBadge(count) {
}
}
function _adlVerifBadge(dl) {
// Verification badge for completed downloads — how this file passed
// verification (status comes from library_history / the live task):
// verified = clean AcoustID pass; unverified = imported but not
// hard-confirmed (cross-script/ambiguous/no fingerprint match);
// force_imported = accepted as best candidate after the retry budget was
// exhausted (version-mismatch fallback).
if (dl.status !== 'completed') return '';
if (dl.verification_status === 'force_imported') {
return ' <span class="verif-badge verif-force" title="Force-imported: accepted as best available candidate after repeated mismatches (version-mismatch fallback). Library AcoustID scans report these as informational.">⚑</span>';
}
if (dl.verification_status === 'unverified') {
return ' <span class="verif-badge verif-unverified" title="Imported but not hard-verified (AcoustID could not confirm — e.g. cross-script metadata or no fingerprint match).">⚠</span>';
}
if (dl.verification_status === 'verified') {
return ' <span class="verif-badge verif-ok" title="AcoustID verified: audio fingerprint matches the expected track.">✔</span>';
}
if (dl.verification_status === 'human_verified') {
return ' <span class="verif-badge verif-human" title="Human verified: you confirmed this file is the right track. The AcoustID scanner skips it.">🛡✔</span>';
}
return '';
}
// ---- Verification review queue (the ⚠ Unverified/Quarantine filter) ----
function verifHistoryId(dl) {
// Persistent history rows carry task_id 'history-<dbid>'.
if (!dl.is_persistent_history || !dl.task_id) return null;
const m = String(dl.task_id).match(/^history-(\d+)$/);
return m ? m[1] : null;
}
function _verifTimeAgo(iso) {
return (typeof formatHistoryTime === 'function' && iso) ? formatHistoryTime(iso) : '';
}
function _verifReasonBadge(dl) {
// Glanceable badge in the style of the library-history quarantine tab.
if (dl.verification_status === 'force_imported') {
return '<span class="verif-reason-badge verif-rb-force" title="Accepted as best candidate after the retry budget was exhausted (version-mismatch fallback)">FORCE-IMPORTED</span>';
}
if (dl.verification_status === 'unverified') {
return '<span class="verif-reason-badge verif-rb-unv" title="AcoustID could not hard-confirm this file (ambiguous / cross-script / no fingerprint match)">ACOUSTID UNCONFIRMED</span>';
}
return '';
}
function _adlReviewActions(dl) {
if (_adlFilter !== 'unverified') return '';
const hid = verifHistoryId(dl);
if (!hid) return '';
const timeAgo = _verifTimeAgo(dl.created_at);
return `<div class="verif-actions" onclick="event.stopPropagation()">
${_verifReasonBadge(dl)}
${timeAgo ? `<span class="verif-time">${timeAgo}</span>` : ''}
<button class="verif-act verif-act-play" onclick="verifPlay('${hid}')" title="Play the downloaded file in the media player"></button>
<button class="verif-act" onclick="verifCompare('${hid}', this)" title="Find this track on Soulseek/streaming sources and play it in the media player — compare against your file"></button>
<button class="verif-act" onclick="verifAudit('${hid}')" title="Open the audit trail for this download (lifecycle, embedded tags, lyrics)">🔍</button>
<button class="verif-act verif-act-ok" onclick="verifApprove('${hid}', this)" title="Approve: mark as human-verified (tag + DB). The AcoustID scanner will skip it."></button>
<button class="verif-act verif-act-del" onclick="verifDelete('${hid}', this)" title="Wrong file: delete it from disk and remove this entry">🗑</button>
</div>`;
}
async function verifPlay(hid) {
// Plays the LOCAL file through the global media player (same machinery as
// library playback) — full player UI with seek/stop instead of an
// invisible Audio element that re-renders wiped.
const dl = _adlData.find(d => verifHistoryId(d) === String(hid));
try {
if (typeof setTrackInfo === 'function') {
setTrackInfo({
title: (dl && dl.title) || 'Review track',
artist: (dl && dl.artist) || '',
album: (dl && dl.album) || '',
is_library: true,
image_url: (dl && dl.artwork) || null,
});
}
if (typeof showLoadingAnimation === 'function') showLoadingAnimation();
const r = await fetch(`/api/verification/${hid}/play`, { method: 'POST' });
const d = await r.json();
if (!d.success) throw new Error(d.error || 'Playback failed');
await startAudioPlayback();
} catch (e) {
if (typeof hideLoadingAnimation === 'function') hideLoadingAnimation();
showToast && showToast('Playback failed: ' + e.message, 'error');
}
}
async function verifCompare(hid, btn) {
// Same pipeline as the /search page play button — run server-side so the
// local file's duration guides candidate ranking (avoids e.g. 10-hour
// YouTube loops winning the match and timing out).
if (btn) { btn.disabled = true; btn.textContent = '…'; }
showToast && showToast('Searching stream for comparison…', 'info');
try {
const res = await fetch(`/api/verification/${hid}/compare-stream`, { method: 'POST' });
const data = await res.json();
if (data.success && data.result && typeof startStream === 'function') {
await startStream(data.result);
} else {
showToast && showToast(data.error || 'No stream candidate found for comparison', 'error');
}
} catch (e) {
showToast && showToast('Stream failed: ' + e.message, 'error');
}
if (btn) { btn.disabled = false; btn.textContent = '⇆'; }
}
async function verifAudit(hid) {
try {
const r = await fetch(`/api/verification/${hid}/entry`);
const d = await r.json();
if (d.success && d.entry && typeof openDownloadAuditModal === 'function') {
openDownloadAuditModal(d.entry);
} else {
showToast && showToast(d.error || 'Audit data not available', 'error');
}
} catch (e) {
showToast && showToast('Audit load failed', 'error');
}
}
async function verifApprove(hid, btn) {
if (btn) btn.disabled = true;
try {
const r = await fetch(`/api/verification/${hid}/approve`, { method: 'POST' });
const d = await r.json();
if (d.success) { showToast && showToast('Marked as human-verified 🛡✔', 'success'); _adlFetch(); }
else showToast && showToast(d.error || 'Approve failed', 'error');
} catch (e) { showToast && showToast('Approve failed', 'error'); }
if (btn) btn.disabled = false;
}
async function verifDelete(hid, btn) {
if (!await showConfirmDialog({
title: 'Delete Unverified File',
message: 'This permanently deletes the downloaded file from disk and removes the review entry. Cannot be undone.',
confirmText: 'Delete',
cancelText: 'Cancel',
destructive: true,
})) return;
if (btn) btn.disabled = true;
try {
const r = await fetch(`/api/verification/${hid}/delete`, { method: 'POST' });
const d = await r.json();
if (d.success) { showToast && showToast('File deleted', 'success'); _adlFetch(); }
else showToast && showToast(d.error || 'Delete failed', 'error');
} catch (e) { showToast && showToast('Delete failed', 'error'); }
if (btn) btn.disabled = false;
}
// ---- Quarantine sub-view inside the ⚠ filter ----
// The review queue covers BOTH kinds of suspect files: imported-but-
// unconfirmed (unverified/force_imported) and not-imported-at-all
// (quarantined). One place to listen, compare, approve or delete.
let _verifSubView = 'unverified';
let _verifQuarEntries = [];
let _verifQuarLoaded = false;
let _verifQuarLoading = false;
// Expanded 🔍 detail panels, keyed by quarantine entry id — survives the
// polling re-render (which rebuilds the rows every few seconds).
const _verifQuarOpenDetails = new Set();
// null = not fetched yet (assume enabled). Without an AcoustID API key
// nothing ever gets a verification status, so the review queue collapses
// to quarantine-only.
let _verifAcoustidEnabled = null;
let _verifConfigLoading = false;
async function _verifLoadConfig() {
if (_verifAcoustidEnabled !== null || _verifConfigLoading) return;
_verifConfigLoading = true;
try {
const r = await fetch('/api/verification/config');
const d = await r.json();
_verifAcoustidEnabled = !!(d && d.acoustid_enabled);
} catch (e) { _verifAcoustidEnabled = true; }
_verifConfigLoading = false;
if (_verifAcoustidEnabled === false) {
_verifSubView = 'quarantine';
const pill = document.querySelector('.adl-pill[data-filter="unverified"]');
if (pill) {
pill.textContent = '🛡 Quarantine';
pill.title = 'Files that failed import checks and were NOT imported. (AcoustID is not configured, so there is no unverified review queue.)';
}
if (_adlFilter === 'unverified') { _verifLoadQuarantine(true); _adlRender(); }
}
}
function verifSetSubView(v) {
if (_verifAcoustidEnabled === false) v = 'quarantine';
_verifSubView = v === 'quarantine' ? 'quarantine' : 'unverified';
if (_verifSubView === 'quarantine') _verifLoadQuarantine(true);
_adlRender();
}
async function _verifLoadQuarantine(force) {
if (_verifQuarLoading || (_verifQuarLoaded && !force)) return;
_verifQuarLoading = true;
try {
const r = await fetch('/api/quarantine/list');
const d = await r.json();
_verifQuarEntries = (d.success && Array.isArray(d.entries)) ? d.entries : [];
} catch (e) { _verifQuarEntries = []; }
_verifQuarLoaded = true;
_verifQuarLoading = false;
if (_adlFilter === 'unverified') _adlRender();
}
const _VERIF_QUAR_TRIGGERS = {
integrity: ['DURATION / INTEGRITY', 'verif-rb-int'],
acoustid: ['ACOUSTID MISMATCH', 'verif-rb-force'],
bit_depth: ['BIT DEPTH FILTER', 'verif-rb-int'],
};
function _verifQuarRows() {
if (!_verifQuarLoaded) return '<div class="adl-section-header">Loading quarantine…</div>';
if (!_verifQuarEntries.length) return '';
let html = '';
_verifQuarEntries.forEach((q, idx) => {
const title = _adlEsc(q.expected_track || q.original_filename || q.filename || 'Unknown file');
const meta = [_adlEsc(q.expected_artist || ''), _adlEsc(q.original_filename || '')].filter(Boolean).join(' — ');
const [trigLabel, trigClass] = _VERIF_QUAR_TRIGGERS[q.trigger] || ['QUARANTINED', 'verif-rb-unv'];
const timeAgo = _verifTimeAgo(q.timestamp);
const approveBtn = q.has_full_context
? `<button class="verif-act verif-act-ok" onclick="verifQuarApprove(${idx}, this)" title="Approve: re-import this exact file into the library, marked human-verified">✔</button>`
: `<button class="verif-act verif-act-ok" onclick="verifQuarRecover(${idx}, this)" title="Recover to Staging for a manual import (legacy entry without embedded context)">⤴</button>`;
const details = [
q.reason ? `<div><span class="verif-detail-label">Reason:</span> ${_adlEsc(q.reason)}</div>` : '',
q.source_username ? `<div><span class="verif-detail-label">Source uploader:</span> ${_adlEsc(q.source_username)}</div>` : '',
q.source_filename ? `<div><span class="verif-detail-label">Original Soulseek file:</span> ${_adlEsc(q.source_filename)}</div>` : '',
q.timestamp ? `<div><span class="verif-detail-label">Quarantined:</span> ${_adlEsc(q.timestamp)}</div>` : '',
].filter(Boolean).join('');
const detailsOpen = _verifQuarOpenDetails.has(q.id);
const artHtml = q.thumb_url
? `<img class="adl-row-art" src="${_adlEsc(q.thumb_url)}" alt="" onerror="this.style.display='none'">`
: '<div class="adl-row-art adl-row-art-empty"></div>';
html += `<div class="adl-row adl-row-failed verif-quar-row" data-quarantine-id="${_adlEsc(q.id)}" onclick="verifQuarInspect(${idx})" title="Click to show/hide details (reason, source uploader, original filename)">
${artHtml}
<div class="adl-row-info">
<div class="adl-row-title">${title}</div>
${meta ? `<div class="adl-row-meta">${meta}</div>` : ''}
<div class="verif-quar-details" id="verif-quar-details-${idx}" style="display:${detailsOpen ? '' : 'none'}">${details || 'No further details in the sidecar.'}</div>
</div>
<div class="verif-actions" onclick="event.stopPropagation()">
<span class="verif-reason-badge ${trigClass}" title="${_adlEsc(q.reason || '')}">${trigLabel}</span>
${timeAgo ? `<span class="verif-time">${timeAgo}</span>` : ''}
<button class="verif-act verif-act-play" onclick="verifQuarPlay(${idx})" title="Play the quarantined file in the media player"></button>
<button class="verif-act" onclick="verifQuarCompare(${idx}, this)" title="Find the expected track on Soulseek/streaming sources and play it in the media player — compare against the quarantined file"></button>
<button class="verif-act" onclick="verifQuarAudit(${idx})" title="Open the audit trail for this quarantined file (details, embedded tags, lyrics)">🔍</button>
${approveBtn}
<button class="verif-act verif-act-del" onclick="verifQuarDelete(${idx}, this)" title="Delete the quarantined file permanently">🗑</button>
</div>
</div>`;
});
return html;
}
function verifQuarInspect(idx) {
// Open-state lives in a Set keyed by entry id (not the DOM) — the polling
// re-render rebuilds the rows every few seconds and would collapse a
// DOM-only toggle right after the click.
const q = _verifQuarEntries[idx];
if (!q) return;
if (_verifQuarOpenDetails.has(q.id)) _verifQuarOpenDetails.delete(q.id);
else _verifQuarOpenDetails.add(q.id);
const el = document.getElementById(`verif-quar-details-${idx}`);
if (el) el.style.display = _verifQuarOpenDetails.has(q.id) ? '' : 'none';
}
async function verifQuarAudit(idx) {
// Same Audit Trail modal as the unverified rows — the backend synthesizes
// a history-shaped entry from the quarantine sidecar (these files were
// never imported, so no real history row exists).
const q = _verifQuarEntries[idx]; if (!q) return;
try {
const r = await fetch(`/api/quarantine/${encodeURIComponent(q.id)}/entry`);
const d = await r.json();
if (d.success && d.entry && typeof openDownloadAuditModal === 'function') {
openDownloadAuditModal(d.entry);
} else {
showToast && showToast(d.error || 'Audit data not available', 'error');
}
} catch (e) {
showToast && showToast('Audit load failed', 'error');
}
}
async function verifQuarPlay(idx) {
const q = _verifQuarEntries[idx]; if (!q) return;
try {
if (typeof setTrackInfo === 'function') {
setTrackInfo({
title: `${q.expected_track || q.original_filename || 'Quarantined file'} (quarantined)`,
artist: q.expected_artist || '',
album: '',
is_library: true,
});
}
if (typeof showLoadingAnimation === 'function') showLoadingAnimation();
const r = await fetch(`/api/quarantine/${encodeURIComponent(q.id)}/play`, { method: 'POST' });
const d = await r.json();
if (!d.success) throw new Error(d.error || 'Playback failed');
await startAudioPlayback();
} catch (e) {
if (typeof hideLoadingAnimation === 'function') hideLoadingAnimation();
showToast && showToast('Playback failed: ' + e.message, 'error');
}
}
async function verifQuarCompare(idx, btn) {
const q = _verifQuarEntries[idx]; if (!q) return;
if (btn) { btn.disabled = true; btn.textContent = '…'; }
showToast && showToast(`Searching stream for "${q.expected_track || ''}"…`, 'info');
try {
const res = await fetch(`/api/quarantine/${encodeURIComponent(q.id)}/compare-stream`, { method: 'POST' });
const data = await res.json();
if (data.success && data.result && typeof startStream === 'function') {
await startStream(data.result);
} else {
showToast && showToast(data.error || 'No stream candidate found for comparison', 'error');
}
} catch (e) {
showToast && showToast('Stream failed: ' + e.message, 'error');
}
if (btn) { btn.disabled = false; btn.textContent = '⇆'; }
}
async function verifQuarApprove(idx, btn) {
const q = _verifQuarEntries[idx]; if (!q) return;
if (btn) btn.disabled = true;
try {
const r = await fetch(`/api/quarantine/${encodeURIComponent(q.id)}/approve`, { method: 'POST' });
const d = await r.json();
if (d.success) {
showToast && showToast('Approved — re-importing, will be marked human-verified 🛡✔', 'success');
_verifLoadQuarantine(true);
} else {
showToast && showToast(d.error || 'Approve failed', 'error');
}
} catch (e) { showToast && showToast('Approve failed', 'error'); }
if (btn) btn.disabled = false;
}
async function verifQuarRecover(idx, btn) {
const q = _verifQuarEntries[idx]; if (!q) return;
if (btn) btn.disabled = true;
try {
const r = await fetch(`/api/quarantine/${encodeURIComponent(q.id)}/recover`, { method: 'POST' });
const d = await r.json();
if (d.success) {
showToast && showToast('Moved to Staging — finish via the Import page', 'success');
_verifLoadQuarantine(true);
} else {
showToast && showToast(d.error || 'Recover failed', 'error');
}
} catch (e) { showToast && showToast('Recover failed', 'error'); }
if (btn) btn.disabled = false;
}
async function verifQuarDelete(idx, btn) {
const q = _verifQuarEntries[idx]; if (!q) return;
if (!await showConfirmDialog({
title: 'Delete Quarantined File',
message: 'This permanently removes the file and its metadata sidecar. Cannot be undone.',
confirmText: 'Delete',
cancelText: 'Cancel',
destructive: true,
})) return;
if (btn) btn.disabled = true;
try {
const r = await fetch(`/api/quarantine/${encodeURIComponent(q.id)}`, { method: 'DELETE' });
const d = await r.json();
if (d.success) { showToast && showToast('Quarantined file deleted', 'success'); _verifLoadQuarantine(true); }
else showToast && showToast(d.error || 'Delete failed', 'error');
} catch (e) { showToast && showToast('Delete failed', 'error'); }
if (btn) btn.disabled = false;
}
function _verifUnverifiedIds() {
const done = ['completed', 'skipped', 'already_owned'];
return _adlData
.filter(d => done.includes(d.status) &&
(d.verification_status === 'unverified' || d.verification_status === 'force_imported'))
.map(d => verifHistoryId(d))
.filter(Boolean);
}
async function verifApproveAll(btn) {
const ids = _verifUnverifiedIds();
if (!ids.length) return;
if (!await showConfirmDialog({
title: 'Approve Unverified Files',
message: `Mark all ${ids.length} unverified entries as human-verified? The AcoustID scanner will skip them from now on.`,
confirmText: 'Approve All',
cancelText: 'Cancel',
})) return;
if (btn) btn.disabled = true;
let ok = 0;
for (const id of ids) {
try {
const r = await fetch(`/api/verification/${id}/approve`, { method: 'POST' });
const d = await r.json();
if (d.success) ok++;
} catch (e) {}
}
showToast && showToast(`Approved ${ok}/${ids.length} entries 🛡✔`, ok === ids.length ? 'success' : 'error');
if (btn) btn.disabled = false;
_adlFetch();
}
async function verifDeleteAll(btn) {
const ids = _verifUnverifiedIds();
if (!ids.length) return;
if (!await showConfirmDialog({
title: 'Delete Unverified Files',
message: `This permanently deletes all ${ids.length} unverified files from disk and removes their review entries. Cannot be undone.`,
confirmText: 'Delete All',
cancelText: 'Cancel',
destructive: true,
})) return;
if (btn) btn.disabled = true;
let ok = 0;
for (const id of ids) {
try {
const r = await fetch(`/api/verification/${id}/delete`, { method: 'POST' });
const d = await r.json();
if (d.success) ok++;
} catch (e) {}
}
showToast && showToast(`Deleted ${ok}/${ids.length} files`, ok === ids.length ? 'success' : 'error');
if (btn) btn.disabled = false;
_adlFetch();
}
async function verifQuarApproveAll(btn) {
const entries = _verifQuarEntries.filter(q => q.has_full_context);
if (!entries.length) {
showToast && showToast('No one-click-approvable entries (legacy sidecars need Recover)', 'info');
return;
}
if (!await showConfirmDialog({
title: 'Approve Quarantined Files',
message: `Approve and re-import all ${entries.length} quarantined files? They will be imported into the library marked human-verified.`,
confirmText: 'Approve & Import All',
cancelText: 'Cancel',
})) return;
if (btn) btn.disabled = true;
let ok = 0;
for (const q of entries) {
try {
const r = await fetch(`/api/quarantine/${encodeURIComponent(q.id)}/approve`, { method: 'POST' });
const d = await r.json();
if (d.success) ok++;
} catch (e) {}
// Each approve spawns a server-side re-import thread — stagger them.
await new Promise(res => setTimeout(res, 500));
}
showToast && showToast(`Approved ${ok}/${entries.length} — re-importing as human-verified`, ok === entries.length ? 'success' : 'error');
if (btn) btn.disabled = false;
_verifLoadQuarantine(true);
}
async function verifQuarClearAll(btn) {
if (!_verifQuarEntries.length) return;
if (!await showConfirmDialog({
title: 'Clear Quarantine',
message: `This permanently deletes all ${_verifQuarEntries.length} quarantined files and their metadata sidecars. Cannot be undone.`,
confirmText: 'Delete All',
cancelText: 'Cancel',
destructive: true,
})) return;
if (btn) btn.disabled = true;
try {
const r = await fetch('/api/quarantine/clear', { method: 'POST' });
const d = await r.json();
if (d.success) showToast && showToast('Quarantine cleared', 'success');
else showToast && showToast(d.error || 'Clear failed', 'error');
} catch (e) { showToast && showToast('Clear failed', 'error'); }
if (btn) btn.disabled = false;
_verifLoadQuarantine(true);
}
window.verifPlay = verifPlay;
window.verifApprove = verifApprove;
window.verifDelete = verifDelete;
window.verifCompare = verifCompare;
window.verifAudit = verifAudit;
window.verifSetSubView = verifSetSubView;
window.verifApproveAll = verifApproveAll;
window.verifDeleteAll = verifDeleteAll;
window.verifQuarPlay = verifQuarPlay;
window.verifQuarCompare = verifQuarCompare;
window.verifQuarInspect = verifQuarInspect;
window.verifQuarAudit = verifQuarAudit;
window.verifQuarApprove = verifQuarApprove;
window.verifQuarRecover = verifQuarRecover;
window.verifQuarDelete = verifQuarDelete;
window.verifQuarApproveAll = verifQuarApproveAll;
window.verifQuarClearAll = verifQuarClearAll;
function _adlRender() {
const list = document.getElementById('adl-list');
const empty = document.getElementById('adl-empty');
@ -2370,6 +2871,10 @@ function _adlRender() {
if (_adlFilter === 'active') filtered = filtered.filter(d => activeStatuses.includes(d.status));
else if (_adlFilter === 'queued') filtered = filtered.filter(d => queuedStatuses.includes(d.status));
else if (_adlFilter === 'completed') filtered = filtered.filter(d => completedStatuses.includes(d.status));
else if (_adlFilter === 'unverified') filtered = filtered.filter(d =>
completedStatuses.includes(d.status) &&
(d.verification_status === 'unverified' || d.verification_status === 'force_imported'));
// (review banner injected below when this filter is active)
else if (_adlFilter === 'failed') filtered = filtered.filter(d => failedStatuses.includes(d.status));
const completedN = _adlData.filter(d =>
@ -2419,6 +2924,48 @@ function _adlRender() {
existingBanner.style.display = 'none';
}
// Review queue sub-view toggle: unverified imports ⇄ quarantined files.
let verifBanner = document.getElementById('verif-subview-banner');
if (_adlFilter === 'unverified') {
_verifLoadConfig(); // no-op once fetched
if (!verifBanner) {
verifBanner = document.createElement('div');
verifBanner.id = 'verif-subview-banner';
verifBanner.className = 'adl-batch-filter-banner';
list.parentNode.insertBefore(verifBanner, list);
}
const quarCount = _verifQuarLoaded ? ` (${_verifQuarEntries.length})` : '';
const bulkBtns = _verifSubView === 'quarantine'
? `<button class="adl-filter-banner-clear" onclick="verifQuarApproveAll(this)" title="Approve + re-import every quarantined file (marked human-verified)">✔ Approve all</button>
<button class="adl-filter-banner-clear verif-bulk-danger" onclick="verifQuarClearAll(this)" title="Permanently delete every quarantined file">🗑 Clear all</button>`
: `<button class="adl-filter-banner-clear" onclick="verifApproveAll(this)" title="Mark every listed entry as human-verified">✔ Approve all</button>
<button class="adl-filter-banner-clear verif-bulk-danger" onclick="verifDeleteAll(this)" title="Delete every listed file from disk and remove its entry">🗑 Delete all</button>`;
// Without an AcoustID key nothing ever gets a verification status —
// hide the pointless Unverified pill and show quarantine only.
const unvPill = _verifAcoustidEnabled === false
? ''
: `<button class="adl-pill${_verifSubView === 'unverified' ? ' active' : ''}" onclick="verifSetSubView('unverified')" title="Imported files that AcoustID could not hard-confirm">⚠ Unverified (${filtered.length})</button>`;
verifBanner.innerHTML = `
${unvPill}
<button class="adl-pill${_verifSubView === 'quarantine' ? ' active' : ''}" onclick="verifSetSubView('quarantine')" title="Files that failed verification and were NOT imported">🛡 Quarantine${quarCount}</button>
<span class="verif-banner-spacer"></span>
${bulkBtns}`;
verifBanner.style.display = '';
if (!_verifQuarLoaded) _verifLoadQuarantine(false); // count for the pill
} else if (verifBanner) {
verifBanner.style.display = 'none';
}
if (_adlFilter === 'unverified' && _verifSubView === 'quarantine') {
const qhtml = _verifQuarRows();
const qEmptyEl = document.getElementById('adl-empty');
const qEmptyHtml = qEmptyEl ? qEmptyEl.outerHTML : '';
list.innerHTML = qEmptyHtml + qhtml;
const qNewEmpty = document.getElementById('adl-empty');
if (qNewEmpty) qNewEmpty.style.display = qhtml ? 'none' : '';
return;
}
if (filtered.length === 0) {
if (empty) empty.style.display = '';
// Clear any existing rows but keep the empty message
@ -2455,6 +3002,7 @@ function _adlRender() {
for (const dl of section.items) {
const statusClass = _adlStatusClass(dl.status);
const statusLabel = _adlStatusLabel(dl.status);
// (verification badge appended next to the label via _adlVerifBadge)
const title = _adlEsc(dl.title || 'Unknown Track');
const artist = _adlEsc(dl.artist || '');
const album = _adlEsc(dl.album || '');
@ -2494,8 +3042,9 @@ function _adlRender() {
</div>
<div class="adl-row-status ${statusClass}">
<span class="adl-status-dot ${statusClass}"></span>
${statusLabel}
${statusLabel}${_adlVerifBadge(dl)}${dl.retry_info && (statusClass === 'active' || statusClass === 'queued') ? ` <span class="adl-retry-info" title="Retry engine: trying the next-best candidate (attempt ${_adlEsc(String(dl.retry_info))}${dl.retry_trigger ? ', triggered by ' + _adlEsc(dl.retry_trigger) : ''})">🔁 ${_adlEsc(String(dl.retry_info))}</span>` : ''}
</div>
${_adlReviewActions(dl)}
${cancelBtnHtml}
</div>`;
}
@ -3126,4 +3675,3 @@ window.adlCancelRow = adlCancelRow;
window.adlCancelAll = adlCancelAll;
window.adlToggleBatchHistory = adlToggleBatchHistory;
window.adlToggleBatchPanel = adlToggleBatchPanel;

View file

@ -1297,6 +1297,8 @@ async function loadSettingsData() {
// Populate Import settings
document.getElementById('import-replace-lower-quality').checked = settings.import?.replace_lower_quality === true;
const _folderArtistEl = document.getElementById('import-folder-artist-override');
if (_folderArtistEl) _folderArtistEl.checked = settings.import?.folder_artist_override === true;
// Populate M3U Export settings
document.getElementById('m3u-export-enabled').checked = settings.m3u_export?.enabled === true;
@ -3128,6 +3130,7 @@ async function saveSettings(quiet = false) {
},
import: {
replace_lower_quality: document.getElementById('import-replace-lower-quality').checked,
folder_artist_override: document.getElementById('import-folder-artist-override')?.checked === true,
staging_path: document.getElementById('staging-path').value || './Staging'
},
lossy_copy: {

View file

@ -281,7 +281,12 @@ function createSearchController({
if (resp.ok) {
const status = await resp.json();
const ms = status && status.metadata_source;
const src = (ms && typeof ms === 'object') ? ms.source : ms;
let src = (ms && typeof ms === 'object') ? ms.source : ms;
// "Spotify (no auth)" reports as 'spotify_free' for display, but the
// search picker only has a 'spotify' icon (it's the same searchable
// source). Map it so no-auth auto-selects Spotify instead of leaving
// the picker empty (no SOURCE_ORDER icon matches 'spotify_free').
if (src === 'spotify_free') src = 'spotify';
if (src && SOURCE_LABELS[src]) state.activeSource = src;
}
} catch (_) { /* best-effort */ }

View file

@ -10864,6 +10864,26 @@ body.helper-mode-active #dashboard-activity-feed:hover {
border-color: rgba(239, 83, 80, 0.3);
color: #ef5350;
}
.download-audit-hero-pill-verified {
background: rgba(74, 222, 128, 0.12);
border-color: rgba(74, 222, 128, 0.3);
color: #4ade80;
}
.download-audit-hero-pill-human_verified {
background: rgba(52, 152, 219, 0.12);
border-color: rgba(52, 152, 219, 0.35);
color: #3498db;
}
.download-audit-hero-pill-force_imported {
background: rgba(230, 126, 34, 0.12);
border-color: rgba(230, 126, 34, 0.35);
color: #e67e22;
}
.download-audit-hero-pill-unverified {
background: rgba(241, 196, 15, 0.12);
border-color: rgba(241, 196, 15, 0.35);
color: #f1c40f;
}
/* Tab bar */
.download-audit-tabs {
@ -67893,6 +67913,30 @@ body.em-scroll-lock { overflow: hidden; }
}
.ma-token-input:focus { outline: none; border-color: var(--ma-brand); }
/* Verification badge on completed downloads (verified / unverified / force-imported) */
.verif-badge { display: inline-block; margin-left: 4px; font-size: 11px; cursor: help; border-radius: 999px; padding: 0 5px; line-height: 16px; }
.verif-badge.verif-ok { color: #2ecc71; background: rgba(46,204,113,0.12); }
.verif-badge.verif-unverified { color: #f1c40f; background: rgba(241,196,15,0.14); }
.verif-badge.verif-force { color: #e67e22; background: rgba(230,126,34,0.16); }
.adl-retry-info { margin-left: 6px; font-size: 11px; color: #e67e22; cursor: help; }
.verif-badge.verif-human { color: #3498db; background: rgba(52,152,219,0.14); }
.verif-actions { display: inline-flex; gap: 6px; margin-left: 10px; align-items: center; }
.verif-act { border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.85); border-radius: 6px; padding: 2px 8px; font-size: 12px; cursor: pointer; line-height: 18px; }
.verif-act:hover { background: rgba(255,255,255,0.14); }
.verif-act-ok:hover { background: rgba(46,204,113,0.25); border-color: rgba(46,204,113,0.5); }
.verif-act-del:hover { background: rgba(231,76,60,0.25); border-color: rgba(231,76,60,0.5); }
.verif-act-play.playing { background: rgba(var(--accent-rgb),0.3); }
/* Review-queue row annotations (library-history-quarantine style) */
.verif-reason-badge { display: inline-block; font-size: 9.5px; font-weight: 600; letter-spacing: 0.04em; border: 1px solid; border-radius: 999px; padding: 1px 7px; line-height: 14px; white-space: nowrap; cursor: help; }
.verif-rb-unv { color: #f1c40f; border-color: rgba(241,196,15,0.5); }
.verif-rb-force { color: #ef5350; border-color: rgba(239,83,80,0.5); }
.verif-rb-int { color: #facc15; border-color: rgba(250,204,21,0.5); }
.verif-time { font-size: 11px; color: rgba(255,255,255,0.45); white-space: nowrap; }
.verif-quar-details { margin-top: 6px; font-size: 11.5px; color: rgba(255,255,255,0.6); line-height: 1.5; word-break: break-all; }
.verif-detail-label { color: rgba(255,255,255,0.4); margin-right: 4px; }
.verif-banner-spacer { flex: 1; }
.verif-bulk-danger { border-color: rgba(248,113,113,0.4) !important; color: #f87171 !important; }
/* ── Security settings: grouped sub-sections + dependency visuals ── */
.security-subgroup {
border: 1px solid rgba(255, 255, 255, 0.07);

View file

@ -108,28 +108,82 @@ self.addEventListener('fetch', (event) => {
});
// ── image fetch throttle ───────────────────────────────────────────────
// A discography page fires 70+ cover-art requests at once. Routed through the
// SW one-for-one, that burst overruns the browser's per-host connection pool
// (~6); the overflow fetches reject, and a cache-first strategy that maps a
// rejection to Response.error() turns each into a hard NS_ERROR_INTERCEPTION_
// FAILED — a permanently broken, *uncached* image for that load. (It only
// "fixes itself" on reload because the images that did win the race are then
// served from cache, shrinking the burst.) We cap how many image fetches the
// SW runs at once so the rest queue instead of failing.
const MAX_CONCURRENT_IMAGE_FETCHES = 6;
let _activeImageFetches = 0;
const _imageFetchQueue = [];
function _acquireImageSlot() {
if (_activeImageFetches < MAX_CONCURRENT_IMAGE_FETCHES) {
_activeImageFetches++;
return Promise.resolve();
}
return new Promise((resolve) => _imageFetchQueue.push(resolve));
}
function _releaseImageSlot() {
const next = _imageFetchQueue.shift();
// Hand the slot straight to the next waiter (count stays put); only
// decrement when nobody is queued.
if (next) next();
else _activeImageFetches--;
}
async function _fetchWithRetry(request, retries = 1, backoffMs = 200) {
try {
return await fetch(request);
} catch (err) {
if (retries <= 0) throw err;
// Most failures here are transient connection-cap rejections that
// clear as the burst drains — one short retry recovers the bulk.
await new Promise((resolve) => setTimeout(resolve, backoffMs));
return _fetchWithRetry(request, retries - 1, backoffMs);
}
}
// ── strategies ───────────────────────────────────────────────────────
async function _cacheFirst(request, cacheName) {
// Cache lookup first — a hit must never consume a fetch slot. If the cache
// layer itself is unavailable, fall through to a throttled network fetch.
let cache = null;
try {
const cache = await caches.open(cacheName);
cache = await caches.open(cacheName);
const hit = await cache.match(request);
if (hit) return hit;
} catch (err) {
cache = null;
}
const response = await fetch(request);
await _acquireImageSlot();
try {
const response = await _fetchWithRetry(request);
// Only cache successful, opaque-OK responses. Don't cache 404s
// / 500s — would pin a bad placeholder for the lifetime of the
// cache version.
if (response && (response.ok || response.type === 'opaque')) {
if (cache && response && (response.ok || response.type === 'opaque')) {
// Clone before .put — body is consumed otherwise.
cache.put(request, response.clone()).catch(() => { /* quota / disk full */ });
}
return response;
} catch (err) {
// Network failure with no cache hit — let the browser surface
// its standard offline / error UI (returning Response.error()
// is equivalent to letting the fetch reject naturally).
return Response.error();
// Both attempts failed (offline / connection cap / CDN error). Return a
// benign 504 rather than Response.error(): a network-error response
// from a SW surfaces as NS_ERROR_INTERCEPTION_FAILED in Firefox, which
// hard-fails the <img>. A plain 504 just yields a broken image that
// recovers on the next navigation once the cache is warm.
return new Response('', { status: 504, statusText: 'Image fetch failed' });
} finally {
_releaseImageSlot();
}
}

View file

@ -3799,8 +3799,10 @@ function openDownloadAuditModal(entry) {
// Live tag read for the Tags tab. Fire-and-forget on open so the
// data is ready by the time the user switches to the Tags tab.
if (entry.id != null) {
fetchAndRenderEmbeddedTags(entry.id);
// Synthetic entries (quarantine review queue) have no history id but
// carry their own tags URL in _file_tags_url.
if (entry.id != null || entry._file_tags_url) {
fetchAndRenderEmbeddedTags(entry.id, entry._file_tags_url);
}
}
@ -3814,11 +3816,9 @@ function switchAuditTab(tabName) {
if (body) body.innerHTML = renderDownloadAuditTabPanel(tabName, _downloadAuditEntry);
// Re-fire the live fetch when switching to Tags (in case it
// raced past first mount before the user got there).
if (tabName === 'tags' && _downloadAuditEntry.id != null) {
fetchAndRenderEmbeddedTags(_downloadAuditEntry.id);
}
if (tabName === 'lyrics' && _downloadAuditEntry.id != null) {
fetchAndRenderEmbeddedTags(_downloadAuditEntry.id);
if ((tabName === 'tags' || tabName === 'lyrics') &&
(_downloadAuditEntry.id != null || _downloadAuditEntry._file_tags_url)) {
fetchAndRenderEmbeddedTags(_downloadAuditEntry.id, _downloadAuditEntry._file_tags_url);
}
}
window.switchAuditTab = switchAuditTab;
@ -3856,7 +3856,14 @@ function renderDownloadAuditHero(entry) {
const pills = [];
if (entry.download_source) pills.push(_auditHeroPill('source', entry.download_source));
if (entry.quality) pills.push(_auditHeroPill('quality', entry.quality));
if (entry.acoustid_result) pills.push(_auditHeroPill('verify', formatAcoustidLabel(entry.acoustid_result), entry.acoustid_result));
const _vsLabels = { verified: 'Verified', unverified: 'Unverified', force_imported: 'Force-imported', human_verified: 'Human verified' };
if (entry.verification_status && _vsLabels[entry.verification_status]) {
pills.push(_auditHeroPill('verify', _vsLabels[entry.verification_status], entry.verification_status));
} else if (entry._quarantined) {
pills.push(_auditHeroPill('verify', 'Quarantined', 'fail'));
} else if (entry.acoustid_result) {
pills.push(_auditHeroPill('verify', formatAcoustidLabel(entry.acoustid_result), entry.acoustid_result));
}
return `
${art}
@ -3957,7 +3964,7 @@ function renderEmbeddedTagsSection(entry) {
return renderDownloadAuditTagsTab(entry);
}
async function fetchAndRenderEmbeddedTags(historyId) {
async function fetchAndRenderEmbeddedTags(historyId, urlOverride) {
const tagsSlot = document.getElementById('download-audit-tags-body');
const lyricsSlot = document.getElementById('download-audit-lyrics-body');
if (!tagsSlot && !lyricsSlot) return;
@ -3967,7 +3974,7 @@ async function fetchAndRenderEmbeddedTags(historyId) {
if (lyricsSlot) lyricsSlot.innerHTML = html;
};
try {
const resp = await fetch(`/api/library/history/${historyId}/file-tags`);
const resp = await fetch(urlOverride || `/api/library/history/${historyId}/file-tags`);
if (!resp.ok) { renderError(`Could not read file tags (HTTP ${resp.status}).`); return; }
const data = await resp.json();
if (!data.success) { renderError(data.error || 'Could not read file tags.'); return; }
@ -4263,8 +4270,8 @@ function buildDownloadAuditSteps(entry) {
{
key: 'verify',
title: 'Verification',
status: auditStatusFromAcoustid(entry.acoustid_result),
detail: buildAcoustidDetail(entry.acoustid_result),
status: auditVerifyStatus(entry),
detail: buildVerificationDetail(entry),
meta: [],
},
(() => {
@ -4381,6 +4388,37 @@ function buildSourceMatchMeta(entry) {
return out;
}
function auditVerifyStatus(entry) {
// The persisted verification status is the final word — it captures
// outcomes the raw acoustid_result can't express (force-imported after
// N retries, human approval via the review queue).
if (entry._quarantined) return 'error';
if (entry.verification_status === 'human_verified') return 'complete';
if (entry.verification_status === 'verified') return 'complete';
if (entry.verification_status === 'force_imported') return 'partial';
if (entry.verification_status === 'unverified') return 'partial';
return auditStatusFromAcoustid(entry.acoustid_result);
}
function buildVerificationDetail(entry) {
if (entry._quarantined) {
return `Quarantined — the file was NOT imported. ${entry._quarantine_reason || ''}`.trim();
}
if (entry.verification_status === 'force_imported') {
return 'Force-imported: accepted as the best available candidate after the retry budget was exhausted (version-mismatch fallback). The AcoustID check was bypassed for this final re-import — earlier attempts had failed it.';
}
if (entry.verification_status === 'human_verified') {
return 'Human verified: you confirmed this file via the review queue. The AcoustID scanner skips it.';
}
if (entry.verification_status === 'unverified') {
return 'Imported but not hard-confirmed: AcoustID could not verify this file (ambiguous / cross-script metadata / no fingerprint match). Review it under Unverified/Quarantine on the Downloads page.';
}
if (entry.verification_status === 'verified') {
return 'AcoustID fingerprint matched the expected track.';
}
return buildAcoustidDetail(entry.acoustid_result);
}
function auditStatusFromAcoustid(result) {
if (!result) return 'unknown';
if (result === 'pass') return 'complete';