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>
This commit is contained in:
2026-07-01 16:43:39 -04:00
parent b6d1805962
commit 7e8266c14c
18 changed files with 2856 additions and 30 deletions

View File

@ -114,11 +114,18 @@ class TestItunesImporter:
xml_path = _make_itunes_xml(tracks, playlists)
library, report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
assert set(library.playlists.keys()) == {"FOLDER1", "PL1"}
# Smart playlists are now imported (the junk blobs can't be parsed, so
# the criteria are flagged unsupported and the iTunes snapshot is kept).
assert set(library.playlists.keys()) == {"FOLDER1", "PL1", "SMART1"}
smart = library.playlists["SMART1"]
assert smart.playlist_type.value == "smart"
assert smart.smart_criteria is not None and smart.smart_criteria.unsupported
assert smart.track_ids == [1] # snapshot fallback
# Track 999 doesn't exist — dropped from the playlist
assert library.playlists["PL1"].track_ids == [2]
assert library.playlists["PL1"].parent_persistent_id == "FOLDER1"
assert report.skipped_smart == 1
assert report.smart_count == 1
assert report.smart_unsupported == 1
assert report.skipped_system == 2
assert report.playlist_count == 1
assert report.folder_count == 1