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>
253 lines
10 KiB
Swift
253 lines
10 KiB
Swift
// Offline downloads + prefetch-ahead. Raw files are pulled from the bridge's
|
|
// /stream endpoint (same bytes VLCKit streams) into app storage and played
|
|
// locally. The prefetch policy keeps the next N episodes after the user's
|
|
// position downloaded, so the next ones are always ready offline — the app-side
|
|
// equivalent of the governor's buffer.
|
|
//
|
|
// Downloads run on a BACKGROUND URLSession: they continue when the app is
|
|
// backgrounded or killed, and iOS relaunches the app to hand results back
|
|
// (AppDelegate.handleEventsForBackgroundURLSession → BackgroundSessionRelay).
|
|
// Because the process can die mid-download, every queued request's metadata is
|
|
// persisted alongside the completed-entries index and re-read on relaunch;
|
|
// the episodeId rides on each task as `taskDescription`.
|
|
|
|
import Foundation
|
|
import CryptoKit
|
|
|
|
struct OfflineEntry: Codable, Identifiable, Hashable {
|
|
let episodeId: String
|
|
let filename: String
|
|
let show: String
|
|
let label: String
|
|
let season: Int
|
|
let episode: Int
|
|
var bytes: Int64
|
|
|
|
var id: String { episodeId }
|
|
/// Movies are stored as S00E00 and show no badge.
|
|
var code: String { season == 0 && episode == 0 ? "" : String(format: "S%02dE%02d", season, episode) }
|
|
}
|
|
|
|
/// Metadata needed to download one episode (so the offline index is self-describing).
|
|
struct DownloadRequest: Codable {
|
|
let episodeId: String
|
|
let ext: String
|
|
let show: String
|
|
let label: String
|
|
let season: Int
|
|
let episode: Int
|
|
let url: URL
|
|
}
|
|
|
|
/// Hands the background-session completion handler from the UIKit app delegate
|
|
/// to the session delegate (they live in different objects).
|
|
@MainActor
|
|
final class BackgroundSessionRelay {
|
|
static let shared = BackgroundSessionRelay()
|
|
var completionHandler: (() -> Void)?
|
|
}
|
|
|
|
@MainActor
|
|
final class DownloadManager: NSObject, ObservableObject {
|
|
static let sessionIdentifier = "local.lilith.TVAnarchyiOS.downloads"
|
|
|
|
enum State: Equatable {
|
|
case downloading(Double) // 0...1
|
|
case completed
|
|
case failed(String)
|
|
}
|
|
|
|
@Published private(set) var entries: [OfflineEntry] = []
|
|
@Published private(set) var states: [String: State] = [:]
|
|
|
|
/// Queued/running request metadata, persisted so a relaunch can finish them.
|
|
private var pending: [String: DownloadRequest] = [:]
|
|
|
|
private lazy var session: URLSession = {
|
|
let cfg = URLSessionConfiguration.background(withIdentifier: Self.sessionIdentifier)
|
|
cfg.sessionSendsLaunchEvents = true
|
|
cfg.isDiscretionary = false
|
|
cfg.waitsForConnectivity = true
|
|
return URLSession(configuration: cfg, delegate: self, delegateQueue: nil)
|
|
}()
|
|
|
|
override init() {
|
|
super.init()
|
|
try? FileManager.default.createDirectory(at: Self.dir, withIntermediateDirectories: true)
|
|
loadIndex()
|
|
reconcileWithSession()
|
|
}
|
|
|
|
// MARK: - Storage layout
|
|
|
|
private static var dir: URL {
|
|
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
.appendingPathComponent("offline", isDirectory: true)
|
|
}
|
|
private static var indexURL: URL { dir.appendingPathComponent("index.json") }
|
|
|
|
private static func filename(for episodeId: String, ext: String) -> String {
|
|
let digest = SHA256.hash(data: Data(episodeId.utf8))
|
|
let hex = digest.map { String(format: "%02x", $0) }.joined()
|
|
return "\(hex).\(ext.isEmpty ? "mkv" : ext)"
|
|
}
|
|
|
|
/// Local file for an episode, if fully downloaded.
|
|
func localURL(episodeId: String) -> URL? {
|
|
guard let entry = entries.first(where: { $0.episodeId == episodeId }) else { return nil }
|
|
let url = Self.dir.appendingPathComponent(entry.filename)
|
|
return FileManager.default.fileExists(atPath: url.path) ? url : nil
|
|
}
|
|
|
|
func isDownloaded(_ episodeId: String) -> Bool { localURL(episodeId: episodeId) != nil }
|
|
|
|
func isActive(_ episodeId: String) -> Bool {
|
|
if case .downloading = states[episodeId] { return true }
|
|
return false
|
|
}
|
|
|
|
var totalBytes: Int64 { entries.reduce(0) { $0 + $1.bytes } }
|
|
|
|
// MARK: - Operations
|
|
|
|
func download(_ req: DownloadRequest) {
|
|
guard !isDownloaded(req.episodeId), !isActive(req.episodeId) else { return }
|
|
pending[req.episodeId] = req
|
|
states[req.episodeId] = .downloading(0)
|
|
saveIndex()
|
|
let task = session.downloadTask(with: req.url)
|
|
task.taskDescription = req.episodeId
|
|
task.resume()
|
|
}
|
|
|
|
func delete(episodeId: String) {
|
|
if let entry = entries.first(where: { $0.episodeId == episodeId }) {
|
|
try? FileManager.default.removeItem(at: Self.dir.appendingPathComponent(entry.filename))
|
|
}
|
|
entries.removeAll { $0.episodeId == episodeId }
|
|
states[episodeId] = nil
|
|
pending[episodeId] = nil
|
|
saveIndex()
|
|
}
|
|
|
|
/// Keep the next `count` upcoming episodes downloaded. `upcoming` is the
|
|
/// ordered list of episodes after the user's current position.
|
|
func prefetch(upcoming: [DownloadRequest], count: Int) {
|
|
for req in upcoming.prefix(count) {
|
|
download(req)
|
|
}
|
|
}
|
|
|
|
// MARK: - Index persistence
|
|
|
|
private struct DiskIndex: Codable {
|
|
var entries: [OfflineEntry]
|
|
var pending: [String: DownloadRequest]
|
|
}
|
|
|
|
private func loadIndex() {
|
|
guard let data = try? Data(contentsOf: Self.indexURL) else { return }
|
|
// Current shape first; fall back to the pre-background plain array.
|
|
if let decoded = try? JSONDecoder().decode(DiskIndex.self, from: data) {
|
|
entries = decoded.entries
|
|
pending = decoded.pending
|
|
} else if let legacy = try? JSONDecoder().decode([OfflineEntry].self, from: data) {
|
|
entries = legacy
|
|
}
|
|
// Drop entries whose file vanished.
|
|
entries = entries.filter { FileManager.default.fileExists(atPath: Self.dir.appendingPathComponent($0.filename).path) }
|
|
}
|
|
|
|
private func saveIndex() {
|
|
if let data = try? JSONEncoder().encode(DiskIndex(entries: entries, pending: pending)) {
|
|
try? data.write(to: Self.indexURL)
|
|
}
|
|
}
|
|
|
|
/// After relaunch: re-attach progress state to tasks iOS kept alive, and
|
|
/// re-submit persisted requests whose tasks died with the old process.
|
|
private func reconcileWithSession() {
|
|
session.getAllTasks { [weak self] tasks in
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
var alive = Set<String>()
|
|
for task in tasks {
|
|
guard let id = task.taskDescription else { continue }
|
|
alive.insert(id)
|
|
if self.pending[id] != nil { self.states[id] = .downloading(0) }
|
|
}
|
|
for (id, req) in self.pending where !alive.contains(id) && !self.isDownloaded(id) {
|
|
self.states[id] = .downloading(0)
|
|
let task = self.session.downloadTask(with: req.url)
|
|
task.taskDescription = id
|
|
task.resume()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fileprivate func finish(episodeId: String, tempURL: URL) {
|
|
guard let req = pending[episodeId] else { return }
|
|
let filename = Self.filename(for: episodeId, ext: req.ext)
|
|
let dest = Self.dir.appendingPathComponent(filename)
|
|
try? FileManager.default.removeItem(at: dest)
|
|
do {
|
|
try FileManager.default.moveItem(at: tempURL, to: dest)
|
|
} catch {
|
|
states[episodeId] = .failed(error.localizedDescription)
|
|
return
|
|
}
|
|
let bytes = (try? FileManager.default.attributesOfItem(atPath: dest.path)[.size] as? Int64) ?? 0
|
|
entries.removeAll { $0.episodeId == episodeId }
|
|
entries.append(OfflineEntry(
|
|
episodeId: episodeId, filename: filename, show: req.show, label: req.label,
|
|
season: req.season, episode: req.episode, bytes: bytes
|
|
))
|
|
entries.sort { ($0.show, $0.season, $0.episode) < ($1.show, $1.season, $1.episode) }
|
|
states[episodeId] = .completed
|
|
pending[episodeId] = nil
|
|
saveIndex()
|
|
}
|
|
}
|
|
|
|
// URLSession delegate runs off the main actor; hop back to update @Published state.
|
|
extension DownloadManager: URLSessionDownloadDelegate {
|
|
nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
|
|
didWriteData bytesWritten: Int64, totalBytesWritten: Int64,
|
|
totalBytesExpectedToWrite: Int64) {
|
|
guard totalBytesExpectedToWrite > 0 else { return }
|
|
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
|
|
guard let ep = downloadTask.taskDescription else { return }
|
|
Task { @MainActor in
|
|
self.states[ep] = .downloading(progress)
|
|
}
|
|
}
|
|
|
|
nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
|
|
didFinishDownloadingTo location: URL) {
|
|
// The temp file is deleted when this returns, so move it synchronously here.
|
|
guard let ep = downloadTask.taskDescription else { return }
|
|
let fm = FileManager.default
|
|
let staged = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
try? fm.moveItem(at: location, to: staged)
|
|
Task { @MainActor in
|
|
self.finish(episodeId: ep, tempURL: staged)
|
|
}
|
|
}
|
|
|
|
nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
|
guard let error, let ep = task.taskDescription else { return }
|
|
Task { @MainActor in
|
|
self.states[ep] = .failed(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
/// iOS relaunched (or woke) the app for this session's events; the system
|
|
/// completion handler must run once the delegate queue drains.
|
|
nonisolated func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
|
|
Task { @MainActor in
|
|
BackgroundSessionRelay.shared.completionHandler?()
|
|
BackgroundSessionRelay.shared.completionHandler = nil
|
|
}
|
|
}
|
|
}
|