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.
69 lines
2.6 KiB
Python
69 lines
2.6 KiB
Python
"""Relocate an AcoustID-mismatched file into Staging for clean re-import (#704).
|
|
|
|
The existing 'retag' fix corrects a mismatched file's tags + DB record but leaves
|
|
the file in the WRONG artist/album folder on disk — so the library shows the right
|
|
title while the file sits under the previous track's artist/album. AcoustID only
|
|
yields a title + artist (not a reliable album), so an *in-place* move has no
|
|
trustworthy target.
|
|
|
|
Instead: retag the file, move it into the staging folder, and drop the stale
|
|
``tracks`` row. The auto-import worker (which watches staging) then re-identifies
|
|
the file with full metadata and files it in the correct artist/album/track path —
|
|
reusing the battle-tested import pipeline rather than guessing a destination here.
|
|
|
|
Side effects are injected so the orchestration is a pure, unit-testable seam.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from typing import Any, Callable, Dict, Optional
|
|
|
|
|
|
def staging_destination(staging_dir: str, filename: str,
|
|
exists: Callable[[str], bool]) -> str:
|
|
"""A non-colliding path for ``filename`` inside ``staging_dir``.
|
|
|
|
If the name is already taken, suffix it ``' (1)'``, ``' (2)'``, … before the
|
|
extension — never overwrite an unrelated file already waiting in staging.
|
|
"""
|
|
base, ext = os.path.splitext(filename)
|
|
dest = os.path.join(staging_dir, filename)
|
|
n = 1
|
|
while exists(dest):
|
|
dest = os.path.join(staging_dir, f"{base} ({n}){ext}")
|
|
n += 1
|
|
return dest
|
|
|
|
|
|
def relocate_mismatch_to_staging(
|
|
resolved_path: str,
|
|
staging_dir: str,
|
|
tag_updates: Optional[Dict[str, Any]],
|
|
*,
|
|
write_tags: Callable[[str, Dict[str, Any]], Any],
|
|
move_file: Callable[[str, str], Any],
|
|
drop_db_row: Callable[[], Any],
|
|
exists: Callable[[str], bool],
|
|
) -> str:
|
|
"""Retag (best-effort) → move into staging → drop the stale DB row.
|
|
|
|
Returns the staging destination path. Order matters: the DB row is dropped
|
|
only AFTER a successful move, so a failed move (which raises) leaves the
|
|
library entry intact rather than orphaning it.
|
|
"""
|
|
if tag_updates:
|
|
try:
|
|
write_tags(resolved_path, tag_updates)
|
|
except Exception:
|
|
# Tags are best-effort — re-import re-derives them from the source.
|
|
# The relocation itself is the point, so don't abort over a tag write.
|
|
pass
|
|
|
|
dest = staging_destination(staging_dir, os.path.basename(resolved_path), exists)
|
|
move_file(resolved_path, dest) # may raise → row NOT dropped (intentional)
|
|
drop_db_row()
|
|
return dest
|
|
|
|
|
|
__all__ = ["staging_destination", "relocate_mismatch_to_staging"]
|