Files
lintunes/lintunes/library_manager.py
T
travandClaude Opus 5 521db2f81d v0.12.0: the merge report stops reporting things you didn't do
"unplayed", "missed and never skipped" and "top 100 in past year" showed up in
the merge window constantly, and nobody had touched their rules. A live smart
playlist's membership is derived, but it was being persisted — and
recompute_smart_playlist rewrites it (and bumped date_modified) every time a
play count moves. Both machines did that against the same file after every
song, so Syncthing conflicted on a list the next load throws away and rebuilds
anyway. Membership now stays in memory: save_playlist writes track_ids: [] for
a live smart playlist, and the recompute passes touch=False so it marks nothing
dirty and moves no timestamp. live_update=False and unsupported criteria are
unchanged — their track_ids are a snapshot, which is real content. Since
_mark_playlist also dirties the metadata, library_metadata.json stops being
rewritten every song too.

The rest was presentation. Every summary read at the same weight, so someone
resizing a column on the other machine popped and raised the same window as a
21-track reconciliation, described as "Library columns/settings taken from the
most recently edited copy." Summaries now carry a level — WARNING for a merge
that couldn't resolve cleanly or discarded a side, CHANGE for real content,
INFO for cosmetic or derived — and the dialog has a Show: selector that filters
to one level and above. It opens at the highest level in the batch, so nothing
routine steals focus and a blank window can't happen; a later warning always
pulls the view back up to it. The wording says what happened instead of how the
merge works: a metadata merge names the keys that actually differed, and
adopting the other machine's music folder grades CHANGE rather than INFO,
because that one is a setting somebody chose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y9ZEFi4qNJ39FMiBtiAxy2
2026-08-25 14:53:37 -04:00

1078 lines
47 KiB
Python

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