mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 12:29:29 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32c6dcc2d6 | |||
| 79f4c8f0e9 | |||
| 2ab3325f30 | |||
| 4856b3f3b2 | |||
| 99500254a1 | |||
| aa9b3aad2a |
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.7.3] - 2026-06-11
|
||||
|
||||
### New Features & Improvements
|
||||
- Add Deno to about credits and engines list.
|
||||
- Enhance speed and ETA display logic during pause and drop.
|
||||
- Accumulate track sizes to present a unified overall progress bar.
|
||||
|
||||
### Fixes
|
||||
- Resolve unknown speed flickering and ultra-wide high-resolution detection.
|
||||
- Emit distinct status messages for individual tracks during download.
|
||||
- Pad overall progress total for first track to prevent 100% snapback.
|
||||
|
||||
## [0.7.2] - 2026-06-11
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -31,6 +31,7 @@ final class DownloadController: ObservableObject {
|
||||
private var pendingNotifications: [(title: String, body: String)] = []
|
||||
private var notificationDebounceTask: Task<Void, Never>?
|
||||
private var lastProgressUpdateTimes: [UUID: Date] = [:]
|
||||
private var speedUnknownSince: [UUID: Date] = [:]
|
||||
|
||||
init(settings: AppSettings) {
|
||||
self.settings = settings
|
||||
@@ -554,12 +555,23 @@ final class DownloadController: ObservableObject {
|
||||
guard $0.status == .downloading else { return }
|
||||
$0.progress = progress.fraction
|
||||
$0.bytesText = progress.bytesText
|
||||
$0.speedText = progress.speedText
|
||||
$0.etaText = progress.etaText
|
||||
$0.connectionCount = progress.connectionCount
|
||||
if $0.message == "Starting" {
|
||||
$0.message = "Downloading Media"
|
||||
let isUnknown = progress.speedText.lowercased() == "unknown" || progress.speedText == "-"
|
||||
if !isUnknown || $0.speedText == "-" || $0.speedText.lowercased() == "unknown" {
|
||||
$0.speedText = progress.speedText
|
||||
self?.speedUnknownSince[item.id] = nil
|
||||
} else {
|
||||
if self?.speedUnknownSince[item.id] == nil {
|
||||
self?.speedUnknownSince[item.id] = Date()
|
||||
}
|
||||
if let since = self?.speedUnknownSince[item.id], Date().timeIntervalSince(since) > 3.0 {
|
||||
$0.speedText = "0 KiB/s"
|
||||
}
|
||||
}
|
||||
let isEtaUnknown = progress.etaText.lowercased() == "unknown" || progress.etaText == "-"
|
||||
if !isEtaUnknown || $0.etaText == "-" || $0.etaText.lowercased() == "unknown" {
|
||||
$0.etaText = progress.etaText
|
||||
}
|
||||
$0.connectionCount = progress.connectionCount
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -612,8 +624,22 @@ final class DownloadController: ObservableObject {
|
||||
guard $0.status == .downloading else { return }
|
||||
$0.progress = progress.fraction
|
||||
$0.bytesText = progress.bytesText
|
||||
$0.speedText = progress.speedText
|
||||
$0.etaText = progress.etaText
|
||||
let isUnknown = progress.speedText.lowercased() == "unknown" || progress.speedText == "-"
|
||||
if !isUnknown || $0.speedText == "-" || $0.speedText.lowercased() == "unknown" {
|
||||
$0.speedText = progress.speedText
|
||||
self?.speedUnknownSince[item.id] = nil
|
||||
} else {
|
||||
if self?.speedUnknownSince[item.id] == nil {
|
||||
self?.speedUnknownSince[item.id] = Date()
|
||||
}
|
||||
if let since = self?.speedUnknownSince[item.id], Date().timeIntervalSince(since) > 3.0 {
|
||||
$0.speedText = "0 KiB/s"
|
||||
}
|
||||
}
|
||||
let isEtaUnknown = progress.etaText.lowercased() == "unknown" || progress.etaText == "-"
|
||||
if !isEtaUnknown || $0.etaText == "-" || $0.etaText.lowercased() == "unknown" {
|
||||
$0.etaText = progress.etaText
|
||||
}
|
||||
$0.connectionCount = progress.connectionCount
|
||||
$0.message = "Downloading"
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
||||
process.standardOutput = outputPipe
|
||||
process.standardError = errorPipe
|
||||
|
||||
let parser = YTDLPProgressParser()
|
||||
let parser = YTDLPProgressParser(totalExpectedBytes: item.sizeBytes)
|
||||
let errorBuffer = LockedDataBuffer()
|
||||
let outputPathTracker = YTDLPOutputPathTracker()
|
||||
let completionGate = CompletionGate(completion)
|
||||
@@ -263,6 +263,7 @@ final class YTDLPOutputHandler: @unchecked Sendable {
|
||||
private let outputPathTracker: YTDLPOutputPathTracker
|
||||
private let progress: @Sendable (DownloadProgress) -> Void
|
||||
private let messageUpdate: @Sendable (String) -> Void
|
||||
private var trackCount = 0
|
||||
|
||||
init(
|
||||
parser: YTDLPProgressParser,
|
||||
@@ -280,18 +281,17 @@ final class YTDLPOutputHandler: @unchecked Sendable {
|
||||
for line in text.split(whereSeparator: \.isNewline) {
|
||||
let stringLine = String(line)
|
||||
outputPathTracker.observe(stringLine)
|
||||
if let update = parser.parse(stringLine) {
|
||||
progress(update)
|
||||
messageUpdate("Downloading Media")
|
||||
} else if let message = statusMessage(for: stringLine) {
|
||||
if let message = statusMessage(for: stringLine) {
|
||||
messageUpdate(message)
|
||||
} else if let update = parser.parse(stringLine) {
|
||||
progress(update)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func statusMessage(for line: String) -> String? {
|
||||
if line.contains("[Merger]") || line.contains("[ExtractAudio]") || line.contains("[Fixup") {
|
||||
return "Processing Media..."
|
||||
return "Merging Media Tracks..."
|
||||
}
|
||||
if line.contains("[youtube]") && line.localizedCaseInsensitiveContains("Downloading") {
|
||||
return "Fetching YouTube data..."
|
||||
@@ -309,7 +309,7 @@ final class YTDLPOutputHandler: @unchecked Sendable {
|
||||
return "YouTube challenge solver unavailable"
|
||||
}
|
||||
if line.contains("Destination:") {
|
||||
return "Starting media download..."
|
||||
return "Downloading Media"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -382,6 +382,77 @@ final class YTDLPProgressParser: @unchecked Sendable {
|
||||
private let etaRegex = try? NSRegularExpression(pattern: #"ETA\s+([^\s]+)"#)
|
||||
private let sizeRegex = try? NSRegularExpression(pattern: #"of\s+~?([0-9.]+[a-zA-Z]+)"#)
|
||||
|
||||
private let totalExpectedBytes: Int64?
|
||||
private var accumulatedBytes: Int64 = 0
|
||||
private var currentFileBytes: Int64 = 0
|
||||
private var lastFraction: Double = 0
|
||||
private var fileIndex: Int = 0
|
||||
private var lastReportedOverallFraction: Double = 0
|
||||
|
||||
init(totalExpectedBytes: Int64?) {
|
||||
self.totalExpectedBytes = totalExpectedBytes
|
||||
}
|
||||
|
||||
private func processCumulativeProgress(
|
||||
fraction: Double,
|
||||
parsedSize: Int64,
|
||||
sizeStr: String
|
||||
) -> (overallFraction: Double, displaySizeStr: String) {
|
||||
if fraction < lastFraction && lastFraction > 0.95 {
|
||||
accumulatedBytes += currentFileBytes
|
||||
fileIndex += 1
|
||||
}
|
||||
|
||||
currentFileBytes = parsedSize
|
||||
lastFraction = fraction
|
||||
|
||||
let totalDownloadedBytes = accumulatedBytes + Int64(Double(parsedSize) * fraction)
|
||||
var overallTotalBytes = max(totalExpectedBytes ?? 0, accumulatedBytes + parsedSize)
|
||||
|
||||
// If we are on the first file, and its size is taking up almost all of totalExpectedBytes,
|
||||
// we pad overallTotalBytes by 20% to leave room for a potential audio track.
|
||||
// This prevents the progress bar from prematurely hitting 100%.
|
||||
if fileIndex == 0 {
|
||||
let paddedSize = Int64(Double(parsedSize) * 1.2)
|
||||
overallTotalBytes = max(overallTotalBytes, paddedSize)
|
||||
}
|
||||
|
||||
var overallFraction = fraction
|
||||
var displaySizeStr = sizeStr
|
||||
|
||||
if overallTotalBytes > 0 {
|
||||
overallFraction = Double(totalDownloadedBytes) / Double(overallTotalBytes)
|
||||
displaySizeStr = ByteFormatter.string(overallTotalBytes)
|
||||
}
|
||||
|
||||
// Ensure overallFraction never decreases visually
|
||||
overallFraction = max(lastReportedOverallFraction, overallFraction)
|
||||
lastReportedOverallFraction = overallFraction
|
||||
|
||||
return (overallFraction, displaySizeStr)
|
||||
}
|
||||
|
||||
private func parseBytes(_ sizeStr: String) -> Int64 {
|
||||
let clean = sizeStr.replacingOccurrences(of: "~", with: "").trimmingCharacters(in: .whitespaces)
|
||||
guard let regex = try? NSRegularExpression(pattern: #"^([0-9.]+)([a-zA-Z]+)$"#) else { return 0 }
|
||||
let nsString = clean as NSString
|
||||
guard let match = regex.firstMatch(in: clean, range: NSRange(location: 0, length: clean.count)),
|
||||
match.numberOfRanges == 3 else { return 0 }
|
||||
|
||||
let numStr = nsString.substring(with: match.range(at: 1))
|
||||
let unitStr = nsString.substring(with: match.range(at: 2)).lowercased()
|
||||
|
||||
guard let value = Double(numStr) else { return 0 }
|
||||
|
||||
switch unitStr {
|
||||
case "b": return Int64(value)
|
||||
case "k", "kb", "kib": return Int64(value * 1024)
|
||||
case "m", "mb", "mib": return Int64(value * 1024 * 1024)
|
||||
case "g", "gb", "gib": return Int64(value * 1024 * 1024 * 1024)
|
||||
default: return Int64(value)
|
||||
}
|
||||
}
|
||||
|
||||
func parse(_ line: String) -> DownloadProgress? {
|
||||
if line.contains("[download]") && line.contains("%") {
|
||||
let fraction = (Double(firstCapture(in: line, regex: percentageRegex) ?? "0") ?? 0) / 100.0
|
||||
@@ -389,9 +460,12 @@ final class YTDLPProgressParser: @unchecked Sendable {
|
||||
let eta = firstCapture(in: line, regex: etaRegex) ?? "-"
|
||||
let size = firstCapture(in: line, regex: sizeRegex) ?? "-"
|
||||
|
||||
let parsedSize = parseBytes(size)
|
||||
let cumulative = processCumulativeProgress(fraction: fraction, parsedSize: parsedSize, sizeStr: size)
|
||||
|
||||
return DownloadProgress(
|
||||
fraction: min(max(fraction, 0), 1),
|
||||
bytesText: size,
|
||||
fraction: min(max(cumulative.overallFraction, 0), 1),
|
||||
bytesText: cumulative.displaySizeStr,
|
||||
speedText: speed,
|
||||
etaText: eta,
|
||||
connectionCount: 1
|
||||
@@ -403,9 +477,12 @@ final class YTDLPProgressParser: @unchecked Sendable {
|
||||
let size = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"/([^\s\(]+)\("#)) ?? "-"
|
||||
let cn = Int(firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"CN:(\d+)"#)) ?? "1") ?? 1
|
||||
|
||||
let parsedSize = parseBytes(size)
|
||||
let cumulative = processCumulativeProgress(fraction: fraction, parsedSize: parsedSize, sizeStr: size)
|
||||
|
||||
return DownloadProgress(
|
||||
fraction: min(max(fraction, 0), 1),
|
||||
bytesText: size,
|
||||
fraction: min(max(cumulative.overallFraction, 0), 1),
|
||||
bytesText: cumulative.displaySizeStr,
|
||||
speedText: speed,
|
||||
etaText: eta,
|
||||
connectionCount: cn
|
||||
|
||||
@@ -271,7 +271,7 @@ enum MediaExtractionEngine {
|
||||
|
||||
let availableResolutions = standardResolutions.filter { resolution, _ in
|
||||
rawFormats.contains { format in
|
||||
isVideo(format) && (format.height ?? 0) > 0 && (format.height ?? 0) <= resolution && (format.height ?? 0) >= resolution - 100
|
||||
isVideo(format) && matchesHeight(format, height: resolution)
|
||||
}
|
||||
}
|
||||
let videoQualities = [(nil as Int?, "Best")] + availableResolutions.map { (Optional($0.0), $0.1) }
|
||||
@@ -415,8 +415,42 @@ enum MediaExtractionEngine {
|
||||
|
||||
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 }
|
||||
return formatHeight <= height && formatHeight >= height - 100
|
||||
|
||||
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? {
|
||||
|
||||
@@ -199,11 +199,13 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
|
||||
}
|
||||
|
||||
var displaySpeedText: String {
|
||||
status == .downloading ? speedText : "-"
|
||||
if status == .completed || status == .paused || status == .queued || status == .canceled { return "-" }
|
||||
return speedText
|
||||
}
|
||||
|
||||
var displayETAText: String {
|
||||
status == .downloading ? etaText : "-"
|
||||
if status == .completed || status == .queued || status == .canceled { return "-" }
|
||||
return etaText
|
||||
}
|
||||
|
||||
var destinationPath: String {
|
||||
|
||||
Reference in New Issue
Block a user