Merge pull request #845 from nick2000713/fix/import-folder-artist-override-optin
feat: import folder-artist override opt-in + verification pipeline review queue
This commit is contained in:
commit
eb35ba86fb
29 changed files with 2327 additions and 558 deletions
|
|
@ -56,6 +56,11 @@
|
||||||
"playlist_path": "$playlist/$artist - $title"
|
"playlist_path": "$playlist/$artist - $title"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"import": {
|
||||||
|
"staging_path": "./Staging",
|
||||||
|
"replace_lower_quality": false,
|
||||||
|
"folder_artist_override": true
|
||||||
|
},
|
||||||
"lossy_copy": {
|
"lossy_copy": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"bitrate": "320",
|
"bitrate": "320",
|
||||||
|
|
|
||||||
|
|
@ -684,7 +684,13 @@ class ConfigManager:
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"staging_path": "./Staging",
|
"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": {
|
"m3u_export": {
|
||||||
"enabled": False,
|
"enabled": False,
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,11 @@ from core.musicbrainz_client import MusicBrainzClient
|
||||||
|
|
||||||
logger = get_logger("acoustid.verification")
|
logger = get_logger("acoustid.verification")
|
||||||
|
|
||||||
# Thresholds
|
# Thresholds — single definition lives in the shared core; re-exported here so
|
||||||
MIN_ACOUSTID_SCORE = 0.80 # Minimum AcoustID fingerprint score to trust
|
# existing importers keep working and the values can't drift between paths.
|
||||||
TITLE_MATCH_THRESHOLD = 0.70 # Title similarity needed to consider a match
|
from core.matching.audio_verification import ( # noqa: E402
|
||||||
ARTIST_MATCH_THRESHOLD = 0.60 # Artist similarity needed to consider a match
|
MIN_ACOUSTID_SCORE, TITLE_MATCH_THRESHOLD, ARTIST_MATCH_THRESHOLD,
|
||||||
|
)
|
||||||
|
|
||||||
# Single matching-engine instance so version detection reuses the same patterns
|
# Single matching-engine instance so version detection reuses the same patterns
|
||||||
# used by the pre-download Soulseek matcher (remix / live / acoustic /
|
# 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
|
ERROR = "error" # Lookup errored (invalid key / rate limit / no backend) - continue, but flag it
|
||||||
|
|
||||||
|
|
||||||
def _normalize(text: str) -> str:
|
# normalize() + similarity() + the alias-aware comparison now live in the shared
|
||||||
"""Normalize a string for comparison: lowercase, strip parentheticals, punctuation."""
|
# decision core (core/matching/audio_verification.py) so import-time verification
|
||||||
if not text:
|
# and the library scan share ONE definition — the <>-strip fix, CJK handling and
|
||||||
return ""
|
# thresholds can't drift apart again. Names kept (`_normalize` etc.) for existing
|
||||||
s = text.lower().strip()
|
# importers/tests.
|
||||||
# Remove ALL parenthetical suffixes — these are metadata annotations, not core title
|
from core.matching.audio_verification import ( # noqa: E402
|
||||||
# Covers: (Live), (Remastered), (Parody of ...), (from "..." Soundtrack), (feat. ...), etc.
|
normalize as _normalize,
|
||||||
s = re.sub(r'\s*\([^)]*\)', '', s)
|
similarity as _similarity,
|
||||||
# Remove ALL square bracket suffixes: [Live], [Remastered], [Deluxe], etc.
|
_alias_aware_artist_sim,
|
||||||
s = re.sub(r'\s*\[[^\]]*\]', '', s)
|
_find_best_title_artist_match as _core_find_best_title_artist_match,
|
||||||
# Remove trailing featuring info not in parentheses: "feat. ...", "ft. ...", "featuring ..."
|
evaluate as _core_evaluate,
|
||||||
s = re.sub(r'\s+(?:feat\.?|ft\.?|featuring)\s+.*$', '', s, flags=re.IGNORECASE)
|
Decision as _CoreDecision,
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
def _similarity(a: str, b: str) -> float:
|
def _find_best_title_artist_match(recordings, expected_title, expected_artist,
|
||||||
"""Calculate similarity between two strings (0.0-1.0) after normalization."""
|
expected_artist_aliases=None):
|
||||||
na = _normalize(a)
|
"""Back-compat wrapper around the shared core matcher (keeps the
|
||||||
nb = _normalize(b)
|
``expected_artist_aliases`` kwarg name for existing callers/tests)."""
|
||||||
if not na or not nb:
|
return _core_find_best_title_artist_match(
|
||||||
return 0.0
|
recordings, expected_title, expected_artist, expected_artist_aliases,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 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
|
# Shared MusicBrainz client for enrichment lookups
|
||||||
_mb_client = None
|
_mb_client = None
|
||||||
|
|
@ -466,241 +330,32 @@ class AcoustIDVerification:
|
||||||
)
|
)
|
||||||
return _alias_cache['value']
|
return _alias_cache['value']
|
||||||
|
|
||||||
# Step 4: Find best title/artist match among AcoustID results
|
# Steps 4-5: delegate the PASS/SKIP/FAIL decision to the shared core
|
||||||
best_rec, title_sim, artist_sim = _find_best_title_artist_match(
|
# (core/matching/audio_verification.evaluate) so import verification
|
||||||
recordings, expected_track_name, expected_artist_name,
|
# and the library scan apply identical logic.
|
||||||
expected_artist_aliases=_aliases_provider,
|
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(
|
logger.info(
|
||||||
f"Best match: '{matched_title}' by '{matched_artist}' "
|
"Best match: '%s' by '%s' (title_sim=%.2f, artist_sim=%.2f) -> %s",
|
||||||
f"(title_sim={title_sim:.2f}, artist_sim={artist_sim:.2f})"
|
outcome.matched_title, outcome.matched_artist,
|
||||||
|
outcome.title_sim, outcome.artist_sim, outcome.decision.value,
|
||||||
)
|
)
|
||||||
|
_decision_map = {
|
||||||
# Step 4b: Version-mismatch gate.
|
_CoreDecision.PASS: VerificationResult.PASS,
|
||||||
#
|
_CoreDecision.SKIP: VerificationResult.SKIP,
|
||||||
# The ``_normalize`` step deliberately strips parentheticals and
|
_CoreDecision.FAIL: VerificationResult.FAIL,
|
||||||
# version tags ("(Instrumental)", "- Live", etc) so that legit
|
}
|
||||||
# name variations don't fail the title-similarity comparison.
|
result = _decision_map[outcome.decision]
|
||||||
# That same stripping made it impossible to tell a vocal track
|
if result == VerificationResult.PASS:
|
||||||
# apart from its instrumental: "In My Feelings" and "In My
|
logger.info("AcoustID verification PASSED - %s", outcome.reason)
|
||||||
# Feelings (Instrumental)" both normalize to "in my feelings",
|
elif result == VerificationResult.FAIL:
|
||||||
# the title sim ends up 1.0, and the file passes verification
|
logger.warning("AcoustID verification FAILED - %s", outcome.reason)
|
||||||
# even though it's the wrong cut.
|
else:
|
||||||
#
|
logger.info("AcoustID verification SKIPPED - %s", outcome.reason)
|
||||||
# Detect the version on each side BEFORE normalization runs.
|
return result, outcome.reason
|
||||||
# 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
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Any unexpected error -> SKIP (fail open)
|
# Any unexpected error -> SKIP (fail open)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ from datetime import datetime
|
||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
from core.imports.folder_artist import resolve_folder_artist
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
|
|
||||||
logger = get_logger("auto_import")
|
logger = get_logger("auto_import")
|
||||||
|
|
@ -1576,31 +1577,18 @@ class AutoImportWorker:
|
||||||
album_name = identification.get('album_name', 'Unknown')
|
album_name = identification.get('album_name', 'Unknown')
|
||||||
image_url = identification.get('image_url', '')
|
image_url = identification.get('image_url', '')
|
||||||
|
|
||||||
# Parent folder artist override: if the staging folder structure is
|
# Parent folder artist override via import.folder_artist_override.
|
||||||
# Artist/Albums/AlbumName or Artist/AlbumName, use the parent folder
|
# Default on to preserve the legacy Artist/Album staging behavior.
|
||||||
# as the artist name when the tag-extracted artist looks wrong.
|
# Users who stage mixed piles under one container folder can turn it off
|
||||||
# This handles mixtapes/compilations where embedded tags have DJ names.
|
# to keep the metadata-identified artist.
|
||||||
try:
|
try:
|
||||||
staging_root = self._resolve_staging_path() or self.staging_path
|
if self._config_manager.get('import.folder_artist_override', True):
|
||||||
rel_path = os.path.relpath(candidate.path, staging_root)
|
staging_root = self._resolve_staging_path() or self.staging_path
|
||||||
parts = [p for p in rel_path.replace('\\', '/').split('/') if p]
|
rel_path = os.path.relpath(candidate.path, staging_root)
|
||||||
|
folder_artist = resolve_folder_artist(rel_path, artist_name, enabled=True)
|
||||||
# parts[0] = artist folder, parts[1] = album or category subfolder, etc.
|
if folder_artist:
|
||||||
# Only attempt override if there's at least 2 levels (artist/album)
|
logger.info(f"[Auto-Import] Parent folder artist '{folder_artist}' differs from tag artist '{artist_name}' — using folder artist")
|
||||||
folder_artist = None
|
artist_name = folder_artist
|
||||||
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
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("folder artist override failed: %s", e)
|
logger.debug("folder artist override failed: %s", e)
|
||||||
release_date = identification.get('release_date', '') or album_data.get('release_date', '')
|
release_date = identification.get('release_date', '') or album_data.get('release_date', '')
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,11 @@ def requeue_quarantined_task_for_retry(task_id, batch_id, trigger):
|
||||||
task.pop('quarantine_entry_id', None)
|
task.pop('quarantine_entry_id', None)
|
||||||
task['status'] = 'searching'
|
task['status'] = 'searching'
|
||||||
task['status_change_time'] = time.time()
|
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(
|
logger.info(
|
||||||
f"[Retry:{trigger}] Re-queuing task {task_id} for next-best candidate "
|
f"[Retry:{trigger}] Re-queuing task {task_id} for next-best candidate "
|
||||||
|
|
|
||||||
|
|
@ -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
|
'error_message': task.get('error_message'), # Surface failure reasons to UI
|
||||||
'quarantine_entry_id': task.get('quarantine_entry_id'),
|
'quarantine_entry_id': task.get('quarantine_entry_id'),
|
||||||
'has_candidates': bool(task.get('cached_candidates')), # Whether search found results (for clickable review)
|
'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 {}
|
_ti = task.get('track_info') if isinstance(task.get('track_info'), dict) else {}
|
||||||
task_filename = task.get('filename') or _ti.get('filename')
|
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'],
|
'priority': _STATUS_PRIORITY['completed'],
|
||||||
'quality': entry.get('quality') or '',
|
'quality': entry.get('quality') or '',
|
||||||
'file_path': entry.get('file_path') or '',
|
'file_path': entry.get('file_path') or '',
|
||||||
|
'verification_status': entry.get('verification_status'),
|
||||||
'is_persistent_history': True,
|
'is_persistent_history': True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -788,6 +795,9 @@ def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict:
|
||||||
'status': status,
|
'status': status,
|
||||||
'progress': progress,
|
'progress': progress,
|
||||||
'error': task.get('error_message'),
|
'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_id': batch_id,
|
||||||
'batch_name': batch.get('playlist_name') or batch.get('album_name') or '',
|
'batch_name': batch.get('playlist_name') or batch.get('album_name') or '',
|
||||||
'batch_source': batch.get('source_page') or batch.get('initiated_from') or '',
|
'batch_source': batch.get('source_page') or batch.get('initiated_from') or '',
|
||||||
|
|
|
||||||
52
core/imports/folder_artist.py
Normal file
52
core/imports/folder_artist.py
Normal 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
|
||||||
|
|
@ -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):
|
def post_process_matched_download(context_key, context, file_path, runtime, metadata_runtime=None):
|
||||||
on_download_completed = getattr(runtime, "on_download_completed", None)
|
on_download_completed = getattr(runtime, "on_download_completed", None)
|
||||||
automation_engine = getattr(runtime, "automation_engine", 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}")
|
logger.info(f"Simple download post-processing complete: {activity_target}")
|
||||||
context['_simple_download_completed'] = True
|
context['_simple_download_completed'] = True
|
||||||
context['_final_path'] = str(destination)
|
context['_final_path'] = str(destination)
|
||||||
|
_persist_verification_status(context, destination)
|
||||||
emit_track_downloaded(context, automation_engine)
|
emit_track_downloaded(context, automation_engine)
|
||||||
record_library_history_download(context)
|
record_library_history_download(context)
|
||||||
record_download_provenance(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
|
final_path = downsampled_path
|
||||||
context['_final_processed_path'] = final_path
|
context['_final_processed_path'] = final_path
|
||||||
|
|
||||||
|
_persist_verification_status(context, final_path)
|
||||||
|
|
||||||
blasphemy_path = create_lossy_copy(final_path)
|
blasphemy_path = create_lossy_copy(final_path)
|
||||||
if blasphemy_path:
|
if blasphemy_path:
|
||||||
context['_final_processed_path'] = 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
|
final_path = downsampled_path
|
||||||
context['_final_processed_path'] = final_path
|
context['_final_processed_path'] = final_path
|
||||||
|
|
||||||
|
_persist_verification_status(context, final_path)
|
||||||
|
|
||||||
blasphemy_path = create_lossy_copy(final_path)
|
blasphemy_path = create_lossy_copy(final_path)
|
||||||
if blasphemy_path:
|
if blasphemy_path:
|
||||||
context['_final_processed_path'] = 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:
|
with tasks_lock:
|
||||||
if task_id in download_tasks:
|
if task_id in download_tasks:
|
||||||
_mark_task_completed(task_id, context.get('track_info'))
|
_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:
|
with matched_context_lock:
|
||||||
if context_key in matched_downloads_context:
|
if context_key in matched_downloads_context:
|
||||||
del matched_downloads_context[context_key]
|
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:
|
if task_id in download_tasks:
|
||||||
_mark_task_completed(task_id, context.get('track_info'))
|
_mark_task_completed(task_id, context.get('track_info'))
|
||||||
download_tasks[task_id]['metadata_enhanced'] = True
|
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')
|
redownload_ctx = download_tasks[task_id].get('_redownload_context')
|
||||||
|
|
||||||
with matched_context_lock:
|
with matched_context_lock:
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,7 @@ def list_quarantine_entries(quarantine_dir: str) -> List[Dict[str, Any]]:
|
||||||
"trigger": sidecar.get("trigger", "unknown"),
|
"trigger": sidecar.get("trigger", "unknown"),
|
||||||
"source_username": source_username,
|
"source_username": source_username,
|
||||||
"source_filename": source_filename,
|
"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
|
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]]:
|
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.
|
"""Locate the `.quarantined` file + JSON sidecar for an entry id.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -268,6 +268,7 @@ def record_library_history_download(context: Dict[str, Any]) -> None:
|
||||||
source_artist=source_artist,
|
source_artist=source_artist,
|
||||||
origin=origin,
|
origin=origin,
|
||||||
origin_context=origin_context,
|
origin_context=origin_context,
|
||||||
|
verification_status=context.get("_verification_status"),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("library history record failed: %s", e)
|
logger.debug("library history record failed: %s", e)
|
||||||
|
|
|
||||||
262
core/matching/audio_verification.py
Normal file
262
core/matching/audio_verification.py
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
"""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)
|
||||||
|
# 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.0–1.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 (kanji↔romaji 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}'")
|
||||||
58
core/matching/verification_status.py
Normal file
58
core/matching/verification_status.py
Normal 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'))
|
||||||
|
|
@ -15,6 +15,9 @@ from typing import Optional
|
||||||
from core.repair_jobs import register_job
|
from core.repair_jobs import register_job
|
||||||
from core.repair_jobs.base import JobContext, JobResult, RepairJob
|
from core.repair_jobs.base import JobContext, JobResult, RepairJob
|
||||||
from utils.logging_config import get_logger
|
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")
|
logger = get_logger("repair_job.acoustid")
|
||||||
|
|
||||||
|
|
@ -220,116 +223,111 @@ class AcoustIDScannerJob(RepairJob):
|
||||||
or expected['artist']
|
or expected['artist']
|
||||||
)
|
)
|
||||||
|
|
||||||
# Normalize and compare
|
# Verification status from the embedded SOULSYNC_VERIFICATION tag.
|
||||||
norm_expected_title = _normalize(expected['title'])
|
# Only a human decision short-circuits the scan: the user explicitly
|
||||||
norm_aid_title = _normalize(aid_title)
|
# confirmed the file via the review queue. Everything else (verified /
|
||||||
norm_expected_artist = _normalize(expected_artist)
|
# unverified / force_imported / untagged) is re-checked; force_imported
|
||||||
norm_aid_artist = _normalize(aid_artist)
|
# mismatches are reported as informational below since a mismatch
|
||||||
|
# there is EXPECTED (the user accepted the best candidate).
|
||||||
title_sim = SequenceMatcher(None, norm_expected_title, norm_aid_title).ratio()
|
file_verif_status = None
|
||||||
# Issue (Foxxify Discord report): AcoustID returns the FULL artist
|
try:
|
||||||
# credit (e.g. `Okayracer, aldrch & poptropicaslutz!`) while the
|
from core.tag_writer import read_file_tags as _rft
|
||||||
# library DB carries only the primary artist (`Okayracer`). Raw
|
file_verif_status = (_rft(fpath) or {}).get('verification_status')
|
||||||
# similarity scores ~43% — well below threshold — so multi-artist
|
except Exception:
|
||||||
# tracks get flagged as Wrong Song even though the primary IS in
|
pass
|
||||||
# the credit. Route through the shared `artist_names_match` helper
|
if file_verif_status == 'human_verified':
|
||||||
# which splits the credit on common separators (comma, ampersand,
|
# The user explicitly confirmed this file via the review queue —
|
||||||
# feat./ft./with/vs., etc.) and checks each token. Primary-in-
|
# never second-guess a human decision.
|
||||||
# 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.
|
|
||||||
if context.report_progress:
|
if context.report_progress:
|
||||||
context.report_progress(
|
context.report_progress(
|
||||||
log_line=(
|
log_line=f'Skipped (human-verified): {fname}', log_type='skip')
|
||||||
f'Resolved (lower-ranked candidate match): {fname} — '
|
return
|
||||||
f'expected "{expected["title"]}" matched candidate '
|
|
||||||
f'"{candidate_match.get("title")}" by '
|
# Fingerprint-collision guard: when the TOP recording's length is wildly
|
||||||
f'"{candidate_match.get("artist")}"'
|
# 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',
|
log_type='ok',
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Issue #587 (Foxxify "17min mashup → 5min track") — duration
|
title_sim = outcome.title_sim
|
||||||
# guard against fingerprint hash collisions. When the file's
|
artist_sim = outcome.artist_sim
|
||||||
# actual duration differs from AcoustID's matched recording by
|
matched_title = outcome.matched_title or aid_title
|
||||||
# more than max(60s, 35%), the fingerprint is almost certainly
|
matched_artist = outcome.matched_artist or aid_artist
|
||||||
# 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
|
|
||||||
|
|
||||||
# Mismatch detected
|
# Mismatch (FAIL) — create finding.
|
||||||
if context.report_progress:
|
if context.report_progress:
|
||||||
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'
|
log_type='error'
|
||||||
)
|
)
|
||||||
if context.create_finding:
|
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(
|
inserted = context.create_finding(
|
||||||
job_id=self.job_id,
|
job_id=self.job_id,
|
||||||
finding_type='acoustid_mismatch',
|
finding_type='acoustid_mismatch',
|
||||||
|
|
@ -337,18 +335,18 @@ class AcoustIDScannerJob(RepairJob):
|
||||||
entity_type='track',
|
entity_type='track',
|
||||||
entity_id=str(track_id),
|
entity_id=str(track_id),
|
||||||
file_path=fpath,
|
file_path=fpath,
|
||||||
title=f'Wrong download: "{expected["title"]}" is actually "{aid_title}"',
|
title=_title,
|
||||||
description=(
|
description=(
|
||||||
f'Expected "{expected["title"]}" by {expected_artist}, '
|
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'(fingerprint: {best_score:.0%}, title match: {title_sim:.0%}, '
|
||||||
f'artist match: {artist_sim:.0%})'
|
f'artist match: {artist_sim:.0%})'
|
||||||
),
|
),
|
||||||
details={
|
details={
|
||||||
'expected_title': expected['title'],
|
'expected_title': expected['title'],
|
||||||
'expected_artist': expected_artist,
|
'expected_artist': expected_artist,
|
||||||
'acoustid_title': aid_title,
|
'acoustid_title': matched_title,
|
||||||
'acoustid_artist': aid_artist,
|
'acoustid_artist': matched_artist,
|
||||||
'fingerprint_score': round(best_score, 3),
|
'fingerprint_score': round(best_score, 3),
|
||||||
'title_similarity': round(title_sim, 3),
|
'title_similarity': round(title_sim, 3),
|
||||||
'artist_similarity': round(artist_sim, 3),
|
'artist_similarity': round(artist_sim, 3),
|
||||||
|
|
@ -356,6 +354,7 @@ class AcoustIDScannerJob(RepairJob):
|
||||||
'artist_thumb_url': expected.get('artist_thumb_url'),
|
'artist_thumb_url': expected.get('artist_thumb_url'),
|
||||||
'album_title': expected.get('album_title', ''),
|
'album_title': expected.get('album_title', ''),
|
||||||
'track_number': expected.get('track_number'),
|
'track_number': expected.get('track_number'),
|
||||||
|
'force_imported': file_verif_status == 'force_imported',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if inserted:
|
if inserted:
|
||||||
|
|
@ -363,6 +362,56 @@ class AcoustIDScannerJob(RepairJob):
|
||||||
else:
|
else:
|
||||||
result.findings_skipped_dedup += 1
|
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:
|
def _load_db_tracks(self, context: JobContext) -> dict:
|
||||||
"""Load all tracks from DB keyed by track ID."""
|
"""Load all tracks from DB keyed by track ID."""
|
||||||
tracks = {}
|
tracks = {}
|
||||||
|
|
@ -480,11 +529,3 @@ class AcoustIDScannerJob(RepairJob):
|
||||||
finally:
|
finally:
|
||||||
if conn:
|
if conn:
|
||||||
conn.close()
|
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()
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ def read_file_tags(file_path: str) -> Dict[str, Any]:
|
||||||
'replaygain_track_peak': None,
|
'replaygain_track_peak': None,
|
||||||
'replaygain_album_gain': None,
|
'replaygain_album_gain': None,
|
||||||
'replaygain_album_peak': 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):
|
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):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
result['has_cover_art'] = bool(audio.tags.getall('APIC'))
|
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':
|
elif isinstance(audio, (FLAC, OggVorbis)) or type(audio).__name__ == 'OggOpus':
|
||||||
# FLAC / OGG
|
# FLAC / OGG
|
||||||
|
|
@ -102,6 +108,7 @@ def read_file_tags(file_path: str) -> Dict[str, Any]:
|
||||||
else:
|
else:
|
||||||
# OGG doesn't have a standard picture field we can easily check
|
# OGG doesn't have a standard picture field we can easily check
|
||||||
result['has_cover_art'] = False
|
result['has_cover_art'] = False
|
||||||
|
result['verification_status'] = _vorbis_first(audio, 'soulsync_verification')
|
||||||
|
|
||||||
elif isinstance(audio, MP4):
|
elif isinstance(audio, MP4):
|
||||||
# MP4 / M4A
|
# MP4 / M4A
|
||||||
|
|
@ -118,6 +125,12 @@ def read_file_tags(file_path: str) -> Dict[str, Any]:
|
||||||
if disk:
|
if disk:
|
||||||
result['disc_number'] = disk[0][0] if isinstance(disk[0], tuple) else None
|
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
|
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:
|
except Exception as e:
|
||||||
result['error'] = str(e)
|
result['error'] = str(e)
|
||||||
|
|
@ -156,6 +169,39 @@ def is_placeholder_meta(value: Any) -> bool:
|
||||||
return s == '' or s in _PLACEHOLDER_META_VALUES
|
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:
|
def guard_placeholder_overwrite(db_val: Any, file_val: Any) -> Any:
|
||||||
"""#800 guard: never replace a real file value with a placeholder.
|
"""#800 guard: never replace a real file value with a placeholder.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -638,11 +638,29 @@ class MusicDatabase:
|
||||||
if 'download_source' not in lh_cols:
|
if 'download_source' not in lh_cols:
|
||||||
cursor.execute("ALTER TABLE library_history ADD COLUMN download_source TEXT")
|
cursor.execute("ALTER TABLE library_history ADD COLUMN download_source TEXT")
|
||||||
logger.info("Added download_source column to library_history")
|
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:
|
if _col not in lh_cols:
|
||||||
cursor.execute(f"ALTER TABLE library_history ADD COLUMN {_col} TEXT")
|
cursor.execute(f"ALTER TABLE library_history ADD COLUMN {_col} TEXT")
|
||||||
logger.info(f"Added {_col} column to library_history")
|
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
|
# Migration: download-origin provenance — what TRIGGERED a download
|
||||||
# ('watchlist' + artist / 'playlist' + playlist name). Read by the
|
# ('watchlist' + artist / 'playlist' + playlist name). Read by the
|
||||||
# origin-history modal on the watchlist + sync pages.
|
# origin-history modal on the watchlist + sync pages.
|
||||||
|
|
@ -2411,6 +2429,11 @@ class MusicDatabase:
|
||||||
if 'musicbrainz_match_status' not in tracks_columns:
|
if 'musicbrainz_match_status' not in tracks_columns:
|
||||||
cursor.execute("ALTER TABLE tracks ADD COLUMN musicbrainz_match_status TEXT")
|
cursor.execute("ALTER TABLE tracks ADD COLUMN musicbrainz_match_status TEXT")
|
||||||
added_tracks = True
|
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:
|
if added_tracks:
|
||||||
columns_added = True
|
columns_added = True
|
||||||
logger.info("Added MusicBrainz columns to tracks table")
|
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,
|
quality=None, server_source=None, file_path=None, thumb_url=None,
|
||||||
download_source=None, source_track_id=None, source_track_title=None,
|
download_source=None, source_track_id=None, source_track_title=None,
|
||||||
source_filename=None, acoustid_result=None, source_artist=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.
|
"""Record a download or import event to the library history table.
|
||||||
|
|
||||||
``origin``/``origin_context`` record what TRIGGERED the download
|
``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,
|
INSERT INTO library_history (event_type, title, artist_name, album_name,
|
||||||
quality, server_source, file_path, thumb_url, download_source,
|
quality, server_source, file_path, thumb_url, download_source,
|
||||||
source_track_id, source_track_title, source_filename,
|
source_track_id, source_track_title, source_filename,
|
||||||
acoustid_result, source_artist, origin, origin_context)
|
acoustid_result, source_artist, origin, origin_context,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
verification_status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""", (event_type, title, artist_name, album_name, quality, server_source, file_path, thumb_url,
|
""", (event_type, title, artist_name, album_name, quality, server_source, file_path, thumb_url,
|
||||||
download_source, source_track_id, source_track_title, source_filename,
|
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()
|
conn.commit()
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
84
tests/imports/test_folder_artist_override.py
Normal file
84
tests/imports/test_folder_artist_override.py
Normal 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
|
||||||
|
|
@ -431,10 +431,10 @@ class TestAliasRescueLogging:
|
||||||
records.append(record)
|
records.append(record)
|
||||||
|
|
||||||
handler = _ListHandler(level=_logging.INFO)
|
handler = _ListHandler(level=_logging.INFO)
|
||||||
# Logger name is `soulsync.acoustid.verification` per
|
# The alias-aware comparison now lives in the shared core
|
||||||
# `core.acoustid_verification`'s `get_logger("acoustid_verification")`
|
# (`core.matching.audio_verification`, logger `audio_verification`),
|
||||||
# — dot-separated, NOT underscored.
|
# which is where the rescue diagnostic is emitted.
|
||||||
verifier_logger = _logging.getLogger('soulsync.acoustid.verification')
|
verifier_logger = _logging.getLogger('soulsync.audio_verification')
|
||||||
verifier_logger.addHandler(handler)
|
verifier_logger.addHandler(handler)
|
||||||
prior_level = verifier_logger.level
|
prior_level = verifier_logger.level
|
||||||
verifier_logger.setLevel(_logging.INFO)
|
verifier_logger.setLevel(_logging.INFO)
|
||||||
|
|
|
||||||
83
tests/matching/test_audio_verification_core.py
Normal file
83
tests/matching/test_audio_verification_core.py
Normal 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
|
||||||
36
tests/matching/test_verification_status.py
Normal file
36
tests/matching/test_verification_status.py
Normal 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
|
||||||
36
tests/test_acoustid_normalize_angle_annotations.py
Normal file
36
tests/test_acoustid_normalize_angle_annotations.py
Normal 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"
|
||||||
|
|
@ -199,7 +199,11 @@ def test_scanner_still_flags_genuine_artist_mismatch():
|
||||||
'best_score': 0.99,
|
'best_score': 0.99,
|
||||||
'recordings': [{
|
'recordings': [{
|
||||||
'title': 'Some Track',
|
'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,
|
'best_score': 0.99,
|
||||||
'recordings': [{
|
'recordings': [{
|
||||||
'title': 'Some Track',
|
'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
|
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)
|
||||||
|
|
|
||||||
50
tests/test_verification_tag_roundtrip.py
Normal file
50
tests/test_verification_tag_roundtrip.py
Normal 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
|
||||||
395
web_server.py
395
web_server.py
|
|
@ -7578,6 +7578,394 @@ def stream_quarantine_item(entry_id):
|
||||||
return jsonify({"error": str(e)}), 500
|
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: Docker↔host 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.
|
||||||
|
"""
|
||||||
|
raw_path = (row.get('file_path') or '').strip()
|
||||||
|
if raw_path and os.path.exists(raw_path):
|
||||||
|
return raw_path
|
||||||
|
resolved = _resolve_library_file_path(raw_path) if raw_path else None
|
||||||
|
if resolved and os.path.exists(resolved):
|
||||||
|
return resolved
|
||||||
|
title = (row.get('title') or '').strip()
|
||||||
|
if not title:
|
||||||
|
return None
|
||||||
|
artist = (row.get('artist_name') or '').strip()
|
||||||
|
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,))
|
||||||
|
candidates = [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 None
|
||||||
|
# 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; otherwise 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_file_path(cand)
|
||||||
|
if cand_resolved and os.path.exists(cand_resolved):
|
||||||
|
return cand_resolved
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@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:
|
||||||
|
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'])
|
||||||
|
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."""
|
||||||
|
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)
|
||||||
|
conn = db._get_connection()
|
||||||
|
conn.cursor().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.cursor().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'])
|
||||||
|
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)."""
|
||||||
|
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'])
|
@app.route('/api/quarantine/<entry_id>/recover', methods=['POST'])
|
||||||
def recover_quarantine_item(entry_id):
|
def recover_quarantine_item(entry_id):
|
||||||
"""Fallback for legacy thin sidecars: move file into Staging so the user
|
"""Fallback for legacy thin sidecars: move file into Staging so the user
|
||||||
|
|
@ -12801,6 +13189,11 @@ def stream_audio():
|
||||||
stream_url = sess.get("stream_url")
|
stream_url = sess.get("stream_url")
|
||||||
if not file_path and not stream_url:
|
if not file_path and not stream_url:
|
||||||
return jsonify({"error": "No audio file ready for streaming"}), 404
|
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).
|
# Library track played via the media server's stream API (#809).
|
||||||
if stream_url:
|
if stream_url:
|
||||||
|
|
@ -12808,7 +13201,7 @@ def stream_audio():
|
||||||
return _proxy_stream_url_with_range(stream_url)
|
return _proxy_stream_url_with_range(stream_url)
|
||||||
|
|
||||||
logger.info(f"Serving audio file: {os.path.basename(file_path)}")
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error serving audio file: {e}")
|
logger.error(f"Error serving audio file: {e}")
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
|
||||||
|
|
@ -2427,6 +2427,7 @@
|
||||||
<button class="adl-pill" data-filter="queued" onclick="adlSetFilter('queued')">Queued</button>
|
<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="completed" onclick="adlSetFilter('completed')">Completed</button>
|
||||||
<button class="adl-pill" data-filter="failed" onclick="adlSetFilter('failed')">Failed</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>
|
||||||
<div style="display:flex;align-items:center;gap:10px;">
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
<span class="adl-count" id="adl-count"></span>
|
<span class="adl-count" id="adl-count"></span>
|
||||||
|
|
@ -6320,6 +6321,22 @@
|
||||||
(e.g. MP3), it will be replaced with the higher quality version (e.g. FLAC).
|
(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.
|
If disabled, existing tracks are always kept and the import file is skipped.
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3676,10 +3676,36 @@ function processModalStatusUpdate(playlistId, data) {
|
||||||
} else {
|
} else {
|
||||||
switch (task.status) {
|
switch (task.status) {
|
||||||
case 'pending': statusText = '⏸️ Pending'; break;
|
case 'pending': statusText = '⏸️ Pending'; break;
|
||||||
case 'searching': statusText = '🔍 Searching...'; break;
|
case 'searching':
|
||||||
case 'downloading': statusText = `⏬ Downloading... ${Math.round(task.progress || 0)}%`; break;
|
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 '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 'not_found': statusText = '🔇 Not Found'; notFoundCount++; break;
|
||||||
case 'failed': {
|
case 'failed': {
|
||||||
// Distinguish quarantine outcomes from generic
|
// Distinguish quarantine outcomes from generic
|
||||||
|
|
@ -3712,7 +3738,14 @@ function processModalStatusUpdate(playlistId, data) {
|
||||||
delete statusEl.dataset.quarantineReason;
|
delete statusEl.dataset.quarantineReason;
|
||||||
delete statusEl.dataset.quarantineTrack;
|
delete statusEl.dataset.quarantineTrack;
|
||||||
delete statusEl.dataset.detailOpen;
|
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
|
// Visual-only hooks: the cell carries its state for the badge
|
||||||
// styling, the row glows while a track is actively working.
|
// styling, the row glows while a track is actively working.
|
||||||
statusEl.dataset.state = isQuarantinedTask ? 'quarantined'
|
statusEl.dataset.state = isQuarantinedTask ? 'quarantined'
|
||||||
|
|
|
||||||
|
|
@ -2288,6 +2288,7 @@ function _adlRenderBatchSummary(activeBatches) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadActiveDownloadsPage() {
|
function loadActiveDownloadsPage() {
|
||||||
|
_verifLoadConfig();
|
||||||
_adlFetch();
|
_adlFetch();
|
||||||
_adlFetchBatchHistory();
|
_adlFetchBatchHistory();
|
||||||
// Poll downloads every 2 seconds, history every 60 seconds
|
// 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() {
|
function _adlRender() {
|
||||||
const list = document.getElementById('adl-list');
|
const list = document.getElementById('adl-list');
|
||||||
const empty = document.getElementById('adl-empty');
|
const empty = document.getElementById('adl-empty');
|
||||||
|
|
@ -2370,6 +2871,10 @@ function _adlRender() {
|
||||||
if (_adlFilter === 'active') filtered = filtered.filter(d => activeStatuses.includes(d.status));
|
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 === 'queued') filtered = filtered.filter(d => queuedStatuses.includes(d.status));
|
||||||
else if (_adlFilter === 'completed') filtered = filtered.filter(d => completedStatuses.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));
|
else if (_adlFilter === 'failed') filtered = filtered.filter(d => failedStatuses.includes(d.status));
|
||||||
|
|
||||||
const completedN = _adlData.filter(d =>
|
const completedN = _adlData.filter(d =>
|
||||||
|
|
@ -2419,6 +2924,48 @@ function _adlRender() {
|
||||||
existingBanner.style.display = 'none';
|
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 (filtered.length === 0) {
|
||||||
if (empty) empty.style.display = '';
|
if (empty) empty.style.display = '';
|
||||||
// Clear any existing rows but keep the empty message
|
// Clear any existing rows but keep the empty message
|
||||||
|
|
@ -2455,6 +3002,7 @@ function _adlRender() {
|
||||||
for (const dl of section.items) {
|
for (const dl of section.items) {
|
||||||
const statusClass = _adlStatusClass(dl.status);
|
const statusClass = _adlStatusClass(dl.status);
|
||||||
const statusLabel = _adlStatusLabel(dl.status);
|
const statusLabel = _adlStatusLabel(dl.status);
|
||||||
|
// (verification badge appended next to the label via _adlVerifBadge)
|
||||||
const title = _adlEsc(dl.title || 'Unknown Track');
|
const title = _adlEsc(dl.title || 'Unknown Track');
|
||||||
const artist = _adlEsc(dl.artist || '');
|
const artist = _adlEsc(dl.artist || '');
|
||||||
const album = _adlEsc(dl.album || '');
|
const album = _adlEsc(dl.album || '');
|
||||||
|
|
@ -2494,8 +3042,9 @@ function _adlRender() {
|
||||||
</div>
|
</div>
|
||||||
<div class="adl-row-status ${statusClass}">
|
<div class="adl-row-status ${statusClass}">
|
||||||
<span class="adl-status-dot ${statusClass}"></span>
|
<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>
|
</div>
|
||||||
|
${_adlReviewActions(dl)}
|
||||||
${cancelBtnHtml}
|
${cancelBtnHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
@ -3126,4 +3675,3 @@ window.adlCancelRow = adlCancelRow;
|
||||||
window.adlCancelAll = adlCancelAll;
|
window.adlCancelAll = adlCancelAll;
|
||||||
window.adlToggleBatchHistory = adlToggleBatchHistory;
|
window.adlToggleBatchHistory = adlToggleBatchHistory;
|
||||||
window.adlToggleBatchPanel = adlToggleBatchPanel;
|
window.adlToggleBatchPanel = adlToggleBatchPanel;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1297,6 +1297,8 @@ async function loadSettingsData() {
|
||||||
|
|
||||||
// Populate Import settings
|
// Populate Import settings
|
||||||
document.getElementById('import-replace-lower-quality').checked = settings.import?.replace_lower_quality === true;
|
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
|
// Populate M3U Export settings
|
||||||
document.getElementById('m3u-export-enabled').checked = settings.m3u_export?.enabled === true;
|
document.getElementById('m3u-export-enabled').checked = settings.m3u_export?.enabled === true;
|
||||||
|
|
@ -3128,6 +3130,7 @@ async function saveSettings(quiet = false) {
|
||||||
},
|
},
|
||||||
import: {
|
import: {
|
||||||
replace_lower_quality: document.getElementById('import-replace-lower-quality').checked,
|
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'
|
staging_path: document.getElementById('staging-path').value || './Staging'
|
||||||
},
|
},
|
||||||
lossy_copy: {
|
lossy_copy: {
|
||||||
|
|
|
||||||
|
|
@ -10864,6 +10864,26 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
||||||
border-color: rgba(239, 83, 80, 0.3);
|
border-color: rgba(239, 83, 80, 0.3);
|
||||||
color: #ef5350;
|
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 */
|
/* Tab bar */
|
||||||
.download-audit-tabs {
|
.download-audit-tabs {
|
||||||
|
|
@ -67893,6 +67913,30 @@ body.em-scroll-lock { overflow: hidden; }
|
||||||
}
|
}
|
||||||
.ma-token-input:focus { outline: none; border-color: var(--ma-brand); }
|
.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 settings: grouped sub-sections + dependency visuals ── */
|
||||||
.security-subgroup {
|
.security-subgroup {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
|
|
||||||
|
|
@ -3799,8 +3799,10 @@ function openDownloadAuditModal(entry) {
|
||||||
|
|
||||||
// Live tag read for the Tags tab. Fire-and-forget on open so the
|
// 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.
|
// data is ready by the time the user switches to the Tags tab.
|
||||||
if (entry.id != null) {
|
// Synthetic entries (quarantine review queue) have no history id but
|
||||||
fetchAndRenderEmbeddedTags(entry.id);
|
// 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);
|
if (body) body.innerHTML = renderDownloadAuditTabPanel(tabName, _downloadAuditEntry);
|
||||||
// Re-fire the live fetch when switching to Tags (in case it
|
// Re-fire the live fetch when switching to Tags (in case it
|
||||||
// raced past first mount before the user got there).
|
// raced past first mount before the user got there).
|
||||||
if (tabName === 'tags' && _downloadAuditEntry.id != null) {
|
if ((tabName === 'tags' || tabName === 'lyrics') &&
|
||||||
fetchAndRenderEmbeddedTags(_downloadAuditEntry.id);
|
(_downloadAuditEntry.id != null || _downloadAuditEntry._file_tags_url)) {
|
||||||
}
|
fetchAndRenderEmbeddedTags(_downloadAuditEntry.id, _downloadAuditEntry._file_tags_url);
|
||||||
if (tabName === 'lyrics' && _downloadAuditEntry.id != null) {
|
|
||||||
fetchAndRenderEmbeddedTags(_downloadAuditEntry.id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.switchAuditTab = switchAuditTab;
|
window.switchAuditTab = switchAuditTab;
|
||||||
|
|
@ -3856,7 +3856,14 @@ function renderDownloadAuditHero(entry) {
|
||||||
const pills = [];
|
const pills = [];
|
||||||
if (entry.download_source) pills.push(_auditHeroPill('source', entry.download_source));
|
if (entry.download_source) pills.push(_auditHeroPill('source', entry.download_source));
|
||||||
if (entry.quality) pills.push(_auditHeroPill('quality', entry.quality));
|
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 `
|
return `
|
||||||
${art}
|
${art}
|
||||||
|
|
@ -3957,7 +3964,7 @@ function renderEmbeddedTagsSection(entry) {
|
||||||
return renderDownloadAuditTagsTab(entry);
|
return renderDownloadAuditTagsTab(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAndRenderEmbeddedTags(historyId) {
|
async function fetchAndRenderEmbeddedTags(historyId, urlOverride) {
|
||||||
const tagsSlot = document.getElementById('download-audit-tags-body');
|
const tagsSlot = document.getElementById('download-audit-tags-body');
|
||||||
const lyricsSlot = document.getElementById('download-audit-lyrics-body');
|
const lyricsSlot = document.getElementById('download-audit-lyrics-body');
|
||||||
if (!tagsSlot && !lyricsSlot) return;
|
if (!tagsSlot && !lyricsSlot) return;
|
||||||
|
|
@ -3967,7 +3974,7 @@ async function fetchAndRenderEmbeddedTags(historyId) {
|
||||||
if (lyricsSlot) lyricsSlot.innerHTML = html;
|
if (lyricsSlot) lyricsSlot.innerHTML = html;
|
||||||
};
|
};
|
||||||
try {
|
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; }
|
if (!resp.ok) { renderError(`Could not read file tags (HTTP ${resp.status}).`); return; }
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (!data.success) { renderError(data.error || 'Could not read file tags.'); return; }
|
if (!data.success) { renderError(data.error || 'Could not read file tags.'); return; }
|
||||||
|
|
@ -4263,8 +4270,8 @@ function buildDownloadAuditSteps(entry) {
|
||||||
{
|
{
|
||||||
key: 'verify',
|
key: 'verify',
|
||||||
title: 'Verification',
|
title: 'Verification',
|
||||||
status: auditStatusFromAcoustid(entry.acoustid_result),
|
status: auditVerifyStatus(entry),
|
||||||
detail: buildAcoustidDetail(entry.acoustid_result),
|
detail: buildVerificationDetail(entry),
|
||||||
meta: [],
|
meta: [],
|
||||||
},
|
},
|
||||||
(() => {
|
(() => {
|
||||||
|
|
@ -4381,6 +4388,37 @@ function buildSourceMatchMeta(entry) {
|
||||||
return out;
|
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) {
|
function auditStatusFromAcoustid(result) {
|
||||||
if (!result) return 'unknown';
|
if (!result) return 'unknown';
|
||||||
if (result === 'pass') return 'complete';
|
if (result === 'pass') return 'complete';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue