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

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 KiB

View File

@@ -0,0 +1,59 @@
{
"images" : [
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "ChatGPT Image 10 янв. 2026 г., 20_55_42.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

128
ContentView.swift Normal file
View File

@@ -0,0 +1,128 @@
import SwiftUI
struct ContentView: View {
@StateObject private var vm = DownloadViewModel()
var body: some View {
VStack(spacing: 16) {
header
GroupBox("Download") {
VStack(alignment: .leading, spacing: 10) {
HStack {
TextField("YouTube video or playlist URL", text: $vm.urlString)
.textFieldStyle(.roundedBorder)
Button("Paste") {
if let s = NSPasteboard.general.string(forType: .string) {
vm.urlString = s.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
}
HStack(spacing: 16) {
Toggle("Audio only (MP3)", isOn: $vm.audioOnly)
Picker("Quality", selection: $vm.quality) {
ForEach(VideoQuality.allCases, id: \.self) { q in
Text(q.title).tag(q)
}
}
.pickerStyle(.menu)
Spacer()
}
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Save to:")
.font(.caption)
.foregroundStyle(.secondary)
Text(vm.destinationPathDisplay)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer()
Button("Choose Folder…") { vm.pickFolder() }
}
Divider()
HStack(spacing: 12) {
Button(vm.isDownloading ? "Downloading…" : "Start") {
vm.start()
}
.disabled(!vm.canStart)
Button("Cancel") {
vm.cancel()
}
.disabled(!vm.isDownloading)
Spacer()
}
}
.padding(8)
}
GroupBox("Progress") {
VStack(alignment: .leading, spacing: 10) {
ProgressView(value: vm.progress)
.progressViewStyle(.linear)
HStack {
Text(vm.statusLine)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(2)
Spacer()
if vm.isDownloading {
Text(vm.percentText)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
}
}
.padding(8)
}
GroupBox("Log") {
ScrollView {
Text(vm.log)
.font(.system(.caption, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
.padding(.vertical, 6)
}
.frame(height: 140)
.padding(.horizontal, 8)
}
}
.padding(18)
.alert("Error", isPresented: $vm.showError) {
Button("OK", role: .cancel) { }
} message: {
Text(vm.errorMessage)
}
}
private var header: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("YoutubeDumper")
.font(.title2.bold())
Text("Uses Homebrew yt-dlp. Supports videos, playlists, and audio-only.")
.foregroundStyle(.secondary)
}
Spacer()
Button("Check yt-dlp") {
vm.checkYtDlp()
}
}
}
}

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

5
Info.plist Normal file
View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>

0
README.md Normal file
View File

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<key>com.apple.security.temporary-exception.files.absolute-path.read-only</key>
<array>
<string>/opt/homebrew/opt/yt-dlp/bin/yt-dlp</string>
<string>/usr/local/bin/yt-dlp</string>
</array>
</dict>
</plist>

11
YoutubeDumperApp.swift Normal file
View File

@@ -0,0 +1,11 @@
import SwiftUI
@main
struct YoutubeDumperApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.frame(minWidth: 720, minHeight: 420)
}
}
}