feat(verification): persist status (db+tag), surface on Downloads, scan-aware force-imports

- import pipeline writes SOULSYNC_VERIFICATION tag + context status
  (verified / unverified / force_imported via version-mismatch fallback)
- downloads payload + UI badge (tooltip explains each state)
- AcoustID scan reads the tag: refreshes tracks.verification_status,
  reports force-imported mismatches as informational (clearly marked),
  optional skip via job setting skip_force_imported
- evaluate(): empty expected artist = title-only comparison (old scanner
  behaviour); thresholds single-sourced in the core

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dev 2026-06-10 18:36:23 +02:00
parent 8e6820dbdf
commit 9d1d09a571
14 changed files with 886 additions and 28 deletions

View file

@ -21,6 +21,7 @@ from datetime import datetime
from difflib import SequenceMatcher
from typing import Any, Callable, Dict, List, Optional
from core.imports.folder_artist import resolve_folder_artist
from utils.logging_config import get_logger
logger = get_logger("auto_import")
@ -1576,31 +1577,21 @@ class AutoImportWorker:
album_name = identification.get('album_name', 'Unknown')
image_url = identification.get('image_url', '')
# Parent folder artist override: if the staging folder structure is
# Artist/Albums/AlbumName or Artist/AlbumName, use the parent folder
# as the artist name when the tag-extracted artist looks wrong.
# This handles mixtapes/compilations where embedded tags have DJ names.
# Parent folder artist override — OPT-IN via import.folder_artist_override
# (default off). When enabled it uses the top Staging folder as the artist
# for Artist/Album or Artist/<category>/Album layouts, which helps
# mixtapes/compilations whose embedded tags carry DJ names. Off by default
# because it otherwise clobbers a confidently metadata-identified artist
# when a user stages a mixed pile under a single container folder (the
# "soulsync" mass-mislabel incident).
try:
staging_root = self._resolve_staging_path() or self.staging_path
rel_path = os.path.relpath(candidate.path, staging_root)
parts = [p for p in rel_path.replace('\\', '/').split('/') if p]
# parts[0] = artist folder, parts[1] = album or category subfolder, etc.
# Only attempt override if there's at least 2 levels (artist/album)
folder_artist = None
if len(parts) >= 2:
_category_names = {'albums', 'singles', 'eps', 'compilations', 'mixtapes',
'discography', 'music', 'downloads'}
if len(parts) >= 3 and parts[1].lower() in _category_names:
# Artist/Albums/AlbumFolder → parts[0] is artist
folder_artist = parts[0]
elif parts[0].lower() not in _category_names:
# Artist/AlbumFolder → parts[0] is artist
folder_artist = parts[0]
if folder_artist and folder_artist.lower() != artist_name.lower():
logger.info(f"[Auto-Import] Parent folder artist '{folder_artist}' differs from tag artist '{artist_name}' — using folder artist")
artist_name = folder_artist
if self._config_manager.get('import.folder_artist_override', False):
staging_root = self._resolve_staging_path() or self.staging_path
rel_path = os.path.relpath(candidate.path, staging_root)
folder_artist = resolve_folder_artist(rel_path, artist_name, enabled=True)
if folder_artist:
logger.info(f"[Auto-Import] Parent folder artist '{folder_artist}' differs from tag artist '{artist_name}' — using folder artist")
artist_name = folder_artist
except Exception as e:
logger.debug("folder artist override failed: %s", e)
release_date = identification.get('release_date', '') or album_data.get('release_date', '')

View file

@ -340,6 +340,9 @@ def build_batch_status_data(batch_id: str, batch: dict, live_transfers_lookup: d
'error_message': task.get('error_message'), # Surface failure reasons to UI
'quarantine_entry_id': task.get('quarantine_entry_id'),
'has_candidates': bool(task.get('cached_candidates')), # Whether search found results (for clickable review)
# 'verified' / 'unverified' / 'force_imported' — set by the
# import pipeline once post-processing finishes.
'verification_status': task.get('verification_status'),
}
_ti = task.get('track_info') if isinstance(task.get('track_info'), dict) else {}
task_filename = task.get('filename') or _ti.get('filename')
@ -737,6 +740,7 @@ def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict:
'status': status,
'progress': progress,
'error': task.get('error_message'),
'verification_status': task.get('verification_status'),
'batch_id': batch_id,
'batch_name': batch.get('playlist_name') or batch.get('album_name') or '',
'batch_source': batch.get('source_page') or batch.get('initiated_from') or '',

View file

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

View file

