feat: play resolved Miruro HLS with external VTT and stable episode progress

This commit is contained in:
2026-10-12 19:17:22 +02:00
parent 1f12660936
commit 470d8feec2
23 changed files with 1528 additions and 38 deletions
+18 -2
View File
@@ -4,22 +4,25 @@ 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 | YOUTUBE_URL]
terebi [--new-instance] [--no-config] --url YOUTUBE_URL
terebi --new-instance --url YOUTUBE_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 media file or YouTube video and start playback, resuming saved
video progress when available. With no arguments, open or activate the player.
Open one local file, YouTube video 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 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 '-'.
@@ -77,6 +80,19 @@ enum PlayerCommand {
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 YouTube URL.") }
return try launch(YouTubeResolver.validateURL(inputs[1]))
+10 -1
View File
@@ -18,6 +18,13 @@ enum TerebiCLI {
case .printDefaultConfig:
print(PlayerConfiguration.defaultTOML, terminator: "")
case .launch(let url, let newInstance, let noConfig, let progressFile):
await launch(url: url, sourceFile: nil, newInstance: newInstance, noConfig: noConfig, progressFile: progressFile)
case .launchSource(let sourceFile, let newInstance, let noConfig, let progressFile):
await launch(url: nil, sourceFile: sourceFile, newInstance: newInstance, noConfig: noConfig, progressFile: progressFile)
}
}
private static func launch(url: URL?, sourceFile: URL?, newInstance: Bool, noConfig: Bool, progressFile: URL?) async {
if let progressFile {
var existing = stat()
guard lstat(progressFile.path, &existing) != 0 else {
@@ -62,6 +69,9 @@ enum TerebiCLI {
if let progressFile {
configuration.arguments += ["--progress-file", progressFile.path]
}
if let sourceFile {
configuration.arguments += ["--source-file", sourceFile.path]
}
if newInstance, let url {
configuration.arguments += ["--open-media", url.absoluteString]
}
@@ -75,7 +85,6 @@ enum TerebiCLI {
fail("Could not launch the player: \(error.localizedDescription)", code: 69)
}
}
}
private static func fail(_ message: String, code: Int32) -> Never {
FileHandle.standardError.write(Data("terebi: \(message)\n".utf8))