433 lines
14 KiB
Swift
433 lines
14 KiB
Swift
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)
|
||
}
|
||
}
|
||
}
|
||
}
|