diff --git a/core/acoustid_verification.py b/core/acoustid_verification.py
index 24930ec0..42cc5500 100644
--- a/core/acoustid_verification.py
+++ b/core/acoustid_verification.py
@@ -17,6 +17,7 @@ from utils.logging_config import get_logger
from core.acoustid_client import AcoustIDClient
from core.matching_engine import MusicMatchingEngine
from core.matching.version_mismatch import is_acceptable_version_mismatch
+from core.matching.script_compat import is_cross_script_mismatch
from core.musicbrainz_client import MusicBrainzClient
logger = get_logger("acoustid.verification")
@@ -655,10 +656,32 @@ class AcoustIDVerification:
and title_sim >= 0.80
and artist_sim >= ARTIST_MATCH_THRESHOLD
)
- if language_script_skip or high_confidence_strong_match_skip:
+ # 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
+ if (language_script_skip or cross_script_artist_skip)
else "title/artist match within tolerance"
)
msg = (
diff --git a/core/downloads/candidates.py b/core/downloads/candidates.py
index 9510a0f5..ddce91fe 100644
--- a/core/downloads/candidates.py
+++ b/core/downloads/candidates.py
@@ -326,6 +326,17 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
"task=%s username=%s filename=%s",
task_id, username, os.path.basename(filename),
)
+ elif track_info and track_info.get('_skip_acoustid'):
+ # Issue #797 — the album-download request had the
+ # per-request "Skip AcoustID verification" toggle on.
+ # Bypass only the AcoustID gate (same as a manual
+ # pick); integrity + bit-depth still run.
+ matched_downloads_context[context_key]['_skip_quarantine_check'] = 'acoustid'
+ logger.info(
+ "[Context] Skip-AcoustID toggle — bypassing AcoustID for "
+ "task=%s filename=%s",
+ task_id, os.path.basename(filename),
+ )
logger.info(f"[Context] Set is_album_download: {is_album_context} (has clean data: {has_clean_spotify_data})")
logger.debug(f"[Debug] Context creation - track_info: {track_info is not None}, playlist_folder_mode: {track_info.get('_playlist_folder_mode', False) if track_info else False}")
diff --git a/core/downloads/master.py b/core/downloads/master.py
index 3769dca3..dfcb4eab 100644
--- a/core/downloads/master.py
+++ b/core/downloads/master.py
@@ -347,6 +347,13 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
batch_playlist_name = 'Unknown Playlist'
batch_playlist_id = playlist_id
batch_source_playlist_ref = ''
+ # Issue #797 — per-request "Skip AcoustID verification" toggle from
+ # the album-download modal. When set, every track in this batch
+ # bypasses the AcoustID quarantine gate (the user has chosen to
+ # trust the metadata over fingerprint disagreement — useful for
+ # non-English artists whose native-script metadata AcoustID can't
+ # reconcile with the romanized request).
+ batch_skip_acoustid = False
with tasks_lock:
if batch_id in download_batches:
force_download_all = download_batches[batch_id].get('force_download_all', False)
@@ -362,6 +369,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
batch_source_playlist_ref = (
download_batches[batch_id].get('source_playlist_ref') or ''
).strip()
+ batch_skip_acoustid = bool(download_batches[batch_id].get('skip_acoustid', False))
from core.downloads.playlist_folder import (
resolve_playlist_folder_mode_for_batch,
@@ -1031,6 +1039,14 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
logger.info(f"[Wishlist] Added album context for: '{track_info.get('name')}' -> '{album_ctx['name']}'")
+ # Issue #797 — propagate the batch-level "skip AcoustID"
+ # toggle onto each track so the per-track download context
+ # (built in core/downloads/candidates.py) can set the
+ # AcoustID quarantine bypass. Mirrors the _playlist_folder_mode
+ # threading pattern below.
+ if batch_skip_acoustid:
+ track_info['_skip_acoustid'] = True
+
# Add playlist folder mode flag for sync page playlists and wishlist
# tracks tied to a mirrored playlist with organize_by_playlist enabled.
task_pl_folder_mode = batch_playlist_folder_mode
diff --git a/core/matching/script_compat.py b/core/matching/script_compat.py
new file mode 100644
index 00000000..88b1cb96
--- /dev/null
+++ b/core/matching/script_compat.py
@@ -0,0 +1,96 @@
+"""Writing-system (script) compatibility helpers for metadata comparison.
+
+Issue #797 — AcoustID returns a recording's title/artist in their
+*original* script (e.g. ``久石譲`` for Joe Hisaishi) while SoulSync's
+expected metadata is romanized / English (``Joe Hisaishi``). A raw
+string-similarity comparison between two different writing systems
+scores ~0 even when they name the very same artist, so correct
+downloads of non-English artists get false-quarantined.
+
+These pure helpers let callers DETECT that situation — "one side is
+written in a non-Latin script, the other in Latin" — so the comparison
+logic can stop treating an untranslatable title/artist as evidence the
+file is wrong.
+
+Deliberately conservative: a single accented Latin character (``é``,
+``ñ``, ``ü``) is still Latin, NOT a script mismatch. Only genuinely
+different writing systems (CJK, Hangul, Cyrillic, Greek, Arabic,
+Hebrew, Thai, …) count as "non-Latin".
+"""
+
+from __future__ import annotations
+
+# Unicode ranges for non-Latin writing systems we treat as a "hard"
+# script difference. Latin (incl. Latin-1 Supplement / Extended with
+# diacritics) is intentionally absent — accented Latin is still Latin.
+# CJK ranges mirror core.matching_engine's issue #722 detection so the
+# two stay consistent.
+_NONLATIN_RANGES = (
+ ('Ͱ', 'Ͽ'), # Greek and Coptic
+ ('Ѐ', 'ӿ'), # Cyrillic
+ ('Ԁ', 'ԯ'), # Cyrillic Supplement
+ ('', ''), # Hebrew
+ ('', 'ۿ'), # Arabic
+ ('ݐ', 'ݿ'), # Arabic Supplement
+ ('', ''), # Thai
+ ('⺀', ''), # CJK Radicals Supplement
+ ('', 'ゟ'), # Hiragana
+ ('゠', 'ヿ'), # Katakana
+ ('㐀', '䶿'), # CJK Unified Ideographs Extension A
+ ('一', '鿿'), # CJK Unified Ideographs
+ ('가', ''), # Hangul Syllables
+ ('豈', ''), # CJK Compatibility Ideographs
+ ('ヲ', 'ᅵ'), # Halfwidth Katakana / Hangul
+)
+
+
+def _is_nonlatin_char(c: str) -> bool:
+ """True when ``c`` belongs to a non-Latin writing system."""
+ for lo, hi in _NONLATIN_RANGES:
+ if lo <= c <= hi:
+ return True
+ return False
+
+
+def has_strong_nonlatin(text: str) -> bool:
+ """True when ``text`` contains at least one non-Latin-script letter.
+
+ Accented Latin (``Beyoncé``, ``Sigur Rós``, ``Mötley Crüe``) returns
+ False — those are Latin. ``久石譲``, ``Дмитрий``, ``방탄소년단`` return True.
+ """
+ if not text:
+ return False
+ return any(_is_nonlatin_char(c) for c in text)
+
+
+def _has_latin_letter(text: str) -> bool:
+ """True when ``text`` contains an ASCII A–Z / a–z letter."""
+ if not text:
+ return False
+ return any(('a' <= c <= 'z') or ('A' <= c <= 'Z') for c in text)
+
+
+def is_cross_script_mismatch(a: str, b: str) -> bool:
+ """True when ``a`` and ``b`` are written in different scripts.
+
+ Specifically: exactly one side uses a non-Latin writing system while
+ the other is genuine Latin text. This is the signal that a raw
+ similarity score between the two is meaningless (a romanized name vs
+ its native-script form), NOT that they name different things.
+
+ Symmetric. Returns False when:
+ - both sides are Latin (ordinary English-vs-English comparison),
+ - both sides are non-Latin (same-script comparison still works),
+ - either side is empty / has no comparable letters.
+ """
+ a_nonlatin = has_strong_nonlatin(a)
+ b_nonlatin = has_strong_nonlatin(b)
+ if a_nonlatin == b_nonlatin:
+ # Same script class on both sides (or neither has non-Latin) —
+ # similarity comparison is meaningful, no script bridge needed.
+ return False
+ # Exactly one side is non-Latin. It's only a true cross-script case
+ # if the OTHER side is real Latin text (not punctuation / digits).
+ if a_nonlatin:
+ return _has_latin_letter(b)
+ return _has_latin_letter(a)
diff --git a/tests/downloads/test_downloads_candidates.py b/tests/downloads/test_downloads_candidates.py
index 95ed1328..8a80a3ab 100644
--- a/tests/downloads/test_downloads_candidates.py
+++ b/tests/downloads/test_downloads_candidates.py
@@ -461,6 +461,45 @@ def test_auto_search_pick_does_not_inject_acoustid_bypass():
assert "_user_manual_pick" not in ctx
+def test_skip_acoustid_track_flag_injects_bypass():
+ """Issue #797: when the album-download request had the per-request
+ 'Skip AcoustID verification' toggle on, the master worker stamps
+ `_skip_acoustid=True` onto each track's track_info. The candidates
+ helper must propagate that into the post-process context as
+ `_skip_quarantine_check='acoustid'` so AcoustID never quarantines
+ this request's files (e.g. correct downloads of non-English artists
+ whose native-script metadata AcoustID can't reconcile)."""
+ deps = _build_deps()
+ _seed_task("t_skip_aid", track_info={"_skip_acoustid": True})
+
+ candidates = [_Candidate(filename="skip.flac", confidence=0.99)]
+ track = _Track()
+
+ result = dc.attempt_download_with_candidates("t_skip_aid", candidates, track, batch_id="b1", deps=deps)
+
+ assert result is True
+ ctx = matched_downloads_context["user1::skip.flac"]
+ assert ctx["_skip_quarantine_check"] == "acoustid"
+ # It's the toggle path, NOT a manual pick.
+ assert "_user_manual_pick" not in ctx
+
+
+def test_no_skip_acoustid_flag_keeps_verification():
+ """Without the toggle (no `_skip_acoustid` on track_info), AcoustID
+ verification must still run — the bypass is opt-in per request."""
+ deps = _build_deps()
+ _seed_task("t_no_skip_aid", track_info={}) # no _skip_acoustid
+
+ candidates = [_Candidate(filename="verify.flac", confidence=0.99)]
+ track = _Track()
+
+ result = dc.attempt_download_with_candidates("t_no_skip_aid", candidates, track, batch_id="b1", deps=deps)
+
+ assert result is True
+ ctx = matched_downloads_context["user1::verify.flac"]
+ assert "_skip_quarantine_check" not in ctx
+
+
def test_equal_confidence_candidates_prefer_better_peer_quality():
"""Equal-confidence Soulseek candidates use peer quality as the tiebreaker."""
deps = _build_deps()
diff --git a/tests/matching/test_script_compat.py b/tests/matching/test_script_compat.py
new file mode 100644
index 00000000..644d16b5
--- /dev/null
+++ b/tests/matching/test_script_compat.py
@@ -0,0 +1,79 @@
+"""Tests for core/matching/script_compat.py — writing-system detection.
+
+Issue #797 — these pin the exact boundary that the AcoustID verifier
+relies on: accented Latin is still Latin (no false cross-script
+trigger), but genuinely different writing systems (CJK / Hangul /
+Cyrillic / Greek / Arabic / Hebrew / Thai) ARE flagged so a
+romanized-vs-native artist comparison isn't treated as a real mismatch.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from core.matching.script_compat import (
+ has_strong_nonlatin,
+ is_cross_script_mismatch,
+)
+
+
+# ---------------------------------------------------------------------------
+# has_strong_nonlatin — accented Latin must NOT count
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize('text', [
+ 'Beyoncé', 'Sigur Rós', 'Mötley Crüe', 'Joe Hisaishi',
+ 'Kendrick Lamar', 'Dmitry Yablonsky', 'AC/DC', 'P!nk',
+ '', ' ', '12345', 'Café del Mar',
+])
+def test_latin_and_accented_latin_is_not_nonlatin(text):
+ assert has_strong_nonlatin(text) is False
+
+
+@pytest.mark.parametrize('text', [
+ '久石譲', # kanji (Joe Hisaishi)
+ '残酷な天使のテーゼ', # kana + kanji
+ 'Дмитрий Яблонский', # Cyrillic
+ '방탄소년단', # Hangul (BTS)
+ 'Σωκράτης', # Greek
+ 'عمرو دياب', # Arabic
+ 'שלום', # Hebrew
+ 'ก้อง สหรัถ', # Thai
+])
+def test_real_nonlatin_scripts_detected(text):
+ assert has_strong_nonlatin(text) is True
+
+
+# ---------------------------------------------------------------------------
+# is_cross_script_mismatch — the verifier's gate
+# ---------------------------------------------------------------------------
+
+def test_romanized_vs_native_is_cross_script():
+ # The reported case (#797): expected romanized, AcoustID native.
+ assert is_cross_script_mismatch('Joe Hisaishi', '久石譲') is True
+ assert is_cross_script_mismatch('Dmitry Yablonsky', 'Дмитрий Яблонский') is True
+
+
+def test_is_symmetric():
+ assert is_cross_script_mismatch('久石譲', 'Joe Hisaishi') is True
+
+
+def test_same_latin_both_sides_is_not_mismatch():
+ # English-vs-English — comparison is meaningful, no bridge. This is
+ # the Kendrick R.O.T.C protection surface: must stay False so the
+ # verifier keeps its strict FAIL path.
+ assert is_cross_script_mismatch('Kendrick Lamar', 'Kendrick Lamar feat. BJ') is False
+ assert is_cross_script_mismatch('Crown', 'Crown of Thorns') is False
+
+
+def test_same_nonlatin_both_sides_is_not_mismatch():
+ # Both native — same-script similarity still works, don't relax.
+ assert is_cross_script_mismatch('久石譲', '久石譲') is False
+ assert is_cross_script_mismatch('久石譲', '坂本龍一') is False
+
+
+def test_empty_or_letterless_other_side_is_not_mismatch():
+ # One side non-Latin but the other has no Latin LETTER to bridge to.
+ assert is_cross_script_mismatch('', '久石譲') is False
+ assert is_cross_script_mismatch('12345', '久石譲') is False
+ assert is_cross_script_mismatch('久石譲', ' ') is False
diff --git a/tests/test_acoustid_skip_logic.py b/tests/test_acoustid_skip_logic.py
index 84c92017..0d820d1b 100644
--- a/tests/test_acoustid_skip_logic.py
+++ b/tests/test_acoustid_skip_logic.py
@@ -185,6 +185,80 @@ def test_high_score_but_artist_mismatch_no_longer_skipped(verifier):
assert result == VerificationResult.FAIL
+def test_low_fingerprint_score_never_skipped_same_script_artist(verifier):
+ """#797 guard — the #607 protection for a SAME-SCRIPT artist (Latin
+ 'Yoko Takahashi' on both sides) with only a cross-script TITLE must
+ stay FAIL below the 0.95 floor. The #797 relaxation is keyed on the
+ ARTIST spanning scripts, which this case is NOT, so nothing changes
+ here. (Duplicates test_low_fingerprint_score_never_skipped's intent
+ explicitly against the new code path.)"""
+ _stub_lookup(verifier, recordings=[
+ {'title': '残酷な天使のテーゼ', 'artist': 'Yoko Takahashi'},
+ ], best_score=0.85)
+
+ result, _msg = verifier.verify_audio_file(
+ '/fake/path.flac',
+ 'Zankoku na Tenshi no Theze',
+ 'Yoko Takahashi',
+ )
+ assert result == VerificationResult.FAIL
+
+
+# ---------------------------------------------------------------------------
+# Issue #797 — non-English ARTIST whose name spans scripts
+# ---------------------------------------------------------------------------
+
+
+def test_cross_script_artist_confirmed_via_alias_skips_below_095(verifier):
+ """#797 headline: requested 'Joe Hisaishi' (romanized), AcoustID
+ returns the recording with the artist/title in their native kanji
+ ('久石譲'). The alias bridge confirms 久石譲 IS Joe Hisaishi, the
+ fingerprint is solid (0.85, above the 0.80 trust floor) but below
+ the old 0.95 language/script bar. Pre-#797 this FAILed and the
+ correct file was quarantined. Now it SKIPs."""
+ with patch(
+ 'core.acoustid_verification._resolve_expected_artist_aliases',
+ return_value=['久石譲'],
+ ):
+ _stub_lookup(verifier, recordings=[
+ {'title': '風のとおり道', 'artist': '久石譲'},
+ ], best_score=0.85)
+
+ result, msg = verifier.verify_audio_file(
+ '/fake/path.flac',
+ 'The Path of the Wind',
+ 'Joe Hisaishi',
+ )
+ assert result == VerificationResult.SKIP
+ assert 'language/script' in msg.lower()
+
+
+def test_cross_script_artist_NOT_confirmed_still_fails(verifier):
+ """#797 tight scope: if the alias bridge can't confirm the artist
+ (lookup returns nothing), we have no positive evidence the kanji
+ artist IS the expected one — so the #797 relaxation must NOT fire.
+ The relaxation only rescues a CONFIRMED cross-script artist.
+
+ Constructed so best_rec is set (partial title overlap → non-zero
+ combined score) and the title stays under the strict threshold, so
+ the flow reaches the same skip-decision point the rescue lives at —
+ proving it doesn't fire without a confirmed artist."""
+ with patch(
+ 'core.acoustid_verification._resolve_expected_artist_aliases',
+ return_value=[],
+ ):
+ _stub_lookup(verifier, recordings=[
+ {'title': 'Summer Night', 'artist': '久石譲'},
+ ], best_score=0.85)
+
+ result, _msg = verifier.verify_audio_file(
+ '/fake/path.flac',
+ 'Summer',
+ 'Joe Hisaishi',
+ )
+ assert result == VerificationResult.FAIL
+
+
def test_old_loose_threshold_no_longer_fires_for_unrelated_titles(verifier):
"""Pin the negative case for the old loose threshold (title_sim
>= 0.55). 'Crown' vs 'Crown of Thorns' had similarity around 0.6
diff --git a/web_server.py b/web_server.py
index a02d9273..2978693f 100644
--- a/web_server.py
+++ b/web_server.py
@@ -18707,6 +18707,10 @@ def start_missing_tracks_process(playlist_id):
is_album_download = data.get('is_album_download', False)
album_context = data.get('album_context', None)
artist_context = data.get('artist_context', None)
+ # Issue #797 — per-request "Skip AcoustID verification" toggle from the
+ # album-download modal. Stored on the batch and propagated per-track by
+ # the master worker so AcoustID never quarantines this request's files.
+ skip_acoustid = bool(data.get('skip_acoustid', False))
if not tracks:
return jsonify({"success": False, "error": "No tracks provided"}), 400
@@ -18774,6 +18778,7 @@ def start_missing_tracks_process(playlist_id):
'album_context': album_context,
'artist_context': artist_context,
'wing_it': wing_it,
+ 'skip_acoustid': skip_acoustid, # #797 per-request AcoustID bypass
'batch_source': _downloads_history.detect_sync_source(playlist_id),
}
diff --git a/webui/static/downloads.js b/webui/static/downloads.js
index d164f615..85c0f944 100644
--- a/webui/static/downloads.js
+++ b/webui/static/downloads.js
@@ -2451,6 +2451,11 @@ async function startMissingTracksProcess(playlistId) {
const forceDownloadCheckbox = document.getElementById(`force-download-all-${playlistId}`);
const forceDownloadAll = forceDownloadCheckbox ? forceDownloadCheckbox.checked : false;
+ // Issue #797 — per-request "Skip AcoustID verification" toggle. Absent
+ // checkbox (other call sites) → false, so behavior is unchanged there.
+ const skipAcoustidCheckbox = document.getElementById(`skip-acoustid-${playlistId}`);
+ const skipAcoustid = skipAcoustidCheckbox ? skipAcoustidCheckbox.checked : false;
+
// Check if playlist folder mode toggle is enabled (only for sync page playlists)
const playlistFolderMode = typeof isPlaylistOrganizeEnabled === 'function'
? isPlaylistOrganizeEnabled(playlistId)
@@ -2495,6 +2500,7 @@ async function startMissingTracksProcess(playlistId) {
force_download_all: forceDownloadAll || isWingIt,
ignore_manual_matches: forceDownloadAll,
wing_it: isWingIt,
+ skip_acoustid: skipAcoustid,
};
// If this is an artist album download, use album name and include full context
diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js
index 7eb1d78e..5e1c1e62 100644
--- a/webui/static/shared-helpers.js
+++ b/webui/static/shared-helpers.js
@@ -1483,6 +1483,10 @@ async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlis
Force Download All
+
${contextType === 'playlist' ? `