Files
Tonuino-Manager/Sources/TonuinoManager/Services/YouTubeImportService.swift
T

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