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