194 lines
7.3 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|
|
}
|