Files
YoutubeDumper/DownloadViewModel.swift
Victor Fedorov fd180ccd2e first commit
2026-01-20 19:35:38 +07:00

433 lines
14 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import AppKit
import SwiftUI
import Combine
enum VideoQuality: String, CaseIterable {
case best
case p1080
case p720
case p480
var title: String {
switch self {
case .best: return "Best"
case .p1080: return "1080p"
case .p720: return "720p"
case .p480: return "480p"
}
}
/// yt-dlp -f format selector
var formatSelector: String {
switch self {
case .best:
return "bv*+ba/b"
case .p1080:
return "bv*[height<=1080]+ba/b[height<=1080]"
case .p720:
return "bv*[height<=720]+ba/b[height<=720]"
case .p480:
return "bv*[height<=480]+ba/b[height<=480]"
}
}
}
@MainActor
final class DownloadViewModel: ObservableObject {
@Published var urlString: String = ""
@Published var audioOnly: Bool = false
@Published var quality: VideoQuality = .best
@Published var progress: Double = 0
@Published var statusLine: String = "Idle"
@Published var log: String = ""
@Published var isDownloading: Bool = false
@Published var showError: Bool = false
@Published var errorMessage: String = ""
private let ytDlpBookmarkKey = "YoutubeDumper.ytDlpBookmark"
private(set) var ytDlpURL: URL?
// security-scoped destination folder
private(set) var destinationURL: URL? {
didSet { updateDestinationDisplay() }
}
@Published var destinationPathDisplay: String = "Not selected"
private var task: Process?
private var stdoutPipe: Pipe?
private var stderrPipe: Pipe?
private let bookmarkKey = "YoutubeDumper.destinationFolderBookmark"
var canStart: Bool {
!isDownloading &&
!urlString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
destinationURL != nil
}
var percentText: String {
"\(Int(progress * 100))%"
}
init() {
destinationURL = restoreSecurityScopedBookmark(key: bookmarkKey)
ytDlpURL = restoreSecurityScopedBookmark(key: ytDlpBookmarkKey)
updateDestinationDisplay()
}
func pickYtDlpBinary() {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.prompt = "Choose"
panel.message = "Select yt-dlp executable (usually /opt/homebrew/bin/yt-dlp)."
if panel.runModal() == .OK, let url = panel.url {
saveSecurityScopedBookmark(for: url, key: ytDlpBookmarkKey)
ytDlpURL = url
appendLog("yt-dlp выбран: \(url.path)\n")
statusLine = "yt-dlp выбран"
}
}
private func saveSecurityScopedBookmark(for url: URL, key: String) {
do {
let data = try url.bookmarkData(
options: [.withSecurityScope],
includingResourceValuesForKeys: nil,
relativeTo: nil
)
UserDefaults.standard.set(data, forKey: key)
} catch {
presentError("Failed to save permission: \(error.localizedDescription)")
}
}
private func restoreSecurityScopedBookmark(key: String) -> URL? {
guard let data = UserDefaults.standard.data(forKey: key) else { return nil }
var stale = false
do {
let url = try URL(
resolvingBookmarkData: data,
options: [.withSecurityScope],
relativeTo: nil,
bookmarkDataIsStale: &stale
)
if stale {
saveSecurityScopedBookmark(for: url, key: key)
}
return url
} catch {
return nil
}
}
func pickFolder() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.prompt = "Choose"
panel.message = "Select the folder where downloads will be saved."
if panel.runModal() == .OK, let url = panel.url {
saveSecurityScopedBookmark(for: url)
destinationURL = url
appendLog("Destination: \(url.path)\n")
}
}
func checkYtDlp() {
do {
let path = try resolveYtDlpURL()
appendLog("yt-dlp found at: \(path)\n")
statusLine = "yt-dlp OK"
} catch {
presentError(error.localizedDescription)
}
}
func start() {
guard !isDownloading else { return }
guard let destinationURL else {
presentError("Please choose a destination folder.")
return
}
let url = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
guard url.lowercased().hasPrefix("http") else {
presentError("Please enter a valid URL.")
return
}
isDownloading = true
progress = 0
statusLine = "Starting…"
appendLog("Starting download: \(url)\n")
Task {
do {
let ytDlpURL = try await resolveYtDlpURL()
try runYtDlp(url: url, destination: destinationURL)
} catch {
let err = error.localizedDescription
finishWithError(error.localizedDescription)
}
}
}
func cancel() {
guard isDownloading else { return }
appendLog("Cancelling…\n")
task?.terminate()
}
// MARK: - yt-dlp
/// Prefer /opt/homebrew/bin/yt-dlp (Apple Silicon) and /usr/local/bin/yt-dlp (Intel),
/// otherwise fallback to /bin/zsh -lc "command -v yt-dlp".
private func resolveYtDlpURL() throws -> URL {
// 1) если уже выбран пользователем
if let url = ytDlpURL {
return url
}
// 2) пробуем стандартные пути (но в sandbox чаще всё равно не запустится без выбора)
let candidates = [
URL(fileURLWithPath: "/opt/homebrew/bin/yt-dlp"),
URL(fileURLWithPath: "/usr/local/bin/yt-dlp")
]
for url in candidates {
if FileManager.default.fileExists(atPath: url.path) {
// Сохраним как подсказку, но запуск может упасть без прав поэтому всё равно лучше выбрать через панель
return url
}
}
throw NSError(domain: "YoutubeDumper", code: 1, userInfo: [
NSLocalizedDescriptionKey: "yt-dlp не найден. Установи brew install yt-dlp или выбери бинарь через 'Choose yt-dlp…'"
])
}
private func runYtDlp(url: String, destination: URL) throws {
let ytDlp = try resolveYtDlpURL()
// ВАЖНО: sandbox доступ
let ytOK = ytDlp.startAccessingSecurityScopedResource()
let destOK = destination.startAccessingSecurityScopedResource()
defer {
if ytOK { ytDlp.stopAccessingSecurityScopedResource() }
if destOK { destination.stopAccessingSecurityScopedResource() }
}
// Разрешим симлинки (чтобы реально запускать конечный файл)
let ytDlpResolved = ytDlp.resolvingSymlinksInPath()
// security scoped access
let accessOK = destination.startAccessingSecurityScopedResource()
defer {
if accessOK { destination.stopAccessingSecurityScopedResource() }
}
let process = Process()
process.executableURL = ytDlpResolved
process.currentDirectoryURL = destination
// Output template
let outputTemplate = "%(title).200B [%(id)s].%(ext)s"
// Stable progress template:
// PROGRESS <percent> <downloaded_bytes> <total_bytes> <speed> <eta>
let progressTemplate = "PROGRESS %(progress._percent_str)s %(progress.downloaded_bytes)s %(progress.total_bytes)s %(progress.speed)s %(progress.eta)s"
var args: [String] = []
args += ["--newline"]
args += ["--progress-template", progressTemplate]
args += ["-o", outputTemplate]
if audioOnly {
args += ["-x", "--audio-format", "mp3"]
args += ["-f", "ba/b"]
} else {
args += ["-f", quality.formatSelector]
args += ["--merge-output-format", "mkv"]
let ffmpegCandidates = ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg"]
if let ffmpegPath = ffmpegCandidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) {
args += ["--ffmpeg-location", ffmpegPath]
}
}
args += ["--no-part"]
args += ["--no-continue", "--force-overwrites"]
args += ["--retries", "10", "--fragment-retries", "10"]
args += [url]
process.arguments = args
stdoutPipe = Pipe()
stderrPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
task = process
stdoutPipe?.fileHandleForReading.readabilityHandler = { [weak self] h in
let data = h.availableData
guard !data.isEmpty, let line = String(data: data, encoding: .utf8) else { return }
Task { @MainActor in self?.handleOutput(line) }
}
stderrPipe?.fileHandleForReading.readabilityHandler = { [weak self] h in
let data = h.availableData
guard !data.isEmpty, let line = String(data: data, encoding: .utf8) else { return }
Task { @MainActor in
self?.appendLog(line)
self?.statusLine = "Working…"
}
}
process.terminationHandler = { [weak self] proc in
Task { @MainActor in
self?.stdoutPipe?.fileHandleForReading.readabilityHandler = nil
self?.stderrPipe?.fileHandleForReading.readabilityHandler = nil
let code = proc.terminationStatus
if code == 0 {
self?.appendLog("\nDone.\n")
self?.statusLine = "Done"
self?.progress = 1.0
self?.isDownloading = false
} else {
self?.finishWithError("yt-dlp exited with code \(code). See log.")
}
self?.task = nil
}
}
try process.run()
statusLine = "Downloading…"
}
private func handleOutput(_ chunk: String) {
let lines = chunk.split(whereSeparator: \.isNewline).map(String.init)
for line in lines {
if line.hasPrefix("PROGRESS ") {
parseProgressLine(line)
} else {
appendLog(line + "\n")
}
}
}
private func parseProgressLine(_ line: String) {
let tokens = line.split(separator: " ").map(String.init).filter { !$0.isEmpty }
guard tokens.count >= 2 else { return }
let percentStr = tokens[1].replacingOccurrences(of: "%", with: "")
if let p = Double(percentStr) {
progress = max(0, min(1, p / 100.0))
statusLine = "Downloading… \(Int(p))%"
} else {
statusLine = "Downloading…"
}
}
// MARK: - Bookmark persistence
private func saveSecurityScopedBookmark(for url: URL) {
do {
let data = try url.bookmarkData(options: [.withSecurityScope],
includingResourceValuesForKeys: nil,
relativeTo: nil)
UserDefaults.standard.set(data, forKey: bookmarkKey)
} catch {
presentError("Failed to save folder permission: \(error.localizedDescription)")
}
}
private func restoreDestinationFromBookmark() {
guard let data = UserDefaults.standard.data(forKey: bookmarkKey) else { return }
var stale = false
do {
let url = try URL(resolvingBookmarkData: data,
options: [.withSecurityScope],
relativeTo: nil,
bookmarkDataIsStale: &stale)
if stale {
saveSecurityScopedBookmark(for: url)
}
destinationURL = url
} catch {
// ignore, user can pick again
}
}
private func updateDestinationDisplay() {
destinationPathDisplay = destinationURL?.path ?? "Not selected"
}
// MARK: - Helpers
private func appendLog(_ s: String) {
log += s
if log.count > 80_000 {
log = String(log.suffix(60_000))
}
}
private func presentError(_ message: String) {
errorMessage = message
showError = true
appendLog("ERROR: \(message)\n")
statusLine = "Error"
}
private func finishWithError(_ message: String) {
isDownloading = false
progress = 0
presentError(message)
task = nil
}
private func runAndCapture(_ executable: String, _ args: [String]) async throws -> String {
try await withCheckedThrowingContinuation { cont in
let p = Process()
p.executableURL = URL(fileURLWithPath: executable)
p.arguments = args
let pipe = Pipe()
p.standardOutput = pipe
p.standardError = Pipe()
p.terminationHandler = { proc in
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let out = String(data: data, encoding: .utf8) ?? ""
if proc.terminationStatus == 0 {
cont.resume(returning: out)
} else {
cont.resume(throwing: NSError(domain: "YoutubeDumper", code: Int(proc.terminationStatus), userInfo: [
NSLocalizedDescriptionKey: "Command failed: \(executable) \(args.joined(separator: " "))"
]))
}
}
do {
try p.run()
} catch {
cont.resume(throwing: error)
}
}
}
}