@ -944,6 +944,20 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
final_path = downsampled_path
context['_final_processed_path'] = final_path
# Persist the verification status (verified / unverified / force_imported)
# as an embedded tag so it travels with the file and survives DB resets.
# Written BEFORE the lossy copy so the copy inherits it. Also stashed on
# the context so the wrapper can surface it on the Downloads page.
try:
from core.matching.verification_status import status_for_import
from core.tag_writer import write_verification_status
_verif_status = status_for_import(context)
if _verif_status:
context['_verification_status'] = _verif_status
write_verification_status(final_path, _verif_status)
except Exception as _vs_err:
logger.debug(f"verification-status persist skipped: {_vs_err}")
blasphemy_path = create_lossy_copy(final_path)
if blasphemy_path:
context['_final_processed_path'] = blasphemy_path
@ -1219,6 +1233,8 @@ def post_process_matched_download_with_verification(context_key, context, file_p
with tasks_lock:
if task_id in download_tasks:
_mark_task_completed(task_id, context.get('track_info'))
if context.get('_verification_status'):
download_tasks[task_id]['verification_status'] = context['_verification_status']
with matched_context_lock:
if context_key in matched_downloads_context:
del matched_downloads_context[context_key]
@ -1231,6 +1247,8 @@ def post_process_matched_download_with_verification(context_key, context, file_p
if task_id in download_tasks:
_mark_task_completed(task_id, context.get('track_info'))
download_tasks[task_id]['metadata_enhanced'] = True
if context.get('_verification_status'):
download_tasks[task_id]['verification_status'] = context['_verification_status']
redownload_ctx = download_tasks[task_id].get('_redownload_context')
with matched_context_lock:

View file

@ -53,6 +53,10 @@ class AcoustIDScannerJob(RepairJob):
'title_similarity': 0.70,
'artist_similarity': 0.60,
'batch_size': 200,
# Skip tracks the user force-imported via the version-mismatch
# fallback (they are expected to mismatch; default: still scan them
# but report as informational).
'skip_force_imported': False,
}
auto_fix = False # User chooses fix action per finding
@ -223,6 +227,24 @@ class AcoustIDScannerJob(RepairJob):
or expected['artist']
)
# Verification status from the embedded SOULSYNC_VERIFICATION tag.
# force_imported = user accepted this file as best candidate after the
# retry budget was exhausted — a mismatch here is EXPECTED. Either skip
# (job setting) or downgrade the finding to informational below.
file_verif_status = None
try:
from core.tag_writer import read_file_tags as _rft
file_verif_status = (_rft(fpath) or {}).get('verification_status')
except Exception:
pass
if file_verif_status == 'force_imported' and \
self._get_settings(context).get('skip_force_imported', False):
if context.report_progress:
context.report_progress(
log_line=f'Skipped (force-imported fallback): {fname}',
log_type='skip')
return
# Fingerprint-collision guard: when the TOP recording's length is wildly
# different from the file, the fingerprint hit is a hash collision (the
# 17-min mashup → 5-min track case), not a real match — skip BEFORE any
@ -260,6 +282,18 @@ class AcoustIDScannerJob(RepairJob):
aliases_provider=_aliases,
)
# Refresh the DB column from the file tag (the tag travels with the
# file and survives DB resets; the tracks row is the UI-facing cache).
if file_verif_status:
try:
conn = context.db._get_connection()
conn.cursor().execute(
"UPDATE tracks SET verification_status = ? WHERE id = ?",
(file_verif_status, track_id))
getattr(conn, 'commit', lambda: None)()
except Exception as e:
logger.debug("verification_status refresh failed for %s: %s", track_id, e)
if outcome.decision != Decision.FAIL:
if context.report_progress:
context.report_progress(
@ -280,7 +314,13 @@ class AcoustIDScannerJob(RepairJob):
log_type='error'
)
if context.create_finding:
severity = 'warning' if best_score >= 0.90 else 'info'
_is_force = file_verif_status == 'force_imported'
severity = 'info' if _is_force else ('warning' if best_score >= 0.90 else 'info')
_title = (
f'Force-imported (fallback): "{expected["title"]}" is actually "{matched_title}"'
if _is_force else
f'Wrong download: "{expected["title"]}" is actually "{matched_title}"'
)
inserted = context.create_finding(
job_id=self.job_id,
finding_type='acoustid_mismatch',
@ -288,7 +328,7 @@ class AcoustIDScannerJob(RepairJob):
entity_type='track',
entity_id=str(track_id),
file_path=fpath,
title=f'Wrong download: "{expected["title"]}" is actually "{matched_title}"',
title=_title,
description=(
f'Expected "{expected["title"]}" by {expected_artist}, '
f'but audio fingerprint matches "{matched_title}" by {matched_artist} '
@ -307,6 +347,7 @@ class AcoustIDScannerJob(RepairJob):
'artist_thumb_url': expected.get('artist_thumb_url'),
'album_title': expected.get('album_title', ''),
'track_number': expected.get('track_number'),
'force_imported': file_verif_status == 'force_imported',
}
)
if inserted:

