Files
lintunes/lintunes/models/playlist.py
T
travandClaude Opus 5 8c097faacc v0.14.0: a removal you make sticks
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
2026-08-27 18:31:33 -04:00

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,
)