Renaming artist/album moves the file: music folder stays organized

Editing artist/album_artist/album now relocates the file to its new
<music_folder>/Music/Artist/Album/ home (folders created as needed,
"Unknown Artist" fallback via the same organized_destination the
importer uses), deliberately relaxing the old never-move rule:

- LibraryManager.organize_root() is the shared definition of the
  managed tree; MainWindow._music_import_dir delegates to it.
- Only files already inside the organize root are managed; external
  files get their tags edited but are never pulled into the tree.
- The move rides the existing undo Command maps as a "location" field:
  Ctrl+Z moves the file back, redo re-moves, both best-effort (a failed
  physical move drops "location" so memory tracks the real file).
- A failed move applies the edit, keeps the old path, and reports via
  the new file_move_failed signal (status bar), mirroring
  tag_write_failed. Collisions get the importer's " 1" suffix.
- Directories left empty are pruned up to (never including) the root.
- conflict_resolver._merge_track_fields now carries "location" with
  the newest-date_modified rule, so a rename-move on one machine
  propagates through both the sync-conflict merge and the live
  reload_from_disk reconcile on the other.
- The on-disk basename is kept verbatim (no re-sanitizing), so files
  named on macOS aren't gratuitously renamed by a tag edit.

Same-filesystem rename keeps the player's open fd valid, so moving the
currently-playing track is safe.
This commit is contained in:
2026-07-02 16:59:59 -04:00
parent 2b02f109f6
commit 8337980d6b
4 changed files with 302 additions and 4 deletions
+95
View File
@@ -8,6 +8,7 @@ from PyQt6.QtCore import QObject, QTimer, pyqtSignal
log = logging.getLogger(__name__)
from lintunes import smart, tagging
from lintunes.importers.file_importer import organized_destination, unique_path
from lintunes.models import Library, Playlist, PlaylistType
from lintunes.storage import json_storage
from lintunes.undo import Command, UndoStack
@@ -36,6 +37,7 @@ class LibraryManager(QObject):
library_reloaded = pyqtSignal() # library re-read from disk (sync)
conflict_resolved = pyqtSignal(list) # list[ConflictSummary] just merged
tag_write_failed = pyqtSignal(str, str) # track name, error text
file_move_failed = pyqtSignal(str, str) # track name, error text
def __init__(self, library: Library, data_dir: Path, parent=None):
super().__init__(parent)
@@ -374,6 +376,10 @@ class LibraryManager(QObject):
# File write failed: leave the library untouched so memory and file
# don't diverge, and don't record an un-undoable phantom edit.
return
new_loc = self._maybe_move_file(track, changed)
if new_loc is not None:
old = {**old, "location": track.location}
changed = {**changed, "location": new_loc}
self._apply_track_fields(track_id, changed)
self.undo_stack.push(Command(
"Edit Info",
@@ -398,6 +404,10 @@ class LibraryManager(QObject):
if not changed or not self._write_track_tags(track, changed):
continue
old = {k: getattr(track, k) for k in changed}
new_loc = self._maybe_move_file(track, changed)
if new_loc is not None:
old = {**old, "location": track.location}
changed = {**changed, "location": new_loc}
self._apply_track_fields(track_id, changed)
changes.append((track_id, changed, old))
if not changes:
@@ -441,8 +451,93 @@ class LibraryManager(QObject):
track = self.library.tracks.get(track_id)
if track:
self._write_track_tags(track, field_map) # best-effort
field_map = self._revert_file_move(track, field_map)
self._apply_track_fields(track_id, field_map)
def _revert_file_move(self, track, field_map: dict) -> dict:
"""Undo/redo of an edit that moved the file: move it to the map's
location, best-effort. If the physical move fails, drop ``location``
from the map so memory keeps pointing at where the file really is."""
target = field_map.get("location")
if target is None or target == track.location:
return field_map
src = Path(track.location)
try:
if not src.is_file():
raise OSError(f"missing source file {src}")
actual = self._move_file(src, Path(target))
return {**field_map, "location": str(actual)}
except OSError as e:
log.warning("Could not move %s back to %s: %s", src, target, e)
self.file_move_failed.emit(track.name or str(src), str(e))
return {k: v for k, v in field_map.items() if k != "location"}
# ---- keeping the music folder organized ----
_ORGANIZE_FIELDS = frozenset({"artist", "album_artist", "album"})
def organize_root(self) -> Path | None:
"""Root of the organized music tree (<music_folder>/Music) — the only
place imports copy into and rename-moves manage."""
if not self.library.music_folder:
return None
return Path(self.library.music_folder) / "Music"
def _maybe_move_file(self, track, changed: dict) -> str | None:
"""If an artist/album_artist/album edit changes where the file belongs
in the organized Artist/Album tree, move it there (creating folders as
needed) and return the new absolute path, else None.
Only files already inside the organize root are managed — an external
file the user never imported must not get pulled into the synced tree
by a tag edit. A failed move is reported via ``file_move_failed`` and
the edit proceeds with the old location (the tags are already written,
so the library stays consistent, just unorganized)."""
if not (self._ORGANIZE_FIELDS & changed.keys()) or not track.location:
return None
root = self.organize_root()
src = Path(track.location)
if root is None or root not in src.parents or not src.is_file():
return None
merged = {key: changed.get(key, getattr(track, key))
for key in self._ORGANIZE_FIELDS}
# Keep the exact on-disk basename: it's already valid here, and
# re-sanitizing could gratuitously rename files that came from macOS.
dest = organized_destination(root, merged, src.name).with_name(src.name)
if dest == src:
return None
try:
return str(self._move_file(src, dest))
except OSError as e:
log.warning("Could not move %s -> %s: %s", src, dest, e)
self.file_move_failed.emit(track.name or str(src), str(e))
return None
def _move_file(self, src: Path, dest: Path) -> Path:
"""Physically relocate a music file and prune the directories it left
empty. Same-filesystem rename, so this is safe even while the player
has the file open (the fd stays valid; the path is re-read at next
play). Returns the actual destination (collision-suffixed if taken)."""
dest = unique_path(dest)
dest.parent.mkdir(parents=True, exist_ok=True)
src.rename(dest)
root = self.organize_root()
if root is not None:
self._prune_empty_dirs(src.parent, root)
return dest
@staticmethod
def _prune_empty_dirs(start: Path, root: Path):
"""Remove now-empty directories from ``start`` upward, strictly below
``root`` (the organize root itself is never touched)."""
current = start
while root in current.parents:
try:
current.rmdir() # raises OSError if not empty
except OSError:
return
current = current.parent
def record_play(self, track_id: int):
track = self.library.tracks.get(track_id)
if not track: