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>
679 lines
25 KiB
Python
679 lines
25 KiB
Python
"""Smart playlists: criteria model, evaluation, and iTunes import.
|
|
|
|
A smart playlist's membership is *derived* from a `SmartCriteria` (a tree of
|
|
rules) rather than hand-curated. This module owns:
|
|
|
|
* the data model (`SmartRule` / `SmartGroup` / `SmartLimit` / `SmartCriteria`),
|
|
recursive so nested rule groups round-trip through the playlist JSON;
|
|
* `FIELD_REGISTRY`, the single source of truth shared by the evaluator and the
|
|
editor dialog (field -> Track attribute, value type, allowed operators);
|
|
* `evaluate()`, which walks the tree against the library and returns the
|
|
ordered list of matching track ids; and
|
|
* `parse_itunes_smart()`, which converts the iTunes "Smart Info"/"Smart
|
|
Criteria" binary blobs into a `SmartCriteria` (via the vendored parser in
|
|
`lintunes.itunes_smart`).
|
|
|
|
The evaluator reads tracks purely by attribute name (`getattr`), so this module
|
|
imports nothing from `lintunes.models` -- keeping it free of import cycles with
|
|
`playlist.py`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import random as _random
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timedelta, timezone
|
|
from enum import Enum
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Field metadata
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
class FieldType(Enum):
|
|
STRING = "string"
|
|
INT = "int"
|
|
DURATION = "duration" # milliseconds, like INT but rendered as time
|
|
DATE = "date"
|
|
BOOL = "bool"
|
|
RATING = "rating" # stored 0-100, edited as 0-5 stars
|
|
|
|
|
|
# Operator vocabularies per field type.
|
|
STRING_OPS = ("is", "is_not", "contains", "not_contains", "starts_with", "ends_with")
|
|
NUMERIC_OPS = ("is", "is_not", "greater", "less", "in_range")
|
|
DATE_OPS = ("after", "before", "in_range", "not_in_range", "in_last", "not_in_last")
|
|
BOOL_OPS = ("is",)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FieldMeta:
|
|
key: str
|
|
label: str
|
|
track_attr: str
|
|
type: FieldType
|
|
operators: tuple
|
|
|
|
@property
|
|
def ops(self) -> tuple:
|
|
return self.operators
|
|
|
|
|
|
def _ops_for(ftype: FieldType) -> tuple:
|
|
if ftype is FieldType.STRING:
|
|
return STRING_OPS
|
|
if ftype in (FieldType.INT, FieldType.DURATION, FieldType.RATING):
|
|
return NUMERIC_OPS
|
|
if ftype is FieldType.DATE:
|
|
return DATE_OPS
|
|
return BOOL_OPS
|
|
|
|
|
|
def _meta(key, label, attr, ftype) -> FieldMeta:
|
|
return FieldMeta(key, label, attr, ftype, _ops_for(ftype))
|
|
|
|
|
|
# The fields the editor offers and the evaluator understands. Only fields the
|
|
# LinTunes Track actually backs are present (the library is music-only).
|
|
FIELD_REGISTRY: dict[str, FieldMeta] = {m.key: m for m in [
|
|
_meta("name", "Name", "name", FieldType.STRING),
|
|
_meta("artist", "Artist", "artist", FieldType.STRING),
|
|
_meta("album_artist", "Album Artist", "album_artist", FieldType.STRING),
|
|
_meta("album", "Album", "album", FieldType.STRING),
|
|
_meta("genre", "Genre", "genre", FieldType.STRING),
|
|
_meta("composer", "Composer", "composer", FieldType.STRING),
|
|
_meta("grouping", "Grouping", "grouping", FieldType.STRING),
|
|
_meta("comments", "Comments", "comments", FieldType.STRING),
|
|
_meta("kind", "Kind", "kind", FieldType.STRING),
|
|
_meta("sort_name", "Sort Name", "sort_name", FieldType.STRING),
|
|
_meta("sort_album", "Sort Album", "sort_album", FieldType.STRING),
|
|
_meta("year", "Year", "year", FieldType.INT),
|
|
_meta("bpm", "BPM", "bpm", FieldType.INT),
|
|
_meta("bit_rate", "Bit Rate", "bit_rate", FieldType.INT),
|
|
_meta("sample_rate", "Sample Rate", "sample_rate", FieldType.INT),
|
|
_meta("size", "Size", "size", FieldType.INT),
|
|
_meta("disc_number", "Disc Number", "disc_number", FieldType.INT),
|
|
_meta("track_number", "Track Number", "track_number", FieldType.INT),
|
|
_meta("play_count", "Plays", "play_count", FieldType.INT),
|
|
_meta("skip_count", "Skips", "skip_count", FieldType.INT),
|
|
_meta("rating", "Rating", "rating", FieldType.RATING),
|
|
_meta("total_time", "Time", "total_time", FieldType.DURATION),
|
|
_meta("compilation", "Compilation", "compilation", FieldType.BOOL),
|
|
_meta("date_added", "Date Added", "date_added", FieldType.DATE),
|
|
_meta("date_modified", "Date Modified", "date_modified", FieldType.DATE),
|
|
_meta("last_played", "Last Played", "play_date_utc", FieldType.DATE),
|
|
_meta("last_skipped", "Last Skipped", "skip_date", FieldType.DATE),
|
|
]}
|
|
|
|
|
|
# Limit "selected by" methods -> (Track attribute to sort on, reverse?).
|
|
# "random" is special-cased. Used by the evaluator to pick which N items.
|
|
SELECTION_SORT: dict[str, tuple] = {
|
|
"random": (None, False),
|
|
"name": ("sort_name", False),
|
|
"album": ("sort_album", False),
|
|
"artist": ("sort_artist", False),
|
|
"genre": ("genre", False),
|
|
"highest_rating": ("rating", True),
|
|
"lowest_rating": ("rating", False),
|
|
"most_recently_played": ("play_date_utc", True),
|
|
"least_recently_played": ("play_date_utc", False),
|
|
"most_often_played": ("play_count", True),
|
|
"least_often_played": ("play_count", False),
|
|
"most_recently_added": ("date_added", True),
|
|
"least_recently_added": ("date_added", False),
|
|
}
|
|
|
|
_UNIT_SECONDS = {"days": 86400, "weeks": 604800, "months": 2628000}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Schema
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
@dataclass
|
|
class SmartRule:
|
|
field: str = "artist"
|
|
operator: str = "contains"
|
|
value: object = ""
|
|
value2: object = None # upper bound for in_range/not_in_range
|
|
unit: Optional[str] = None # "days"|"weeks"|"months" for in_last/not_in_last
|
|
|
|
def to_dict(self) -> dict:
|
|
d = {"kind": "rule", "field": self.field,
|
|
"operator": self.operator, "value": self.value}
|
|
if self.value2 is not None:
|
|
d["value2"] = self.value2
|
|
if self.unit is not None:
|
|
d["unit"] = self.unit
|
|
return d
|
|
|
|
@classmethod
|
|
def from_dict(cls, d: dict) -> "SmartRule":
|
|
return cls(field=d.get("field", ""), operator=d.get("operator", ""),
|
|
value=d.get("value", ""), value2=d.get("value2"),
|
|
unit=d.get("unit"))
|
|
|
|
|
|
@dataclass
|
|
class SmartGroup:
|
|
conjunction: str = "all" # "all" (AND) | "any" (OR)
|
|
children: list = field(default_factory=list) # SmartRule | SmartGroup
|
|
|
|
def to_dict(self) -> dict:
|
|
return {"kind": "group", "conjunction": self.conjunction,
|
|
"children": [c.to_dict() for c in self.children]}
|
|
|
|
@classmethod
|
|
def from_dict(cls, d: dict) -> "SmartGroup":
|
|
return cls(conjunction=d.get("conjunction", "all"),
|
|
children=[_node_from_dict(c) for c in d.get("children", [])])
|
|
|
|
|
|
def _node_from_dict(d: dict):
|
|
if d.get("kind") == "group" or "children" in d:
|
|
return SmartGroup.from_dict(d)
|
|
return SmartRule.from_dict(d)
|
|
|
|
|
|
@dataclass
|
|
class SmartLimit:
|
|
enabled: bool = False
|
|
count: int = 25
|
|
by: str = "items" # items|minutes|hours|mb|gb
|
|
selection: str = "random" # key into SELECTION_SORT
|
|
|
|
def to_dict(self) -> dict:
|
|
return {"enabled": self.enabled, "count": self.count,
|
|
"by": self.by, "selection": self.selection}
|
|
|
|
@classmethod
|
|
def from_dict(cls, d: dict) -> "SmartLimit":
|
|
return cls(enabled=d.get("enabled", False), count=d.get("count", 25),
|
|
by=d.get("by", "items"), selection=d.get("selection", "random"))
|
|
|
|
|
|
@dataclass
|
|
class SmartCriteria:
|
|
root: SmartGroup = field(default_factory=SmartGroup)
|
|
limit: SmartLimit = field(default_factory=SmartLimit)
|
|
live_update: bool = True
|
|
match_only_checked: bool = False
|
|
# True when the iTunes blob couldn't be fully converted; the manager then
|
|
# keeps the imported track snapshot instead of recomputing membership.
|
|
unsupported: bool = False
|
|
# True when the rule tree contains a nested group; the editor is read-only
|
|
# for these in Phase 1 (nested-group editing UI is Phase 2).
|
|
has_nested: bool = False
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"root": self.root.to_dict(),
|
|
"limit": self.limit.to_dict(),
|
|
"live_update": self.live_update,
|
|
"match_only_checked": self.match_only_checked,
|
|
"unsupported": self.unsupported,
|
|
"has_nested": self.has_nested,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, d: dict) -> "SmartCriteria":
|
|
root_d = d.get("root")
|
|
return cls(
|
|
root=SmartGroup.from_dict(root_d) if root_d else SmartGroup(),
|
|
limit=SmartLimit.from_dict(d.get("limit", {})),
|
|
live_update=d.get("live_update", True),
|
|
match_only_checked=d.get("match_only_checked", False),
|
|
unsupported=d.get("unsupported", False),
|
|
has_nested=d.get("has_nested", False),
|
|
)
|
|
|
|
def references_attr(self, attr: str) -> bool:
|
|
"""True if any rule matches on the given Track attribute."""
|
|
return _group_references(self.root, attr)
|
|
|
|
def referenced_attrs(self) -> set:
|
|
out: set = set()
|
|
_collect_attrs(self.root, out)
|
|
return out
|
|
|
|
|
|
def _group_references(group: SmartGroup, attr: str) -> bool:
|
|
for child in group.children:
|
|
if isinstance(child, SmartGroup):
|
|
if _group_references(child, attr):
|
|
return True
|
|
else:
|
|
meta = FIELD_REGISTRY.get(child.field)
|
|
if meta and meta.track_attr == attr:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _collect_attrs(group: SmartGroup, out: set) -> None:
|
|
for child in group.children:
|
|
if isinstance(child, SmartGroup):
|
|
_collect_attrs(child, out)
|
|
else:
|
|
meta = FIELD_REGISTRY.get(child.field)
|
|
if meta:
|
|
out.add(meta.track_attr)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Evaluation
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def _parse_dt(value) -> Optional[datetime]:
|
|
"""Parse a Track ISO date string into a naive-UTC datetime."""
|
|
if not value:
|
|
return None
|
|
if isinstance(value, datetime):
|
|
dt = value
|
|
else:
|
|
try:
|
|
dt = datetime.fromisoformat(str(value))
|
|
except (ValueError, TypeError):
|
|
return None
|
|
if dt.tzinfo is not None:
|
|
dt = dt.replace(tzinfo=None)
|
|
return dt
|
|
|
|
|
|
def _match_string(op, track_val, qval) -> bool:
|
|
t = ("" if track_val is None else str(track_val)).casefold()
|
|
q = ("" if qval is None else str(qval)).casefold()
|
|
if op == "is":
|
|
return t == q
|
|
if op == "is_not":
|
|
return t != q
|
|
if op == "contains":
|
|
return q in t
|
|
if op == "not_contains":
|
|
return q not in t
|
|
if op == "starts_with":
|
|
return t.startswith(q)
|
|
if op == "ends_with":
|
|
return t.endswith(q)
|
|
return False
|
|
|
|
|
|
def _match_numeric(op, track_val, qval, qval2) -> bool:
|
|
try:
|
|
t = float(track_val or 0)
|
|
q = float(qval or 0)
|
|
except (ValueError, TypeError):
|
|
return False
|
|
if op == "is":
|
|
return t == q
|
|
if op == "is_not":
|
|
return t != q
|
|
if op == "greater":
|
|
return t > q
|
|
if op == "less":
|
|
return t < q
|
|
if op == "in_range":
|
|
try:
|
|
q2 = float(qval2 or 0)
|
|
except (ValueError, TypeError):
|
|
return False
|
|
lo, hi = (q, q2) if q <= q2 else (q2, q)
|
|
return lo <= t <= hi
|
|
return False
|
|
|
|
|
|
def _match_date(op, track_val, rule: "SmartRule", now: datetime) -> bool:
|
|
dt = _parse_dt(track_val)
|
|
if op in ("after", "before", "in_range"):
|
|
# iTunes treats a null date (never played/skipped/etc.) as the distant
|
|
# past: "before X" matches it; "after"/"in range" don't.
|
|
d = dt if dt is not None else datetime.min
|
|
if op == "in_range":
|
|
lo, hi = _parse_dt(rule.value), _parse_dt(rule.value2)
|
|
if lo is None or hi is None:
|
|
return False
|
|
if lo > hi:
|
|
lo, hi = hi, lo
|
|
return lo <= d <= hi
|
|
q = _parse_dt(rule.value)
|
|
if q is None:
|
|
return False
|
|
return d > q if op == "after" else d < q
|
|
if op == "not_in_range":
|
|
if dt is None:
|
|
return True
|
|
lo, hi = _parse_dt(rule.value), _parse_dt(rule.value2)
|
|
if lo is None or hi is None:
|
|
return True
|
|
if lo > hi:
|
|
lo, hi = hi, lo
|
|
return not (lo <= dt <= hi)
|
|
if op in ("in_last", "not_in_last"):
|
|
seconds = _rule_window_seconds(rule)
|
|
if op == "in_last":
|
|
return dt is not None and (now - dt) <= timedelta(seconds=seconds)
|
|
return dt is None or (now - dt) > timedelta(seconds=seconds)
|
|
return False
|
|
|
|
|
|
def _rule_window_seconds(rule: "SmartRule") -> float:
|
|
try:
|
|
count = float(rule.value or 0)
|
|
except (ValueError, TypeError):
|
|
count = 0
|
|
return count * _UNIT_SECONDS.get(rule.unit or "days", 86400)
|
|
|
|
|
|
def _match_rule(rule: SmartRule, track, now: datetime) -> bool:
|
|
meta = FIELD_REGISTRY.get(rule.field)
|
|
if meta is None:
|
|
return False
|
|
track_val = getattr(track, meta.track_attr, None)
|
|
if meta.type is FieldType.STRING:
|
|
return _match_string(rule.operator, track_val, rule.value)
|
|
if meta.type in (FieldType.INT, FieldType.DURATION, FieldType.RATING):
|
|
return _match_numeric(rule.operator, track_val, rule.value, rule.value2)
|
|
if meta.type is FieldType.DATE:
|
|
return _match_date(rule.operator, track_val, rule, now)
|
|
if meta.type is FieldType.BOOL:
|
|
return bool(track_val) == bool(rule.value)
|
|
return False
|
|
|
|
|
|
def _match_group(group: SmartGroup, track, now: datetime) -> bool:
|
|
if not group.children:
|
|
return True
|
|
results = (
|
|
_match_group(c, track, now) if isinstance(c, SmartGroup)
|
|
else _match_rule(c, track, now)
|
|
for c in group.children
|
|
)
|
|
return all(results) if group.conjunction == "all" else any(results)
|
|
|
|
|
|
def _selection_key(selection: str, pid_seed: int):
|
|
attr, reverse = SELECTION_SORT.get(selection, (None, False))
|
|
|
|
if attr is None: # random -> deterministic shuffle (stable between runs)
|
|
rng = _random.Random(pid_seed)
|
|
|
|
def keyfn(track):
|
|
return rng.random()
|
|
return keyfn, False
|
|
|
|
def keyfn(track):
|
|
val = getattr(track, attr, None)
|
|
if attr in ("play_date_utc", "date_added"):
|
|
dt = _parse_dt(val)
|
|
# Tracks never played/added sort oldest.
|
|
return dt or datetime.min
|
|
return val if val is not None else ""
|
|
return keyfn, reverse
|
|
|
|
|
|
def _apply_limit(matches: list, limit: SmartLimit, seed: int) -> list:
|
|
keyfn, reverse = _selection_key(limit.selection, seed)
|
|
ordered = sorted(matches, key=keyfn, reverse=reverse)
|
|
if limit.by == "items":
|
|
return ordered[:max(0, limit.count)]
|
|
# Cumulative budget by time or size.
|
|
if limit.by in ("minutes", "hours"):
|
|
budget = limit.count * (60000 if limit.by == "minutes" else 3600000)
|
|
attr = "total_time"
|
|
else: # mb, gb
|
|
budget = limit.count * (1024 ** 2 if limit.by == "mb" else 1024 ** 3)
|
|
attr = "size"
|
|
out, total = [], 0
|
|
for track in ordered:
|
|
amount = getattr(track, attr, 0) or 0
|
|
if out and total + amount > budget:
|
|
break
|
|
out.append(track)
|
|
total += amount
|
|
return out
|
|
|
|
|
|
def evaluate(criteria: SmartCriteria, tracks, now: Optional[datetime] = None,
|
|
seed: int = 0) -> list:
|
|
"""Return the ordered list of track ids matching `criteria`.
|
|
|
|
`tracks` is any iterable of Track objects. `now` (naive UTC) is injectable
|
|
for deterministic date tests. `seed` makes "limit ... selected by random"
|
|
stable between recomputes (pass the playlist's persistent id hash).
|
|
"""
|
|
if now is None:
|
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
|
matched = [t for t in tracks if _match_group(criteria.root, t, now)]
|
|
if criteria.limit.enabled and criteria.limit.count > 0:
|
|
matched = _apply_limit(matched, criteria.limit, seed)
|
|
return sorted(t.track_id for t in matched)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# iTunes import
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
class _UnsupportedCriteria(Exception):
|
|
"""Raised mid-conversion when a rule can't be represented in our model."""
|
|
|
|
|
|
# cvzi field enum name -> our registry key.
|
|
_ITUNES_FIELD_TO_KEY = {
|
|
"Name": "name", "Artist": "artist", "AlbumArtist": "album_artist",
|
|
"Album": "album", "Genre": "genre", "Composer": "composer",
|
|
"Grouping": "grouping", "Comments": "comments", "Kind": "kind",
|
|
"SortName": "sort_name", "SortAlbum": "sort_album",
|
|
"Year": "year", "BPM": "bpm", "BitRate": "bit_rate",
|
|
"SampleRate": "sample_rate", "Size": "size", "DiskNumber": "disc_number",
|
|
"TrackNumber": "track_number", "Plays": "play_count", "Skips": "skip_count",
|
|
"Rating": "rating", "Duration": "total_time", "Compilation": "compilation",
|
|
"DateAdded": "date_added", "DateModified": "date_modified",
|
|
"LastPlayed": "last_played", "LastSkipped": "last_skipped",
|
|
}
|
|
|
|
_ITUNES_STRING_OP = {
|
|
"like": "contains", "not like": "not_contains", "is": "is",
|
|
"is not": "is_not", "starts with": "starts_with", "ends with": "ends_with",
|
|
}
|
|
_ITUNES_INT_OP = {
|
|
"is": "is", "is not": "is_not", "greater than": "greater",
|
|
"less than": "less", "between": "in_range",
|
|
}
|
|
_ITUNES_DATE_OP = {
|
|
"is after": "after", "is before": "before", "is in the range": "in_range",
|
|
"is not in the range": "not_in_range", "is in the last": "in_last",
|
|
"is not in the last": "not_in_last",
|
|
}
|
|
|
|
|
|
def _unix_to_iso(ts) -> str:
|
|
return datetime.fromtimestamp(int(ts), timezone.utc).replace(tzinfo=None).isoformat()
|
|
|
|
|
|
def _convert_leaf(leaf: dict):
|
|
"""Convert a cvzi fullTree leaf dict into a SmartRule (or None to drop)."""
|
|
fname = leaf.get("field", "")
|
|
ltype = leaf.get("type", "")
|
|
op = leaf.get("operator", "")
|
|
value = leaf.get("value")
|
|
|
|
# Fields with no Track backing.
|
|
if fname == "Love":
|
|
return None # user does not use "loved"; drop the rule entirely
|
|
if fname == "MediaKind":
|
|
if op == "is" and value in ("Music", "Music Video"):
|
|
return None # implicit/no-op filter in a music-only library
|
|
raise _UnsupportedCriteria("MediaKind rule")
|
|
|
|
key = _ITUNES_FIELD_TO_KEY.get(fname)
|
|
if key is None or key not in FIELD_REGISTRY:
|
|
raise _UnsupportedCriteria("unmapped field %r" % fname)
|
|
meta = FIELD_REGISTRY[key]
|
|
|
|
if meta.type is FieldType.STRING:
|
|
our_op = _ITUNES_STRING_OP.get(op)
|
|
if our_op is None:
|
|
raise _UnsupportedCriteria("string op %r" % op)
|
|
return SmartRule(field=key, operator=our_op, value=str(value))
|
|
|
|
if meta.type is FieldType.BOOL: # compilation
|
|
truth = bool(value)
|
|
if op == "is not":
|
|
truth = not truth
|
|
return SmartRule(field=key, operator="is", value=truth)
|
|
|
|
if meta.type in (FieldType.INT, FieldType.DURATION, FieldType.RATING):
|
|
our_op = _ITUNES_INT_OP.get(op)
|
|
if our_op is None:
|
|
raise _UnsupportedCriteria("int op %r" % op)
|
|
scale = 20 if meta.type is FieldType.RATING else 1
|
|
if our_op == "in_range" and isinstance(value, (tuple, list)):
|
|
return SmartRule(field=key, operator="in_range",
|
|
value=int(value[0]) * scale,
|
|
value2=int(value[1]) * scale)
|
|
return SmartRule(field=key, operator=our_op, value=int(value) * scale)
|
|
|
|
if meta.type is FieldType.DATE:
|
|
our_op = _ITUNES_DATE_OP.get(op)
|
|
if our_op is None:
|
|
raise _UnsupportedCriteria("date op %r" % op)
|
|
if our_op in ("in_last", "not_in_last"):
|
|
count, unit = _parse_relative_date(leaf.get("value_date"), value)
|
|
return SmartRule(field=key, operator=our_op, value=count, unit=unit)
|
|
if our_op in ("in_range", "not_in_range") and isinstance(value, (tuple, list)):
|
|
return SmartRule(field=key, operator=our_op,
|
|
value=_unix_to_iso(value[0]),
|
|
value2=_unix_to_iso(value[1]))
|
|
return SmartRule(field=key, operator=our_op, value=_unix_to_iso(value))
|
|
|
|
raise _UnsupportedCriteria("unhandled type %r" % meta.type)
|
|
|
|
|
|
def _parse_relative_date(value_date, raw_seconds):
|
|
"""cvzi gives value_date like '12 months'; fall back to raw seconds."""
|
|
if isinstance(value_date, str):
|
|
parts = value_date.split()
|
|
if len(parts) == 2 and parts[1] in _UNIT_SECONDS:
|
|
try:
|
|
return int(parts[0]), parts[1]
|
|
except ValueError:
|
|
pass
|
|
# Fallback: convert raw seconds to whole days.
|
|
try:
|
|
return max(1, int(int(raw_seconds) // 86400)), "days"
|
|
except (ValueError, TypeError):
|
|
return 1, "days"
|
|
|
|
|
|
def _convert_group(node: dict) -> SmartGroup:
|
|
op = "and" if "and" in node else "or" if "or" in node else None
|
|
if op is None:
|
|
return SmartGroup("all", [])
|
|
children = []
|
|
for child in node[op]:
|
|
if isinstance(child, dict) and ("and" in child or "or" in child):
|
|
children.append(_convert_group(child))
|
|
else:
|
|
rule = _convert_leaf(child)
|
|
if rule is not None:
|
|
children.append(rule)
|
|
return SmartGroup("all" if op == "and" else "any", children)
|
|
|
|
|
|
def _simplify(group: SmartGroup) -> SmartGroup:
|
|
"""Drop empty groups (left behind by dropped MediaKind/Love rules) and
|
|
unwrap a group whose only child is itself a group. iTunes wraps every
|
|
playlist in an implicit "MediaKind is Music" group; stripping that keeps
|
|
ordinary playlists flat (and therefore editable)."""
|
|
new_children = []
|
|
for child in group.children:
|
|
if isinstance(child, SmartGroup):
|
|
child = _simplify(child)
|
|
if not child.children:
|
|
continue
|
|
new_children.append(child)
|
|
group.children = new_children
|
|
if len(group.children) == 1 and isinstance(group.children[0], SmartGroup):
|
|
return group.children[0]
|
|
return group
|
|
|
|
|
|
def _contains_group(group: SmartGroup) -> bool:
|
|
return any(isinstance(c, SmartGroup) for c in group.children)
|
|
|
|
|
|
def parse_itunes_smart(info: Optional[bytes],
|
|
criteria: Optional[bytes]) -> SmartCriteria:
|
|
"""Decode the iTunes "Smart Info"/"Smart Criteria" blobs into a
|
|
SmartCriteria. Never raises: on any problem it returns a criteria flagged
|
|
`unsupported=True`, so the importer falls back to the track snapshot.
|
|
"""
|
|
if not info or not criteria:
|
|
return SmartCriteria(unsupported=True)
|
|
info, criteria = bytes(info), bytes(criteria)
|
|
# Real iTunes criteria blobs always start with the "SLst" magic. Anything
|
|
# else is junk we shouldn't trust (and would otherwise parse to an empty
|
|
# "match everything" rule set), so fall back to the snapshot.
|
|
if not criteria.startswith(b"SLst") or len(info) < 14:
|
|
return SmartCriteria(unsupported=True)
|
|
try:
|
|
from lintunes.itunes_smart.parse import SmartPlaylistParser
|
|
parser = SmartPlaylistParser()
|
|
parser.data(info, criteria)
|
|
parser.parse()
|
|
except Exception as exc: # noqa: BLE001 - import must never abort
|
|
logger.warning("smart criteria parse failed: %r", exc)
|
|
return SmartCriteria(unsupported=True)
|
|
|
|
limit = _read_limit(bytes(info))
|
|
result = SmartCriteria(limit=limit,
|
|
live_update=bool(info[0]),
|
|
match_only_checked=bool(info[12]))
|
|
|
|
if getattr(parser, "ignore", "").strip():
|
|
# The parser hit a field/case it couldn't decode -> incomplete tree.
|
|
result.unsupported = True
|
|
return result
|
|
|
|
try:
|
|
root = _simplify(_convert_group(parser.fullTreeRoot or {}))
|
|
except _UnsupportedCriteria as exc:
|
|
logger.info("smart criteria not fully supported: %s", exc)
|
|
result.unsupported = True
|
|
return result
|
|
result.root = root
|
|
result.has_nested = _contains_group(root)
|
|
return result
|
|
|
|
|
|
_LIMIT_METHOD = {1: "minutes", 2: "mb", 3: "items", 4: "hours", 5: "gb"}
|
|
# (iTunes selection-method byte, "is least" sign) -> our selection key.
|
|
_SELECTION_BASE = {
|
|
0x02: "random", 0x05: "name", 0x06: "album", 0x07: "artist", 0x09: "genre",
|
|
0x1c: "highest_rating", 0x01: "lowest_rating",
|
|
}
|
|
_SELECTION_SIGNED = {
|
|
0x1a: ("most_recently_played", "least_recently_played"),
|
|
0x19: ("most_often_played", "least_often_played"),
|
|
0x15: ("most_recently_added", "least_recently_added"),
|
|
}
|
|
|
|
|
|
def _read_limit(info: bytes) -> SmartLimit:
|
|
if len(info) < 14 or info[2] != 1:
|
|
return SmartLimit(enabled=False)
|
|
count = int.from_bytes(info[8:12], "big")
|
|
by = _LIMIT_METHOD.get(info[3], "items")
|
|
method = info[7]
|
|
if method in _SELECTION_SIGNED:
|
|
# cvzi: sign == 1 (i.e. the sign byte is 0) selects the "least" variant.
|
|
least = (info[13] == 0)
|
|
selection = _SELECTION_SIGNED[method][1 if least else 0]
|
|
else:
|
|
selection = _SELECTION_BASE.get(method, "random")
|
|
return SmartLimit(enabled=True, count=count, by=by, selection=selection)
|