Merge pull request #748 from Nezreka/fix/reorganize-skip-deleted-quarantine-746
Reorganize: skip files in the duplicate-cleaner /deleted quarantine (…
This commit is contained in:
commit
56c58c3afc
3 changed files with 227 additions and 0 deletions
|
|
@ -956,6 +956,17 @@ def preview_album_reorganize(
|
||||||
'disc_number': None,
|
'disc_number': None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# #746: never reorganize files sitting in the duplicate-cleaner
|
||||||
|
# quarantine (<transfer>/deleted). Surface as a non-matched skip so
|
||||||
|
# the preview shows WHY and apply leaves them put. Checked before the
|
||||||
|
# matched branch so a quarantined track that happens to match the API
|
||||||
|
# tracklist is still skipped.
|
||||||
|
if _is_in_deleted_quarantine(resolved, transfer_dir):
|
||||||
|
item['matched'] = False
|
||||||
|
item['reason'] = 'In deleted/quarantine folder — skipped'
|
||||||
|
preview_tracks.append(item)
|
||||||
|
continue
|
||||||
|
|
||||||
if not plan_item['matched']:
|
if not plan_item['matched']:
|
||||||
preview_tracks.append(item)
|
preview_tracks.append(item)
|
||||||
continue
|
continue
|
||||||
|
|
@ -1011,6 +1022,42 @@ def preview_album_reorganize(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_in_deleted_quarantine(resolved_path, transfer_dir) -> bool:
|
||||||
|
"""True when ``resolved_path`` lives inside the duplicate-cleaner
|
||||||
|
quarantine folder (``<transfer_dir>/deleted/...``).
|
||||||
|
|
||||||
|
The Duplicate Cleaner (``core/library/duplicate_cleaner.py``) moves
|
||||||
|
de-duplicated files into ``<transfer_dir>/deleted/``. If the user's
|
||||||
|
media server scans the transfer folder (e.g. a ``/music`` root that
|
||||||
|
contains both the library and the transfer dir), those quarantined
|
||||||
|
files get real rows in SoulSync's DB — and Reorganize, being purely
|
||||||
|
DB-driven, would otherwise dutifully move them back OUT of /deleted
|
||||||
|
to the template location. This guard makes Reorganize skip them so
|
||||||
|
the quarantine stays quarantined (#746).
|
||||||
|
|
||||||
|
Anchored to ``<transfer_dir>/deleted`` specifically so a legitimately
|
||||||
|
named artist/album like "Deleted" elsewhere in the library is NOT
|
||||||
|
skipped. When ``transfer_dir`` is unavailable we fall back to an exact
|
||||||
|
``deleted`` path-SEGMENT match (mirrors the cleaner's own
|
||||||
|
``if 'deleted' in dirs`` skip) — never a substring, so "Undeleted"
|
||||||
|
or "deleted scenes" stay safe.
|
||||||
|
"""
|
||||||
|
if not resolved_path:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _norm(p):
|
||||||
|
# normpath collapses redundant separators / '..'; normcase applies
|
||||||
|
# the platform's case rule (lowercases on Windows, no-op on posix);
|
||||||
|
# fold to '/' so the segment/prefix checks are separator-agnostic.
|
||||||
|
return os.path.normcase(os.path.normpath(p)).replace('\\', '/')
|
||||||
|
|
||||||
|
norm = _norm(resolved_path)
|
||||||
|
if transfer_dir:
|
||||||
|
quarantine = _norm(os.path.join(transfer_dir, 'deleted'))
|
||||||
|
return norm == quarantine or norm.startswith(quarantine + '/')
|
||||||
|
return 'deleted' in norm.split('/')
|
||||||
|
|
||||||
|
|
||||||
def _trim_to_transfer(db_path, resolved, transfer_dir):
|
def _trim_to_transfer(db_path, resolved, transfer_dir):
|
||||||
"""Compose the user-facing 'current path' string — relative to the
|
"""Compose the user-facing 'current path' string — relative to the
|
||||||
transfer dir if the file lives there, else the raw DB value."""
|
transfer dir if the file lives there, else the raw DB value."""
|
||||||
|
|
@ -1094,6 +1141,7 @@ class _RunContext:
|
||||||
update_track_path_fn: Optional[Callable[[Any, str], None]] = None
|
update_track_path_fn: Optional[Callable[[Any, str], None]] = None
|
||||||
on_progress: Optional[Callable[[dict], None]] = None
|
on_progress: Optional[Callable[[dict], None]] = None
|
||||||
stop_check: Optional[Callable[[], bool]] = None
|
stop_check: Optional[Callable[[], bool]] = None
|
||||||
|
transfer_dir: Optional[str] = None # anchors the #746 /deleted-quarantine skip
|
||||||
|
|
||||||
def emit(self, **updates) -> None:
|
def emit(self, **updates) -> None:
|
||||||
"""Fire the progress callback. Caller is responsible for
|
"""Fire the progress callback. Caller is responsible for
|
||||||
|
|
@ -1271,6 +1319,15 @@ def _process_one_track(ctx: _RunContext, plan_item: dict) -> None:
|
||||||
f"File not found on disk — DB path: {db_path or '(empty)'}")
|
f"File not found on disk — DB path: {db_path or '(empty)'}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# #746: leave duplicate-cleaner quarantine files (<transfer>/deleted)
|
||||||
|
# where they are. Matches the preview's skip so apply never yanks a file
|
||||||
|
# back out of /deleted. (Mirrors the preview guard in
|
||||||
|
# preview_album_reorganize.)
|
||||||
|
if _is_in_deleted_quarantine(resolved_src, ctx.transfer_dir):
|
||||||
|
ctx.record_error(track_id, title,
|
||||||
|
'In deleted/quarantine folder — skipped')
|
||||||
|
return
|
||||||
|
|
||||||
staging_file = _stage_track(ctx, track_id, title, resolved_src)
|
staging_file = _stage_track(ctx, track_id, title, resolved_src)
|
||||||
if staging_file is None:
|
if staging_file is None:
|
||||||
return
|
return
|
||||||
|
|
@ -1470,6 +1527,7 @@ def reorganize_album(
|
||||||
update_track_path_fn=update_track_path_fn,
|
update_track_path_fn=update_track_path_fn,
|
||||||
on_progress=on_progress,
|
on_progress=on_progress,
|
||||||
stop_check=stop_check,
|
stop_check=stop_check,
|
||||||
|
transfer_dir=transfer_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -1934,3 +1934,100 @@ def test_stop_check_aborts_remaining_tracks(monkeypatch, tmpdirs):
|
||||||
# but not ALL 10 — the stop_check cut off the unstarted ones.
|
# but not ALL 10 — the stop_check cut off the unstarted ones.
|
||||||
assert pp_count[0] < 10
|
assert pp_count[0] < 10
|
||||||
assert pp_count[0] >= 2
|
assert pp_count[0] >= 2
|
||||||
|
|
||||||
|
|
||||||
|
# --- tests: #746 /deleted-quarantine skip ---------------------------------
|
||||||
|
|
||||||
|
def test_preview_skips_track_in_deleted_quarantine(monkeypatch, tmpdirs):
|
||||||
|
"""A track whose file lives in <transfer>/deleted (duplicate-cleaner
|
||||||
|
quarantine) must be surfaced as a non-matched skip in the preview, even
|
||||||
|
though its title matches the API tracklist — Reorganize must not offer to
|
||||||
|
move it back out of /deleted (#746)."""
|
||||||
|
library, _staging, transfer = tmpdirs
|
||||||
|
db = _FakeDB()
|
||||||
|
# One normal track in the library, one quarantined track under
|
||||||
|
# <transfer>/deleted. Both have titles that match the API list.
|
||||||
|
quarantine = transfer / 'deleted' / 'Aerosmith'
|
||||||
|
quarantine.mkdir(parents=True)
|
||||||
|
deleted_file = quarantine / 'dream.flac'
|
||||||
|
deleted_file.write_bytes(b'dupe')
|
||||||
|
_setup_album(db, deezer_id='dz-1', tracks=[
|
||||||
|
('t1', 1, 'Same Old Song And Dance', _make_audio_file(library, 't1.flac')),
|
||||||
|
('t2', 2, 'Dream On', str(deleted_file)),
|
||||||
|
])
|
||||||
|
|
||||||
|
monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer')
|
||||||
|
monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p])
|
||||||
|
monkeypatch.setattr(library_reorganize, 'get_album_for_source',
|
||||||
|
lambda *a: {'id': 'dz-1', 'name': 'Aerosmith'})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
library_reorganize, 'get_album_tracks_for_source',
|
||||||
|
lambda *a: {'items': [
|
||||||
|
{'id': 'a1', 'name': 'Same Old Song And Dance', 'track_number': 1},
|
||||||
|
{'id': 'a2', 'name': 'Dream On', 'track_number': 2},
|
||||||
|
]},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = library_reorganize.preview_album_reorganize(
|
||||||
|
album_id='alb-1', db=db, transfer_dir=str(transfer),
|
||||||
|
resolve_file_path_fn=lambda p: p,
|
||||||
|
build_final_path_fn=_fake_path_builder,
|
||||||
|
)
|
||||||
|
|
||||||
|
by_title = {it['title']: it for it in result['tracks']}
|
||||||
|
# Normal track: matched + gets a destination.
|
||||||
|
assert by_title['Same Old Song And Dance']['matched'] is True
|
||||||
|
assert by_title['Same Old Song And Dance']['new_path']
|
||||||
|
# Quarantined track: skipped despite matching the API tracklist.
|
||||||
|
assert by_title['Dream On']['matched'] is False
|
||||||
|
assert 'quarantine' in (by_title['Dream On']['reason'] or '').lower()
|
||||||
|
assert by_title['Dream On']['new_path'] == ''
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_skips_track_in_deleted_quarantine(monkeypatch, tmpdirs):
|
||||||
|
"""Apply mirrors the preview: post-process is never called for a
|
||||||
|
quarantined track, the original is left in /deleted, and it's counted as
|
||||||
|
skipped (not moved, not failed) (#746)."""
|
||||||
|
library, staging, transfer = tmpdirs
|
||||||
|
db = _FakeDB()
|
||||||
|
quarantine = transfer / 'deleted' / 'Aerosmith'
|
||||||
|
quarantine.mkdir(parents=True)
|
||||||
|
deleted_file = quarantine / 'dream.flac'
|
||||||
|
deleted_file.write_bytes(b'dupe')
|
||||||
|
_setup_album(db, deezer_id='dz-1', tracks=[
|
||||||
|
('t1', 1, 'Same Old Song And Dance', _make_audio_file(library, 't1.flac')),
|
||||||
|
('t2', 2, 'Dream On', str(deleted_file)),
|
||||||
|
])
|
||||||
|
|
||||||
|
monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer')
|
||||||
|
monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p])
|
||||||
|
monkeypatch.setattr(library_reorganize, 'get_album_for_source',
|
||||||
|
lambda *a: {'id': 'dz-1', 'name': 'Aerosmith'})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
library_reorganize, 'get_album_tracks_for_source',
|
||||||
|
lambda *a: {'items': [
|
||||||
|
{'id': 'a1', 'name': 'Same Old Song And Dance', 'track_number': 1},
|
||||||
|
{'id': 'a2', 'name': 'Dream On', 'track_number': 2},
|
||||||
|
]},
|
||||||
|
)
|
||||||
|
|
||||||
|
pp_titles = []
|
||||||
|
|
||||||
|
def pp(key, ctx, fp):
|
||||||
|
pp_titles.append(ctx['track_info']['name'])
|
||||||
|
ctx['_final_processed_path'] = fp
|
||||||
|
with open(fp, 'wb') as f:
|
||||||
|
f.write(b'final')
|
||||||
|
|
||||||
|
summary = library_reorganize.reorganize_album(
|
||||||
|
album_id='alb-1', db=db, staging_root=str(staging),
|
||||||
|
resolve_file_path_fn=lambda p: p, post_process_fn=pp,
|
||||||
|
transfer_dir=str(transfer),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only the normal track was post-processed; the quarantined one was not.
|
||||||
|
assert pp_titles == ['Same Old Song And Dance']
|
||||||
|
assert summary['moved'] == 1
|
||||||
|
assert summary['skipped'] == 1
|
||||||
|
# The quarantined file is untouched on disk.
|
||||||
|
assert deleted_file.exists()
|
||||||
|
|
|
||||||
72
tests/test_reorganize_deleted_quarantine.py
Normal file
72
tests/test_reorganize_deleted_quarantine.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
"""Reorganize must skip files in the duplicate-cleaner quarantine (#746).
|
||||||
|
|
||||||
|
The Duplicate Cleaner moves de-duplicated files into ``<transfer>/deleted/``.
|
||||||
|
If the user's media server scans the transfer folder (e.g. a ``/music`` root
|
||||||
|
holding both the library and the transfer dir), those quarantined files get real
|
||||||
|
rows in SoulSync's DB. Reorganize is purely DB-driven, so without a guard it
|
||||||
|
would move them back OUT of /deleted to the template location.
|
||||||
|
|
||||||
|
These tests pin:
|
||||||
|
1. ``_is_in_deleted_quarantine`` — the anchored detection, including the
|
||||||
|
false-positive guard (an album literally named "Deleted" elsewhere is kept).
|
||||||
|
2. ``preview_album_reorganize`` — a quarantined track is surfaced as a skip,
|
||||||
|
a normal track is not (proves the planner-shared guard fires on the path
|
||||||
|
both preview AND apply run through).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from core.library_reorganize import _is_in_deleted_quarantine
|
||||||
|
|
||||||
|
|
||||||
|
TRANSFER = os.path.join(os.sep, "music", "soulsync")
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsInDeletedQuarantine:
|
||||||
|
def test_file_directly_in_quarantine_is_flagged(self):
|
||||||
|
p = os.path.join(TRANSFER, "deleted", "Artist", "Album", "01.flac")
|
||||||
|
assert _is_in_deleted_quarantine(p, TRANSFER) is True
|
||||||
|
|
||||||
|
def test_file_in_normal_album_is_not_flagged(self):
|
||||||
|
p = os.path.join(TRANSFER, "Artist", "Album", "01.flac")
|
||||||
|
assert _is_in_deleted_quarantine(p, TRANSFER) is False
|
||||||
|
|
||||||
|
def test_album_with_deleted_in_name_is_kept(self):
|
||||||
|
"""Anchored to the <transfer>/deleted PREFIX, not a substring — a
|
||||||
|
real album like 'Deleted Scenes' nested under an artist must NOT be
|
||||||
|
skipped."""
|
||||||
|
p = os.path.join(TRANSFER, "Artist", "Deleted Scenes", "01.flac")
|
||||||
|
assert _is_in_deleted_quarantine(p, TRANSFER) is False
|
||||||
|
|
||||||
|
def test_known_unavoidable_collision_is_documented(self):
|
||||||
|
"""The ONE genuine ambiguity: an artist folder named exactly
|
||||||
|
'deleted' sitting directly at the transfer root occupies the same
|
||||||
|
path as the duplicate-cleaner quarantine, so it IS treated as
|
||||||
|
quarantine. This is unavoidable (we can't tell a real 'deleted'
|
||||||
|
artist from the cleaner's dir) and accepted — pinned here so the
|
||||||
|
behavior is intentional, not a surprise. Differently-cased or
|
||||||
|
nested 'Deleted' names are safe (see the other tests)."""
|
||||||
|
collision = os.path.join(TRANSFER, "deleted", "Album", "01.flac")
|
||||||
|
assert _is_in_deleted_quarantine(collision, TRANSFER) is True
|
||||||
|
|
||||||
|
def test_substring_not_matched(self):
|
||||||
|
"""'Undeleted' / 'deleted_scenes' as a segment must not trip the
|
||||||
|
exact-segment / prefix check."""
|
||||||
|
p = os.path.join(TRANSFER, "Undeleted", "Album", "01.flac")
|
||||||
|
assert _is_in_deleted_quarantine(p, TRANSFER) is False
|
||||||
|
|
||||||
|
def test_no_transfer_dir_falls_back_to_segment_match(self):
|
||||||
|
p = os.path.join(os.sep, "anywhere", "deleted", "x.flac")
|
||||||
|
assert _is_in_deleted_quarantine(p, None) is True
|
||||||
|
p2 = os.path.join(os.sep, "anywhere", "Undeleted", "x.flac")
|
||||||
|
assert _is_in_deleted_quarantine(p2, None) is False
|
||||||
|
|
||||||
|
def test_empty_path_is_safe(self):
|
||||||
|
assert _is_in_deleted_quarantine(None, TRANSFER) is False
|
||||||
|
assert _is_in_deleted_quarantine("", TRANSFER) is False
|
||||||
|
|
||||||
|
def test_nested_subfolders_under_quarantine_flagged(self):
|
||||||
|
p = os.path.join(TRANSFER, "deleted", "a", "b", "c", "track.flac")
|
||||||
|
assert _is_in_deleted_quarantine(p, TRANSFER) is True
|
||||||
Loading…
Reference in a new issue