Files
lintunes/lintunes/library_manager.py
trav 9575f0400f Live multi-machine sync: watch files, reconcile, alert on conflicts
Both machines now reflect each other's changes within a couple seconds, and
genuine Syncthing conflicts auto-merge with a backup and an alert.

- sync_watcher.py: QFileSystemWatcher (debounced, re-arms after atomic renames)
  emits a single `changed`; the manager decides if it was external.
- library_manager: `reload_from_disk()` re-reads and reconciles disk into memory
  (max play/skip counts, newest edit wins, playlist membership unioned), keeping
  local unsaved edits and object identity so open views stay valid, then refreshes
  the UI without touching the player. `flush()` records each file's (mtime, size)
  so our own writes are never mistaken for an external change. New signals
  library_reloaded / conflict_resolved.
- conflict_resolver: back up BOTH sides into .resolved/<ts>/{original,incoming}/
  before merging, return list[ConflictSummary], add restore_backup(); runs at
  startup and live.
- gui/conflict_dialog.py: modeless summary with Open/Restore backup; main_window
  shows it from a status-bar notice and preserves scroll+selection on reload.

Tests: tests/test_round16.py (9) + full suite green (225).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:55:45 -04:00

698 lines
28 KiB
Python

import secrets
from datetime import datetime, timezone
from pathlib import Path
from PyQt6.QtCore import QObject, QTimer, pyqtSignal
from lintunes import smart, tagging
from lintunes.models import Library, Playlist, PlaylistType
from lintunes.storage import json_storage
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
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)
self._dirty_tracks = False
self._dirty_metadata = False
self._dirty_playlists: set[str] = set()
self._deleted_playlists: set[str] = set()
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()
# ---- id generation ----
def new_track_id(self) -> int:
return max(self.library.tracks.keys(), default=0) + 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]):
playlist = self.library.playlists.get(pid)
if playlist is None:
return
playlist.track_ids = list(ids)
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._dirty_tracks = True
self._dirty_metadata = True
self._schedule_save()
self._touch_smart(None)
return track.track_id
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
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
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)
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}
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:
if not track.location:
return True
try:
tagging.write_tags(track.location, field_map)
return True
except Exception as e:
print(f"Could not write tags to {track.location}: {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
self._apply_track_fields(track_id, field_map)
def record_play(self, track_id: int):
track = self.library.tracks.get(track_id)
if not track:
return
track.play_count += 1
track.play_date_utc = _utc_now_iso()
self._dirty_tracks = 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
track.skip_count += 1
track.skip_date = _utc_now_iso()
self._dirty_tracks = 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
self._set_track_ids(pid, ids) # marks dirty + emits; never undoable
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:
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)
finally:
self._recomputing = False
@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_tracks:
json_storage.save_tracks(self.library, self.data_dir)
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:
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")))
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)
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:
self.library.music_folder = disk.music_folder
self.library.import_date = disk.import_date
self.library.library_settings = disk.library_settings
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 _merge_track_fields
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: keep our
order and append any tracks that exist only on the other machine (a merge
never drops tracks)."""
local = list(mem_pl.track_ids)
local_set = set(local)
mem_pl.track_ids = local + [tid for tid in disk_pl.track_ids
if tid not in local_set]
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).replace(tzinfo=None).isoformat()