Files
lintunes/tests/test_itunes_importer.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

192 lines
7.5 KiB
Python

import plistlib
import tempfile
from datetime import datetime
from pathlib import Path
import pytest
from lintunes.importers.itunes_importer import (
import_itunes_xml, make_location_remapper,
)
MUSIC_FOLDER = "file:///Volumes/Lucia/iTunes/iTunes%20Media/"
MUSIC_ROOT = Path("/run/media/trav/tummult/music/iTunes Media")
def _make_itunes_xml(tracks: dict, playlists: list) -> Path:
plist_data = {
"Major Version": 1,
"Minor Version": 1,
"Application Version": "12.8.3.1",
"Music Folder": MUSIC_FOLDER,
"Tracks": tracks,
"Playlists": playlists,
}
tmp = tempfile.NamedTemporaryFile(suffix=".xml", delete=False)
plistlib.dump(plist_data, tmp, fmt=plistlib.FMT_XML)
tmp.close()
return Path(tmp.name)
def _track(track_id, name, location_suffix, **extra):
return {
"Track ID": track_id,
"Name": name,
"Persistent ID": f"PID{track_id}",
"Track Type": "File",
"Location": MUSIC_FOLDER + location_suffix,
**extra,
}
class TestLocationRemapper:
def test_remaps_music_folder_prefix(self):
remap = make_location_remapper(MUSIC_FOLDER, MUSIC_ROOT)
assert remap("/Volumes/Lucia/iTunes/iTunes Media/Music/A/B/C.m4a") == \
"/run/media/trav/tummult/music/iTunes Media/Music/A/B/C.m4a"
def test_leaves_outside_paths_alone(self):
remap = make_location_remapper(MUSIC_FOLDER, MUSIC_ROOT)
assert remap("/Volumes/Other/song.mp3") == "/Volumes/Other/song.mp3"
def test_localhost_form(self):
remap = make_location_remapper(
"file://localhost/Users/test/Music/iTunes/iTunes%20Media/",
Path("/home/user/Music"))
assert remap("/Users/test/Music/iTunes/iTunes Media/Music/X/Y/Z.mp3") == \
"/home/user/Music/Music/X/Y/Z.mp3"
class TestItunesImporter:
def test_import_basic_track(self):
tracks = {
"100": _track(
100, "Test Song", "Music/Test%20Artist/Test%20Album/Test%20Song.mp3",
Artist="Test Artist", Album="Test Album", Genre="Rock",
**{"Total Time": 200000,
"Date Added": datetime(2020, 1, 1, 0, 0, 0)},
)
}
playlists = [
{
"Name": "Library",
"Master": True,
"Playlist Persistent ID": "MASTER",
"Playlist Items": [{"Track ID": 100}],
}
]
xml_path = _make_itunes_xml(tracks, playlists)
library, report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
assert len(library.tracks) == 1
track = library.tracks[100]
assert track.name == "Test Song"
assert track.artist == "Test Artist"
assert track.location == str(MUSIC_ROOT / "Music/Test Artist/Test Album/Test Song.mp3")
# Master playlist is a system playlist — not imported
assert len(library.playlists) == 0
assert report.skipped_system == 1
assert report.track_count == 1
xml_path.unlink()
def test_import_playlists_skips_smart_and_system(self):
tracks = {
"1": _track(1, "Song A", "Music/A/A/A.mp3"),
"2": _track(2, "Song B", "Music/B/B/B.mp3"),
}
playlists = [
{"Name": "Library", "Master": True,
"Playlist Persistent ID": "MASTER",
"Playlist Items": [{"Track ID": 1}, {"Track ID": 2}]},
{"Name": "Movies", "Distinguished Kind": 2,
"Playlist Persistent ID": "MOVIES"},
{"Name": "Smarty", "Smart Info": b"x", "Smart Criteria": b"y",
"Playlist Persistent ID": "SMART1",
"Playlist Items": [{"Track ID": 1}]},
{"Name": "My Folder", "Folder": True,
"Playlist Persistent ID": "FOLDER1"},
{"Name": "My Playlist", "Playlist Persistent ID": "PL1",
"Parent Persistent ID": "FOLDER1",
"Playlist Items": [{"Track ID": 2}, {"Track ID": 999}]},
]
xml_path = _make_itunes_xml(tracks, playlists)
library, report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
# 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.smart_count == 1
assert report.smart_unsupported == 1
assert report.skipped_system == 2
assert report.playlist_count == 1
assert report.folder_count == 1
xml_path.unlink()
def test_orphaned_child_detached_when_parent_skipped(self):
playlists = [
{"Name": "Inside Smart Folder", "Playlist Persistent ID": "PL1",
"Parent Persistent ID": "GONE"},
]
xml_path = _make_itunes_xml({}, playlists)
library, _report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
assert library.playlists["PL1"].parent_persistent_id == ""
xml_path.unlink()
def test_skips_stream_tracks(self):
tracks = {
"1": {"Track ID": 1, "Name": "Radio", "Track Type": "URL",
"Persistent ID": "R1"},
"2": _track(2, "Real Song", "Music/A/A/A.mp3"),
}
xml_path = _make_itunes_xml(tracks, [])
library, report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
assert set(library.tracks.keys()) == {2}
assert report.skipped_tracks == 1
xml_path.unlink()
def test_unmapped_location_reported(self):
tracks = {
"1": {"Track ID": 1, "Name": "Elsewhere", "Persistent ID": "E1",
"Track Type": "File",
"Location": "file:///Volumes/Other/song.mp3"},
}
xml_path = _make_itunes_xml(tracks, [])
library, report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
assert report.unmapped_locations == ["/Volumes/Other/song.mp3"]
assert 1 in library.tracks # still imported, just flagged
xml_path.unlink()
def test_computed_ratings_dropped(self):
tracks = {
"1": _track(1, "Guessed", "Music/A/A/A.mp3",
**{"Rating": 100, "Rating Computed": True,
"Album Rating": 80, "Album Rating Computed": True}),
"2": _track(2, "Explicit", "Music/B/B/B.mp3",
**{"Rating": 80, "Album Rating": 60}),
}
xml_path = _make_itunes_xml(tracks, [])
library, _report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
assert library.tracks[1].rating == 0
assert library.tracks[1].album_rating == 0
assert library.tracks[2].rating == 80
assert library.tracks[2].album_rating == 60
xml_path.unlink()
def test_import_empty_library(self):
xml_path = _make_itunes_xml({}, [])
library, report = import_itunes_xml(xml_path, MUSIC_ROOT, check_files=False)
assert len(library.tracks) == 0
assert len(library.playlists) == 0
assert report.track_count == 0
xml_path.unlink()