Compare commits

..

2 Commits

17 changed files with 554 additions and 104 deletions
+2 -2
View File
@@ -191,7 +191,7 @@ private enum WindowDragCheck {
model.showsQueue = true model.showsQueue = true
case 13: case 13:
model.showsQueue = false model.showsQueue = false
model.openYouTube("not a video URL") model.openStream("not a video URL")
case 15: model.open(fixtures.appendingPathComponent("audio.wav")) case 15: model.open(fixtures.appendingPathComponent("audio.wav"))
case 16: model.open(fixtures.appendingPathComponent("cache.mp4")) case 16: model.open(fixtures.appendingPathComponent("cache.mp4"))
case 19: model.hideControls(hideCursor: false) case 19: model.hideControls(hideCursor: false)
@@ -787,7 +787,7 @@ private enum CLIInstanceCheck {
if let action = command["action"] as? String { if let action = command["action"] as? String {
switch action { switch action {
case "youtube": case "youtube":
model.openYouTube(command["url"] as! String) model.openStream(command["url"] as! String)
case "queue": case "queue":
model.showsQueue = command["visible"] as! Bool model.showsQueue = command["visible"] as! Bool
model.revealControls() model.revealControls()
+7 -7
View File
@@ -59,9 +59,9 @@ struct AutomaticQualityPlaybackChecks {
let normalFrame = window.frame let normalFrame = window.frame
let compactKey = try! KeyChord("p") let compactKey = try! KeyChord("p")
mark("gate-open") mark("gate-open")
model.openYouTube("https://www.youtube.com/watch?v=aqz-KE-bpKQ") model.openStream("https://www.youtube.com/watch?v=aqz-KE-bpKQ")
until(model, "initial YouTube resolution reaches the controlled extraction gate") { until(model, "initial YouTube resolution reaches the controlled extraction gate") {
model.resolvingYouTube && marked("entered-open") model.resolvingStream && marked("entered-open")
} }
model.handleKey(compactKey) model.handleKey(compactKey)
require(model.isCompact, "compact shortcut enters while the YouTube URL is unresolved") require(model.isCompact, "compact shortcut enters while the YouTube URL is unresolved")
@@ -74,7 +74,7 @@ struct AutomaticQualityPlaybackChecks {
until(model, "compact shortcut restores while YouTube resolution is still pending") { until(model, "compact shortcut restores while YouTube resolution is still pending") {
!model.isCompact && !model.compactWindow.isTransitioning !model.isCompact && !model.compactWindow.isTransitioning
} }
require(window.frame == normalFrame && model.resolvingYouTube, require(window.frame == normalFrame && model.resolvingStream,
"resolution continues while restoring the original normal window") "resolution continues while restoring the original normal window")
require(model.perform(.enterCompact), "compact control action is available during resolution") require(model.perform(.enterCompact), "compact control action is available during resolution")
mark("release-open") mark("release-open")
@@ -98,7 +98,7 @@ struct AutomaticQualityPlaybackChecks {
} }
clock.now = 2000 clock.now = 2000
polled(model) polled(model)
require(model.youtubeHeight == 1080 && !model.resolvingYouTube, require(model.youtubeHeight == 1080 && !model.resolvingStream,
"disabled automatic quality leaves a stalled manual/default stream alone") "disabled automatic quality leaves a stalled manual/default stream alone")
model.setAutomaticYouTubeQuality(true) model.setAutomaticYouTubeQuality(true)
polled(model) polled(model)
@@ -146,12 +146,12 @@ struct AutomaticQualityPlaybackChecks {
} }
clock.now = 3060 clock.now = 3060
until(model, "automatic upshift reaches the selected extraction gate") { until(model, "automatic upshift reaches the selected extraction gate") {
marked("entered-1080") && model.resolvingYouTube marked("entered-1080") && model.resolvingStream
} }
require(model.setPlayback(playing: false), "pause is accepted during automatic extraction") require(model.setPlayback(playing: false), "pause is accepted during automatic extraction")
mark("release-1080") mark("release-1080")
until(model, "cancelled extraction finishes and native pause is acknowledged") { until(model, "cancelled extraction finishes and native pause is acknowledged") {
marked("finished-1080") && !model.resolvingYouTube && model.canTogglePlayback && marked("finished-1080") && !model.resolvingStream && model.canTogglePlayback &&
model.state.status == PLAYER_PAUSED && !model.state.seek_pending model.state.status == PLAYER_PAUSED && !model.state.seek_pending
} }
let cancelledPosition = model.state.position let cancelledPosition = model.state.position
@@ -179,7 +179,7 @@ struct AutomaticQualityPlaybackChecks {
let originalPosition = model.state.position let originalPosition = model.state.position
model.setYouTubeQuality(1080) model.setYouTubeQuality(1080)
until(model, "failed manual quality switch restores the original native playback state") { until(model, "failed manual quality switch restores the original native playback state") {
model.errorText != nil && !model.resolvingYouTube && model.canTogglePlayback && model.errorText != nil && !model.resolvingStream && model.canTogglePlayback &&
model.state.status == (playing ? PLAYER_PLAYING : PLAYER_PAUSED) && model.state.status == (playing ? PLAYER_PLAYING : PLAYER_PAUSED) &&
(!playing || model.state.position > originalPosition + 0.1) (!playing || model.state.position > originalPosition + 0.1)
} }
+6
View File
@@ -38,6 +38,12 @@ struct ConfigurationChecks {
static func checkBindings() throws { static func checkBindings() throws {
let bindings = KeyBindings() let bindings = KeyBindings()
try require(PlayerAction(name: "open_stream") == .openStream && PlayerAction.openStream.name == "open_stream",
"stream action uses the general config name")
try require(PlayerAction(name: "open_youtube") == nil, "obsolete YouTube action has no alias")
try require(bindings.shortcuts(for: .openStream) == [try KeyChord("cmd+shift+o")],
"stream action preserves its shortcut")
try requireError(["open_youtube"]) { _ = try KeyBindings(overrides: ["open_youtube": []]) }
let inversePairs: [(PlayerAction, PlayerAction)] = [ let inversePairs: [(PlayerAction, PlayerAction)] = [
(.play, .pause), (.mute, .unmute), (.enterFullscreen, .exitFullscreen), (.play, .pause), (.mute, .unmute), (.enterFullscreen, .exitFullscreen),
(.enterCompact, .exitCompact), (.enableAlwaysOnTop, .disableAlwaysOnTop), (.enterCompact, .exitCompact), (.enableAlwaysOnTop, .disableAlwaysOnTop),
+3
View File
@@ -33,6 +33,9 @@ struct PlayerCommandChecks {
require(try opened([colonFile.path]) == colonFile, "colons in filesystem paths are not URL schemes") require(try opened([colonFile.path]) == colonFile, "colons in filesystem paths are not URL schemes")
let canonical = URL(string: "https://www.youtube.com/watch?v=jNQXAC9IVRw")! let canonical = URL(string: "https://www.youtube.com/watch?v=jNQXAC9IVRw")!
require(try opened(["https://youtu.be/jNQXAC9IVRw"]) == canonical, "YouTube positional URLs canonicalize for resume identity") require(try opened(["https://youtu.be/jNQXAC9IVRw"]) == canonical, "YouTube positional URLs canonicalize for resume identity")
let miruro = URL(string: "https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/the-apothecary-diaries-season-3?ep=1")!
require(try opened([miruro.absoluteString]) == miruro, "Miruro positional URLs retain episode page identity")
require(try opened(["--url", miruro.absoluteString]) == miruro, "explicit Miruro URLs forward without network resolution")
require(try opened(["--new-instance"], newInstance: true) == nil, "an independent launch can omit media") require(try opened(["--new-instance"], newInstance: true) == nil, "an independent launch can omit media")
require(try opened(["--new-instance", movie.lastPathComponent], newInstance: true) == movie, require(try opened(["--new-instance", movie.lastPathComponent], newInstance: true) == movie,
"independent local launches preserve relative paths and spaces") "independent local launches preserve relative paths and spaces")
+19 -9
View File
@@ -6,13 +6,13 @@ struct ResolvedPlaybackCheck {
static func require(_ condition: @autoclosure () -> Bool, _ message: String) { static func require(_ condition: @autoclosure () -> Bool, _ message: String) {
if !condition() { fatalError("FAIL: " + message) } if !condition() { fatalError("FAIL: " + message) }
} }
static func until(_ message: String, model: PlayerModel, _ predicate: () -> Bool) { static func until(_ message: String, model: PlayerModel, _ predicate: () -> Bool) async throws {
let deadline = ContinuousClock.now.advanced(by: .seconds(45)) let deadline = ContinuousClock.now.advanced(by: .seconds(45))
while !predicate(), ContinuousClock.now < deadline { while !predicate(), ContinuousClock.now < deadline {
if model.state.status == PLAYER_ERROR || model.commandError != nil { if model.state.status == PLAYER_ERROR || model.commandError != nil {
fatalError("FAIL: " + (model.errorText ?? "Playback failed")) fatalError("FAIL: " + (model.errorText ?? "Playback failed"))
} }
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.01)) try await Task.sleep(for: .milliseconds(10))
} }
require(predicate(), message) require(predicate(), message)
} }
@@ -24,21 +24,31 @@ struct ResolvedPlaybackCheck {
static func run() async throws { static func run() async throws {
_ = NSApplication.shared _ = NSApplication.shared
NSApp.setActivationPolicy(.prohibited) NSApp.setActivationPolicy(.prohibited)
let file = URL(fileURLWithPath: CommandLine.arguments[1]) let input = CommandLine.arguments[1]
let source = try ResolvedSource.load(from: file) let file = URL(fileURLWithPath: input)
let source: ResolvedSource
if input.hasPrefix("https://") {
guard case .miruro(let mediaID, let episode) = try StreamURL.parse(input) else {
fatalError("FAIL: URL smoke requires a Miruro episode")
}
source = try await MiruroResolver.resolve(mediaID: mediaID, episode: episode)
} else {
source = try ResolvedSource.load(from: file)
}
let suite = "terebi.resolved-check." + UUID().uuidString let suite = "terebi.resolved-check." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)! let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) } defer { defaults.removePersistentDomain(forName: suite) }
let model = PlayerModel(defaults: defaults, progressFile: CommandLine.arguments.count > 2 ? CommandLine.arguments[2] : nil) let model = PlayerModel(defaults: defaults, progressFile: CommandLine.arguments.count > 2 ? CommandLine.arguments[2] : nil)
defer { model.shutdown() } defer { model.shutdown() }
model.openResolvedSource(file) if input.hasPrefix("https://") { model.openStream(input) }
until("video/audio decode and advancing playback", model: model) { else { model.openResolvedSource(file) }
try await until("video/audio decode and advancing playback", model: model) {
model.state.status == PLAYER_PLAYING && model.state.has_video && model.state.has_audio && model.state.position > 0.2 model.state.status == PLAYER_PLAYING && model.state.has_video && model.state.has_audio && model.state.position > 0.2
} }
print("PASS: resolved HLS decodes video/audio and advances") print("PASS: resolved HLS decodes video/audio and advances")
fflush(stdout) fflush(stdout)
require(model.setPlayback(playing: false), "pause accepted") require(model.setPlayback(playing: false), "pause accepted")
until("pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED } try await until("pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
require(model.state.can_seek && model.state.duration > 0, "seekable measured duration") require(model.state.can_seek && model.state.duration > 0, "seekable measured duration")
guard let index = source.subtitles.firstIndex(where: \.isDefault) else { guard let index = source.subtitles.firstIndex(where: \.isDefault) else {
fatalError("FAIL: smoke requires a default subtitle") fatalError("FAIL: smoke requires a default subtitle")
@@ -50,14 +60,14 @@ struct ResolvedPlaybackCheck {
let target = playableCues[1].start + min(0.1, (playableCues[1].end - playableCues[1].start) / 2) let target = playableCues[1].start + min(0.1, (playableCues[1].end - playableCues[1].start) / 2)
require(!document.text(at: target).isEmpty, "forward seek targets a visible English cue") require(!document.text(at: target).isEmpty, "forward seek targets a visible English cue")
require(model.seekRelative(target - model.state.position), "seek accepted") require(model.seekRelative(target - model.state.position), "seek accepted")
until("seek acknowledged and subtitle synchronized", model: model) { try await until("seek acknowledged and subtitle synchronized", model: model) {
!model.state.seek_pending && abs(model.state.position - target) < 0.2 && !model.state.seek_pending && abs(model.state.position - target) < 0.2 &&
model.subtitleText == document.text(at: model.state.position) && model.subtitleError == nil model.subtitleText == document.text(at: model.state.position) && model.subtitleError == nil
} }
require(model.selectedSubtitle == index && model.subtitleTracks[index].label == track.label, "default selection and label retained") require(model.selectedSubtitle == index && model.subtitleTracks[index].label == track.label, "default selection and label retained")
let backward = playableCues[0].start + min(0.1, (playableCues[0].end - playableCues[0].start) / 2) let backward = playableCues[0].start + min(0.1, (playableCues[0].end - playableCues[0].start) / 2)
require(model.seekRelative(backward - model.state.position), "backward seek accepted") require(model.seekRelative(backward - model.state.position), "backward seek accepted")
until("backward seek subtitle synchronized", model: model) { try await until("backward seek subtitle synchronized", model: model) {
!model.state.seek_pending && abs(model.state.position - backward) < 0.2 && !model.state.seek_pending && abs(model.state.position - backward) < 0.2 &&
model.subtitleText == document.text(at: model.state.position) model.subtitleText == document.text(at: model.state.position)
} }
+149
View File
@@ -0,0 +1,149 @@
import Foundation
@main
struct StreamResolverChecks {
struct Failure: Error { let message: String }
static let mediaID = "OERe_a0YS4h-DlsBDOeXwX7HOO6316tw"
static let page = "https://barelystarted.miruro.tv/watch/\(mediaID)/the-apothecary-diaries-season-3?ep=1"
// Sanitized shape from Tubular's observed source response and projection fixture.
static let fixture = #"""
{
"episodeNumber": 1,
"config": {"sun": {"player": "native", "proxy": false,
"hls": {"segments": {"enabled": false}, "query": {"enabled": false}}}},
"providerOrder": ["sun", "bun"],
"tracks": [{"track": "ssub", "providers": [{
"provider": "sun",
"servers": [
{"server": "iframe", "headers": null, "streams": [], "embed": {"url": "https://embed.example/"}},
{"server": "icarus-2-1", "headers": {"Origin": "https://ultracloud.cc", "Referer": "https://ultracloud.cc/"},
"streams": [{"url": "https://vault.example/master.m3u8?token=secret", "format": "hls", "embed": null}]}
],
"subtitles": [
{"file": "https://vault.example/english.jpeg", "format": "vtt", "language": "en", "label": "English", "default": true},
{"file": "https://vault.example/spanish.jpeg", "format": "vtt", "language": "sp", "label": "Spanish (Latin America)", "default": false},
{"file": "https://vault.example/unsupported.srt", "format": "srt", "language": "en", "label": "English SRT", "default": false}
]
}]}]
}
"""#
static func require(_ condition: @autoclosure () -> Bool, _ message: String) throws {
if !condition() { throw Failure(message: message) }
}
static func reject(_ message: String, _ operation: () throws -> Void) throws {
do { try operation() }
catch {
try require(!error.localizedDescription.contains("secret"), "errors must not expose source secrets")
return
}
throw Failure(message: "Accepted \(message)")
}
static func project(_ json: String) throws -> ResolvedSource {
try MiruroResolver.decode(Data(json.utf8), mediaID: mediaID, episode: 1)
}
static func checks() async throws {
guard case .miruro(let id, let episode) = try StreamURL.parse(" \n" + page + "\n") else {
throw Failure(message: "Miruro URL classification")
}
try require(id == mediaID && episode == 1, "opaque identity and route episode retained")
guard case .youtube(let youtube) = try StreamURL.parse("https://youtu.be/jNQXAC9IVRw") else {
throw Failure(message: "YouTube URL classification")
}
try require(youtube.absoluteString == "https://www.youtube.com/watch?v=jNQXAC9IVRw", "existing YouTube normalization")
for invalid in [
"https://example.com/video", "https://www.miruro.to/watch?id=21&ep=1",
page.replacingOccurrences(of: "barelystarted.miruro.tv", with: "barelystarted.miruro.tv.evil.example"),
page.replacingOccurrences(of: "barelystarted.miruro.tv", with: "barelystarted.miruro.tv@evil.example"),
page.replacingOccurrences(of: "https://", with: "http://"),
page.replacingOccurrences(of: "https://", with: "https://user:secret@"),
page.replacingOccurrences(of: "/watch/", with: "/info/"),
page.replacingOccurrences(of: mediaID, with: "invalid%2Fid"),
page.replacingOccurrences(of: mediaID, with: "%2e%2e"),
page.replacingOccurrences(of: "?ep=1", with: ""),
page.replacingOccurrences(of: "?ep=1", with: "?ep=0"),
page.replacingOccurrences(of: "?ep=1", with: "?ep=01"),
page.replacingOccurrences(of: "?ep=1", with: "?ep=-1"),
page.replacingOccurrences(of: "?ep=1", with: "?ep=2147483648"),
page.replacingOccurrences(of: "?ep=1", with: "?ep=%31"),
page + "&ep=2", page + "&provider=bee", page + "#secret",
page.replacingOccurrences(of: ".tv/", with: ".tv:444/"),
page.replacingOccurrences(of: "?ep", with: "/?ep"),
"https://youtu.be/invalid", "file:///secret"
] {
try reject("unsupported or unsafe URL") { _ = try StreamURL.parse(invalid) }
}
let source = try project(fixture)
try require(source.identity == "miruro:\(mediaID):1", "stable catalog identity")
try require(source.url == "https://vault.example/master.m3u8?token=secret", "first direct HLS stream retained")
try require(source.headers == "Origin: https://ultracloud.cc\r\nReferer: https://ultracloud.cc/\r\n", "all server headers retained")
try require(source.subtitles.count == 2 && source.subtitles[0].isDefault, "only external VTT retained with default")
try require(source.subtitles[0].url.hasSuffix(".jpeg") && source.subtitles[1].label == "Spanish (Latin America)", "subtitle format and labels independent of file extension")
try require(source.subtitles.allSatisfy { $0.headers == source.headers }, "verified Sun subtitle headers retained")
let renewed = try project(fixture.replacingOccurrences(of: "token=secret", with: "token=renewed"))
try require(renewed.identity == source.identity && renewed.url != source.url, "token rotation does not change stable identity")
for (old, replacement) in [
("\"episodeNumber\": 1", "\"episodeNumber\": 49"),
("\"proxy\": false", "\"proxy\": true"),
("\"enabled\": false", "\"enabled\": true"),
("\"player\": \"native\"", "\"player\": \"iframe\""),
("\"track\": \"ssub\"", "\"track\": \"dub\""),
("\"provider\": \"sun\"", "\"provider\": \"bee\""),
("\"format\": \"hls\"", "\"format\": \"mp4\""),
("\"embed\": null", "\"embed\": {\"url\": \"https://embed.example/secret\"}"),
("https://vault.example/master.m3u8?token=secret", "file:///secret"),
("https://vault.example/english.jpeg", "https://user:secret@vault.example/english.jpeg"),
("https://ultracloud.cc/\"", "https://ultracloud.cc/\\r\\nX-Injected: secret\""),
("\"Origin\":", "\"origin\": \"secret\", \"Origin\":"),
("\"default\": false", "\"default\": true")
] {
try reject("unsafe/unsupported source projection") {
_ = try project(fixture.replacingOccurrences(of: old, with: replacement))
}
}
try reject("oversized metadata") {
_ = try MiruroResolver.decode(Data(repeating: 32, count: MiruroResolver.bodyLimit + 1), mediaID: mediaID, episode: 1)
}
let responseURL = URL(string: "https://barelystarted.miruro.tv/api/sources")!
for (status, type, length) in [(403, "text/html", "12"), (302, "application/json", "12"), (200, "text/html", "12"), (200, "application/json", "2097153")] {
let response = HTTPURLResponse(url: responseURL, statusCode: status, httpVersion: nil,
headerFields: ["Content-Type": type, "Content-Length": length])!
try reject("protection/redirect/oversized response") { try MiruroResolver.validateResponse(response) }
}
let cancelled = Task {
withUnsafeCurrentTask { $0?.cancel() }
return try await MiruroResolver.resolve(mediaID: mediaID, episode: 1)
}
do {
_ = try await cancelled.value
throw Failure(message: "Cancelled resolution was accepted")
} catch is CancellationError {}
print("PASS: streaming URL security, Sun projection, shared validation, bounded responses and cancellation")
}
static func main() async {
do {
let args = Array(CommandLine.arguments.dropFirst())
if args.count == 2, args[0] == "--live" {
guard case .miruro(let id, let episode) = try StreamURL.parse(args[1]) else {
throw Failure(message: "--live requires a Miruro episode URL")
}
let source = try await MiruroResolver.resolve(mediaID: id, episode: episode)
print("PASS: live \(source.identity); \(source.subtitles.count) VTT tracks")
} else if args.count == 2, args[0] == "--fixture" {
let data = try Data(contentsOf: URL(fileURLWithPath: args[1]))
let source = try MiruroResolver.decode(data, mediaID: mediaID, episode: 1)
print("PASS: fixture \(source.identity); \(source.subtitles.count) VTT tracks")
} else if args.isEmpty {
try await checks()
} else {
throw Failure(message: "Usage: stream-resolver-check [--live EPISODE_URL | --fixture SOURCES_JSON]")
}
} catch {
let message = (error as? Failure)?.message ?? error.localizedDescription
FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8))
exit(1)
}
}
}
+12 -2
View File
@@ -17,6 +17,8 @@ OBJECTS := $(BUILD_DIR)/player.o $(BUILD_DIR)/progress_reporter.o $(BUILD_DIR)/O
SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/PlayerActions.swift Sources/App/CompactWindow.swift Sources/App/YouTubeResolver.swift Sources/App/PlaybackProgress.swift Sources/App/PlaybackQueue.swift Sources/App/SystemPlaybackControls.swift Sources/App/AutomaticQuality.swift Sources/App/ResolvedSource.swift Sources/App/ExternalSubtitles.swift Sources/App/CastSession.swift Sources/App/CastMediaServer.swift Sources/App/CastControls.swift SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/PlayerActions.swift Sources/App/CompactWindow.swift Sources/App/YouTubeResolver.swift Sources/App/PlaybackProgress.swift Sources/App/PlaybackQueue.swift Sources/App/SystemPlaybackControls.swift Sources/App/AutomaticQuality.swift Sources/App/ResolvedSource.swift Sources/App/ExternalSubtitles.swift Sources/App/CastSession.swift Sources/App/CastMediaServer.swift Sources/App/CastControls.swift
RUNTIME_SOURCE := Sources/Launcher/FFmpegRuntime.swift RUNTIME_SOURCE := Sources/Launcher/FFmpegRuntime.swift
CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift $(RUNTIME_SOURCE) CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift $(RUNTIME_SOURCE)
SWIFT_SOURCES += Sources/App/StreamResolver.swift
CLI_SOURCES += Sources/App/StreamResolver.swift
CONFIG_BUILD := .build/configuration CONFIG_BUILD := .build/configuration
CONFIG_BIN := $(shell xcrun swift build --scratch-path $(CONFIG_BUILD) -c release --show-bin-path) CONFIG_BIN := $(shell xcrun swift build --scratch-path $(CONFIG_BUILD) -c release --show-bin-path)
CONFIG_LIBRARY := $(CONFIG_BIN)/libTerebiConfiguration.a CONFIG_LIBRARY := $(CONFIG_BIN)/libTerebiConfiguration.a
@@ -154,6 +156,7 @@ $(BUILD_DIR)/fixtures/local-seek.mp4: Makefile | check-dependencies
check-local-seek: $(BUILD_DIR)/local-seek-check $(BUILD_DIR)/fixtures/local-seek.mp4 $(DECODE_FIXTURES) check-local-seek: $(BUILD_DIR)/local-seek-check $(BUILD_DIR)/fixtures/local-seek.mp4 $(DECODE_FIXTURES)
$(BUILD_DIR)/local-seek-check $(BUILD_DIR)/fixtures/local-seek.mp4 $(DECODE_FIXTURES) $(BUILD_DIR)/local-seek-check $(BUILD_DIR)/fixtures/local-seek.mp4 $(DECODE_FIXTURES)
check: check-stream
check: check-dependencies $(BUILD_DIR)/playback-check check-seek-queue check-local-seek check-video-decoding check-fixtures check-audio check-window check-config check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast check-queue check: check-dependencies $(BUILD_DIR)/playback-check check-seek-queue check-local-seek check-video-decoding check-fixtures check-audio check-window check-config check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast check-queue
$(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures $(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures
@@ -208,6 +211,13 @@ $(BUILD_DIR)/youtube-resolver-check: Checks/youtube-resolver.swift Sources/App/Y
check-youtube: $(BUILD_DIR)/youtube-resolver-check $(BUNDLE_STAMP) check-youtube: $(BUILD_DIR)/youtube-resolver-check $(BUNDLE_STAMP)
$(BUILD_DIR)/youtube-resolver-check "$(abspath $(APP))" $(BUILD_DIR)/youtube-resolver-check "$(abspath $(APP))"
$(BUILD_DIR)/stream-resolver-check: Checks/stream-resolver.swift Sources/App/StreamResolver.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift Makefile | $(BUILD_DIR)
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/stream-resolver.swift Sources/App/StreamResolver.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift -framework Foundation -o $@
.PHONY: check-stream
check-stream: $(BUILD_DIR)/stream-resolver-check
$(BUILD_DIR)/stream-resolver-check
$(BUILD_DIR)/automatic-quality-check: Checks/automatic-quality.swift Sources/App/AutomaticQuality.swift Makefile | $(BUILD_DIR) check-dependencies $(BUILD_DIR)/automatic-quality-check: Checks/automatic-quality.swift Sources/App/AutomaticQuality.swift Makefile | $(BUILD_DIR) check-dependencies
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/automatic-quality.swift Sources/App/AutomaticQuality.swift -framework Foundation -o $@ $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/automatic-quality.swift Sources/App/AutomaticQuality.swift -framework Foundation -o $@
@@ -258,8 +268,8 @@ $(BUILD_DIR)/progress-reporting-check: Checks/progress-reporting.mm Sources/Core
check-reporting: $(BUILD_DIR)/progress-reporting-check check-reporting: $(BUILD_DIR)/progress-reporting-check
$(BUILD_DIR)/progress-reporting-check $(BUILD_DIR)/progress-reporting-check
$(BUILD_DIR)/player-command-check: Checks/player-command.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift Makefile | $(BUILD_DIR) check-dependencies $(BUILD_DIR)/player-command-check: Checks/player-command.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift Sources/App/StreamResolver.swift Makefile | $(BUILD_DIR) check-dependencies
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/player-command.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift -framework Foundation -o $@ $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/player-command.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift Sources/App/StreamResolver.swift -framework Foundation -o $@
check-cli: $(BUILD_DIR)/player-command-check check-cli: $(BUILD_DIR)/player-command-check
$(BUILD_DIR)/player-command-check $(BUILD_DIR)/player-command-check
+13 -5
View File
@@ -35,9 +35,9 @@ Builds are ad-hoc signed for personal use, not notarized. Bundled licenses and s
## Usage ## Usage
Open one or more local files with **⌘O**, drag them onto the video, or use Finder's **Open With**. A selection replaces the session's queue and plays in natural filename order (for example, episode 2 before episode 10). Open YouTube with **⇧⌘O** or drop a video link. Media starts automatically and resumes its saved position; finished videos restart from the beginning. Live and audio-only media do not save progress. Open one or more local files with **⌘O**, drag them onto the video, or use Finder's **Open With**. A selection replaces the session's queue and plays in natural filename order (for example, episode 2 before episode 10). Open a YouTube video or Miruro episode with **⇧⌘O** or drop its link. Media starts automatically and resumes its saved position; finished videos restart from the beginning. Live and audio-only media do not save progress.
The start screen uses a fixed 640 × 400-point two-pane window: Terebi's identity and version on the left; **Open…**, **Open URL…**, and the six most recently played local files or YouTube videos on the right. Opening media restores normal resizing; closing media returns to the fixed start size. Shortcut hints follow your configured bindings. **Open URL…** reveals a focused inline YouTube field. When the most recent item has saved progress, it appears once as a **Resume** row with its saved timestamp. Recent titles and ordering survive relaunches; failed opens are not added. Resolved streams are excluded because reopening them requires a fresh external handoff. The action pane scrolls, and drag-and-drop remains available. The start screen uses a fixed 640 × 400-point two-pane window: Terebi's identity and version on the left; **Open…**, **Open Stream…**, and at most the three most recently played local files or YouTube videos on the right, including any **Resume** row. Opening media restores normal resizing; closing media returns to the fixed start size. Shortcut hints follow your configured bindings. **Open Stream…** reveals a focused inline URL field. When the most recent item has saved progress, it appears once as a **Resume** row with its saved timestamp. Recent titles and ordering survive relaunches; failed opens are not added. Resolved streams are excluded from recents. The action pane scrolls, and drag-and-drop remains available.
Start-screen actions use 14-point text and begin 64 points below the top edge. Branding uses a 16-point app name and 12-point version; the drop hint uses 13-point text. Open actions have no idle fill; their background appears on hover or press. Start-screen actions use 14-point text and begin 64 points below the top edge. Branding uses a 16-point app name and 12-point version; the drop hint uses 13-point text. Open actions have no idle fill; their background appears on hover or press.
@@ -93,6 +93,14 @@ Playback defaults to the best available SDR resolution up to **1080p**. Use **ge
Only individual videos are supported, not playlists or channels. Private, age-restricted, region-blocked, or sign-in-required videos may fail; browser cookies are not read. The extractor is bundled, so YouTube compatibility updates require rebuilding/reinstalling terebi, not updating an external yt-dlp. Only individual videos are supported, not playlists or channels. Private, age-restricted, region-blocked, or sign-in-required videos may fail; browser cookies are not read. The extractor is bundled, so YouTube compatibility updates require rebuilding/reinstalling terebi, not updating an external yt-dlp.
### Miruro episode links
**Open Stream…**, dropped links and CLI URLs accept `https://barelystarted.miruro.tv/watch/<opaque-id>/<slug>?ep=<episode>`. An explicit positive episode number is required; series pages, other Miruro domains and arbitrary streaming sites are not supported.
Terebi resolves a fresh direct Sun soft-subtitle source, preserving stream headers, external WebVTT subtitles and stable episode identity. No provider fallback, cookies, protection bypass or proxy/HLS rewriting is used. Resolution errors leave the previous media loaded. Miruro URL playback currently uses the title “Miruro episode N”.
The configurable URL action is now `open_stream` (formerly `open_youtube`); custom key bindings must use the new name. The default remains **⇧⌘O**.
### Chromecast ### Chromecast
Open media, select the **Cast** icon or **Playback → Cast to Device → Find Cast Devices…**, and choose a receiver on the same LAN. Allow macOS Local Network access and incoming firewall connections. Terebi keeps the Mac awake during the handoff and active Cast playback while allowing the screensaver and display sleep once local playback stops. Keep the Mac connected: it relays media to the receiver. Closing the lid, explicitly sleeping the Mac or critical-battery sleep can still interrupt playback. Open media, select the **Cast** icon or **Playback → Cast to Device → Find Cast Devices…**, and choose a receiver on the same LAN. Allow macOS Local Network access and incoming firewall connections. Terebi keeps the Mac awake during the handoff and active Cast playback while allowing the screensaver and display sleep once local playback stops. Keep the Mac connected: it relays media to the receiver. Closing the lid, explicitly sleeping the Mac or critical-battery sleep can still interrupt playback.
@@ -120,16 +128,16 @@ The CLI opens one file or video, reusing the running player by default. With no
### Resolved sources and progress reporting ### Resolved sources and progress reporting
Miruro playback requires a freshly resolved source document from an external caller such as Tubular; terebi does not discover episodes or accept Miruro page URLs directly. External callers such as Tubular can continue supplying freshly resolved Miruro source documents. This path does not re-resolve page URLs or override the caller's selection, headers, title or subtitles; the source-file and progress-report contracts are unchanged.
```sh ```sh
terebi --new-instance --source-file '/private/session/source.json' \ terebi --new-instance --source-file '/private/session/source.json' \
--progress-file '/private/session/progress.jsonl' --progress-file '/private/session/progress.jsonl'
``` ```
Both options require `--new-instance`; `--progress-file` also works with YouTube. Use a private session directory and a nonexistent report destination. Keep the source file present and unchanged during playback; the caller owns cleanup. Reports contain playback facts, not watched-state decisions. Both options require `--new-instance`; `--progress-file` also works with YouTube and Miruro episode URLs. Use a private session directory and a nonexistent report destination. Keep the source file present and unchanged during playback; the caller owns cleanup. Reports contain playback facts, not watched-state decisions.
See the [integration contract](docs/miruro-playback-handoff.md) for source JSON, resource-header/origin restrictions, and progress schemas. Tubular still needs to adopt Miruro's version-2 progress identity. See the [integration contract](docs/miruro-playback-handoff.md) for source JSON, resource-header/origin restrictions, and progress schemas.
## Configuration ## Configuration
+5 -5
View File
@@ -94,8 +94,8 @@ extension PlayerModel {
private func handleKeyEvent(_ event: NSEvent, in window: NSWindow, chord: KeyChord) -> Bool { private func handleKeyEvent(_ event: NSEvent, in window: NSWindow, chord: KeyChord) -> Bool {
guard event.window === window, NSApp.modalWindow == nil, window.attachedSheet == nil else { return false } guard event.window === window, NSApp.modalWindow == nil, window.attachedSheet == nil else { return false }
if chord.modifiers.contains(.command), keyBindings.shortcuts(for: .openYoutube).contains(chord) { if chord.modifiers.contains(.command), keyBindings.shortcuts(for: .openStream).contains(chord) {
return perform(.openYoutube) return perform(.openStream)
} }
let responder = window.firstResponder let responder = window.firstResponder
if let text = responder as? NSTextView, text.isEditable || text.isSelectable { return false } if let text = responder as? NSTextView, text.isEditable || text.isSelectable { return false }
@@ -200,9 +200,9 @@ struct PlayerCommands: Commands {
.disabled(!model.canPerform(.openFile)) .disabled(!model.canPerform(.openFile))
Button("Add to Queue…", action: model.addQueuePanel) Button("Add to Queue…", action: model.addQueuePanel)
.disabled(!model.canOpen) .disabled(!model.canOpen)
Button("Open YouTube URL…") { model.performMenuAction(.openYoutube) } Button("Open Stream…") { model.performMenuAction(.openStream) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .openYoutube).first?.keyboardShortcut) .keyboardShortcut(model.keyBindings.shortcuts(for: .openStream).first?.keyboardShortcut)
.disabled(!model.canPerform(.openYoutube)) .disabled(!model.canPerform(.openStream))
Button("Close File") { model.performMenuAction(.closeFile) } Button("Close File") { model.performMenuAction(.closeFile) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .closeFile).first?.keyboardShortcut) .keyboardShortcut(model.keyBindings.shortcuts(for: .closeFile).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.closeFile)) .disabled(!hasFocusedScene || !model.canPerform(.closeFile))
+38 -21
View File
@@ -12,6 +12,15 @@ struct ResolvedSubtitle: Decodable, Sendable {
headerFields.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value)\r\n" }.joined() headerFields.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value)\r\n" }.joined()
} }
fileprivate var validatedHeaders: [String: String] { headerFields } fileprivate var validatedHeaders: [String: String] { headerFields }
init(url: String, format: String, language: String, label: String, isDefault: Bool, headers: [String: String]) {
self.url = url
self.format = format
self.language = language
self.label = label
self.isDefault = isDefault
self.headerFields = headers
}
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case url, format, language, label case url, format, language, label
case isDefault = "default" case isDefault = "default"
@@ -26,7 +35,7 @@ struct ResolvedSource: Sendable {
let title: String let title: String
let subtitles: [ResolvedSubtitle] let subtitles: [ResolvedSubtitle]
private struct Document: Decodable { struct Document: Decodable {
struct Identity: Decodable { struct Identity: Decodable {
let provider: String let provider: String
let media_id: String let media_id: String
@@ -62,29 +71,37 @@ struct ResolvedSource: Sendable {
let data = try handle.read(upToCount: 1_048_577) ?? Data() let data = try handle.read(upToCount: 1_048_577) ?? Data()
guard data.count <= 1_048_576 else { throw InvalidSource() } guard data.count <= 1_048_576 else { throw InvalidSource() }
let document = try JSONDecoder().decode(Document.self, from: data) let document = try JSONDecoder().decode(Document.self, from: data)
let id = document.identity return try validated(document)
guard document.version == 1, id.provider == "miruro",
!id.media_id.isEmpty, id.media_id.utf8.count <= 512,
id.media_id.utf8.allSatisfy({ isIDByte($0) }),
id.episode > 0, id.episode <= Int(Int32.max),
validURL(document.url), validHeaders(document.headers),
validText(document.title ?? "", limit: 4096),
document.subtitles.count <= 32,
document.subtitles.filter({ $0.isDefault }).count <= 1 else { throw InvalidSource() }
for subtitle in document.subtitles {
guard validURL(subtitle.url), subtitle.format == "vtt",
validText(subtitle.language, limit: 128), !subtitle.language.isEmpty,
validText(subtitle.label, limit: 4096), !subtitle.label.isEmpty,
validHeaders(subtitle.validatedHeaders) else { throw InvalidSource() }
}
let headers = document.headers.sorted { $0.key < $1.key }
.map { "\($0.key): \($0.value)\r\n" }.joined()
return ResolvedSource(url: document.url, headers: headers,
identity: "miruro:\(id.media_id):\(id.episode)", title: document.title ?? "Miruro episode \(id.episode)",
subtitles: document.subtitles)
} catch { throw InvalidSource() } } catch { throw InvalidSource() }
} }
static func validIdentity(mediaID: String, episode: Int) -> Bool {
!mediaID.isEmpty && mediaID.utf8.count <= 512 &&
mediaID.utf8.allSatisfy({ isIDByte($0) }) &&
episode > 0 && episode <= Int(Int32.max)
}
static func validated(_ document: Document) throws -> ResolvedSource {
let id = document.identity
guard document.version == 1, id.provider == "miruro",
validIdentity(mediaID: id.media_id, episode: id.episode),
validURL(document.url), validHeaders(document.headers),
validText(document.title ?? "", limit: 4096),
document.subtitles.count <= 32,
document.subtitles.filter({ $0.isDefault }).count <= 1 else { throw InvalidSource() }
for subtitle in document.subtitles {
guard validURL(subtitle.url), subtitle.format == "vtt",
validText(subtitle.language, limit: 128), !subtitle.language.isEmpty,
validText(subtitle.label, limit: 4096), !subtitle.label.isEmpty,
validHeaders(subtitle.validatedHeaders) else { throw InvalidSource() }
}
let headers = document.headers.sorted { $0.key < $1.key }
.map { "\($0.key): \($0.value)\r\n" }.joined()
return ResolvedSource(url: document.url, headers: headers,
identity: "miruro:\(id.media_id):\(id.episode)", title: document.title ?? "Miruro episode \(id.episode)",
subtitles: document.subtitles)
}
private static func isIDByte(_ byte: UInt8) -> Bool { private static func isIDByte(_ byte: UInt8) -> Bool {
(65...90).contains(byte) || (97...122).contains(byte) || (65...90).contains(byte) || (97...122).contains(byte) ||
(48...57).contains(byte) || byte == 45 || byte == 95 (48...57).contains(byte) || byte == 45 || byte == 95
+199
View File
@@ -0,0 +1,199 @@
import Foundation
private struct StreamResolutionError: LocalizedError {
let message: String
var errorDescription: String? { message }
}
enum StreamURL: Sendable {
case youtube(URL)
case miruro(mediaID: String, episode: Int)
static func parse(_ text: String) throws -> StreamURL {
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
if let url = try? YouTubeResolver.validateURL(text) { return .youtube(url) }
let invalid = StreamResolutionError(message: "Enter a YouTube video URL or a Miruro episode URL from barelystarted.miruro.tv.")
guard !text.unicodeScalars.contains(where: {
CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0)
}), let components = URLComponents(string: text),
components.scheme?.lowercased() == "https",
components.host?.lowercased() == "barelystarted.miruro.tv",
components.user == nil, components.password == nil,
components.port == nil || components.port == 443,
components.fragment == nil,
components.percentEncodedPath == components.path,
components.url != nil else { throw invalid }
let path = components.path.split(separator: "/", omittingEmptySubsequences: false)
guard path.count == 4, path[0].isEmpty, path[1] == "watch",
!path[3].isEmpty, path[3].utf8.count <= 512,
path[3].utf8.allSatisfy({ isSegmentByte($0) }),
let query = components.queryItems, query.count == 1, query[0].name == "ep",
let number = query[0].value, let episode = Int(number),
components.percentEncodedQuery == "ep=\(episode)",
ResolvedSource.validIdentity(mediaID: String(path[2]), episode: episode) else { throw invalid }
return .miruro(mediaID: String(path[2]), episode: episode)
}
private static func isSegmentByte(_ byte: UInt8) -> Bool {
(65...90).contains(byte) || (97...122).contains(byte) ||
(48...57).contains(byte) || byte == 45 || byte == 95
}
}
enum MiruroResolver {
static let bodyLimit = 2 * 1024 * 1024
static func resolve(mediaID: String, episode: Int) async throws -> ResolvedSource {
try Task.checkCancellation()
guard ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else {
throw StreamResolutionError(message: "Invalid Miruro episode identity.")
}
var components = URLComponents(string: "https://barelystarted.miruro.tv/api/sources")!
components.queryItems = [URLQueryItem(name: "id", value: mediaID), URLQueryItem(name: "n", value: String(episode))]
var request = URLRequest(url: components.url!)
request.timeoutInterval = 25
request.setValue("application/json", forHTTPHeaderField: "Accept")
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 25
configuration.timeoutIntervalForResource = 25
configuration.httpShouldSetCookies = false
configuration.httpCookieStorage = nil
configuration.urlCache = nil
let session = URLSession(configuration: configuration, delegate: MiruroRedirectPolicy(), delegateQueue: nil)
defer { session.invalidateAndCancel() }
do {
let (bytes, response) = try await session.bytes(for: request)
try validateResponse(response)
var data = Data()
if response.expectedContentLength > 0 { data.reserveCapacity(Int(response.expectedContentLength)) }
for try await byte in bytes {
try Task.checkCancellation()
guard data.count < bodyLimit else {
throw StreamResolutionError(message: "Miruro source response exceeded 2 MiB.")
}
data.append(byte)
}
try Task.checkCancellation()
return try decode(data, mediaID: mediaID, episode: episode)
} catch {
if Task.isCancelled || error is CancellationError || (error as? URLError)?.code == .cancelled {
throw CancellationError()
}
if let error = error as? StreamResolutionError { throw error }
throw StreamResolutionError(message: "Miruro request failed or timed out. No retry was attempted.")
}
}
static func validateResponse(_ response: URLResponse) throws {
guard let response = response as? HTTPURLResponse else {
throw StreamResolutionError(message: "Miruro returned an invalid HTTP response.")
}
guard response.statusCode == 200 else {
throw StreamResolutionError(message: "Miruro HTTP \(response.statusCode). No retry was attempted.")
}
guard response.mimeType?.lowercased() == "application/json" else {
throw StreamResolutionError(message: "Miruro returned an unexpected content type; protection pages are not supported.")
}
guard response.expectedContentLength <= Int64(bodyLimit) else {
throw StreamResolutionError(message: "Miruro source response exceeded 2 MiB.")
}
}
// Match Tubular's direct ssub/Sun projection, without provider or server retry.
static func decode(_ data: Data, mediaID: String, episode: Int) throws -> ResolvedSource {
do {
guard data.count <= bodyLimit,
ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else { throw InvalidProjection() }
let response = try JSONDecoder().decode(Sources.self, from: data)
guard response.episodeNumber == episode else { throw InvalidProjection() }
let config = response.config.sun
guard config.player == "native", !config.proxy,
config.hls?.segments?.enabled != true, config.hls?.query?.enabled != true else {
throw StreamResolutionError(message: "Miruro Sun requires unsupported proxy or HLS rewriting.")
}
guard let provider = response.tracks.first(where: { $0.track == "ssub" })?
.providers.first(where: { $0.provider == "sun" }),
let server = provider.servers.first(where: { !$0.streams.isEmpty }),
let stream = server.streams.first else {
throw StreamResolutionError(message: "Direct Miruro Sun soft-subtitle source is unavailable.")
}
guard ["hls", "m3u8", "application/vnd.apple.mpegurl", "application/x-mpegurl"].contains(stream.format),
!stream.hasEmbed else { throw InvalidProjection() }
let headers = server.headers ?? [:]
var subtitles: [ResolvedSubtitle] = []
for subtitle in provider.subtitles where subtitle.format == "vtt" {
guard subtitles.count < 32 else { throw InvalidProjection() }
subtitles.append(ResolvedSubtitle(url: subtitle.file, format: subtitle.format, language: subtitle.language,
label: subtitle.label, isDefault: subtitle.default, headers: headers))
}
return try ResolvedSource.validated(.init(version: 1, url: stream.url, headers: headers,
identity: .init(provider: "miruro", media_id: mediaID, episode: episode),
title: nil, subtitles: subtitles))
} catch {
if let error = error as? StreamResolutionError { throw error }
throw StreamResolutionError(message: "Miruro returned invalid or unsupported source metadata.")
}
}
private struct InvalidProjection: Error {}
private struct Sources: Decodable {
let episodeNumber: Int
let tracks: [Track]
let config: Config
}
private struct Config: Decodable { let sun: ProviderConfig }
private struct ProviderConfig: Decodable {
let player: String
let proxy: Bool
let hls: HLS?
}
private struct HLS: Decodable {
struct Rule: Decodable { let enabled: Bool? }
let segments: Rule?
let query: Rule?
}
private struct Track: Decodable {
let track: String
let providers: [Provider]
}
private struct Provider: Decodable {
let provider: String
let servers: [Server]
let subtitles: [Subtitle]
}
private struct Server: Decodable {
let headers: [String: String]?
let streams: [Stream]
}
private struct Stream: Decodable {
let url: String
let format: String
let hasEmbed: Bool
enum CodingKeys: String, CodingKey { case url, format, embed }
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
url = try values.decode(String.self, forKey: .url)
format = try values.decode(String.self, forKey: .format)
if values.contains(.embed) {
hasEmbed = try !values.decodeNil(forKey: .embed)
} else {
hasEmbed = false
}
}
}
private struct Subtitle: Decodable {
let file: String
let format: String
let language: String
let label: String
let `default`: Bool
}
}
private final class MiruroRedirectPolicy: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void) {
completionHandler(nil)
}
}
+71 -35
View File
@@ -58,7 +58,7 @@ final class PlayerModel: ObservableObject {
private var currentMedia: MediaSource? private var currentMedia: MediaSource?
private var shouldRecordRecentMedia = false private var shouldRecordRecentMedia = false
var pendingRevision: UInt64? { transition.pendingRevision } var pendingRevision: UInt64? { transition.pendingRevision }
var resolvingYouTube: Bool { transition.isResolving } var resolvingStream: Bool { transition.isResolving }
var isLiveStream: Bool { transition.isLive } var isLiveStream: Bool { transition.isLive }
var isNetworkMedia: Bool { transition.isNetwork } var isNetworkMedia: Bool { transition.isNetwork }
@Published private(set) var youtubeHeights: [Int] = [] @Published private(set) var youtubeHeights: [Int] = []
@@ -216,7 +216,7 @@ final class PlayerModel: ObservableObject {
func cast(to device: CastDevice, playing: Bool? = nil) { func cast(to device: CastDevice, playing: Bool? = nil) {
guard canCast, let source = currentMedia, let handle else { return } guard canCast, let source = currentMedia, let handle else { return }
cancelYouTubeResolution() cancelStreamResolution()
commandError = nil commandError = nil
casting.start(to: device, source: source, title: fileName ?? "Terebi", subtitle: selectedSubtitle, casting.start(to: device, source: source, title: fileName ?? "Terebi", subtitle: selectedSubtitle,
handle: handle, snapshot: state, playing: playing ?? effectivePlaying, handle: handle, snapshot: state, playing: playing ?? effectivePlaying,
@@ -294,11 +294,11 @@ final class PlayerModel: ObservableObject {
} }
func openYouTube(_ text: String) { func openStream(_ text: String) {
guard canOpen else { return } guard canOpen else { return }
let url: URL let stream: StreamURL
do { do {
url = try YouTubeResolver.validateURL(text) stream = try StreamURL.parse(text)
} catch { } catch {
commandError = error.localizedDescription commandError = error.localizedDescription
revealControls() revealControls()
@@ -306,14 +306,17 @@ final class PlayerModel: ObservableObject {
} }
showsYouTubeInput = false showsYouTubeInput = false
clearQueue() clearQueue()
resolveYouTube(url, height: nil) switch stream {
case .youtube(let url): resolveYouTube(url, height: nil)
case .miruro(let mediaID, let episode): resolveMiruro(mediaID: mediaID, episode: episode)
}
} }
func openResolvedSource(_ file: URL) { func openResolvedSource(_ file: URL) {
do { do {
let source = try ResolvedSource.load(from: file) let source = try ResolvedSource.load(from: file)
clearQueue() clearQueue()
cancelYouTubeResolution() cancelStreamResolution()
openMedia(.resolved(source)) openMedia(.resolved(source))
} catch { } catch {
commandError = error.localizedDescription commandError = error.localizedDescription
@@ -362,7 +365,7 @@ final class PlayerModel: ObservableObject {
func setAutomaticYouTubeQuality(_ enabled: Bool) { func setAutomaticYouTubeQuality(_ enabled: Bool) {
automaticYouTubeQuality = enabled automaticYouTubeQuality = enabled
automaticQuality = AutomaticQuality() automaticQuality = AutomaticQuality()
if !enabled && transition.resolvingAutomaticQuality { cancelYouTubeResolution() } if !enabled && transition.resolvingAutomaticQuality { cancelStreamResolution() }
revealControls() revealControls()
} }
@@ -381,10 +384,43 @@ final class PlayerModel: ObservableObject {
resolveYouTube(url, height: height, change: change) resolveYouTube(url, height: height, change: change)
} }
private func resolveMiruro(mediaID: String, episode: Int) {
guard let handle else { return }
if castDevice != nil { stopCasting(resume: false) }
cancelStreamResolution()
automaticQuality = AutomaticQuality()
guard player_pause(handle) else {
commandError = "Could not pause the current media to open the stream. Please try again."
revealControls()
return
}
objectWillChange.send()
let request = transition.beginResolution(.open)
systemPlayback?.update()
commandError = nil
revealControls()
let task = Task { [weak self] in
do {
let source = try await MiruroResolver.resolve(mediaID: mediaID, episode: episode)
guard !Task.isCancelled, let self else { return }
self.objectWillChange.send()
guard let change = self.transition.finishResolution(request) else { return }
self.openMedia(.resolved(source), change: change)
} catch {
guard !Task.isCancelled, let self else { return }
self.objectWillChange.send()
guard self.transition.finishResolution(request) != nil else { return }
self.commandError = error.localizedDescription
self.revealControls()
}
}
transition.attachResolution(task, request: request)
}
private func resolveYouTube(_ url: URL, height: Int?, change: MediaChange = .open) { private func resolveYouTube(_ url: URL, height: Int?, change: MediaChange = .open) {
guard let handle else { return } guard let handle else { return }
if castDevice != nil { stopCasting(resume: false) } if castDevice != nil { stopCasting(resume: false) }
cancelYouTubeResolution() cancelStreamResolution()
if !change.automatic { automaticQuality = AutomaticQuality() } if !change.automatic { automaticQuality = AutomaticQuality() }
if !change.automatic && !player_pause(handle) { if !change.automatic && !player_pause(handle) {
commandError = "Could not pause the current media to open YouTube. Please try again." commandError = "Could not pause the current media to open YouTube. Please try again."
@@ -422,7 +458,7 @@ final class PlayerModel: ObservableObject {
transition.attachResolution(task, request: request) transition.attachResolution(task, request: request)
} }
private func cancelYouTubeResolution() { private func cancelStreamResolution() {
guard transition.isResolving else { return } guard transition.isResolving else { return }
objectWillChange.send() objectWillChange.send()
transition.cancelResolution() transition.cancelResolution()
@@ -545,7 +581,7 @@ final class PlayerModel: ObservableObject {
} }
func open(_ url: URL) { func open(_ url: URL) {
if url.isFileURL { openFiles([url]) } else { openYouTube(url.absoluteString) } if url.isFileURL { openFiles([url]) } else { openStream(url.absoluteString) }
} }
func openFiles(_ urls: [URL]) { func openFiles(_ urls: [URL]) {
@@ -595,7 +631,7 @@ final class PlayerModel: ObservableObject {
let receiver = keepingReceiver ? (castDevice ?? queueCastDevice) : nil let receiver = keepingReceiver ? (castDevice ?? queueCastDevice) : nil
queuePlaybackRevision = nil queuePlaybackRevision = nil
queueCompletionHandled = true queueCompletionHandled = true
cancelYouTubeResolution() cancelStreamResolution()
openMedia(.file(url), change: .queue(playing: receiver == nil)) openMedia(.file(url), change: .queue(playing: receiver == nil))
queueCastDevice = receiver queueCastDevice = receiver
} }
@@ -634,7 +670,7 @@ final class PlayerModel: ObservableObject {
return accepted return accepted
} }
guard let handle, canTogglePlayback else { return false } guard let handle, canTogglePlayback else { return false }
if !playing && transition.resolvingAutomaticQuality { cancelYouTubeResolution() } if !playing && transition.resolvingAutomaticQuality { cancelStreamResolution() }
if !playing { automaticQuality.resetHealth() } if !playing { automaticQuality.resetHealth() }
switch transition.setPlayback(playing, status: state.status, handle: handle) { switch transition.setPlayback(playing, status: state.status, handle: handle) {
case .unchanged: case .unchanged:
@@ -651,7 +687,7 @@ final class PlayerModel: ObservableObject {
} }
func finishSeeking() { func finishSeeking() {
if transition.resolvingAutomaticQuality { cancelYouTubeResolution() } if transition.resolvingAutomaticQuality { cancelStreamResolution() }
automaticQuality.resetHealth() automaticQuality.resetHealth()
isSeeking = false isSeeking = false
guard let handle, canSeek else { return } guard let handle, canSeek else { return }
@@ -669,7 +705,7 @@ final class PlayerModel: ObservableObject {
guard let handle, canClose else { return } guard let handle, canClose else { return }
saveProgress() saveProgress()
if castDevice != nil { stopCasting(resume: false) } if castDevice != nil { stopCasting(resume: false) }
cancelYouTubeResolution() cancelStreamResolution()
automaticQuality = AutomaticQuality() automaticQuality = AutomaticQuality()
guard player_close(handle) else { guard player_close(handle) else {
commandError = "Could not queue closing the file. Please try again." commandError = "Could not queue closing the file. Please try again."
@@ -705,7 +741,7 @@ final class PlayerModel: ObservableObject {
@discardableResult @discardableResult
func seekRelative(_ seconds: Double) -> Bool { func seekRelative(_ seconds: Double) -> Bool {
guard canSeek, let handle, seconds.isFinite else { return false } guard canSeek, let handle, seconds.isFinite else { return false }
if transition.resolvingAutomaticQuality { cancelYouTubeResolution() } if transition.resolvingAutomaticQuality { cancelStreamResolution() }
automaticQuality.resetHealth() automaticQuality.resetHealth()
let target = min(max(0, ((transition.seekPending || state.seek_pending) ? seekPosition : state.position) + seconds), duration) let target = min(max(0, ((transition.seekPending || state.seek_pending) ? seekPosition : state.position) + seconds), duration)
let accepted = casting.active ? seekCast(to: target) : transition.seek(target, handle: handle) let accepted = casting.active ? seekCast(to: target) : transition.seek(target, handle: handle)
@@ -890,7 +926,7 @@ final class PlayerModel: ObservableObject {
return window != nil && !isFullScreen && fullScreenIntent == nil && return window != nil && !isFullScreen && fullScreenIntent == nil &&
!isCompact && compactIntent != true && !compactWindow.isTransitioning && !isCompact && compactIntent != true && !compactWindow.isTransitioning &&
(action == .enableAlwaysOnTop ? !isOnTop : isOnTop) (action == .enableAlwaysOnTop ? !isOnTop : isOnTop)
case .openFile, .openYoutube: return canOpen case .openFile, .openStream: return canOpen
case .closeFile: return canClose case .closeFile: return canClose
case .quit: return true case .quit: return true
case .showBufferInfo: return !showsBufferInfo case .showBufferInfo: return !showsBufferInfo
@@ -948,7 +984,7 @@ final class PlayerModel: ObservableObject {
window?.level = isOnTop ? .floating : .normal window?.level = isOnTop ? .floating : .normal
revealControls() revealControls()
case .openFile: openPanel() case .openFile: openPanel()
case .openYoutube: showsYouTubeInput.toggle() case .openStream: showsYouTubeInput.toggle()
case .closeFile: closeFile() case .closeFile: closeFile()
case .quit: NSApp.terminate(nil) case .quit: NSApp.terminate(nil)
case .showBufferInfo: setShowsBufferInfo(true) case .showBufferInfo: setShowsBufferInfo(true)
@@ -1013,7 +1049,7 @@ final class PlayerModel: ObservableObject {
} }
func beginSeeking() { func beginSeeking() {
if transition.resolvingAutomaticQuality { cancelYouTubeResolution() } if transition.resolvingAutomaticQuality { cancelStreamResolution() }
automaticQuality.resetHealth() automaticQuality.resetHealth()
isSeeking = true isSeeking = true
revealControls() revealControls()
@@ -1185,7 +1221,7 @@ final class PlayerModel: ObservableObject {
saveProgress() saveProgress()
if castDevice != nil { stopCasting(resume: false) } if castDevice != nil { stopCasting(resume: false) }
castDiscovery.stop() castDiscovery.stop()
cancelYouTubeResolution() cancelStreamResolution()
selectSubtitle(nil) selectSubtitle(nil)
systemPlayback?.shutdown() systemPlayback?.shutdown()
systemPlayback = nil systemPlayback = nil
@@ -2118,13 +2154,13 @@ private struct PlayerContent: View {
} }
private func toggleYouTubeInput() { private func toggleYouTubeInput() {
model.perform(.openYoutube) model.perform(.openStream)
} }
private func startScreen(in size: CGSize) -> some View { private func startScreen(in size: CGSize) -> some View {
let sidebarWidth = min(260, max(112, size.width * 0.28)) let sidebarWidth = min(260, max(112, size.width * 0.28))
let openKeys = model.keyBindings.shortcuts(for: .openFile) let openKeys = model.keyBindings.shortcuts(for: .openFile)
let urlKeys = model.keyBindings.shortcuts(for: .openYoutube) let urlKeys = model.keyBindings.shortcuts(for: .openStream)
let latest = model.recentMedia.first let latest = model.recentMedia.first
let resumePosition = latest.flatMap { model.savedPosition(for: $0.url) } let resumePosition = latest.flatMap { model.savedPosition(for: $0.url) }
return HStack(spacing: 0) { return HStack(spacing: 0) {
@@ -2164,14 +2200,14 @@ private struct PlayerContent: View {
.accessibilityLabel("Open File…") .accessibilityLabel("Open File…")
Button(action: toggleYouTubeInput) { Button(action: toggleYouTubeInput) {
HStack { HStack {
Text("Open URL…") Text("Open Stream…")
Spacer(minLength: 8) Spacer(minLength: 8)
Text((urlKeys.first { $0.modifiers.contains(.command) } ?? urlKeys.first)?.displayText ?? "") Text((urlKeys.first { $0.modifiers.contains(.command) } ?? urlKeys.first)?.displayText ?? "")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
.buttonStyle(StartScreenButtonStyle()) .buttonStyle(StartScreenButtonStyle())
.accessibilityLabel("Open YouTube URL…") .accessibilityLabel("Open Stream…")
if showsYouTubeInput { if showsYouTubeInput {
startURLInput.padding(.horizontal, 14) startURLInput.padding(.horizontal, 14)
.transition(.opacity.combined(with: .scale(scale: 0.96, anchor: .top))) .transition(.opacity.combined(with: .scale(scale: 0.96, anchor: .top)))
@@ -2189,7 +2225,7 @@ private struct PlayerContent: View {
.help("Resume \(latest.title) at \(time(resumePosition))") .help("Resume \(latest.title) at \(time(resumePosition))")
.padding(.vertical, 8) .padding(.vertical, 8)
} }
ForEach(model.recentMedia.filter { resumePosition == nil || $0.id != latest?.id }) { item in ForEach(model.recentMedia.prefix(4).filter { resumePosition == nil || $0.id != latest?.id }) { item in
Button { model.open(item.url) } label: { Button { model.open(item.url) } label: {
HStack(spacing: 10) { HStack(spacing: 10) {
Image(systemName: item.url.isFileURL ? "doc.richtext" : "link") Image(systemName: item.url.isFileURL ? "doc.richtext" : "link")
@@ -2226,20 +2262,20 @@ private struct PlayerContent: View {
private var startURLInput: some View { private var startURLInput: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
TextField("YouTube video URL", text: $youTubeURL, TextField("Stream URL", text: $youTubeURL,
prompt: Text("YouTube video URL") prompt: Text("Stream URL")
.foregroundStyle(Color(SunsetPalette.secondaryText).opacity(0.6))) .foregroundStyle(Color(SunsetPalette.secondaryText).opacity(0.6)))
.textFieldStyle(.plain) .textFieldStyle(.plain)
.focused($youTubeInputFocused) .focused($youTubeInputFocused)
.accessibilityLabel("YouTube video URL") .accessibilityLabel("Stream URL")
.onAppear { youTubeInputFocused = true } .onAppear { youTubeInputFocused = true }
.onSubmit { model.openYouTube(youTubeURL) } .onSubmit { model.openStream(youTubeURL) }
Button { model.openYouTube(youTubeURL) } label: { Button { model.openStream(youTubeURL) } label: {
Image(systemName: "arrow.right").frame(width: 24, height: 24).contentShape(Rectangle()) Image(systemName: "arrow.right").frame(width: 24, height: 24).contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Open YouTube Video") .accessibilityLabel("Open Stream")
.help("Open YouTube Video") .help("Open Stream")
.disabled(youTubeURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .disabled(youTubeURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
} }
.font(.system(size: 12)) .font(.system(size: 12))
@@ -2369,7 +2405,7 @@ private struct PlayerContent: View {
Button(model.showsQueue ? "Hide Queue" : "Show Queue") { model.showsQueue.toggle() } Button(model.showsQueue ? "Hide Queue" : "Show Queue") { model.showsQueue.toggle() }
Button("Previous Queue Item") { model.advanceQueue(-1) }.disabled(!model.canPreviousQueueItem) Button("Previous Queue Item") { model.advanceQueue(-1) }.disabled(!model.canPreviousQueueItem)
Button("Next Queue Item") { model.advanceQueue(1) }.disabled(!model.canNextQueueItem) Button("Next Queue Item") { model.advanceQueue(1) }.disabled(!model.canNextQueueItem)
PlayerActionButton(model: model, action: .openYoutube, title: "Open YouTube URL…") PlayerActionButton(model: model, action: .openStream, title: "Open Stream…")
PlayerActionButton(model: model, action: .closeFile, title: "Close File") PlayerActionButton(model: model, action: .closeFile, title: "Close File")
Divider() Divider()
PlayerQualityMenu(model: model) PlayerQualityMenu(model: model)
@@ -2870,7 +2906,7 @@ private struct PlayerContent: View {
VStack(spacing: 12) { VStack(spacing: 12) {
ProgressView().controlSize(.large) ProgressView().controlSize(.large)
Text(model.castConnecting ? "Connecting to \(model.castDevice?.name ?? "Cast device")…" : Text(model.castConnecting ? "Connecting to \(model.castDevice?.name ?? "Cast device")…" :
(model.resolvingYouTube ? "Resolving YouTube…" : "Opening…")) (model.resolvingStream ? "Resolving stream…" : "Opening…"))
.font(.callout).foregroundStyle(Color(SunsetPalette.secondaryText)) .font(.callout).foregroundStyle(Color(SunsetPalette.secondaryText))
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -3125,7 +3161,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
@objc private func openInternetURL(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) { @objc private func openInternetURL(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
guard let text = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue else { return } guard let text = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue else { return }
PlayerModel.shared.openYouTube(text) PlayerModel.shared.openStream(text)
} }
@objc private func openDocuments(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) { @objc private func openDocuments(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
+19 -9
View File
@@ -7,21 +7,21 @@ enum PlayerCommand {
case launchSource(sourceFile: URL, newInstance: Bool, noConfig: Bool, progressFile: URL?) case launchSource(sourceFile: URL, newInstance: Bool, noConfig: Bool, progressFile: URL?)
static let usage = """ static let usage = """
Usage: terebi [--new-instance] [--no-config] [FILE | YOUTUBE_URL] Usage: terebi [--new-instance] [--no-config] [FILE | STREAM_URL]
terebi [--new-instance] [--no-config] --url YOUTUBE_URL terebi [--new-instance] [--no-config] --url STREAM_URL
terebi --new-instance --url YOUTUBE_URL --progress-file PATH terebi --new-instance --url STREAM_URL --progress-file PATH
terebi --new-instance --source-file PATH [--progress-file PATH] terebi --new-instance --source-file PATH [--progress-file PATH]
terebi [--new-instance] [--no-config] -- FILE terebi [--new-instance] [--no-config] -- FILE
terebi --help terebi --help
terebi --default-config terebi --default-config
Open one local file, YouTube video or resolved source and start playback, Open one local file, YouTube video, Miruro episode or resolved source and start playback,
resuming saved video progress. With no arguments, open or activate the player. resuming saved video progress. With no arguments, open or activate the player.
Quote paths and URLs containing spaces or shell metacharacters. Quote paths and URLs containing spaces or shell metacharacters.
--new-instance Launch a separate player, leaving existing playback untouched. --new-instance Launch a separate player, leaving existing playback untouched.
--no-config Use built-in settings instead of config.toml on a fresh launch. --no-config Use built-in settings instead of config.toml on a fresh launch.
--url URL Open a YouTube video URL. --url URL Open a YouTube video or Miruro episode URL.
--source-file PATH Open a validated resolved HTTP(S) source in a new player. --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 --progress-file PATH Write JSONL playback facts to a new private file; requires
--new-instance. The GUI owns reporting after this CLI exits. --new-instance. The GUI owns reporting after this CLI exits.
@@ -71,6 +71,16 @@ enum PlayerCommand {
} }
return .launch(url, newInstance: newInstance, noConfig: noConfig, progressFile: progressFile) 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.isEmpty { return try launch(nil) }
if inputs == ["--help"] || inputs == ["-h"] { if inputs == ["--help"] || inputs == ["-h"] {
guard progressFile == nil else { throw ArgumentError("--progress-file cannot be combined with --help.") } guard progressFile == nil else { throw ArgumentError("--progress-file cannot be combined with --help.") }
@@ -94,8 +104,8 @@ enum PlayerCommand {
noConfig: noConfig, progressFile: progressFile) noConfig: noConfig, progressFile: progressFile)
} }
if inputs.first == "--url" { if inputs.first == "--url" {
guard inputs.count == 2 else { throw ArgumentError("--url requires exactly one YouTube URL.") } guard inputs.count == 2 else { throw ArgumentError("--url requires exactly one stream URL.") }
return try launch(YouTubeResolver.validateURL(inputs[1])) return try launch(streamURL(inputs[1]))
} }
let filenameOnly = inputs.first == "--" let filenameOnly = inputs.first == "--"
if filenameOnly { if filenameOnly {
@@ -104,7 +114,7 @@ enum PlayerCommand {
throw ArgumentError("Unknown option. Use -- before filenames beginning with '-'.") throw ArgumentError("Unknown option. Use -- before filenames beginning with '-'.")
} }
guard inputs.count == 1, let input = inputs.first, !input.isEmpty else { guard inputs.count == 1, let input = inputs.first, !input.isEmpty else {
throw ArgumentError("Expected exactly one file or YouTube URL.") throw ArgumentError("Expected exactly one file or stream URL.")
} }
let file: URL let file: URL
if !filenameOnly && input.contains("://") { if !filenameOnly && input.contains("://") {
@@ -115,7 +125,7 @@ enum PlayerCommand {
} }
file = url file = url
} else { } else {
return try launch(YouTubeResolver.validateURL(input)) return try launch(streamURL(input))
} }
} else { } else {
let base = directory.hasDirectoryPath ? directory : URL(fileURLWithPath: directory.path, isDirectory: true) let base = directory.hasDirectoryPath ? directory : URL(fileURLWithPath: directory.path, isDirectory: true)
+1 -1
View File
@@ -83,7 +83,7 @@ public struct KeyBindings: Equatable, Sendable {
.enterFullscreen: ["f", "ctrl+cmd+f"], .exitFullscreen: ["f", "ctrl+cmd+f", "escape"], .enterFullscreen: ["f", "ctrl+cmd+f"], .exitFullscreen: ["f", "ctrl+cmd+f", "escape"],
.enterCompact: ["p", "ctrl+cmd+p"], .exitCompact: ["p", "ctrl+cmd+p"], .enterCompact: ["p", "ctrl+cmd+p"], .exitCompact: ["p", "ctrl+cmd+p"],
.enableAlwaysOnTop: ["shift+p"], .disableAlwaysOnTop: ["shift+p"], .enableAlwaysOnTop: ["shift+p"], .disableAlwaysOnTop: ["shift+p"],
.openFile: ["o", "cmd+o"], .openYoutube: ["cmd+shift+o"], .openFile: ["o", "cmd+o"], .openStream: ["cmd+shift+o"],
.closeFile: ["cmd+shift+w"], .quit: ["q", "cmd+q"] .closeFile: ["cmd+shift+w"], .quit: ["q", "cmd+q"]
] ]
return texts.mapValues { $0.map { try! KeyChord($0) } } return texts.mapValues { $0.map { try! KeyChord($0) } }
+2 -2
View File
@@ -4,7 +4,7 @@ public enum PlayerAction: Hashable, Sendable {
case stepFrameForward, stepFrameBackward case stepFrameForward, stepFrameBackward
case enterFullscreen, exitFullscreen, enterCompact, exitCompact case enterFullscreen, exitFullscreen, enterCompact, exitCompact
case enableAlwaysOnTop, disableAlwaysOnTop case enableAlwaysOnTop, disableAlwaysOnTop
case openFile, openYoutube, closeFile, quit case openFile, openStream, closeFile, quit
case showBufferInfo, hideBufferInfo case showBufferInfo, hideBufferInfo
case enableCompactSnapping, disableCompactSnapping case enableCompactSnapping, disableCompactSnapping
case enableAutomaticQuality, disableAutomaticQuality case enableAutomaticQuality, disableAutomaticQuality
@@ -19,7 +19,7 @@ public enum PlayerAction: Hashable, Sendable {
("enter_fullscreen", .enterFullscreen), ("exit_fullscreen", .exitFullscreen), ("enter_fullscreen", .enterFullscreen), ("exit_fullscreen", .exitFullscreen),
("enter_compact", .enterCompact), ("exit_compact", .exitCompact), ("enter_compact", .enterCompact), ("exit_compact", .exitCompact),
("enable_always_on_top", .enableAlwaysOnTop), ("disable_always_on_top", .disableAlwaysOnTop), ("enable_always_on_top", .enableAlwaysOnTop), ("disable_always_on_top", .disableAlwaysOnTop),
("open_file", .openFile), ("open_youtube", .openYoutube), ("close_file", .closeFile), ("quit", .quit), ("open_file", .openFile), ("open_stream", .openStream), ("close_file", .closeFile), ("quit", .quit),
("show_buffer_info", .showBufferInfo), ("hide_buffer_info", .hideBufferInfo), ("show_buffer_info", .showBufferInfo), ("hide_buffer_info", .hideBufferInfo),
("enable_compact_snapping", .enableCompactSnapping), ("disable_compact_snapping", .disableCompactSnapping), ("enable_compact_snapping", .enableCompactSnapping), ("disable_compact_snapping", .disableCompactSnapping),
("enable_automatic_quality", .enableAutomaticQuality), ("disable_automatic_quality", .disableAutomaticQuality), ("enable_automatic_quality", .enableAutomaticQuality), ("disable_automatic_quality", .disableAutomaticQuality),
+3 -3
View File
@@ -12,10 +12,10 @@
- synchronized 250 ms sidebar/window reveal, reversible mid-transition, with immediate Reduce Motion changes; top-right controls stay anchored to the moving window corner; native checks passed for rendered controls during transitions, stable video size, reversals, screen-edge fitting, fullscreen and compact restoration; animation feel and Reduce Motion still need manual verification - synchronized 250 ms sidebar/window reveal, reversible mid-transition, with immediate Reduce Motion changes; top-right controls stay anchored to the moving window corner; native checks passed for rendered controls during transitions, stable video size, reversals, screen-edge fitting, fullscreen and compact restoration; animation feel and Reduce Motion still need manual verification
- [x] make the window draggable (in all cases/view states) so long as a button/clickable element is not hovered. - [x] make the window draggable (in all cases/view states) so long as a button/clickable element is not hovered.
- scoped native background dragging across start/playback/queue/error/Cast/compact views, with passive loading/audio overlays passing input through; 20 native input-routing probes passed, including controls and row-reorder exclusions; physical pointer dragging and Cast-screen interaction still need manual verification - scoped native background dragging across start/playback/queue/error/Cast/compact views, with passive loading/audio overlays passing input through; 20 native input-routing probes passed, including controls and row-reorder exclusions; physical pointer dragging and Cast-screen interaction still need manual verification
- [ ] on the start screen: - [x] on the start screen:
- [x] make the youtube url input field that appears when clicking the youtube button appear with a nice transition - [x] make the youtube url input field that appears when clicking the youtube button appear with a nice transition
- 200 ms fade/scale with animated row reflow; the button and ⇧⌘O toggle the same inline input, including while editing; native focus, draft-preservation and rapid-reversal checks cover mouse/shortcut interoperation - 200 ms fade/scale with animated row reflow; the button and ⇧⌘O toggle the same inline input, including while editing; native focus, draft-preservation and rapid-reversal checks cover mouse/shortcut interoperation
- [ ] change the youtube button to general streaming - [x] change the youtube button to general streaming
- we can figure out the stream and how to handle it from the url (i.e. if youtube url, handle as youtube stream; if miruro url, handle accordingly) - Open Stream routes YouTube videos and explicit barelystarted.miruro.tv episode links; live Miruro video/audio and VTT seek checks passed, with shared validated source metadata and unchanged Tubular source/progress contracts; CLI handoff emitted v2 playback/end facts, but its GUI-state checker timed out without snapshots; physical UI/drop/Cast verification remains unverified
- [ ] check whether we're using appropriately new apple ui APIs / SDKs for the project. - [ ] check whether we're using appropriately new apple ui APIs / SDKs for the project.
- we should strive to use modern and idiomatic SDKs and APIs including SwiftUI among others - we should strive to use modern and idiomatic SDKs and APIs including SwiftUI among others
+5 -3
View File
@@ -4,14 +4,14 @@
Terebi now supports resolved Miruro HTTP(S) sources with per-source headers, external WebVTT and stable episode progress. Direct **`ssub` / `sun`** playback was exercised with a freshly resolved source: native video/audio advanced, default English subtitles loaded, and the model's cue text matched expected subtitles after forward and backward acknowledged seeks. The user also reported that visible playback appears to work; this is not a comprehensive visual synchronization sign-off. Terebi now supports resolved Miruro HTTP(S) sources with per-source headers, external WebVTT and stable episode progress. Direct **`ssub` / `sun`** playback was exercised with a freshly resolved source: native video/audio advanced, default English subtitles loaded, and the model's cue text matched expected subtitles after forward and backward acknowledged seeks. The user also reported that visible playback appears to work; this is not a comprehensive visual synchronization sign-off.
Tubular integration is still required. Terebi's source/progress contracts below are implemented, not merely proposed; Tubular has not yet adopted them in this work. Tubular's existing source-file/progress-file handoff remains supported unchanged. Terebi also accepts explicit Miruro episode page URLs through Open Stream, drop, CLI and URL events; that path resolves into the same validated source representation without re-resolving externally supplied source files.
## Ownership ## Ownership
- **Tubular:** discovery, episode/provider/language selection, fresh source resolution, source-document creation, session cleanup and watched-state decisions. - **Tubular:** discovery, episode/provider/language selection, fresh source resolution, source-document creation, session cleanup and watched-state decisions.
- **Terebi:** native playback, Cast discovery/control and LAN delivery, request-header propagation, subtitle loading/selection/rendering, stable-identity bookmarks and acknowledged progress reporting. - **Terebi:** native playback, Cast discovery/control and LAN delivery, request-header propagation, subtitle loading/selection/rendering, stable-identity bookmarks and acknowledged progress reporting.
No Miruro scraping, `strm.cx` embedding, cookies, impersonation or protection-bypass machinery was added to Terebi. Native episode URL resolution uses Miruro's source endpoint and the same direct ssub/Sun selection as Tubular. No catalog browsing, `strm.cx` embedding, cookies, impersonation or protection-bypass machinery is used.
## Launch contract ## Launch contract
@@ -101,7 +101,7 @@ YouTube records remain **version 1** with canonical `media_url`. Miruro records
Reporting includes ready state after restoration, approximately five-second playback intervals, acknowledged pause/seek transitions, the first eligible 95% crossing, native end and final close/quit snapshots. Launch success and window closure do not establish completion. Consumers must tolerate duplicates, partial trailing lines and abrupt termination; a `closed` record is not guaranteed. Reporting includes ready state after restoration, approximately five-second playback intervals, acknowledged pause/seek transitions, the first eligible 95% crossing, native end and final close/quit snapshots. Launch success and window closure do not establish completion. Consumers must tolerate duplicates, partial trailing lines and abrupt termination; a `closed` record is not guaranteed.
**Required in Tubular `src/video_state.rs`:** retain canonical YouTube/version-1 validation, add version-2 validation against the episode identity expected for that launched session, and key Miruro watched state by `(miruro, opaque media ID, episode)`. Never use stream URL, token, slug or selected playback provider as identity. Tubular integration must retain canonical YouTube/version-1 validation, accept version-2 validation against the episode identity expected for that launched session, and key Miruro watched state by `(miruro, opaque media ID, episode)`. Never use stream URL, token, slug or selected playback provider as identity.
Retain the current completion decision for matching identities: Retain the current completion decision for matching identities:
@@ -129,6 +129,8 @@ Handle `null` duration as ineligible. This measures position, not viewing covera
Earlier startup runs failed held-Space and YouTube end-transition assertions. The user reported possible manual interaction. The full rerun passed without playback-code changes; causality of the earlier failures was not established. No full aggregate `make check` pass is claimed. Earlier startup runs failed held-Space and YouTube end-transition assertions. The user reported possible manual interaction. The full rerun passed without playback-code changes; causality of the earlier failures was not established. No full aggregate `make check` pass is claimed.
Streaming-input verification (2026-10-19): native URLSession resolved a live Miruro episode with nine VTT tracks; native URL-to-playback exercised video/audio, pause, default captions and forward/backward acknowledged subtitle seeks. Offline stream parsing/projection/security, CLI/config, resolved playback and YouTube resolver/adaptive checks passed. The actual source-file CLI launch emitted version-2 progress and ended records, but the startup check timed out waiting for GUI state snapshots; this run does not establish the complete two-window handoff scenario. The live probe's original synchronous wait blocked async resolution; yielding the actor fixed the probe without playback-code changes.
### Reproduction commands from Terebi ### Reproduction commands from Terebi
```sh ```sh