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 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) } } } }