134 lines
7.0 KiB
Swift
134 lines
7.0 KiB
Swift
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."
|
|
}
|
|
}
|
|
}
|