Surface the existing pin (keep-from-cull) and per-file delete actions as visible inline buttons on each offline cache row instead of context-menu-only: a star toggles protection from auto-cull (and restore-if-missing), a trash culls that file early. Aligns wording/icons to the star metaphor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
888 lines
No EOL
41 KiB
Swift
888 lines
No EOL
41 KiB
Swift
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?
|
|
/// 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
|
|
|
|
/// 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) }
|
|
if let active {
|
|
if let pct {
|
|
return "Downloading \(done + 1)/\(total) · \(pct)% — \(active.name)"
|
|
}
|
|
return "Downloading \(done + 1)/\(total) — \(active.name)"
|
|
}
|
|
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() {
|
|
let name = (ep.plumPath as NSString).lastPathComponent
|
|
if MediaPaths.localCopy(of: ep.plumPath) != nil {
|
|
setQueueItem(id: ep.plumPath, state: .done, progress: 1)
|
|
ok += 1
|
|
continue
|
|
}
|
|
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")
|
|
continue
|
|
}
|
|
setQueueItem(id: ep.plumPath, state: .downloading, progress: nil)
|
|
status = "\(action) \(i + 1)/\(episodes.count): \(name)…"
|
|
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)
|
|
if didFetch { ok += 1 }
|
|
}
|
|
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)
|
|
}
|
|
|
|
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
|
|
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 })
|
|
}
|
|
} |