tv-anarchy/Sources/TVAnarchyCore/OfflineCacheController.swift
Natalie b44b5a2d1a feat(@applications): add adult content browsing tab
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-09 19:51:12 -07:00

127 lines
6.3 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 (mount) path
public let remotePath: String // black-side path, for the rsync source
public var id: String { plumPath }
}
/// Pulls the next-Y-episodes-of-the-most-recent-Z-shows to local disk so a
/// cellphone/laptop device can play offline (flights, off-LAN). Planning is pure
/// and unit-tested; fetching rsyncs only files that exist on black (best-effort,
/// resumable via `--append-verify`, like the `media-fetch` skill). No daemon the
/// app drives it on demand, gated by a device's `services.offlineCache`.
@Observable
@MainActor
public final class OfflineCacheController {
public private(set) var status: String?
public private(set) var caching = false
public private(set) var lastPlanCount = 0
private let library: LibraryController
public init(library: LibraryController) { self.library = library }
/// black SSH host (env-overridable; matches the rest of the stack).
private nonisolated static var blackHost: String {
ProcessInfo.processInfo.environment["BLACK_SSH_HOST"] ?? "lilith@10.0.0.11"
}
/// Local destination root for cached episodes. `nonisolated` so `DownloadsIndex`
/// (which indexes this dir to route downloaded episodes to a local player) can
/// read it off the main actor.
public nonisolated static var destRoot: URL {
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)
/// The episodes to cache: from the most-recent `showCount` shows (continue-
/// watching or recently-added), the next `episodesPerShow` episodes starting at
/// the show's current position (continue-watching) or its first episode
/// (recently-added). Adult excluded unless `includeAdult`.
public static func plan(shows: [CachedShow], continueWatching: [ContinueItem],
recent: [CachedShow], fromContinueWatching: Bool,
showCount: Int, episodesPerShow: Int,
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) }
}
var picks: [OfflineEpisode] = []
for (show, anchor) in sources {
let eps = show.seasons.flatMap { show.episodes(inSeason: $0) }
guard !eps.isEmpty else { continue }
let start = anchor.flatMap { a in eps.firstIndex { $0.path == a } } ?? 0
for ep in eps[start...].prefix(max(1, episodesPerShow)) {
picks.append(OfflineEpisode(show: show.name, label: ep.label,
plumPath: ep.path, remotePath: MediaPaths.toRemote(ep.path)))
}
}
return picks
}
// MARK: fetch
/// Compute the plan from the live library + settings and rsync each episode
/// from black into `destRoot/<show>/`. Only files that exist on black transfer;
/// `--append-verify` makes re-runs resume rather than restart.
public func cacheNow(settings: AppSettings = SettingsStore.load()) async {
guard !caching else { return }
caching = true
defer { caching = false }
let plan = Self.plan(
shows: library.shows,
continueWatching: library.continueWatching,
recent: library.recentlyAdded(limit: settings.offlineShows),
fromContinueWatching: settings.offlineFromContinueWatching,
showCount: settings.offlineShows, episodesPerShow: settings.offlineEpisodes,
includeAdult: settings.surfaceAdultOnHome)
lastPlanCount = plan.count
guard !plan.isEmpty else { status = "Nothing to cache (no recent shows)"; return }
status = "Caching \(plan.count) episodes…"
Log.info("offline cache: \(plan.count) episodes → \(Self.destRoot.path)")
var ok = 0
for ep in plan {
let destDir = Self.destRoot.appendingPathComponent(sanitize(ep.show), isDirectory: true)
let didFetch = await Task.detached(priority: .utility) {
Self.rsync(remotePath: ep.remotePath, destDir: destDir.path)
}.value
if didFetch { ok += 1; status = "Cached \(ok)/\(plan.count)" }
}
status = "Cached \(ok)/\(plan.count) episodes to \(Self.destRoot.lastPathComponent)"
Log.info("offline cache done: \(ok)/\(plan.count)")
// Re-index downloads so the just-cached episodes route to a local player.
await Task.detached(priority: .utility) { _ = DownloadsIndex.shared.refresh() }.value
}
/// rsync one file from black. `--append-verify` resumes partials; the file
/// only transfers if it exists on black (rsync no-ops / errors otherwise).
private nonisolated static func rsync(remotePath: String, destDir: String) -> Bool {
let q: (String) -> String = { "'" + $0.replacingOccurrences(of: "'", with: "'\\''") + "'" }
let mk = "mkdir -p \(q(destDir))"
let cmd = "\(mk) && rsync -a --append-verify -e ssh \(q("\(blackHost):\(remotePath)")) \(q(destDir))/"
return ProcessRunner.runShell(cmd, timeout: 600).ok
}
private nonisolated func sanitize(_ s: String) -> String {
String(s.map { $0 == "/" ? "-" : $0 })
}
}