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
165 lines
6.5 KiB
Python
165 lines
6.5 KiB
Python
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from typing import Optional
|
|
|
|
|
|
class PlaylistType(Enum):
|
|
REGULAR = "regular"
|
|
FOLDER = "folder"
|
|
SMART = "smart"
|
|
SYSTEM = "system" # Master, Music, etc.
|
|
|
|
|
|
@dataclass
|
|
class PlaylistSettings:
|
|
visible_columns: list[str] = field(default_factory=lambda: [
|
|
"name", "artist", "album", "genre", "total_time", "year",
|
|
"play_count", "rating", "date_added",
|
|
])
|
|
sort_column: str = "#" # "#" = manual play order
|
|
sort_ascending: bool = True
|
|
column_widths: dict[str, int] = field(default_factory=dict)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"visible_columns": self.visible_columns,
|
|
"sort_column": self.sort_column,
|
|
"sort_ascending": self.sort_ascending,
|
|
"column_widths": self.column_widths,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, d: dict) -> "PlaylistSettings":
|
|
return cls(
|
|
visible_columns=d.get("visible_columns", [
|
|
"name", "artist", "album", "genre", "total_time", "year",
|
|
"play_count", "rating", "date_added",
|
|
]),
|
|
sort_column=d.get("sort_column", "#"),
|
|
sort_ascending=d.get("sort_ascending", True),
|
|
column_widths=d.get("column_widths", {}),
|
|
)
|
|
|
|
|
|
# Keys that indicate system/special playlists. Note: "All Items" is present
|
|
# on every playlist in the XML, so it is NOT a system marker.
|
|
SYSTEM_PLAYLIST_FLAGS = {
|
|
"Master", "Distinguished Kind", "Music", "Movies", "TV Shows",
|
|
"Podcasts", "Audiobooks", "Books", "Purchased Music",
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class Playlist:
|
|
name: str = ""
|
|
persistent_id: str = ""
|
|
parent_persistent_id: str = ""
|
|
playlist_type: PlaylistType = PlaylistType.REGULAR
|
|
track_ids: list[int] = field(default_factory=list)
|
|
settings: PlaylistSettings = field(default_factory=PlaylistSettings)
|
|
is_system: bool = False
|
|
# Bumped only when track_ids change (LibraryManager._set_track_ids), never
|
|
# for column widths or sort order — the conflict merge uses it to decide
|
|
# whose order wins, and the file's mtime moves for cosmetic reasons.
|
|
date_modified: Optional[str] = None
|
|
# {track_id: [when, "add"|"remove"]} for membership changes inside the
|
|
# retention window. Without it a merge is a union and a song removed on one
|
|
# machine comes back from the other forever; see lintunes/tombstones.py.
|
|
track_events: dict = field(default_factory=dict)
|
|
# Set only for SMART playlists. Membership (track_ids) is derived from this
|
|
# by the LibraryManager; for imported playlists track_ids also doubles as a
|
|
# snapshot fallback when the criteria couldn't be fully parsed.
|
|
smart_criteria: Optional["SmartCriteria"] = None
|
|
|
|
@property
|
|
def is_smart(self) -> bool:
|
|
return self.playlist_type == PlaylistType.SMART
|
|
|
|
@property
|
|
def has_derived_membership(self) -> bool:
|
|
"""True when track_ids are rebuilt from the criteria on every load, so
|
|
they are in-memory state rather than something to persist or merge.
|
|
|
|
A live smart playlist's membership moves every time a play count does.
|
|
Persisting it meant both machines rewrote the same file continuously and
|
|
Syncthing conflicted on it once a song — for a list the next load throws
|
|
away and recomputes anyway. Non-live and `unsupported` criteria are the
|
|
exception: their track_ids *are* the content (a snapshot), so they keep
|
|
being stored."""
|
|
c = self.smart_criteria
|
|
return bool(self.is_smart and c is not None and not c.unsupported
|
|
and c.live_update)
|
|
|
|
def to_dict(self) -> dict:
|
|
d = {
|
|
"name": self.name,
|
|
"persistent_id": self.persistent_id,
|
|
"playlist_type": self.playlist_type.value,
|
|
"track_ids": self.track_ids,
|
|
"settings": self.settings.to_dict(),
|
|
"is_system": self.is_system,
|
|
}
|
|
if self.parent_persistent_id:
|
|
d["parent_persistent_id"] = self.parent_persistent_id
|
|
if self.date_modified:
|
|
d["date_modified"] = self.date_modified
|
|
if self.track_events:
|
|
# JSON object keys are strings; track ids are ints everywhere else.
|
|
d["track_events"] = {str(tid): event
|
|
for tid, event in self.track_events.items()}
|
|
if self.smart_criteria is not None:
|
|
d["smart_criteria"] = self.smart_criteria.to_dict()
|
|
return d
|
|
|
|
@classmethod
|
|
def from_dict(cls, d: dict) -> "Playlist":
|
|
smart_criteria = None
|
|
if d.get("smart_criteria") is not None:
|
|
from lintunes.smart import SmartCriteria
|
|
smart_criteria = SmartCriteria.from_dict(d["smart_criteria"])
|
|
return cls(
|
|
name=d.get("name", ""),
|
|
persistent_id=d.get("persistent_id", ""),
|
|
parent_persistent_id=d.get("parent_persistent_id", ""),
|
|
playlist_type=PlaylistType(d.get("playlist_type", "regular")),
|
|
track_ids=d.get("track_ids", []),
|
|
settings=PlaylistSettings.from_dict(d.get("settings", {})),
|
|
is_system=d.get("is_system", False),
|
|
date_modified=d.get("date_modified"),
|
|
track_events={int(tid): list(event)
|
|
for tid, event in (d.get("track_events") or {}).items()},
|
|
smart_criteria=smart_criteria,
|
|
)
|
|
|
|
@classmethod
|
|
def from_itunes_dict(cls, itunes_dict: dict) -> "Playlist":
|
|
name = itunes_dict.get("Name", "")
|
|
persistent_id = itunes_dict.get("Playlist Persistent ID", "")
|
|
parent_id = itunes_dict.get("Parent Persistent ID", "")
|
|
|
|
# Determine playlist type
|
|
is_system = any(flag in itunes_dict for flag in SYSTEM_PLAYLIST_FLAGS)
|
|
if itunes_dict.get("Folder", False):
|
|
playlist_type = PlaylistType.FOLDER
|
|
elif "Smart Info" in itunes_dict or "Smart Criteria" in itunes_dict:
|
|
playlist_type = PlaylistType.SMART
|
|
elif is_system:
|
|
playlist_type = PlaylistType.SYSTEM
|
|
else:
|
|
playlist_type = PlaylistType.REGULAR
|
|
|
|
# Extract track IDs
|
|
track_ids = []
|
|
for item in itunes_dict.get("Playlist Items", []):
|
|
if "Track ID" in item:
|
|
track_ids.append(item["Track ID"])
|
|
|
|
return cls(
|
|
name=name,
|
|
persistent_id=persistent_id,
|
|
parent_persistent_id=parent_id,
|
|
playlist_type=playlist_type,
|
|
track_ids=track_ids,
|
|
is_system=is_system,
|
|
)
|