mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-28 13:00:10 +00:00
aa9b3aad2a
- Ignore 'Unknown' or '-' speed/ETA updates if a valid speed is already known to prevent UI flickering. - Improve high-resolution video detection by checking 'format_note', parsing the 'resolution' string, and using more relaxed height tolerances to correctly identify ultra-wide formats (e.g., 2160p with a 1920 height).
600 lines
23 KiB
Swift
600 lines
23 KiB
Swift
import Foundation
|
|
|
|
struct RawMediaFormat: Decodable, Sendable, Equatable {
|
|
let format_id: String?
|
|
let ext: String?
|
|
let resolution: String?
|
|
let format_note: String?
|
|
let vcodec: String?
|
|
let acodec: String?
|
|
let height: Int?
|
|
let filesize: Int64?
|
|
let filesize_approx: Int64?
|
|
}
|
|
|
|
struct MediaMetadata: Decodable, Sendable, Equatable {
|
|
let id: String?
|
|
let title: String?
|
|
let uploader: String?
|
|
let channel: String?
|
|
let thumbnail: URL?
|
|
let duration: Double?
|
|
let formats: [RawMediaFormat]?
|
|
|
|
var displayUploader: String? {
|
|
channel ?? uploader
|
|
}
|
|
}
|
|
|
|
enum MediaType: String, Sendable, Equatable {
|
|
case video = "Video"
|
|
case audio = "Audio"
|
|
}
|
|
|
|
struct CleanFormatOption: Identifiable, Equatable, Sendable {
|
|
var id: String { name }
|
|
let name: String
|
|
let formatSelector: String
|
|
let isAudioOnly: Bool
|
|
let symbol: String
|
|
let outputExtension: String
|
|
let detail: String
|
|
let estimatedBytes: Int64?
|
|
|
|
let mediaType: MediaType
|
|
let qualityName: String
|
|
let containerName: String
|
|
}
|
|
|
|
enum MediaExtractionEngine {
|
|
private final class CacheEntry {
|
|
let metadata: MediaMetadata
|
|
let options: [CleanFormatOption]
|
|
let date: Date
|
|
init(metadata: MediaMetadata, options: [CleanFormatOption], date: Date) {
|
|
self.metadata = metadata
|
|
self.options = options
|
|
self.date = date
|
|
}
|
|
}
|
|
nonisolated(unsafe) private static let metadataCache = NSCache<NSURL, CacheEntry>()
|
|
|
|
private static let metadataTimeoutSeconds: UInt64 = 120
|
|
|
|
enum ExtractionError: Error, LocalizedError {
|
|
case processFailed(String)
|
|
case invalidOutput
|
|
case parsingFailed(Error)
|
|
case timedOut
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .processFailed(let msg): return "Extraction failed: \(msg)"
|
|
case .invalidOutput: return "Invalid output from media engine."
|
|
case .parsingFailed(let err): return "Failed to parse metadata: \(err.localizedDescription)"
|
|
case .timedOut: return "Fetching metadata timed out. Try again or change the selected browser cookie source."
|
|
}
|
|
}
|
|
}
|
|
|
|
static func fetchMetadata(
|
|
for url: URL,
|
|
cookieSource: BrowserCookieSource,
|
|
credentials: DownloadCredentials?,
|
|
transferOptions: DownloadTransferOptions,
|
|
proxyConfiguration: DownloadProxyConfiguration
|
|
) async throws -> (MediaMetadata, [CleanFormatOption]) {
|
|
if let cached = metadataCache.object(forKey: url as NSURL), Date().timeIntervalSince(cached.date) < 300 {
|
|
return (cached.metadata, cached.options)
|
|
}
|
|
guard let ytDlpURL = await MediaEngineManager.shared.preparedBinaryPath(for: .ytDlp),
|
|
FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
|
|
throw ExtractionError.processFailed("yt-dlp binary not found.")
|
|
}
|
|
let ytDlpPath = ytDlpURL.path
|
|
|
|
var args = [
|
|
"-J",
|
|
"--no-warnings",
|
|
"--no-playlist",
|
|
"--no-check-formats",
|
|
"--socket-timeout", "20",
|
|
"--retries", "3",
|
|
"--extractor-retries", "3",
|
|
"--compat-options", "no-youtube-unavailable-videos"
|
|
]
|
|
|
|
if let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg),
|
|
FileManager.default.isExecutableFile(atPath: ffmpegURL.path) {
|
|
args.append(contentsOf: ["--ffmpeg-location", ffmpegURL.path])
|
|
}
|
|
|
|
if let proxyURI = proxyConfiguration.customProxyURI, proxyConfiguration.mode == .custom {
|
|
args.append(contentsOf: ["--proxy", proxyURI])
|
|
}
|
|
|
|
let tempConfigDir = appendCommonArguments(
|
|
to: &args,
|
|
cookieSource: cookieSource,
|
|
credentials: credentials,
|
|
transferOptions: transferOptions,
|
|
preferredDenoURL: cachedDenoURL(near: ytDlpURL)
|
|
)
|
|
defer {
|
|
if let tempConfigDir {
|
|
try? FileManager.default.removeItem(at: tempConfigDir)
|
|
}
|
|
}
|
|
args.append(url.absoluteString)
|
|
|
|
let data = try await YTDLPMetadataProcess(
|
|
executableURL: URL(fileURLWithPath: ytDlpPath),
|
|
arguments: args
|
|
).run(timeoutSeconds: metadataTimeoutSeconds)
|
|
|
|
guard !data.isEmpty else {
|
|
throw ExtractionError.invalidOutput
|
|
}
|
|
|
|
do {
|
|
let metadata = try JSONDecoder().decode(MediaMetadata.self, from: data)
|
|
let options = extractOptions(from: metadata)
|
|
metadataCache.setObject(CacheEntry(metadata: metadata, options: options, date: Date()), forKey: url as NSURL)
|
|
return (metadata, options)
|
|
} catch {
|
|
throw ExtractionError.parsingFailed(error)
|
|
}
|
|
}
|
|
|
|
static func appendCommonArguments(
|
|
to args: inout [String],
|
|
cookieSource: BrowserCookieSource,
|
|
credentials: DownloadCredentials?,
|
|
transferOptions: DownloadTransferOptions,
|
|
preferredDenoURL: URL? = nil
|
|
) -> URL? {
|
|
if let browserName = cookieSource.ytDlpBrowserName {
|
|
args.append(contentsOf: ["--cookies-from-browser", browserName])
|
|
}
|
|
|
|
appendJavaScriptRuntimeArguments(to: &args, preferredDenoURL: preferredDenoURL)
|
|
|
|
for header in transferOptions.requestHeaders.map(\.normalized) where !header.isEmpty {
|
|
args.append(contentsOf: ["--add-header", header.headerLine])
|
|
}
|
|
|
|
if let cookieHeader = transferOptions.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!cookieHeader.isEmpty {
|
|
args.append(contentsOf: ["--add-header", "Cookie: \(cookieHeader)"])
|
|
}
|
|
|
|
var tempConfigDir: URL?
|
|
if let credentials, !credentials.isEmpty {
|
|
let configContent = "--username \"\(credentials.username)\"\n--password \"\(credentials.password)\"\n"
|
|
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("firelink-yt-dlp-\(UUID().uuidString)")
|
|
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
|
let fileURL = tempDir.appendingPathComponent("yt-dlp.conf")
|
|
try? configContent.write(to: fileURL, atomically: true, encoding: .utf8)
|
|
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path)
|
|
args.append(contentsOf: ["--config-locations", fileURL.path])
|
|
tempConfigDir = tempDir
|
|
}
|
|
return tempConfigDir
|
|
}
|
|
|
|
private static func appendJavaScriptRuntimeArguments(
|
|
to args: inout [String],
|
|
preferredDenoURL: URL?
|
|
) {
|
|
var runtimes: [String] = []
|
|
if let denoPath = executablePath(at: preferredDenoURL) ??
|
|
bundledExecutablePath(named: "deno") ??
|
|
executablePath(named: "deno", candidates: [
|
|
"/opt/homebrew/bin/deno",
|
|
"/usr/local/bin/deno"
|
|
]) {
|
|
runtimes.append("deno:\(denoPath)")
|
|
}
|
|
|
|
if let nodePath = executablePath(named: "node", candidates: [
|
|
"/opt/homebrew/bin/node",
|
|
"/usr/local/bin/node",
|
|
"/usr/bin/node"
|
|
]) {
|
|
runtimes.append("node:\(nodePath)")
|
|
}
|
|
|
|
if !runtimes.isEmpty {
|
|
args.append(contentsOf: ["--js-runtimes", runtimes.joined(separator: ",")])
|
|
}
|
|
}
|
|
|
|
private static func cachedDenoURL(near ytDlpURL: URL) -> URL? {
|
|
let denoURL = ytDlpURL.deletingLastPathComponent().appendingPathComponent("deno")
|
|
return FileManager.default.isExecutableFile(atPath: denoURL.path) ? denoURL : nil
|
|
}
|
|
|
|
private static func executablePath(at url: URL?) -> String? {
|
|
guard let url, FileManager.default.isExecutableFile(atPath: url.path) else {
|
|
return nil
|
|
}
|
|
return url.path
|
|
}
|
|
|
|
private static func bundledExecutablePath(named name: String) -> String? {
|
|
if let bundled = Bundle.main.url(forResource: name, withExtension: nil),
|
|
FileManager.default.isExecutableFile(atPath: bundled.path) {
|
|
return bundled.path
|
|
}
|
|
|
|
if Bundle.main.bundleURL.pathExtension.lowercased() != "app" {
|
|
#if SWIFT_PACKAGE
|
|
if let bundled = Bundle.module.url(forResource: name, withExtension: nil),
|
|
FileManager.default.isExecutableFile(atPath: bundled.path) {
|
|
return bundled.path
|
|
}
|
|
#endif
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private static func executablePath(named name: String, candidates: [String]) -> String? {
|
|
var safeCandidates = candidates
|
|
safeCandidates.append(contentsOf: [
|
|
"/opt/homebrew/bin/\(name)",
|
|
"/usr/local/bin/\(name)",
|
|
"/usr/bin/\(name)",
|
|
"/opt/local/bin/\(name)"
|
|
])
|
|
|
|
for candidate in safeCandidates {
|
|
if FileManager.default.isExecutableFile(atPath: candidate) {
|
|
return candidate
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func extractOptions(from metadata: MediaMetadata) -> [CleanFormatOption] {
|
|
var options: [CleanFormatOption] = []
|
|
let rawFormats = metadata.formats ?? []
|
|
|
|
let standardResolutions = [
|
|
(2160, "4K"),
|
|
(1440, "1440p"),
|
|
(1080, "1080p"),
|
|
(720, "720p"),
|
|
(480, "480p"),
|
|
(360, "360p")
|
|
]
|
|
|
|
let availableResolutions = standardResolutions.filter { resolution, _ in
|
|
rawFormats.contains { format in
|
|
isVideo(format) && matchesHeight(format, height: resolution)
|
|
}
|
|
}
|
|
let videoQualities = [(nil as Int?, "Best")] + availableResolutions.map { (Optional($0.0), $0.1) }
|
|
let videoContainers = [
|
|
("mp4", "MP4"),
|
|
("mkv", "MKV"),
|
|
("webm", "WebM")
|
|
]
|
|
|
|
for (height, qualityName) in videoQualities {
|
|
for (container, containerName) in videoContainers {
|
|
guard hasVideoFormat(rawFormats, height: height, container: container) else { continue }
|
|
let estimatedBytes = estimatedVideoBytes(rawFormats, height: height, container: container)
|
|
options.append(CleanFormatOption(
|
|
name: "\(qualityName) \(containerName)",
|
|
formatSelector: videoSelector(height: height, container: container),
|
|
isAudioOnly: false,
|
|
symbol: "play.tv.fill",
|
|
outputExtension: container,
|
|
detail: optionDetail(
|
|
base: height == nil ? "Best available video" : "Up to \(qualityName)",
|
|
estimatedBytes: estimatedBytes
|
|
),
|
|
estimatedBytes: estimatedBytes,
|
|
mediaType: .video,
|
|
qualityName: qualityName,
|
|
containerName: containerName
|
|
))
|
|
}
|
|
}
|
|
|
|
if hasAudioFormat(rawFormats, preferredExtension: nil) {
|
|
let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: nil)
|
|
options.append(CleanFormatOption(
|
|
name: "Audio MP3",
|
|
formatSelector: "bestaudio/best",
|
|
isAudioOnly: true,
|
|
symbol: "music.note",
|
|
outputExtension: "mp3",
|
|
detail: optionDetail(base: "Converted with ffmpeg", estimatedBytes: estimatedBytes),
|
|
estimatedBytes: estimatedBytes,
|
|
mediaType: .audio,
|
|
qualityName: "Best",
|
|
containerName: "MP3"
|
|
))
|
|
}
|
|
|
|
if hasAudioFormat(rawFormats, preferredExtension: "m4a") {
|
|
let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: "m4a")
|
|
options.append(CleanFormatOption(
|
|
name: "Audio M4A",
|
|
formatSelector: "bestaudio[ext=m4a]/bestaudio/best",
|
|
isAudioOnly: true,
|
|
symbol: "waveform",
|
|
outputExtension: "m4a",
|
|
detail: optionDetail(base: "Prefer native M4A", estimatedBytes: estimatedBytes),
|
|
estimatedBytes: estimatedBytes,
|
|
mediaType: .audio,
|
|
qualityName: "Best",
|
|
containerName: "M4A"
|
|
))
|
|
}
|
|
|
|
if hasAudioFormat(rawFormats, preferredExtension: "webm") {
|
|
let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: "webm")
|
|
options.append(CleanFormatOption(
|
|
name: "Audio Opus",
|
|
formatSelector: "bestaudio[ext=webm]/bestaudio/best",
|
|
isAudioOnly: true,
|
|
symbol: "waveform",
|
|
outputExtension: "opus",
|
|
detail: optionDetail(base: "Efficient audio", estimatedBytes: estimatedBytes),
|
|
estimatedBytes: estimatedBytes,
|
|
mediaType: .audio,
|
|
qualityName: "Best",
|
|
containerName: "Opus"
|
|
))
|
|
}
|
|
|
|
return options
|
|
}
|
|
|
|
private static func hasVideoFormat(_ formats: [RawMediaFormat], height: Int?, container: String) -> Bool {
|
|
formats.contains { format in
|
|
guard isVideo(format), matchesHeight(format, height: height) else { return false }
|
|
return container == "mkv" || format.ext?.caseInsensitiveCompare(container) == .orderedSame
|
|
}
|
|
}
|
|
|
|
private static func hasAudioFormat(_ formats: [RawMediaFormat], preferredExtension: String?) -> Bool {
|
|
formats.contains { format in
|
|
guard isAudio(format) else { return false }
|
|
guard let preferredExtension else { return true }
|
|
return format.ext?.caseInsensitiveCompare(preferredExtension) == .orderedSame
|
|
}
|
|
}
|
|
|
|
private static func estimatedVideoBytes(_ formats: [RawMediaFormat], height: Int?, container: String) -> Int64? {
|
|
let videoBytes = formats
|
|
.filter { format in
|
|
guard isVideo(format), matchesHeight(format, height: height) else { return false }
|
|
return container == "mkv" || format.ext?.caseInsensitiveCompare(container) == .orderedSame
|
|
}
|
|
.compactMap { formatSize($0) }
|
|
.max()
|
|
|
|
guard let videoBytes else { return nil }
|
|
let audioBytes = estimatedAudioBytes(formats, preferredExtension: container == "webm" ? "webm" : "m4a") ??
|
|
estimatedAudioBytes(formats, preferredExtension: nil) ??
|
|
0
|
|
return videoBytes + audioBytes
|
|
}
|
|
|
|
private static func estimatedAudioBytes(_ formats: [RawMediaFormat], preferredExtension: String?) -> Int64? {
|
|
let preferred = formats
|
|
.filter { format in
|
|
guard isAudio(format) else { return false }
|
|
guard let preferredExtension else { return true }
|
|
return format.ext?.caseInsensitiveCompare(preferredExtension) == .orderedSame
|
|
}
|
|
.compactMap { formatSize($0) }
|
|
.max()
|
|
|
|
if preferred != nil || preferredExtension == nil {
|
|
return preferred
|
|
}
|
|
|
|
return estimatedAudioBytes(formats, preferredExtension: nil)
|
|
}
|
|
|
|
private static func isVideo(_ format: RawMediaFormat) -> Bool {
|
|
guard let vcodec = format.vcodec?.lowercased(), vcodec != "none" else { return false }
|
|
return true
|
|
}
|
|
|
|
private static func isAudio(_ format: RawMediaFormat) -> Bool {
|
|
let acodec = format.acodec?.lowercased()
|
|
let vcodec = format.vcodec?.lowercased()
|
|
return acodec != nil && acodec != "none" && (vcodec == nil || vcodec == "none")
|
|
}
|
|
|
|
private static func matchesHeight(_ format: RawMediaFormat, height: Int?) -> Bool {
|
|
guard let height else { return true }
|
|
|
|
if let note = format.format_note {
|
|
if height == 2160 && (note.contains("2160p") || note.contains("4K") || note.contains("4k")) { return true }
|
|
if height == 1440 && note.contains("1440p") { return true }
|
|
if height == 1080 && note.contains("1080p") { return true }
|
|
if height == 720 && note.contains("720p") { return true }
|
|
if height == 480 && note.contains("480p") { return true }
|
|
if height == 360 && note.contains("360p") { return true }
|
|
}
|
|
|
|
if let res = format.resolution {
|
|
let parts = res.split(separator: "x").compactMap { Int($0) }
|
|
if parts.count == 2 {
|
|
let maxDim = max(parts[0], parts[1])
|
|
switch height {
|
|
case 2160: return maxDim >= 3800
|
|
case 1440: return maxDim >= 2500 && maxDim < 3800
|
|
case 1080: return maxDim >= 1900 && maxDim < 2500
|
|
case 720: return maxDim >= 1200 && maxDim < 1900
|
|
case 480: return maxDim >= 800 && maxDim < 1200
|
|
case 360: return maxDim >= 600 && maxDim < 800
|
|
default: break
|
|
}
|
|
}
|
|
}
|
|
|
|
guard let formatHeight = format.height else { return false }
|
|
|
|
let tolerance: Int
|
|
if height >= 2160 { tolerance = 600 }
|
|
else if height >= 1440 { tolerance = 400 }
|
|
else if height >= 1080 { tolerance = 300 }
|
|
else if height >= 720 { tolerance = 200 }
|
|
else { tolerance = 100 }
|
|
|
|
return formatHeight <= height && formatHeight >= height - tolerance
|
|
}
|
|
|
|
private static func formatSize(_ format: RawMediaFormat) -> Int64? {
|
|
format.filesize ?? format.filesize_approx
|
|
}
|
|
|
|
private static func optionDetail(base: String, estimatedBytes: Int64?) -> String {
|
|
guard let estimatedBytes, estimatedBytes > 0 else { return base }
|
|
return "\(base) - ~\(ByteFormatter.string(estimatedBytes))"
|
|
}
|
|
|
|
private static func videoSelector(height: Int?, container: String) -> String {
|
|
let filter = heightFilter(height)
|
|
switch container {
|
|
case "mp4":
|
|
return "bestvideo\(filter)[ext=mp4]+bestaudio[ext=m4a]/best\(filter)[ext=mp4]/bestvideo\(filter)+bestaudio/best\(filter)"
|
|
case "webm":
|
|
return "bestvideo\(filter)[ext=webm]+bestaudio[ext=webm]/best\(filter)[ext=webm]/bestvideo\(filter)+bestaudio/best\(filter)"
|
|
default:
|
|
return "bestvideo\(filter)+bestaudio/best\(filter)"
|
|
}
|
|
}
|
|
|
|
private static func heightFilter(_ height: Int?) -> String {
|
|
guard let height else { return "" }
|
|
return "[height<=\(height)]"
|
|
}
|
|
}
|
|
|
|
private final class YTDLPMetadataProcess: @unchecked Sendable {
|
|
private let executableURL: URL
|
|
private let arguments: [String]
|
|
private let lock = NSLock()
|
|
private var process: Process?
|
|
|
|
init(executableURL: URL, arguments: [String]) {
|
|
self.executableURL = executableURL
|
|
self.arguments = arguments
|
|
}
|
|
|
|
func run(timeoutSeconds: UInt64) async throws -> Data {
|
|
try await withTaskCancellationHandler {
|
|
try await withThrowingTaskGroup(of: Data.self) { group in
|
|
group.addTask {
|
|
try await self.runProcess()
|
|
}
|
|
group.addTask {
|
|
try await Task.sleep(for: .seconds(timeoutSeconds))
|
|
self.terminate()
|
|
throw MediaExtractionEngine.ExtractionError.timedOut
|
|
}
|
|
|
|
guard let result = try await group.next() else {
|
|
throw MediaExtractionEngine.ExtractionError.invalidOutput
|
|
}
|
|
group.cancelAll()
|
|
return result
|
|
}
|
|
} onCancel: {
|
|
self.terminate()
|
|
}
|
|
}
|
|
|
|
private func runProcess() async throws -> Data {
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
let process = Process()
|
|
let outputPipe = Pipe()
|
|
let errorPipe = Pipe()
|
|
let outputBuffer = LockedDataBuffer(maxBytes: 64 * 1024 * 1024)
|
|
let errorBuffer = LockedDataBuffer()
|
|
|
|
process.executableURL = executableURL
|
|
process.arguments = arguments
|
|
process.standardOutput = outputPipe
|
|
process.standardError = errorPipe
|
|
process.standardInput = nil
|
|
|
|
outputPipe.fileHandleForReading.readabilityHandler = { handle in
|
|
let data = handle.availableData
|
|
if data.isEmpty {
|
|
handle.readabilityHandler = nil
|
|
} else {
|
|
outputBuffer.append(data)
|
|
}
|
|
}
|
|
|
|
errorPipe.fileHandleForReading.readabilityHandler = { handle in
|
|
let data = handle.availableData
|
|
if data.isEmpty {
|
|
handle.readabilityHandler = nil
|
|
} else {
|
|
errorBuffer.append(data)
|
|
}
|
|
}
|
|
|
|
lock.withLock {
|
|
self.process = process
|
|
}
|
|
|
|
process.terminationHandler = { finishedProcess in
|
|
// Allow a brief moment for final pipe data to flush
|
|
DispatchQueue.global().asyncAfter(deadline: .now() + 0.25) {
|
|
outputPipe.fileHandleForReading.readabilityHandler = nil
|
|
errorPipe.fileHandleForReading.readabilityHandler = nil
|
|
if finishedProcess.terminationStatus == 0 {
|
|
continuation.resume(returning: outputBuffer.data)
|
|
} else {
|
|
let stderr = String(data: errorBuffer.data, encoding: .utf8)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let stdout = String(data: outputBuffer.data, encoding: .utf8)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let message = [stderr, stdout]
|
|
.compactMap { $0 }
|
|
.filter { !$0.isEmpty }
|
|
.joined(separator: "\n")
|
|
continuation.resume(
|
|
throwing: MediaExtractionEngine.ExtractionError.processFailed(
|
|
message.isEmpty ? "Exit code \(finishedProcess.terminationStatus)" : message
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
do {
|
|
try process.run()
|
|
if Task.isCancelled {
|
|
self.terminate()
|
|
}
|
|
outputPipe.fileHandleForWriting.closeFile()
|
|
errorPipe.fileHandleForWriting.closeFile()
|
|
} catch {
|
|
outputPipe.fileHandleForReading.readabilityHandler = nil
|
|
errorPipe.fileHandleForReading.readabilityHandler = nil
|
|
// We do not care about the DispatchGroup if we throw immediately here
|
|
continuation.resume(throwing: MediaExtractionEngine.ExtractionError.processFailed(error.localizedDescription))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func terminate() {
|
|
let p = lock.withLock { self.process }
|
|
guard let p, p.isRunning else { return }
|
|
ProcessTreeTerminator.terminate(p)
|
|
}
|
|
}
|