Auto-populating, criteria-driven playlists that import faithfully from iTunes. - lintunes/smart.py: recursive SmartCriteria/SmartGroup/SmartRule/SmartLimit model (JSON round-tripping), a shared FIELD_REGISTRY used by both the evaluator and the editor, and evaluate() (match all/any, nested groups, string/int/duration/rating/date/bool ops, "in the last N", limit by items/time/size). Null play/skip dates are treated as the distant past, matching iTunes. - iTunes import via a vendored MIT-licensed binary parser (lintunes/itunes_smart/, from cvzi/itunes_smartplaylist). Nested groups parse and evaluate; blobs we can't represent (MediaKind/iCloud/etc.) flag unsupported and keep the imported snapshot. "loved" is dropped per user pref. - library_manager: create/set/recompute smart playlists (undoable), field-scoped coalesced live recompute hooked into the edit/play/skip/add funnels, a no-op equality guard to avoid Syncthing churn, and manual-edit guards. main.py recomputes on load; conflict_resolver keeps newest criteria for smart lists. - GUI: ❧ glyph painted in the sidebar branch column, read-only track table for smart playlists, New/Edit Smart Playlist menus, and SmartPlaylistEditorDialog (per-field rule rows, match all/any, limits, live updating). Tests: tests/test_round14.py (real captured blobs in tests/smart_blobs.json). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
133 lines
4.7 KiB
Python
133 lines
4.7 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
|
|
# 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
|
|
|
|
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.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),
|
|
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,
|
|
)
|