import Foundation enum PlayerCommand { case help case printDefaultConfig case checkSource(mediaID: String, episode: Int) case launch(URL?, newInstance: Bool, noConfig: Bool, progressFile: URL?) case launchSource(sourceFile: URL, newInstance: Bool, noConfig: Bool, progressFile: URL?) case launchQueue(queueFile: URL, newInstance: Bool, noConfig: Bool) case enqueue(queueFile: URL, noConfig: Bool) 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] --queue-file PATH terebi --enqueue [--no-config] --queue-file PATH terebi --check-source ZOKO_ANILIST_SUB_URL terebi [--new-instance] [--no-config] -- FILE terebi --help terebi --default-config Open one local file, yt-dlp-supported video, Miruro/AniList 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 Resolve an HTTP(S) video, Miruro episode or ZokoAnime AniList sub URL. --check-source URL Read-only source/manifest check; one version-1 JSON result, no player launch, configuration, progress or resume writes. --source-file PATH Open a validated resolved HTTP(S) source in a new player. --queue-file PATH Open an ordered version-1 stream queue with per-item reports. --enqueue Append a queue to the running player without interrupting playback; launch and start its first item if no player is running. --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 { // Parse this isolated command before any launch flags, paths or file reads. if arguments.prefix(while: { $0 != "--" }).contains("--check-source") { let invalid = ArgumentError("--check-source requires exactly one canonical ZokoAnime AniList sub URL and no other options.") guard arguments.count == 2, arguments[0] == "--check-source", arguments[1].utf8.count <= 512, arguments[1] == arguments[1].trimmingCharacters(in: .whitespacesAndNewlines), let parsed = try? StreamURL.parse(arguments[1]), case .anilist(let mediaID, let episode) = parsed else { throw invalid } return .checkSource(mediaID: mediaID, episode: episode) } 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 var enqueue = false while let option = inputs.first, ["--new-instance", "--no-config", "--enqueue"].contains(option) { if option == "--new-instance" { guard !newInstance else { throw ArgumentError("--new-instance may only be supplied once.") } newInstance = true } else if option == "--enqueue" { guard !enqueue else { throw ArgumentError("--enqueue may only be supplied once.") } enqueue = true } else { guard !noConfig else { throw ArgumentError("--no-config may only be supplied once.") } noConfig = true } inputs.removeFirst() } guard !enqueue || (!newInstance && progressFile == nil && inputs.first == "--queue-file") else { throw ArgumentError("--enqueue requires --queue-file and cannot combine with --new-instance, --progress-file or other media.") } 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, .anilist: 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 == "--queue-file" { guard newInstance || enqueue, progressFile == nil, inputs.count == 2, inputs[1].hasPrefix("/"), !inputs[1].contains("\0") else { throw ArgumentError("--queue-file requires --new-instance or --enqueue and one absolute manifest path; it cannot be combined with --progress-file.") } let queueFile = URL(fileURLWithPath: inputs[1]).standardizedFileURL _ = try StreamQueue.load(from: queueFile) if enqueue { return .enqueue(queueFile: queueFile, noConfig: noConfig) } return .launchQueue(queueFile: queueFile, newInstance: newInstance, noConfig: noConfig) } 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 } } }