Round 43 made the merge report honest, and in doing so made its one real
limitation impossible to miss: every merge was a union, so a song removed from a
playlist on one machine was handed straight back by the other on the next sync,
and a track deleted from the library came back with it. A deletion you cannot
make stick is not a deletion.
The union was there for a real reason — two copies with no common ancestor
cannot tell "A added this" from "B removed it" — so the missing evidence is
written down instead of inferred. New lintunes/tombstones.py: a playlist keeps
track_events {track id: [when, add|remove]}, the library keeps deleted_tracks
{track id: when} in library_metadata.json, and a merge applies the newest event
per track across both copies. A removal beats a copy that merely still had the
song; a deliberate re-add afterwards beats the removal; a track nobody touched
still merges as a union, which stays the safe behavior where there is no
evidence either way.
Deliberately not "the newer copy wins wholesale": that one-liner silently drops
a song the other machine added while you were removing one, which
test_an_unrelated_addition_is_not_lost pins.
Events are recorded in the funnels that already exist — _set_track_ids diffs
before/after so a reorder records nothing, _remove_tracks stamps the library,
_restore_tracks clears it so Ctrl+Z takes the tombstone back — and pruned after
30 days at the save boundary.
Three edges worth naming:
* Track ids are never reused. The next id came from max(library.tracks), so
deleting the highest-numbered track freed its id, and the next import would be
dropped on sight by the dead id's own tombstone on every machine.
* library_metadata.json is merged before library.json, since it carries the
record the library merge is filtered against and rglob order is not a plan.
* A merge applying a deletion never touches a music file — it drops the library
entry only, and test_a_merge_never_touches_a_music_file fails the run if
send_to_trash is so much as called. Applied removals grade WARNING and name
the song.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y9ZEFi4qNJ39FMiBtiAxy2
1154 lines
51 KiB
Python
1154 lines
51 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, tombstones, 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()
|
|
# Which of those are dirty because their *contents* changed, as opposed
|
|
# to a column drag or a sort click. A sync that arrives while a playlist
|
|
# is merely cosmetically dirty must adopt the disk copy wholesale, not
|
|
# treat this machine as the one holding the edits.
|
|
self._dirty_playlist_content: 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 = self._highest_track_id()
|
|
|
|
# ---- id generation ----
|
|
|
|
def new_track_id(self) -> int:
|
|
return self._max_track_id + 1
|
|
|
|
def _highest_track_id(self) -> int:
|
|
"""The highest id ever used, deletions included. Ids must never be
|
|
reused: a new track handed a deleted one's id would be dropped on sight
|
|
by that id's own tombstone (and on every other machine too)."""
|
|
return max([*self.library.tracks, *self.library.deleted_tracks], default=0)
|
|
|
|
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._dirty_playlist_content.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._dirty_playlist_content.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
|
|
# A rename is an edit like any other: without a stamp it is
|
|
# invisible to every merge, and the other machine's older name wins
|
|
# by default forever.
|
|
playlist.date_modified = _utc_now_iso()
|
|
self._mark_playlist(pid, content=True)
|
|
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
|
|
playlist.date_modified = _utc_now_iso()
|
|
self._mark_playlist(pid, content=True)
|
|
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
|
|
before = list(playlist.track_ids)
|
|
playlist.track_ids = list(ids)
|
|
# The single funnel for every content change (add / remove / reorder /
|
|
# undo). date_modified moves only where content genuinely changed: here,
|
|
# a rename or reparent, and a reconcile whose union is content neither
|
|
# copy had — never for a column resize, which must not look like an edit
|
|
# to the merge. Nor for 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()
|
|
# Record *what* changed, not just that something did. A merge has no
|
|
# common ancestor, so without this a removal is indistinguishable
|
|
# from the other copy's addition and the union hands the song back.
|
|
tombstones.record(playlist.track_events, before, playlist.track_ids)
|
|
self._mark_playlist(pid, content=True)
|
|
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}
|
|
when = _utc_now_iso()
|
|
for track in tracks:
|
|
self.library.tracks.pop(track.track_id, None)
|
|
# Same reason as the playlist events: library.json merges as a
|
|
# union, so a deletion the other copy hasn't heard about comes
|
|
# straight back without a record that it was deliberate.
|
|
self.library.deleted_tracks[track.track_id] = when
|
|
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.library.deleted_tracks.pop(track.track_id, None)
|
|
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, content: bool = False):
|
|
self._dirty_playlists.add(pid)
|
|
if content:
|
|
self._dirty_playlist_content.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()
|
|
self._dirty_playlist_content.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)
|
|
# Only a *content* edit makes this machine's copy the one to reconcile
|
|
# against. A playlist whose local dirt is a column width adopts the disk
|
|
# copy — otherwise a sync that lands in the three seconds after a sort
|
|
# click reverted the other machine's reorder and then flushed the
|
|
# revert back to disk.
|
|
content_pids = set(self._dirty_playlist_content)
|
|
pending_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)
|
|
|
|
# Deletion records are additive on both sides: ours are not on disk yet
|
|
# if we haven't flushed, and theirs are news to us. Union first, then
|
|
# apply, so a track deleted on either machine stays deleted on both.
|
|
self.library.deleted_tracks = tombstones.merge_events(
|
|
self.library.deleted_tracks, disk.deleted_tracks)
|
|
for tid in self.library.deleted_tracks:
|
|
self.library.tracks.pop(tid, None)
|
|
disk.tracks.pop(tid, None)
|
|
|
|
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 content_pids:
|
|
_reconcile_playlist(mem_pl, disk_pl) # merge the two edits
|
|
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 pending_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 = self._highest_track_id()
|
|
|
|
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 copy that just
|
|
synced in. Whichever side recorded the more recent edit sets the order, and
|
|
tracks that exist only on the other side are merged back into position
|
|
rather than appended (a merge never drops tracks).
|
|
|
|
The stamps used to go unread here: the local order simply won, on the
|
|
strength of a docstring asserting it was the newer one. It often wasn't —
|
|
and the result was flushed straight back to disk, so a reorder made on the
|
|
other machine was quietly undone. Same rule as
|
|
conflict_resolver._playlist_newer, one layer up."""
|
|
from lintunes.storage.conflict_resolver import merge_track_order
|
|
mem_pl.track_events = tombstones.merge_events(mem_pl.track_events,
|
|
disk_pl.track_events)
|
|
ours, theirs = mem_pl.date_modified, disk_pl.date_modified
|
|
disk_newer = bool(theirs) and (not ours or theirs > ours)
|
|
mine, other = list(mem_pl.track_ids), list(disk_pl.track_ids)
|
|
merged = (merge_track_order(other, mine) if disk_newer
|
|
else merge_track_order(mine, other))
|
|
if disk_newer:
|
|
# The disk copy is the edited one, so its name/folder/columns are the
|
|
# edited ones too — all four used to be dropped on the floor here.
|
|
mem_pl.name = disk_pl.name
|
|
mem_pl.parent_persistent_id = disk_pl.parent_persistent_id
|
|
mem_pl.settings = disk_pl.settings
|
|
if disk_newer and merged == other and theirs:
|
|
mem_pl.date_modified = theirs
|
|
elif merged != mine:
|
|
# A union is content neither copy had, so it is newer than both; saying
|
|
# so is what lets the other machine adopt it instead of the two trading
|
|
# the same tracks back and forth.
|
|
mem_pl.date_modified = _utc_now_iso()
|
|
# A union would hand back anything the newest event says was removed.
|
|
gone = tombstones.removed_ids(mem_pl.track_events)
|
|
mem_pl.track_ids = [tid for tid in merged if tid not in gone]
|
|
|
|
|
|
def _utc_now_iso() -> str:
|
|
return datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
|