Merge pull request #591 from Nezreka/feature/quarantine-management

Feature/quarantine management
This commit is contained in:
BoulderBadgeDad 2026-05-14 08:57:18 -07:00 committed by GitHub
commit 4226be761a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 903 additions and 14 deletions

View file

@ -29,8 +29,22 @@ def _get_config_manager():
return config_manager
def move_to_quarantine(file_path: str, context: dict, reason: str, automation_engine=None) -> str:
"""Move a file to the quarantine folder and write a metadata sidecar."""
def move_to_quarantine(file_path: str, context: dict, reason: str, automation_engine=None, *, trigger: str = "unknown") -> str:
"""Move a file to the quarantine folder and write a metadata sidecar.
`trigger` identifies which check fired (`integrity` / `acoustid` /
`bit_depth` / `unknown`) and is persisted in the sidecar so
one-click Approve can set the matching `_skip_quarantine_check`
bypass when re-running the pipeline.
Sidecar also persists a JSON-safe snapshot of the full `context`
dict via `serialize_quarantine_context`, enabling in-place approve
without losing the matched-track metadata. Legacy sidecars (written
before this expansion) lack the `context` field Approve falls
back to `recover_to_staging` for those.
"""
from core.imports.quarantine import serialize_quarantine_context
download_dir = _get_config_manager().get("soulseek.download_path", "./downloads")
quarantine_dir = Path(download_dir) / "ss_quarantine"
quarantine_dir.mkdir(parents=True, exist_ok=True)
@ -56,6 +70,8 @@ def move_to_quarantine(file_path: str, context: dict, reason: str, automation_en
"expected_track": get_import_clean_title(context, default=original_search.get("title", "Unknown")),
"expected_artist": get_import_clean_artist(context, default=(artist_context.get("name", "") if isinstance(artist_context, dict) else "Unknown")),
"context_key": context.get("context_key", "unknown"),
"trigger": trigger,
"context": serialize_quarantine_context(context),
}
try:

View file

@ -163,15 +163,23 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
_duration_tolerance_override = resolve_duration_tolerance(
config_manager.get('post_processing.duration_tolerance_seconds', 0)
)
try:
integrity = check_audio_integrity(
file_path,
_expected_duration_ms,
length_tolerance_s=_duration_tolerance_override,
)
except Exception as integrity_error:
logger.error(f"[Integrity] Check raised unexpectedly (continuing): {integrity_error}")
# Per-check quarantine bypass — set by `approve_quarantine_entry`
# when the user explicitly approves a previously-quarantined
# file. Skips ONLY the named check; other gates still run.
_bypass_check = context.get('_skip_quarantine_check')
if _bypass_check == 'integrity':
logger.info(f"[Integrity] Skipped (user approval) for {_basename}")
integrity = None
else:
try:
integrity = check_audio_integrity(
file_path,
_expected_duration_ms,
length_tolerance_s=_duration_tolerance_override,
)
except Exception as integrity_error:
logger.error(f"[Integrity] Check raised unexpectedly (continuing): {integrity_error}")
integrity = None
if integrity is not None and not integrity.ok:
logger.error(f"[Integrity] Rejected {_basename}: {integrity.reason}")
@ -183,6 +191,7 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
context,
f"Integrity check failed: {integrity.reason}",
automation_engine,
trigger='integrity',
)
logger.error(f"File quarantined due to integrity failure: {quarantine_path}")
except Exception as quarantine_error:
@ -218,7 +227,9 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
f"drift={integrity.checks.get('length_drift_s', 'n/a')})"
)
_skip_acoustid = False
_skip_acoustid = context.get('_skip_quarantine_check') == 'acoustid'
if _skip_acoustid:
logger.info(f"[AcoustID] Skipped (user approval) for {_basename}")
try:
from core.acoustid_verification import AcoustIDVerification, VerificationResult
@ -260,6 +271,7 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
context,
verification_msg,
automation_engine,
trigger='acoustid',
)
logger.error(f"File quarantined due to verification failure: {quarantine_path}")
except Exception as quarantine_error:
@ -432,7 +444,9 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
if context['_audio_quality']:
logger.info(f"Audio quality detected: {context['_audio_quality']}")
rejection_reason = check_flac_bit_depth(file_path, context)
rejection_reason = None if context.get('_skip_quarantine_check') == 'bit_depth' else check_flac_bit_depth(file_path, context)
if context.get('_skip_quarantine_check') == 'bit_depth':
logger.info(f"[BitDepth] Skipped (user approval) for {_basename}")
if rejection_reason:
try:
quarantine_path = move_to_quarantine(
@ -440,6 +454,7 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
context,
rejection_reason,
automation_engine,
trigger='bit_depth',
)
logger.info(f"File quarantined due to bit depth filter: {quarantine_path}")
except Exception as quarantine_error:
@ -560,7 +575,9 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
if context['_audio_quality']:
logger.info(f"Audio quality detected: {context['_audio_quality']}")
rejection_reason = check_flac_bit_depth(file_path, context)
rejection_reason = None if context.get('_skip_quarantine_check') == 'bit_depth' else check_flac_bit_depth(file_path, context)
if context.get('_skip_quarantine_check') == 'bit_depth':
logger.info(f"[BitDepth] Skipped (user approval) for {_basename}")
if rejection_reason:
try:
quarantine_path = move_to_quarantine(
@ -568,6 +585,7 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
context,
rejection_reason,
automation_engine,
trigger='bit_depth',
)
logger.info(f"File quarantined due to bit depth filter: {quarantine_path}")
except Exception as quarantine_error:

