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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user