Files
terebi/Sources/CLI/PlayerCommand.swift
T

171 lines
9.1 KiB
Swift

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?)
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 [--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.
--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 {
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 }
}
}