import Foundation enum PlayerCommand { case help case printDefaultConfig case launch(URL?, newInstance: Bool, noConfig: Bool, progressFile: URL?) case launchSource(sourceFile: URL, newInstance: Bool, noConfig: Bool, progressFile: URL?) static let usage = """ Usage: terebi [--new-instance] [--no-config] [FILE | STREAM_URL] terebi [--new-instance] [--no-config] --url STREAM_URL terebi --new-instance --url STREAM_URL --progress-file PATH terebi --new-instance --source-file PATH [--progress-file PATH] terebi [--new-instance] [--no-config] -- FILE terebi --help terebi --default-config Open one local file, YouTube video, Miruro episode or resolved source and start playback, resuming saved video progress. With no arguments, open or activate the player. Quote paths and URLs containing spaces or shell metacharacters. --new-instance Launch a separate player, leaving existing playback untouched. --no-config Use built-in settings instead of config.toml on a fresh launch. --url URL Open a YouTube video or Miruro episode URL. --source-file PATH Open a validated resolved HTTP(S) source in a new player. --progress-file PATH Write JSONL playback facts to a new private file; requires --new-instance. The GUI owns reporting after this CLI exits. -- Treat the following argument as a filename, even with a leading '-'. -h, --help Show this help without launching the player. --default-config Print the complete built-in TOML without launching. Reuses the running player unless --new-instance is supplied before the media arguments. The command exits once macOS accepts the launch; playback and extraction errors appear in the player window. """ static func parse(_ arguments: [String], directory: URL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)) throws -> PlayerCommand { var inputs = arguments var progressFile: URL? var index = 0 while index < inputs.count && inputs[index] != "--" { if inputs[index] != "--progress-file" { index += 1 continue } guard progressFile == nil else { throw ArgumentError("--progress-file may only be supplied once.") } guard index + 1 < inputs.count, !inputs[index + 1].isEmpty, !inputs[index + 1].contains("\0"), !inputs[index + 1].hasPrefix("--") else { throw ArgumentError("--progress-file requires a filesystem path.") } let base = directory.hasDirectoryPath ? directory : URL(fileURLWithPath: directory.path, isDirectory: true) progressFile = URL(fileURLWithPath: inputs[index + 1], relativeTo: base).absoluteURL.standardizedFileURL inputs.removeSubrange(index...index + 1) } var newInstance = false var noConfig = false while let option = inputs.first, option == "--new-instance" || option == "--no-config" { if option == "--new-instance" { guard !newInstance else { throw ArgumentError("--new-instance may only be supplied once.") } newInstance = true } else { guard !noConfig else { throw ArgumentError("--no-config may only be supplied once.") } noConfig = true } inputs.removeFirst() } func launch(_ url: URL?) throws -> PlayerCommand { guard progressFile == nil || newInstance else { throw ArgumentError("--progress-file requires --new-instance.") } return .launch(url, newInstance: newInstance, noConfig: noConfig, progressFile: progressFile) } func streamURL(_ text: String) throws -> URL { switch try StreamURL.parse(text) { case .youtube(let url): return url case .miruro: guard let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)) else { throw ArgumentError("Invalid stream URL.") } return url } } if inputs.isEmpty { return try launch(nil) } if inputs == ["--help"] || inputs == ["-h"] { guard progressFile == nil else { throw ArgumentError("--progress-file cannot be combined with --help.") } return .help } if inputs == ["--default-config"] { guard progressFile == nil else { throw ArgumentError("--progress-file cannot be combined with --default-config.") } return .printDefaultConfig } if inputs.first == "--source-file" { guard newInstance else { throw ArgumentError("--source-file requires --new-instance.") } guard inputs.count == 2, !inputs[1].isEmpty, !inputs[1].contains("\0"), !inputs[1].hasPrefix("--") else { throw ArgumentError("--source-file requires exactly one filesystem path.") } let base = directory.hasDirectoryPath ? directory : URL(fileURLWithPath: directory.path, isDirectory: true) let sourceFile = URL(fileURLWithPath: inputs[1], relativeTo: base).absoluteURL.standardizedFileURL _ = try ResolvedSource.load(from: sourceFile) return .launchSource(sourceFile: sourceFile, newInstance: newInstance, noConfig: noConfig, progressFile: progressFile) } if inputs.first == "--url" { guard inputs.count == 2 else { throw ArgumentError("--url requires exactly one stream URL.") } return try launch(streamURL(inputs[1])) } let filenameOnly = inputs.first == "--" if filenameOnly { inputs.removeFirst() } else if inputs.first?.hasPrefix("-") == true { throw ArgumentError("Unknown option. Use -- before filenames beginning with '-'.") } guard inputs.count == 1, let input = inputs.first, !input.isEmpty else { throw ArgumentError("Expected exactly one file or stream URL.") } let file: URL if !filenameOnly && input.contains("://") { guard let url = URL(string: input) else { throw ArgumentError("Invalid media URL.") } if url.isFileURL { guard url.host == nil || url.host == "" || url.host == "localhost" else { throw ArgumentError("File URLs must refer to this machine.") } file = url } else { return try launch(streamURL(input)) } } else { let base = directory.hasDirectoryPath ? directory : URL(fileURLWithPath: directory.path, isDirectory: true) file = URL(fileURLWithPath: input, relativeTo: base).absoluteURL } guard (try? file.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true, FileManager.default.isReadableFile(atPath: file.path) else { throw ArgumentError("File does not exist or is not a readable regular file.") } return try launch(file.standardizedFileURL) } private struct ArgumentError: LocalizedError { let message: String init(_ message: String) { self.message = message } var errorDescription: String? { message } } }