v0.7.0: startup speed — the 21k-track library stops freezing
Three reported symptoms, one shape: every operation was whole-library, whole-file, on the Qt main thread. Measured on the real library (21,482 tracks, 506 playlists, 15 MB library.json). The track table sorted through a QSortFilterProxyModel, which asks data() for a value on every comparison — 580k Python round trips, 8.5 s per table load, paid again on every reload. TrackTableModel now keeps _tracks in canonical (playlist) order plus an _order index list and sorts a key list computed once per track. Nothing used the proxy's filtering. Sorting by # is the identity order, so "source row" still means "playlist position" for drag-reorder. 8.5 s -> 0.13 s; MainWindow() 18.1 s -> 0.79 s. Smart rules compile to closures once per evaluate() instead of being re-dispatched per track — the operator lookup, casefolding the query and parsing the rule's own date constants all leave the 21k-iteration loop (_match_date was re-parsing its own constant 21,482 times per rule). Verified identical membership against the old evaluator on all 20 real smart playlists. 2.54 s -> 0.72 s, and it now runs after the first paint. Also: _reconcile_track skips two to_dict() round trips per unchanged track (MERGEABLE_FIELDS in the resolver is the single source of truth for what a merge touches); no git subprocess on the startup path (Updater.enabled is probed lazily on the background thread, version-button tooltip deferred); and .resolved/ is pruned to the newest 10 snapshots — it had reached 1.7 GB across 73 snapshots inside the Syncthing share. Time to interactive window ~15 s -> 3.2 s. The mid-session freeze when Syncthing delivers a change — a full reload + recompute + table rebuild with the window already on screen, which is what GNOME was offering to force-quit — ~16 s -> 3.8 s. The merge rework itself (playlist ordering, per-machine play journal, quieting the dialog) is queued in TASKS.md as Round 36.
This commit is contained in:
+154
-91
@@ -284,80 +284,14 @@ def _parse_dt(value) -> Optional[datetime]:
|
||||
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
|
||||
_STRING_OPS = {
|
||||
"is": lambda value, query: value == query,
|
||||
"is_not": lambda value, query: value != query,
|
||||
"contains": lambda value, query: query in value,
|
||||
"not_contains": lambda value, query: query not in value,
|
||||
"starts_with": lambda value, query: value.startswith(query),
|
||||
"ends_with": lambda value, query: value.endswith(query),
|
||||
}
|
||||
|
||||
|
||||
def _rule_window_seconds(rule: "SmartRule") -> float:
|
||||
@@ -368,31 +302,159 @@ def _rule_window_seconds(rule: "SmartRule") -> float:
|
||||
return count * _UNIT_SECONDS.get(rule.unit or "days", 86400)
|
||||
|
||||
|
||||
def _match_rule(rule: SmartRule, track, now: datetime) -> bool:
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Rule compilation
|
||||
#
|
||||
# Each rule becomes a closure once per evaluate() instead of being re-dispatched
|
||||
# per track. That hoists everything that depends only on the rule — the operator
|
||||
# lookup, casefolding the query, parsing the rule's own date constants — out of
|
||||
# a loop that runs 21k+ times per smart playlist.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_ALWAYS_FALSE = lambda track: False # noqa: E731
|
||||
_ALWAYS_TRUE = lambda track: True # noqa: E731
|
||||
|
||||
|
||||
def _compile_string(attr, op, value):
|
||||
compare = _STRING_OPS.get(op)
|
||||
if compare is None:
|
||||
return _ALWAYS_FALSE
|
||||
query = ("" if value is None else str(value)).casefold()
|
||||
|
||||
def matches(track):
|
||||
track_val = getattr(track, attr, None)
|
||||
text = ("" if track_val is None else str(track_val)).casefold()
|
||||
return compare(text, query)
|
||||
return matches
|
||||
|
||||
|
||||
def _compile_numeric(attr, op, value, value2):
|
||||
try:
|
||||
query = float(value or 0)
|
||||
except (ValueError, TypeError):
|
||||
return _ALWAYS_FALSE
|
||||
if op == "in_range":
|
||||
try:
|
||||
query2 = float(value2 or 0)
|
||||
except (ValueError, TypeError):
|
||||
return _ALWAYS_FALSE
|
||||
low, high = (query, query2) if query <= query2 else (query2, query)
|
||||
|
||||
def in_range(track):
|
||||
try:
|
||||
return low <= float(getattr(track, attr, None) or 0) <= high
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return in_range
|
||||
|
||||
compare = {
|
||||
"is": lambda t: t == query,
|
||||
"is_not": lambda t: t != query,
|
||||
"greater": lambda t: t > query,
|
||||
"less": lambda t: t < query,
|
||||
}.get(op)
|
||||
if compare is None:
|
||||
return _ALWAYS_FALSE
|
||||
|
||||
def matches(track):
|
||||
try:
|
||||
return compare(float(getattr(track, attr, None) or 0))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return matches
|
||||
|
||||
|
||||
def _compile_date(attr, op, rule, now):
|
||||
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.
|
||||
if op == "in_range":
|
||||
low, high = _parse_dt(rule.value), _parse_dt(rule.value2)
|
||||
if low is None or high is None:
|
||||
return _ALWAYS_FALSE
|
||||
if low > high:
|
||||
low, high = high, low
|
||||
return lambda track: (
|
||||
low <= (_parse_dt(getattr(track, attr, None)) or datetime.min)
|
||||
<= high)
|
||||
query = _parse_dt(rule.value)
|
||||
if query is None:
|
||||
return _ALWAYS_FALSE
|
||||
if op == "after":
|
||||
return lambda track: (
|
||||
_parse_dt(getattr(track, attr, None)) or datetime.min) > query
|
||||
return lambda track: (
|
||||
_parse_dt(getattr(track, attr, None)) or datetime.min) < query
|
||||
|
||||
if op == "not_in_range":
|
||||
low, high = _parse_dt(rule.value), _parse_dt(rule.value2)
|
||||
if low is None or high is None:
|
||||
return _ALWAYS_TRUE
|
||||
if low > high:
|
||||
low, high = high, low
|
||||
|
||||
def not_in_range(track):
|
||||
parsed = _parse_dt(getattr(track, attr, None))
|
||||
return parsed is None or not low <= parsed <= high
|
||||
return not_in_range
|
||||
|
||||
if op in ("in_last", "not_in_last"):
|
||||
window = timedelta(seconds=_rule_window_seconds(rule))
|
||||
if op == "in_last":
|
||||
def in_last(track):
|
||||
parsed = _parse_dt(getattr(track, attr, None))
|
||||
return parsed is not None and (now - parsed) <= window
|
||||
return in_last
|
||||
|
||||
def not_in_last(track):
|
||||
parsed = _parse_dt(getattr(track, attr, None))
|
||||
return parsed is None or (now - parsed) > window
|
||||
return not_in_last
|
||||
|
||||
return _ALWAYS_FALSE
|
||||
|
||||
|
||||
def _compile_rule(rule: SmartRule, now: datetime):
|
||||
meta = FIELD_REGISTRY.get(rule.field)
|
||||
if meta is None:
|
||||
return False
|
||||
track_val = getattr(track, meta.track_attr, None)
|
||||
return _ALWAYS_FALSE
|
||||
attr = meta.track_attr
|
||||
if meta.type is FieldType.STRING:
|
||||
return _match_string(rule.operator, track_val, rule.value)
|
||||
return _compile_string(attr, rule.operator, rule.value)
|
||||
if meta.type in (FieldType.INT, FieldType.DURATION, FieldType.RATING):
|
||||
return _match_numeric(rule.operator, track_val, rule.value, rule.value2)
|
||||
return _compile_numeric(attr, rule.operator, rule.value, rule.value2)
|
||||
if meta.type is FieldType.DATE:
|
||||
return _match_date(rule.operator, track_val, rule, now)
|
||||
return _compile_date(attr, rule.operator, rule, now)
|
||||
if meta.type is FieldType.BOOL:
|
||||
return bool(track_val) == bool(rule.value)
|
||||
return False
|
||||
wanted = bool(rule.value)
|
||||
return lambda track: bool(getattr(track, attr, None)) is wanted
|
||||
return _ALWAYS_FALSE
|
||||
|
||||
|
||||
def _match_group(group: SmartGroup, track, now: datetime) -> bool:
|
||||
def _compile_group(group: SmartGroup, now: datetime):
|
||||
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)
|
||||
return _ALWAYS_TRUE
|
||||
children = [
|
||||
_compile_group(child, now) if isinstance(child, SmartGroup)
|
||||
else _compile_rule(child, now)
|
||||
for child in group.children
|
||||
]
|
||||
if len(children) == 1:
|
||||
return children[0]
|
||||
if group.conjunction == "all":
|
||||
def matches_all(track):
|
||||
for child in children:
|
||||
if not child(track):
|
||||
return False
|
||||
return True
|
||||
return matches_all
|
||||
|
||||
def matches_any(track):
|
||||
for child in children:
|
||||
if child(track):
|
||||
return True
|
||||
return False
|
||||
return matches_any
|
||||
|
||||
|
||||
def _selection_key(selection: str, pid_seed: int):
|
||||
@@ -447,7 +509,8 @@ def evaluate(criteria: SmartCriteria, tracks, now: Optional[datetime] = None,
|
||||
"""
|
||||
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)]
|
||||
predicate = _compile_group(criteria.root, now)
|
||||
matched = [t for t in tracks if predicate(t)]
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user