feat: add headless Zoko source checks

This commit is contained in:
2026-10-25 18:01:47 +01:00
parent e6a2759497
commit b95f7d251e
11 changed files with 329 additions and 12 deletions
+14
View File
@@ -3,6 +3,7 @@ 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)
@@ -15,6 +16,7 @@ enum PlayerCommand {
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
@@ -26,6 +28,8 @@ enum PlayerCommand {
--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;
@@ -42,6 +46,16 @@ enum PlayerCommand {
"""
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
+53
View File
@@ -0,0 +1,53 @@
import Foundation
// Deliberately separate from launch: no configuration, application, player or state APIs.
enum SourceCheck {
enum Status: String, Sendable, CaseIterable {
case available, unavailable, failed
var json: String {
"{\"version\":1,\"provider\":\"zokoanime\",\"track\":\"sub\",\"status\":\"\(rawValue)\"}"
}
}
static func run(
mediaID: String, episode: Int, timeout: Duration = .seconds(15),
resolve: @escaping @Sendable (String, Int) async throws -> ResolvedSource = {
try await ZokoResolver.resolve(mediaID: $0, episode: $1, enrichTitle: false)
},
validate: @escaping @Sendable (ResolvedSource) async throws -> Void = {
try await MiruroTransport.checkZokoRoot($0)
}
) async -> Status {
await withTaskGroup(of: Status.self) { group in
group.addTask {
let source: ResolvedSource
do {
try Task.checkCancellation()
source = try await resolve(mediaID, episode)
try Task.checkCancellation()
} catch ZokoError.unavailable {
return Task.isCancelled ? .failed : .unavailable
} catch {
return .failed
}
do {
try await validate(source)
try Task.checkCancellation()
return .available
} catch {
// A manifest 404, protection page or other resource failure is
// not evidence of the provider's explicit missing-episode page.
return .failed
}
}
group.addTask {
try? await Task.sleep(for: timeout)
return .failed
}
let result = await group.next() ?? .failed
group.cancelAll()
return result
}
}
}
+2
View File
@@ -17,6 +17,8 @@ enum TerebiCLI {
print(PlayerCommand.usage)
case .printDefaultConfig:
print(PlayerConfiguration.defaultTOML, terminator: "")
case .checkSource(let mediaID, let episode):
print(await SourceCheck.run(mediaID: mediaID, episode: episode).json)
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):