perf(watch-history): ✨ stop the background poll freezing the main thread
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) <noreply@anthropic.com>
This commit is contained in:
parent
e532fe14bc
commit
ee3da4e101
2 changed files with 62 additions and 13 deletions
|
|
@ -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<String>,
|
||||
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<String> {
|
||||
playedPaths(from: readWatchlog())
|
||||
}
|
||||
|
||||
private static func playedPaths(from events: [WatchEvent]) -> Set<String> {
|
||||
var out = Set<String>()
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue