Files
Firelink/Sources/Firelink/MediaEngineManager.swift
T
nimbold 1a2c59d243 fix: media downloads connections, progress parsing, file size, and selection highlight
- Removed hardcoded 1 connection limit for media downloads
- Added support for aria2c progress parsing in media downloads
- Fixed missing file size after download completion by reading from disk
- Fixed row selection highlight in DownloadTable by using onTapGesture instead of simultaneousGesture
2026-06-07 22:28:23 +03:30

192 lines
6.2 KiB
Swift

import Foundation
import Combine
enum AddonState: Equatable, Sendable {
case notInstalled
case downloading(progress: Double)
case installed(version: String)
case failed(error: String)
}
enum AddonType: String, CaseIterable, Sendable {
case ytDlp = "yt-dlp"
case ffmpeg
var defaultsKey: String {
return "Firelink.AddonVersion.\(self.rawValue)"
}
var binaryName: String {
switch self {
case .ytDlp: return "yt-dlp"
case .ffmpeg: return "ffmpeg"
}
}
}
@MainActor
final class MediaEngineManager: ObservableObject {
static let shared = MediaEngineManager()
@Published var ytDlpState: AddonState = .notInstalled
@Published var ffmpegState: AddonState = .notInstalled
private let configURL = URL(string: "https://nimbold.github.io/Firelink/firelink-addons.json")!
private var addonsDirectory: URL {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let bundleID = Bundle.main.bundleIdentifier ?? "com.firelink.app"
return appSupport.appendingPathComponent(bundleID).appendingPathComponent("Addons", isDirectory: true)
}
private init() {
checkLocalInstallation()
}
func binaryPath(for addon: AddonType) -> URL {
return addonsDirectory.appendingPathComponent(addon.binaryName)
}
func checkLocalInstallation() {
for addon in AddonType.allCases {
let path = binaryPath(for: addon)
if FileManager.default.isExecutableFile(atPath: path.path) {
if let version = UserDefaults.standard.string(forKey: addon.defaultsKey) {
setState(for: addon, to: .installed(version: version))
} else {
setState(for: addon, to: .installed(version: "Unknown"))
}
} else {
setState(for: addon, to: .notInstalled)
}
}
}
func fetchLatestConfig() async throws -> GatekeeperConfig {
var request = URLRequest(url: configURL)
request.cachePolicy = .reloadIgnoringLocalCacheData
request.timeoutInterval = 30
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode(GatekeeperConfig.self, from: data)
}
func ensureInstalled(addons requiredAddons: Set<AddonType> = Set(AddonType.allCases)) async throws {
let config = try await fetchLatestConfig()
try await withThrowingTaskGroup(of: Void.self) { group in
for addon in requiredAddons where shouldInstall(addon: addon, config: config) {
group.addTask {
try await self.install(addon: addon, from: config)
}
}
try await group.waitForAll()
}
}
func ensureAvailable(addons requiredAddons: Set<AddonType>) async throws {
checkLocalInstallation()
let missingAddons = requiredAddons.filter { addon in
switch state(for: addon) {
case .installed, .downloading:
return false
case .notInstalled, .failed:
return true
}
}
guard !missingAddons.isEmpty else { return }
try await ensureInstalled(addons: missingAddons)
}
private func shouldInstall(addon: AddonType, config: GatekeeperConfig) -> Bool {
let state: AddonState
let configVersion: String?
switch addon {
case .ytDlp:
state = ytDlpState
configVersion = config.ytDlp?.version
case .ffmpeg:
state = ffmpegState
configVersion = config.ffmpeg?.version
}
switch state {
case .notInstalled, .failed:
return true
case .downloading:
return false
case .installed(let version):
guard let configVersion else { return false }
return version != configVersion
}
}
private func state(for addon: AddonType) -> AddonState {
switch addon {
case .ytDlp: return ytDlpState
case .ffmpeg: return ffmpegState
}
}
func install(addon: AddonType, from config: GatekeeperConfig) async throws {
setState(for: addon, to: .downloading(progress: 0))
let addonConfig: AddonConfig? = {
switch addon {
case .ytDlp: return config.ytDlp
case .ffmpeg: return config.ffmpeg
}
}()
guard let addonConfig = addonConfig else {
setState(for: addon, to: .failed(error: "Missing configuration for \(addon.rawValue)"))
throw URLError(.badURL)
}
guard let downloadURL = addonConfig.currentArchURL else {
setState(for: addon, to: .failed(error: "No download URL for current architecture"))
throw URLError(.badURL)
}
guard downloadURL.scheme?.lowercased() == "https" else {
setState(for: addon, to: .failed(error: "Add-on URL must use HTTPS"))
throw URLError(.badURL)
}
do {
try FileManager.default.createDirectory(at: addonsDirectory, withIntermediateDirectories: true, attributes: nil)
let destination = binaryPath(for: addon)
try await BinaryDownloader.download(
from: downloadURL,
to: destination,
expectedSHA256: addonConfig.currentArchSHA256
) { progress in
Task { @MainActor in
self.setState(for: addon, to: .downloading(progress: progress))
}
}
UserDefaults.standard.set(addonConfig.version, forKey: addon.defaultsKey)
setState(for: addon, to: .installed(version: addonConfig.version))
} catch {
setState(for: addon, to: .failed(error: error.localizedDescription))
throw error
}
}
private func setState(for addon: AddonType, to state: AddonState) {
switch addon {
case .ytDlp: ytDlpState = state
case .ffmpeg: ffmpegState = state
}
}
}