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>
698 lines
28 KiB
Python
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()
|