tv-anarchy/Sources/TVAnarchyiOS/DownloadManager.swift
Natalie 17cf518418 feat(ios): downloads (DownloadManager/DownloadsView), remote control view + bridge/player refinements
(cherry picked from commit a1f7e44e17bb41f48976b69f4dbe5278cbad06b2)
2026-06-09 06:38:45 -07:00

194 lines
7.3 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.
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 }
var code: String { String(format: "S%02dE%02d", season, episode) }
}
/// Metadata needed to download one episode (so the offline index is self-describing).
struct DownloadRequest {
let episodeId: String
let ext: String
let show: String
let label: String
let season: Int
let episode: Int
let url: URL
}
@MainActor
final class DownloadManager: NSObject, ObservableObject {
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] = [:]
private var taskToEpisode: [Int: String] = [:]
private var requestByEpisode: [String: DownloadRequest] = [:]
private lazy var session: URLSession = {
let cfg = URLSessionConfiguration.default
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()
}
// 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 }
requestByEpisode[req.episodeId] = req
states[req.episodeId] = .downloading(0)
let task = session.downloadTask(with: req.url)
taskToEpisode[task.taskIdentifier] = 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
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 func loadIndex() {
guard let data = try? Data(contentsOf: Self.indexURL),
let decoded = try? JSONDecoder().decode([OfflineEntry].self, from: data) else { return }
// Drop entries whose file vanished.
entries = decoded.filter { FileManager.default.fileExists(atPath: Self.dir.appendingPathComponent($0.filename).path) }
}
private func saveIndex() {
if let data = try? JSONEncoder().encode(entries) {
try? data.write(to: Self.indexURL)
}
}
fileprivate func finish(episodeId: String, tempURL: URL) {
guard let req = requestByEpisode[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
requestByEpisode[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)
let tid = downloadTask.taskIdentifier
Task { @MainActor in
if let ep = self.taskToEpisode[tid] { 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.
let tid = downloadTask.taskIdentifier
let fm = FileManager.default
let staged = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try? fm.moveItem(at: location, to: staged)
Task { @MainActor in
guard let ep = self.taskToEpisode[tid] else { return }
self.taskToEpisode[tid] = nil
self.finish(episodeId: ep, tempURL: staged)
}
}
nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard let error else { return }
let tid = task.taskIdentifier
Task { @MainActor in
if let ep = self.taskToEpisode[tid] {
self.states[ep] = .failed(error.localizedDescription)
self.taskToEpisode[tid] = nil
}
}
}
}