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." } } }