import Foundation import Observation /// One episode selected for offline caching. public struct OfflineEpisode: Identifiable, Sendable, Equatable { public let show: String public let label: String public let plumPath: String // library path public let remotePath: String // black-side path, for the rsync source public var id: String { plumPath } } public enum OfflineQueueState: String, Codable, Sendable { case pending, downloading, done, failed } /// Result of a storage-budget eviction pass. public struct OfflineCullResult: Sendable, Equatable { public let count: Int public let fileNames: [String] public let freedBytes: Int64 public static let none = OfflineCullResult(count: 0, fileNames: [], freedBytes: 0) } /// One row in the active offline download queue (warmup or on-demand fetch). public struct OfflineQueueItem: Identifiable, Codable, Sendable, Equatable { public let id: String public let show: String public let name: String public var state: OfflineQueueState public var progress: Double? public init(episode: OfflineEpisode, state: OfflineQueueState = .pending, progress: Double? = nil) { id = episode.plumPath show = episode.show name = (episode.plumPath as NSString).lastPathComponent self.state = state self.progress = progress } public init(path: String, show: String, state: OfflineQueueState = .pending, progress: Double? = nil) { id = path self.show = show name = (path as NSString).lastPathComponent self.state = state self.progress = progress } } /// A file present on disk in the local offline cache (under destRoot//...). /// Populated by scanning; sorted newest-first (by mtime) so the list reflects recent fills. public struct OfflineCachedFile: Identifiable, Sendable, Equatable, Hashable { public let localPath: String public let name: String public let showDir: String // parent folder name (sanitized show) under cache root public let size: Int64 public let modifiedAt: Date public var id: String { localPath } } /// Pulls episodes of recent shows to local disk so a laptop can play away from home. /// Planning is pure and unit-tested; fetching rsyncs from black (resumable via /// `--append-verify`). A storage budget culls the oldest downloads outside the /// active per-show window when space is needed. @Observable @MainActor public final class OfflineCacheController { public private(set) var status: String? public private(set) var caching = false public private(set) var lastPlanCount = 0 /// Label for the file currently transferring (nil when idle). public private(set) var downloadingLabel: String? /// 0…1 when rsync reports progress; nil = indeterminate spinner. public private(set) var downloadingProgress: Double? public private(set) var diskBytes: Int64 = 0 public private(set) var diskFileCount: Int = 0 /// Current on-disk offline cache contents (refreshed on disk scans after fill/cull). /// Newest-first by mtime so UI list shows recent additions first; auto-cull removes old. public private(set) var cachedItems: [OfflineCachedFile] = [] /// Active warmup/fetch queue — populated while `caching` or a single-file fetch runs. public private(set) var downloadQueue: [OfflineQueueItem] = [] /// Overall queue progress 0…1 (completed files + partial active file). public private(set) var queueProgress: Double? /// User-visible summary of the most recent cull (nil when none yet). public private(set) var lastCullSummary: String? public private(set) var lastCulledAt: Date? public private(set) var lastCulledFiles: [String] = [] /// Episodes in the current warmup window that are not on disk (updated each reconcile). public private(set) var planMissingCount = 0 /// User-pinned basenames (from policy) absent from disk. Restored with high priority on reconcile when warmup + headroom. public private(set) var pinnedMissingCount = 0 /// On-demand downloads the user prioritized (e.g. picked goon clips). These /// jump ahead of the background warmup plan: drained before each plan item, /// and fetched immediately when the cache is otherwise idle. public private(set) var priorityCount = 0 private var priorityEpisodes: [OfflineEpisode] = [] /// Background reconcile: cull when over budget, refetch missing plan episodes. public static let reconcileInterval: TimeInterval = 600 private let library: LibraryController private let policyActuator: OfflinePolicyActuator private var reconcileTask: Task? private static weak var active: OfflineCacheController? public init(library: LibraryController, policyActuator: OfflinePolicyActuator? = nil) { self.library = library self.policyActuator = policyActuator ?? OfflinePolicyActuator.shared Self.active = self self.policyActuator.syncFromDisk() refreshDiskStats() } public var isDownloading: Bool { downloadingLabel != nil || caching || !downloadQueue.isEmpty } /// Whether the app's shared offline-cache controller is actively downloading. public static var isCacheActive: Bool { active?.isDownloading == true } /// Compact line for the sidebar status strip. public var sidebarLine: String { if caching || !downloadQueue.isEmpty { return downloadSummaryLine } if planMissingCount > 0 { return "\(planMissingCount) missing offline · checking every \(Int(Self.reconcileInterval / 60))m" } if pinnedMissingCount > 0 { return "\(pinnedMissingCount) pinned to restore · checking every \(Int(Self.reconcileInterval / 60))m" } if let lastCullSummary { return lastCullSummary } if diskFileCount > 0 { return "\(diskFileCount) offline · \(Self.formatBytes(diskBytes))" } return status ?? "Offline cache idle" } /// "Downloading 3/15 · 67%" or "Caching 3/15…" when percent unknown. public var downloadSummaryLine: String { guard !downloadQueue.isEmpty else { return status ?? "Downloading…" } let total = downloadQueue.count let done = downloadQueue.filter { $0.state == .done }.count let active = downloadQueue.first { $0.state == .downloading } let pct = queueProgress.map { Int($0 * 100) } let pri = priorityCount > 0 ? " · ⤴︎\(priorityCount) prioritized" : "" if let active { if let pct { return "Downloading \(done + 1)/\(total) · \(pct)% — \(active.name)\(pri)" } return "Downloading \(done + 1)/\(total) — \(active.name)\(pri)" } if done > 0 { return "Downloading \(done)/\(total)…" } return "Queued \(total) episodes…" } /// black SSH endpoints (env-overridable; LAN first, WG overlay fallback when away). private nonisolated static var storageHosts: [String] { DevicesConfig.storageSSHEndpoints() } /// Quick SSH `true` probe against the configured storage endpoints (black). /// Returns true if any responds. Used to gate automatic downloads/culls while /// the depended-on media server is offline; prevents repeated attempts and makes /// culling more restrictive (historical cache is retained until refills possible). nonisolated static func isStorageReachable() -> Bool { let hosts = storageHosts guard !hosts.isEmpty else { return false } for host in hosts { let opts = ["-o", "ConnectTimeout=4", "-o", "BatchMode=yes"] let r = ProcessRunner.run("/usr/bin/ssh", opts + [host, "true"]) if r.ok { return true } } return false } /// Local destination root for cached episodes. public nonisolated static var destRoot: URL { destRoot(for: DevicesConfig.localOfflinePolicy()) } public nonisolated static func destRoot(for policy: OfflineCachePolicy) -> URL { if let p = policy.cacheDir, !p.isEmpty { return URL(fileURLWithPath: (p as NSString).expandingTildeInPath, isDirectory: true) } if let p = ProcessInfo.processInfo.environment["TV_ANARCHY_OFFLINE_DIR"], !p.isEmpty { return URL(fileURLWithPath: p, isDirectory: true) } return FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Movies/tv-anarchy-offline") } // MARK: pure planning (unit-tested) /// Episodes to cache per show: up to `episodesBehind` before the resume point, /// then `episodesAhead` from the resume point forward (inclusive). public static func plan(shows: [CachedShow], continueWatching: [ContinueItem], recent: [CachedShow], fromContinueWatching: Bool, showCount: Int, episodesAhead: Int, episodesBehind: Int = 0, includeAdult: Bool) -> [OfflineEpisode] { let byName = Dictionary(shows.map { ($0.name, $0) }, uniquingKeysWith: { a, _ in a }) var sources: [(show: CachedShow, anchor: String?)] = [] if fromContinueWatching { var seen = Set() for it in continueWatching where includeAdult || !it.isAdult { guard let name = it.show, let show = byName[name], !seen.contains(show.rootDir) else { continue } seen.insert(show.rootDir) sources.append((show, it.path)) if sources.count >= showCount { break } } } else { sources = recent.filter { includeAdult || !LibraryConfig.isAdult(category: $0.category) } .prefix(showCount).map { ($0, nil) } } let ahead = max(1, episodesAhead) let behind = max(0, episodesBehind) var picks: [OfflineEpisode] = [] for (show, anchor) in sources { let eps = show.orderedEpisodes guard !eps.isEmpty else { continue } let anchorIdx = anchor.flatMap { a in let r = MediaPaths.toRemote(a) return eps.firstIndex { MediaPaths.toRemote($0.path) == r } } ?? 0 let start = max(0, anchorIdx - behind) let end = min(eps.count, anchorIdx + ahead) for ep in eps[start.. (Int, Int64) { let (c, b, _) = scanDiskDetailed(at: root) return (c, b) } /// Full scan returning rich list + aggregates. Files sorted newest mtime first. /// Used for both stats and the playable offline list UI. nonisolated static func scanDiskDetailed(at root: String? = nil) -> (count: Int, bytes: Int64, items: [OfflineCachedFile]) { let fm = FileManager.default let rootPath = root ?? destRoot.path var items: [OfflineCachedFile] = [] guard let entries = try? fm.contentsOfDirectory(atPath: rootPath) else { return (0, 0, []) } var stack = entries.map { rootPath + "/" + $0 } while let dir = stack.popLast() { guard let kids = try? fm.contentsOfDirectory(atPath: dir) else { continue } let showDir = (dir as NSString).lastPathComponent for name in kids { let p = dir + "/" + name var isDir: ObjCBool = false guard fm.fileExists(atPath: p, isDirectory: &isDir) else { continue } if isDir.boolValue { stack.append(p); continue } guard DownloadsIndex.isVideoFilename(name) else { continue } let attrs = (try? fm.attributesOfItem(atPath: p)) ?? [:] let size = (attrs[.size] as? Int64) ?? 0 let mtime = (attrs[.modificationDate] as? Date) ?? .distantPast items.append(OfflineCachedFile(localPath: p, name: name, showDir: showDir, size: size, modifiedAt: mtime)) } } let count = items.count let bytes = items.reduce(Int64(0)) { $0 + $1.size } let sorted = items.sorted { $0.modifiedAt > $1.modifiedAt } return (count, bytes, sorted) } /// Delete oldest cached files until usage is at or below `budgetBytes`. Files /// whose library-path basename is in `protectedNames` are never removed. @discardableResult nonisolated static func evictOldest(protectedNames: Set, budgetBytes: Int64, cacheRoot: String? = nil) -> OfflineCullResult { let fm = FileManager.default let root = cacheRoot ?? destRoot.path var files: [(path: String, name: String, displayName: String, mtime: Date, bytes: Int64)] = [] var stack = (try? fm.contentsOfDirectory(atPath: root))?.map { root + "/" + $0 } ?? [] while let dir = stack.popLast() { guard let kids = try? fm.contentsOfDirectory(atPath: dir) else { continue } for name in kids { let p = dir + "/" + name var isDir: ObjCBool = false guard fm.fileExists(atPath: p, isDirectory: &isDir) else { continue } if isDir.boolValue { stack.append(p); continue } guard DownloadsIndex.isVideoFilename(name) else { continue } let mtime = (try? fm.attributesOfItem(atPath: p)[.modificationDate] as? Date) ?? .distantPast let bytes = (try? fm.attributesOfItem(atPath: p)[.size] as? Int64) ?? 0 files.append((p, name.lowercased(), name, mtime, bytes)) } } var total = files.reduce(Int64(0)) { $0 + $1.bytes } guard total > budgetBytes else { return .none } var names: [String] = [] var freed: Int64 = 0 for f in files.sorted(by: { $0.mtime < $1.mtime }) { guard total > budgetBytes else { break } guard !protectedNames.contains(f.name) else { continue } try? fm.removeItem(atPath: f.path) total -= f.bytes names.append(f.displayName) freed += f.bytes } return OfflineCullResult(count: names.count, fileNames: names, freedBytes: freed) } /// 1-click nuclear destroy for freeing space: permanently deletes the entire offline /// cache directory (all media files under destRoot for the policy) and all contents. /// Updates live state, clears activity, records as lastCull for UI visibility, notifies, /// and refreshes DownloadsIndex. Does not toggle the offlineCache service flag or alter /// the persisted policy/pins (user may disable service separately to prevent refills). /// Returns the pre-destroy byte count (space that will be / was freed). Safe if absent. @discardableResult public func destroyAllOfflineMedia(policy: OfflineCachePolicy? = nil) async -> Int64 { let p = policy ?? policyActuator.policyForActuation() let rootURL = Self.destRoot(for: p) let rootPath = rootURL.path let (preCount, preBytes, _) = Self.scanDiskDetailed(at: rootPath) if preBytes == 0 && !FileManager.default.fileExists(atPath: rootPath) { await MainActor.run { self.refreshDiskStats() self.status = "No offline media present" } return 0 } await MainActor.run { if self.caching { self.caching = false } self.downloadingLabel = nil self.downloadingProgress = nil self.downloadQueue = [] self.queueProgress = nil self.status = "Destroying all offline media…" self.lastPlanCount = 0 self.planMissingCount = 0 self.pinnedMissingCount = 0 self.lastCullSummary = "Destroyed \(preCount) files (\(Self.formatBytes(preBytes)))" self.lastCulledAt = Date() self.lastCulledFiles = [] } let fm = FileManager.default if fm.fileExists(atPath: rootPath) { do { try fm.removeItem(at: rootURL) Log.info("offline cache: destroyed \(rootPath)") } catch { Log.error("offline destroy failed for \(rootPath): \(error)") // Partial removal may still have freed most; proceed to rescan. } } await MainActor.run { self.refreshDiskStats() let postBytes = Self.scanDisk(at: rootPath).1 let freed = preBytes - postBytes self.status = "All offline media destroyed (\(Self.formatBytes(freed)) freed)" Log.info("offline destroy complete: freed \(Self.formatBytes(freed))") if SettingsStore.load().notifyDownloads { NotificationsService.shared.post( title: "Offline media destroyed", body: "\(Self.formatBytes(freed)) freed — source files remain on storage" ) } else { NotificationsService.shared.showBanner("Offline media destroyed — \(Self.formatBytes(freed)) freed") } Task.detached(priority: .utility) { _ = DownloadsIndex.shared.refresh() } } return preBytes } /// Plan episodes absent from the local offline cache. public nonisolated static func missingEpisodes(in plan: [OfflineEpisode]) -> [OfflineEpisode] { plan.filter { MediaPaths.localCopy(of: $0.plumPath) == nil } } nonisolated static func protectedNames(for plan: [OfflineEpisode], additionalPinned: [String] = []) -> Set { var names = Set(plan.map { ($0.plumPath as NSString).lastPathComponent.lowercased() }) for p in additionalPinned { names.insert(p.lowercased()) } return names } /// Merge pinned from the passed policy with the on-disk persisted one (pins take effect immediately on save). nonisolated static func effectivePinned(from policy: OfflineCachePolicy) -> [String] { let saved = DevicesConfig.localOfflinePolicy() var uniq: [String] = [] var seen = Set() for p in policy.pinned where !seen.contains(p.lowercased()) { seen.insert(p.lowercased()); uniq.append(p) } for p in saved.pinned where !seen.contains(p.lowercased()) { seen.insert(p.lowercased()); uniq.append(p) } return uniq } /// Pinned basenames that have no local copy yet. Uses library to resolve original path for rsync. nonisolated static func missingPinned(pinned: [String], shows: [CachedShow]) -> [OfflineEpisode] { guard !pinned.isEmpty else { return [] } let need = Set(pinned.map { $0.lowercased() }) var found: [OfflineEpisode] = [] var seen = Set() for show in shows { for ep in show.orderedEpisodes { let bn = (ep.path as NSString).lastPathComponent.lowercased() if need.contains(bn), !seen.contains(bn) { if MediaPaths.localCopy(of: ep.path) == nil { found.append(OfflineEpisode(show: show.name, label: ep.label, plumPath: ep.path, remotePath: MediaPaths.toRemote(ep.path))) seen.insert(bn) } } } } return found } /// Total size of the filesystem holding `path` (the drive / storage volume). public nonisolated static func storageTotalBytes(at path: String) -> Int64? { guard let attrs = try? FileManager.default.attributesOfFileSystem(forPath: path) else { return nil } return attrs[.systemSize] as? Int64 } /// Free space on the filesystem holding `path`. public nonisolated static func storageFreeBytes(at path: String) -> Int64? { guard let attrs = try? FileManager.default.attributesOfFileSystem(forPath: path) else { return nil } return attrs[.systemFreeSize] as? Int64 } private nonisolated static let gibibyte: Int64 = 1_073_741_824 /// Bytes to keep free on the cache volume — always enforced for downloads/cull. public nonisolated static func reserveFreeBytes(policy: OfflineCachePolicy) -> Int64 { Int64(max(0, min(100, policy.reserveFreeGB))) * gibibyte } /// Max cache footprint that still leaves `reserveFreeGB` on the volume. public nonisolated static func maxCacheBytesRespectingReserve(at path: String, cacheBytes: Int64, reserveBytes: Int64) -> Int64 { guard let free = storageFreeBytes(at: path) else { return Int64.max } return max(0, free + cacheBytes - reserveBytes) } /// Effective cache cap: percent budget (when culling on) ∩ reserve-free floor. public nonisolated static func budgetBytes(policy: OfflineCachePolicy, cacheBytesOnDisk: Int64? = nil) -> Int64 { let root = destRoot(for: policy).path let cacheBytes = cacheBytesOnDisk ?? scanDisk(at: root).1 let reserveCap = maxCacheBytesRespectingReserve( at: root, cacheBytes: cacheBytes, reserveBytes: reserveFreeBytes(policy: policy)) var percentCap = Int64.max if policy.cullEnabled { let pct = max(1, min(50, policy.budgetPercent)) if let total = storageTotalBytes(at: root) { percentCap = total * Int64(pct) / 100 } } return min(percentCap, reserveCap) } /// True when the cache volume has headroom above the reserved free-space floor. public nonisolated static func hasDownloadHeadroom(policy: OfflineCachePolicy, cacheRoot: String? = nil) -> Bool { let root = cacheRoot ?? destRoot(for: policy).path guard let free = storageFreeBytes(at: root) else { return true } return free > reserveFreeBytes(policy: policy) } /// True when the local player device has offline-cache duty enabled. nonisolated static func localOfflineCacheEnabled() -> Bool { DevicesConfig.loadOrSeed().localDevice?.services.offlineCache ?? false } /// Auto-warmup on launch — gated by the local device's offline policy + duty. public func warmupIfEnabled(policy: OfflineCachePolicy? = nil) async { let p = policy ?? policyActuator.policyForActuation() guard p.warmupEnabled, Self.localOfflineCacheEnabled() else { return } await cacheNow(policy: p) } /// Periodic cull + refetch of missing warmup-window episodes (every `reconcileInterval`). public func startPeriodicReconcile(interval: TimeInterval = reconcileInterval) { reconcileTask?.cancel() reconcileTask = Task { [weak self] in while !Task.isCancelled { await self?.reconcileIfNeeded() try? await Task.sleep(for: .seconds(interval)) } } } public func stopPeriodicReconcile() { reconcileTask?.cancel() reconcileTask = nil } /// Play-triggered reconcile: after an episode finishes, rebuild the warmup window /// from the updated Continue Watching rail, cull when over budget, and fetch any /// newly-needed episodes ahead of the frontier. public func reconcileAfterEpisodeFinished() async { guard Self.localOfflineCacheEnabled() else { return } if caching { scheduleReconcileWhenIdle() return } Log.info("offline cache: episode finished — running warmup/cull reconcile") await reconcileIfNeeded() } private var idleReconcileTask: Task? /// If a fetch is already running, reconcile once it completes. private func scheduleReconcileWhenIdle() { idleReconcileTask?.cancel() idleReconcileTask = Task { [weak self] in while let self, self.caching, !Task.isCancelled { try? await Task.sleep(for: .seconds(2)) } guard !Task.isCancelled else { return } await self?.reconcileAfterEpisodeFinished() } } /// Cull when over budget; redownload plan episodes that were culled or never fetched. /// Also restores any user-pinned files (e.g. kept adult clips) with high priority before plan items. public func reconcileIfNeeded(policy: OfflineCachePolicy? = nil) async { guard !caching else { return } guard Self.localOfflineCacheEnabled() else { return } let policy = policy ?? policyActuator.policyForActuation() let plan = buildPlan(policy: policy) lastPlanCount = plan.count planMissingCount = plan.isEmpty ? 0 : Self.missingEpisodes(in: plan).count let cacheRoot = Self.destRoot(for: policy).path let pinnedEps = Self.missingPinned(pinned: Self.effectivePinned(from: policy), shows: library.shows) pinnedMissingCount = pinnedEps.count // When black (the depended-on media server) is offline, skip both automatic // refetches (no "keep trying") *and* culling. Culling is more restrictive: // non-window historical items stay on disk until we can actually refill the // current warmup window. Sidebar still reflects planMissing/pinnedMissing. if !Self.isStorageReachable() { return } applyCullIfNeeded(policy: policy, plan: plan, cacheRoot: cacheRoot) planMissingCount = Self.missingEpisodes(in: plan).count pinnedMissingCount = Self.missingPinned(pinned: Self.effectivePinned(from: policy), shows: library.shows).count guard !plan.isEmpty || pinnedMissingCount > 0 else { return } guard policy.warmupEnabled else { if !isDownloading { status = idleStatus(plan: plan, policy: policy) } return } guard Self.hasDownloadHeadroom(policy: policy, cacheRoot: cacheRoot) else { let msg = pinnedMissingCount > 0 ? "\(pinnedMissingCount) pinned + \(planMissingCount) window missing — waiting for space" : "\(planMissingCount) episode\(planMissingCount == 1 ? "" : "s") missing — waiting for space (keeps \(policy.reserveFreeGB) GB free)" status = msg return } // Highly prioritize pinned restores (adult keep requests etc) before regular plan refetches. if pinnedMissingCount > 0 { await fetchEpisodes(pinnedEps, policy: policy, action: "Restoring pinned") } let stillPlanMissing = Self.missingEpisodes(in: plan) planMissingCount = stillPlanMissing.count if !stillPlanMissing.isEmpty { await fetchEpisodes(stillPlanMissing, policy: policy, action: "Refetching") } } // MARK: fetch /// `policy` nil → debounced actuation policy; explicit value → immediate (e.g. "Warm up now"). public func cacheNow(policy: OfflineCachePolicy? = nil) async { let policy = policy ?? policyActuator.policyForActuation() guard !caching else { return } let plan = buildPlan(policy: policy) lastPlanCount = plan.count guard !plan.isEmpty else { status = "Nothing to cache (no recent shows)" NotificationsService.shared.showBanner(status!) return } let cacheRoot = Self.destRoot(for: policy).path if !Self.isStorageReachable() { status = "Black offline — downloads skipped" NotificationsService.shared.showBanner(status!) return } applyCullIfNeeded(policy: policy, plan: plan, cacheRoot: cacheRoot) await fetchEpisodes(plan, policy: policy, action: "Caching") } private func buildPlan(policy: OfflineCachePolicy) -> [OfflineEpisode] { let adult = SettingsStore.load().surfaceAdultOnHome return Self.plan( shows: library.shows, continueWatching: library.continueWatching, recent: library.recentlyAdded(limit: policy.shows), fromContinueWatching: policy.fromContinueWatching, showCount: policy.shows, episodesAhead: policy.episodesAhead, episodesBehind: policy.episodesBehind, includeAdult: adult) } @discardableResult private func applyCullIfNeeded(policy: OfflineCachePolicy, plan: [OfflineEpisode], cacheRoot: String) -> OfflineCullResult { let cacheBytes = Self.scanDisk(at: cacheRoot).1 let budget = Self.budgetBytes(policy: policy, cacheBytesOnDisk: cacheBytes) let pins = Self.effectivePinned(from: policy) let prot = Self.protectedNames(for: plan, additionalPinned: pins) let result = Self.evictOldest(protectedNames: prot, budgetBytes: budget, cacheRoot: cacheRoot) if result.count > 0 { recordCull(result, policy: policy, cacheRoot: cacheRoot) refreshDiskStats() } return result } private func recordCull(_ result: OfflineCullResult, policy: OfflineCachePolicy, cacheRoot: String) { let reason = Self.cullReason(policy: policy, cacheRoot: cacheRoot) let summary = "Culled \(result.count) episode\(result.count == 1 ? "" : "s") (\(Self.formatBytes(result.freedBytes))) for \(reason)" lastCullSummary = summary lastCulledAt = Date() lastCulledFiles = result.fileNames status = summary + " · missing episodes retry every \(Int(Self.reconcileInterval / 60))m" Log.info("offline cache: \(summary) — \(result.fileNames.joined(separator: ", "))") NotificationsService.shared.showBanner(summary) if SettingsStore.load().notifyDownloads { NotificationsService.shared.post(title: "Offline cache culled", body: summary) } } private nonisolated static func cullReason(policy: OfflineCachePolicy, cacheRoot: String) -> String { let cacheBytes = scanDisk(at: cacheRoot).1 let reserveCap = maxCacheBytesRespectingReserve( at: cacheRoot, cacheBytes: cacheBytes, reserveBytes: reserveFreeBytes(policy: policy)) var percentCap = Int64.max if policy.cullEnabled, let total = storageTotalBytes(at: cacheRoot) { let pct = max(1, min(50, policy.budgetPercent)) percentCap = total * Int64(pct) / 100 } if percentCap <= reserveCap { return "\(policy.budgetPercent)% drive budget" } return "\(policy.reserveFreeGB) GB free reserve" } private func idleStatus(plan: [OfflineEpisode], policy: OfflineCachePolicy) -> String { let n = plan.count let bytes = Self.formatBytes(diskBytes) return "\(n) episodes in window · \(diskFileCount) on disk (\(bytes)) · check every \(Int(Self.reconcileInterval / 60))m" } private func fetchEpisodes(_ episodes: [OfflineEpisode], policy: OfflineCachePolicy, action: String) async { guard !episodes.isEmpty else { return } guard !caching else { return } caching = true defer { caching = false downloadingLabel = nil downloadingProgress = nil downloadQueue = [] queueProgress = nil refreshDiskStats() let plan = buildPlan(policy: policy) planMissingCount = plan.isEmpty ? 0 : Self.missingEpisodes(in: plan).count pinnedMissingCount = Self.missingPinned(pinned: Self.effectivePinned(from: policy), shows: library.shows).count } let cacheRoot = Self.destRoot(for: policy).path beginQueue(episodes) status = "\(action) 0/\(episodes.count)…" Log.info("offline cache: \(action.lowercased()) \(episodes.count) episodes → \(cacheRoot)") var ok = 0 for (i, ep) in episodes.enumerated() { await drainPriority(policy: policy, cacheRoot: cacheRoot) status = "\(action) \(i + 1)/\(episodes.count): \((ep.plumPath as NSString).lastPathComponent)…" if await fetchOne(ep, policy: policy, cacheRoot: cacheRoot) { ok += 1 } } await drainPriority(policy: policy, cacheRoot: cacheRoot) await Task.detached(priority: .utility) { _ = DownloadsIndex.shared.refresh() }.value let bytes = Self.scanDisk(at: cacheRoot).1 let verb = action == "Refetching" ? "Refetched" : "Cached" status = "\(verb) \(ok)/\(episodes.count) episodes (\(Self.formatBytes(bytes)) on disk)" Log.info("offline cache done: \(ok)/\(episodes.count)") notifyCacheComplete(ok: ok, total: episodes.count, bytes: bytes, title: verb) } /// Fetch one episode into the cache, updating its queue row. Appends a row if /// absent (priority items aren't part of the plan's `beginQueue`). Returns /// true on success or when the file is already present locally. @discardableResult private func fetchOne(_ ep: OfflineEpisode, policy: OfflineCachePolicy, cacheRoot: String) async -> Bool { let name = (ep.plumPath as NSString).lastPathComponent if !downloadQueue.contains(where: { $0.id == ep.plumPath }) { downloadQueue.append(OfflineQueueItem(episode: ep)) recomputeQueueProgress() } if MediaPaths.localCopy(of: ep.plumPath) != nil { setQueueItem(id: ep.plumPath, state: .done, progress: 1) return true } if !Self.hasDownloadHeadroom(policy: policy, cacheRoot: cacheRoot) { setQueueItem(id: ep.plumPath, state: .failed, progress: nil) Log.warn("offline cache: skipped \(name) — below \(policy.reserveFreeGB) GB free reserve") return false } setQueueItem(id: ep.plumPath, state: .downloading, progress: nil) let destDir = Self.destRoot(for: policy).appendingPathComponent(Self.sanitize(ep.show), isDirectory: true) let didFetch = await Self.rsync(remotePath: ep.remotePath, destDir: destDir.path) { p in await MainActor.run { Self.active?.setQueueItem(id: ep.plumPath, state: .downloading, progress: p) } } setQueueItem(id: ep.plumPath, state: didFetch ? .done : .failed, progress: didFetch ? 1 : nil) return didFetch } /// Drain all queued priority downloads (culling to budget, protecting the /// file being fetched). Called before each plan item and by the idle drainer. private func drainPriority(policy: OfflineCachePolicy, cacheRoot: String) async { while !priorityEpisodes.isEmpty { let ep = priorityEpisodes.removeFirst() priorityCount = priorityEpisodes.count let name = (ep.plumPath as NSString).lastPathComponent let prot = Self.protectedNames(for: [], additionalPinned: Self.effectivePinned(from: policy) + [name]) let budget = Self.budgetBytes(policy: policy, cacheBytesOnDisk: Self.scanDisk(at: cacheRoot).1) let cull = Self.evictOldest(protectedNames: prot, budgetBytes: budget, cacheRoot: cacheRoot) if cull.count > 0 { recordCull(cull, policy: policy, cacheRoot: cacheRoot); refreshDiskStats() } status = "Priority: \(name)…" _ = await fetchOne(ep, policy: policy, cacheRoot: cacheRoot) } } // MARK: priority lane (user-chosen downloads jump the warmup plan) /// Queue files to download ahead of the warmup plan. Already-local or already- /// queued paths are dropped. If a fetch loop is running they're drained before /// the next plan item; if idle, a priority-only fetch starts immediately. public func enqueuePriority(_ episodes: [OfflineEpisode]) { let existing = Set(priorityEpisodes.map(\.plumPath)) let fresh = episodes.filter { MediaPaths.localCopy(of: $0.plumPath) == nil && !existing.contains($0.plumPath) } guard !fresh.isEmpty else { return } priorityEpisodes.append(contentsOf: fresh) priorityCount = priorityEpisodes.count if caching { status = "Prioritized \(fresh.count) download\(fresh.count == 1 ? "" : "s") — fetching next" return } Task { await self.fetchPriorityIfIdle() } } /// True while `path` is waiting in or being fetched by the priority lane. public func isPrioritized(path: String) -> Bool { priorityEpisodes.contains { $0.plumPath == path } } private func fetchPriorityIfIdle() async { guard !caching, !priorityEpisodes.isEmpty else { return } guard Self.isStorageReachable() else { status = "Black offline — priority downloads paused" return } caching = true let policy = policyActuator.policyForActuation() let cacheRoot = Self.destRoot(for: policy).path queueProgress = 0 defer { caching = false downloadingLabel = nil downloadingProgress = nil downloadQueue = [] queueProgress = nil refreshDiskStats() } await drainPriority(policy: policy, cacheRoot: cacheRoot) await Task.detached(priority: .utility) { _ = DownloadsIndex.shared.refresh() }.value status = "Priority downloads done (\(Self.formatBytes(Self.scanDisk(at: cacheRoot).1)) on disk)" } /// Build an episode for a single library path and prioritize its download. /// Used by on-demand callers (e.g. the Adult collection clip list). public static func prioritizeFetch(path: String, show: String?) { guard let active else { return } let remote = MediaPaths.toRemote(path) let ep = OfflineEpisode(show: show ?? inferShowName(remote), label: (path as NSString).lastPathComponent, plumPath: path, remotePath: remote) active.enqueuePriority([ep]) } /// Await a prioritized download: resolves true once the file lands locally, /// false once the lane has drained without it. Polled (1 s) up to ~1 h. public func awaitDownload(path: String) async -> Bool { for _ in 0..<3600 { if MediaPaths.localCopy(of: path) != nil { return true } if !isPrioritized(path: path) && !caching { break } try? await Task.sleep(for: .seconds(1)) } return MediaPaths.localCopy(of: path) != nil } private func notifyCacheComplete(ok: Int, total: Int, bytes: Int64, title: String = "Offline cache") { let body = "\(ok)/\(total) episodes · \(Self.formatBytes(bytes))" if SettingsStore.load().notifyDownloads { NotificationsService.shared.post(title: title, body: body) } else { NotificationsService.shared.showBanner("\(title) — \(body)") } } /// Pull one library episode from the storage server so a local player can open it. public static func fetchFile(path: String, show: String?, onStatus: (@MainActor (String) -> Void)? = nil) async -> Bool { let policy = Self.active?.policyActuator.policyForActuation() ?? DevicesConfig.localOfflinePolicy() let remote = MediaPaths.toRemote(path) let showName = show ?? inferShowName(remote) let fileName = (path as NSString).lastPathComponent // A warmup/plan (or another priority drain) is already rsyncing — don't // start a competing transfer. Hand this to the priority lane so it jumps // ahead of the remaining plan items, and await its completion. if let a = active, a.caching { a.enqueuePriority([OfflineEpisode(show: showName, label: fileName, plumPath: path, remotePath: remote)]) onStatus?("Prioritized \(fileName) — downloading next") return await a.awaitDownload(path: path) } let cacheRoot = destRoot(for: policy).path let cacheBytes = scanDisk(at: cacheRoot).1 let budget = budgetBytes(policy: policy, cacheBytesOnDisk: cacheBytes) let pins = Self.effectivePinned(from: policy) let prot = Self.protectedNames(for: [], additionalPinned: pins + [fileName]) if Self.isStorageReachable() { let cull = evictOldest(protectedNames: prot, budgetBytes: budget, cacheRoot: cacheRoot) if cull.count > 0 { await active?.recordCull(cull, policy: policy, cacheRoot: cacheRoot) await active?.refreshDiskStats() } } else { Log.warn("offline fetch: skipped cull for \(fileName) — black offline") onStatus?("Black offline — cannot download \(fileName)") return false } guard hasDownloadHeadroom(policy: policy, cacheRoot: cacheRoot) else { Log.warn("offline fetch: skipped \(fileName) — below \(policy.reserveFreeGB) GB free reserve") return false } await active?.beginSingleFetch(path: path, show: showName) onStatus?("Downloading \(fileName)…") let destDir = destRoot.appendingPathComponent(sanitize(showName), isDirectory: true).path let ok = await rsync(remotePath: remote, destDir: destDir) { p in await MainActor.run { let pct = Int(p * 100) onStatus?("Downloading \(fileName)… \(pct)%") active?.setQueueItem(id: path, state: .downloading, progress: p) } } await active?.setQueueItem(id: path, state: ok ? .done : .failed, progress: ok ? 1 : nil) await active?.clearQueue() await active?.refreshDiskStats() if ok { _ = await Task.detached(priority: .utility) { DownloadsIndex.shared.refresh() }.value } return ok && MediaPaths.localCopy(of: path) != nil } private func beginQueue(_ plan: [OfflineEpisode]) { downloadQueue = plan.map { OfflineQueueItem(episode: $0) } queueProgress = 0 downloadingLabel = nil downloadingProgress = nil } private func beginSingleFetch(path: String, show: String) { downloadQueue = [OfflineQueueItem(path: path, show: show, state: .downloading)] queueProgress = 0 downloadingLabel = (path as NSString).lastPathComponent downloadingProgress = nil } private func clearQueue() { downloadQueue = [] queueProgress = nil downloadingLabel = nil downloadingProgress = nil } private func setQueueItem(id: String, state: OfflineQueueState, progress: Double?) { guard let i = downloadQueue.firstIndex(where: { $0.id == id }) else { return } downloadQueue[i].state = state downloadQueue[i].progress = progress if state == .downloading { downloadingLabel = downloadQueue[i].name downloadingProgress = progress } else if downloadQueue.allSatisfy({ $0.state != .downloading }) { downloadingLabel = nil downloadingProgress = nil } recomputeQueueProgress() } private func recomputeQueueProgress() { guard !downloadQueue.isEmpty else { queueProgress = nil; return } let total = Double(downloadQueue.count) var sum = 0.0 for item in downloadQueue { switch item.state { case .done: sum += 1 case .downloading: sum += item.progress ?? 0 case .failed: sum += 1 case .pending: break } } queueProgress = min(1, max(0, sum / total)) } /// Show folder name from a black-side path (`…/media/tv/Show Name/…`). static func inferShowName(_ remote: String) -> String { for marker in ["/media/tv/", "/media/anime/", "/media/cartoons/", "/media/movies/"] { guard let r = remote.range(of: marker) else { continue } let rest = remote[r.upperBound...] if let first = rest.split(separator: "/").first { return String(first) } } return ((remote as NSString).deletingLastPathComponent as NSString).lastPathComponent } public static func formatBytes(_ bytes: Int64) -> String { ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) } /// rsync one file from black; optional progress callback (0…1) from `--info=progress2`. private nonisolated static func rsync(remotePath: String, destDir: String, onProgress: (@Sendable (Double) async -> Void)? = nil) async -> Bool { let q: (String) -> String = { "'" + $0.replacingOccurrences(of: "'", with: "'\\''") + "'" } let mk = "mkdir -p \(q(destDir))" let sshE = "ssh -o ConnectTimeout=4 -o BatchMode=yes" for host in storageHosts { let cmd = "\(mk) && rsync -a --append-verify --info=progress2 -e \(q(sshE)) \(q("\(host):\(remotePath)")) \(q(destDir))/" let ok = await runRsyncWithProgress(cmd, onProgress: onProgress) if ok { return true } } return false } private nonisolated static func runRsyncWithProgress(_ command: String, onProgress: (@Sendable (Double) async -> Void)?) async -> Bool { await Task.detached(priority: .userInitiated) { let p = Process() p.executableURL = URL(fileURLWithPath: "/bin/zsh") p.arguments = ["-ilc", command] let err = Pipe() p.standardOutput = Pipe() p.standardError = err do { try p.run() } catch { return false } let handle = err.fileHandleForReading var line = "" while p.isRunning { let chunk = handle.availableData if chunk.isEmpty { Thread.sleep(forTimeInterval: 0.1); continue } let text = String(decoding: chunk, as: UTF8.self) for ch in text { if ch == "\r" || ch == "\n" { if let pct = parseRsyncProgress(line) { await onProgress?(pct) } line = "" } else { line.append(ch) } } } _ = handle.readDataToEndOfFile() p.waitUntilExit() return p.terminationStatus == 0 }.value } /// Parse `rsync --info=progress2` lines like `1.23M 45% 1.00MB/s 0:00:12` nonisolated static func parseRsyncProgress(_ line: String) -> Double? { let parts = line.split(whereSeparator: \.isWhitespace).map(String.init) guard parts.count >= 2 else { return nil } let pct = parts[1].hasSuffix("%") ? parts[1].dropLast() : Substring(parts[1]) return Double(pct).map { min(1, max(0, $0 / 100)) } } private nonisolated static func sanitize(_ s: String) -> String { String(s.map { $0 == "/" ? "-" : $0 }) } }