Add Tonuino Manager website and Sparkle updates
This commit is contained in:
commit
1cac427ff2
44 files changed
+5861
No files matched your search
@@ -0,0 +1,133 @@
|
||||
import Foundation
|
||||
|
||||
struct YouTubeChapter: Decodable, Identifiable, Sendable {
|
||||
let start: Double
|
||||
let end: Double?
|
||||
let title: String?
|
||||
var id: Int { Int(start * 1000) }
|
||||
|
||||
enum CodingKeys: String, CodingKey { case title; case start = "start_time"; case end = "end_time" }
|
||||
}
|
||||
|
||||
struct YouTubeVideoInfo: Decodable, Sendable {
|
||||
let id: String
|
||||
let webpageURL: String?
|
||||
let title: String
|
||||
let uploader: String?
|
||||
let channel: String?
|
||||
let duration: Double?
|
||||
let chapters: [YouTubeChapter]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, title, uploader, channel, duration, chapters
|
||||
case webpageURL = "webpage_url"
|
||||
}
|
||||
|
||||
var artist: String { channel?.isEmpty == false ? channel! : (uploader ?? "") }
|
||||
var usableChapters: [YouTubeChapter] {
|
||||
guard let chapters else { return [] }
|
||||
return chapters.filter { $0.start.isFinite && $0.start >= 0 && ($0.end ?? duration ?? 0) > $0.start }
|
||||
}
|
||||
}
|
||||
|
||||
struct PreparedYouTubeImport: Sendable {
|
||||
let directory: URL
|
||||
let files: [URL]
|
||||
}
|
||||
|
||||
enum YouTubeImportService {
|
||||
static func inspect(_ rawURL: String) throws -> YouTubeVideoInfo {
|
||||
guard let url = validatedURL(rawURL) else { throw YouTubeImportError.invalidURL }
|
||||
guard let ytDLP = MediaToolLocator.executable(named: "yt-dlp") else {
|
||||
throw MediaToolError.missing("yt-dlp")
|
||||
}
|
||||
guard MediaToolLocator.executable(named: "deno") != nil else { throw MediaToolError.missing("Deno") }
|
||||
let arguments = ["--dump-single-json", "--skip-download", "--no-playlist", "--no-warnings", "--quiet", "--no-cache-dir"]
|
||||
+ MediaToolLocator.javascriptRuntimeArguments() + [url.absoluteString]
|
||||
let data = try MediaProcess.run(ytDLP, arguments: arguments)
|
||||
do { return try JSONDecoder().decode(YouTubeVideoInfo.self, from: data) }
|
||||
catch { throw YouTubeImportError.invalidVideoInfo(error.localizedDescription) }
|
||||
}
|
||||
|
||||
static func prepare(_ video: YouTubeVideoInfo, splitByChapters: Bool) throws -> PreparedYouTubeImport {
|
||||
guard let sourceURL = validatedURL(video.webpageURL ?? "https://www.youtube.com/watch?v=\(video.id)") else { throw YouTubeImportError.invalidURL }
|
||||
guard let ytDLP = MediaToolLocator.executable(named: "yt-dlp") else { throw MediaToolError.missing("yt-dlp") }
|
||||
guard let ffmpeg = MediaToolLocator.executable(named: "ffmpeg") else { throw MediaToolError.missing("FFmpeg") }
|
||||
guard MediaToolLocator.executable(named: "deno") != nil else { throw MediaToolError.missing("Deno") }
|
||||
let chapters = video.usableChapters
|
||||
guard !splitByChapters || !chapters.isEmpty else { throw YouTubeImportError.noChapters }
|
||||
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("tonuino-youtube-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)
|
||||
do {
|
||||
let outputTemplate = directory.appendingPathComponent("source.%(ext)s").path
|
||||
let downloadArguments = ["--format", "bestaudio/best", "--output", outputTemplate, "--no-playlist", "--no-warnings", "--no-progress", "--quiet", "--no-cache-dir"]
|
||||
+ MediaToolLocator.javascriptRuntimeArguments() + [sourceURL.absoluteString]
|
||||
_ = try MediaProcess.run(ytDLP, arguments: downloadArguments)
|
||||
|
||||
let sourceFiles = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])
|
||||
.filter { $0.lastPathComponent.hasPrefix("source.") && !$0.pathExtension.lowercased().hasSuffix("part") }
|
||||
guard let audioURL = sourceFiles.first else { throw YouTubeImportError.downloadOutputMissing }
|
||||
|
||||
let outputs: [URL]
|
||||
if splitByChapters {
|
||||
outputs = try chapters.enumerated().map { index, chapter in
|
||||
let end = chapter.end ?? video.duration ?? 0
|
||||
let length = end - chapter.start
|
||||
guard length > 0 else { throw YouTubeImportError.invalidChapter }
|
||||
let output = directory.appendingPathComponent(String(format: "chapter-%03d.mp3", index + 1))
|
||||
let displayTitle = chapter.title?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "Kapitel \(index + 1)"
|
||||
let args = [
|
||||
"-nostdin", "-hide_banner", "-loglevel", "error", "-y",
|
||||
"-ss", String(format: "%.3f", chapter.start), "-i", audioURL.path, "-t", String(format: "%.3f", length),
|
||||
"-map", "0:a:0", "-vn", "-codec:a", "libmp3lame", "-q:a", "2", "-id3v2_version", "3",
|
||||
"-metadata", "title=\(displayTitle)", "-metadata", "artist=\(video.artist)", "-metadata", "album=\(video.title)",
|
||||
"-metadata", "track=\(index + 1)/\(chapters.count)", output.path
|
||||
]
|
||||
_ = try MediaProcess.run(ffmpeg, arguments: args)
|
||||
return output
|
||||
}
|
||||
} else {
|
||||
let output = directory.appendingPathComponent("audio.mp3")
|
||||
let args = [
|
||||
"-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", audioURL.path,
|
||||
"-map", "0:a:0", "-vn", "-codec:a", "libmp3lame", "-q:a", "2", "-id3v2_version", "3",
|
||||
"-metadata", "title=\(video.title)", "-metadata", "artist=\(video.artist)", "-metadata", "album=\(video.title)", output.path
|
||||
]
|
||||
_ = try MediaProcess.run(ffmpeg, arguments: args)
|
||||
outputs = [output]
|
||||
}
|
||||
return PreparedYouTubeImport(directory: directory, files: outputs)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private static func validatedURL(_ raw: String) -> URL? {
|
||||
guard let components = URLComponents(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
components.scheme?.lowercased() == "https",
|
||||
let host = components.host?.lowercased(),
|
||||
["youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com", "youtu.be", "www.youtube-nocookie.com"].contains(host),
|
||||
let url = components.url else { return nil }
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nonEmpty: String? { isEmpty ? nil : self }
|
||||
}
|
||||
|
||||
enum YouTubeImportError: LocalizedError {
|
||||
case invalidURL, invalidVideoInfo(String), noChapters, invalidChapter, downloadOutputMissing
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL: "Bitte eine HTTPS-URL zu einem einzelnen YouTube-Video eingeben. Playlists werden nicht importiert."
|
||||
case .invalidVideoInfo(let detail): "Videoinformationen konnten nicht gelesen werden: \(detail)"
|
||||
case .noChapters: "Dieses Video enthält keine verwendbaren Kapitel. Importiere es als eine Datei."
|
||||
case .invalidChapter: "Mindestens ein Kapitel hat eine ungültige Zeitspanne."
|
||||
case .downloadOutputMissing: "yt-dlp hat keine Audiodatei erzeugt."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user