tv-anarchy/Sources/TVAnarchyCore/OfflineCacheController.swift

1000 lines
47 KiB
Swift
Raw Normal View History

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/<sanitized-show>/...).
/// 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?
/// 01 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 01 (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<Void, Never>?
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<String>()
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..<end] {
picks.append(OfflineEpisode(show: show.name, label: ep.label,
plumPath: ep.path, remotePath: MediaPaths.toRemote(ep.path)))
}
}
return picks
}
// MARK: disk + eviction
public func refreshDiskStats() {
let (n, b, items) = Self.scanDiskDetailed()
diskFileCount = n
diskBytes = b
cachedItems = items
}
/// Walk the offline dir; return (file count, total bytes). Delegates to detailed scan.
nonisolated static func scanDisk(at root: String? = nil) -> (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<String>, 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<String> {
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<String>()
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<String>()
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<Void, Never>?
/// 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 (01) 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 })
}
}