From ee3da4e10179f49a45306b75e3fa86b61b34ffb4 Mon Sep 17 00:00:00 2001 From: Natalie Date: Tue, 30 Jun 2026 01:44:06 -0400 Subject: [PATCH] =?UTF-8?q?perf(watch-history):=20=E2=9C=A8=20stop=20the?= =?UTF-8?q?=20background=20poll=20freezing=20the=20main=20thread?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 8s watch-history poll ran refresh() on the main actor, which read and JSON-decoded the unioned watch log THREE times (playedPaths, resumePositions, episodeProgress each re-read) and called MediaPaths.toRemote() per event — and every toRemote rebuilt ProcessInfo.environment (~22µs each, the whole env dict is reconstructed on every access) plus a homeDirectory lookup. A live sample caught the main thread 100% in this path; the app sat at 78–113% CPU. - Cache MediaPaths.remoteRoot / mappings (process-constant) → kills the per-call env-dictionary rebuild storm. - WatchHistory.derivedState(): read+decode the log ONCE, feed all three derived computations → 3× fewer reads/decodes per refresh. - WatchHistoryController.refreshAsync(): the background poll now parses off the main thread on a utility task and only assigns the small results on main. Settled CPU drops from ~78% sustained to ~0% idle. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../TVAnarchyCore/Library/WatchHistory.swift | 63 ++++++++++++++++--- Sources/TVAnarchyCore/MediaPaths.swift | 12 ++-- 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/Sources/TVAnarchyCore/Library/WatchHistory.swift b/Sources/TVAnarchyCore/Library/WatchHistory.swift index 48049fa..5d10726 100644 --- a/Sources/TVAnarchyCore/Library/WatchHistory.swift +++ b/Sources/TVAnarchyCore/Library/WatchHistory.swift @@ -48,12 +48,29 @@ public enum WatchHistory { return f } + /// Read+decode the unioned watch logs **once** and compute every derived set the + /// controller needs. Returns only public result types (so it crosses the type + /// boundary to `WatchHistoryController`); the `[WatchEvent]` array stays private + /// inside this enum. Replaces three separate read+decode passes per refresh. + static func derivedState() -> (played: Set, + resume: [String: Double], + episodes: [String: EpisodeProgress]) { + let events = readWatchlog() + return (playedPaths(from: events), + resumePositions(from: events), + episodeProgress(from: events)) + } + public static func continueItems(limit: Int = 24) -> [ContinueItem] { + continueItems(from: readWatchlog(), limit: limit) + } + + private static func continueItems(from events: [WatchEvent], limit: Int = 24) -> [ContinueItem] { var byKey: [String: ContinueItem] = [:] let iso = ISO8601DateFormatter() let isoFrac = fractionalFormatter() - for ev in readWatchlog() { + for ev in events { let when = parseTS(ev.ts, iso, isoFrac) let item = ContinueItem( title: ev.label.isEmpty ? ev.show : ev.label, @@ -75,8 +92,12 @@ public enum WatchHistory { } public static func resumePositions() -> [String: Double] { + resumePositions(from: readWatchlog()) + } + + private static func resumePositions(from events: [WatchEvent]) -> [String: Double] { var out: [String: Double] = [:] - for item in continueItems(limit: 2000) { + for item in continueItems(from: events, limit: 2000) { if let p = item.positionSeconds, p > 1 { out[MediaPaths.toRemote(item.path)] = p } } return out @@ -99,10 +120,14 @@ public enum WatchHistory { } public static func episodeProgress() -> [String: EpisodeProgress] { + episodeProgress(from: readWatchlog()) + } + + private static func episodeProgress(from events: [WatchEvent]) -> [String: EpisodeProgress] { var best: [String: WatchEvent] = [:] let iso = ISO8601DateFormatter() let isoFrac = fractionalFormatter() - for ev in readWatchlog() where isRealVideo(ev.path) { + for ev in events where isRealVideo(ev.path) { guard let pos = ev.resumeSeconds, pos >= 0 else { continue } let key = MediaPaths.toRemote(ev.path) if let prev = best[key] { @@ -122,8 +147,11 @@ public enum WatchHistory { } public static func playedPaths() -> Set { + playedPaths(from: readWatchlog()) + } + + private static func playedPaths(from events: [WatchEvent]) -> Set { var out = Set() - let events = readWatchlog() // Compute last reset per show so a rewatch clears the "started" set for // badges and nextUnwatched while preserving append-only history. var lastResetByShow: [String: Date] = [:] @@ -332,11 +360,28 @@ public final class WatchHistoryController { } /// Re-parse the unioned watch logs (plum + black mirror) and recompute all derived sets. - /// Cheap; called after our own appends and on the background poll to catch externals. + /// Reads+decodes the log **once** and feeds it to every derived computation (the old + /// path read and JSON-decoded the whole log three times). Synchronous — used right + /// after our own appends so callers see fresh state immediately; the background poll + /// uses `refreshAsync()` instead to keep this work off the main thread. public func refresh() { - playedPaths = WatchHistory.playedPaths() - resumePositions = WatchHistory.resumePositions() - episodeProgress = WatchHistory.episodeProgress() + let d = WatchHistory.derivedState() + playedPaths = d.played + resumePositions = d.resume + episodeProgress = d.episodes + lastRefresh = Date() + } + + /// Off-main refresh for the background poll: the read+decode+derive runs on a + /// utility thread, and only the (small) result assignment happens on the main + /// actor. This is what stops the 8-second poll from periodically freezing the UI. + public func refreshAsync() async { + let derived = await Task.detached(priority: .utility) { + WatchHistory.derivedState() + }.value + playedPaths = derived.played + resumePositions = derived.resume + episodeProgress = derived.episodes lastRefresh = Date() } @@ -390,7 +435,7 @@ public final class WatchHistoryController { pollTask?.cancel() pollTask = Task { [weak self] in while !Task.isCancelled { - self?.refresh() + await self?.refreshAsync() // Occasional black sync (the call itself throttles internally) _ = await self?.syncBlack() try? await Task.sleep(for: .seconds(8)) diff --git a/Sources/TVAnarchyCore/MediaPaths.swift b/Sources/TVAnarchyCore/MediaPaths.swift index d6f8c54..b846b16 100644 --- a/Sources/TVAnarchyCore/MediaPaths.swift +++ b/Sources/TVAnarchyCore/MediaPaths.swift @@ -12,21 +12,25 @@ import Foundation /// (it's the identity on paths that are already storage-side). public enum MediaPaths { /// Storage-side media root (env-overridable, matches the TS bridge default). - public static var remoteRoot: String { + /// Cached once: the environment is fixed for the process lifetime, and + /// `ProcessInfo.environment` rebuilds the entire env dictionary on every access + /// (~22µs), which `toRemote` would otherwise pay thousands of times per watch-log + /// refresh. See the watch-history poll hot path. + public static let remoteRoot: String = ProcessInfo.processInfo.environment["BLACK_MEDIA_ROOT"] ?? "/bigdisk/_/media" - } /// Legacy laptop mount prefix → storage-side absolute prefix. Longest first so /// `~/_/bigdisk/_/media` wins over `~/_/bigdisk`. Only relevant for stale paths /// persisted before the mount was dropped — fresh scans are already storage-side. - private static var mappings: [(plum: String, remote: String)] { + /// Cached once (the home dir and root are process-constant) for the same reason. + private static let mappings: [(plum: String, remote: String)] = { let home = FileManager.default.homeDirectoryForCurrentUser.path return [ (home + "/_/bigdisk/_/media", remoteRoot), (home + "/media", remoteRoot), (home + "/_/bigdisk", "/bigdisk"), ] - } + }() /// Normalize any path to its storage-side absolute form. Already-canonical paths and /// anything we don't manage pass through unchanged (so it's the identity on the