Compare commits

...

6 Commits

Author SHA1 Message Date
NimBold 32c6dcc2d6 chore(release): prepare 0.7.3 2026-06-11 18:26:41 +03:30
NimBold 79f4c8f0e9 fix: pad overall progress total for first track to prevent 100% snapback
- When yt-dlp underestimates the total bytes (which often excludes audio), the first track download would prematurely hit 100% and then snap back to a lower percentage when the second track started.
- Added a 20% padding to the overall expected size during the first file download to leave room for potential secondary tracks.
- Also added a safeguard to ensure the visually reported overall progress fraction never decreases during a download.
2026-06-11 14:38:29 +03:30
NimBold 2ab3325f30 feat: accumulate track sizes to present a unified overall progress bar
- Removed track-specific status messages in favor of a simpler generic message.
- Passed initial `sizeBytes` estimation into the progress parser.
- YTDLPProgressParser now accumulates sizes across multiple sequential downloads (like video then audio).
- The progress fraction and displayed size are correctly computed relative to the overall expected size, preventing the progress bar from resetting to 0% mid-download.
2026-06-11 14:31:29 +03:30
NimBold 4856b3f3b2 fix: emit distinct status messages for individual tracks during download
- yt-dlp downloads video and audio streams sequentially, causing the progress bar to reset when switching streams.
- The status message was previously hardcoded to 'Downloading Media' and overwrote track change indicators.
- Now, when yt-dlp starts a new stream, the status message clearly reads 'Downloading Video Track', 'Downloading Audio Track', etc., explaining the progress reset to the user.
2026-06-11 14:19:59 +03:30
NimBold 99500254a1 feat: enhance speed and ETA display logic during pause and drop
- Keep the ETA visible even when the download is paused so the user knows how much time is left.
- When the connection drops, show the last known speed for 3 seconds. If the connection isn't restored, show '0 KiB/s' instead of 'Unknown'.
- Clear speed and ETA fields when a download is completed, queued, or canceled.
2026-06-11 14:12:16 +03:30
NimBold aa9b3aad2a fix: resolve unknown speed flickering and ultra-wide high-resolution detection
- 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).
2026-06-11 14:04:57 +03:30
5 changed files with 173 additions and 22 deletions
+12
View File
@@ -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
+33 -7
View File
@@ -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"
}
+88 -11
View File
@@ -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
+36 -2
View File
@@ -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? {
+4 -2
View File
@@ -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 {