Files
lintunes/lintunes/models/playlist.py
trav 7e8266c14c Add smart playlists with full iTunes 12 import (Phase 1)
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>
2026-07-01 16:43:39 -04:00

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