329
core/imports/quarantine.py Normal file
View file

@ -0,0 +1,329 @@
"""Quarantine entry management — pure helpers for list/delete/approve/recover.
Quarantined files live in `<download_path>/ss_quarantine/` as
`<timestamp>_<original>.<ext>.quarantined` paired with a JSON sidecar
`<timestamp>_<original>.json` written by `core.imports.guards.move_to_quarantine`.
This module provides the read/write/restore primitives. Web routes are
thin glue around these. Pipeline re-run on approval is the caller's
job (we hand back `(file_path, context, bypass_check)`).
"""
from __future__ import annotations
import json
import os
import shutil
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from utils.logging_config import get_logger
logger = get_logger("imports.quarantine")
_QUARANTINE_SUFFIX = ".quarantined"
# JSON-serializable scalar predicate. dict / list values get walked
# recursively; anything else is dropped during sidecar serialization.
_SAFE_SCALARS = (str, int, float, bool, type(None))
def serialize_quarantine_context(context: Any) -> Dict[str, Any]:
"""Walk a context dict and emit a JSON-safe copy.
Drops non-serializable values (sets, custom objects, callables,
open file handles, etc) silently sidecar must round-trip through
`json.dump` / `json.load` without raising. Lists are walked element
by element; dicts are walked recursively. Anything that isn't a
scalar / dict / list is converted to a string fallback so caller
still sees *something* (rather than a silent drop) but won't break
the JSON write.
"""
if not isinstance(context, dict):
return {}
return _coerce_dict(context)
def _coerce_value(value: Any) -> Any:
if isinstance(value, _SAFE_SCALARS):
return value
if isinstance(value, dict):
return _coerce_dict(value)
if isinstance(value, (list, tuple)):
return [_coerce_value(v) for v in value]
if isinstance(value, set):
return [_coerce_value(v) for v in value]
# Fallback — preserve via str() so caller sees the value's shape
# without breaking JSON serialization.
try:
return str(value)
except Exception:
return None
def _coerce_dict(d: Dict[str, Any]) -> Dict[str, Any]:
out: Dict[str, Any] = {}
for key, value in d.items():
if not isinstance(key, str):
try:
key = str(key)
except Exception:
continue
out[key] = _coerce_value(value)
return out
def _entry_id_from_filename(quarantined_filename: str) -> str:
"""Derive a stable entry id from the quarantined filename.
Strip the `.quarantined` suffix; strip the original file extension;
return the bare `<timestamp>_<original>` stem. Sidecar uses the
same stem with a `.json` extension, so the id pairs both sides.
"""
base = quarantined_filename
if base.endswith(_QUARANTINE_SUFFIX):
base = base[: -len(_QUARANTINE_SUFFIX)]
return Path(base).stem
def list_quarantine_entries(quarantine_dir: str) -> List[Dict[str, Any]]:
"""Enumerate quarantined files paired with their sidecars.
Returns one dict per `.quarantined` file with: id, filename,
original_filename (from sidecar), reason, expected_track,
expected_artist, timestamp, size_bytes, has_full_context (True
when the sidecar carries a `context` field required for one-click
Approve), trigger (which check fired: integrity / acoustid /
bit_depth / unknown).
Orphaned `.quarantined` files (no sidecar) still surface caller
can delete them. Orphaned sidecars (no file) are skipped silently.
Sorted newest-first by timestamp prefix.
"""
entries: List[Dict[str, Any]] = []
if not os.path.isdir(quarantine_dir):
return entries
for name in os.listdir(quarantine_dir):
if not name.endswith(_QUARANTINE_SUFFIX):
continue
full_path = os.path.join(quarantine_dir, name)
if not os.path.isfile(full_path):
continue
entry_id = _entry_id_from_filename(name)
sidecar_path = os.path.join(quarantine_dir, f"{entry_id}.json")
sidecar: Dict[str, Any] = {}
if os.path.isfile(sidecar_path):
try:
with open(sidecar_path, encoding="utf-8") as f:
loaded = json.load(f)
if isinstance(loaded, dict):
sidecar = loaded
except Exception as exc:
logger.debug("sidecar read failed for %s: %s", entry_id, exc)
try:
size_bytes = os.path.getsize(full_path)
except OSError:
size_bytes = 0
entries.append(
{
"id": entry_id,
"filename": name,
"original_filename": sidecar.get("original_filename", name),
"reason": sidecar.get("quarantine_reason", "Unknown reason"),
"expected_track": sidecar.get("expected_track", ""),
"expected_artist": sidecar.get("expected_artist", ""),
"timestamp": sidecar.get("timestamp", ""),
"size_bytes": size_bytes,
"has_full_context": isinstance(sidecar.get("context"), dict),
"trigger": sidecar.get("trigger", "unknown"),
}
)
entries.sort(key=lambda e: e["id"], reverse=True)
return entries
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.
Returns (file_path, sidecar_path), either may be None if missing.
"""
if not os.path.isdir(quarantine_dir) or not entry_id:
return None, None
file_path: Optional[str] = None
for name in os.listdir(quarantine_dir):
if not name.endswith(_QUARANTINE_SUFFIX):
continue
if _entry_id_from_filename(name) == entry_id:
file_path = os.path.join(quarantine_dir, name)
break
sidecar_path = os.path.join(quarantine_dir, f"{entry_id}.json")
if not os.path.isfile(sidecar_path):
sidecar_path = None
return file_path, sidecar_path
def delete_quarantine_entry(quarantine_dir: str, entry_id: str) -> bool:
"""Delete the quarantined file + sidecar for the given entry id.
Returns True if at least one of the two was removed. False when
neither existed (entry already gone).
"""
file_path, sidecar_path = _resolve_entry_paths(quarantine_dir, entry_id)
removed = False
if file_path and os.path.isfile(file_path):
try:
os.remove(file_path)
removed = True
except OSError as exc:
logger.error("Failed to delete quarantine file %s: %s", file_path, exc)
if sidecar_path and os.path.isfile(sidecar_path):
try:
os.remove(sidecar_path)
removed = True
except OSError as exc:
logger.error("Failed to delete quarantine sidecar %s: %s", sidecar_path, exc)
return removed
def _restore_filename(quarantined_filename: str, sidecar_original: Optional[str] = None) -> str:
"""Resolve the filename to restore.
Sidecar's `original_filename` wins when provided — it's the
canonical record of what the file was named before quarantine.
Otherwise parse the `<YYYYMMDD_HHMMSS>_<original>.<ext>.quarantined`
convention written by `move_to_quarantine`, dropping the timestamp
prefix and `.quarantined` suffix. Final fallback returns the
quarantined filename minus the suffix unchanged.
"""
if sidecar_original:
return sidecar_original
base = quarantined_filename
if base.endswith(_QUARANTINE_SUFFIX):
base = base[: -len(_QUARANTINE_SUFFIX)]
parts = base.split("_", 2)
if len(parts) >= 3 and parts[0].isdigit() and parts[1].isdigit():
return parts[2]
return base
def approve_quarantine_entry(
quarantine_dir: str,
entry_id: str,
restore_dir: str,
) -> Optional[Tuple[str, Dict[str, Any], str]]:
"""Restore a quarantined file for re-import via the post-process pipeline.
Reads the sidecar's `context` + `trigger`, moves the file out of
quarantine to `restore_dir` (with the original filename + extension),
deletes the sidecar.
Returns `(restored_file_path, context, trigger)` so the caller can
set the appropriate `_skip_quarantine_check` bypass flag and
dispatch the post-process pipeline.
Returns None when:
- the entry doesn't exist
- the sidecar lacks a serialized `context` (legacy thin sidecar
caller should fall back to `recover_to_staging` instead)
- the file move fails
"""
file_path, sidecar_path = _resolve_entry_paths(quarantine_dir, entry_id)
if not file_path or not sidecar_path:
logger.warning("approve: entry %s missing file or sidecar", entry_id)
return None
try:
with open(sidecar_path, encoding="utf-8") as f:
sidecar = json.load(f)
except Exception as exc:
logger.error("approve: sidecar read failed for %s: %s", entry_id, exc)
return None
context = sidecar.get("context")
if not isinstance(context, dict):
logger.info("approve: entry %s has thin sidecar (no context) — caller should recover-to-staging", entry_id)
return None
trigger = str(sidecar.get("trigger", "unknown"))
original_name = sidecar.get("original_filename") or _restore_filename(os.path.basename(file_path))
os.makedirs(restore_dir, exist_ok=True)
restored_path = os.path.join(restore_dir, original_name)
restored_path = _ensure_unique_path(restored_path)
try:
shutil.move(file_path, restored_path)
except OSError as exc:
logger.error("approve: failed to restore %s -> %s: %s", file_path, restored_path, exc)
return None
try:
os.remove(sidecar_path)
except OSError as exc:
logger.warning("approve: failed to remove sidecar %s: %s", sidecar_path, exc)
return restored_path, context, trigger
def recover_to_staging(
quarantine_dir: str,
staging_dir: str,
entry_id: str,
) -> Optional[str]:
"""Move a quarantined file into Staging for manual import.
Strips the timestamp prefix + `.quarantined` suffix, drops the file
into `staging_dir` so the user can finish via the existing Import
flow. Sidecar is removed. Used as the fallback path for legacy thin
sidecars (no embedded `context`) where one-click Approve is
impossible.
"""
file_path, sidecar_path = _resolve_entry_paths(quarantine_dir, entry_id)
if not file_path:
return None
sidecar_original = None
if sidecar_path:
try:
with open(sidecar_path, encoding="utf-8") as f:
sidecar_original = json.load(f).get("original_filename")
except Exception as exc:
logger.debug("recover: sidecar read failed for %s: %s", entry_id, exc)
restored_name = _restore_filename(os.path.basename(file_path), sidecar_original)
os.makedirs(staging_dir, exist_ok=True)
target = _ensure_unique_path(os.path.join(staging_dir, restored_name))
try:
shutil.move(file_path, target)
except OSError as exc:
logger.error("recover: failed to move %s -> %s: %s", file_path, target, exc)
return None
if sidecar_path and os.path.isfile(sidecar_path):
try:
os.remove(sidecar_path)
except OSError as exc:
logger.warning("recover: failed to remove sidecar %s: %s", sidecar_path, exc)
return target
def _ensure_unique_path(target: str) -> str:
"""Append `_(2)`, `_(3)`, ... before the extension when target exists."""
if not os.path.exists(target):
return target
base, ext = os.path.splitext(target)
counter = 2
while True:
candidate = f"{base}_({counter}){ext}"
if not os.path.exists(candidate):
return candidate
counter += 1

View file

@ -0,0 +1,273 @@
import json
import os
from core.imports.quarantine import (
approve_quarantine_entry,
delete_quarantine_entry,
list_quarantine_entries,
recover_to_staging,
serialize_quarantine_context,
)
# ──────────────────────────────────────────────────────────────────────
# serialize_quarantine_context — JSON-safe coercion
# ──────────────────────────────────────────────────────────────────────
def test_serialize_passes_scalar_dict_unchanged():
ctx = {"title": "DNA.", "track_number": 2, "active": True, "missing": None, "duration_ms": 185000}
out = serialize_quarantine_context(ctx)
assert out == ctx
def test_serialize_walks_nested_dicts():
ctx = {"track_info": {"name": "DNA.", "artists": [{"name": "Kendrick"}, {"name": "Rihanna"}]}}
out = serialize_quarantine_context(ctx)
assert out == ctx
def test_serialize_coerces_set_to_list():
ctx = {"sources": {"spotify", "deezer"}}
out = serialize_quarantine_context(ctx)
assert sorted(out["sources"]) == ["deezer", "spotify"]
def test_serialize_coerces_tuple_to_list():
ctx = {"pair": (1, 2, 3)}
out = serialize_quarantine_context(ctx)
assert out == {"pair": [1, 2, 3]}
def test_serialize_stringifies_unknown_objects():
class Custom:
def __str__(self):
return "<custom obj>"
out = serialize_quarantine_context({"obj": Custom()})
assert out["obj"] == "<custom obj>"
def test_serialize_non_dict_returns_empty_dict():
assert serialize_quarantine_context(None) == {}
assert serialize_quarantine_context("string") == {}
assert serialize_quarantine_context([1, 2, 3]) == {}
def test_serialize_round_trips_through_json():
ctx = {
"track_info": {"name": "X", "artists": [{"name": "A"}, {"name": "B"}]},
"spotify_artist": {"name": "A", "id": "abc"},
"duration_ms": 180000,
"sources": {"spotify"},
}
serialized = serialize_quarantine_context(ctx)
json.dumps(serialized) # must not raise
# ──────────────────────────────────────────────────────────────────────
# list_quarantine_entries
# ──────────────────────────────────────────────────────────────────────
def _write_entry(quarantine_dir, entry_id, original_name, *, with_context=False, trigger="integrity", reason="boom", file_bytes=b"X" * 100):
qfile = quarantine_dir / f"{entry_id}_{original_name}.quarantined"
qfile.write_bytes(file_bytes)
sidecar = {
"original_filename": original_name,
"quarantine_reason": reason,
"expected_track": "Track",
"expected_artist": "Artist",
"timestamp": "2026-05-14T12:00:00",
"trigger": trigger,
}
if with_context:
sidecar["context"] = {"track_info": {"name": "Track"}, "context_key": entry_id}
sidecar_path = quarantine_dir / f"{entry_id}_{os.path.splitext(original_name)[0]}.json"
sidecar_path.write_text(json.dumps(sidecar))
return qfile, sidecar_path
def test_list_returns_empty_for_missing_dir(tmp_path):
assert list_quarantine_entries(str(tmp_path / "nope")) == []
def test_list_returns_empty_for_empty_dir(tmp_path):
assert list_quarantine_entries(str(tmp_path)) == []
def test_list_returns_entry_with_sidecar_fields(tmp_path):
_write_entry(tmp_path, "20260514_120000", "song.flac", reason="Duration mismatch")
entries = list_quarantine_entries(str(tmp_path))
assert len(entries) == 1
e = entries[0]
assert e["original_filename"] == "song.flac"
assert e["reason"] == "Duration mismatch"
assert e["expected_track"] == "Track"
assert e["expected_artist"] == "Artist"
assert e["has_full_context"] is False
assert e["trigger"] == "integrity"
assert e["size_bytes"] == 100
def test_list_flags_full_context_entries(tmp_path):
_write_entry(tmp_path, "20260514_120000", "song.flac", with_context=True)
entries = list_quarantine_entries(str(tmp_path))
assert entries[0]["has_full_context"] is True
def test_list_handles_orphan_quarantined_file_without_sidecar(tmp_path):
qfile = tmp_path / "20260514_120000_orphan.flac.quarantined"
qfile.write_bytes(b"X")
entries = list_quarantine_entries(str(tmp_path))
assert len(entries) == 1
assert entries[0]["reason"] == "Unknown reason"
assert entries[0]["has_full_context"] is False
def test_list_skips_orphan_sidecars_without_file(tmp_path):
sidecar = tmp_path / "20260514_120000_only.json"
sidecar.write_text(json.dumps({"original_filename": "only.flac", "quarantine_reason": "x"}))
assert list_quarantine_entries(str(tmp_path)) == []
def test_list_sorts_newest_first(tmp_path):
_write_entry(tmp_path, "20260101_120000", "old.flac")
_write_entry(tmp_path, "20260514_120000", "new.flac")
entries = list_quarantine_entries(str(tmp_path))
assert entries[0]["original_filename"] == "new.flac"
assert entries[1]["original_filename"] == "old.flac"
def test_list_swallows_corrupt_sidecar_gracefully(tmp_path):
qfile = tmp_path / "20260514_120000_song.flac.quarantined"
qfile.write_bytes(b"X")
sidecar = tmp_path / "20260514_120000_song.json"
sidecar.write_text("{ this is not valid json")
entries = list_quarantine_entries(str(tmp_path))
assert len(entries) == 1
assert entries[0]["reason"] == "Unknown reason"
# ──────────────────────────────────────────────────────────────────────
# delete_quarantine_entry
# ──────────────────────────────────────────────────────────────────────
def test_delete_removes_both_file_and_sidecar(tmp_path):
_write_entry(tmp_path, "20260514_120000", "song.flac")
assert delete_quarantine_entry(str(tmp_path), "20260514_120000_song") is True
assert not (tmp_path / "20260514_120000_song.flac.quarantined").exists()
assert not (tmp_path / "20260514_120000_song.json").exists()
def test_delete_returns_false_when_entry_missing(tmp_path):
assert delete_quarantine_entry(str(tmp_path), "nonexistent") is False
def test_delete_handles_orphan_file_without_sidecar(tmp_path):
qfile = tmp_path / "20260514_120000_orphan.flac.quarantined"
qfile.write_bytes(b"X")
assert delete_quarantine_entry(str(tmp_path), "20260514_120000_orphan") is True
assert not qfile.exists()
# ──────────────────────────────────────────────────────────────────────
# approve_quarantine_entry — full-context path
# ──────────────────────────────────────────────────────────────────────
def test_approve_restores_file_and_returns_context_and_trigger(tmp_path):
quarantine = tmp_path / "ss_quarantine"
quarantine.mkdir()
restore = tmp_path / "restore"
_write_entry(quarantine, "20260514_120000", "song.flac", with_context=True, trigger="integrity")
result = approve_quarantine_entry(str(quarantine), "20260514_120000_song", str(restore))
assert result is not None
restored_path, context, trigger = result
assert os.path.basename(restored_path) == "song.flac"
assert os.path.isfile(restored_path)
assert context["track_info"]["name"] == "Track"
assert trigger == "integrity"
# Sidecar removed after approve
assert not (quarantine / "20260514_120000_song.json").exists()
def test_approve_returns_none_for_thin_sidecar_without_context(tmp_path):
_write_entry(tmp_path, "20260514_120000", "song.flac", with_context=False)
result = approve_quarantine_entry(str(tmp_path), "20260514_120000_song", str(tmp_path / "restore"))
assert result is None
def test_approve_returns_none_for_missing_entry(tmp_path):
assert approve_quarantine_entry(str(tmp_path), "nope", str(tmp_path)) is None
def test_approve_avoids_filename_collision(tmp_path):
quarantine = tmp_path / "q"
quarantine.mkdir()
restore = tmp_path / "r"
restore.mkdir()
(restore / "song.flac").write_bytes(b"existing")
_write_entry(quarantine, "20260514_120000", "song.flac", with_context=True)
result = approve_quarantine_entry(str(quarantine), "20260514_120000_song", str(restore))
assert result is not None
restored_path = result[0]
assert os.path.basename(restored_path) == "song_(2).flac"
assert (restore / "song.flac").read_bytes() == b"existing"
# ──────────────────────────────────────────────────────────────────────
# recover_to_staging — fallback for thin sidecars
# ──────────────────────────────────────────────────────────────────────
def test_recover_strips_prefix_and_suffix(tmp_path):
quarantine = tmp_path / "q"
quarantine.mkdir()
staging = tmp_path / "s"
qfile, _ = _write_entry(quarantine, "20260514_120000", "song.flac")
target = recover_to_staging(str(quarantine), str(staging), "20260514_120000_song")
assert target is not None
assert os.path.basename(target) == "song.flac"
assert os.path.isfile(target)
assert not qfile.exists()
def test_recover_uses_sidecar_original_filename_when_available(tmp_path):
quarantine = tmp_path / "q"
quarantine.mkdir()
staging = tmp_path / "s"
qfile = quarantine / "20260514_120000_munged_name.flac.quarantined"
qfile.write_bytes(b"X")
sidecar = quarantine / "20260514_120000_munged_name.json"
sidecar.write_text(json.dumps({"original_filename": "Pretty Track Name.flac"}))
target = recover_to_staging(str(quarantine), str(staging), "20260514_120000_munged_name")
assert target is not None
assert os.path.basename(target) == "Pretty Track Name.flac"
def test_recover_returns_none_for_missing_entry(tmp_path):
assert recover_to_staging(str(tmp_path / "q"), str(tmp_path / "s"), "nope") is None
def test_recover_avoids_filename_collision(tmp_path):
quarantine = tmp_path / "q"
quarantine.mkdir()
staging = tmp_path / "s"
staging.mkdir()
(staging / "song.flac").write_bytes(b"existing")
_write_entry(quarantine, "20260514_120000", "song.flac")
target = recover_to_staging(str(quarantine), str(staging), "20260514_120000_song")
assert target is not None
assert os.path.basename(target) == "song_(2).flac"
def test_recover_removes_sidecar_after_move(tmp_path):
quarantine = tmp_path / "q"
quarantine.mkdir()
staging = tmp_path / "s"
_, sidecar = _write_entry(quarantine, "20260514_120000", "song.flac")
recover_to_staging(str(quarantine), str(staging), "20260514_120000_song")
assert not sidecar.exists()

View file

@ -8194,6 +8194,93 @@ def clear_quarantine():
logger.error(f"[Quarantine] Error clearing quarantine: {e}")
return jsonify({"success": False, "error": str(e)}), 500
def _get_quarantine_dir():
return os.path.join(
docker_resolve_path(config_manager.get('soulseek.download_path', './downloads')),
'ss_quarantine',
)
@app.route('/api/quarantine/list', methods=['GET'])
def list_quarantine():
"""Return all quarantined files with sidecar metadata."""
try:
from core.imports.quarantine import list_quarantine_entries
entries = list_quarantine_entries(_get_quarantine_dir())
return jsonify({"success": True, "entries": entries})
except Exception as e:
logger.error(f"[Quarantine] Error listing entries: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/quarantine/<entry_id>', methods=['DELETE'])
def delete_quarantine_item(entry_id):
"""Delete a single quarantined file + sidecar."""
try:
from core.imports.quarantine import delete_quarantine_entry
ok = delete_quarantine_entry(_get_quarantine_dir(), entry_id)
if not ok:
return jsonify({"success": False, "error": "Entry not found"}), 404
return jsonify({"success": True})
except Exception as e:
logger.error(f"[Quarantine] Error deleting {entry_id}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/quarantine/<entry_id>/approve', methods=['POST'])
def approve_quarantine_item(entry_id):
"""One-click approve: restore the file and re-run post-process with the
matching per-check bypass flag set so the original quarantine trigger
is skipped. Other checks still run."""
try:
from core.imports.quarantine import approve_quarantine_entry
# Restore inside the soulseek download dir so existing path-resolution
# logic finds it. Unique subdir keeps it from re-mingling with active
# transfers.
restore_dir = os.path.join(
docker_resolve_path(config_manager.get('soulseek.download_path', './downloads')),
'Transfer',
)
result = approve_quarantine_entry(_get_quarantine_dir(), entry_id, restore_dir)
if result is None:
return jsonify({
"success": False,
"error": "Cannot one-click approve — entry has thin sidecar (no embedded context). Use 'Recover to Staging' instead.",
}), 400
restored_path, context, trigger = result
# Mark the bypass so the pipeline skips the trigger that fired.
context['_skip_quarantine_check'] = trigger
# Re-dispatch through the same pipeline. Run async so the HTTP
# request returns quickly — UI polls /list to see the entry vanish.
context_key = f"approve_{entry_id}_{int(time.time())}"
threading.Thread(
target=lambda: _post_process_matched_download(context_key, context, restored_path),
daemon=True,
).start()
logger.info(f"[Quarantine] Approved {entry_id} (bypass={trigger}) → re-running pipeline")
return jsonify({"success": True, "trigger_bypassed": trigger})
except Exception as e:
logger.error(f"[Quarantine] Error approving {entry_id}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/quarantine/<entry_id>/recover', methods=['POST'])
def recover_quarantine_item(entry_id):
"""Fallback for legacy thin sidecars: move file into Staging so the user
can manually finish via the existing Import flow."""
try:
from core.imports.quarantine import recover_to_staging
from core.imports.staging import get_staging_path
target = recover_to_staging(_get_quarantine_dir(), get_staging_path(), entry_id)
if not target:
return jsonify({"success": False, "error": "Entry not found"}), 404
return jsonify({"success": True, "staged_path": target})
except Exception as e:
logger.error(f"[Quarantine] Error recovering {entry_id}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/scan/request', methods=['POST'])
def request_media_scan():
"""

