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

22
tests/smart_blobs.json Normal file
View File

@ -0,0 +1,22 @@
{
"convert these": {
"items": 0,
"info_b64": "AQEAAwAAAAIAAAAZAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"crit_b64": "U0xzdAABAAEAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEEU0xzdAABAAEAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABEAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1FNMc3QAAQABAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJAQAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFABzAG8AdQBuAGQAIABmAGkAbABl"
},
"sun": {
"items": 156,
"info_b64": "AQEAAwAAAAIAAAAZAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"crit_b64": "U0xzdAABAAEAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGAU0xzdAABAAEAAAACAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABEAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8AAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQpTTHN0AAEAAQAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgEAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYAcwB1AG4AAAACAwAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADABzAHUAbgBkAGEAeQ=="
},
"top 300 of the decade!": {
"items": 300,
"info_b64": "AQEBAwAAABkAAAEsAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"crit_b64": "U0xzdAABAAEAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABELa4tri2uLa7/////////iAAAAAAAKBmgLa4tri2uLa4AAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAA="
},
"unrated": {
"items": 4035,
"info_b64": "AQEAAwAAAAIAAAAZAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"crit_b64": "U0xzdAABAAEAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABkAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAkAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAA"
}
}

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

327
tests/test_round14.py Normal file
View File

