The 'retag' fix corrects a mismatched file's tags/DB but leaves it in the WRONG artist/album folder, so the library shows the right title while the file sits under the previous track. AcoustID yields only title+artist (no reliable album), so an in-place move has no safe target. New 'relocate' action: retag the file, move it into Staging, drop the stale tracks row, and clean up the emptied folder. The auto-import worker (which watches Staging) re-identifies it with full metadata and files it correctly — reusing the import pipeline instead of guessing a destination. - core/repair_jobs/relocate.py: pure, injectable orchestration (retag -> move -> drop row) + collision-safe staging_destination. Row is dropped only AFTER a successful move, so a failed move never orphans the library entry. - _fix_acoustid_mismatch gains the 'relocate' branch (thin wrapper: resolve path, staging dir, drop-row closure, empty-parent cleanup). - UI: "Relocate" button on the AcoustID-mismatch fix modal. Tests (8): staging-dest collision suffixing; relocate happy path; tag-write failure still relocates; FAILED move does NOT drop the row; no-tags skips write; a real file move through safe_move_file; and a handler integration test (file moved to staging + tracks row deleted end-to-end). Repair + integrity suites green.
117 lines
5.4 KiB
Python
117 lines
5.4 KiB
Python
"""#704: relocate an AcoustID-mismatched file to staging for re-import — pure
|
|
orchestration (retag -> move -> drop row) with the side effects injected, plus a
|
|
real-file move through safe_move_file."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
import pytest
|
|
|
|
from core.repair_jobs.relocate import staging_destination, relocate_mismatch_to_staging
|
|
|
|
|
|
# ── staging_destination: never overwrite an unrelated staged file ──────────
|
|
def test_staging_destination_no_collision():
|
|
assert staging_destination('/stg', 'Song.mp3', exists=lambda p: False) == os.path.join('/stg', 'Song.mp3')
|
|
|
|
|
|
def test_staging_destination_suffixes_on_collision():
|
|
taken = {os.path.join('/stg', 'Song.mp3'), os.path.join('/stg', 'Song (1).mp3')}
|
|
assert staging_destination('/stg', 'Song.mp3', exists=lambda p: p in taken) == os.path.join('/stg', 'Song (2).mp3')
|
|
|
|
|
|
# ── relocate orchestration ─────────────────────────────────────────────────
|
|
class _Spy:
|
|
def __init__(self): self.tagged = None; self.moved = None; self.dropped = False
|
|
def write_tags(self, path, updates): self.tagged = (path, updates)
|
|
def move(self, src, dst): self.moved = (src, dst)
|
|
def drop(self): self.dropped = True
|
|
|
|
|
|
def test_relocate_happy_path():
|
|
s = _Spy()
|
|
dest = relocate_mismatch_to_staging(
|
|
'/lib/Artist X/Album Y/03 - x.mp3', '/stg', {'title': 'Real Song'},
|
|
write_tags=s.write_tags, move_file=s.move, drop_db_row=s.drop, exists=lambda p: False)
|
|
assert s.tagged == ('/lib/Artist X/Album Y/03 - x.mp3', {'title': 'Real Song'})
|
|
assert s.moved == ('/lib/Artist X/Album Y/03 - x.mp3', os.path.join('/stg', '03 - x.mp3'))
|
|
assert s.dropped is True
|
|
assert dest == os.path.join('/stg', '03 - x.mp3')
|
|
|
|
|
|
def test_relocate_tag_failure_still_relocates():
|
|
s = _Spy()
|
|
def boom(*a): raise RuntimeError('tag write failed')
|
|
dest = relocate_mismatch_to_staging(
|
|
'/lib/x.mp3', '/stg', {'title': 'T'},
|
|
write_tags=boom, move_file=s.move, drop_db_row=s.drop, exists=lambda p: False)
|
|
assert s.moved and s.dropped and dest == os.path.join('/stg', 'x.mp3')
|
|
|
|
|
|
def test_relocate_failed_move_does_not_drop_row():
|
|
# The library row must survive a failed move (no orphaning).
|
|
s = _Spy()
|
|
def bad_move(src, dst): raise OSError('cross-device move failed')
|
|
with pytest.raises(OSError):
|
|
relocate_mismatch_to_staging('/lib/x.mp3', '/stg', None,
|
|
write_tags=s.write_tags, move_file=bad_move, drop_db_row=s.drop, exists=lambda p: False)
|
|
assert s.dropped is False
|
|
|
|
|
|
def test_relocate_no_tag_updates_skips_write():
|
|
s = _Spy()
|
|
relocate_mismatch_to_staging('/lib/x.mp3', '/stg', None,
|
|
write_tags=s.write_tags, move_file=s.move, drop_db_row=s.drop, exists=lambda p: False)
|
|
assert s.tagged is None and s.moved is not None
|
|
|
|
|
|
# ── real-file move through the actual safe_move_file ───────────────────────
|
|
def test_real_file_moves_into_staging(tmp_path):
|
|
from core.imports.file_ops import safe_move_file
|
|
lib = tmp_path / 'lib' / 'Artist X' / 'Album Y'; lib.mkdir(parents=True)
|
|
src = lib / '03 - wrong.mp3'; src.write_bytes(b'\x00' * 64)
|
|
stg = tmp_path / 'Staging'; stg.mkdir()
|
|
dropped = []
|
|
dest = relocate_mismatch_to_staging(
|
|
str(src), str(stg), None,
|
|
write_tags=lambda *a: None, move_file=safe_move_file,
|
|
drop_db_row=lambda: dropped.append(True), exists=os.path.exists)
|
|
assert not src.exists() # moved out of the wrong folder
|
|
assert os.path.exists(dest) # present in staging
|
|
assert os.path.dirname(dest) == str(stg)
|
|
assert dropped == [True]
|
|
|
|
|
|
# ── handler integration: _fix_acoustid_mismatch relocate end-to-end ─────────
|
|
def test_relocate_handler_moves_file_and_drops_row(tmp_path):
|
|
from database.music_database import MusicDatabase
|
|
from core.repair_worker import RepairWorker
|
|
|
|
db = MusicDatabase(str(tmp_path / 'm.db'))
|
|
lib = tmp_path / 'music' / 'Wrong Artist' / 'Wrong Album'
|
|
lib.mkdir(parents=True)
|
|
wrong = lib / '03 - wrong.mp3'
|
|
wrong.write_bytes(b'\x00' * 64)
|
|
staging = tmp_path / 'Staging'; staging.mkdir()
|
|
|
|
with db._get_connection() as conn:
|
|
conn.execute("INSERT OR REPLACE INTO artists (id, name, server_source) VALUES ('a1','Wrong Artist','plex')")
|
|
conn.execute("INSERT OR REPLACE INTO albums (id, title, artist_id) VALUES (10,'Wrong Album','a1')")
|
|
conn.execute("INSERT INTO tracks (id, album_id, artist_id, title, track_number, duration, file_path, server_source) "
|
|
"VALUES ('t1',10,'a1','Wrong Title',3,100,?, 'plex')", (str(wrong),))
|
|
conn.commit()
|
|
|
|
worker = RepairWorker(db)
|
|
worker._config_manager = type('C', (), {
|
|
'get': staticmethod(lambda k, d=None: str(staging) if k == 'import.staging_path' else d)})()
|
|
|
|
res = worker._fix_acoustid_mismatch(
|
|
'track', 't1', str(wrong),
|
|
{'_fix_action': 'relocate', 'acoustid_title': 'Real Song', 'acoustid_artist': 'Real Artist'})
|
|
|
|
assert res['success'] is True and res['action'] == 'relocated'
|
|
assert not wrong.exists() # gone from the wrong album folder
|
|
assert (staging / '03 - wrong.mp3').exists() # now staged for re-import
|
|
with db._get_connection() as conn:
|
|
assert conn.execute("SELECT COUNT(*) FROM tracks WHERE id='t1'").fetchone()[0] == 0 # stale row dropped
|