View file

@ -2200,6 +2200,7 @@
</div>
</div>
</div>
</div>
</div>
@ -7571,6 +7572,9 @@
<button class="library-history-tab" data-tab="import" onclick="switchHistoryTab('import')">
Server Imports <span class="library-history-tab-count" id="history-import-count">0</span>
</button>
<button class="library-history-tab" data-tab="quarantine" onclick="switchHistoryTab('quarantine')">
Quarantine <span class="library-history-tab-count" id="history-quarantine-count">0</span>
</button>
</div>
<div class="history-source-bar" id="history-source-bar" style="display:none"></div>
<div class="library-history-list" id="library-history-list"></div>

View file

@ -3382,7 +3382,18 @@ function processModalStatusUpdate(playlistId, data) {
case 'post_processing': statusText = '⌛ Processing...'; break;
case 'completed': statusText = '✅ Completed'; completedCount++; break;
case 'not_found': statusText = '🔇 Not Found'; notFoundCount++; break;
case 'failed': statusText = '❌ Failed'; failedOrCancelledCount++; break;
case 'failed': {
// Distinguish quarantine outcomes from generic
// failures — the file is recoverable, not lost.
const _em = (task.error_message || '').toLowerCase();
if (_em.includes('integrity check failed') || _em.includes('bit depth filter') || _em.includes('verification failed') || _em.includes('quarantin')) {
statusText = '🛡️ Quarantined';
} else {
statusText = '❌ Failed';
}
failedOrCancelledCount++;
break;
}
case 'cancelled': statusText = '🚫 Cancelled'; failedOrCancelledCount++; break;
default: statusText = `${task.status}`; break;
}

