Files
terebi/Sources/CLI/PlayerCommand.swift
T

171 lines
9.0 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 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 URL with yt-dlp, or open a Miruro episode.
--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:
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 }
}
}