first commit

This commit is contained in:
Victor Fedorov
2026-01-20 19:35:38 +07:00
commit fd180ccd2e
10 changed files with 669 additions and 0 deletions

432
DownloadViewModel.swift Normal file
View File

@@ -0,0 +1,432 @@
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)
}
}
}
}