123 lines
5.9 KiB
Swift
123 lines
5.9 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.
|
|
public 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 || $0.category != "porn" }
|
|
.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)")
|
|
}
|
|
|
|
/// 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 })
|
|
}
|
|
}
|