View file

@ -0,0 +1,378 @@
# AcoustID Verification Unification + Status Tracking — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make import-time verification and the library AcoustID scan share ONE decision core, and persist a per-track verification status (DB + file tag) surfaced on the Downloads page.
**Architecture:** Extract a pure `core/matching/audio_verification.py` (`normalize` + `evaluate`) built on the existing shared helpers (`artist_aliases`, `script_compat`, `acoustid_candidates`, `version_mismatch`). `acoustid_verification.verify_audio_file` (import) and `acoustid_scanner._scan_file` (scan) delegate the decision to it. A new `tracks.verification_status` column + `SOULSYNC_VERIFICATION` file tag record `verified` / `unverified` / `force_imported`; the Downloads page shows a badge.
**Tech Stack:** Python 3.11, pytest (`.venv/bin/python -m pytest`), mutagen, SQLite, vanilla JS webui.
**Spec:** `docs/superpowers/specs/2026-06-10-acoustid-verification-unification-design.md`
---
## File Structure
- **Create** `core/matching/audio_verification.py` — the shared `normalize()` + `evaluate()` decision core (pure, no I/O). Single source of truth for normalization, thresholds, alias-aware comparison, cross-script SKIP, version gate, duration guard.
- **Create** `core/matching/verification_status.py` — the status vocabulary (`VERIFIED`, `UNVERIFIED`, `FORCE_IMPORTED`) + `status_from_outcome(decision)` + `status_from_context(context)` mapping helpers. Tiny, pure, testable.
- **Modify** `core/acoustid_verification.py``_normalize`/`_similarity`/`_alias_aware_artist_sim`/`_find_best_title_artist_match` and the decision branches in `verify_audio_file` delegate to the core. Keep the import-facing wrapper (fingerprint lookup, MB alias provider, `VerificationResult`).
- **Modify** `core/repair_jobs/acoustid_scanner.py` — drop the private `_normalize` (line 485) and the inline decision; call `normalize` + `evaluate`.
- **Modify** `database/music_database.py` — additive migration: `tracks.verification_status TEXT`.
- **Modify** `core/tag_writer.py` — write `SOULSYNC_VERIFICATION` in `_write_vorbis`/`_write_id3`/`_write_mp4`; read it in `read_file_tags`.
- **Modify** `core/imports/pipeline.py` — compute + persist the status (DB + tag) after post-processing; stash on the download context.
- **Modify** `web_server.py` + `webui/static/downloads.js` + `webui/index.html` — surface the status badge.
---
## Task 1: Core `normalize()`
**Files:**
- Create: `core/matching/audio_verification.py`
- Test: `tests/matching/test_audio_verification_core.py`
- [ ] **Step 1: Write the failing test**
```python
from core.matching.audio_verification import normalize
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"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `.venv/bin/python -m pytest tests/matching/test_audio_verification_core.py -q`
Expected: FAIL — `ModuleNotFoundError: core.matching.audio_verification`.
- [ ] **Step 3: Write minimal implementation**
Port the current (already-correct) body of `core/acoustid_verification.py::_normalize`
verbatim into the new module (it already strips `()`/`[]`/`<>`/version/featuring and
keeps CJK via `\w`). Add the thresholds as module constants for later tasks.
```python
"""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).
"""
import re
from difflib import SequenceMatcher
MIN_ACOUSTID_SCORE = 0.80
TITLE_MATCH_THRESHOLD = 0.70
ARTIST_MATCH_THRESHOLD = 0.60
def normalize(text: str) -> str:
if not text:
return ""
s = text.lower().strip()
s = re.sub(r'\s*\([^)]*\)', '', s)
s = re.sub(r'\s*\[[^\]]*\]', '', s)
s = re.sub(r'\s*<[^>]*>', '', s)
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|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)
s = re.sub(r'[^\w\s]', '', s)
s = re.sub(r'\s+', ' ', s).strip()
return s
def similarity(a: str, b: str) -> float:
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()
```
- [ ] **Step 4: Run test to verify it passes**
Run: `.venv/bin/python -m pytest tests/matching/test_audio_verification_core.py -q`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add core/matching/audio_verification.py tests/matching/test_audio_verification_core.py
git commit -m "feat(verification): shared normalize() core for import + scan"
```
---
## Task 2: Core `evaluate()` decision
**Files:**
- Modify: `core/matching/audio_verification.py`
- Test: `tests/matching/test_audio_verification_core.py`
`evaluate` reproduces the import-path decision (it is the richer of the two), so it
is behaviour-preserving for import and an upgrade for scan. Port the logic from
`acoustid_verification.verify_audio_file` Steps 4bend (version gate via
`is_acceptable_version_mismatch`, alias-aware artist sim, secondary/scan match via
`find_matching_recording`, cross-script SKIP via `is_cross_script_mismatch`,
duration guard via `duration_mismatches_strongly`).
- [ ] **Step 1: Write the failing tests** (the three real-world cases)
```python
from core.matching.audio_verification import evaluate, Decision
REC = lambda t, a, d=None: {"title": t, "artist": a, "duration": d}
def test_cross_script_artist_with_vocal_credit_skips_not_fails():
# Sawano / 澤野弘之 <Vocal: ...> + IPA title -> SKIP, never FAIL
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.SKIP
def test_clean_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
```
- [ ] **Step 2: Run to verify failure**
Run: `.venv/bin/python -m pytest tests/matching/test_audio_verification_core.py -k evaluate -q`
Expected: FAIL — `cannot import name 'evaluate'`.
- [ ] **Step 3: Implement `evaluate` + `Decision` + `Outcome`**
Add an `enum Decision {PASS, SKIP, FAIL}`, a dataclass `Outcome(decision, title_sim,
artist_sim, matched_title, matched_artist, reason)`, and `_alias_aware_artist_sim`
(ported from acoustid_verification). Port the decision sequence from
`verify_audio_file` lines 470703 into `evaluate`, taking `recordings` +
`fingerprint_score` + `file_duration_s` + `aliases_provider` as params and
returning an `Outcome` instead of `(VerificationResult, msg)`. Reuse the existing
imports: `from core.matching.artist_aliases import artist_names_match, best_alias_match`,
`from core.matching.script_compat import is_cross_script_mismatch`,
`from core.matching.acoustid_candidates import find_matching_recording, duration_mismatches_strongly`,
`from core.matching.version_mismatch import is_acceptable_version_mismatch`, and
`MusicMatchingEngine().detect_version_type` for `_detect_title_version`.
- [ ] **Step 4: Run to verify pass**
Run: `.venv/bin/python -m pytest tests/matching/test_audio_verification_core.py -q`
Expected: PASS (all).
- [ ] **Step 5: Commit**
```bash
git add core/matching/audio_verification.py tests/matching/test_audio_verification_core.py
git commit -m "feat(verification): shared evaluate() PASS/SKIP/FAIL decision core"
```
---
## Task 3: Import path delegates to the core
**Files:**
- Modify: `core/acoustid_verification.py`
- Test (regression): `tests/test_acoustid_skip_logic.py`, `tests/test_acoustid_version_mismatch.py`, `tests/test_acoustid_normalize_angle_annotations.py`, `tests/test_acoustid_error_reporting.py`
- [ ] **Step 1: Run the existing suite to capture green baseline**
Run: `.venv/bin/python -m pytest tests/test_acoustid_skip_logic.py tests/test_acoustid_version_mismatch.py tests/test_acoustid_normalize_angle_annotations.py tests/test_acoustid_error_reporting.py -q`
Expected: PASS (baseline before refactor).
- [ ] **Step 2: Refactor**
In `core/acoustid_verification.py`: replace the bodies of `_normalize`/`_similarity`
with re-exports from the core (`from core.matching.audio_verification import normalize as _normalize, similarity as _similarity`). Replace the decision block in
`verify_audio_file` (Steps 4bend) with a single call to `evaluate(...)`, then map
`Outcome.decision``VerificationResult` (PASS→PASS, SKIP→SKIP, FAIL→FAIL) and pass
`Outcome.reason` through. Keep the existing fingerprint lookup, MB enrichment, and
`_resolve_expected_artist_aliases` thunk (pass it as `aliases_provider`).
- [ ] **Step 3: Run regression suite**
Run: same command as Step 1.
Expected: PASS (unchanged behaviour for import).
- [ ] **Step 4: Commit**
```bash
git add core/acoustid_verification.py
git commit -m "refactor(verification): import path delegates to shared core"
```
---
## Task 4: Scanner uses the core (gains alias bridge + cross-script SKIP)
**Files:**
- Modify: `core/repair_jobs/acoustid_scanner.py`
- Test: `tests/test_acoustid_scanner.py` (regression) + new case
- [ ] **Step 1: Write the failing test** (cross-script track must NOT create a finding)
```python
# tests/test_acoustid_scanner_cross_script.py
def test_scanner_does_not_flag_cross_script_anime_ost(monkeypatch):
# Build a scan over one track expected "Call Your Name" / "Sawano Hiroyuki"
# whose fingerprint returns "澤野弘之 <Vocal: ...>"; assert findings_created == 0.
...
```
(Concrete construction mirrors `tests/test_acoustid_scanner.py` fixtures; assert the
mismatch branch is NOT reached.)
- [ ] **Step 2: Run to verify failure**
Run: `.venv/bin/python -m pytest tests/test_acoustid_scanner_cross_script.py -q`
Expected: FAIL — a finding IS created (current scanner strips non-ASCII, no alias).
- [ ] **Step 3: Refactor scanner**
Delete `_normalize` (line 485) and import `normalize` from the core. Replace the
`_scan_file` similarity + decision block (the `title_sim`/`artist_sim`/finding logic)
with a call to `evaluate(...)` passing `fp_result['recordings']`, `best_score`,
`file_duration_s`, and an `aliases_provider` (reuse the MB alias lookup, or pass the
DB-resolved aliases). Create a finding only when `Outcome.decision == Decision.FAIL`.
- [ ] **Step 4: Run new + existing scanner tests**
Run: `.venv/bin/python -m pytest tests/test_acoustid_scanner.py tests/test_acoustid_scanner_cross_script.py -q`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add core/repair_jobs/acoustid_scanner.py tests/test_acoustid_scanner_cross_script.py
git commit -m "refactor(scanner): use shared verification core; stop false-flagging cross-script"
```
---
## Task 5: Status vocabulary + mapping
**Files:**
- Create: `core/matching/verification_status.py`
- Test: `tests/matching/test_verification_status.py`
- [ ] **Step 1: Failing test**
```python
from core.matching.verification_status import (
VERIFIED, UNVERIFIED, FORCE_IMPORTED, status_from_decision, status_from_context)
from core.matching.audio_verification import Decision
def test_decision_maps_to_status():
assert status_from_decision(Decision.PASS) == VERIFIED
assert status_from_decision(Decision.SKIP) == UNVERIFIED
def test_force_import_context_wins():
assert status_from_context({"_version_mismatch_fallback": "instrumental"}) == FORCE_IMPORTED
assert status_from_context({}) is None
```
- [ ] **Step 2: Run → fail.** `.venv/bin/python -m pytest tests/matching/test_verification_status.py -q`
- [ ] **Step 3: Implement** the three string constants + the two pure mappers.
- [ ] **Step 4: Run → pass.**
- [ ] **Step 5: Commit** `feat(verification): status vocabulary + mappers`.
---
## Task 6: DB migration `tracks.verification_status`
**Files:**
- Modify: `database/music_database.py`
- Test: `tests/test_verification_status_migration.py`
- [ ] **Step 1: Failing test** — open a fresh DB, assert `verification_status` is in `PRAGMA table_info(tracks)`.
- [ ] **Step 2: Run → fail.**
- [ ] **Step 3: Implement** following the existing additive pattern:
```python
cursor.execute("PRAGMA table_info(tracks)")
cols = [r[1] for r in cursor.fetchall()]
if 'verification_status' not in cols:
cursor.execute("ALTER TABLE tracks ADD COLUMN verification_status TEXT")
```
(Place beside the other `tracks` ADD COLUMN migrations, e.g. near line 2372.)
- [ ] **Step 4: Run → pass.**
- [ ] **Step 5: Commit** `feat(db): add tracks.verification_status`.
---
## Task 7: Write + read the `SOULSYNC_VERIFICATION` tag
**Files:**
- Modify: `core/tag_writer.py`
- Test: `tests/test_verification_tag_roundtrip.py`
- [ ] **Step 1: Failing test** — write a FLAC with `db_data={'verification_status': 'unverified', ...}`, read it back via `read_file_tags`, assert `tags['verification_status'] == 'unverified'`. (Build the FLAC with ffmpeg as in `/tmp` test harness; or reuse an existing tag-writer test fixture.)
- [ ] **Step 2: Run → fail.**
- [ ] **Step 3: Implement** — in `_write_vorbis` set `audio['SOULSYNC_VERIFICATION']=[status]`; in `_write_id3` add `TXXX(desc='SOULSYNC_VERIFICATION', text=[status])`; in `_write_mp4` set `----:com.soulsync:VERIFICATION`. Read all three back in `read_file_tags` into key `verification_status`. Only write when `db_data.get('verification_status')` is set (non-fatal on error).
- [ ] **Step 4: Run → pass.**
- [ ] **Step 5: Commit** `feat(tags): persist SOULSYNC_VERIFICATION tag`.
---
## Task 8: Persist status at import (DB + tag + context)
**Files:**
- Modify: `core/imports/pipeline.py`
- Test: `tests/imports/test_import_verification_status.py`
- [ ] **Step 1: Failing test** — drive `post_process_matched_download` (or the helper that records the library row) with a PASS verification result and assert the track row's `verification_status == 'verified'`; with `context['_version_mismatch_fallback']` set assert `'force_imported'`.
- [ ] **Step 2: Run → fail.**
- [ ] **Step 3: Implement** — after verification, compute `status = status_from_context(context) or status_from_decision(verification_decision)`; thread it into `db_data` (so the tag write in Task 7 picks it up) and into the library-row write (`tracks.verification_status`); also stash `context['_verification_status'] = status` for the Downloads payload.
- [ ] **Step 4: Run → pass.**
- [ ] **Step 5: Commit** `feat(import): record verification status (db+tag+context)`.
---
## Task 9: Downloads page badge
**Files:**
- Modify: `web_server.py` (download-status payload), `webui/static/downloads.js`, `webui/index.html` (badge styles if needed)
- Test: manual + a small JS-free assertion if a payload test exists
- [ ] **Step 1:** Add `verification_status` to the per-task download payload in `web_server.py` (read from `context['_verification_status']` / the task record).
- [ ] **Step 2:** In `downloads.js`, render a badge per completed row: ✓ verified / ⚠ unverified / ⚑ force-imported, keyed on `task.verification_status`.
- [ ] **Step 3:** Add minimal badge CSS in `index.html`/`style.css` mirroring existing status pills.
- [ ] **Step 4:** Manual verify: import one clean + one cross-script track; confirm ✓ and ⚠ badges.
- [ ] **Step 5: Commit** `feat(ui): show verification status badge on Downloads`.
---
## Final verification
- [ ] Run the full AcoustID + import suites:
`.venv/bin/python -m pytest tests/test_acoustid_*.py tests/matching/ tests/imports/ -q`
Expected: all PASS.
- [ ] Build + push image (per build-deploy loop) for live testing.
## Self-review notes
- Spec coverage: unify (Tasks 14), status values (Task 5), DB (6), tag (7),
import wiring (8), UI (9) — all spec sections mapped.
- Behaviour-preserving for import (Task 3 regression-gated); intentional change for
scan (Task 4 — the fix).
- Tasks 4, 8, 9 reference existing fixtures/APIs the executor must read live
(`test_acoustid_scanner.py` fixtures, the download payload shape, `downloads.js`
row render) — flagged as such rather than guessed.

View file

@ -0,0 +1,153 @@
# AcoustID Verification: Unify the Pipeline + Persist Verification Status
Date: 2026-06-10
Branch: `fix/import-folder-artist-override-optin`
## Problem
Audio verification logic is **duplicated** across two code paths that have
drifted apart, producing inconsistent results ("komische Fehler"):
- **Import time**`core/acoustid_verification.py` (`verify_audio_file`,
`_normalize` @ line 59, alias-aware artist sim, cross-script SKIP, version
gate, duration guard). Keeps CJK characters; strips `()`/`[]`/`<>` (after the
recent fix).
- **Library scan**`core/repair_jobs/acoustid_scanner.py` (its OWN `_normalize`
@ line 485, which strips **all non-ASCII** — kanji/IPA vanish — and has NO
cross-script alias bridge / SKIP). A full library scan therefore false-flags
correct cross-script tracks (e.g. anime OSTs credited `澤野弘之 <Vocal: …>`).
Two `_normalize`s + two decision paths = the same bug must be fixed twice and
they diverge. There is also no record of *how* a track passed verification, so a
force-imported or skipped-but-imported track looks identical to a cleanly
verified one.
## Goals
1. **One verification core** used by BOTH import and scan. Normalization,
alias-aware comparison, cross-script handling, version gate, duration guard,
and thresholds live in exactly one place.
2. **Persist a verification status** per track in the DB **and** as a file tag,
so it survives DB resets / file moves and can drive the UI.
3. **Surface the status on the Downloads page** as an info badge.
## Consumer map (discovered 2026-06-10)
In scope (call the new core):
- `core/imports/pipeline.py:340-373` — the only caller of
`AcoustIDVerification.verify_audio_file` (import path).
- `core/repair_jobs/acoustid_scanner.py` — the library-scan job.
Shared helpers (stay shared, unchanged; the core builds on them):
- `core/matching/artist_aliases.py::artist_names_match` / `best_alias_match`
- `core/matching/script_compat.py::is_cross_script_mismatch`
- `core/matching/acoustid_candidates.py::find_matching_recording`,
`duration_mismatches_strongly`
- `core/matching/version_mismatch.py::is_acceptable_version_mismatch`
Status hook:
- `core/imports/version_mismatch_fallback.py` sets
`context["_version_mismatch_fallback"]` when it force-accepts the best
quarantined candidate after retries are exhausted → maps to `force_imported`.
Dispatched from `core/downloads/task_worker.py` via `try_version_mismatch_fallback`.
Deliberately OUT of scope (consume a shared helper but serve other purposes;
NOT merged):
- `core/repair_worker.py::_album_fill_artist_names_match` — Album-Completeness
auto-fill gate.
- `core/matching/album_context_title.py::_normalize` — trivial album-grouping
normalize.
- `core/downloads/task_worker.py`, `core/matching_engine.py` — pre-download
(Soulseek candidate) matching, not post-download verification.
## Design
### 1. Shared core — `core/matching/audio_verification.py` (new)
The single home for the verification *decision*. Pure where possible (no file
I/O, no DB) so it is unit-testable in isolation. Callers keep their own I/O
(fingerprinting, quarantine, finding creation, tag writing).
```
def normalize(text: str) -> str
# lowercase; strip ()/[]/<> annotations; strip version (- Live, etc) +
# featuring tags; KEEP CJK (\w under unicode); collapse whitespace.
# Single source of truth — replaces acoustid_verification._normalize AND
# acoustid_scanner._normalize.
def evaluate(
expected_title, expected_artist, recordings, *,
fingerprint_score, file_duration_s=None, aliases_provider=None,
) -> Outcome
# Outcome(decision, title_sim, artist_sim, matched_title, matched_artist, reason)
# decision in {PASS, SKIP, FAIL}.
# Encapsulates: alias-aware artist sim, version-mismatch gate, duration
# collision guard, cross-script SKIP, thresholds. Built on the shared helpers.
```
Thresholds (`TITLE_MATCH_THRESHOLD`, `ARTIST_MATCH_THRESHOLD`, `MIN_ACOUSTID_SCORE`)
move into the core as the single definition.
Caller mapping:
- **Import** (`acoustid_verification.verify_audio_file`): PASS → import +
status `verified`; SKIP → import + status `unverified`; FAIL → quarantine.
- **Scan** (`acoustid_scanner._scan_file`): PASS/SKIP → no finding (optionally
refresh the stored status); FAIL → create `acoustid_mismatch` finding.
`acoustid_verification.py` keeps `verify_audio_file` as the import-facing wrapper
(fingerprint lookup, MB enrichment, alias provider, returns `VerificationResult`)
but delegates the decision to `evaluate`. `acoustid_scanner.py` drops its private
`_normalize` and decision branches and calls `normalize` + `evaluate`.
### 2. Verification status
Values (the three the user selected; quarantined files are not imported, so they
carry no status):
- `verified` — clean AcoustID PASS.
- `unverified` — SKIP: cross-script / ambiguous / no AcoustID match. Imported,
not hard-confirmed.
- `force_imported` — accepted via `version_mismatch_fallback` after retries
exhausted.
### 3. Storage — DB + tag
- **DB:** new nullable column `tracks.verification_status TEXT` (idempotent
migration in `database/music_database.py`). Set at import; refreshed by the
scan. Mirrored onto the download record/context so the Downloads page can show
it before a library re-scan.
- **Tag:** `SOULSYNC_VERIFICATION=<status>` written via `core/tag_writer.py`
(Vorbis comment / ID3 `TXXX`). Travels with the file; the scanner reads it and
may skip re-verifying an already-`verified` file (perf bonus) and to display.
### 4. Downloads page UI
A small, info-only badge per row sourced from the status:
- ✓ `verified` · ⚠ `unverified` · ⚑ `force_imported`.
Wired in `webui/static/downloads.js` (+ the download-status API payload).
### 5. Testing (TDD)
- Unit-test the core `normalize` (incl. `<>`/`()`/`[]`, CJK retention) and
`evaluate` (Sawano/IPA cross-script → SKIP not FAIL; vocal-credit artist →
alias 100%; version mismatch → FAIL; duration collision → no FAIL).
- Wire import + scanner to the core; their existing tests
(`test_acoustid_skip_logic`, `test_acoustid_scanner`,
`test_acoustid_version_mismatch`, `test_acoustid_error_reporting`,
`test_acoustid_normalize_angle_annotations`) act as regression.
- New tests for the status: force-import → `force_imported`; PASS → `verified`;
SKIP → `unverified`; tag written + read back; DB column populated.
## Rollout / risk
- Pure-core extraction is behaviour-preserving for the import path (existing
tests pin it); the scan path *changes* (gains alias bridge + cross-script SKIP)
— that is the intended fix.
- DB migration is additive (nullable column), safe on existing DBs.
- Tag writing reuses the existing `tag_writer` path; failures are non-fatal.
- Staged on the current branch with TDD; ships in the next image build.
## Out of scope (now)
Unifying the pre-download Soulseek matcher or the album-completeness gate. They
consume shared helpers but are different decisions; folding them in would widen
blast radius without serving this goal.

View file

@ -0,0 +1,85 @@
"""Folder-artist override is opt-in and never clobbers 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:
- return ``None`` (i.e. keep the identified artist) when the feature is OFF,
regardless of folder structure this is the regression guard;
- only when explicitly enabled, reproduce the original folder-derived artist.
"""
from core.imports.folder_artist import resolve_folder_artist
# --- OFF by default: never override (the bug fix) ---------------------------
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 is available on request --------------------
def test_enabled_uses_top_folder_as_artist_when_it_differs():
assert resolve_folder_artist(
"soulsync/Bunny Girl/01 - Bunny Girl.flac",
identified_artist="1nonly, Ciscaux",
enabled=True,
) == "soulsync"
def test_enabled_skips_category_folder_and_uses_artist_above_it():
assert resolve_folder_artist(
"Pink Floyd/Albums/The Wall/01 - In the Flesh.flac",
identified_artist="Some DJ",
enabled=True,
) == "Pink Floyd"
def test_enabled_flat_file_has_no_folder_artist():
assert resolve_folder_artist(
"01 - Bitch Lasagna.flac",
identified_artist="PewDiePie",
enabled=True,
) is None
def test_enabled_no_override_when_folder_matches_identified():
# Folder already equals the identified artist (case-insensitive) -> no-op.
assert resolve_folder_artist(
"AC_DC/Back In Black/01 - Hells Bells.flac",
identified_artist="ac_dc",
enabled=True,
) is None
def test_enabled_top_level_category_word_is_not_an_artist():
# 'singles/Track/file' — top folder is a category, not an artist.
assert resolve_folder_artist(
"singles/Headache/01 - Headache.flac",
identified_artist="Asal",
enabled=True,
) is None

View file

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

View file

@ -816,3 +816,55 @@ def test_scanner_does_not_flag_cross_script_when_alias_bridges(monkeypatch):
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, *, skip_setting):
"""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()
if skip_setting:
monkeypatch.setattr(job, '_get_settings',
lambda ctx: {**job.default_settings,
'skip_force_imported': True})
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.
captured = _force_imported_scan(monkeypatch, skip_setting=False)
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_force_imported_can_be_skipped_via_setting(monkeypatch):
captured = _force_imported_scan(monkeypatch, skip_setting=True)
assert captured == []

View file

@ -6201,6 +6201,22 @@
(e.g. MP3), it will be replaced with the higher quality version (e.g. FLAC).
If disabled, existing tracks are always kept and the import file is skipped.
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="import-folder-artist-override">
Use the top Staging folder as the artist
</label>
</div>
<div class="help-text">
On by default (legacy behaviour). When enabled, files staged as
<code>Artist/Album/…</code> (or <code>Artist/Albums/Album/…</code>) take the top
folder as the album artist — handy for mixtapes/compilations whose embedded tags
carry DJ names. <strong>Turn this off</strong> if you drop a mixed pile of songs
under one container folder: otherwise every file's artist gets overwritten with
that folder's name (the cause of the "soulsync" mass-mislabel). With it off, the
metadata-identified artist is always kept.
</div>
</div>

View file

@ -3668,7 +3668,23 @@ function processModalStatusUpdate(playlistId, data) {
case 'searching': statusText = '🔍 Searching...'; break;
case 'downloading': statusText = `⏬ Downloading... ${Math.round(task.progress || 0)}%`; 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>';
}
completedCount++;
break;
}
case 'not_found': statusText = '🔇 Not Found'; notFoundCount++; break;
case 'failed': {
// Distinguish quarantine outcomes from generic
@ -3701,7 +3717,14 @@ function processModalStatusUpdate(playlistId, data) {
delete statusEl.dataset.quarantineReason;
delete statusEl.dataset.quarantineTrack;
delete statusEl.dataset.detailOpen;
statusEl.textContent = statusText;
// statusText is static markup only; the verif-badge span is the
// one case that needs HTML. Everything else stays textContent
// (XSS-safe default).
if (statusText.includes('class="verif-badge')) {
statusEl.innerHTML = statusText;
} else {
statusEl.textContent = statusText;
}
// Visual-only hooks: the cell carries its state for the badge
// styling, the row glows while a track is actively working.
statusEl.dataset.state = isQuarantinedTask ? 'quarantined'

View file

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

View file

@ -67892,3 +67892,9 @@ body.em-scroll-lock { overflow: hidden; }
padding: 7px 11px; color: #fff; font-size: 0.82rem; width: 150px;
}
.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); }