View file

@ -3416,6 +3416,7 @@ const WHATS_NEW = {
'2.5.2': [
// --- May 13, 2026 — 2.5.2 release ---
{ date: 'May 13, 2026 — 2.5.2 release' },
{ title: 'Quarantine Management — See, Approve, Delete Files Without Touching The Filesystem', desc: 'github issue #584: quarantined files used to just sit in `ss_quarantine/` with a thin sidecar — no UI, no recovery, no way to see what got dropped or why. new **Quarantine** tab on the existing Library History modal (downloads page → Download History button) lists every quarantined file with the same row chrome as the Downloads + Server Imports tabs: thumb placeholder, expected track + artist, original filename, trigger badge (Duration / AcoustID / Bit Depth), relative time, expandable details panel showing the full failure reason. three per-row actions: **Approve** (restores the file, re-runs post-processing with ONLY the failing check skipped, lands in your library with full tags + lyrics + scan), **Recover** (legacy fallback for entries quarantined before this PR with thin sidecars — moves to Staging so you finish via Import flow), **Delete** (permanent removal of file + sidecar). all three use the themed soulsync confirm modal + toast feedback (no native browser alert / confirm). per-check bypass means approving a duration-mismatch file still runs AcoustID; approving an AcoustID failure still runs bit-depth — other quality gates stay live so you can only override one trigger at a time. files that fail a different check after approval get re-quarantined with the new trigger label so you can decide again. sidecar now persists the full json-safe context so approve has everything the pipeline needs to re-process. download modal status differentiates "🛡️ Quarantined" from "❌ Failed" so recoverable files are visible at a glance. logic lifted to pure helpers in `core/imports/quarantine.py` (list / delete / approve / recover_to_staging / serialize_quarantine_context) with 27 boundary tests covering orphan files / orphan sidecars / corrupt sidecars / collision-safe filename restoration / full-context vs thin-sidecar dispatch / json round-trip safety. four new endpoints. pipeline change is per-check conditionals at the existing quarantine sites — no blanket skip-all flag.', page: 'downloads' },
{ title: 'Configurable Duration Tolerance For Quarantined Tracks', desc: 'discord question: tracks were quarantining when their actual length drifted by a few seconds from what spotify/musicbrainz reported (3s tolerance hardcoded, 5s for tracks >10min). live recordings, alternate masterings, and some legitimate uploads routinely drift more than that. new setting on settings → metadata → post-processing: "duration tolerance (seconds)". `0 = auto` (preserves the existing 3s/5s defaults). raise it to 10 / 15 / 20 if your library has a lot of drift-prone material. capped at 60s — past that the check is effectively off. applies to ALL matched downloads (soulseek / tidal / qobuz / hifi / youtube / deezer-direct) since they all flow through the same post-process integrity check. logic lifted to a pure helper `core/imports/file_integrity.py:resolve_duration_tolerance` that coerces the config value (none / empty / 0 / negative / unparseable / above-cap) to either a float override or `None` for the auto-scaled default. 12 tests pin every input shape.', page: 'settings' },
{ title: 'Soulseek Downloads: Multi-Artist Tags Now Get Written Properly', desc: 'discord report: tracks downloaded via soulseek were getting tagged with primary artist only (no collab artists), while the same track downloaded via deezer tagged everyone correctly. trace: the soulseek matched-download context constructed `original_search_result` with `artist` (singular string) but no `artists` (list), even though the full multi-artist list lived on `track_info` (the matched spotify track object). `core/metadata/source.py:extract_source_metadata` only read `original_search.artists`, so soulseek path always fell through to the single-artist branch. fix: lifted artist resolution into a pure helper `core/metadata/artist_resolution.py:resolve_track_artists` that walks `original_search.artists` → `track_info.artists` → `artist_dict.name` fallback chain. handles all three list-item shapes (spotify-style dicts, bare strings, anything else stringified). 13 tests pin the resolution order, fallback chain, mixed-shape normalization, whitespace stripping, empty/none handling. composes with the existing deezer per-track upgrade (still fires when single-artist + track_id available) and feat_in_title / artist_separator settings (still drive the joined ARTIST string downstream).', page: 'downloads' },
{ title: 'Download Missing Modal: Tracklist Got A Polish Pass', desc: 'visual tune-up only — column layout untouched. hairline row dividers, accent gradient + edge bar on hover, monospace track numbers (glow accent on row hover), monospace tabular duration. status text in both library-match + download-status columns picks up a leading colored dot with a soft halo (green found / amber missing / blue checking / orange downloading / red failed) and pulses while in-flight. artist column centered. soft scrollbar.', page: 'downloads' },

View file

@ -3094,6 +3094,150 @@ function openLibraryHistoryModal() {
}
}
// ──────────────────────────────────────────────────────────────────────
// Quarantine tab — rendered inside the Library History modal as a third
// tab next to Downloads + Server Imports. Reuses the existing list +
// pagination chrome; provides per-row Approve / Recover / Delete actions.
// ──────────────────────────────────────────────────────────────────────
async function loadQuarantineList() {
const list = document.getElementById('library-history-list');
const pagination = document.getElementById('library-history-pagination');
const sourceBar = document.getElementById('history-source-bar');
if (!list) return;
list.innerHTML = '<div class="library-history-loading">Loading...</div>';
if (pagination) pagination.innerHTML = '';
if (sourceBar) sourceBar.style.display = 'none';
try {
const resp = await fetch('/api/quarantine/list');
const data = await resp.json();
const entries = data.entries || [];
const countEl = document.getElementById('history-quarantine-count');
if (countEl) countEl.textContent = entries.length;
if (!data.success) {
list.innerHTML = `<div class="library-history-empty">Error: ${escapeHtml(data.error || 'Failed to load')}</div>`;
return;
}
if (entries.length === 0) {
list.innerHTML = '<div class="library-history-empty">🛡️<br><br>No quarantined files. Nice and clean.</div>';
return;
}
list.innerHTML = entries.map(renderQuarantineEntry).join('');
} catch (err) {
console.error('Error loading quarantine entries:', err);
list.innerHTML = '<div class="library-history-empty">Error loading quarantine</div>';
}
}
function renderQuarantineEntry(entry) {
const triggerLabels = { integrity: 'Duration / Integrity', acoustid: 'AcoustID Mismatch', bit_depth: 'Bit Depth Filter', unknown: 'Unknown' };
const triggerColors = { integrity: '#facc15', acoustid: '#ef5350', bit_depth: '#fb923c', unknown: '#888' };
const triggerLabel = triggerLabels[entry.trigger] || entry.trigger || 'Unknown';
const triggerColor = triggerColors[entry.trigger] || '#888';
const id = escapeHtml(entry.id);
const approveLabel = entry.has_full_context ? 'Approve' : 'Recover';
const approveTitle = entry.has_full_context
? 'Re-run post-processing with only the failing check skipped'
: 'Legacy entry — move to Staging, finish via Import flow';
const approveCall = entry.has_full_context
? `approveQuarantineEntry('${id}')`
: `recoverQuarantineEntry('${id}')`;
const meta = [entry.expected_artist, entry.original_filename].filter(Boolean).join(' — ');
const triggerBadge = `<span class="library-history-badge" style="border-color:${triggerColor};color:${triggerColor}">${escapeHtml(triggerLabel)}</span>`;
const reasonDetail = `<div class="library-history-entry-source"><span class="lh-prov-label">Reason:</span> ${escapeHtml(entry.reason || 'Unknown')}</div>`;
return `<div class="library-history-entry lh-expandable" onclick="this.classList.toggle('lh-expanded')">
<div class="library-history-thumb-placeholder">🛡</div>
<div class="library-history-entry-content">
<div class="library-history-entry-row1">
<div class="library-history-entry-text">
<div class="library-history-entry-title">${escapeHtml(entry.expected_track || entry.original_filename || 'Unknown')}</div>
<div class="library-history-entry-meta">${escapeHtml(meta)}</div>
</div>
<div class="library-history-entry-badges">${triggerBadge}</div>
<div class="library-history-entry-time">${formatHistoryTime(entry.timestamp)}</div>
<button class="lh-audit-btn" title="${approveTitle}" onclick="event.stopPropagation();${approveCall}">${approveLabel}</button>
<button class="lh-audit-btn" title="Delete permanently" style="border-color:rgba(248,113,113,0.4);color:#f87171" onclick="event.stopPropagation();deleteQuarantineEntry('${id}')">Delete</button>
<span class="lh-expand-btn">&#x25BE;</span>
</div>
<div class="library-history-entry-details">
${reasonDetail}
</div>
</div>
</div>`;
}
async function approveQuarantineEntry(entryId) {
const ok = await showConfirmDialog({
title: 'Approve Quarantined File',
message: 'Re-run post-processing for this file with only the failing check skipped. The file will be tagged, lyrics generated, and moved into your library. Other quality gates (AcoustID + bit-depth) still run.',
confirmText: 'Approve & Import',
cancelText: 'Cancel',
});
if (!ok) return;
try {
const r = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/approve`, { method: 'POST' });
const data = await r.json();
if (!data.success) {
showToast(`Approve failed: ${data.error}`, 'error');
} else {
showToast(`Approved — skipped ${data.trigger_bypassed} check, re-running pipeline.`, 'success');
}
} catch (err) {
showToast(`Approve failed: ${err.message}`, 'error');
}
loadQuarantineList();
}
async function recoverQuarantineEntry(entryId) {
const ok = await showConfirmDialog({
title: 'Recover To Staging',
message: 'Legacy entry — no embedded context. The file will be moved to your Staging folder so you can finish via the Import page (manual match).',
confirmText: 'Move To Staging',
cancelText: 'Cancel',
});
if (!ok) return;
try {
const r = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/recover`, { method: 'POST' });
const data = await r.json();
if (!data.success) {
showToast(`Recover failed: ${data.error}`, 'error');
} else {
showToast('Moved to Staging — finish via the Import page.', 'success');
}
} catch (err) {
showToast(`Recover failed: ${err.message}`, 'error');
}
loadQuarantineList();
}
async function deleteQuarantineEntry(entryId) {
const ok = 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,
});
if (!ok) return;
try {
const r = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}`, { method: 'DELETE' });
const data = await r.json();
if (!data.success) {
showToast(`Delete failed: ${data.error}`, 'error');
} else {
showToast('Quarantined file deleted.', 'success');
}
} catch (err) {
showToast(`Delete failed: ${err.message}`, 'error');
}
loadQuarantineList();
}
function closeLibraryHistoryModal() {
const overlay = document.getElementById('library-history-overlay');
if (overlay) overlay.classList.add('hidden');
@ -3110,6 +3254,12 @@ function switchHistoryTab(tab) {
async function loadLibraryHistory() {
const { tab, page, limit } = _libraryHistoryState;
if (tab === 'quarantine') {
// Refresh the count for the other two tabs in the background so
// the badge stays accurate when the user switches over.
loadQuarantineList();
return;
}
const list = document.getElementById('library-history-list');
const pagination = document.getElementById('library-history-pagination');
if (!list) return;