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>
192 lines
7.5 KiB
Python
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()
|