@ -0,0 +1,327 @@
"""Round 14: smart playlists — criteria model, evaluation, iTunes import,
manager recompute/guards, and conflict resolution.
The iTunes blobs in tests/smart_blobs.json are real "Smart Info"/"Smart
Criteria" data captured from the test library (gitignored), base64-encoded so
the parser is exercised against genuine iTunes 12 output.
"""
import base64
import json
import os
from datetime import datetime
from pathlib import Path
from lintunes.smart import (
SmartCriteria, SmartGroup, SmartRule, SmartLimit, evaluate, parse_itunes_smart,
)
from lintunes.library_manager import LibraryManager
from lintunes.models import Library, Track, Playlist, PlaylistType
_BLOBS = json.loads((Path(__file__).parent / "smart_blobs.json").read_text())
def _blob(name):
rec = _BLOBS[name]
return base64.b64decode(rec["info_b64"]), base64.b64decode(rec["crit_b64"])
# --------------------------------------------------------------------------- #
# iTunes parser / converter (real captured blobs)
# --------------------------------------------------------------------------- #
class TestParseItunes:
def test_sun_string_rules(self):
c = parse_itunes_smart(*_blob("sun"))
assert not c.unsupported and not c.has_nested
assert c.root.conjunction == "all"
rules = c.root.children
assert all(isinstance(r, SmartRule) for r in rules)
assert (rules[0].field, rules[0].operator, rules[0].value) == ("name", "contains", "sun")
assert (rules[1].field, rules[1].operator, rules[1].value) == ("name", "not_contains", "sunday")
def test_unrated_numeric_rules(self):
c = parse_itunes_smart(*_blob("unrated"))
assert not c.unsupported
rating, plays = c.root.children
assert rating.field == "rating" and rating.operator == "in_range"
assert rating.value == 0 and rating.value2 == 0
assert plays.field == "play_count" and plays.operator == "greater" and plays.value == 1
def test_top_relative_date_and_limit(self):
c = parse_itunes_smart(*_blob("top 300 of the decade!"))
assert not c.unsupported
rule = c.root.children[0]
assert rule.field == "date_added" and rule.operator == "in_last"
assert rule.value == 120 and rule.unit == "months"
assert c.limit.enabled and c.limit.count == 300 and c.limit.by == "items"
def test_mediakind_is_unsupported(self):
# "convert these" matches on MediaKind (Podcast), which LinTunes can't
# represent -> falls back to the imported snapshot.
c = parse_itunes_smart(*_blob("convert these"))
assert c.unsupported is True
def test_junk_blob_is_unsupported_no_raise(self):
c = parse_itunes_smart(b"not a real blob", b"x" * 200)
assert c.unsupported is True
def test_missing_blobs_unsupported(self):
assert parse_itunes_smart(None, None).unsupported is True
# --------------------------------------------------------------------------- #
# Schema round-trip
# --------------------------------------------------------------------------- #
def test_criteria_dict_roundtrip_with_nesting():
crit = SmartCriteria(
root=SmartGroup("all", [
SmartRule("genre", "contains", "Rock"),
SmartGroup("any", [
SmartRule("rating", "in_range", 80, 100),
SmartRule("play_count", "greater", 10),
]),
]),
limit=SmartLimit(True, 25, "items", "random"),
live_update=False, has_nested=True)
back = SmartCriteria.from_dict(json.loads(json.dumps(crit.to_dict())))
assert back.root.conjunction == "all"
assert isinstance(back.root.children[1], SmartGroup)
assert back.root.children[1].children[0].value2 == 100
assert back.limit.selection == "random" and back.live_update is False
assert back.has_nested is True
# --------------------------------------------------------------------------- #
# Evaluator
# --------------------------------------------------------------------------- #
def _tracks(*specs):
out = []
for i, kw in enumerate(specs, start=1):
kw.setdefault("track_id", i)
out.append(Track(**kw))
return out
class TestEvaluate:
def test_string_contains_casefold(self):
ts = _tracks({"name": "Sunshine"}, {"name": "Moon"}, {"name": "SUN city"})
c = SmartCriteria(root=SmartGroup("all", [SmartRule("name", "contains", "sun")]))
assert evaluate(c, ts) == [1, 3]
def test_string_is_not(self):
ts = _tracks({"genre": "Rock"}, {"genre": "Jazz"})
c = SmartCriteria(root=SmartGroup("all", [SmartRule("genre", "is_not", "rock")]))
assert evaluate(c, ts) == [2]
def test_numeric_in_range_and_greater(self):
ts = _tracks({"play_count": 1}, {"play_count": 5}, {"play_count": 10})
c = SmartCriteria(root=SmartGroup("all", [SmartRule("play_count", "in_range", 2, 9)]))
assert evaluate(c, ts) == [2]
c2 = SmartCriteria(root=SmartGroup("all", [SmartRule("play_count", "greater", 4)]))
assert evaluate(c2, ts) == [2, 3]
def test_rating_stars(self):
ts = _tracks({"rating": 60}, {"rating": 80}, {"rating": 100})
c = SmartCriteria(root=SmartGroup("all", [SmartRule("rating", "in_range", 80, 100)]))
assert evaluate(c, ts) == [2, 3]
def test_date_in_last(self):
now = datetime(2026, 6, 26)
ts = _tracks(
{"date_added": "2026-06-01T00:00:00"}, # within
{"date_added": "2025-01-01T00:00:00"}, # outside
{"date_added": None}, # never -> excluded
)
c = SmartCriteria(root=SmartGroup("all", [SmartRule("date_added", "in_last", 3, unit="months")]))
assert evaluate(c, ts, now=now) == [1]
def test_date_before_treats_null_as_distant_past(self):
# A never-played track (play_date_utc=None) must match "last played
# before X" — iTunes treats null dates as the distant past.
ts = _tracks(
{"play_date_utc": "2020-01-01T00:00:00"}, # before -> match
{"play_date_utc": "2025-01-01T00:00:00"}, # after -> no
{"play_date_utc": None}, # never played -> match
)
c = SmartCriteria(root=SmartGroup("all", [
SmartRule("last_played", "before", "2023-01-01T00:00:00")]))
assert evaluate(c, ts) == [1, 3]
# "after" must NOT match a null date.
c2 = SmartCriteria(root=SmartGroup("all", [
SmartRule("last_played", "after", "2023-01-01T00:00:00")]))
assert evaluate(c2, ts) == [2]
def test_bool_compilation(self):
ts = _tracks({"compilation": True}, {"compilation": False})
c = SmartCriteria(root=SmartGroup("all", [SmartRule("compilation", "is", True)]))
assert evaluate(c, ts) == [1]
def test_match_any_vs_all(self):
ts = _tracks({"genre": "Rock", "year": 1990}, {"genre": "Jazz", "year": 2020})
rules = [SmartRule("genre", "is", "rock"), SmartRule("year", "greater", 2000)]
assert evaluate(SmartCriteria(root=SmartGroup("any", rules)), ts) == [1, 2]
assert evaluate(SmartCriteria(root=SmartGroup("all", rules)), ts) == []
def test_nested_group(self):
ts = _tracks(
{"genre": "Rock", "rating": 100},
{"genre": "Rock", "rating": 20},
{"genre": "Jazz", "rating": 100},
)
crit = SmartCriteria(root=SmartGroup("all", [
SmartRule("genre", "is", "rock"),
SmartGroup("any", [SmartRule("rating", "greater", 79)]),
]))
assert evaluate(crit, ts) == [1]
def test_empty_rules_matches_all(self):
ts = _tracks({"name": "a"}, {"name": "b"})
assert evaluate(SmartCriteria(root=SmartGroup("all", [])), ts) == [1, 2]
def test_limit_items_by_most_played(self):
ts = _tracks({"play_count": 1}, {"play_count": 9}, {"play_count": 5})
c = SmartCriteria(root=SmartGroup("all", []),
limit=SmartLimit(True, 2, "items", "most_often_played"))
# Highest two are tracks 2 (9) and 3 (5); returned sorted by id.
assert evaluate(c, ts) == [2, 3]
def test_limit_by_minutes_budget(self):
ts = _tracks({"total_time": 120000}, {"total_time": 120000}, {"total_time": 120000})
c = SmartCriteria(root=SmartGroup("all", []),
limit=SmartLimit(True, 3, "minutes", "least_recently_added"))
# Budget 3 minutes = 180000 ms -> only one 2-minute track fits.
assert len(evaluate(c, ts)) == 1
# --------------------------------------------------------------------------- #
# LibraryManager
# --------------------------------------------------------------------------- #
def _manager(tmp_path, tracks=()):
library = Library()
for t in tracks:
library.tracks[t.track_id] = t
return LibraryManager(library, tmp_path)
class TestManagerSmart:
def test_create_computes_membership(self, qapp, tmp_path):
mgr = _manager(tmp_path, _tracks(
{"genre": "Rock"}, {"genre": "Jazz"}, {"genre": "Rock"}))
crit = SmartCriteria(root=SmartGroup("all", [SmartRule("genre", "is", "rock")]))
pl = mgr.create_smart_playlist("rockers", crit)
assert pl.track_ids == [1, 3]
assert pl.is_smart
def test_edit_field_updates_membership_and_emits(self, qapp, tmp_path):
mgr = _manager(tmp_path, _tracks({"genre": "Jazz"}, {"genre": "Rock"}))
crit = SmartCriteria(root=SmartGroup("all", [SmartRule("genre", "is", "rock")]))
pl = mgr.create_smart_playlist("rockers", crit)
assert pl.track_ids == [2]
emitted = []
mgr.playlist_content_changed.connect(emitted.append)
mgr.update_track_fields(1, {"genre": "Rock"}) # track 1 becomes Rock
mgr._flush_recompute()
assert pl.track_ids == [1, 2]
assert pl.persistent_id in emitted
def test_record_play_updates_most_played(self, qapp, tmp_path):
mgr = _manager(tmp_path, _tracks({"play_count": 0}, {"play_count": 0}))
crit = SmartCriteria(root=SmartGroup("all", [SmartRule("play_count", "greater", 0)]))
pl = mgr.create_smart_playlist("played", crit)
assert pl.track_ids == []
mgr.record_play(2)
mgr._flush_recompute()
assert pl.track_ids == [2]
def test_noop_recompute_not_dirty(self, qapp, tmp_path):
mgr = _manager(tmp_path, _tracks({"genre": "Rock"}))
crit = SmartCriteria(root=SmartGroup("all", [SmartRule("genre", "is", "rock")]))
pl = mgr.create_smart_playlist("rockers", crit)
mgr.flush()
assert pl.persistent_id not in mgr._dirty_playlists
assert mgr.recompute_smart_playlist(pl.persistent_id) is False
assert pl.persistent_id not in mgr._dirty_playlists
def test_manual_edits_blocked(self, qapp, tmp_path):
mgr = _manager(tmp_path, _tracks({"genre": "Rock"}, {"genre": "Jazz"}))
crit = SmartCriteria(root=SmartGroup("all", [SmartRule("genre", "is", "rock")]))
pl = mgr.create_smart_playlist("rockers", crit)
assert pl.track_ids == [1]
mgr.add_tracks_to_playlist(pl.persistent_id, [2])
mgr.remove_tracks_from_playlist(pl.persistent_id, [0])
mgr.move_tracks_in_playlist(pl.persistent_id, [0], 0)
assert pl.track_ids == [1] # unchanged
def test_unsupported_keeps_snapshot(self, qapp, tmp_path):
mgr = _manager(tmp_path, _tracks({"genre": "Rock"}, {"genre": "Jazz"}))
pl = Playlist(name="frozen", persistent_id="AB12CD34",
playlist_type=PlaylistType.SMART,
smart_criteria=SmartCriteria(unsupported=True), track_ids=[2])
mgr.library.playlists[pl.persistent_id] = pl
assert mgr.recompute_smart_playlist(pl.persistent_id, force=True) is False
assert pl.track_ids == [2] # snapshot preserved
def test_edit_criteria_undo_redo(self, qapp, tmp_path):
mgr = _manager(tmp_path, _tracks({"genre": "Rock"}, {"genre": "Jazz"}))
crit = SmartCriteria(root=SmartGroup("all", [SmartRule("genre", "is", "rock")]))
pl = mgr.create_smart_playlist("p", crit)
assert pl.track_ids == [1]
new = SmartCriteria(root=SmartGroup("all", [SmartRule("genre", "is", "jazz")]))
mgr.set_smart_criteria(pl.persistent_id, new)
assert pl.track_ids == [2]
mgr.undo_stack.undo()
assert pl.smart_criteria.root.children[0].value == "rock"
assert pl.track_ids == [1]
mgr.undo_stack.redo()
assert pl.track_ids == [2]
def test_recompute_all_respects_live_update(self, qapp, tmp_path):
mgr = _manager(tmp_path, _tracks({"genre": "Rock"}))
live = SmartCriteria(root=SmartGroup("all", [SmartRule("genre", "is", "rock")]),
live_update=True)
frozen = SmartCriteria(root=SmartGroup("all", [SmartRule("genre", "is", "rock")]),
live_update=False)
a = Playlist(name="a", persistent_id="AAAA0001",
playlist_type=PlaylistType.SMART, smart_criteria=live, track_ids=[])
b = Playlist(name="b", persistent_id="BBBB0002",
playlist_type=PlaylistType.SMART, smart_criteria=frozen, track_ids=[])
mgr.library.playlists["AAAA0001"] = a
mgr.library.playlists["BBBB0002"] = b
changed = mgr.recompute_all_smart() # force=False
assert "AAAA0001" in changed and "BBBB0002" not in changed
assert a.track_ids == [1] and b.track_ids == []
# --------------------------------------------------------------------------- #
# Conflict resolution
# --------------------------------------------------------------------------- #
def test_conflict_resolver_smart_newest_wins(tmp_path):
from lintunes.storage.conflict_resolver import resolve_conflicts
pdir = tmp_path / "playlists"
pdir.mkdir()
def crit_dict(genre):
return SmartCriteria(root=SmartGroup("all", [SmartRule("genre", "is", genre)])).to_dict()
original = {"name": "p", "persistent_id": "PID1", "playlist_type": "smart",
"track_ids": [1, 2], "smart_criteria": crit_dict("rock"), "settings": {}}
conflict = {"name": "p", "persistent_id": "PID1", "playlist_type": "smart",
"track_ids": [3, 4], "smart_criteria": crit_dict("jazz"), "settings": {}}
orig_path = pdir / "PID1.json"
conf_path = pdir / "PID1.sync-conflict-20250101-120000-ABCDEFG.json"
orig_path.write_text(json.dumps(original))
conf_path.write_text(json.dumps(conflict))
os.utime(orig_path, (1000, 1000)) # original older
os.utime(conf_path, (2000, 2000)) # conflict newer
resolve_conflicts(tmp_path)
merged = json.loads(orig_path.read_text())
# Newest criteria wins; track_ids are NOT unioned (no [1,2,3,4]).
assert merged["smart_criteria"]["root"]["children"][0]["value"] == "jazz"
assert merged["track_ids"] == [3, 4]