"unplayed", "missed and never skipped" and "top 100 in past year" showed up in the merge window constantly, and nobody had touched their rules. A live smart playlist's membership is derived, but it was being persisted — and recompute_smart_playlist rewrites it (and bumped date_modified) every time a play count moves. Both machines did that against the same file after every song, so Syncthing conflicted on a list the next load throws away and rebuilds anyway. Membership now stays in memory: save_playlist writes track_ids: [] for a live smart playlist, and the recompute passes touch=False so it marks nothing dirty and moves no timestamp. live_update=False and unsupported criteria are unchanged — their track_ids are a snapshot, which is real content. Since _mark_playlist also dirties the metadata, library_metadata.json stops being rewritten every song too. The rest was presentation. Every summary read at the same weight, so someone resizing a column on the other machine popped and raised the same window as a 21-track reconciliation, described as "Library columns/settings taken from the most recently edited copy." Summaries now carry a level — WARNING for a merge that couldn't resolve cleanly or discarded a side, CHANGE for real content, INFO for cosmetic or derived — and the dialog has a Show: selector that filters to one level and above. It opens at the highest level in the batch, so nothing routine steals focus and a blank window can't happen; a later warning always pulls the view back up to it. The wording says what happened instead of how the merge works: a metadata merge names the keys that actually differed, and adopting the other machine's music folder grades CHANGE rather than INFO, because that one is a setting somebody chose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y9ZEFi4qNJ39FMiBtiAxy2
1078 lines
47 KiB
Python
1078 lines
47 KiB
Python
import logging
|
|
import secrets
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from PyQt6.QtCore import QObject, QTimer, pyqtSignal
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
from lintunes import music_folder as music_folder_mod
|
|
from lintunes import smart, tagging, trash
|
|
from lintunes.importers.file_importer import organized_destination, unique_path
|
|
from lintunes.models import Library, Playlist, PlaylistType
|
|
from lintunes.paths import to_relative
|
|
from lintunes.perf import timed
|
|
from lintunes.storage import json_storage
|
|
from lintunes.storage.play_journal import PlayJournal
|
|
from lintunes.undo import Command, UndoStack
|
|
|
|
|
|
SAVE_DEBOUNCE_MS = 3000
|
|
|
|
|
|
class LibraryManager(QObject):
|
|
"""Owns the Library, applies mutations, and persists them (debounced).
|
|
|
|
Dirty state is tracked per area so a play-count bump only rewrites
|
|
library.json and a playlist edit only rewrites that playlist's file.
|
|
|
|
User-driven structural edits (playlist create/delete/rename/move, track
|
|
add/remove/reorder, metadata edits) are recorded on ``undo_stack`` so they
|
|
can be reversed with Ctrl+Z. The internal ``_apply_*``/``_set_*`` helpers do
|
|
the actual mutation (plus dirty-marking and the existing refresh signals)
|
|
and never touch the stack, so undo/redo reuse them without recursing.
|
|
"""
|
|
|
|
playlists_changed = pyqtSignal() # structure: add/remove/rename/move
|
|
playlist_content_changed = pyqtSignal(str) # persistent_id
|
|
track_updated = pyqtSignal(int) # track_id
|
|
track_fields_edited = pyqtSignal(int, list) # track_id, changed field names
|
|
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
|
|
tracks_removed = pyqtSignal(list) # track ids gone from the library
|
|
tracks_restored = pyqtSignal(list) # track ids back after an undo
|
|
music_folder_changed = pyqtSignal(str) # new media folder ('' if none)
|
|
|
|
def __init__(self, library: Library, data_dir: Path, parent=None):
|
|
super().__init__(parent)
|
|
self.library = library
|
|
self.data_dir = Path(data_dir)
|
|
self.undo_stack = UndoStack(parent=self)
|
|
|
|
# Resolved once and cached: organize_root() is consulted on every tag
|
|
# edit, and resolution stats the filesystem.
|
|
self._music_folder: Path | None = None
|
|
self._music_folder_source = "unset"
|
|
self.refresh_music_folder()
|
|
|
|
self._dirty_tracks = False
|
|
self._dirty_metadata = False
|
|
self._dirty_playlists: set[str] = set()
|
|
self._deleted_playlists: set[str] = set()
|
|
|
|
# Play counts live in per-machine journals, not library.json — see
|
|
# storage/play_journal. Folding them in here means the rest of the app
|
|
# keeps reading one plain track.play_count.
|
|
self.play_journal = PlayJournal()
|
|
self.play_journal.load(self.data_dir, self.library)
|
|
self._dirty_journal = False
|
|
|
|
self._save_timer = QTimer(self)
|
|
self._save_timer.setSingleShot(True)
|
|
self._save_timer.setInterval(SAVE_DEBOUNCE_MS)
|
|
self._save_timer.timeout.connect(self.flush)
|
|
|
|
# Smart-playlist recompute is coalesced through a 0 ms timer so a burst
|
|
# of track edits / play bumps triggers a single pass. ``_recomputing``
|
|
# guards against re-entrancy; ``_pending_recompute_fields`` is the union
|
|
# of changed Track attributes (None means "recompute everything").
|
|
self._recomputing = False
|
|
self._pending_recompute_fields: set[str] | None = set()
|
|
self._recompute_timer = QTimer(self)
|
|
self._recompute_timer.setSingleShot(True)
|
|
self._recompute_timer.setInterval(0)
|
|
self._recompute_timer.timeout.connect(self._flush_recompute)
|
|
|
|
# Signatures (mtime, size) of the data files as we last wrote/read them,
|
|
# so the sync watcher can tell our own writes from another machine's.
|
|
self._own_sigs: dict[str, tuple | None] = {}
|
|
self.snapshot_sync_state()
|
|
|
|
self._max_track_id = max(self.library.tracks.keys(), default=0)
|
|
|
|
# ---- id generation ----
|
|
|
|
def new_track_id(self) -> int:
|
|
return self._max_track_id + 1
|
|
|
|
def new_persistent_id(self) -> str:
|
|
while True:
|
|
pid = secrets.token_hex(8).upper()
|
|
if pid not in self.library.playlists:
|
|
return pid
|
|
|
|
# ---- playlist structure ----
|
|
|
|
def create_playlist(self, name: str, parent_pid: str = "") -> Playlist:
|
|
playlist = Playlist(
|
|
name=name,
|
|
persistent_id=self.new_persistent_id(),
|
|
parent_persistent_id=parent_pid,
|
|
playlist_type=PlaylistType.REGULAR,
|
|
)
|
|
self._insert_playlists([playlist])
|
|
self.undo_stack.push(Command(
|
|
"New Playlist",
|
|
undo=lambda: self._remove_playlists([playlist]),
|
|
redo=lambda: self._insert_playlists([playlist])))
|
|
return playlist
|
|
|
|
def create_folder(self, name: str, parent_pid: str = "") -> Playlist:
|
|
folder = Playlist(
|
|
name=name,
|
|
persistent_id=self.new_persistent_id(),
|
|
parent_persistent_id=parent_pid,
|
|
playlist_type=PlaylistType.FOLDER,
|
|
)
|
|
self._insert_playlists([folder])
|
|
self.undo_stack.push(Command(
|
|
"New Folder",
|
|
undo=lambda: self._remove_playlists([folder]),
|
|
redo=lambda: self._insert_playlists([folder])))
|
|
return folder
|
|
|
|
def create_smart_playlist(self, name: str, criteria, parent_pid: str = "") -> Playlist:
|
|
playlist = Playlist(
|
|
name=name,
|
|
persistent_id=self.new_persistent_id(),
|
|
parent_persistent_id=parent_pid,
|
|
playlist_type=PlaylistType.SMART,
|
|
smart_criteria=criteria,
|
|
)
|
|
self._insert_playlists([playlist])
|
|
self.recompute_smart_playlist(playlist.persistent_id, force=True)
|
|
self.undo_stack.push(Command(
|
|
"New Smart Playlist",
|
|
undo=lambda: self._remove_playlists([playlist]),
|
|
redo=lambda: self._insert_playlists([playlist])))
|
|
return playlist
|
|
|
|
def set_smart_criteria(self, pid: str, criteria):
|
|
playlist = self.library.playlists.get(pid)
|
|
if not playlist or not playlist.is_smart:
|
|
return
|
|
old = playlist.smart_criteria
|
|
self._apply_smart_criteria(pid, criteria)
|
|
self.undo_stack.push(Command(
|
|
"Edit Smart Playlist",
|
|
undo=lambda: self._apply_smart_criteria(pid, old),
|
|
redo=lambda: self._apply_smart_criteria(pid, criteria)))
|
|
|
|
def _apply_smart_criteria(self, pid: str, criteria):
|
|
playlist = self.library.playlists.get(pid)
|
|
if not playlist:
|
|
return
|
|
playlist.smart_criteria = criteria
|
|
self._mark_playlist(pid)
|
|
self.recompute_smart_playlist(pid, force=True)
|
|
self.playlists_changed.emit()
|
|
|
|
def rename_playlist(self, pid: str, name: str):
|
|
playlist = self.library.playlists.get(pid)
|
|
if playlist and name and playlist.name != name:
|
|
old = playlist.name
|
|
self._apply_rename(pid, name)
|
|
self.undo_stack.push(Command(
|
|
"Rename Playlist",
|
|
undo=lambda: self._apply_rename(pid, old),
|
|
redo=lambda: self._apply_rename(pid, name)))
|
|
|
|
def delete_playlist(self, pid: str):
|
|
"""Delete a playlist, or a folder and everything inside it."""
|
|
playlist = self.library.playlists.get(pid)
|
|
if not playlist:
|
|
return
|
|
doomed_ids = [pid]
|
|
if playlist.playlist_type == PlaylistType.FOLDER:
|
|
doomed_ids.extend(self._descendant_ids(pid))
|
|
doomed = [self.library.playlists[d] for d in doomed_ids
|
|
if d in self.library.playlists]
|
|
self._remove_playlists(doomed)
|
|
self.undo_stack.push(Command(
|
|
"Delete Playlist",
|
|
undo=lambda: self._insert_playlists(doomed),
|
|
redo=lambda: self._remove_playlists(doomed)))
|
|
|
|
def move_playlist(self, pid: str, new_parent_pid: str):
|
|
playlist = self.library.playlists.get(pid)
|
|
if not playlist or pid == new_parent_pid:
|
|
return
|
|
if new_parent_pid and new_parent_pid in self._descendant_ids(pid):
|
|
return # no cycles
|
|
old_parent = playlist.parent_persistent_id
|
|
if old_parent == new_parent_pid:
|
|
return
|
|
self._apply_reparent(pid, new_parent_pid)
|
|
self.undo_stack.push(Command(
|
|
"Move Playlist",
|
|
undo=lambda: self._apply_reparent(pid, old_parent),
|
|
redo=lambda: self._apply_reparent(pid, new_parent_pid)))
|
|
|
|
def playlists_containing(self, track_id: int) -> list[tuple[str, str]]:
|
|
"""(persistent_id, name) of every REGULAR playlist holding this track,
|
|
sorted by name. Folders/smart/system playlists are excluded — they
|
|
aren't jump-to targets with static membership."""
|
|
out = [(p.persistent_id, p.name)
|
|
for p in self.library.playlists.values()
|
|
if p.playlist_type == PlaylistType.REGULAR
|
|
and track_id in p.track_ids]
|
|
return sorted(out, key=lambda pn: pn[1].casefold())
|
|
|
|
def _descendant_ids(self, pid: str) -> list[str]:
|
|
result = []
|
|
stack = [pid]
|
|
while stack:
|
|
current = stack.pop()
|
|
for p in self.library.playlists.values():
|
|
if p.parent_persistent_id == current:
|
|
result.append(p.persistent_id)
|
|
stack.append(p.persistent_id)
|
|
return result
|
|
|
|
# structure apply helpers (no undo push) --------------------------------
|
|
|
|
def _insert_playlists(self, playlists: list[Playlist]):
|
|
for playlist in playlists:
|
|
self.library.playlists[playlist.persistent_id] = playlist
|
|
self._dirty_playlists.add(playlist.persistent_id)
|
|
self._deleted_playlists.discard(playlist.persistent_id)
|
|
self._dirty_metadata = True
|
|
self._schedule_save()
|
|
self.playlists_changed.emit()
|
|
|
|
def _remove_playlists(self, playlists: list[Playlist]):
|
|
for playlist in playlists:
|
|
pid = playlist.persistent_id
|
|
self.library.playlists.pop(pid, None)
|
|
self._dirty_playlists.discard(pid)
|
|
self._deleted_playlists.add(pid)
|
|
self._dirty_metadata = True
|
|
self._schedule_save()
|
|
self.playlists_changed.emit()
|
|
|
|
def _apply_rename(self, pid: str, name: str):
|
|
playlist = self.library.playlists.get(pid)
|
|
if playlist:
|
|
playlist.name = name
|
|
self._mark_playlist(pid)
|
|
self.playlists_changed.emit()
|
|
|
|
def _apply_reparent(self, pid: str, parent_pid: str):
|
|
playlist = self.library.playlists.get(pid)
|
|
if playlist:
|
|
playlist.parent_persistent_id = parent_pid
|
|
self._mark_playlist(pid)
|
|
self.playlists_changed.emit()
|
|
|
|
# ---- playlist contents ----
|
|
|
|
def add_tracks_to_playlist(self, pid: str, track_ids: list[int],
|
|
position: int | None = None):
|
|
playlist = self.library.playlists.get(pid)
|
|
if not playlist or playlist.playlist_type == PlaylistType.FOLDER \
|
|
or playlist.is_smart:
|
|
return
|
|
track_ids = [tid for tid in track_ids if tid in self.library.tracks]
|
|
if not track_ids:
|
|
return
|
|
before = list(playlist.track_ids)
|
|
after = list(before)
|
|
if position is None or position >= len(after):
|
|
after.extend(track_ids)
|
|
else:
|
|
after[position:position] = track_ids
|
|
self._set_track_ids(pid, after)
|
|
self._push_track_ids("Add to Playlist", pid, before, after)
|
|
|
|
def remove_tracks_from_playlist(self, pid: str, rows: list[int]):
|
|
playlist = self.library.playlists.get(pid)
|
|
if not playlist or playlist.is_smart:
|
|
return
|
|
before = list(playlist.track_ids)
|
|
after = list(before)
|
|
for row in sorted(set(rows), reverse=True):
|
|
if 0 <= row < len(after):
|
|
del after[row]
|
|
self._set_track_ids(pid, after)
|
|
self._push_track_ids("Remove from Playlist", pid, before, after)
|
|
|
|
def move_tracks_in_playlist(self, pid: str, rows: list[int], dest: int):
|
|
"""Move the tracks at the given manual-order rows so the block starts at dest."""
|
|
playlist = self.library.playlists.get(pid)
|
|
if not playlist or playlist.is_smart:
|
|
return
|
|
before = list(playlist.track_ids)
|
|
rows = sorted(set(r for r in rows if 0 <= r < len(before)))
|
|
if not rows:
|
|
return
|
|
moving = [before[r] for r in rows]
|
|
# Destination index counted in the list *after* removal
|
|
dest -= sum(1 for r in rows if r < dest)
|
|
remaining = [tid for i, tid in enumerate(before) if i not in rows]
|
|
dest = max(0, min(dest, len(remaining)))
|
|
after = remaining[:dest] + moving + remaining[dest:]
|
|
self._set_track_ids(pid, after)
|
|
self._push_track_ids("Reorder Playlist", pid, before, after)
|
|
|
|
def _set_track_ids(self, pid: str, ids: list[int], touch: bool = True):
|
|
"""Set a playlist's membership. ``touch=False`` means "this wasn't an
|
|
edit": no timestamp, no dirty mark, just the signal — used by the smart
|
|
recompute, whose result isn't persisted."""
|
|
playlist = self.library.playlists.get(pid)
|
|
if playlist is None:
|
|
return
|
|
playlist.track_ids = list(ids)
|
|
# The single funnel for every content change (add / remove / reorder /
|
|
# undo), and deliberately the *only* place date_modified moves — a
|
|
# column resize must not look like an edit to the merge. Nor may a smart
|
|
# recompute: it fires every time a play count moves, and bumping the
|
|
# timestamp there made "most recently edited" meaningless for smart
|
|
# playlists. See conflict_resolver._playlist_newer.
|
|
if touch:
|
|
playlist.date_modified = _utc_now_iso()
|
|
self._mark_playlist(pid)
|
|
self.playlist_content_changed.emit(pid)
|
|
|
|
def _push_track_ids(self, label, pid, before, after):
|
|
if before != after:
|
|
self.undo_stack.push(Command(
|
|
label,
|
|
undo=lambda: self._set_track_ids(pid, before),
|
|
redo=lambda: self._set_track_ids(pid, after)))
|
|
|
|
# ---- tracks ----
|
|
|
|
def add_track(self, track) -> int:
|
|
if not track.track_id or track.track_id in self.library.tracks:
|
|
track.track_id = self.new_track_id()
|
|
if not track.persistent_id:
|
|
track.persistent_id = secrets.token_hex(8).upper()
|
|
self.library.tracks[track.track_id] = track
|
|
self._max_track_id = max(self._max_track_id, track.track_id)
|
|
self._dirty_tracks = True
|
|
self._dirty_metadata = True
|
|
self._schedule_save()
|
|
self._touch_smart(None)
|
|
return track.track_id
|
|
|
|
def delete_tracks(self, track_ids: list[int],
|
|
delete_files: bool = False) -> tuple[list[int], list[tuple[str, str]]]:
|
|
"""Remove tracks from the library (and every playlist holding them),
|
|
optionally moving their files to the desktop trash.
|
|
|
|
Returns ``(removed_ids, failures)`` where a failure is
|
|
``(track name, error)``. A track whose file can't be trashed is **kept**
|
|
— a library entry pointing at a file still on disk is recoverable, a
|
|
silently-orphaned one is not.
|
|
|
|
Playlist cleanup happens here rather than lazily on display: playlist
|
|
edits address tracks by row index into ``track_ids``, so an id left
|
|
dangling would shift the stored rows out of step with the displayed ones
|
|
and a later removal would hit the wrong track.
|
|
"""
|
|
tracks = [self.library.tracks[tid] for tid in dict.fromkeys(track_ids)
|
|
if tid in self.library.tracks]
|
|
if not tracks:
|
|
return [], []
|
|
|
|
failures: list[tuple[str, str]] = []
|
|
trashed: list[tuple[object, Path]] = []
|
|
if delete_files:
|
|
doomed = []
|
|
for track in tracks:
|
|
try:
|
|
trashed.append((track, self._trash_file(track)))
|
|
doomed.append(track)
|
|
except trash.TrashError as e:
|
|
log.warning("Could not trash %s: %s", track.location, e)
|
|
failures.append((track.name or str(track.location), str(e)))
|
|
self.file_move_failed.emit(track.name or str(track.location), str(e))
|
|
tracks = doomed
|
|
if not tracks:
|
|
return [], failures
|
|
|
|
snapshots = self._playlist_snapshots([t.track_id for t in tracks])
|
|
self._remove_tracks(tracks, snapshots)
|
|
|
|
# The trashed paths live in a mutable cell: a redo re-trashes the files
|
|
# and may land on different collision-suffixed names than the first pass.
|
|
state = {"trashed": trashed}
|
|
|
|
def undo():
|
|
for track, path in state["trashed"]:
|
|
try:
|
|
trash.restore_from_trash(path, Path(track.location))
|
|
except trash.TrashError as e:
|
|
# Trash emptied behind our back. Restore the library entry
|
|
# anyway — a track pointing at a missing file is something
|
|
# the user can see and fix; a silently dropped one isn't.
|
|
log.warning("Could not restore %s: %s", track.location, e)
|
|
self.file_move_failed.emit(track.name or str(path), str(e))
|
|
self._restore_tracks(tracks, snapshots)
|
|
|
|
def redo():
|
|
state["trashed"] = []
|
|
if delete_files:
|
|
for track in tracks:
|
|
try:
|
|
state["trashed"].append((track, self._trash_file(track)))
|
|
except trash.TrashError as e:
|
|
log.warning("Could not re-trash %s: %s", track.location, e)
|
|
self.file_move_failed.emit(
|
|
track.name or str(track.location), str(e))
|
|
self._remove_tracks(tracks, snapshots)
|
|
|
|
self.undo_stack.push(Command(
|
|
"Delete from Library" if delete_files else "Remove from Library",
|
|
undo=undo, redo=redo))
|
|
return [t.track_id for t in tracks], failures
|
|
|
|
def _trash_file(self, track) -> Path:
|
|
"""Send a track's file to the trash and tidy up the folders it emptied."""
|
|
if not track.location:
|
|
raise trash.TrashError("This track has no file on disk.")
|
|
src = Path(track.location)
|
|
dest = trash.send_to_trash(src)
|
|
root = self.organize_root()
|
|
if root is not None and root in src.parents:
|
|
self._prune_empty_dirs(src.parent, root)
|
|
return dest
|
|
|
|
def _playlist_snapshots(self, track_ids: list[int]) -> dict[str, list[int]]:
|
|
"""``{pid: track_ids before removal}`` for each non-smart playlist holding
|
|
any of these tracks — enough for undo to restore original positions."""
|
|
doomed = set(track_ids)
|
|
return {p.persistent_id: list(p.track_ids)
|
|
for p in self.library.playlists.values()
|
|
if not p.is_smart and doomed.intersection(p.track_ids)}
|
|
|
|
def _remove_tracks(self, tracks: list, snapshots: dict[str, list[int]]):
|
|
doomed = {t.track_id for t in tracks}
|
|
for track in tracks:
|
|
self.library.tracks.pop(track.track_id, None)
|
|
self._invalidate_artwork(track)
|
|
for pid in snapshots:
|
|
playlist = self.library.playlists.get(pid)
|
|
if playlist is not None:
|
|
self._set_track_ids(
|
|
pid, [tid for tid in playlist.track_ids if tid not in doomed])
|
|
self._dirty_tracks = True
|
|
self._dirty_metadata = True
|
|
self._schedule_save()
|
|
self._touch_smart(None)
|
|
self.tracks_removed.emit(sorted(doomed))
|
|
|
|
def _restore_tracks(self, tracks: list, snapshots: dict[str, list[int]]):
|
|
for track in tracks:
|
|
self.library.tracks[track.track_id] = track
|
|
self._max_track_id = max(self._max_track_id, track.track_id)
|
|
for pid, ids in snapshots.items():
|
|
if pid in self.library.playlists:
|
|
self._set_track_ids(pid, list(ids))
|
|
self._dirty_tracks = True
|
|
self._dirty_metadata = True
|
|
self._schedule_save()
|
|
self._touch_smart(None)
|
|
self.tracks_restored.emit(sorted(t.track_id for t in tracks))
|
|
self.playlists_changed.emit()
|
|
|
|
@staticmethod
|
|
def _invalidate_artwork(track):
|
|
"""Drop the track's exported art. Imported lazily: mpris pulls in QtDBus,
|
|
which core library code shouldn't need just to delete a song."""
|
|
try:
|
|
from lintunes.mpris import invalidate_artwork
|
|
invalidate_artwork(track)
|
|
except Exception: # no D-Bus bindings here — the cache just ages out
|
|
pass
|
|
|
|
def update_track_fields(self, track_id: int, fields: dict):
|
|
"""In-memory-only field update (no file write, not undoable)."""
|
|
track = self.library.tracks.get(track_id)
|
|
if not track:
|
|
return
|
|
changed_keys = []
|
|
for key, value in fields.items():
|
|
if hasattr(track, key) and getattr(track, key) != value:
|
|
setattr(track, key, value)
|
|
changed_keys.append(key)
|
|
if changed_keys:
|
|
track.date_modified = _utc_now_iso()
|
|
self._dirty_tracks = True
|
|
self._schedule_save()
|
|
self.track_updated.emit(track_id)
|
|
self.track_fields_edited.emit(track_id, changed_keys)
|
|
self._touch_smart(set(changed_keys))
|
|
|
|
def set_track_location(self, track_id: int, location: str):
|
|
"""Repoint a track at a new on-disk path (e.g. user relocated a file
|
|
that was moved/renamed outside lintunes). Not a content edit: no tag
|
|
write, no date_modified bump, not undoable."""
|
|
track = self.library.tracks.get(track_id)
|
|
if not track or track.location == location:
|
|
return
|
|
track.location = location
|
|
self._dirty_tracks = True
|
|
self._schedule_save()
|
|
self.track_updated.emit(track_id)
|
|
|
|
def edit_track_fields(self, track_id: int, fields: dict):
|
|
"""Edit a track's metadata: write the changed tags to the audio file,
|
|
update the in-memory library, and record the change for undo.
|
|
|
|
Reverts text/number fields only — artwork is written separately by the
|
|
Info dialog and is not part of the undo history.
|
|
"""
|
|
track = self.library.tracks.get(track_id)
|
|
if not track:
|
|
return
|
|
changed = {k: v for k, v in fields.items()
|
|
if hasattr(track, k) and getattr(track, k) != v}
|
|
if not changed:
|
|
return
|
|
with timed("edit_track_fields %r", track.name):
|
|
old = {k: getattr(track, k) for k in changed}
|
|
if not self._write_track_tags(track, changed):
|
|
# 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",
|
|
undo=lambda: self._revert_track_fields(track_id, old),
|
|
redo=lambda: self._revert_track_fields(track_id, changed)))
|
|
|
|
def edit_tracks_fields(self, track_ids: list[int], fields: dict):
|
|
"""Apply the same field edits to many tracks at once, writing each
|
|
file's tags, recorded as ONE undoable command.
|
|
|
|
Per track only fields that genuinely differ are written, so a value a
|
|
track already has costs nothing and a track that ends up unchanged is
|
|
skipped entirely (no file write, no undo entry).
|
|
"""
|
|
changes = [] # (track_id, new_fields, old_fields)
|
|
with timed("edit_tracks_fields (%d tracks)", len(track_ids)):
|
|
for track_id in track_ids:
|
|
track = self.library.tracks.get(track_id)
|
|
if not track:
|
|
continue
|
|
changed = {k: v for k, v in fields.items()
|
|
if hasattr(track, k) and getattr(track, k) != v}
|
|
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:
|
|
return
|
|
self.undo_stack.push(Command(
|
|
"Edit Info",
|
|
undo=lambda: [self._revert_track_fields(tid, old)
|
|
for tid, _new, old in changes],
|
|
redo=lambda: [self._revert_track_fields(tid, new)
|
|
for tid, new, _old in changes]))
|
|
|
|
def _write_track_tags(self, track, field_map: dict) -> bool:
|
|
# Library-only fields (rating, size, start/stop times) have no tag
|
|
# representation; don't rewrite the audio file for them.
|
|
writable = {k: v for k, v in field_map.items()
|
|
if k in tagging.EDITABLE_FIELDS}
|
|
if not writable or not track.location:
|
|
return True
|
|
try:
|
|
tagging.write_tags(track.location, writable)
|
|
return True
|
|
except Exception as e:
|
|
log.warning("Could not write tags to %s: %s", track.location, e)
|
|
self.tag_write_failed.emit(track.name or track.location, str(e))
|
|
return False
|
|
|
|
def _apply_track_fields(self, track_id: int, field_map: dict):
|
|
track = self.library.tracks.get(track_id)
|
|
if not track:
|
|
return
|
|
for key, value in field_map.items():
|
|
setattr(track, key, value)
|
|
track.date_modified = _utc_now_iso()
|
|
self._dirty_tracks = True
|
|
self._schedule_save()
|
|
self.track_updated.emit(track_id)
|
|
self.track_fields_edited.emit(track_id, list(field_map.keys()))
|
|
self._touch_smart(set(field_map.keys()))
|
|
|
|
def _revert_track_fields(self, track_id: int, field_map: dict):
|
|
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"})
|
|
|
|
# ---- music folder ----
|
|
|
|
def refresh_music_folder(self):
|
|
"""Re-resolve which media folder applies on this machine."""
|
|
state = music_folder_mod.resolve(self.library, self.data_dir)
|
|
self._music_folder = state.path
|
|
self._music_folder_source = state.source
|
|
|
|
def music_folder_state(self):
|
|
"""Full resolution (path + where it came from), for the GUI."""
|
|
return music_folder_mod.resolve(self.library, self.data_dir)
|
|
|
|
def _should_adopt_music_folder(self, disk) -> bool:
|
|
"""Whether a synced-in library_metadata.json may replace our setting.
|
|
|
|
The dirty flag only protects us until the next flush; after that a
|
|
*stale* metadata file arriving from the other machine would silently
|
|
revert a deliberate change. So compare the semantic stamp instead of
|
|
the file's mtime, which moves for cosmetic reasons (the Round 39
|
|
lesson, applied to metadata).
|
|
"""
|
|
ours = self.library.music_folder_set_at
|
|
if not ours:
|
|
# Legacy library, or never set here — keep the old behavior of
|
|
# trusting disk, so nothing regresses for existing libraries.
|
|
return True
|
|
theirs = disk.music_folder_set_at
|
|
return bool(theirs) and theirs >= ours
|
|
|
|
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.
|
|
|
|
None when no music folder is set *or* the one on file isn't present on
|
|
this machine (an unmounted drive). Callers must handle None rather than
|
|
inventing a path: a relative fallback would resolve against the process
|
|
working directory, which GNOME's dash does not set predictably.
|
|
"""
|
|
if self._music_folder is None:
|
|
return None
|
|
return self._music_folder / "Music"
|
|
|
|
def set_music_folder(self, chosen, *, machine_only: bool = False) -> Path:
|
|
"""Point the library at a media folder. Returns the stored folder.
|
|
|
|
``machine_only`` writes just this machine's override in config.json and
|
|
leaves the synced library metadata untouched — used when the shared
|
|
value is real but simply isn't reachable here, so one machine can never
|
|
clobber another's setting.
|
|
"""
|
|
media = music_folder_mod.normalize_media_folder(chosen)
|
|
if machine_only:
|
|
music_folder_mod.set_machine_override(str(media))
|
|
else:
|
|
# A stale override would otherwise keep shadowing the new shared
|
|
# value on this machine.
|
|
music_folder_mod.set_machine_override(None)
|
|
self.library.music_folder = str(media)
|
|
self.library.music_folder_rel = to_relative(str(media), self.data_dir)
|
|
self.library.music_folder_set_at = _utc_now_iso()
|
|
# Mandatory: without the dirty mark, reload_from_disk's
|
|
# `if not self._dirty_metadata` branch silently reverts this the
|
|
# next time the sync watcher fires. Flushing immediately shrinks
|
|
# that window to nothing — dirty means protected, flushed means
|
|
# disk already agrees with us.
|
|
self.mark_library_settings_dirty()
|
|
self.flush()
|
|
self.refresh_music_folder()
|
|
self.music_folder_changed.emit(str(self._music_folder or ""))
|
|
return media
|
|
|
|
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)."""
|
|
with timed("move file %s", src.name):
|
|
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:
|
|
return
|
|
now = _utc_now_iso()
|
|
track.play_count += 1
|
|
track.play_date_utc = now
|
|
# Deliberately does NOT mark library.json dirty: a play only moves this
|
|
# machine's journal, so finishing a track no longer rewrites 15 MB (and
|
|
# no longer hands Syncthing a conflict once per song).
|
|
self.play_journal.record_play(track_id, now)
|
|
self._dirty_journal = True
|
|
self._schedule_save()
|
|
self.track_updated.emit(track_id)
|
|
self._touch_smart({"play_count", "play_date_utc"})
|
|
|
|
def record_skip(self, track_id: int):
|
|
track = self.library.tracks.get(track_id)
|
|
if not track:
|
|
return
|
|
now = _utc_now_iso()
|
|
track.skip_count += 1
|
|
track.skip_date = now
|
|
self.play_journal.record_skip(track_id, now)
|
|
self._dirty_journal = True
|
|
self._schedule_save()
|
|
self.track_updated.emit(track_id)
|
|
self._touch_smart({"skip_count", "skip_date"})
|
|
|
|
# ---- smart playlist recompute ----
|
|
|
|
def recompute_smart_playlist(self, pid: str, force: bool = False) -> bool:
|
|
"""Recompute a smart playlist's membership from its criteria. Returns
|
|
True if the membership actually changed. A no-op recompute does not mark
|
|
the file dirty or emit (keeps startup recompute from churning every
|
|
smart playlist file, which Syncthing would propagate)."""
|
|
playlist = self.library.playlists.get(pid)
|
|
if not playlist or not playlist.is_smart:
|
|
return False
|
|
criteria = playlist.smart_criteria
|
|
if criteria is None or criteria.unsupported:
|
|
return False # keep the imported snapshot
|
|
if not force and not criteria.live_update:
|
|
return False
|
|
try:
|
|
seed = int(playlist.persistent_id, 16)
|
|
except ValueError:
|
|
seed = 0
|
|
ids = smart.evaluate(criteria, self.library.tracks.values(), seed=seed)
|
|
if ids == playlist.track_ids:
|
|
return False
|
|
# Never undoable. For a derived playlist nothing is written or timestamped
|
|
# either — the membership lives in memory and is rebuilt on the next load.
|
|
self._set_track_ids(pid, ids, touch=not playlist.has_derived_membership)
|
|
return True
|
|
|
|
def recompute_all_smart(self, force: bool = False) -> list[str]:
|
|
"""Recompute every smart playlist (used on library load). Respects each
|
|
playlist's live_update flag unless force=True."""
|
|
changed = []
|
|
self._recomputing = True
|
|
try:
|
|
for pid in list(self.library.playlists):
|
|
if self.recompute_smart_playlist(pid, force=force):
|
|
changed.append(pid)
|
|
finally:
|
|
self._recomputing = False
|
|
return changed
|
|
|
|
def _touch_smart(self, fields: set[str] | None):
|
|
"""Schedule a coalesced recompute after a track change. ``fields`` is the
|
|
set of changed Track attribute names (None = structural/everything)."""
|
|
if self._recomputing:
|
|
return
|
|
if not any(p.is_smart for p in self.library.playlists.values()):
|
|
return
|
|
if fields is None or self._pending_recompute_fields is None:
|
|
self._pending_recompute_fields = None
|
|
else:
|
|
self._pending_recompute_fields |= fields
|
|
self._recompute_timer.start()
|
|
|
|
def _flush_recompute(self):
|
|
fields = self._pending_recompute_fields
|
|
self._pending_recompute_fields = set()
|
|
self._recomputing = True
|
|
try:
|
|
with timed("smart playlist recompute"):
|
|
self._recompute_touched(fields)
|
|
finally:
|
|
self._recomputing = False
|
|
|
|
def _recompute_touched(self, fields: set[str] | None):
|
|
for pid, playlist in list(self.library.playlists.items()):
|
|
if not playlist.is_smart:
|
|
continue
|
|
criteria = playlist.smart_criteria
|
|
if criteria is None or criteria.unsupported or not criteria.live_update:
|
|
continue
|
|
if fields is not None and not self._criteria_touched(criteria, fields):
|
|
continue
|
|
self.recompute_smart_playlist(pid)
|
|
|
|
@staticmethod
|
|
def _criteria_touched(criteria, fields: set[str]) -> bool:
|
|
if criteria.referenced_attrs() & fields:
|
|
return True
|
|
if criteria.limit.enabled:
|
|
attr, _ = smart.SELECTION_SORT.get(criteria.limit.selection, (None, False))
|
|
if attr and attr in fields:
|
|
return True
|
|
return False
|
|
|
|
# ---- settings persistence (no signals; UI-originated) ----
|
|
|
|
def mark_playlist_settings_dirty(self, pid: str):
|
|
self._mark_playlist(pid)
|
|
|
|
def mark_library_settings_dirty(self):
|
|
self._dirty_metadata = True
|
|
self._schedule_save()
|
|
|
|
# ---- saving ----
|
|
|
|
def _mark_playlist(self, pid: str):
|
|
self._dirty_playlists.add(pid)
|
|
self._dirty_metadata = True
|
|
self._schedule_save()
|
|
|
|
def _schedule_save(self):
|
|
self._save_timer.start()
|
|
|
|
def flush(self):
|
|
self._save_timer.stop()
|
|
if self._dirty_journal:
|
|
self.play_journal.save(self.data_dir)
|
|
self._dirty_journal = False
|
|
self._record_sig(self.data_dir / "plays"
|
|
/ f"{self.play_journal.machine}.json")
|
|
if self._dirty_tracks:
|
|
with timed("flush library.json (%d tracks)",
|
|
len(self.library.tracks)):
|
|
json_storage.save_tracks(self.library, self.data_dir,
|
|
self.play_journal)
|
|
self._dirty_tracks = False
|
|
self._record_sig(self.data_dir / "library.json")
|
|
for pid in list(self._dirty_playlists):
|
|
playlist = self.library.playlists.get(pid)
|
|
if playlist:
|
|
with timed("flush playlist %s", pid):
|
|
json_storage.save_playlist(playlist, self.data_dir)
|
|
self._record_sig(self.data_dir / "playlists" / f"{pid}.json")
|
|
self._dirty_playlists.clear()
|
|
for pid in list(self._deleted_playlists):
|
|
json_storage.delete_playlist_file(pid, self.data_dir)
|
|
self._own_sigs.pop(str(self.data_dir / "playlists" / f"{pid}.json"), None)
|
|
self._deleted_playlists.clear()
|
|
if self._dirty_metadata:
|
|
json_storage.save_metadata(self.library, self.data_dir)
|
|
self._dirty_metadata = False
|
|
self._record_sig(self.data_dir / "library_metadata.json")
|
|
|
|
# ---- multi-machine sync (watch for external changes, reconcile) ----
|
|
|
|
def _data_files(self) -> list[Path]:
|
|
files = [self.data_dir / "library.json",
|
|
self.data_dir / "library_metadata.json"]
|
|
playlists_dir = self.data_dir / "playlists"
|
|
if playlists_dir.exists():
|
|
files.extend(sorted(playlists_dir.glob("*.json")))
|
|
plays_dir = self.data_dir / "plays"
|
|
if plays_dir.exists():
|
|
files.extend(sorted(plays_dir.glob("*.json")))
|
|
return files
|
|
|
|
@staticmethod
|
|
def _sig(path: Path):
|
|
try:
|
|
st = path.stat()
|
|
return (st.st_mtime_ns, st.st_size)
|
|
except OSError:
|
|
return None
|
|
|
|
def _record_sig(self, path: Path):
|
|
self._own_sigs[str(path)] = self._sig(path)
|
|
|
|
def snapshot_sync_state(self):
|
|
"""Baseline the data files' signatures to their current on-disk state, so
|
|
only genuinely external (Syncthing) changes look external afterwards."""
|
|
self._own_sigs = {str(p): self._sig(p) for p in self._data_files()}
|
|
|
|
def _has_external_changes(self) -> bool:
|
|
current = {str(p): self._sig(p) for p in self._data_files()}
|
|
if set(current) != set(self._own_sigs):
|
|
return True
|
|
return any(current[k] != self._own_sigs.get(k) for k in current)
|
|
|
|
def check_for_external_changes(self):
|
|
"""Entry point for the sync watcher (debounced). Merges any Syncthing
|
|
conflict files, and if the data changed underneath us (another machine
|
|
synced in), reloads and reconciles it into the running app."""
|
|
from lintunes.storage import conflict_resolver
|
|
summaries = conflict_resolver.resolve_conflicts(self.data_dir)
|
|
if not summaries and not self._has_external_changes():
|
|
return # our own write, or a no-op event
|
|
self.reload_from_disk()
|
|
if summaries:
|
|
self.conflict_resolved.emit(summaries)
|
|
|
|
def reload_from_disk(self):
|
|
"""Re-read the library from disk and reconcile it into memory, keeping
|
|
any local unsaved edits that are newer (play counts take the max, the
|
|
newest metadata edit wins, playlist membership is unioned). Preserves
|
|
object identity so open views stay valid, then refreshes the UI. Never
|
|
touches the player."""
|
|
was_dirty = (self._dirty_tracks or bool(self._dirty_playlists)
|
|
or self._dirty_metadata)
|
|
dirty_pids = set(self._dirty_playlists)
|
|
disk = json_storage.load_library(self.data_dir)
|
|
# Fold the journals onto the disk copy *before* reconciling. The tracks
|
|
# in memory already carry effective counts, so without this the merge
|
|
# would compare effective against base and the max() would win by an
|
|
# amount equal to this machine's journal.
|
|
self.play_journal.load(self.data_dir, disk)
|
|
|
|
for tid, disk_track in disk.tracks.items():
|
|
mem_track = self.library.tracks.get(tid)
|
|
if mem_track is None:
|
|
self.library.tracks[tid] = disk_track
|
|
else:
|
|
_reconcile_track(mem_track, disk_track)
|
|
for tid in list(self.library.tracks):
|
|
if tid not in disk.tracks:
|
|
del self.library.tracks[tid]
|
|
|
|
for pid, disk_pl in disk.playlists.items():
|
|
mem_pl = self.library.playlists.get(pid)
|
|
if mem_pl is None:
|
|
self.library.playlists[pid] = disk_pl
|
|
elif pid in dirty_pids:
|
|
_reconcile_playlist(mem_pl, disk_pl) # keep local edits, union in
|
|
else:
|
|
mem_pl.__dict__.update(disk_pl.__dict__) # adopt disk (identity kept)
|
|
for pid in list(self.library.playlists):
|
|
if pid not in disk.playlists and pid not in dirty_pids:
|
|
self.library.playlists.pop(pid, None)
|
|
|
|
if not self._dirty_metadata:
|
|
if self._should_adopt_music_folder(disk):
|
|
self.library.music_folder = disk.music_folder
|
|
self.library.music_folder_rel = disk.music_folder_rel
|
|
self.library.music_folder_set_at = disk.music_folder_set_at
|
|
self.library.import_date = disk.import_date
|
|
self.library.library_settings = disk.library_settings
|
|
self.refresh_music_folder()
|
|
|
|
self._max_track_id = max(self.library.tracks.keys(), default=0)
|
|
|
|
self._recomputing = True
|
|
try:
|
|
self.recompute_all_smart()
|
|
finally:
|
|
self._recomputing = False
|
|
|
|
# Persist the reconciled result only when we had local edits to fold in
|
|
# (or a recompute diverged); a pure adopt leaves disk untouched.
|
|
if (was_dirty or self._dirty_tracks or self._dirty_playlists
|
|
or self._dirty_metadata):
|
|
self.flush()
|
|
# Re-baseline to whatever is now on disk so our own/adopted state isn't
|
|
# re-flagged as external next tick.
|
|
self.snapshot_sync_state()
|
|
|
|
self.playlists_changed.emit()
|
|
self.library_reloaded.emit()
|
|
|
|
|
|
def _reconcile_track(mem_track, disk_track):
|
|
"""Fold the disk copy of a track into the in-memory one using the same rules
|
|
as the conflict resolver (max play/skip counts, newest date_modified wins),
|
|
mutating mem_track in place so any view holding it stays valid."""
|
|
from lintunes.storage.conflict_resolver import (
|
|
MERGEABLE_FIELDS, _merge_track_fields)
|
|
# A sync typically changes a handful of tracks out of tens of thousands.
|
|
# Comparing the mergeable fields first skips two to_dict() round trips per
|
|
# untouched track, which is most of the cost of a reload.
|
|
if all(getattr(mem_track, f, None) == getattr(disk_track, f, None)
|
|
for f in MERGEABLE_FIELDS):
|
|
return
|
|
merged = mem_track.to_dict()
|
|
_merge_track_fields(merged, disk_track.to_dict()) # disk wins where newer
|
|
for key, value in merged.items():
|
|
setattr(mem_track, key, value)
|
|
|
|
|
|
def _reconcile_playlist(mem_pl, disk_pl):
|
|
"""Reconcile a playlist we were locally editing with the disk copy: our order
|
|
wins (the local edit is the more recent one), and tracks that exist only on
|
|
the other machine are merged back into position rather than appended (a merge
|
|
never drops tracks)."""
|
|
from lintunes.storage.conflict_resolver import merge_track_order
|
|
mem_pl.track_ids = merge_track_order(list(mem_pl.track_ids),
|
|
list(disk_pl.track_ids))
|
|
|
|
|
|
def _utc_now_iso() -> str:
|
|
return datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
|