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:
2026-08-19 17:09:07 -04:00
parent 72994a6a6a
commit e69917a290
14 changed files with 658 additions and 141 deletions
+154 -91
View File
@@ -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)